Compare commits

...

7 Commits

Author SHA1 Message Date
sudacode d5bfdcae7b fix(stats): repair legacy combined-season anime rows on startup (#116) 2026-06-09 12:41:07 -07:00
sudacode 311f1e8ee5 feat(stats): speed up session maintenance and improve stats UI (#111) 2026-06-08 02:20:52 -07:00
sudacode e6a16a069b fix(anilist): mark entry completed when final episode is reached (#115) 2026-06-07 23:45:09 -07:00
Autumn (Bee) af67c53dd6 [codex] Restart Jellyfin remote session after setup login (#112) 2026-06-06 11:52:16 -07:00
sudacode ea79e331fa build: make deps initializes submodules before installing JS deps
- Add `submodules` target that runs `git submodule update --init --recursive`
- `deps` now depends on `submodules` so fresh checkouts work out of the box
- Update docs to replace manual install steps with `make deps`
- Fix Windows build-from-source steps to include stats and submodule init
2026-06-06 01:25:01 -07:00
sudacode ee89b0c8a9 feat(release): add contributor attribution to release notes (#114) 2026-06-06 01:07:47 -07:00
sudacode f2fd58cd2b docs(changelog): require reconciled fragments, not just new ones (#113) 2026-06-06 00:55:34 -07:00
129 changed files with 8903 additions and 765 deletions
+1 -1
View File
@@ -31,6 +31,6 @@ If docs-site/ changed, also: bun run docs:test && bun run docs:build
## Checklist ## Checklist
- [ ] Added a changelog fragment, or this PR is labeled `skip-changelog` (see [`changes/README.md`](../changes/README.md)) - [ ] Reconciled current-outcome changelog fragment(s), or this PR is labeled `skip-changelog` (see [`changes/README.md`](../changes/README.md))
- [ ] Docs updated in the same PR if behavior, defaults, flags, shortcuts, ports, or APIs changed - [ ] Docs updated in the same PR if behavior, defaults, flags, shortcuts, ports, or APIs changed
- [ ] Relevant checks pass locally (typecheck, tests, build) - [ ] Relevant checks pass locally (typecheck, tests, build)
+1 -1
View File
@@ -68,7 +68,7 @@ Start here, then leave this file.
## Release / PR Notes ## Release / PR Notes
- User-visible PRs need one fragment in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; apply the `skip-changelog` label to opt out) - User-visible PRs need reconciled current-outcome fragment(s) in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; inspect existing same-PR fragments, then update/remove stale bullets or add only genuinely separate outcomes; apply the `skip-changelog` label to opt out)
- User-visible docs changes get a `type: docs` fragment - User-visible docs changes get a `type: docs` fragment
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check` - CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
- PR review helpers: - PR review helpers:
+7 -4
View File
@@ -1,4 +1,4 @@
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev .PHONY: help submodules deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
APP_NAME := subminer APP_NAME := subminer
THEME_SOURCE := assets/themes/subminer.rasi THEME_SOURCE := assets/themes/subminer.rasi
@@ -72,7 +72,8 @@ help:
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \ " generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
"" \ "" \
"Other targets:" \ "Other targets:" \
" deps Install JS dependencies (root + stats + texthooker-ui)" \ " submodules Initialize/update git submodules" \
" deps Initialize submodules and install JS dependencies (root + stats + texthooker-ui)" \
" uninstall-linux Remove Linux install artifacts" \ " uninstall-linux Remove Linux install artifacts" \
" uninstall-macos Remove macOS install artifacts" \ " uninstall-macos Remove macOS install artifacts" \
" uninstall-windows Remove Windows mpv plugin artifacts" \ " uninstall-windows Remove Windows mpv plugin artifacts" \
@@ -105,8 +106,10 @@ print-dirs:
"MACOS_APP_SRC=$(MACOS_APP_SRC)" \ "MACOS_APP_SRC=$(MACOS_APP_SRC)" \
"MACOS_ZIP_SRC=$(MACOS_ZIP_SRC)" "MACOS_ZIP_SRC=$(MACOS_ZIP_SRC)"
deps: submodules:
@$(MAKE) --no-print-directory ensure-bun @git submodule update --init --recursive
deps: submodules ensure-bun
@bun install @bun install
@cd stats && bun install --frozen-lockfile @cd stats && bun install --frozen-lockfile
@cd vendor/texthooker-ui && bun install --frozen-lockfile @cd vendor/texthooker-ui && bun install --frozen-lockfile
@@ -0,0 +1,4 @@
type: added
area: release
- Release notes now credit contributors with a `What's Changed` list (`by @author in #pr`) and a `New Contributors` section for first-time authors, resolved from changelog fragments via git and the GitHub API.
+7
View File
@@ -31,6 +31,13 @@ Rules:
- `README.md` is ignored by the generator - `README.md` is ignored by the generator
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment - if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
PR branch workflow:
- Before adding a fragment or bullet, inspect the `changes/*.md` files already changed in the PR
- If the new work fixes, modifies, renames, or supersedes behavior introduced or referenced by that fragment, edit or remove the stale bullet instead of adding follow-up churn
- Add a new bullet only when it describes a truly separate user-visible outcome
- Multiple fragment files are allowed when one PR has genuinely separate release-note outcomes, but keep them minimized and current
How fragments turn into a release: How fragments turn into a release:
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that. - At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
@@ -0,0 +1,4 @@
type: fixed
area: anilist
- Marked AniList entries completed when a post-watch update reaches the final known episode of the season.
@@ -0,0 +1,4 @@
type: changed
area: release
- Changed PR changelog guidance to preserve multiple fragments for genuinely separate outcomes while directing contributors to update, remove, or merge same-PR fragment notes before adding follow-up churn.
@@ -0,0 +1,5 @@
type: fixed
area: jellyfin
- Restarted the Jellyfin remote session after successful setup login so websocket reconnects use the freshly saved credentials.
- Stopped the Jellyfin remote session on setup logout.
+6
View File
@@ -0,0 +1,6 @@
type: changed
area: stats
- Split local and Jellyfin library entries by detected season, using season folders first and filename parsing as fallback.
- Repaired older combined-series stats rows by moving parsed episodes into season-specific library entries, rebuilding summaries, and deleting now-empty legacy rows.
- Refresh anime detail and library cover art immediately after manually changing an AniList entry.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: build
- Updated `make deps` so a fresh source checkout initializes submodules before installing root, stats, and texthooker-ui dependencies.
+9
View File
@@ -0,0 +1,9 @@
type: changed
area: stats
- Added the Stats Search tab for realtime subtitle sentence search with media context, headword matching, and mining actions for source-backed sentence cards or exact-match word/audio cards.
- Improved Stats mining from Search and vocabulary examples: empty `ankiConnect.deck` can use Yomitan's mining deck, sentence cards are created before slow media generation finishes, stored/requested secondary subtitles are preserved before falling back to sidecar files or temporary alass-retimed English sidecars for sentence Selection Text, invalid stored timings are blocked before FFmpeg runs, future out-of-order subtitle timing pairs are skipped until valid timings arrive, and partial media failures are shown.
- Fixed Stats mining field/audio behavior so sentence clips update `SentenceAudio`, word audio uses the configured Yomitan sources, English subtitle text is not written onto word cards, and secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
- Improved vocabulary review with remembered Hide Known/Hide Kana filters, cross-title Hide Kana filtering, duplicate-collapsed exclusions across token variants, and Related Seen Words matching based on shared readings or kanji.
- Reorganized the Stats Trends tab into clearer Activity, Cumulative Totals, Efficiency, Patterns, and Library sections, disambiguated per-period vs cumulative charts, and added Words/Min and Cards/Hour efficiency charts.
- Improved Stats browsing reliability by remembering library card size, retrying stored cover art without extra AniList lookups, preserving PNG/WebP cover MIME types, honoring custom AnkiConnect URLs for Browse, showing progress during session deletes, and making session deletes refresh faster.
+1 -1
View File
@@ -496,7 +496,7 @@
"tags": [ "tags": [
"SubMiner" "SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. "deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
"fields": { "fields": {
"word": "Expression", // Card field for the mined word or expression text. "word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio. "audio": "ExpressionAudio", // Card field that receives generated sentence audio.
+1 -1
View File
@@ -38,7 +38,7 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
The update flow: The update flow:
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename. It tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable. 1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count. 2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped. 3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`. 4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
+3 -2
View File
@@ -4,6 +4,7 @@ SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-
This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior. This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
::: tip New to these terms? ::: tip New to these terms?
- **Anki** is the flashcard app where your study cards live. - **Anki** is the flashcard app where your study cards live.
- **AnkiConnect** is a free add-on that lets other programs (like SubMiner) talk to Anki over a local connection. SubMiner needs it installed to add or edit cards. - **AnkiConnect** is a free add-on that lets other programs (like SubMiner) talk to Anki over a local connection. SubMiner needs it installed to add or edit cards.
- A **note type** (also called a "model") is the template that defines what a card looks like - for example the Kiku or Lapis templates many Japanese learners use. - A **note type** (also called a "model") is the template that defines what a card looks like - for example the Kiku or Lapis templates many Japanese learners use.
@@ -22,7 +23,7 @@ AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the po
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, image, and translation fields automatically. Two detection methods are available: When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, image, and translation fields automatically. Two detection methods are available:
**Proxy mode** (default) - SubMiner runs a local *proxy*: a small middleman server that sits between Yomitan and Anki. Yomitan sends new cards to SubMiner, SubMiner enriches them, then passes them along to Anki. This makes enrichment instant. **Proxy mode** (default) - SubMiner runs a local _proxy_: a small middleman server that sits between Yomitan and Anki. Yomitan sends new cards to SubMiner, SubMiner enriches them, then passes them along to Anki. This makes enrichment instant.
**Polling mode** (fallback, when the proxy is disabled) - SubMiner asks AnkiConnect every few seconds whether any new cards were added, then enriches them. Simpler setup, but with a short delay (~3 seconds). **Polling mode** (fallback, when the proxy is disabled) - SubMiner asks AnkiConnect every few seconds whether any new cards were added, then enriches them. Simpler setup, but with a short delay (~3 seconds).
@@ -36,7 +37,7 @@ In both modes, the enrichment workflow is the same:
4. Fills the translation field from the secondary subtitle or AI. 4. Fills the translation field from the secondary subtitle or AI.
5. Writes metadata to the miscInfo field. 5. Writes metadata to the miscInfo field.
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks. In Settings, the AnkiConnect deck dropdown auto-fills from Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect. Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it uses Yomitan's current mining deck when available; otherwise it searches all decks. In Settings, the AnkiConnect deck dropdown auto-fills and persists Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect.
Known-word sync scope is controlled by `ankiConnect.knownWords.decks`. Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
### Proxy Mode Setup (Yomitan / Texthooker) ### Proxy Mode Setup (Yomitan / Texthooker)
+9 -7
View File
@@ -52,7 +52,7 @@ The Settings window groups options by workflow instead of mirroring the raw conf
- Tracking & App - Tracking & App
- Advanced - Advanced
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names. The AnkiConnect deck field also reads Yomitan's current mining deck and auto-fills an empty setting when one is found. Keybinding fields use click-to-learn controls instead of raw text boxes. Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names. The AnkiConnect deck field also reads Yomitan's current mining deck and persists it into an empty setting when one is found. Stats mining also uses Yomitan's current mining deck when `ankiConnect.deck` is empty. Keybinding fields use click-to-learn controls instead of raw text boxes.
The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies. The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies.
@@ -517,8 +517,8 @@ See `config.example.jsonc` for detailed configuration options.
``` ```
| Option | Values | Description | | Option | Values | Description |
| ----------------------- | ---------------------------------- | ------------------------------------------------------ | | ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`) | | `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match |
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track | | `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) | | `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
@@ -944,7 +944,7 @@ This example is intentionally compact. The option table below documents availabl
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation. **Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
| Option | Values | Description | | Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | | `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | | `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | | `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
@@ -953,7 +953,7 @@ This example is intentionally compact. The option table below documents availabl
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) | | `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) | | `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | | `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. In Settings, this dropdown auto-fills from Yomitan's current mining deck when available. | | `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | | `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | | `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) | | `fields.image` | string | Card field for images (default: `Picture`) |
@@ -1122,6 +1122,8 @@ Sync the active subtitle track from the overlay picker using `alass` or `ffsubsy
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. | | `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. | | `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
Stats dashboard sentence mining also uses `alass_path` when available to align a local English sidecar against the local Japanese sidecar before filling the card translation field. This stats-only retime writes a temporary cached copy and never edits the original subtitle files.
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`. Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
Customize it there, or set it to `null` to disable. Customize it there, or set it to `null` to disable.
@@ -1433,7 +1435,7 @@ Usage notes:
- The browser UI is served at `http://127.0.0.1:<serverPort>`. - The browser UI is served at `http://127.0.0.1:<serverPort>`.
- The overlay toggle is local to the focused visible overlay window; it is not registered as a global OS shortcut. - The overlay toggle is local to the focused visible overlay window; it is not registered as a global OS shortcut.
- The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear. - The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear.
- The UI includes Overview, Library, Trends, Vocabulary, and Sessions tabs. - The UI includes Overview, Library, Trends, Vocabulary, Search, and Sessions tabs.
### MPV Launcher ### MPV Launcher
@@ -1457,7 +1459,7 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
``` ```
| Option | Values | Description | | Option | Values | Description |
| ----------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) | | `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) | | `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) | | `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
+3 -8
View File
@@ -11,15 +11,10 @@ For internal architecture/workflow guidance, use `docs/README.md` at the repo ro
```bash ```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
cd SubMiner cd SubMiner
# if you cloned without --recurse-submodules: make deps
git submodule update --init --recursive
bun install
(cd stats && bun install --frozen-lockfile)
(cd vendor/texthooker-ui && bun install --frozen-lockfile)
``` ```
`make deps` is still available as a convenience wrapper around the same dependency install flow. `make deps` initializes submodules and installs root, `stats/`, and `vendor/texthooker-ui` dependencies. The Yomitan submodule installs its own dependencies on demand during `bun run build`.
## Building ## Building
@@ -216,7 +211,7 @@ Run `make help` for a full list of targets. Key ones:
| `make build` | Build platform package for detected OS | | `make build` | Build platform package for detected OS |
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` | | `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) | | `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) | | `make deps` | Init submodules and install root/stats/texthooker-ui deps |
| `make pretty` | Run scoped Prettier formatting for maintained source/config files | | `make pretty` | Run scoped Prettier formatting for maintained source/config files |
| `make generate-config` | Generate default config from centralized registry | | `make generate-config` | Generate default config from centralized registry |
| `make build-linux` | Convenience wrapper for Linux packaging | | `make build-linux` | Convenience wrapper for Linux packaging |
+21 -11
View File
@@ -18,8 +18,8 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_
{ {
"immersionTracking": { "immersionTracking": {
"enabled": true, "enabled": true,
"dbPath": "" "dbPath": "",
} },
} }
``` ```
@@ -48,13 +48,19 @@ Recent sessions, streak calendar, watch-time history, and a tracking snapshot wi
Cover-art library with search and sorting, per-series progress, episode drill-down, and direct links into mined cards. Cover-art library with search and sorting, per-series progress, episode drill-down, and direct links into mined cards.
Local files and Jellyfin items with detected season numbers are split into season-specific library entries, so `Season 1` and `Season 2` folders do not merge into one show card.
When older stats already grouped multiple seasons under one series entry, SubMiner moves parsed episodes into the season-specific entries on startup and rebuilds the affected summaries.
Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard.
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section. When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
![Stats Library](/screenshots/stats-library.png) ![Stats Library](/screenshots/stats-library.png)
#### Trends #### Trends
Watch time, sessions, words seen, and per-anime progress/pattern charts with configurable date ranges and grouping. Grouped into Activity (per-day/month watch time, cards, words, sessions), Cumulative Totals (running totals incl. new words seen and episodes), Efficiency (words/min, cards/hour, lookups per 100 words), Patterns (watch time by day of week and hour), and per-anime Library charts — all with configurable date ranges and grouping.
![Stats Trends](/screenshots/stats-trends.png) ![Stats Trends](/screenshots/stats-trends.png)
@@ -66,10 +72,14 @@ Expandable session history with new-word activity, cumulative totals, and pause/
#### Vocabulary #### Vocabulary
Top repeated words (click a bar to open the word), new-word timeline, frequency rank table with full readings, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons. Top repeated words (click a bar to open the word), new-word timeline, cross-title and frequency rank tables with Hide Known / Hide Kana filters, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons.
![Stats Vocabulary](/screenshots/stats-vocabulary.png) ![Stats Vocabulary](/screenshots/stats-vocabulary.png)
#### Search
Realtime search across tracked primary subtitle lines and media titles. Results show the source media, session, line number, timing, and sentence text. Secondary subtitle text is not shown or searched here because separate subtitle tracks may not line up sentence-for-sentence. Sentence cards can be mined from any result with a valid local source and timing. Word and audio card buttons appear only when the searched word exactly appears in the primary sentence text; matching text is highlighted in the result.
Stats server config lives under `stats`: Stats server config lives under `stats`:
```jsonc ```jsonc
@@ -78,8 +88,8 @@ Stats server config lives under `stats`:
"toggleKey": "Backquote", "toggleKey": "Backquote",
"serverPort": 6969, "serverPort": 6969,
"autoStartServer": true, "autoStartServer": true,
"autoOpenBrowser": false "autoOpenBrowser": false,
} },
} }
``` ```
@@ -96,15 +106,15 @@ Stats server config lives under `stats`:
## Mining Cards from the Stats Page ## Mining Cards from the Stats Page
The Vocabulary tab's word detail panel shows example lines from your viewing history. Each example line with a valid source file offers three mining buttons: The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence:
- **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded. - **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded.
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio, image, and translation from the secondary subtitle if available. - **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video.
- **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip. - **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip.
All three modes respect your `ankiConnect` config: deck, model, field mappings, media settings (static vs AVIF, quality, dimensions), audio padding, metadata pattern, and tags. Media generation runs in parallel for faster card creation. All three modes respect your `ankiConnect` config: deck, model, field mappings, media settings (static vs AVIF, quality, dimensions), audio padding, metadata pattern, and tags. Media generation runs in parallel for faster card creation.
Secondary subtitle text (typically English translations) is stored alongside primary subtitles during playback and used as the translation field when mining from the stats page. Secondary subtitle text (typically English translations) is stored alongside primary subtitles during playback and can be used as the translation field when mining sentence cards from Search or vocabulary occurrences. The Search tab does not use that text for display or matching.
### Word Exclusion List ### Word Exclusion List
@@ -115,7 +125,7 @@ The Vocabulary tab toolbar includes an **Exclusions** button for hiding words fr
By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance: By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance:
| Data type | Retention | | Data type | Retention |
| -------------- | --------- | | --------------- | ------------ |
| Raw events | 0 (keep all) | | Raw events | 0 (keep all) |
| Telemetry | 0 (keep all) | | Telemetry | 0 (keep all) |
| Sessions | 0 (keep all) | | Sessions | 0 (keep all) |
@@ -147,7 +157,7 @@ The tracker is optimized for "keep everything" defaults:
All policy options live under `immersionTracking` in your config: All policy options live under `immersionTracking` in your config:
| Option | Description | | Option | Description |
| ------ | ----------- | | ------------------------------ | ------------------------------------------------------------------ |
| `batchSize` | Writes per flush batch | | `batchSize` | Writes per flush batch |
| `flushIntervalMs` | Max delay between flushes (default: 500ms) | | `flushIntervalMs` | Max delay between flushes (default: 500ms) |
| `queueCap` | Max queued writes before oldest are dropped | | `queueCap` | Max queued writes before oldest are dropped |
+6 -6
View File
@@ -185,7 +185,7 @@ Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config
```bash ```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
cd SubMiner cd SubMiner
bun install make deps
bun run build bun run build
# Optional: build AppImage # Optional: build AppImage
@@ -202,7 +202,7 @@ Bundled Yomitan is built during `bun run build`.
```bash ```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
cd SubMiner cd SubMiner
git submodule update --init --recursive make deps
make build-macos make build-macos
``` ```
@@ -216,14 +216,14 @@ The built app will be in the `release` directory (`.dmg` and `.zip`). For unsign
```powershell ```powershell
git clone https://github.com/ksyasuda/SubMiner.git git clone https://github.com/ksyasuda/SubMiner.git
cd SubMiner cd SubMiner
git submodule update --init --recursive
bun install bun install
Set-Location stats
# Windows requires building texthooker-ui manually before the main build bun install --frozen-lockfile
Set-Location vendor/texthooker-ui Set-Location ../vendor/texthooker-ui
bun install --frozen-lockfile bun install --frozen-lockfile
bun run build bun run build
Set-Location ../.. Set-Location ../..
bun run build:win bun run build:win
``` ```
+4 -2
View File
@@ -4,7 +4,7 @@ This guide walks through the sentence mining loop - from watching a video to cre
## Overview ## Overview
*Sentence mining* means turning real sentences you encounter while watching native video into Anki flashcards, so you learn vocabulary in the context where you actually met it. SubMiner automates the tedious parts of that loop. _Sentence mining_ means turning real sentences you encounter while watching native video into Anki flashcards, so you learn vocabulary in the context where you actually met it. SubMiner automates the tedious parts of that loop.
SubMiner runs as a transparent overlay on top of mpv (the video player). As subtitles play, the overlay displays them as interactive text. You hover a word, trigger a Yomitan dictionary lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, an audio clip, and a screenshot to that card - no manual copy-pasting or screen capturing. SubMiner runs as a transparent overlay on top of mpv (the video player). As subtitles play, the overlay displays them as interactive text. You hover a word, trigger a Yomitan dictionary lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, an audio clip, and a screenshot to that card - no manual copy-pasting or screen capturing.
@@ -123,7 +123,7 @@ By default the **primary** bar is `visible` (`subtitleStyle.primaryDefaultMode`)
Cycle each bar's mode at runtime with its own shortcut: Cycle each bar's mode at runtime with its own shortcut:
| Shortcut | Action | Config key | | Shortcut | Action | Config key |
| -------------------- | -------------------------------------------------------- | ------------------------------ | | ------------------ | -------------------------------------------------------- | ------------------------------ |
| `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local | | `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | | `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
@@ -166,6 +166,8 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
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. 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.
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.
Install the sync tools separately - see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found. Install the sync tools separately - see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
## Texthooker ## Texthooker
+1 -1
View File
@@ -496,7 +496,7 @@
"tags": [ "tags": [
"SubMiner" "SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. "deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
"fields": { "fields": {
"word": "Expression", // Card field for the mined word or expression text. "word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio. "audio": "ExpressionAudio", // Card field that receives generated sentence audio.
+1 -1
View File
@@ -287,7 +287,7 @@ Notes:
- For YouTube URLs, `subminer` probes available YouTube subtitle tracks, reuses existing authoritative tracks when available, and downloads only missing sides. - For YouTube URLs, `subminer` probes available YouTube subtitle tracks, reuses existing authoritative tracks when available, and downloads only missing sides.
- Native mpv secondary subtitle rendering stays hidden so the overlay remains the visible secondary subtitle surface. - Native mpv secondary subtitle rendering stays hidden so the overlay remains the visible secondary subtitle surface.
- Primary subtitle target languages come from `youtube.primarySubLanguages` (defaults to `["ja","jpn"]`). - Primary subtitle target languages come from `youtube.primarySubLanguages` (defaults to `["ja","jpn"]`).
- Secondary target languages come from `secondarySub.secondarySubLanguages` (empty by default; when empty, no language-based secondary track is auto-selected, though mpv's `--slang` list above still prefers English variants). - Secondary target languages come from `secondarySub.secondarySubLanguages` (empty by default; when empty, no language-based secondary track is auto-selected, though mpv's `--slang` list above still prefers English variants). When multiple matching secondary tracks exist, SubMiner prefers a non-Signs/Songs track.
- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtube` and `secondarySub`. - Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtube` and `secondarySub`.
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources. For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
+1
View File
@@ -77,6 +77,7 @@ Notes:
- `changelog:check` now rejects tag/package version mismatches. - `changelog:check` now rejects tag/package version mismatches.
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over. - `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely. - `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- `release/release-notes.md` (and `release/prerelease-notes.md`) end with GitHub-style attribution: a `## Whats Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag. - The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
- Do not tag while `changes/*.md` fragments still exist. - Do not tag while `changes/*.md` fragments still exist.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts. - Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
-30
View File
@@ -1,30 +0,0 @@
## Highlights
### Changed
- **Yomitan**: Updated the bundled Yomitan to the latest revision.
- Picks up the newest lookup improvements and fixes from the SubMiner Yomitan fork.
### Fixed
- **Anki / Animated AVIF**: Clips with word audio no longer start or end early.
- Clip boundaries are now snapped to the nearest AVIF frame edge, keeping audio lead-in and playback in sync.
- **macOS Overlay**: Resolved several interactivity and focus issues triggered by autoplay and modal windows.
- After autoplay starts with "wait for overlay to be ready" enabled, subtitles are immediately hoverable and Yomitan lookups work - no longer require an extra click to activate.
- After any modal closes (Settings, Stats, sidebar, etc.), the overlay and subtitles reappear automatically and mpv keyboard shortcuts (pause, seek, etc.) are restored to mpv right away, including in native fullscreen.
- **Hyprland Fullscreen Overlay**: Fixed overlay alignment when mpv is fullscreen on Hyprland.
- Compositor client bounds are now verified before positioning, so the stats panel, modals, and subtitle sidebar no longer shift below the mpv window.
## 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`.
+102 -2
View File
@@ -488,7 +488,7 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
changedEntries: [{ path: 'src/main-entry.ts', status: 'M' }], changedEntries: [{ path: 'src/main-entry.ts', status: 'M' }],
changedLabels: [], changedLabels: [],
}), }),
/requires a changelog fragment/, /requires a reconciled changelog fragment/,
); );
assert.doesNotThrow(() => assert.doesNotThrow(() =>
@@ -514,7 +514,7 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
], ],
changedLabels: [], changedLabels: [],
}), }),
/requires a changelog fragment/, /requires a reconciled changelog fragment/,
); );
assert.doesNotThrow(() => assert.doesNotThrow(() =>
@@ -526,6 +526,27 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
changedLabels: [], changedLabels: [],
}), }),
); );
assert.doesNotThrow(() =>
verifyPullRequestChangelog({
changedEntries: [
{ path: 'src/main-entry.ts', status: 'M' },
{ path: 'changes/001.md', status: 'M' },
],
changedLabels: [],
}),
);
assert.doesNotThrow(() =>
verifyPullRequestChangelog({
changedEntries: [
{ path: 'src/main-entry.ts', status: 'M' },
{ path: 'changes/001.md', status: 'A' },
{ path: 'changes/002.md', status: 'A' },
],
changedLabels: [],
}),
);
}); });
test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => { test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => {
@@ -1044,6 +1065,85 @@ test('writeChangelogArtifacts filters internal fragments from the release-notes
} }
}); });
test('writeChangelogArtifacts appends contributor attribution and a new-contributors section to release notes', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('release-notes-contributors');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Added a feature.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '002.md'),
['type: fixed', 'area: jellyfin', '', '- Fixed a bug.'].join('\n'),
'utf8',
);
try {
const stub = defaultStubClaude();
const resolveContributionsCalls: string[][] = [];
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.6.0',
date: '2026-05-06',
deps: {
runClaude: stub.runClaude,
resolveContributions: (fragmentPaths) => {
resolveContributionsCalls.push(fragmentPaths);
return [
{
prNumber: 110,
login: 'ksyasuda',
title: 'feat(overlay): add a feature',
isFirstContribution: false,
},
{
prNumber: 112,
login: 'bee-san',
title: 'fix(jellyfin): restart remote session',
isFirstContribution: true,
},
];
},
},
});
assert.equal(resolveContributionsCalls.length, 1, 'resolves contributions once per release');
assert.deepEqual(resolveContributionsCalls[0], [
path.join(projectRoot, 'changes', '001.md'),
path.join(projectRoot, 'changes', '002.md'),
]);
const releaseNotes = fs.readFileSync(
path.join(projectRoot, 'release', 'release-notes.md'),
'utf8',
);
assert.match(releaseNotes, /## Whats Changed\n\n/);
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
assert.match(
releaseNotes,
/## New Contributors\n\n- @bee-san made their first contribution in #112/,
);
assert.doesNotMatch(
releaseNotes,
/ksyasuda made their first contribution/,
'returning contributors are not listed under New Contributors',
);
// Attribution is a release-notes concern only; the CHANGELOG stays clean.
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.doesNotMatch(changelog, /Whats Changed/);
assert.doesNotMatch(changelog, /New Contributors/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => { test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
const { writeChangelogArtifacts } = await loadModule(); const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('reuse-existing-section'); const workspace = createWorkspace('reuse-existing-section');
+174 -3
View File
@@ -4,6 +4,20 @@ import { execFileSync } from 'node:child_process';
type RunClaude = (input: string, args: string[]) => string; type RunClaude = (input: string, args: string[]) => string;
// A single PR's contribution, resolved from the fragment files released in this
// cycle. Used to append GitHub-style attribution to the release notes.
type Contribution = {
prNumber: number;
login: string;
title: string;
isFirstContribution: boolean;
};
// Resolves the contributions behind a set of changelog fragment paths. Injected
// in tests so we never hit git/gh; the default implementation walks git history
// and the GitHub API.
type ResolveContributions = (fragmentPaths: string[], cwd: string) => Contribution[];
type ChangelogFsDeps = { type ChangelogFsDeps = {
existsSync?: (candidate: string) => boolean; existsSync?: (candidate: string) => boolean;
mkdirSync?: (candidate: string, options: { recursive: true }) => void; mkdirSync?: (candidate: string, options: { recursive: true }) => void;
@@ -13,6 +27,7 @@ type ChangelogFsDeps = {
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void; writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
log?: (message: string) => void; log?: (message: string) => void;
runClaude?: RunClaude; runClaude?: RunClaude;
resolveContributions?: ResolveContributions;
}; };
type PolishMode = 'changelog' | 'release-notes'; type PolishMode = 'changelog' | 'release-notes';
@@ -296,6 +311,152 @@ function defaultRunClaude(input: string, args: string[]): string {
} }
} }
function resolveFragmentRelativePath(fragmentPath: string, cwd: string): string {
return path.relative(cwd, fragmentPath).split(path.sep).join('/');
}
// Walks git history + the GitHub API to attribute each released fragment to the
// PR (and author) that introduced it. One git call and one gh call per fragment,
// plus one gh call per unique author for the first-contribution check. Best
// effort: if gh is unavailable/unauthenticated or any lookup fails, we warn and
// drop attribution rather than failing the release.
function defaultResolveContributions(fragmentPaths: string[], cwd: string): Contribution[] {
if (fragmentPaths.length === 0) {
return [];
}
try {
const slug = execFileSync(
'gh',
['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'],
{
cwd,
encoding: 'utf8',
},
).trim();
if (!slug) {
return [];
}
const byPr = new Map<number, Contribution>();
for (const fragmentPath of fragmentPaths) {
const relativePath = resolveFragmentRelativePath(fragmentPath, cwd);
// git log lists newest first, so the commit that *added* the file is the
// last line of the --diff-filter=A history.
const addingSha = execFileSync(
'git',
['log', '--diff-filter=A', '--follow', '--format=%H', '--', relativePath],
{ cwd, encoding: 'utf8' },
)
.trim()
.split(/\r?\n/)
.filter(Boolean)
.pop();
if (!addingSha) {
continue;
}
const prRaw = execFileSync(
'gh',
[
'api',
`repos/${slug}/commits/${addingSha}/pulls`,
'--jq',
'.[0] // empty | {number, login: .user.login, title}',
],
{ cwd, encoding: 'utf8' },
).trim();
if (!prRaw) {
continue;
}
const pr = JSON.parse(prRaw) as { number?: number; login?: string; title?: string };
if (typeof pr.number !== 'number' || !pr.login || !pr.title) {
continue;
}
if (!byPr.has(pr.number)) {
byPr.set(pr.number, {
prNumber: pr.number,
login: pr.login,
title: pr.title,
isFirstContribution: false,
});
}
}
const firstPrByAuthor = new Map<string, number | null>();
for (const contribution of byPr.values()) {
if (!firstPrByAuthor.has(contribution.login)) {
const firstRaw = execFileSync(
'gh',
[
'api',
'-X',
'GET',
'search/issues',
'-f',
`q=repo:${slug} is:pr is:merged author:${contribution.login}`,
'-f',
'sort=created',
'-f',
'order=asc',
'-f',
'per_page=1',
'--jq',
'.items[0].number // empty',
],
{ cwd, encoding: 'utf8' },
).trim();
firstPrByAuthor.set(contribution.login, firstRaw ? Number.parseInt(firstRaw, 10) : null);
}
const firstPr = firstPrByAuthor.get(contribution.login) ?? null;
contribution.isFirstContribution = firstPr !== null && firstPr === contribution.prNumber;
}
return [...byPr.values()].sort((a, b) => a.prNumber - b.prNumber);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Skipping contributor attribution: ${message}`);
return [];
}
}
function resolveContributionsForFragments(
fragments: ChangeFragment[],
cwd: string,
deps?: ChangelogFsDeps,
): Contribution[] {
const resolve = deps?.resolveContributions ?? defaultResolveContributions;
return resolve(
fragments.filter((fragment) => fragment.type !== 'internal').map((fragment) => fragment.path),
cwd,
);
}
function renderContributorsSections(contributions: Contribution[]): string[] {
if (contributions.length === 0) {
return [];
}
const lines: string[] = ['## Whats Changed', ''];
for (const contribution of contributions) {
lines.push(`- ${contribution.title} by @${contribution.login} in #${contribution.prNumber}`);
}
const firstTimers = contributions.filter((contribution) => contribution.isFirstContribution);
if (firstTimers.length > 0) {
lines.push('', '## New Contributors', '');
for (const contribution of firstTimers) {
lines.push(
`- @${contribution.login} made their first contribution in #${contribution.prNumber}`,
);
}
}
lines.push('');
return lines;
}
function serializeFragmentsForPrompt( function serializeFragmentsForPrompt(
fragments: ChangeFragment[], fragments: ChangeFragment[],
mode: PolishMode, mode: PolishMode,
@@ -473,6 +634,7 @@ function renderReleaseNotes(
changes: string, changes: string,
options?: { options?: {
disclaimer?: string; disclaimer?: string;
contributions?: Contribution[];
}, },
): string { ): string {
const prefix = options?.disclaimer ? [options.disclaimer, ''] : []; const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
@@ -494,6 +656,7 @@ function renderReleaseNotes(
'', '',
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.', 'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
'', '',
...renderContributorsSections(options?.contributions ?? []),
].join('\n'); ].join('\n');
} }
@@ -504,6 +667,7 @@ function writeReleaseNotesFile(
options?: { options?: {
disclaimer?: string; disclaimer?: string;
outputPath?: string; outputPath?: string;
contributions?: Contribution[];
}, },
): string { ): string {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
@@ -530,6 +694,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
const version = resolveVersion(options ?? {}); const version = resolveVersion(options ?? {});
const date = resolveDate(options?.date); const date = resolveDate(options?.date);
const fragments = readChangeFragments(cwd, options?.deps); const fragments = readChangeFragments(cwd, options?.deps);
const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps);
const existingChangelogPath = path.join(cwd, 'CHANGELOG.md'); const existingChangelogPath = path.join(cwd, 'CHANGELOG.md');
const existingChangelog = existsSync(existingChangelogPath) const existingChangelog = existsSync(existingChangelogPath)
? readFileSync(existingChangelogPath, 'utf8') ? readFileSync(existingChangelogPath, 'utf8')
@@ -547,6 +712,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
cwd, cwd,
stripDetailsBlocks(existingReleaseSection), stripDetailsBlocks(existingReleaseSection),
options?.deps, options?.deps,
{ contributions },
); );
log(`Generated ${releaseNotesPath}`); log(`Generated ${releaseNotesPath}`);
@@ -572,7 +738,9 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
date, date,
deps: options?.deps, deps: options?.deps,
}); });
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps); const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps, {
contributions,
});
log(`Generated ${releaseNotesPath}`); log(`Generated ${releaseNotesPath}`);
for (const fragment of fragments) { for (const fragment of fragments) {
@@ -661,14 +829,15 @@ export function verifyPullRequestChangelog(options: PullRequestChangelogOptions)
return; return;
} }
const hasFragment = normalizedEntries.some( const fragmentEntries = normalizedEntries.filter(
(entry) => entry.status !== 'D' && isFragmentPath(entry.path), (entry) => entry.status !== 'D' && isFragmentPath(entry.path),
); );
const hasFragment = fragmentEntries.length > 0;
const requiresFragment = normalizedEntries.some((entry) => !isIgnoredPullRequestPath(entry.path)); const requiresFragment = normalizedEntries.some((entry) => !isIgnoredPullRequestPath(entry.path));
if (requiresFragment && !hasFragment) { if (requiresFragment && !hasFragment) {
throw new Error( throw new Error(
`This pull request changes release-relevant files and requires a changelog fragment under changes/ or the ${SKIP_CHANGELOG_LABEL} label.`, `This pull request changes release-relevant files and requires a reconciled changelog fragment under changes/ or the ${SKIP_CHANGELOG_LABEL} label. Before adding a new fragment, update the existing PR fragment when the new work modifies, fixes, or supersedes behavior already described there.`,
); );
} }
} }
@@ -832,10 +1001,12 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
existingReleaseNotes, existingReleaseNotes,
deps: options?.deps, deps: options?.deps,
}); });
const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps);
return writeReleaseNotesFile(cwd, changes, options?.deps, { return writeReleaseNotesFile(cwd, changes, options?.deps, {
disclaimer: disclaimer:
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
outputPath: PRERELEASE_NOTES_PATH, outputPath: PRERELEASE_NOTES_PATH,
contributions,
}); });
} }
+16
View File
@@ -156,6 +156,22 @@ export class AnkiConnectClient {
return (result as number[]) || []; return (result as number[]) || [];
} }
async findCards(query: string, options?: { maxRetries?: number }): Promise<number[]> {
const result = await this.invoke('findCards', { query }, options);
return (result as number[]) || [];
}
async changeDeck(cardIds: number[], deckName: string): Promise<void> {
if (cardIds.length === 0 || !deckName.trim()) {
return;
}
await this.invoke('changeDeck', {
cards: cardIds,
deck: deckName,
});
}
async deckNames(): Promise<string[]> { async deckNames(): Promise<string[]> {
const result = await this.invoke('deckNames'); const result = await this.invoke('deckNames');
return Array.isArray(result) return Array.isArray(result)
@@ -63,7 +63,7 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'string', kind: 'string',
defaultValue: defaultConfig.ankiConnect.deck, defaultValue: defaultConfig.ankiConnect.deck,
description: description:
'Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.', 'Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.',
}, },
{ {
path: 'ankiConnect.fields.word', path: 'ankiConnect.fields.word',
File diff suppressed because it is too large Load Diff
@@ -34,13 +34,13 @@ test('guessAnilistMediaInfo fills missing guessit episode from filename parser',
}); });
}); });
test('guessAnilistMediaInfo ignores low-confidence parser details when guessit omits them', async () => { test('guessAnilistMediaInfo keeps season directory scope when guessit omits details', async () => {
const result = await guessAnilistMediaInfo('/tmp/Season 2/Guessit Title.mkv', null, { const result = await guessAnilistMediaInfo('/tmp/Season 2/Guessit Title.mkv', null, {
runGuessit: async () => JSON.stringify({ title: 'Guessit Title' }), runGuessit: async () => JSON.stringify({ title: 'Guessit Title' }),
}); });
assert.deepEqual(result, { assert.deepEqual(result, {
title: 'Guessit Title', title: 'Guessit Title',
season: null, season: 2,
episode: null, episode: null,
source: 'guessit', source: 'guessit',
}); });
@@ -235,6 +235,86 @@ test('updateAnilistPostWatchProgress uses the configured AniList rate limiter',
} }
}); });
test('updateAnilistPostWatchProgress marks the final season episode completed', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async (_input, init) => {
call += 1;
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 12, episodes: 12, title: { english: 'Final Show' } }],
},
},
});
}
if (call === 2) {
return createJsonResponse({
data: {
Media: { id: 12, mediaListEntry: { progress: 11, status: 'CURRENT' } },
},
});
}
assert.equal(body.variables?.progress, 12);
assert.equal(body.variables?.status, 'COMPLETED');
return createJsonResponse({
data: { SaveMediaListEntry: { progress: 12, status: 'COMPLETED' } },
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Final Show', 12);
assert.equal(result.status, 'updated');
assert.match(result.message, /completed/i);
assert.equal(call, 3);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress marks an already watched final season episode completed', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async (_input, init) => {
call += 1;
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 12, episodes: 12, title: { english: 'Final Show' } }],
},
},
});
}
if (call === 2) {
return createJsonResponse({
data: {
Media: { id: 12, mediaListEntry: { progress: 12, status: 'CURRENT' } },
},
});
}
assert.equal(body.variables?.progress, 12);
assert.equal(body.variables?.status, 'COMPLETED');
return createJsonResponse({
data: { SaveMediaListEntry: { progress: 12, status: 'COMPLETED' } },
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Final Show', 12);
assert.equal(result.status, 'updated');
assert.match(result.message, /completed/i);
assert.equal(call, 3);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress skips when progress already reached', async () => { test('updateAnilistPostWatchProgress skips when progress already reached', async () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
let call = 0; let call = 0;
+24 -8
View File
@@ -228,7 +228,7 @@ function pickBestSearchResult(
native?: string | null; native?: string | null;
}; };
}>, }>,
): { id: number; title: string } | null { ): { id: number; title: string; episodes: number | null } | null {
const filtered = media.filter((item) => { const filtered = media.filter((item) => {
const totalEpisodes = item.episodes; const totalEpisodes = item.episodes;
return totalEpisodes === null || totalEpisodes >= episode; return totalEpisodes === null || totalEpisodes >= episode;
@@ -247,7 +247,7 @@ function pickBestSearchResult(
const selected = exact ?? candidates[0]!; const selected = exact ?? candidates[0]!;
const selectedTitle = const selectedTitle =
selected.title?.english || selected.title?.romaji || selected.title?.native || title; selected.title?.english || selected.title?.romaji || selected.title?.native || title;
return { id: selected.id, title: selectedTitle }; return { id: selected.id, title: selectedTitle, episodes: selected.episodes };
} }
function isUpdateableListStatus(status: string | null | undefined): boolean { function isUpdateableListStatus(status: string | null | undefined): boolean {
@@ -259,6 +259,15 @@ function formatListStatus(status: string | null | undefined): string {
return `marked ${status.toLowerCase().replace(/_/g, ' ')} on AniList`; return `marked ${status.toLowerCase().replace(/_/g, ' ')} on AniList`;
} }
function isKnownFinalEpisode(totalEpisodes: number | null, episode: number): boolean {
return (
typeof totalEpisodes === 'number' &&
Number.isInteger(totalEpisodes) &&
totalEpisodes > 0 &&
episode === totalEpisodes
);
}
export async function guessAnilistMediaInfo( export async function guessAnilistMediaInfo(
mediaPath: string | null, mediaPath: string | null,
mediaTitle: string | null, mediaTitle: string | null,
@@ -283,7 +292,7 @@ export async function guessAnilistMediaInfo(
title: buildGuessitTitle(title, alternativeTitle), title: buildGuessitTitle(title, alternativeTitle),
...(alternativeTitle ? { alternativeTitle } : {}), ...(alternativeTitle ? { alternativeTitle } : {}),
...(year ? { year } : {}), ...(year ? { year } : {}),
season: season ?? (canUseFallbackDetails ? fallback.season : null), season: season ?? fallback.season,
episode: episode ?? (canUseFallbackDetails ? fallback.episode : null), episode: episode ?? (canUseFallbackDetails ? fallback.episode : null),
source: 'guessit', source: 'guessit',
}; };
@@ -394,7 +403,8 @@ export async function updateAnilistPostWatchProgress(
} }
const currentProgress = entry.progress ?? 0; const currentProgress = entry.progress ?? 0;
if (typeof currentProgress === 'number' && currentProgress >= episode) { const shouldMarkCompleted = isKnownFinalEpisode(picked.episodes, episode);
if (typeof currentProgress === 'number' && currentProgress >= episode && !shouldMarkCompleted) {
return { return {
status: 'skipped', status: 'skipped',
message: `AniList already at episode ${currentProgress} (${picked.title}).`, message: `AniList already at episode ${currentProgress} (${picked.title}).`,
@@ -404,14 +414,18 @@ export async function updateAnilistPostWatchProgress(
const saveResponse = await anilistGraphQl<AnilistSaveEntryData>( const saveResponse = await anilistGraphQl<AnilistSaveEntryData>(
accessToken, accessToken,
` `
mutation ($mediaId: Int!, $progress: Int!) { mutation ($mediaId: Int!, $progress: Int!, $status: MediaListStatus!) {
SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: CURRENT) { SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: $status) {
progress progress
status status
} }
} }
`, `,
{ mediaId: picked.id, progress: episode }, {
mediaId: picked.id,
progress: episode,
status: shouldMarkCompleted ? 'COMPLETED' : 'CURRENT',
},
options, options,
); );
const saveError = firstErrorMessage(saveResponse); const saveError = firstErrorMessage(saveResponse);
@@ -421,6 +435,8 @@ export async function updateAnilistPostWatchProgress(
return { return {
status: 'updated', status: 'updated',
message: `AniList updated "${picked.title}" to episode ${episode}.`, message: shouldMarkCompleted
? `AniList updated "${picked.title}" to episode ${episode} and marked it completed.`
: `AniList updated "${picked.title}" to episode ${episode}.`,
}; };
} }
@@ -6,6 +6,7 @@ import path from 'node:path';
import { toMonthKey } from './immersion-tracker/maintenance'; import { toMonthKey } from './immersion-tracker/maintenance';
import { enqueueWrite } from './immersion-tracker/queue'; import { enqueueWrite } from './immersion-tracker/queue';
import { toDbTimestamp } from './immersion-tracker/query-shared'; import { toDbTimestamp } from './immersion-tracker/query-shared';
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
import { Database, type DatabaseSync } from './immersion-tracker/sqlite'; import { Database, type DatabaseSync } from './immersion-tracker/sqlite';
import { nowMs as trackerNowMs } from './immersion-tracker/time'; import { nowMs as trackerNowMs } from './immersion-tracker/time';
import { import {
@@ -1164,6 +1165,54 @@ test('recordSubtitleLine leaves session token counts at zero when tokenization i
} }
}); });
test('recordSubtitleLine skips invalid cue timing and still stores the later valid cue', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange('/tmp/timing.mkv', 'Timing');
tracker.recordSubtitleLine('same subtitle', 953.991, 953.891);
tracker.recordSubtitleLine('same subtitle', 953.991, 956.56);
const privateApi = tracker as unknown as {
flushTelemetry: (force?: boolean) => void;
flushNow: () => void;
};
privateApi.flushTelemetry(true);
privateApi.flushNow();
const db = new Database(dbPath);
const rows = db
.prepare(
`SELECT line_index, segment_start_ms, segment_end_ms, text
FROM imm_subtitle_lines
ORDER BY line_id ASC`,
)
.all() as Array<{
line_index: number;
segment_start_ms: number | null;
segment_end_ms: number | null;
text: string;
}>;
db.close();
assert.deepEqual(rows, [
{
line_index: 1,
segment_start_ms: 953991,
segment_end_ms: 956560,
text: 'same subtitle',
},
]);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('subtitle-line event payload omits duplicated subtitle text', async () => { test('subtitle-line event payload omits duplicated subtitle text', async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;
@@ -1470,7 +1519,7 @@ test('handleMediaChange links parsed anime metadata on the active video row', as
assert.equal(row?.parsed_season, 2); assert.equal(row?.parsed_season, 2);
assert.equal(row?.parsed_episode, 5); assert.equal(row?.parsed_episode, 5);
assert.ok(row?.parser_source === 'guessit' || row?.parser_source === 'fallback'); assert.ok(row?.parser_source === 'guessit' || row?.parser_source === 'fallback');
assert.equal(row?.anime_title, 'Little Witch Academia'); assert.equal(row?.anime_title, 'Little Witch Academia Season 2');
assert.equal(row?.anilist_id, null); assert.equal(row?.anilist_id, null);
} finally { } finally {
tracker?.destroy(); tracker?.destroy();
@@ -1535,13 +1584,13 @@ test('handleMediaChange reuses the same provisional anime row across matching fi
{ {
sourcePath: '/tmp/Little Witch Academia S02E05.mkv', sourcePath: '/tmp/Little Witch Academia S02E05.mkv',
parsedEpisode: 5, parsedEpisode: 5,
animeTitle: 'Little Witch Academia', animeTitle: 'Little Witch Academia Season 2',
anilistId: null, anilistId: null,
}, },
{ {
sourcePath: '/tmp/Little Witch Academia S02E06.mkv', sourcePath: '/tmp/Little Witch Academia S02E06.mkv',
parsedEpisode: 6, parsedEpisode: 6,
animeTitle: 'Little Witch Academia', animeTitle: 'Little Witch Academia Season 2',
anilistId: null, anilistId: null,
}, },
], ],
@@ -1552,6 +1601,351 @@ test('handleMediaChange reuses the same provisional anime row across matching fi
} }
}); });
test('handleMediaChange splits matching parsed titles across distinct seasons', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange('/tmp/KonoSuba/Season 1/KonoSuba S01E05.mkv', 'Episode 5');
await waitForPendingAnimeMetadata(tracker);
tracker.handleMediaChange('/tmp/KonoSuba/Season 2/KonoSuba S02E05.mkv', 'Episode 5');
await waitForPendingAnimeMetadata(tracker);
const privateApi = tracker as unknown as {
db: DatabaseSync;
};
const rows = privateApi.db
.prepare(
`
SELECT
v.source_path,
v.anime_id,
v.parsed_season,
a.canonical_title AS anime_title,
a.normalized_title_key
FROM imm_videos v
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.source_path IN (?, ?)
ORDER BY v.source_path
`,
)
.all(
'/tmp/KonoSuba/Season 1/KonoSuba S01E05.mkv',
'/tmp/KonoSuba/Season 2/KonoSuba S02E05.mkv',
) as Array<{
source_path: string | null;
anime_id: number | null;
parsed_season: number | null;
anime_title: string | null;
normalized_title_key: string | null;
}>;
assert.equal(rows.length, 2);
assert.ok(rows[0]?.anime_id);
assert.ok(rows[1]?.anime_id);
assert.notEqual(rows[0]?.anime_id, rows[1]?.anime_id);
assert.deepEqual(
rows.map((row) => ({
sourcePath: row.source_path,
parsedSeason: row.parsed_season,
animeTitle: row.anime_title,
normalizedTitleKey: row.normalized_title_key,
})),
[
{
sourcePath: '/tmp/KonoSuba/Season 1/KonoSuba S01E05.mkv',
parsedSeason: 1,
animeTitle: 'KonoSuba Season 1',
normalizedTitleKey: 'konosuba season 1',
},
{
sourcePath: '/tmp/KonoSuba/Season 2/KonoSuba S02E05.mkv',
parsedSeason: 2,
animeTitle: 'KonoSuba Season 2',
normalizedTitleKey: 'konosuba season 2',
},
],
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('startup redistributes legacy combined anime rows across parsed seasons', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
privateApi.db.exec(`
INSERT INTO imm_anime (
anime_id,
normalized_title_key,
canonical_title,
anilist_id,
title_romaji,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (
1,
'frieren',
'Frieren',
154587,
'Sousou no Frieren',
1000,
1000
);
INSERT INTO imm_videos (
video_id,
video_key,
canonical_title,
anime_id,
source_type,
source_path,
parsed_basename,
parsed_title,
parsed_season,
parsed_episode,
parser_source,
parser_confidence,
watched,
duration_ms,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(
1,
'local:/tmp/Frieren S01E01.mkv',
'Frieren S01E01',
1,
1,
'/tmp/Frieren S01E01.mkv',
'Frieren S01E01.mkv',
'Frieren',
1,
1,
'fallback',
0.9,
1,
0,
1000,
1000
),
(
2,
'local:/tmp/Frieren S02E01.mkv',
'Frieren S02E01',
1,
1,
'/tmp/Frieren S02E01.mkv',
'Frieren S02E01.mkv',
'Frieren',
2,
1,
'fallback',
0.9,
1,
0,
1000,
1000
);
INSERT INTO imm_sessions (
session_id,
session_uuid,
video_id,
started_at_ms,
ended_at_ms,
status,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(1, 'season-repair-session-1', 1, 1000, 2000, 2, 1000, 2000),
(2, 'season-repair-session-2', 2, 3000, 4000, 2, 3000, 4000);
INSERT INTO imm_session_telemetry (
session_id,
sample_ms,
total_watched_ms,
active_watched_ms,
lines_seen,
tokens_seen,
cards_mined,
lookup_count,
lookup_hits,
pause_count,
pause_ms,
seek_forward_count,
seek_backward_count,
media_buffer_events
) VALUES
(1, 2000, 1000, 1000, 1, 10, 1, 0, 0, 0, 0, 0, 0, 0),
(2, 4000, 2000, 2000, 2, 20, 2, 0, 0, 0, 0, 0, 0, 0);
`);
tracker.destroy();
tracker = new Ctor({ dbPath });
const repairedApi = tracker as unknown as { db: DatabaseSync };
const rows = repairedApi.db
.prepare(
`
SELECT
a.canonical_title AS canonicalTitle,
a.normalized_title_key AS normalizedTitleKey,
a.anilist_id AS anilistId,
COUNT(v.video_id) AS videoCount,
COALESCE(lm.total_active_ms, 0) AS totalActiveMs
FROM imm_anime a
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
LEFT JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id
GROUP BY a.anime_id
ORDER BY a.canonical_title ASC
`,
)
.all() as Array<{
canonicalTitle: string;
normalizedTitleKey: string;
anilistId: number | null;
videoCount: number;
totalActiveMs: number;
}>;
assert.deepEqual(rows, [
{
canonicalTitle: 'Frieren Season 1',
normalizedTitleKey: 'frieren season 1',
anilistId: 154587,
videoCount: 1,
totalActiveMs: 1000,
},
{
canonicalTitle: 'Frieren Season 2',
normalizedTitleKey: 'frieren season 2',
anilistId: null,
videoCount: 1,
totalActiveMs: 2000,
},
]);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('startup skips single-season anime rows during legacy season repair', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
privateApi.db.exec(`
INSERT INTO imm_anime (
anime_id,
normalized_title_key,
canonical_title,
anilist_id,
title_romaji,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (
1,
'frieren',
'Frieren',
154587,
'Sousou no Frieren',
1000,
1000
);
INSERT INTO imm_videos (
video_id,
video_key,
canonical_title,
anime_id,
source_type,
source_path,
parsed_basename,
parsed_title,
parsed_season,
parsed_episode,
parser_source,
parser_confidence,
watched,
duration_ms,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (
1,
'local:/tmp/Frieren S01E01.mkv',
'Frieren S01E01',
1,
1,
'/tmp/Frieren S01E01.mkv',
'Frieren S01E01.mkv',
'Frieren',
1,
1,
'fallback',
0.9,
1,
0,
1000,
1000
);
`);
tracker.destroy();
tracker = new Ctor({ dbPath });
const repairedApi = tracker as unknown as { db: DatabaseSync };
const rows = repairedApi.db
.prepare(
`
SELECT
a.canonical_title AS canonicalTitle,
a.normalized_title_key AS normalizedTitleKey,
a.anilist_id AS anilistId,
COUNT(v.video_id) AS videoCount
FROM imm_anime a
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
GROUP BY a.anime_id
ORDER BY a.anime_id ASC
`,
)
.all() as Array<{
canonicalTitle: string;
normalizedTitleKey: string;
anilistId: number | null;
videoCount: number;
}>;
assert.deepEqual(rows, [
{
canonicalTitle: 'Frieren',
normalizedTitleKey: 'frieren',
anilistId: 154587,
videoCount: 1,
},
]);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('Jellyfin playback metadata links stream videos to existing series title', async () => { test('Jellyfin playback metadata links stream videos to existing series title', async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;
@@ -1595,8 +1989,41 @@ test('Jellyfin playback metadata links stream videos to existing series title',
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000', 'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000',
'The Beginning After the End S02E02 The Princess Begins Adventuring', 'The Beginning After the End S02E02 The Princess Begins Adventuring',
); );
tracker.handleMediaChange(null, null);
tracker.recordJellyfinPlaybackMetadata({
mediaPath:
'http://jellyfin.local/Videos/item-3/stream?static=true&api_key=token&MediaSourceId=ms-2',
displayTitle: 'The Beginning After the End S02E03 Dragon Has Left the Building',
itemTitle: 'Dragon Has Left the Building',
seriesTitle: 'The Beginning After the End',
seasonNumber: 2,
episodeNumber: 3,
itemId: 'item-3',
});
tracker.handleMediaChange(
'http://jellyfin.local/Videos/item-3/stream?static=true&api_key=token&MediaSourceId=ms-2&AudioStreamIndex=3&SubtitleStreamIndex=4',
'The Beginning After the End S02E03 Dragon Has Left the Building',
);
await waitForPendingAnimeMetadata(tracker);
const privateApi = tracker as unknown as { db: DatabaseSync }; const privateApi = tracker as unknown as { db: DatabaseSync };
const videoRows = privateApi.db
.prepare(
`
SELECT source_url, canonical_title AS video_title
FROM imm_videos
ORDER BY video_id
`,
)
.all() as Array<{ source_url: string | null; video_title: string }>;
assert.equal(videoRows.length, 3);
assert.equal(
videoRows.some(
(row) => row.source_url?.includes('api_key=') || row.video_title.includes('api_key='),
),
false,
);
const rows = privateApi.db const rows = privateApi.db
.prepare( .prepare(
` `
@@ -1623,7 +2050,7 @@ test('Jellyfin playback metadata links stream videos to existing series title',
anime_title: string; anime_title: string;
}>; }>;
assert.equal(rows.length, 2); assert.equal(rows.length, 3);
assert.equal(new Set(rows.map((row) => row.anime_title)).size, 1); assert.equal(new Set(rows.map((row) => row.anime_title)).size, 1);
const jellyfinRow = rows.find( const jellyfinRow = rows.find(
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-2', (row) => row.source_url === 'jellyfin://jellyfin.local/item/item-2',
@@ -1637,7 +2064,250 @@ test('Jellyfin playback metadata links stream videos to existing series title',
assert.equal(jellyfinRow.parsed_season, 2); assert.equal(jellyfinRow.parsed_season, 2);
assert.equal(jellyfinRow.parsed_episode, 2); assert.equal(jellyfinRow.parsed_episode, 2);
assert.equal(jellyfinRow.parser_source, 'jellyfin'); assert.equal(jellyfinRow.parser_source, 'jellyfin');
assert.equal(jellyfinRow.anime_title, 'The Beginning After the End'); assert.equal(jellyfinRow.anime_title, 'The Beginning After the End Season 2');
const streamVariantRow = rows.find(
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-3',
);
assert.ok(streamVariantRow);
assert.equal(
streamVariantRow.video_title,
'The Beginning After the End S02E03 Dragon Has Left the Building',
);
assert.equal(streamVariantRow.source_url?.includes('api_key='), false);
assert.equal(streamVariantRow.video_title.includes('api_key='), false);
assert.equal(streamVariantRow.video_title.includes('stream?'), false);
assert.equal(streamVariantRow.parsed_title, 'The Beginning After the End');
assert.equal(streamVariantRow.parsed_season, 2);
assert.equal(streamVariantRow.parsed_episode, 3);
assert.equal(streamVariantRow.parser_source, 'jellyfin');
assert.equal(streamVariantRow.anime_title, 'The Beginning After the End Season 2');
} 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;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const streamUrl =
'http://jellyfin.local/Videos/item-9/stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4';
tracker.handleMediaChange(
streamUrl,
'stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4',
);
tracker.handleMediaChange(null, null);
const titledStreamUrl =
'http://jellyfin.local/Videos/item-10/stream?static=true&api_key=secret-token&MediaSourceId=ms-2';
tracker.handleMediaChange(titledStreamUrl, 'KonoSuba S01E06 Decision! Class Rep');
tracker.handleMediaChange(null, null);
tracker.recordJellyfinPlaybackMetadata({
mediaPath: 'http://jellyfin.local/Videos/item-9/stream?static=true&api_key=secret-token',
displayTitle: 'Frieren S01E09 Aura the Guillotine',
itemTitle: 'Aura the Guillotine',
seriesTitle: 'Frieren',
seasonNumber: 1,
episodeNumber: 9,
itemId: 'item-9',
});
tracker.destroy();
tracker = null;
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
const videoRows = privateApi.db
.prepare(
`
SELECT
video_id,
video_key,
source_url,
canonical_title,
parser_source,
parsed_basename,
parsed_title,
parse_metadata_json
FROM imm_videos
ORDER BY video_id
`,
)
.all() as Array<{
video_id: number;
video_key: string;
source_url: string | null;
canonical_title: string;
parser_source: string | null;
parsed_basename: string | null;
parsed_title: string | null;
parse_metadata_json: string | null;
}>;
assert.equal(videoRows.length, 3);
const frierenRows = videoRows.filter(
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-9',
);
assert.equal(frierenRows.length, 2);
for (const row of frierenRows) {
assert.equal(row.source_url, 'jellyfin://jellyfin.local/item/item-9');
assert.equal(row.canonical_title, 'Frieren S01E09 Aura the Guillotine');
assert.equal(row.parser_source, 'jellyfin');
assert.equal(row.video_key.includes('api_key='), false);
assert.equal(row.source_url?.includes('api_key='), false);
assert.equal(row.canonical_title.includes('api_key='), false);
}
const titledRow = videoRows.find(
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-10',
);
assert.ok(titledRow);
assert.equal(titledRow.canonical_title, 'KonoSuba S01E06 Decision! Class Rep');
assert.equal(titledRow.video_key.includes('api_key='), false);
assert.equal(titledRow.source_url?.includes('api_key='), false);
assert.equal(JSON.stringify(videoRows).includes('api_key='), false);
assert.equal(JSON.stringify(videoRows).includes('secret-token'), false);
const animeRows = privateApi.db
.prepare(
`
SELECT canonical_title, normalized_title_key
FROM imm_anime
ORDER BY anime_id
`,
)
.all() as Array<{ canonical_title: string; normalized_title_key: string }>;
assert.equal(JSON.stringify(animeRows).includes('api_key='), false);
assert.equal(JSON.stringify(animeRows).includes('api key'), false);
assert.equal(JSON.stringify(animeRows).includes('secret-token'), false);
const sessionRows = privateApi.db
.prepare(
`
SELECT v.source_url, v.canonical_title
FROM imm_sessions s
JOIN imm_videos v ON v.video_id = s.video_id
ORDER BY s.session_id
`,
)
.all() as Array<{ source_url: string | null; canonical_title: string }>;
assert.deepEqual(
sessionRows.map((row) => row.canonical_title),
['Frieren S01E09 Aura the Guillotine', 'KonoSuba S01E06 Decision! Class Rep'],
);
assert.equal(
sessionRows.some((row) => row.source_url?.includes('api_key=')),
false,
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('Jellyfin link repair removes merged leaked anime rows and sanitizes orphan video titles', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
const db = privateApi.db;
const timestamp = toDbTimestamp(trackerNowMs());
const leakedTitle =
'http://jellyfin.local/Videos/item-20/stream?static=true&api_key=secret-token&MediaSourceId=ms-1';
const orphanLeakedTitle =
'http://jellyfin.local/Videos/item-21/stream?static=true&api_key=secret-token&MediaSourceId=ms-2&AudioStreamIndex=3';
const existingAnime = db
.prepare(
`
INSERT INTO imm_anime (
normalized_title_key,
canonical_title,
CREATED_DATE,
LAST_UPDATE_DATE
)
VALUES ('frieren', 'Frieren', ?, ?)
RETURNING anime_id
`,
)
.get(timestamp, timestamp) as { anime_id: number };
const leakedAnime = db
.prepare(
`
INSERT INTO imm_anime (
normalized_title_key,
canonical_title,
CREATED_DATE,
LAST_UPDATE_DATE
)
VALUES ('http jellyfin local videos item 20 stream static true api key secret token mediasourceid ms 1', ?, ?, ?)
RETURNING anime_id
`,
)
.get(leakedTitle, timestamp, timestamp) as { anime_id: number };
db.prepare(
`
INSERT INTO imm_videos (
video_key,
anime_id,
canonical_title,
source_type,
source_url,
duration_ms,
CREATED_DATE,
LAST_UPDATE_DATE
)
VALUES (?, ?, 'Frieren', 2, ?, 0, ?, ?)
`,
).run(`remote:${leakedTitle}`, leakedAnime.anime_id, leakedTitle, timestamp, timestamp);
db.prepare(
`
INSERT INTO imm_videos (
video_key,
anime_id,
canonical_title,
source_type,
source_url,
duration_ms,
CREATED_DATE,
LAST_UPDATE_DATE
)
VALUES (?, NULL, ?, 2, ?, 0, ?, ?)
`,
).run(
`remote:${orphanLeakedTitle}`,
orphanLeakedTitle,
orphanLeakedTitle,
timestamp,
timestamp,
);
const summary = repairJellyfinStreamVideoLinks(db);
assert.equal(summary.repaired, 3);
const leakedAnimeRow = db
.prepare('SELECT anime_id FROM imm_anime WHERE anime_id = ?')
.get(leakedAnime.anime_id);
assert.equal(leakedAnimeRow, undefined);
const reparentedCount = db
.prepare('SELECT COUNT(*) AS count FROM imm_videos WHERE anime_id = ?')
.get(existingAnime.anime_id) as { count: number };
assert.equal(reparentedCount.count, 1);
const orphanVideo = db
.prepare(
`
SELECT canonical_title
FROM imm_videos
WHERE source_url = 'jellyfin://jellyfin.local/item/item-21'
`,
)
.get() as { canonical_title: string };
assert.equal(orphanVideo.canonical_title, 'Jellyfin Video');
} finally { } finally {
tracker?.destroy(); tracker?.destroy();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
@@ -2447,6 +3117,216 @@ test('reassignAnimeAnilist preserves existing description when description is om
} }
}); });
test('reassignAnimeAnilist redistributes conflicting legacy combined row before assigning AniList id', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
privateApi.db.exec(`
INSERT INTO imm_anime (
anime_id,
normalized_title_key,
canonical_title,
anilist_id,
title_romaji,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(
1,
'konosuba',
'KonoSuba',
21202,
'Kono Subarashii Sekai ni Shukufuku wo!',
1000,
1000
),
(
2,
'konosuba season 1',
'KonoSuba Season 1',
NULL,
NULL,
1000,
1000
);
INSERT INTO imm_videos (
video_id,
video_key,
canonical_title,
anime_id,
source_type,
source_path,
parsed_basename,
parsed_title,
parsed_season,
parsed_episode,
parser_source,
parser_confidence,
watched,
duration_ms,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(
1,
'local:/tmp/KonoSuba S01E01.mkv',
'KonoSuba S01E01',
1,
1,
'/tmp/KonoSuba S01E01.mkv',
'KonoSuba S01E01.mkv',
'KonoSuba',
1,
1,
'fallback',
0.9,
1,
0,
1000,
1000
),
(
2,
'local:/tmp/KonoSuba S02E01.mkv',
'KonoSuba S02E01',
1,
1,
'/tmp/KonoSuba S02E01.mkv',
'KonoSuba S02E01.mkv',
'KonoSuba',
2,
1,
'fallback',
0.9,
1,
0,
1000,
1000
),
(
3,
'local:/tmp/KonoSuba S01E02.mkv',
'KonoSuba S01E02',
2,
1,
'/tmp/KonoSuba S01E02.mkv',
'KonoSuba S01E02.mkv',
'KonoSuba',
1,
2,
'fallback',
0.9,
1,
0,
1000,
1000
);
INSERT INTO imm_sessions (
session_id,
session_uuid,
video_id,
started_at_ms,
ended_at_ms,
status,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(1, 'anilist-conflict-session-1', 1, 1000, 2000, 2, 1000, 2000),
(2, 'anilist-conflict-session-2', 2, 3000, 4000, 2, 3000, 4000),
(3, 'anilist-conflict-session-3', 3, 5000, 6000, 2, 5000, 6000);
INSERT INTO imm_subtitle_lines (
session_id,
video_id,
anime_id,
line_index,
text,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(1, 1, 1, 0, 'season one legacy line', 1000, 1000),
(2, 2, 1, 0, 'season two legacy line', 1000, 1000);
INSERT INTO imm_session_telemetry (
session_id,
sample_ms,
total_watched_ms,
active_watched_ms,
lines_seen,
tokens_seen,
cards_mined,
lookup_count,
lookup_hits,
pause_count,
pause_ms,
seek_forward_count,
seek_backward_count,
media_buffer_events
) VALUES
(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);
`);
await tracker.reassignAnimeAnilist(2, {
anilistId: 21202,
titleRomaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
});
const rows = privateApi.db
.prepare(
`
SELECT
a.canonical_title AS canonicalTitle,
a.anilist_id AS anilistId,
COUNT(DISTINCT v.video_id) AS videoCount,
COUNT(DISTINCT sl.line_id) AS subtitleLineCount,
COALESCE(lm.total_active_ms, 0) AS totalActiveMs
FROM imm_anime a
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
LEFT JOIN imm_subtitle_lines sl ON sl.anime_id = a.anime_id
LEFT JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id
GROUP BY a.anime_id
ORDER BY a.canonical_title ASC
`,
)
.all() as Array<{
canonicalTitle: string;
anilistId: number | null;
videoCount: number;
subtitleLineCount: number;
totalActiveMs: number;
}>;
assert.deepEqual(rows, [
{
canonicalTitle: 'KonoSuba Season 1',
anilistId: 21202,
videoCount: 2,
subtitleLineCount: 1,
totalActiveMs: 4000,
},
{
canonicalTitle: 'KonoSuba Season 2',
anilistId: null,
videoCount: 1,
subtitleLineCount: 1,
totalActiveMs: 2000,
},
]);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('handleMediaChange stores youtube metadata for new youtube sessions', async () => { test('handleMediaChange stores youtube metadata for new youtube sessions', async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;
+77 -7
View File
@@ -55,6 +55,7 @@ import {
getStatsExcludedWords, getStatsExcludedWords,
getVocabularyStats, getVocabularyStats,
replaceStatsExcludedWords, replaceStatsExcludedWords,
searchSubtitleSentences,
getWordAnimeAppearances, getWordAnimeAppearances,
getWordDetail, getWordDetail,
getWordOccurrences, getWordOccurrences,
@@ -89,6 +90,11 @@ import {
markVideoWatched, markVideoWatched,
upsertCoverArt, upsertCoverArt,
} from './immersion-tracker/query-maintenance'; } from './immersion-tracker/query-maintenance';
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
import {
repairLegacySeasonlessAnimeRows,
resolveAnimeAnilistConflict,
} from './immersion-tracker/anime-season-repair';
import { import {
buildVideoKey, buildVideoKey,
deriveCanonicalTitle, deriveCanonicalTitle,
@@ -148,6 +154,8 @@ import {
type MediaLibraryRow, type MediaLibraryRow,
type NewAnimePerDayRow, type NewAnimePerDayRow,
type QueuedWrite, type QueuedWrite,
type SentenceSearchOptions,
type SentenceSearchResultRow,
type SessionEventRow, type SessionEventRow,
type SessionState, type SessionState,
type SessionSummaryQueryRow, type SessionSummaryQueryRow,
@@ -328,6 +336,34 @@ function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string
} }
} }
const JELLYFIN_MEDIA_ALIAS_QUERY_KEYS = [
'api_key',
'StartTimeTicks',
'AudioStreamIndex',
'SubtitleStreamIndex',
];
function deleteSearchParamsCaseInsensitive(searchParams: URLSearchParams, names: string[]): void {
const loweredNames = new Set(names.map((name) => name.toLowerCase()));
for (const key of [...searchParams.keys()]) {
if (loweredNames.has(key.toLowerCase())) {
searchParams.delete(key);
}
}
}
function buildJellyfinMediaPathAliasCandidates(mediaPath: string): string[] {
const candidates = new Set<string>([mediaPath]);
try {
const parsed = new URL(mediaPath);
deleteSearchParamsCaseInsensitive(parsed.searchParams, JELLYFIN_MEDIA_ALIAS_QUERY_KEYS);
candidates.add(parsed.toString());
} catch {
// Non-URL fallback paths are already represented by the raw candidate.
}
return [...candidates];
}
export class ImmersionTrackerService { export class ImmersionTrackerService {
private readonly logger = createLogger('main:immersion-tracker'); private readonly logger = createLogger('main:immersion-tracker');
private readonly db: DatabaseSync; private readonly db: DatabaseSync;
@@ -437,6 +473,19 @@ export class ImmersionTrackerService {
`Recovered stale active sessions on startup: reconciledSessions=${reconciledSessions}`, `Recovered stale active sessions on startup: reconciledSessions=${reconciledSessions}`,
); );
} }
const jellyfinRepair = repairJellyfinStreamVideoLinks(this.db);
if (jellyfinRepair.repaired > 0) {
this.logger.info(
`Repaired Jellyfin stats links on startup: scanned=${jellyfinRepair.scanned} repaired=${jellyfinRepair.repaired}`,
);
}
const seasonRepair = repairLegacySeasonlessAnimeRows(this.db);
if (seasonRepair.movedVideos > 0 || seasonRepair.deletedAnimeRows > 0) {
this.logger.info(
`Repaired season-scoped stats links on startup: scanned=${seasonRepair.scanned} movedVideos=${seasonRepair.movedVideos} deletedAnimeRows=${seasonRepair.deletedAnimeRows}`,
);
rebuildLifetimeSummaryTables(this.db);
}
if (shouldBackfillLifetimeSummaries(this.db)) { if (shouldBackfillLifetimeSummaries(this.db)) {
const result = rebuildLifetimeSummaryTables(this.db); const result = rebuildLifetimeSummaryTables(this.db);
if (result.appliedSessions > 0) { if (result.appliedSessions > 0) {
@@ -568,6 +617,14 @@ export class ImmersionTrackerService {
return getKanjiOccurrences(this.db, kanji, limit, offset); return getKanjiOccurrences(this.db, kanji, limit, offset);
} }
async searchSubtitleSentences(
query: string,
limit = 50,
options?: SentenceSearchOptions,
): Promise<SentenceSearchResultRow[]> {
return searchSubtitleSentences(this.db, query, limit, options);
}
async getSessionEvents( async getSessionEvents(
sessionId: number, sessionId: number,
limit = 500, limit = 500,
@@ -687,6 +744,7 @@ export class ImmersionTrackerService {
coverUrl?: string | null; coverUrl?: string | null;
}, },
): Promise<void> { ): Promise<void> {
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId);
this.db this.db
.prepare( .prepare(
` `
@@ -712,6 +770,9 @@ export class ImmersionTrackerService {
nowMs(), nowMs(),
animeId, animeId,
); );
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
rebuildLifetimeSummaryTables(this.db);
}
// Update cover art for all videos in this anime // Update cover art for all videos in this anime
if (info.coverUrl) { if (info.coverUrl) {
@@ -1149,7 +1210,9 @@ export class ImmersionTrackerService {
return; return;
} }
const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId); const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
this.mediaPathAliases.set(rawPath, normalizedPath); for (const alias of buildJellyfinMediaPathAliasCandidates(rawPath)) {
this.mediaPathAliases.set(alias, normalizedPath);
}
const displayTitle = const displayTitle =
normalizeText(metadata.displayTitle) || normalizeText(metadata.displayTitle) ||
@@ -1158,6 +1221,8 @@ export class ImmersionTrackerService {
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle; const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
const seriesTitle = normalizeText(metadata.seriesTitle); const seriesTitle = normalizeText(metadata.seriesTitle);
const libraryTitle = seriesTitle || itemTitle; const libraryTitle = seriesTitle || itemTitle;
const seasonNumber = normalizeMetadataInt(metadata.seasonNumber);
const episodeNumber = normalizeMetadataInt(metadata.episodeNumber);
if (!libraryTitle) { if (!libraryTitle) {
return; return;
} }
@@ -1181,12 +1246,13 @@ export class ImmersionTrackerService {
itemTitle, itemTitle,
seriesTitle: seriesTitle || null, seriesTitle: seriesTitle || null,
displayTitle, displayTitle,
seasonNumber: normalizeMetadataInt(metadata.seasonNumber), seasonNumber,
episodeNumber: normalizeMetadataInt(metadata.episodeNumber), episodeNumber,
}); });
const animeId = getOrCreateAnimeRecord(this.db, { const animeId = getOrCreateAnimeRecord(this.db, {
parsedTitle: libraryTitle, parsedTitle: libraryTitle,
canonicalTitle: libraryTitle, canonicalTitle: libraryTitle,
seasonScope: seasonNumber,
anilistId: null, anilistId: null,
titleRomaji: null, titleRomaji: null,
titleEnglish: null, titleEnglish: null,
@@ -1197,8 +1263,8 @@ export class ImmersionTrackerService {
animeId, animeId,
parsedBasename: null, parsedBasename: null,
parsedTitle: libraryTitle, parsedTitle: libraryTitle,
parsedSeason: normalizeMetadataInt(metadata.seasonNumber), parsedSeason: seasonNumber,
parsedEpisode: normalizeMetadataInt(metadata.episodeNumber), parsedEpisode: episodeNumber,
parserSource: 'jellyfin', parserSource: 'jellyfin',
parserConfidence: 1, parserConfidence: 1,
parseMetadataJson: metadataJson, parseMetadataJson: metadataJson,
@@ -1221,7 +1287,10 @@ export class ImmersionTrackerService {
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void { handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
const rawPath = normalizeMediaPath(mediaPath); const rawPath = normalizeMediaPath(mediaPath);
const normalizedPath = this.mediaPathAliases.get(rawPath) ?? rawPath; const normalizedPath =
buildJellyfinMediaPathAliasCandidates(rawPath)
.map((alias) => this.mediaPathAliases.get(alias))
.find((alias): alias is string => Boolean(alias)) ?? rawPath;
const normalizedTitle = normalizeText(mediaTitle); const normalizedTitle = normalizeText(mediaTitle);
this.logger.info( this.logger.info(
`handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`, `handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`,
@@ -1294,7 +1363,7 @@ export class ImmersionTrackerService {
const cleaned = normalizeText(text); const cleaned = normalizeText(text);
if (!cleaned) return; if (!cleaned) return;
if (!endSec || endSec <= 0) { if (!Number.isFinite(startSec) || !Number.isFinite(endSec) || endSec <= startSec) {
return; return;
} }
@@ -1826,6 +1895,7 @@ export class ImmersionTrackerService {
const animeId = getOrCreateAnimeRecord(this.db, { const animeId = getOrCreateAnimeRecord(this.db, {
parsedTitle: parsed.parsedTitle, parsedTitle: parsed.parsedTitle,
canonicalTitle: parsed.parsedTitle, canonicalTitle: parsed.parsedTitle,
seasonScope: parsed.parsedSeason,
anilistId: null, anilistId: null,
titleRomaji: null, titleRomaji: null,
titleEnglish: null, titleEnglish: null,
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
test('getRollupGroupsForSessions uses only localtime rollup keys', () => {
const source = fs.readFileSync(
path.join(process.cwd(), 'src/core/services/immersion-tracker/maintenance.ts'),
'utf8',
);
const start = source.indexOf('export function getRollupGroupsForSessions');
const end = source.indexOf('export function refreshRollupsForGroupsInTransaction');
const functionSource = source.slice(start, end);
assert.match(functionSource, /'unixepoch', 'localtime'/);
assert.doesNotMatch(functionSource, /UNION/);
assert.doesNotMatch(functionSource, /86400000/);
});
@@ -356,6 +356,81 @@ test('split session and lexical helpers return distinct-headword, detail, appear
} }
}); });
test('similar words use same reading and shared kanji without kana suffix noise', () => {
const { db, dbPath, stmts } = createDb();
try {
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Similar Words Anime',
canonicalTitle: 'Similar Words Anime',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/similar-words.mkv', {
canonicalTitle: 'Similar Words Episode',
sourcePath: '/tmp/similar-words.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const sessionId = startSessionRecord(db, videoId, 1_000_000).sessionId;
const araiId = insertWordOccurrence(db, stmts, {
sessionId,
videoId,
animeId,
lineIndex: 1,
text: '荒い息',
word: { headword: '荒い', word: '荒い', reading: 'あらい' },
});
insertWordOccurrence(db, stmts, {
sessionId,
videoId,
animeId,
lineIndex: 2,
text: '洗い物',
word: { headword: '洗い', word: '洗い', reading: 'あらい' },
});
insertWordOccurrence(db, stmts, {
sessionId,
videoId,
animeId,
lineIndex: 3,
text: '荒波',
word: { headword: '荒波', word: '荒波', reading: 'あらなみ' },
});
for (let lineIndex = 4; lineIndex < 9; lineIndex++) {
insertWordOccurrence(db, stmts, {
sessionId,
videoId,
animeId,
lineIndex,
text: '良い',
word: { headword: '良い', word: '良い', reading: 'よい' },
});
}
insertWordOccurrence(db, stmts, {
sessionId,
videoId,
animeId,
lineIndex: 9,
text: 'お構いなく',
word: { headword: 'お構いなく', word: 'お構いなく', reading: 'おかまいなく' },
});
assert.deepEqual(
getSimilarWords(db, araiId, 10).map((row) => row.headword),
['洗い', '荒波'],
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('split library helpers return anime/media session and analytics rows', () => { test('split library helpers return anime/media session and analytics rows', () => {
const { db, dbPath, stmts } = createDb(); const { db, dbPath, stmts } = createDb();
@@ -605,6 +680,189 @@ test('split maintenance helpers update anime metadata and watched state', () =>
} }
}); });
test('updateAnimeAnilistInfo redistributes legacy combined row before assigning duplicate AniList id', () => {
const { db, dbPath } = createDb();
try {
const legacyAnimeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'KonoSuba',
canonicalTitle: 'KonoSuba',
anilistId: 21202,
titleRomaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
const seasonAnimeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'KonoSuba',
canonicalTitle: 'KonoSuba',
seasonScope: 1,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
const legacySeasonOneVideoId = getOrCreateVideoRecord(db, 'local:/tmp/konosuba-s01e01.mkv', {
canonicalTitle: 'KonoSuba S01E01',
sourcePath: '/tmp/konosuba-s01e01.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const legacySeasonTwoVideoId = getOrCreateVideoRecord(db, 'local:/tmp/konosuba-s02e01.mkv', {
canonicalTitle: 'KonoSuba S02E01',
sourcePath: '/tmp/konosuba-s02e01.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const targetVideoId = getOrCreateVideoRecord(db, 'local:/tmp/konosuba-s01e02.mkv', {
canonicalTitle: 'KonoSuba S01E02',
sourcePath: '/tmp/konosuba-s01e02.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
linkVideoToAnimeRecord(db, legacySeasonOneVideoId, {
animeId: legacyAnimeId,
parsedBasename: 'konosuba-s01e01.mkv',
parsedTitle: 'KonoSuba',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
linkVideoToAnimeRecord(db, legacySeasonTwoVideoId, {
animeId: legacyAnimeId,
parsedBasename: 'konosuba-s02e01.mkv',
parsedTitle: 'KonoSuba',
parsedSeason: 2,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
linkVideoToAnimeRecord(db, targetVideoId, {
animeId: seasonAnimeId,
parsedBasename: 'konosuba-s01e02.mkv',
parsedTitle: 'KonoSuba',
parsedSeason: 1,
parsedEpisode: 2,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
updateAnimeAnilistInfo(db, targetVideoId, {
anilistId: 21202,
titleRomaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
titleEnglish: null,
titleNative: null,
episodesTotal: 10,
});
const rows = db
.prepare(
`
SELECT
a.canonical_title AS canonicalTitle,
a.anilist_id AS anilistId,
COUNT(v.video_id) AS videoCount
FROM imm_anime a
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
GROUP BY a.anime_id
ORDER BY a.canonical_title ASC
`,
)
.all() as Array<{
canonicalTitle: string;
anilistId: number | null;
videoCount: number;
}>;
assert.deepEqual(rows, [
{ canonicalTitle: 'KonoSuba Season 1', anilistId: 21202, videoCount: 2 },
{ canonicalTitle: 'KonoSuba Season 2', anilistId: null, videoCount: 1 },
]);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('deleteSessions refreshes only rollups affected by deleted sessions', () => {
const { db, dbPath } = createDb();
try {
const keepVideoId = getOrCreateVideoRecord(db, 'local:/tmp/rollup-keep.mkv', {
canonicalTitle: 'Rollup Keep',
sourcePath: '/tmp/rollup-keep.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const dropVideoId = getOrCreateVideoRecord(db, 'local:/tmp/rollup-drop.mkv', {
canonicalTitle: 'Rollup Drop',
sourcePath: '/tmp/rollup-drop.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const keepStartedAtMs = 1_700_000_000_000;
const dropStartedAtMs = 1_700_086_400_000;
const keepSessionId = startSessionRecord(db, keepVideoId, keepStartedAtMs).sessionId;
const dropSessionId = startSessionRecord(db, dropVideoId, dropStartedAtMs).sessionId;
finalizeSessionMetrics(db, keepSessionId, keepStartedAtMs, {
activeWatchedMs: 30_000,
cardsMined: 1,
});
finalizeSessionMetrics(db, dropSessionId, dropStartedAtMs, {
activeWatchedMs: 60_000,
cardsMined: 2,
});
const keepDay = getLocalEpochDay(db, keepStartedAtMs);
const dropDay = getLocalEpochDay(db, dropStartedAtMs);
const keepMonth = 202311;
const dropMonth = 202311;
const insertDaily = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertMonthly = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertDaily.run(keepDay, keepVideoId, 1, 0.5, 3, 6, 1, keepStartedAtMs, keepStartedAtMs);
insertDaily.run(dropDay, dropVideoId, 1, 1, 3, 6, 2, dropStartedAtMs, dropStartedAtMs);
insertMonthly.run(keepMonth, keepVideoId, 1, 0.5, 3, 6, 1, keepStartedAtMs, keepStartedAtMs);
insertMonthly.run(dropMonth, dropVideoId, 1, 1, 3, 6, 2, dropStartedAtMs, dropStartedAtMs);
deleteSessions(db, [dropSessionId]);
const dailyRows = db
.prepare('SELECT rollup_day, video_id, total_cards FROM imm_daily_rollups ORDER BY video_id')
.all() as Array<{ rollup_day: number; video_id: number; total_cards: number }>;
const monthlyRows = db
.prepare(
'SELECT rollup_month, video_id, total_cards FROM imm_monthly_rollups ORDER BY video_id',
)
.all() as Array<{ rollup_month: number; video_id: number; total_cards: number }>;
assert.deepEqual(dailyRows, [{ rollup_day: keepDay, video_id: keepVideoId, total_cards: 1 }]);
assert.deepEqual(monthlyRows, [
{ rollup_month: keepMonth, video_id: keepVideoId, total_cards: 1 },
]);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('split maintenance helpers delete multiple sessions and whole videos with dependent rows', () => { test('split maintenance helpers delete multiple sessions and whole videos with dependent rows', () => {
const { db, dbPath, stmts } = createDb(); const { db, dbPath, stmts } = createDb();
@@ -35,9 +35,11 @@ import {
getSessionTimeline, getSessionTimeline,
getSessionWordsByLine, getSessionWordsByLine,
getWordOccurrences, getWordOccurrences,
searchSubtitleSentences,
upsertCoverArt, upsertCoverArt,
} from '../query.js'; } from '../query.js';
import { import {
getLocalEpochDay,
getShiftedLocalDaySec, getShiftedLocalDaySec,
getStartOfLocalDayTimestamp, getStartOfLocalDayTimestamp,
toDbTimestamp, toDbTimestamp,
@@ -759,6 +761,10 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
assert.equal(dashboard.progress.watchTime[1]?.value, 75); assert.equal(dashboard.progress.watchTime[1]?.value, 75);
assert.equal(dashboard.progress.lookups[1]?.value, 18); assert.equal(dashboard.progress.lookups[1]?.value, 18);
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1)); assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1));
assert.equal(dashboard.ratios.cardsPerHour[0]?.value, +(2 / (30 / 60)).toFixed(1));
assert.equal(dashboard.ratios.cardsPerHour[1]?.value, +(3 / (45 / 60)).toFixed(1));
assert.equal(dashboard.ratios.readingSpeed[0]?.value, +(120 / 30).toFixed(1));
assert.equal(dashboard.ratios.readingSpeed[1]?.value, +(140 / 45).toFixed(1));
assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime'); assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime');
assert.equal(dashboard.animeCumulative.watchTime[1]?.value, 75); assert.equal(dashboard.animeCumulative.watchTime[1]?.value, 75);
assert.equal( assert.equal(
@@ -771,6 +777,84 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
} }
}); });
test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const rawStreamTitle =
'stream?static true&api key secret-token&MediaSourceId ms-1&AudioStreamIndex 3&SubtitleStreamIndex 4';
const videoId = getOrCreateVideoRecord(
db,
'remote:http://jellyfin.local/Videos/item-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4',
{
canonicalTitle: rawStreamTitle,
sourcePath: null,
sourceUrl:
'http://jellyfin.local/Videos/item-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4',
sourceType: SOURCE_TYPE_REMOTE,
},
);
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: rawStreamTitle,
canonicalTitle: rawStreamTitle,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename:
'stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4',
parsedTitle: rawStreamTitle,
parsedSeason: null,
parsedEpisode: null,
parserSource: 'guessit',
parserConfidence: 1,
parseMetadataJson: null,
});
const startedAtMs = 1_700_000_000_000;
const session = startSessionRecord(db, videoId, startedAtMs);
db.prepare(
`
UPDATE imm_sessions
SET
ended_at_ms = ?,
total_watched_ms = ?,
active_watched_ms = ?,
tokens_seen = ?
WHERE session_id = ?
`,
).run(`${startedAtMs + 30 * 60_000}`, 30 * 60_000, 30 * 60_000, 120, session.sessionId);
db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards
) VALUES (?, ?, ?, ?, ?, ?, ?)
`,
).run(Math.floor(startedAtMs / 86_400_000), videoId, 1, 30, 10, 120, 0);
const dashboard = getTrendsDashboard(db, 'all', 'day');
const titles = [
...dashboard.animeCumulative.watchTime.map((point) => point.animeTitle),
...dashboard.librarySummary.map((row) => row.title),
];
assert.deepEqual([...new Set(titles)], ['Jellyfin Video']);
assert.equal(titles.some((title) => title.includes('api_key=')), false);
assert.equal(titles.some((title) => title.includes('api key')), false);
assert.equal(titles.some((title) => title.includes('secret-token')), false);
assert.equal(titles.some((title) => title.includes('stream?')), false);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getTrendsDashboard keeps local-midnight session buckets separate', () => { test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
@@ -3686,6 +3770,187 @@ test('getWordOccurrences maps a normalized word back to anime, video, and subtit
} }
}); });
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Dungeon Meshi',
canonicalTitle: 'Dungeon Meshi',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: '{"source":"test"}',
});
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/dungeon-meshi-01.mkv', {
canonicalTitle: 'Episode 1',
sourcePath: '/tmp/Dungeon Meshi 01.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: 'Dungeon Meshi 01.mkv',
parsedTitle: 'Dungeon Meshi',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'fallback',
parserConfidence: 1,
parseMetadataJson: '{"episode":1}',
});
const { sessionId } = startSessionRecord(db, videoId, 3_000_000);
db.prepare(
`INSERT INTO imm_subtitle_lines (
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
sessionId,
null,
videoId,
animeId,
7,
4_000,
5_500,
'魔物を食べるなんて信じられない',
'I cannot believe we are eating monsters',
3_000,
3_000,
);
db.prepare(
`INSERT INTO imm_subtitle_lines (
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
sessionId,
null,
videoId,
animeId,
8,
6_000,
7_000,
'これは別の行です',
'Another line',
2_000,
2_000,
);
const rows = searchSubtitleSentences(db, '魔物 食べる', 10);
assert.deepEqual(rows, [
{
animeId,
animeTitle: 'Dungeon Meshi',
sourcePath: '/tmp/Dungeon Meshi 01.mkv',
secondaryText: 'I cannot believe we are eating monsters',
videoId,
videoTitle: 'Episode 1',
sessionId,
lineIndex: 7,
segmentStartMs: 4_000,
segmentEndMs: 5_500,
text: '魔物を食べるなんて信じられない',
},
]);
assert.deepEqual(searchSubtitleSentences(db, 'monsters', 10), []);
assert.doesNotThrow(() => searchSubtitleSentences(db, '魔物', Number.POSITIVE_INFINITY));
assert.equal(searchSubtitleSentences(db, '魔物', -1).length, 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('searchSubtitleSentences searches subtitle lines by resolved headword candidates', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Little Witch Academia',
canonicalTitle: 'Little Witch Academia',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: '{"source":"test"}',
});
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/lwa-05.mkv', {
canonicalTitle: 'Episode 5',
sourcePath: '/tmp/Little Witch Academia S01E05.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: 'Little Witch Academia S01E05.mkv',
parsedTitle: 'Little Witch Academia',
parsedSeason: 1,
parsedEpisode: 5,
parserSource: 'fallback',
parserConfidence: 1,
parseMetadataJson: '{"episode":5}',
});
const { sessionId } = startSessionRecord(db, videoId, 4_000_000);
const lineResult = db
.prepare(
`INSERT INTO imm_subtitle_lines (
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
sessionId,
null,
videoId,
animeId,
20,
247_000,
250_000,
'ああ、名無しが何だか知らねえが',
null,
4_000,
4_000,
);
const wordResult = db
.prepare(
`INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run('知る', '知らねえ', 'しらねえ', 'verb', '動詞', '自立', '', 4_000, 4_000, 1);
db.prepare(
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count)
VALUES (?, ?, ?)`,
).run(Number(lineResult.lastInsertRowid), Number(wordResult.lastInsertRowid), 1);
assert.deepEqual(searchSubtitleSentences(db, '知らない', 10), []);
const rows = searchSubtitleSentences(db, '知らない', 10, {
headwordTerms: [{ term: '知らない', headwords: ['知る'] }],
});
assert.deepEqual(
rows.map((row) => row.text),
['ああ、名無しが何だか知らねえが'],
);
assert.deepEqual(
searchSubtitleSentences(db, '知らねえ', 10).map((row) => row.text),
['ああ、名無しが何だか知らねえが'],
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => { test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
@@ -4100,8 +4365,14 @@ test('deleteSession removes zero-session media from library and trends', () => {
const startedAtMs = 9_000_000; const startedAtMs = 9_000_000;
const endedAtMs = startedAtMs + 120_000; const endedAtMs = startedAtMs + 120_000;
const rollupDay = Math.floor(startedAtMs / 86_400_000); const rollupDay = getLocalEpochDay(db, startedAtMs);
const rollupMonth = 197001; 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;
const { sessionId } = startSessionRecord(db, videoId, startedAtMs); const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
db.prepare( db.prepare(
@@ -0,0 +1,330 @@
import type { DatabaseSync } from './sqlite';
import { getOrCreateAnimeRecord } from './storage';
import { toDbTimestamp } from './query-shared';
import { nowMs } from './time';
export interface AnimeSeasonRepairSummary {
scanned: number;
repaired: number;
movedVideos: number;
deletedAnimeRows: number;
}
interface AnimeRow {
anime_id: number;
anilist_id: number | null;
title_romaji: string | null;
title_english: string | null;
title_native: string | null;
episodes_total: number | null;
description: string | null;
}
interface ParsedVideoRow {
video_id: number;
parsed_title: string | null;
parsed_season: number | null;
}
interface RedistributeOptions {
transferAnilistToAnimeId?: number | null;
transferLegacyAnilist?: boolean;
overwriteTargetAnilist?: boolean;
}
function emptySummary(scanned = 0): AnimeSeasonRepairSummary {
return {
scanned,
repaired: 0,
movedVideos: 0,
deletedAnimeRows: 0,
};
}
function mergeSummary(
target: AnimeSeasonRepairSummary,
source: AnimeSeasonRepairSummary,
): AnimeSeasonRepairSummary {
target.scanned += source.scanned;
target.repaired += source.repaired;
target.movedVideos += source.movedVideos;
target.deletedAnimeRows += source.deletedAnimeRows;
return target;
}
function runInTransaction<T>(db: DatabaseSync, work: () => T): T {
db.exec('BEGIN');
try {
const result = work();
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
function normalizeSeason(value: number | null): number | null {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
return null;
}
return value;
}
function getAnimeRow(db: DatabaseSync, animeId: number): AnimeRow | null {
return db
.prepare(
`
SELECT
anime_id,
anilist_id,
title_romaji,
title_english,
title_native,
episodes_total,
description
FROM imm_anime
WHERE anime_id = ?
`,
)
.get(animeId) as AnimeRow | null;
}
function getParsedVideos(db: DatabaseSync, animeId: number): ParsedVideoRow[] {
return db
.prepare(
`
SELECT video_id, parsed_title, parsed_season
FROM imm_videos
WHERE anime_id = ?
ORDER BY video_id ASC
`,
)
.all(animeId) as ParsedVideoRow[];
}
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);
}
function assignAnilistToTarget(
db: DatabaseSync,
source: AnimeRow,
targetAnimeId: number,
overwriteTarget: boolean,
updatedAt: string,
): boolean {
if (source.anilist_id === null || targetAnimeId === source.anime_id) {
return false;
}
const target = getAnimeRow(db, targetAnimeId);
if (!target) {
return false;
}
if (!overwriteTarget && target.anilist_id !== null && target.anilist_id !== source.anilist_id) {
return false;
}
db.prepare(
`
UPDATE imm_anime
SET anilist_id = NULL,
LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
).run(updatedAt, source.anime_id);
const updated = db
.prepare(
`
UPDATE imm_anime
SET
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,
) as { changes: number };
return updated.changes > 0;
}
function redistributeAnimeRowByParsedSeasonsInTransaction(
db: DatabaseSync,
animeId: number,
options: RedistributeOptions = {},
): AnimeSeasonRepairSummary {
const source = getAnimeRow(db, animeId);
if (!source) {
return emptySummary(1);
}
const videos = getParsedVideos(db, animeId);
const summary = emptySummary(1);
const updatedAt = toDbTimestamp(nowMs());
const targetBySeason = new Map<number, number>();
for (const video of videos) {
const parsedTitle = video.parsed_title?.trim();
const season = normalizeSeason(video.parsed_season);
if (!parsedTitle || season === null) {
continue;
}
const targetAnimeId = getOrCreateAnimeRecord(db, {
parsedTitle,
canonicalTitle: parsedTitle,
seasonScope: season,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
targetBySeason.set(season, targetAnimeId);
if (targetAnimeId === animeId) {
continue;
}
const videoUpdate = db
.prepare(
`
UPDATE imm_videos
SET anime_id = ?,
LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
)
.run(targetAnimeId, updatedAt, video.video_id) as { changes: number };
const lineUpdate = db
.prepare(
`
UPDATE imm_subtitle_lines
SET anime_id = ?,
LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
)
.run(targetAnimeId, updatedAt, video.video_id) as { changes: number };
if (videoUpdate.changes > 0 || lineUpdate.changes > 0) {
summary.movedVideos += 1;
}
}
const transferTarget =
options.transferAnilistToAnimeId ??
(options.transferLegacyAnilist
? (targetBySeason.get(1) ??
(targetBySeason.size === 1 ? [...targetBySeason.values()][0] : null))
: null);
if (transferTarget) {
const transferred = assignAnilistToTarget(
db,
source,
transferTarget,
options.overwriteTargetAnilist ?? false,
updatedAt,
);
if (transferred) {
summary.repaired += 1;
}
}
if (!hasAnimeReferences(db, animeId)) {
const deleted = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?').run(animeId) as {
changes: number;
};
if (deleted.changes > 0) {
summary.deletedAnimeRows += 1;
}
}
if (summary.movedVideos > 0 || summary.deletedAnimeRows > 0) {
summary.repaired += 1;
}
return summary;
}
export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRepairSummary {
return runInTransaction(db, () => {
const candidates = db
.prepare(
`
SELECT a.anime_id AS animeId
FROM imm_anime a
JOIN imm_videos v ON v.anime_id = a.anime_id
WHERE v.parsed_title IS NOT NULL
AND TRIM(v.parsed_title) != ''
AND v.parsed_season IS NOT NULL
AND v.parsed_season > 0
GROUP BY a.anime_id
HAVING COUNT(DISTINCT v.parsed_season) > 1
ORDER BY a.anime_id ASC
`,
)
.all() as Array<{ animeId: number }>;
const summary = emptySummary();
for (const candidate of candidates) {
mergeSummary(
summary,
redistributeAnimeRowByParsedSeasonsInTransaction(db, candidate.animeId, {
transferLegacyAnilist: true,
}),
);
}
return summary;
});
}
export function resolveAnimeAnilistConflict(
db: DatabaseSync,
targetAnimeId: number,
anilistId: number,
): AnimeSeasonRepairSummary {
const conflict = db
.prepare(
`
SELECT anime_id AS animeId
FROM imm_anime
WHERE anilist_id = ?
AND anime_id != ?
LIMIT 1
`,
)
.get(anilistId, targetAnimeId) as { animeId: number } | null;
if (!conflict) {
return emptySummary();
}
return runInTransaction(db, () =>
redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
transferAnilistToAnimeId: targetAnimeId,
overwriteTargetAnilist: true,
}),
);
}
@@ -0,0 +1,413 @@
import type { DatabaseSync } from './sqlite';
import { normalizeText } from './reducer';
import { normalizeAnimeIdentityKey } from './storage';
import { nowMs } from './time';
import { toDbTimestamp } from './query-shared';
import type { JellyfinLinkRepairSummary } from './types';
type LegacyJellyfinVideoRow = {
video_id: number;
video_key: string;
source_url: string | null;
canonical_title: string;
};
type JellyfinTargetVideoRow = {
video_id: number;
anime_id: number | null;
canonical_title: string;
parsed_basename: string | null;
parsed_title: string | null;
parsed_season: number | null;
parsed_episode: number | null;
parser_source: string | null;
parser_confidence: number | null;
parse_metadata_json: string | null;
};
type LeakedAnimeTitleRow = {
anime_id: number;
canonical_title: string;
normalized_title_key: string;
title_romaji: string | null;
title_english: string | null;
title_native: string | null;
linked_video_title: string | null;
};
function looksLikeLeakedJellyfinTitle(value: string | null): boolean {
if (!value) return false;
const lowered = value.toLowerCase();
const hasApiKey = /api[\s_-]*key(?:\s|=|$)/i.test(value);
return (
hasApiKey &&
(lowered.includes('stream?') ||
lowered.includes('/stream?') ||
lowered.includes('/videos/') ||
lowered.includes('mediasourceid'))
);
}
function chooseSafeAnimeTitle(row: LeakedAnimeTitleRow): string | null {
const candidates = [
row.title_english,
row.title_romaji,
row.title_native,
row.linked_video_title?.replace(/^\[Jellyfin\/direct]\s*/i, ''),
];
for (const candidate of candidates) {
const normalized = candidate?.trim();
if (normalized && !looksLikeLeakedJellyfinTitle(normalized)) {
return normalized;
}
}
return null;
}
function parseLegacyJellyfinStreamUrl(value: string | null): URL | null {
if (!value) return null;
const trimmed = value.trim();
const urlText = trimmed.startsWith('remote:') ? trimmed.slice('remote:'.length) : trimmed;
try {
const url = new URL(urlText);
const pathSegments = url.pathname.split('/').filter(Boolean);
const videosIndex = pathSegments.findIndex((segment) => segment.toLowerCase() === 'videos');
if (
videosIndex < 0 ||
pathSegments[videosIndex + 1] === undefined ||
pathSegments[videosIndex + 2]?.toLowerCase() !== 'stream'
) {
return null;
}
if (!url.searchParams.has('api_key')) {
return null;
}
return url;
} catch {
return null;
}
}
function buildJellyfinStatsUrlFromLegacyStream(url: URL): string | null {
const pathSegments = url.pathname.split('/').filter(Boolean);
const videosIndex = pathSegments.findIndex((segment) => segment.toLowerCase() === 'videos');
const itemId = normalizeText(pathSegments[videosIndex + 1]);
if (!itemId) return null;
return `jellyfin://${url.host}/item/${encodeURIComponent(itemId)}`;
}
function buildSanitizedJellyfinVideoKey(
db: DatabaseSync,
videoId: number,
statsUrl: string,
): string {
const baseKey = `remote:${statsUrl}`;
const existing = db
.prepare('SELECT video_id FROM imm_videos WHERE video_key = ?')
.get(baseKey) as { video_id: number } | null;
if (!existing || existing.video_id === videoId) {
return baseKey;
}
return `${baseKey}#legacy-${videoId}`;
}
function repairLeakedJellyfinAnimeTitles(db: DatabaseSync, currentTimestamp: string): number {
const candidates = (
db
.prepare(
`
SELECT
a.anime_id,
a.normalized_title_key,
a.canonical_title,
a.title_romaji,
a.title_english,
a.title_native,
(
SELECT v.canonical_title
FROM imm_videos v
WHERE v.anime_id = a.anime_id
AND v.canonical_title NOT LIKE '%api_key=%'
AND lower(v.canonical_title) NOT LIKE '%api key%'
ORDER BY v.LAST_UPDATE_DATE DESC, v.video_id DESC
LIMIT 1
) AS linked_video_title
FROM imm_anime a
WHERE a.canonical_title LIKE '%api_key=%'
OR lower(a.canonical_title) LIKE '%api key%'
OR lower(a.normalized_title_key) LIKE '%api key%'
`,
)
.all() as LeakedAnimeTitleRow[]
).filter(
(row) =>
looksLikeLeakedJellyfinTitle(row.canonical_title) ||
looksLikeLeakedJellyfinTitle(row.normalized_title_key),
);
let repaired = 0;
for (const candidate of candidates) {
const replacementTitle = chooseSafeAnimeTitle(candidate);
if (!replacementTitle) {
continue;
}
const replacementKey = normalizeAnimeIdentityKey(replacementTitle);
if (!replacementKey) {
continue;
}
const existing = db
.prepare(
`
SELECT anime_id
FROM imm_anime
WHERE normalized_title_key = ?
AND anime_id != ?
`,
)
.get(replacementKey, candidate.anime_id) as { anime_id: number } | null;
if (existing) {
const videoUpdate = db
.prepare(
`
UPDATE imm_videos
SET anime_id = ?, LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
)
.run(existing.anime_id, currentTimestamp, candidate.anime_id) as { changes: number };
const subtitleUpdate = db
.prepare(
`
UPDATE imm_subtitle_lines
SET anime_id = ?, LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
)
.run(existing.anime_id, currentTimestamp, candidate.anime_id) as { changes: number };
const animeDelete = db
.prepare(
`
DELETE FROM imm_anime
WHERE anime_id = ?
AND NOT EXISTS (SELECT 1 FROM imm_videos WHERE anime_id = ?)
AND NOT EXISTS (SELECT 1 FROM imm_subtitle_lines WHERE anime_id = ?)
`,
)
.run(candidate.anime_id, candidate.anime_id, candidate.anime_id) as { changes: number };
if (videoUpdate.changes > 0 || subtitleUpdate.changes > 0) {
repaired += 1;
} else if (animeDelete.changes > 0) {
repaired += 1;
}
continue;
}
const updated = db
.prepare(
`
UPDATE imm_anime
SET
normalized_title_key = ?,
canonical_title = ?,
LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
)
.run(replacementKey, replacementTitle, currentTimestamp, candidate.anime_id) as {
changes: number;
};
if (updated.changes > 0) {
repaired += 1;
}
}
return repaired;
}
function repairLeakedJellyfinVideoParseMetadata(
db: DatabaseSync,
currentTimestamp: string,
): number {
const updated = db
.prepare(
`
UPDATE imm_videos
SET
parsed_basename = NULL,
parsed_title = NULL,
parse_metadata_json = NULL,
parser_source = CASE
WHEN parser_source = 'guessit' THEN 'jellyfin'
ELSE parser_source
END,
LAST_UPDATE_DATE = ?
WHERE source_type = 2
AND (
parsed_basename LIKE '%api_key=%'
OR lower(parsed_basename) LIKE '%api key%'
OR parsed_title LIKE '%api_key=%'
OR lower(parsed_title) LIKE '%api key%'
OR parse_metadata_json LIKE '%api_key=%'
OR lower(parse_metadata_json) LIKE '%api key%'
)
`,
)
.run(currentTimestamp) as { changes: number };
return updated.changes;
}
export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRepairSummary {
const candidates = db
.prepare(
`
SELECT video_id, video_key, source_url, canonical_title
FROM imm_videos
WHERE source_type = 2
AND (
video_key LIKE '%api_key=%'
OR lower(video_key) LIKE '%api key%'
OR source_url LIKE '%api_key=%'
OR lower(source_url) LIKE '%api key%'
OR canonical_title LIKE '%api_key=%'
OR lower(canonical_title) LIKE '%api key%'
)
`,
)
.all() as LegacyJellyfinVideoRow[];
const summary: JellyfinLinkRepairSummary = {
scanned: candidates.length,
repaired: 0,
};
if (candidates.length === 0) {
const currentTimestamp = toDbTimestamp(nowMs());
const repaired =
repairLeakedJellyfinAnimeTitles(db, currentTimestamp) +
repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp);
summary.repaired += repaired;
return summary;
}
const currentTimestamp = toDbTimestamp(nowMs());
db.exec('BEGIN IMMEDIATE');
try {
for (const candidate of candidates) {
const legacyUrl =
parseLegacyJellyfinStreamUrl(candidate.source_url) ??
parseLegacyJellyfinStreamUrl(candidate.video_key);
if (!legacyUrl) {
continue;
}
const statsUrl = buildJellyfinStatsUrlFromLegacyStream(legacyUrl);
if (!statsUrl) {
continue;
}
const sanitizedVideoKey = buildSanitizedJellyfinVideoKey(db, candidate.video_id, statsUrl);
const sanitizedCanonicalTitle = looksLikeLeakedJellyfinTitle(candidate.canonical_title)
? 'Jellyfin Video'
: candidate.canonical_title;
const target = db
.prepare(
`
SELECT
video_id,
anime_id,
canonical_title,
parsed_basename,
parsed_title,
parsed_season,
parsed_episode,
parser_source,
parser_confidence,
parse_metadata_json
FROM imm_videos
WHERE video_id != ?
AND (video_key = ? OR source_url = ?)
ORDER BY parser_source = 'jellyfin' DESC, video_id DESC
LIMIT 1
`,
)
.get(candidate.video_id, `remote:${statsUrl}`, statsUrl) as JellyfinTargetVideoRow | null;
if (!target) {
const updated = db
.prepare(
`
UPDATE imm_videos
SET
video_key = ?,
source_url = ?,
canonical_title = ?,
parser_source = COALESCE(parser_source, 'jellyfin'),
LAST_UPDATE_DATE = ?
WHERE video_id = ?
AND (video_key != ? OR source_url != ? OR canonical_title != ?)
`,
)
.run(
sanitizedVideoKey,
statsUrl,
sanitizedCanonicalTitle,
currentTimestamp,
candidate.video_id,
sanitizedVideoKey,
statsUrl,
sanitizedCanonicalTitle,
) as { changes: number };
if (updated.changes > 0) {
summary.repaired += 1;
}
continue;
}
db.prepare(
`
UPDATE imm_videos
SET
video_key = ?,
anime_id = ?,
canonical_title = ?,
source_url = ?,
parsed_basename = ?,
parsed_title = ?,
parsed_season = ?,
parsed_episode = ?,
parser_source = ?,
parser_confidence = ?,
parse_metadata_json = ?,
LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
).run(
sanitizedVideoKey,
target.anime_id,
target.canonical_title,
statsUrl,
target.parsed_basename,
target.parsed_title,
target.parsed_season,
target.parsed_episode,
target.parser_source,
target.parser_confidence,
target.parse_metadata_json,
currentTimestamp,
candidate.video_id,
);
if (target.anime_id !== null) {
db.prepare(
`
UPDATE imm_subtitle_lines
SET anime_id = ?, LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
).run(target.anime_id, currentTimestamp, candidate.video_id);
}
summary.repaired += 1;
}
summary.repaired += repairLeakedJellyfinAnimeTitles(db, currentTimestamp);
summary.repaired += repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
return summary;
}
+168 -44
View File
@@ -60,6 +60,34 @@ interface RetainedSessionRow {
mediaBufferEvents: number; mediaBufferEvents: number;
} }
const RETAINED_SESSION_METRICS_CTE = `
retained_sessions AS (
SELECT
s.session_id,
s.video_id,
v.anime_id,
s.started_at_ms,
s.ended_at_ms,
MAX(COALESCE(t.active_watched_ms, s.active_watched_ms, 0), 0) AS active_ms,
MAX(COALESCE(t.cards_mined, s.cards_mined, 0), 0) AS cards_mined,
MAX(COALESCE(t.lines_seen, s.lines_seen, 0), 0) AS lines_seen,
MAX(COALESCE(t.tokens_seen, s.tokens_seen, 0), 0) AS tokens_seen,
CASE WHEN v.watched > 0 THEN 1 ELSE 0 END AS completed
FROM imm_sessions s
JOIN imm_videos v
ON v.video_id = s.video_id
LEFT JOIN imm_session_telemetry t
ON t.telemetry_id = (
SELECT telemetry_id
FROM imm_session_telemetry
WHERE session_id = s.session_id
ORDER BY sample_ms DESC, telemetry_id DESC
LIMIT 1
)
WHERE s.ended_at_ms IS NOT NULL
)
`;
function hasRetainedPriorSession( function hasRetainedPriorSession(
db: DatabaseSync, db: DatabaseSync,
videoId: number, videoId: number,
@@ -154,54 +182,150 @@ function rebuildLifetimeSummariesInternal(
db: DatabaseSync, db: DatabaseSync,
rebuiltAtMs: number, rebuiltAtMs: number,
): LifetimeRebuildSummary { ): LifetimeRebuildSummary {
const rows = db const rebuiltAtDbMs = toDbTimestamp(rebuiltAtMs);
.prepare( const appliedSessions = Number(
` (
SELECT db
session_id AS sessionId, .prepare('SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NOT NULL')
video_id AS videoId, .get() as { total: number }
started_at_ms AS startedAtMs, ).total,
ended_at_ms AS endedAtMs, );
ended_media_ms AS lastMediaMs,
total_watched_ms AS totalWatchedMs,
active_watched_ms AS activeWatchedMs,
lines_seen AS linesSeen,
tokens_seen AS tokensSeen,
cards_mined AS cardsMined,
lookup_count AS lookupCount,
lookup_hits AS lookupHits,
yomitan_lookup_count AS yomitanLookupCount,
pause_count AS pauseCount,
pause_ms AS pauseMs,
seek_forward_count AS seekForwardCount,
seek_backward_count AS seekBackwardCount,
media_buffer_events AS mediaBufferEvents
FROM imm_sessions
WHERE ended_at_ms IS NOT NULL
ORDER BY started_at_ms ASC, session_id ASC
`,
)
.all() as Array<
Omit<RetainedSessionRow, 'startedAtMs' | 'endedAtMs' | 'lastMediaMs'> & {
startedAtMs: number | string;
endedAtMs: number | string;
lastMediaMs: number | string | null;
}
>;
const sessions = rows.map((row) => ({
...row,
startedAtMs: row.startedAtMs,
endedAtMs: row.endedAtMs,
lastMediaMs: row.lastMediaMs === null ? null : Number(row.lastMediaMs),
})) as RetainedSessionRow[];
resetLifetimeSummaries(db, rebuiltAtMs); resetLifetimeSummaries(db, rebuiltAtMs);
for (const session of sessions) {
applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs); db.prepare(
} `
INSERT INTO imm_lifetime_applied_sessions (
session_id,
applied_at_ms,
CREATED_DATE,
LAST_UPDATE_DATE
)
SELECT
session_id,
ended_at_ms,
?,
?
FROM imm_sessions
WHERE ended_at_ms IS NOT NULL
`,
).run(rebuiltAtDbMs, rebuiltAtDbMs);
db.prepare(
`
WITH ${RETAINED_SESSION_METRICS_CTE}
INSERT INTO imm_lifetime_media (
video_id,
total_sessions,
total_active_ms,
total_cards,
total_lines_seen,
total_tokens_seen,
completed,
first_watched_ms,
last_watched_ms,
CREATED_DATE,
LAST_UPDATE_DATE
)
SELECT
video_id,
COUNT(*) AS total_sessions,
COALESCE(SUM(active_ms), 0) AS total_active_ms,
COALESCE(SUM(cards_mined), 0) AS total_cards,
COALESCE(SUM(lines_seen), 0) AS total_lines_seen,
COALESCE(SUM(tokens_seen), 0) AS total_tokens_seen,
MAX(completed) AS completed,
MIN(started_at_ms) AS first_watched_ms,
MAX(ended_at_ms) AS last_watched_ms,
? AS CREATED_DATE,
? AS LAST_UPDATE_DATE
FROM retained_sessions
GROUP BY video_id
`,
).run(rebuiltAtDbMs, rebuiltAtDbMs);
db.prepare(
`
WITH ${RETAINED_SESSION_METRICS_CTE}
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
anime_id,
COUNT(*) AS total_sessions,
COALESCE(SUM(active_ms), 0) AS total_active_ms,
COALESCE(SUM(cards_mined), 0) AS total_cards,
COALESCE(SUM(lines_seen), 0) AS total_lines_seen,
COALESCE(SUM(tokens_seen), 0) AS total_tokens_seen,
COUNT(DISTINCT video_id) AS episodes_started,
COUNT(DISTINCT CASE WHEN completed > 0 THEN video_id END) AS episodes_completed,
MIN(started_at_ms) AS first_watched_ms,
MAX(ended_at_ms) AS last_watched_ms,
? AS CREATED_DATE,
? AS LAST_UPDATE_DATE
FROM retained_sessions
WHERE anime_id IS NOT NULL
GROUP BY anime_id
`,
).run(rebuiltAtDbMs, rebuiltAtDbMs);
db.prepare(
`
WITH ${RETAINED_SESSION_METRICS_CTE},
anime_completion AS (
SELECT
rs.anime_id,
MAX(a.episodes_total) AS episodes_total,
COUNT(DISTINCT CASE WHEN rs.completed > 0 THEN rs.video_id END) AS completed_videos
FROM retained_sessions rs
JOIN imm_anime a
ON a.anime_id = rs.anime_id
WHERE rs.anime_id IS NOT NULL
GROUP BY rs.anime_id
)
UPDATE imm_lifetime_global
SET
total_sessions = (SELECT COUNT(*) FROM retained_sessions),
total_active_ms = (SELECT COALESCE(SUM(active_ms), 0) FROM retained_sessions),
total_cards = (SELECT COALESCE(SUM(cards_mined), 0) FROM retained_sessions),
active_days = (
SELECT COUNT(DISTINCT CAST(
julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
AS INTEGER
))
FROM retained_sessions
),
episodes_started = (SELECT COUNT(DISTINCT video_id) FROM retained_sessions),
episodes_completed = (
SELECT COUNT(DISTINCT CASE WHEN completed > 0 THEN video_id END)
FROM retained_sessions
),
anime_completed = (
SELECT COUNT(*)
FROM anime_completion
WHERE episodes_total IS NOT NULL
AND episodes_total > 0
AND completed_videos >= episodes_total
),
last_rebuilt_ms = ?,
LAST_UPDATE_DATE = ?
WHERE global_id = 1
`,
).run(rebuiltAtDbMs, rebuiltAtDbMs);
return { return {
appliedSessions: sessions.length, appliedSessions,
rebuiltAtMs, rebuiltAtMs,
}; };
} }
@@ -1,6 +1,6 @@
import type { DatabaseSync } from './sqlite'; import type { DatabaseSync } from './sqlite';
import { nowMs } from './time'; import { nowMs } from './time';
import { subtractDbTimestamp, toDbTimestamp } from './query-shared'; import { makePlaceholders, subtractDbTimestamp, toDbTimestamp } from './query-shared';
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms'; const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
const DAILY_MS = 86_400_000; const DAILY_MS = 86_400_000;
@@ -20,6 +20,12 @@ interface RollupTelemetryResult {
maxSampleMs: number | null; maxSampleMs: number | null;
} }
export interface RollupGroup {
rollupDay: number;
rollupMonth: number;
videoId: number;
}
interface RawRetentionResult { interface RawRetentionResult {
deletedSessionEvents: number; deletedSessionEvents: number;
deletedTelemetryRows: number; deletedTelemetryRows: number;
@@ -164,6 +170,26 @@ function upsertDailyRollupsForGroups(
} }
const upsertStmt = db.prepare(` const upsertStmt = db.prepare(`
WITH matching_sessions AS (
SELECT *
FROM imm_sessions
WHERE CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ?
AND video_id = ?
),
session_metrics AS (
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
MAX(t.lines_seen) AS max_lines,
MAX(t.tokens_seen) AS max_tokens,
MAX(t.cards_mined) AS max_cards,
MAX(t.lookup_count) AS max_lookups,
MAX(t.lookup_hits) AS max_hits
FROM imm_session_telemetry t
JOIN matching_sessions s
ON s.session_id = t.session_id
GROUP BY t.session_id
)
INSERT INTO imm_daily_rollups ( INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, cards_per_hour, total_tokens_seen, total_cards, cards_per_hour,
@@ -197,20 +223,8 @@ function upsertDailyRollupsForGroups(
END AS lookup_hit_rate, END AS lookup_hit_rate,
? AS CREATED_DATE, ? AS CREATED_DATE,
? AS LAST_UPDATE_DATE ? AS LAST_UPDATE_DATE
FROM imm_sessions s FROM matching_sessions s
LEFT JOIN ( LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
MAX(t.lines_seen) AS max_lines,
MAX(t.tokens_seen) AS max_tokens,
MAX(t.cards_mined) AS max_cards,
MAX(t.lookup_count) AS max_lookups,
MAX(t.lookup_hits) AS max_hits
FROM imm_session_telemetry t
GROUP BY t.session_id
) sm ON s.session_id = sm.session_id
WHERE CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ? AND s.video_id = ?
GROUP BY rollup_day, s.video_id GROUP BY rollup_day, s.video_id
ON CONFLICT (rollup_day, video_id) DO UPDATE SET ON CONFLICT (rollup_day, video_id) DO UPDATE SET
total_sessions = excluded.total_sessions, total_sessions = excluded.total_sessions,
@@ -226,7 +240,7 @@ function upsertDailyRollupsForGroups(
`); `);
for (const { rollupDay, videoId } of groups) { for (const { rollupDay, videoId } of groups) {
upsertStmt.run(rollupNowMs, rollupNowMs, rollupDay, videoId); upsertStmt.run(rollupDay, videoId, rollupNowMs, rollupNowMs);
} }
} }
@@ -240,6 +254,24 @@ function upsertMonthlyRollupsForGroups(
} }
const upsertStmt = db.prepare(` const upsertStmt = db.prepare(`
WITH matching_sessions AS (
SELECT *
FROM imm_sessions
WHERE CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) = ?
AND video_id = ?
),
session_metrics AS (
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
MAX(t.lines_seen) AS max_lines,
MAX(t.tokens_seen) AS max_tokens,
MAX(t.cards_mined) AS max_cards
FROM imm_session_telemetry t
JOIN matching_sessions s
ON s.session_id = t.session_id
GROUP BY t.session_id
)
INSERT INTO imm_monthly_rollups ( INSERT INTO imm_monthly_rollups (
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen, rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
@@ -254,18 +286,8 @@ function upsertMonthlyRollupsForGroups(
COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) AS total_cards, COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) AS total_cards,
? AS CREATED_DATE, ? AS CREATED_DATE,
? AS LAST_UPDATE_DATE ? AS LAST_UPDATE_DATE
FROM imm_sessions s FROM matching_sessions s
LEFT JOIN ( LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
MAX(t.lines_seen) AS max_lines,
MAX(t.tokens_seen) AS max_tokens,
MAX(t.cards_mined) AS max_cards
FROM imm_session_telemetry t
GROUP BY t.session_id
) sm ON s.session_id = sm.session_id
WHERE CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER) = ? AND s.video_id = ?
GROUP BY rollup_month, s.video_id GROUP BY rollup_month, s.video_id
ON CONFLICT (rollup_month, video_id) DO UPDATE SET ON CONFLICT (rollup_month, video_id) DO UPDATE SET
total_sessions = excluded.total_sessions, total_sessions = excluded.total_sessions,
@@ -278,10 +300,75 @@ function upsertMonthlyRollupsForGroups(
`); `);
for (const { rollupMonth, videoId } of groups) { for (const { rollupMonth, videoId } of groups) {
upsertStmt.run(rollupNowMs, rollupNowMs, rollupMonth, videoId); upsertStmt.run(rollupMonth, videoId, rollupNowMs, rollupNowMs);
} }
} }
export function getRollupGroupsForSessions(db: DatabaseSync, sessionIds: number[]): RollupGroup[] {
if (sessionIds.length === 0) {
return [];
}
const placeholders = makePlaceholders(sessionIds);
const rows = db
.prepare(
`
SELECT DISTINCT
CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS rollup_day,
CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) AS rollup_month,
video_id
FROM imm_sessions
WHERE session_id IN (${placeholders})
`,
)
.all(...sessionIds) as RollupGroupRow[];
return rows.map((row) => ({
rollupDay: row.rollup_day,
rollupMonth: row.rollup_month,
videoId: row.video_id,
}));
}
export function refreshRollupsForGroupsInTransaction(
db: DatabaseSync,
groups: RollupGroup[],
): void {
if (groups.length === 0) {
return;
}
const rollupNowMs = toDbTimestamp(nowMs());
const dailyGroups = dedupeGroups(
groups.map((group) => ({
rollupDay: group.rollupDay,
videoId: group.videoId,
})),
);
const monthlyGroups = dedupeGroups(
groups.map((group) => ({
rollupMonth: group.rollupMonth,
videoId: group.videoId,
})),
);
const deleteDailyStmt = db.prepare(
'DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?',
);
const deleteMonthlyStmt = db.prepare(
'DELETE FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ?',
);
for (const { rollupDay, videoId } of dailyGroups) {
deleteDailyStmt.run(rollupDay, videoId);
}
for (const { rollupMonth, videoId } of monthlyGroups) {
deleteMonthlyStmt.run(rollupMonth, videoId);
}
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
}
function getAffectedRollupGroups( function getAffectedRollupGroups(
db: DatabaseSync, db: DatabaseSync,
lastRollupSampleMs: number | string, lastRollupSampleMs: number | string,
@@ -179,6 +179,32 @@ test('guessAnimeVideoMetadata uses guessit basename output first when available'
}); });
}); });
test('guessAnimeVideoMetadata keeps season directory scope when guessit omits season', async () => {
const parsed = await guessAnimeVideoMetadata(
'/tmp/KonoSuba/Season 2/KonoSuba - 05.mkv',
'Episode 5',
{
runGuessit: async () =>
JSON.stringify({
title: 'KonoSuba',
}),
},
);
assert.deepEqual(parsed, {
parsedBasename: 'KonoSuba - 05.mkv',
parsedTitle: 'KonoSuba',
parsedSeason: 2,
parsedEpisode: null,
parserSource: 'guessit',
parserConfidence: 1,
parseMetadataJson: JSON.stringify({
filename: 'KonoSuba - 05.mkv',
source: 'guessit',
}),
});
});
test('guessAnimeVideoMetadata falls back to parser when guessit throws', async () => { test('guessAnimeVideoMetadata falls back to parser when guessit throws', async () => {
const parsed = await guessAnimeVideoMetadata( const parsed = await guessAnimeVideoMetadata(
'/tmp/Little Witch Academia S02E05.mkv', '/tmp/Little Witch Academia S02E05.mkv',
@@ -7,6 +7,8 @@ import type {
KanjiOccurrenceRow, KanjiOccurrenceRow,
KanjiStatsRow, KanjiStatsRow,
KanjiWordRow, KanjiWordRow,
SentenceSearchOptions,
SentenceSearchResultRow,
SessionEventRow, SessionEventRow,
SimilarWordRow, SimilarWordRow,
StatsExcludedWordRow, StatsExcludedWordRow,
@@ -20,6 +22,56 @@ import { nowMs } from './time';
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4; const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100; const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
const SENTENCE_SEARCH_DEFAULT_LIMIT = 50;
const SENTENCE_SEARCH_MAX_LIMIT = 100;
const KANJI_PATTERN = /\p{Script=Han}/gu;
function resolveSentenceSearchLimit(limit: number): number {
if (!Number.isFinite(limit)) return SENTENCE_SEARCH_DEFAULT_LIMIT;
const normalized = Math.floor(limit);
if (normalized <= 0) return SENTENCE_SEARCH_DEFAULT_LIMIT;
return Math.min(normalized, SENTENCE_SEARCH_MAX_LIMIT);
}
export function splitSentenceSearchTerms(query: string): string[] {
return query
.trim()
.split(/\s+/)
.map((term) => term.trim())
.filter(Boolean)
.slice(0, 8);
}
function escapeLikeTerm(term: string): string {
return term.replace(/[\\%_]/g, (match) => `\\${match}`);
}
function uniqueNonEmptyTerms(values: readonly string[] | undefined): string[] {
const seen = new Set<string>();
const terms: string[] = [];
for (const value of values ?? []) {
const term = value.trim();
if (!term || seen.has(term)) continue;
seen.add(term);
terms.push(term);
}
return terms;
}
function getHeadwordCandidatesForSentenceSearchTerm(
term: string,
options: SentenceSearchOptions | undefined,
): string[] {
const headwords =
options?.headwordTerms
?.filter((entry) => entry.term === term)
.flatMap((entry) => entry.headwords) ?? [];
return uniqueNonEmptyTerms(headwords);
}
function uniqueKanji(text: string): string[] {
return Array.from(new Set(text.match(KANJI_PATTERN) ?? []));
}
function toVocabularyToken(row: VocabularyStatsRow): MergedToken { function toVocabularyToken(row: VocabularyStatsRow): MergedToken {
const partOfSpeech = const partOfSpeech =
@@ -211,6 +263,70 @@ export function getKanjiOccurrences(
.all(kanji, limit, offset) as unknown as KanjiOccurrenceRow[]; .all(kanji, limit, offset) as unknown as KanjiOccurrenceRow[];
} }
export function searchSubtitleSentences(
db: DatabaseSync,
query: string,
limit = SENTENCE_SEARCH_DEFAULT_LIMIT,
options?: SentenceSearchOptions,
): SentenceSearchResultRow[] {
const terms = splitSentenceSearchTerms(query);
if (terms.length === 0) return [];
const resolvedLimit = resolveSentenceSearchLimit(limit);
const clauses: string[] = [];
const params: string[] = [];
for (const term of terms) {
const likeTerm = `%${escapeLikeTerm(term)}%`;
const headwords = getHeadwordCandidatesForSentenceSearchTerm(term, options);
const headwordClause =
headwords.length > 0
? `
OR EXISTS (
SELECT 1
FROM imm_word_line_occurrences o
JOIN imm_words w ON w.id = o.word_id
WHERE o.line_id = l.line_id
AND w.headword IN (${headwords.map(() => '?').join(', ')})
)
`
: '';
clauses.push(`
(
l.text LIKE ? ESCAPE '\\'
OR v.canonical_title LIKE ? ESCAPE '\\'
OR COALESCE(a.canonical_title, '') LIKE ? ESCAPE '\\'
${headwordClause}
)
`);
params.push(likeTerm, likeTerm, likeTerm, ...headwords);
}
return db
.prepare(
`
SELECT
l.anime_id AS animeId,
a.canonical_title AS animeTitle,
l.video_id AS videoId,
v.canonical_title AS videoTitle,
v.source_path AS sourcePath,
l.secondary_text AS secondaryText,
l.session_id AS sessionId,
l.line_index AS lineIndex,
l.segment_start_ms AS segmentStartMs,
l.segment_end_ms AS segmentEndMs,
l.text AS text
FROM imm_subtitle_lines l
JOIN imm_videos v ON v.video_id = l.video_id
LEFT JOIN imm_anime a ON a.anime_id = l.anime_id
WHERE ${clauses.join(' AND ')}
ORDER BY l.CREATED_DATE DESC, l.line_id DESC
LIMIT ?
`,
)
.all(...params, resolvedLimit) as unknown as SentenceSearchResultRow[];
}
export function getSessionEvents( export function getSessionEvents(
db: DatabaseSync, db: DatabaseSync,
sessionId: number, sessionId: number,
@@ -287,24 +403,38 @@ export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): S
reading: string; reading: string;
} | null; } | null;
if (!word || word.headword.trim() === '') return []; if (!word || word.headword.trim() === '') return [];
const clauses: string[] = [];
const params: string[] = [];
const reading = word.reading.trim();
if (reading !== '') {
clauses.push('reading = ?');
params.push(word.reading);
}
for (const kanji of uniqueKanji(word.headword)) {
clauses.push("headword LIKE ? ESCAPE '\\'");
params.push(`%${escapeLikeTerm(kanji)}%`);
}
if (clauses.length === 0) return [];
const orderBy =
reading !== '' ? 'CASE WHEN reading = ? THEN 0 ELSE 1 END, frequency DESC' : 'frequency DESC';
const orderParams = reading !== '' ? [word.reading] : [];
return db return db
.prepare( .prepare(
` `
SELECT id AS wordId, headword, word, reading, frequency SELECT id AS wordId, headword, word, reading, frequency
FROM imm_words FROM imm_words
WHERE id != ? WHERE id != ?
AND (reading = ? OR headword LIKE ? OR headword LIKE ?) AND (${clauses.join(' OR ')})
ORDER BY frequency DESC ORDER BY ${orderBy}
LIMIT ? LIMIT ?
`, `,
) )
.all( .all(wordId, ...params, ...orderParams, limit) as SimilarWordRow[];
wordId,
word.reading,
`%${word.headword.charAt(0)}%`,
`%${word.headword.charAt(word.headword.length - 1)}%`,
limit,
) as SimilarWordRow[];
} }
export function getKanjiDetail(db: DatabaseSync, kanjiId: number): KanjiDetailRow | null { export function getKanjiDetail(db: DatabaseSync, kanjiId: number): KanjiDetailRow | null {
@@ -1,9 +1,10 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import type { DatabaseSync } from './sqlite'; import type { DatabaseSync } from './sqlite';
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage'; import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
import { rebuildLifetimeSummariesInTransaction } from './lifetime'; import { rebuildLifetimeSummaries, rebuildLifetimeSummariesInTransaction } from './lifetime';
import { rebuildRollupsInTransaction } from './maintenance'; import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance';
import { nowMs } from './time'; import { nowMs } from './time';
import { resolveAnimeAnilistConflict } from './anime-season-repair';
import { PartOfSpeech, type MergedToken } from '../../../types'; import { PartOfSpeech, type MergedToken } from '../../../types';
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage'; import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech'; import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
@@ -425,6 +426,14 @@ export function updateAnimeAnilistInfo(
} | null; } | null;
if (!row?.anime_id) return; if (!row?.anime_id) return;
const repair = resolveAnimeAnilistConflict(db, row.anime_id, info.anilistId);
const targetRow = db
.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?')
.get(videoId) as {
anime_id: number | null;
} | null;
if (!targetRow?.anime_id) return;
db.prepare( db.prepare(
` `
UPDATE imm_anime UPDATE imm_anime
@@ -444,8 +453,11 @@ export function updateAnimeAnilistInfo(
info.titleNative, info.titleNative,
info.episodesTotal, info.episodesTotal,
toDbTimestamp(nowMs()), toDbTimestamp(nowMs()),
row.anime_id, targetRow.anime_id,
); );
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
rebuildLifetimeSummaries(db);
}
} }
export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void { export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void {
@@ -474,13 +486,14 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
const sessionIds = [sessionId]; const sessionIds = [sessionId];
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds); const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds); const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
db.exec('BEGIN IMMEDIATE'); db.exec('BEGIN IMMEDIATE');
try { try {
deleteSessionsByIds(db, sessionIds); deleteSessionsByIds(db, sessionIds);
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds); refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
rebuildLifetimeSummariesInTransaction(db); rebuildLifetimeSummariesInTransaction(db);
rebuildRollupsInTransaction(db); refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
db.exec('COMMIT'); db.exec('COMMIT');
} catch (error) { } catch (error) {
db.exec('ROLLBACK'); db.exec('ROLLBACK');
@@ -492,13 +505,14 @@ export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
if (sessionIds.length === 0) return; if (sessionIds.length === 0) return;
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds); const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds); const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
db.exec('BEGIN IMMEDIATE'); db.exec('BEGIN IMMEDIATE');
try { try {
deleteSessionsByIds(db, sessionIds); deleteSessionsByIds(db, sessionIds);
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds); refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
rebuildLifetimeSummariesInTransaction(db); rebuildLifetimeSummariesInTransaction(db);
rebuildRollupsInTransaction(db); refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
db.exec('COMMIT'); db.exec('COMMIT');
} catch (error) { } catch (error) {
db.exec('ROLLBACK'); db.exec('ROLLBACK');
@@ -536,7 +550,6 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void {
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId); db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds); refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
rebuildLifetimeSummariesInTransaction(db); rebuildLifetimeSummariesInTransaction(db);
rebuildRollupsInTransaction(db);
db.exec('COMMIT'); db.exec('COMMIT');
} catch (error) { } catch (error) {
db.exec('ROLLBACK'); db.exec('ROLLBACK');
@@ -74,6 +74,8 @@ export interface TrendsDashboardQueryResult {
}; };
ratios: { ratios: {
lookupsPerHundred: TrendChartPoint[]; lookupsPerHundred: TrendChartPoint[];
cardsPerHour: TrendChartPoint[];
readingSpeed: TrendChartPoint[];
}; };
animeCumulative: { animeCumulative: {
watchTime: TrendPerAnimePoint[]; watchTime: TrendPerAnimePoint[];
@@ -176,11 +178,31 @@ function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSe
return session.tokensSeen; return session.tokensSeen;
} }
function looksLikeJellyfinStreamTitle(title: string): boolean {
const lowered = title.toLowerCase();
const hasApiKey = /api[\s_-]*key(?:\s|=|$)/i.test(title);
return (
hasApiKey &&
(lowered.includes('stream?') ||
lowered.includes('/stream?') ||
lowered.includes('/videos/') ||
lowered.includes('mediasourceid'))
);
}
function sanitizeTrendTitle(title: string): string {
const normalized = title.trim();
if (!normalized) {
return 'Unknown';
}
return looksLikeJellyfinStreamTitle(normalized) ? 'Jellyfin Video' : normalized;
}
function resolveTrendAnimeTitle(value: { function resolveTrendAnimeTitle(value: {
animeTitle: string | null; animeTitle: string | null;
canonicalTitle: string | null; canonicalTitle: string | null;
}): string { }): string {
return value.animeTitle ?? value.canonicalTitle ?? 'Unknown'; return sanitizeTrendTitle(value.animeTitle ?? value.canonicalTitle ?? 'Unknown');
} }
function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] { function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
@@ -225,6 +247,26 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
})); }));
} }
function buildEfficiencyRates(rows: ReturnType<typeof buildAggregatedTrendRows>): {
cardsPerHour: TrendChartPoint[];
readingSpeed: TrendChartPoint[];
} {
const cardsPerHour: TrendChartPoint[] = [];
const readingSpeed: TrendChartPoint[] = [];
for (const row of rows) {
const hours = row.activeMin / 60;
cardsPerHour.push({
label: row.label,
value: hours > 0 ? +(row.cards / hours).toFixed(1) : 0,
});
readingSpeed.push({
label: row.label,
value: row.activeMin > 0 ? +(row.words / row.activeMin).toFixed(1) : 0,
});
}
return { cardsPerHour, readingSpeed };
}
function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] { function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
const totals = new Array(7).fill(0); const totals = new Array(7).fill(0);
for (const session of sessions) { for (const session of sessions) {
@@ -449,7 +491,7 @@ function getVideoAnimeTitleMap(
) )
.all(...uniqueIds) as Array<{ videoId: number; animeTitle: string }>; .all(...uniqueIds) as Array<{ videoId: number; animeTitle: string }>;
return new Map(rows.map((row) => [row.videoId, row.animeTitle])); return new Map(rows.map((row) => [row.videoId, sanitizeTrendTitle(row.animeTitle)]));
} }
function resolveVideoAnimeTitle( function resolveVideoAnimeTitle(
@@ -675,6 +717,7 @@ export function getTrendsDashboard(
); );
const aggregatedRows = buildAggregatedTrendRows(chartRollups); const aggregatedRows = buildAggregatedTrendRows(chartRollups);
const efficiency = buildEfficiencyRates(aggregatedRows);
const activity = { const activity = {
watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })), watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
cards: aggregatedRows.map((row) => ({ label: row.label, value: row.cards })), cards: aggregatedRows.map((row) => ({ label: row.label, value: row.cards })),
@@ -724,6 +767,8 @@ export function getTrendsDashboard(
}, },
ratios: { ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy), lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
cardsPerHour: efficiency.cardsPerHour,
readingSpeed: efficiency.readingSpeed,
}, },
animeCumulative: { animeCumulative: {
watchTime: buildCumulativePerAnime(animePerDay.watchTime), watchTime: buildCumulativePerAnime(animePerDay.watchTime),
@@ -813,7 +813,7 @@ test('ensureSchema migrates legacy videos and backfills anime metadata from file
.all() as Array<{ canonical_title: string }>; .all() as Array<{ canonical_title: string }>;
assert.deepEqual( assert.deepEqual(
animeRows.map((row) => row.canonical_title), animeRows.map((row) => row.canonical_title),
['Frieren', 'Little Witch Academia'], ['Frieren', 'Little Witch Academia Season 2'],
); );
const littleWitchRows = db const littleWitchRows = db
@@ -855,7 +855,7 @@ test('ensureSchema migrates legacy videos and backfills anime metadata from file
})), })),
[ [
{ {
animeTitle: 'Little Witch Academia', animeTitle: 'Little Witch Academia Season 2',
parsedTitle: 'Little Witch Academia', parsedTitle: 'Little Witch Academia',
parsedBasename: 'Little Witch Academia S02E05.mkv', parsedBasename: 'Little Witch Academia S02E05.mkv',
parsedSeason: 2, parsedSeason: 2,
@@ -863,7 +863,7 @@ test('ensureSchema migrates legacy videos and backfills anime metadata from file
parserSource: 'fallback', parserSource: 'fallback',
}, },
{ {
animeTitle: 'Little Witch Academia', animeTitle: 'Little Witch Academia Season 2',
parsedTitle: 'Little Witch Academia', parsedTitle: 'Little Witch Academia',
parsedBasename: 'Little Witch Academia S02E06.mkv', parsedBasename: 'Little Witch Academia S02E06.mkv',
parsedSeason: 2, parsedSeason: 2,
+35 -3
View File
@@ -23,6 +23,7 @@ export interface TrackerPreparedStatements {
export interface AnimeRecordInput { export interface AnimeRecordInput {
parsedTitle: string; parsedTitle: string;
canonicalTitle: string; canonicalTitle: string;
seasonScope?: number | null;
anilistId: number | null; anilistId: number | null;
titleRomaji: string | null; titleRomaji: string | null;
titleEnglish: string | null; titleEnglish: string | null;
@@ -300,6 +301,31 @@ export function normalizeAnimeIdentityKey(title: string): string {
.replace(/\s+/g, ' '); .replace(/\s+/g, ' ');
} }
function normalizeSeasonScope(value: number | null | undefined): number | null {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
return null;
}
return value;
}
function titleAlreadyHasSeasonScope(title: string, season: number): boolean {
const normalized = title.normalize('NFKC').toLowerCase();
const padded = String(season).padStart(2, '0');
return (
new RegExp(`\\bseason\\s*0?${season}\\b`, 'i').test(normalized) ||
new RegExp(`\\bs0?${season}\\b`, 'i').test(normalized) ||
new RegExp(`\\bs${padded}\\b`, 'i').test(normalized)
);
}
function buildSeasonScopedAnimeTitle(title: string, season: number | null): string {
const trimmed = title.trim();
if (!trimmed || season === null || titleAlreadyHasSeasonScope(trimmed, season)) {
return trimmed;
}
return `${trimmed} Season ${season}`;
}
function looksLikeEpisodeOnlyTitle(title: string): boolean { function looksLikeEpisodeOnlyTitle(title: string): boolean {
const normalized = title.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim(); const normalized = title.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
return /^(episode|ep)\s*\d{1,3}$/.test(normalized) || /^第\s*\d{1,3}\s*話$/.test(normalized); return /^(episode|ep)\s*\d{1,3}$/.test(normalized) || /^第\s*\d{1,3}\s*話$/.test(normalized);
@@ -478,7 +504,12 @@ function ensureStatsExcludedWordsTable(db: DatabaseSync): void {
} }
export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput): number { export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput): number {
const normalizedTitleKey = normalizeAnimeIdentityKey(input.parsedTitle); const seasonScope = normalizeSeasonScope(input.seasonScope);
const identityTitle = buildSeasonScopedAnimeTitle(input.parsedTitle, seasonScope);
const canonicalTitle =
buildSeasonScopedAnimeTitle(input.canonicalTitle || input.parsedTitle, seasonScope) ||
identityTitle;
const normalizedTitleKey = normalizeAnimeIdentityKey(identityTitle);
if (!normalizedTitleKey) { if (!normalizedTitleKey) {
throw new Error('parsedTitle is required to create or update an anime record'); throw new Error('parsedTitle is required to create or update an anime record');
} }
@@ -508,7 +539,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
WHERE anime_id = ? WHERE anime_id = ?
`, `,
).run( ).run(
input.canonicalTitle, canonicalTitle,
input.anilistId, input.anilistId,
input.titleRomaji, input.titleRomaji,
input.titleEnglish, input.titleEnglish,
@@ -539,7 +570,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
) )
.run( .run(
normalizedTitleKey, normalizedTitleKey,
input.canonicalTitle, canonicalTitle,
input.anilistId, input.anilistId,
input.titleRomaji, input.titleRomaji,
input.titleEnglish, input.titleEnglish,
@@ -648,6 +679,7 @@ function migrateLegacyAnimeMetadata(db: DatabaseSync): void {
const animeId = getOrCreateAnimeRecord(db, { const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: parsed.title, parsedTitle: parsed.title,
canonicalTitle: parsed.title, canonicalTitle: parsed.title,
seasonScope: parsed.season,
anilistId: null, anilistId: null,
titleRomaji: null, titleRomaji: null,
titleEnglish: null, titleEnglish: null,
@@ -52,6 +52,11 @@ export interface ImmersionTrackerPolicy {
}; };
} }
export interface JellyfinLinkRepairSummary {
scanned: number;
repaired: number;
}
export interface TelemetryAccumulator { export interface TelemetryAccumulator {
totalWatchedMs: number; totalWatchedMs: number;
activeWatchedMs: number; activeWatchedMs: number;
@@ -367,6 +372,29 @@ export interface KanjiOccurrenceRow {
occurrenceCount: number; occurrenceCount: number;
} }
export interface SentenceSearchResultRow {
animeId: number | null;
animeTitle: string | null;
videoId: number;
videoTitle: string;
sourcePath: string | null;
secondaryText: string | null;
sessionId: number;
lineIndex: number;
segmentStartMs: number | null;
segmentEndMs: number | null;
text: string;
}
export interface SentenceSearchHeadwordTerm {
term: string;
headwords: string[];
}
export interface SentenceSearchOptions {
headwordTerms?: SentenceSearchHeadwordTerm[];
}
export interface SessionEventRow { export interface SessionEventRow {
eventType: number; eventType: number;
tsMs: number; tsMs: number;
+21
View File
@@ -235,6 +235,27 @@ test('dispatchMpvProtocolMessage prefers the already selected matching secondary
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]); assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
}); });
test('dispatchMpvProtocolMessage skips signs and songs when choosing secondary subtitles', async () => {
const { deps, state } = createDeps({
getResolvedConfig: () => ({
secondarySub: { secondarySubLanguages: ['eng', 'en'] },
}),
});
await dispatchMpvProtocolMessage(
{
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
data: [
{ type: 'sub', id: 2, lang: 'eng', title: 'English Signs & Songs' },
{ type: 'sub', id: 3, lang: 'eng', title: 'English Dialogue' },
],
},
deps,
);
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
});
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => { test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
const { deps, state } = createDeps(); const { deps, state } = createDeps();
+14 -2
View File
@@ -149,6 +149,11 @@ function getSubtitleTrackIdentity(track: SubtitleTrackCandidate): string {
return `id:${track.id}`; return `id:${track.id}`;
} }
function isSignsOrSongsSubtitleTrack(track: SubtitleTrackCandidate): boolean {
const label = `${track.title} ${track.externalFilename ?? ''}`.toLowerCase();
return /\b(signs?|songs?)\b/.test(label);
}
function pickSecondarySubtitleTrackId( function pickSecondarySubtitleTrackId(
tracks: Array<Record<string, unknown>>, tracks: Array<Record<string, unknown>>,
preferredLanguages: string[], preferredLanguages: string[],
@@ -177,12 +182,19 @@ function pickSecondarySubtitleTrackId(
const uniqueTracks = [...dedupedTracks.values()]; const uniqueTracks = [...dedupedTracks.values()];
for (const language of normalizedLanguages) { for (const language of normalizedLanguages) {
const selectedMatch = uniqueTracks.find((track) => track.selected && track.lang === language); const languageTracks = uniqueTracks.filter((track) => track.lang === language);
if (languageTracks.length === 0) {
continue;
}
const cleanTracks = languageTracks.filter((track) => !isSignsOrSongsSubtitleTrack(track));
const candidateTracks = cleanTracks.length > 0 ? cleanTracks : languageTracks;
const selectedMatch = candidateTracks.find((track) => track.selected);
if (selectedMatch) { if (selectedMatch) {
return selectedMatch.id; return selectedMatch.id;
} }
const match = uniqueTracks.find((track) => track.lang === language); const match = candidateTracks[0];
if (match) { if (match) {
return match.id; return match.id;
} }
@@ -0,0 +1,484 @@
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runCommand, type CommandResult } from '../../subsync/utils';
import { parseSubtitleCues, type SubtitleCue } from './subtitle-cue-parser.js';
import { isEnglishYoutubeLang, normalizeYoutubeLangCode } from './youtube/labels.js';
const DEFAULT_SECONDARY_SUBTITLE_LANGUAGES = ['en', 'eng', 'english', 'en-us', 'enus'];
const DEFAULT_PRIMARY_SUBTITLE_LANGUAGES = ['ja', 'jpn', 'jp', 'japanese'];
const SUPPORTED_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass', '.ssa']);
const TIMING_TOLERANCE_SECONDS = 0.25;
const SAME_TIMING_EPSILON_SECONDS = 0.001;
const RETIMED_SUBTITLE_TIMEOUT_MS = 30_000;
const FALLBACK_ALASS_PATHS = [
'/opt/homebrew/bin/alass-cli',
'/opt/homebrew/bin/alass',
'/usr/local/bin/alass-cli',
'/usr/local/bin/alass',
'/usr/bin/alass',
];
type SidecarCandidate = {
path: string;
languageRank: number;
extensionRank: number;
name: string;
};
type RetimedSubtitleCacheEntry = {
path: string;
cleanupDir: string;
promise?: Promise<string>;
};
export type RetimedSubtitleCommandRunner = (
alassPath: string,
referencePath: string,
inputPath: string,
outputPath: string,
) => Promise<CommandResult>;
export type RetimedSecondarySubtitleInput = {
sourcePath: string;
startMs: number;
endMs: number;
languages?: readonly string[];
primaryLanguages?: readonly string[];
alassPath?: string | null;
runAlass?: RetimedSubtitleCommandRunner;
};
const retimedSubtitleCache = new Map<string, RetimedSubtitleCacheEntry>();
let retimedSubtitleCleanupRegistered = false;
function unique(values: string[]): string[] {
return values.filter((value, index) => value.length > 0 && values.indexOf(value) === index);
}
function expandPreferredLanguages(
languages: readonly string[] | undefined,
fallback: readonly string[],
): string[] {
const normalized = unique(
(languages ?? []).map((language) => normalizeYoutubeLangCode(language)).filter(Boolean),
);
const base = normalized.length > 0 ? normalized : [...fallback];
const expanded: string[] = [];
for (const language of base) {
expanded.push(language);
if (isEnglishYoutubeLang(language)) {
expanded.push(...DEFAULT_SECONDARY_SUBTITLE_LANGUAGES);
}
}
return unique(expanded);
}
function isExecutableFile(filePath: string): boolean {
try {
return statSync(filePath).isFile();
} catch {
return false;
}
}
function pathEntries(): string[] {
const entries = (process.env.PATH ?? '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
return unique([...entries, ...FALLBACK_ALASS_PATHS.map((candidate) => path.dirname(candidate))]);
}
function executableNames(name: string): string[] {
if (process.platform !== 'win32') return [name];
const extensions = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT')
.split(';')
.map((entry) => entry.trim())
.filter(Boolean);
if (path.extname(name)) return [name];
return [name, ...extensions.map((extension) => `${name}${extension}`)];
}
function findExecutable(names: readonly string[]): string {
for (const name of names) {
if (path.dirname(name) !== '.') {
return isExecutableFile(name) ? name : '';
}
}
for (const dir of pathEntries()) {
for (const name of names) {
for (const executableName of executableNames(name)) {
const candidate = path.join(dir, executableName);
if (isExecutableFile(candidate)) return candidate;
}
}
}
for (const candidate of FALLBACK_ALASS_PATHS) {
if (isExecutableFile(candidate)) return candidate;
}
return '';
}
function resolveAlassPath(configuredPath: string | null | undefined): string {
const trimmed = configuredPath?.trim() ?? '';
if (trimmed) {
return findExecutable([trimmed]);
}
return findExecutable(['alass', 'alass-cli']);
}
function fileSignature(filePath: string): string | null {
try {
const stats = statSync(filePath);
if (!stats.isFile()) return null;
return `${stats.size}:${stats.mtimeMs}`;
} catch {
return null;
}
}
function retimedCacheKey(
alassPath: string,
primaryPath: string,
secondaryPath: string,
): string | null {
const primarySignature = fileSignature(primaryPath);
const secondarySignature = fileSignature(secondaryPath);
if (!primarySignature || !secondarySignature) return null;
return [alassPath, primaryPath, primarySignature, secondaryPath, secondarySignature].join('\0');
}
function cleanupRetimedSubtitleCache(): void {
for (const entry of retimedSubtitleCache.values()) {
try {
rmSync(entry.cleanupDir, { recursive: true, force: true });
} catch {
// Best-effort temp cleanup.
}
}
retimedSubtitleCache.clear();
}
function registerRetimedSubtitleCleanup(): void {
if (retimedSubtitleCleanupRegistered) return;
retimedSubtitleCleanupRegistered = true;
process.once('exit', cleanupRetimedSubtitleCache);
}
export function clearRetimedSecondarySubtitleCache(): void {
cleanupRetimedSubtitleCache();
}
function splitLanguageSuffix(value: string): string[] {
const normalizedWhole = normalizeYoutubeLangCode(value);
const tokens = value
.split(/[^A-Za-z0-9-]+/g)
.map((token) => normalizeYoutubeLangCode(token))
.filter(Boolean);
return unique([normalizedWhole, ...tokens]);
}
function languageTokenMatches(token: string, preferredLanguage: string): boolean {
if (token === preferredLanguage) {
return true;
}
if (token.startsWith(`${preferredLanguage}-`) || preferredLanguage.startsWith(`${token}-`)) {
return true;
}
return isEnglishYoutubeLang(token) && isEnglishYoutubeLang(preferredLanguage);
}
function resolveLanguageRank(suffix: string, preferredLanguages: string[]): number {
const tokens = splitLanguageSuffix(suffix);
for (let index = 0; index < preferredLanguages.length; index += 1) {
const preferredLanguage = preferredLanguages[index]!;
if (tokens.some((token) => languageTokenMatches(token, preferredLanguage))) {
return index;
}
}
return Number.POSITIVE_INFINITY;
}
function extensionRank(ext: string): number {
if (ext === '.srt') return 0;
if (ext === '.vtt') return 1;
if (ext === '.ass') return 2;
if (ext === '.ssa') return 3;
return 4;
}
function findSidecarSubtitleCandidates(
sourcePath: string,
preferredLanguages: string[],
): SidecarCandidate[] {
const source = path.parse(sourcePath);
let entries: string[];
try {
entries = readdirSync(source.dir);
} catch {
return [];
}
const prefix = `${source.name}.`;
return entries
.map((entry) => {
const parsed = path.parse(entry);
const ext = parsed.ext.toLowerCase();
if (!SUPPORTED_SUBTITLE_EXTENSIONS.has(ext) || !parsed.name.startsWith(prefix)) {
return null;
}
const suffix = parsed.name.slice(prefix.length);
const languageRank = resolveLanguageRank(suffix, preferredLanguages);
if (!Number.isFinite(languageRank)) {
return null;
}
return {
path: path.join(source.dir, entry),
languageRank,
extensionRank: extensionRank(ext),
name: entry,
};
})
.filter((candidate): candidate is SidecarCandidate => candidate !== null)
.sort((left, right) => {
if (left.languageRank !== right.languageRank) return left.languageRank - right.languageRank;
if (left.extensionRank !== right.extensionRank)
return left.extensionRank - right.extensionRank;
return left.name.localeCompare(right.name);
});
}
function combineCueText(cues: SubtitleCue[]): string {
return unique(cues.map((cue) => cue.text.trim()).filter(Boolean))
.join('\n')
.trim();
}
function overlapSeconds(cue: SubtitleCue, startSeconds: number, endSeconds: number): number {
return (
Math.min(cue.endTime, endSeconds + TIMING_TOLERANCE_SECONDS) -
Math.max(cue.startTime, startSeconds - TIMING_TOLERANCE_SECONDS)
);
}
function isSameCueTiming(left: SubtitleCue, right: SubtitleCue): boolean {
return (
Math.abs(left.startTime - right.startTime) <= SAME_TIMING_EPSILON_SECONDS &&
Math.abs(left.endTime - right.endTime) <= SAME_TIMING_EPSILON_SECONDS
);
}
function compareCueTimingMatch(
startSeconds: number,
endSeconds: number,
left: { cue: SubtitleCue; overlap: number },
right: { cue: SubtitleCue; overlap: number },
): number {
if (left.overlap !== right.overlap) {
return right.overlap - left.overlap;
}
const leftStartDistance = Math.abs(left.cue.startTime - startSeconds);
const rightStartDistance = Math.abs(right.cue.startTime - startSeconds);
if (leftStartDistance !== rightStartDistance) {
return leftStartDistance - rightStartDistance;
}
const leftEndDistance = Math.abs(left.cue.endTime - endSeconds);
const rightEndDistance = Math.abs(right.cue.endTime - endSeconds);
if (leftEndDistance !== rightEndDistance) {
return leftEndDistance - rightEndDistance;
}
return left.cue.startTime - right.cue.startTime;
}
function findCueTextAtTiming(cues: SubtitleCue[], startMs: number, endMs: number): string {
const startSeconds = startMs / 1000;
const endSeconds = endMs / 1000;
const midpointSeconds = (startSeconds + endSeconds) / 2;
const midpointMatches = cues
.filter(
(cue) =>
cue.startTime - TIMING_TOLERANCE_SECONDS <= midpointSeconds &&
cue.endTime + TIMING_TOLERANCE_SECONDS >= midpointSeconds,
)
.map((cue) => ({ cue, overlap: overlapSeconds(cue, startSeconds, endSeconds) }))
.sort((left, right) => compareCueTimingMatch(startSeconds, endSeconds, left, right));
const [bestMidpointMatch] = midpointMatches;
const midpointText = bestMidpointMatch
? combineCueText(
midpointMatches
.filter((match) => isSameCueTiming(match.cue, bestMidpointMatch.cue))
.map((match) => match.cue),
)
: '';
if (midpointText) {
return midpointText;
}
const [bestOverlap] = cues
.map((cue) => ({ cue, overlap: overlapSeconds(cue, startSeconds, endSeconds) }))
.filter((entry) => entry.overlap > 0)
.sort((left, right) => compareCueTimingMatch(startSeconds, endSeconds, left, right));
return bestOverlap ? bestOverlap.cue.text.trim() : '';
}
function readCueTextAtTiming(filePath: string, startMs: number, endMs: number): string {
const content = readFileSync(filePath, 'utf8');
const cues = parseSubtitleCues(content, filePath);
return findCueTextAtTiming(cues, startMs, endMs);
}
async function defaultRunAlass(
alassPath: string,
referencePath: string,
inputPath: string,
outputPath: string,
): Promise<CommandResult> {
return runCommand(alassPath, [referencePath, inputPath, outputPath], RETIMED_SUBTITLE_TIMEOUT_MS);
}
async function retimeSecondarySubtitle(input: {
alassPath: string;
primaryPath: string;
secondaryPath: string;
runAlass: RetimedSubtitleCommandRunner;
}): Promise<string> {
const key = retimedCacheKey(input.alassPath, input.primaryPath, input.secondaryPath);
if (!key) return '';
const cached = retimedSubtitleCache.get(key);
if (cached?.promise) {
return cached.promise;
}
if (cached && existsSync(cached.path)) {
return cached.path;
}
if (cached) {
retimedSubtitleCache.delete(key);
try {
rmSync(cached.cleanupDir, { recursive: true, force: true });
} catch {}
}
registerRetimedSubtitleCleanup();
const cleanupDir = mkdtempSync(path.join(os.tmpdir(), 'subminer-retimed-secondary-'));
const parsedSecondary = path.parse(input.secondaryPath);
const outputPath = path.join(
cleanupDir,
`${parsedSecondary.name}.retimed${parsedSecondary.ext || '.srt'}`,
);
const entry: RetimedSubtitleCacheEntry = { path: outputPath, cleanupDir };
entry.promise = input
.runAlass(input.alassPath, input.primaryPath, input.secondaryPath, outputPath)
.then((result) => {
if (!result.ok || !existsSync(outputPath)) {
rmSync(cleanupDir, { recursive: true, force: true });
retimedSubtitleCache.delete(key);
return '';
}
entry.promise = undefined;
return outputPath;
})
.catch(() => {
rmSync(cleanupDir, { recursive: true, force: true });
retimedSubtitleCache.delete(key);
return '';
});
retimedSubtitleCache.set(key, entry);
return entry.promise;
}
export function resolveSecondarySubtitleTextFromSidecar(input: {
sourcePath: string;
startMs: number;
endMs: number;
languages?: readonly string[];
}): string {
if (!input.sourcePath || !existsSync(input.sourcePath)) {
return '';
}
try {
if (!statSync(input.sourcePath).isFile()) {
return '';
}
} catch {
return '';
}
const preferredLanguages = expandPreferredLanguages(
input.languages,
DEFAULT_SECONDARY_SUBTITLE_LANGUAGES,
);
const candidates = findSidecarSubtitleCandidates(input.sourcePath, preferredLanguages);
for (const candidate of candidates) {
try {
const text = readCueTextAtTiming(candidate.path, input.startMs, input.endMs);
if (text) {
return text;
}
} catch {
// Try the next matching sidecar.
}
}
return '';
}
export async function resolveRetimedSecondarySubtitleTextFromSidecar(
input: RetimedSecondarySubtitleInput,
): Promise<string> {
if (!input.sourcePath || !existsSync(input.sourcePath)) {
return '';
}
try {
if (!statSync(input.sourcePath).isFile()) {
return '';
}
} catch {
return '';
}
const alassPath = resolveAlassPath(input.alassPath);
if (!alassPath) return '';
const primaryLanguages = expandPreferredLanguages(
input.primaryLanguages,
DEFAULT_PRIMARY_SUBTITLE_LANGUAGES,
);
const secondaryLanguages = expandPreferredLanguages(
input.languages,
DEFAULT_SECONDARY_SUBTITLE_LANGUAGES,
);
const primaryCandidates = findSidecarSubtitleCandidates(input.sourcePath, primaryLanguages);
const secondaryCandidates = findSidecarSubtitleCandidates(input.sourcePath, secondaryLanguages);
const runAlass = input.runAlass ?? defaultRunAlass;
for (const primary of primaryCandidates) {
for (const secondary of secondaryCandidates) {
if (primary.path === secondary.path) continue;
try {
const retimedPath = await retimeSecondarySubtitle({
alassPath,
primaryPath: primary.path,
secondaryPath: secondary.path,
runAlass,
});
if (!retimedPath) continue;
const text = readCueTextAtTiming(retimedPath, input.startMs, input.endMs);
if (text) return text;
} catch {
// Try the next sidecar pair.
}
}
}
return '';
}
+478 -47
View File
@@ -1,5 +1,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { ImmersionTrackerService } from './immersion-tracker-service.js'; import type { ImmersionTrackerService } from './immersion-tracker-service.js';
import { splitSentenceSearchTerms } from './immersion-tracker/query-lexical.js';
import http, { type IncomingMessage, type ServerResponse } from 'node:http'; import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import { basename, extname, resolve, sep } from 'node:path'; import { basename, extname, resolve, sep } from 'node:path';
import { readFileSync, existsSync, statSync } from 'node:fs'; import { readFileSync, existsSync, statSync } from 'node:fs';
@@ -7,6 +8,7 @@ import { Readable } from 'node:stream';
import { MediaGenerator } from '../../media-generator.js'; import { MediaGenerator } from '../../media-generator.js';
import { AnkiConnectClient } from '../../anki-connect.js'; import { AnkiConnectClient } from '../../anki-connect.js';
import type { AnkiConnectConfig } from '../../types.js'; import type { AnkiConnectConfig } from '../../types.js';
import { createLogger } from '../../logger.js';
import { import {
getConfiguredSentenceFieldName, getConfiguredSentenceFieldName,
getConfiguredTranslationFieldName, getConfiguredTranslationFieldName,
@@ -15,18 +17,50 @@ import {
} from '../../anki-field-config.js'; } from '../../anki-field-config.js';
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js'; import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
import type { AnilistRateLimiter } from './anilist/rate-limiter.js'; import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
import {
resolveRetimedSecondarySubtitleTextFromSidecar,
resolveSecondarySubtitleTextFromSidecar,
type RetimedSecondarySubtitleInput,
} from './secondary-subtitle-sidecar.js';
type StatsServerNoteInfo = { type StatsServerNoteInfo = {
noteId: number; noteId: number;
fields: Record<string, { value: string }>; fields: Record<string, { value: string }>;
}; };
type StatsServerMediaGenerator = {
generateAudio: (...args: Parameters<MediaGenerator['generateAudio']>) => Promise<Buffer | null>;
generateScreenshot: (
...args: Parameters<MediaGenerator['generateScreenshot']>
) => Promise<Buffer | null>;
generateAnimatedImage: (
...args: Parameters<MediaGenerator['generateAnimatedImage']>
) => Promise<Buffer | null>;
};
export type StatsMiningTimingEvent = {
mode: 'word' | 'sentence' | 'audio';
phase: string;
elapsedMs: number;
noteId?: number;
};
type StatsExcludedWordPayload = { type StatsExcludedWordPayload = {
headword: string; headword: string;
word: string; word: string;
reading: string; reading: string;
}; };
type StatsCoverImagePayload = {
contentType: string;
dataUrl: string;
} | null;
type StatsCoverBatchBody = {
animeIds?: unknown;
videoIds?: unknown;
};
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number { function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
if (raw === undefined) return fallback; if (raw === undefined) return fallback;
const n = Number(raw); const n = Number(raw);
@@ -73,6 +107,62 @@ function parseExcludedWordsBody(body: unknown): StatsExcludedWordPayload[] | nul
return words; return words;
} }
function parsePositiveIdList(raw: unknown, maxItems = 100): number[] {
if (!Array.isArray(raw)) return [];
const ids = new Set<number>();
for (const rawId of raw) {
const id = typeof rawId === 'number' ? rawId : typeof rawId === 'string' ? Number(rawId) : NaN;
if (Number.isFinite(id) && id > 0) {
ids.add(Math.floor(id));
if (ids.size >= maxItems) break;
}
}
return Array.from(ids).sort((a, b) => a - b);
}
function coverImagePayload(
art: { coverBlob?: Uint8Array | null } | null | undefined,
): StatsCoverImagePayload {
if (!art?.coverBlob) return null;
const bytes = new Uint8Array(art.coverBlob);
const contentType = detectImageContentType(bytes);
return {
contentType,
dataUrl: `data:${contentType};base64,${Buffer.from(bytes).toString('base64')}`,
};
}
function detectImageContentType(bytes: Uint8Array): string {
if (
bytes.length >= 8 &&
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47
) {
return 'image/png';
}
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return 'image/jpeg';
}
if (
bytes.length >= 12 &&
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
) {
return 'image/webp';
}
return 'application/octet-stream';
}
function resolveStatsNoteFieldName( function resolveStatsNoteFieldName(
noteInfo: StatsServerNoteInfo, noteInfo: StatsServerNoteInfo,
...preferredNames: (string | undefined)[] ...preferredNames: (string | undefined)[]
@@ -87,6 +177,57 @@ function resolveStatsNoteFieldName(
return null; return null;
} }
function uniqueFieldNames(...fieldNames: (string | null | undefined)[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const fieldName of fieldNames) {
const normalized = fieldName?.trim();
if (!normalized) continue;
const key = normalized.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(normalized);
}
return result;
}
function getStatsWordMiningAudioFieldName(
ankiConfig: AnkiConnectConfig,
noteInfo: StatsServerNoteInfo | null,
): string {
return (
(noteInfo
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', ankiConfig.fields?.audio)
: null) ??
ankiConfig.fields?.audio ??
'ExpressionAudio'
);
}
function getStatsDirectMiningAudioFieldNames(
ankiConfig: AnkiConnectConfig,
noteInfo: StatsServerNoteInfo | null,
mode: 'sentence' | 'audio',
): string[] {
const configuredAudioField = ankiConfig.fields?.audio ?? 'ExpressionAudio';
if (!ankiConfig.isLapis?.enabled && !ankiConfig.isKiku?.enabled) {
return [configuredAudioField];
}
const sentenceAudioField = noteInfo
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', configuredAudioField)
: 'SentenceAudio';
const expressionAudioField = noteInfo
? resolveStatsNoteFieldName(noteInfo, configuredAudioField)
: configuredAudioField;
if (mode === 'sentence') {
return uniqueFieldNames(sentenceAudioField);
}
return uniqueFieldNames(sentenceAudioField, expressionAudioField);
}
function toFetchHeaders(headers: IncomingMessage['headers']): Headers { function toFetchHeaders(headers: IncomingMessage['headers']): Headers {
const fetchHeaders = new Headers(); const fetchHeaders = new Headers();
for (const [name, value] of Object.entries(headers)) { for (const [name, value] of Object.entries(headers)) {
@@ -256,9 +397,19 @@ export interface StatsServerConfig {
knownWordCachePath?: string; knownWordCachePath?: string;
mpvSocketPath?: string; mpvSocketPath?: string;
ankiConnectConfig?: AnkiConnectConfig; ankiConnectConfig?: AnkiConnectConfig;
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
getYomitanAnkiDeckName?: () => Promise<string | null | undefined> | string | null | undefined;
secondarySubtitleLanguages?: string[];
getSecondarySubtitleLanguages?: () => string[] | undefined;
statsMiningAlassPath?: string;
getStatsMiningAlassPath?: () => string | null | undefined;
resolveRetimedSecondarySubtitleText?: (
input: RetimedSecondarySubtitleInput,
) => Promise<string> | string;
anilistRateLimiter?: AnilistRateLimiter; anilistRateLimiter?: AnilistRateLimiter;
addYomitanNote?: (word: string) => Promise<number | null>; addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number; resolveAnkiNoteId?: (noteId: number) => number;
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
} }
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = { const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
@@ -279,6 +430,52 @@ const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
'.woff2': 'font/woff2', '.woff2': 'font/woff2',
}; };
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000; const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
const statsMiningLogger = createLogger('stats:mining');
function defaultNowMs(): number {
return Date.now();
}
function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean {
if (raw === undefined) return fallback;
const normalized = raw.trim().toLowerCase();
if (!normalized) return fallback;
return !['0', 'false', 'no', 'off'].includes(normalized);
}
function uniqueNonEmptyStrings(values: readonly string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = value.trim();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
}
return result;
}
async function buildSentenceSearchOptions(
query: string,
searchByHeadword: boolean,
resolveSentenceSearchHeadwords: ((term: string) => Promise<string[]> | string[]) | undefined,
): Promise<{ headwordTerms: Array<{ term: string; headwords: string[] }> } | undefined> {
if (!searchByHeadword) return undefined;
const terms = splitSentenceSearchTerms(query);
const headwordTerms: Array<{ term: string; headwords: string[] }> = [];
for (const term of terms) {
const resolved = resolveSentenceSearchHeadwords
? await resolveSentenceSearchHeadwords(term)
: [term];
const headwords = uniqueNonEmptyStrings(resolved);
if (headwords.length > 0) {
headwordTerms.push({ term, headwords });
}
}
return headwordTerms.length > 0 ? { headwordTerms } : undefined;
}
function buildAnkiNotePreview( function buildAnkiNotePreview(
fields: Record<string, { value: string }>, fields: Record<string, { value: string }>,
@@ -340,12 +537,81 @@ export function createStatsApp(
knownWordCachePath?: string; knownWordCachePath?: string;
mpvSocketPath?: string; mpvSocketPath?: string;
ankiConnectConfig?: AnkiConnectConfig; ankiConnectConfig?: AnkiConnectConfig;
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
getYomitanAnkiDeckName?: () => Promise<string | null | undefined> | string | null | undefined;
secondarySubtitleLanguages?: string[];
getSecondarySubtitleLanguages?: () => string[] | undefined;
statsMiningAlassPath?: string;
getStatsMiningAlassPath?: () => string | null | undefined;
resolveRetimedSecondarySubtitleText?: (
input: RetimedSecondarySubtitleInput,
) => Promise<string> | string;
anilistRateLimiter?: AnilistRateLimiter; anilistRateLimiter?: AnilistRateLimiter;
addYomitanNote?: (word: string) => Promise<number | null>; addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number; resolveAnkiNoteId?: (noteId: number) => number;
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
createMediaGenerator?: () => StatsServerMediaGenerator;
onMiningTiming?: (event: StatsMiningTimingEvent) => void;
nowMs?: () => number;
}, },
) { ) {
const app = new Hono(); const app = new Hono();
const nowMs = options?.nowMs ?? defaultNowMs;
const getAnkiConnectConfig = (): AnkiConnectConfig | undefined =>
options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig;
const getSecondarySubtitleLanguages = (): string[] =>
options?.getSecondarySubtitleLanguages?.() ?? options?.secondarySubtitleLanguages ?? [];
const getStatsMiningAlassPath = (): string | null | undefined =>
options?.getStatsMiningAlassPath?.() ?? options?.statsMiningAlassPath;
const getEffectiveMiningDeckName = async (ankiConfig: AnkiConnectConfig): Promise<string> => {
const configuredDeckName = ankiConfig.deck?.trim() ?? '';
if (configuredDeckName) return configuredDeckName;
try {
const yomitanDeckName = await options?.getYomitanAnkiDeckName?.();
return typeof yomitanDeckName === 'string' ? yomitanDeckName.trim() : '';
} catch (error) {
statsMiningLogger.warn(
'Failed to resolve Yomitan Anki deck for stats mining:',
error instanceof Error ? error.message : String(error),
);
return '';
}
};
const recordMiningTiming = (event: StatsMiningTimingEvent): void => {
options?.onMiningTiming?.(event);
statsMiningLogger.debug(
`[stats:mining] ${event.mode} ${event.phase} ${Math.round(event.elapsedMs)}ms`,
event,
);
};
const timeMiningPhase = async <T>(
mode: StatsMiningTimingEvent['mode'],
phase: string,
fn: () => Promise<T>,
details?: (value: T) => Partial<StatsMiningTimingEvent>,
): Promise<T> => {
const startedAtMs = nowMs();
try {
const value = await fn();
recordMiningTiming({
mode,
phase,
elapsedMs: nowMs() - startedAtMs,
...details?.(value),
});
return value;
} catch (err) {
recordMiningTiming({
mode,
phase,
elapsedMs: nowMs() - startedAtMs,
});
throw err;
}
};
app.get('/api/stats/overview', async (c) => { app.get('/api/stats/overview', async (c) => {
const [rawSessions, rollups, hints] = await Promise.all([ const [rawSessions, rollups, hints] = await Promise.all([
@@ -509,6 +775,20 @@ export function createStatsApp(
return c.json(occurrences); return c.json(occurrences);
}); });
app.get('/api/stats/sentences/search', async (c) => {
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json([]);
const limit = parseIntQuery(c.req.query('limit'), 50, 100);
const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true);
const searchOptions = await buildSentenceSearchOptions(
query,
searchByHeadword,
options?.resolveSentenceSearchHeadwords,
);
const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions);
return c.json(rows);
});
app.get('/api/stats/kanji', async (c) => { app.get('/api/stats/kanji', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 100, 500); const limit = parseIntQuery(c.req.query('limit'), 100, 500);
const kanji = await tracker.getKanjiStats(limit); const kanji = await tracker.getKanjiStats(limit);
@@ -707,14 +987,36 @@ export function createStatsApp(
return c.json({ ok: true }); return c.json({ ok: true });
}); });
app.post('/api/stats/covers', async (c) => {
const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null;
const animeIds = parsePositiveIdList(body?.animeIds);
const videoIds = parsePositiveIdList(body?.videoIds);
const anime: Record<number, StatsCoverImagePayload> = {};
const media: Record<number, StatsCoverImagePayload> = {};
await Promise.all(
animeIds.map(async (animeId) => {
anime[animeId] = coverImagePayload(await tracker.getAnimeCoverArt(animeId));
}),
);
await Promise.all(
videoIds.map(async (videoId) => {
media[videoId] = coverImagePayload(await tracker.getCoverArt(videoId));
}),
);
return c.json({ anime, media });
});
app.get('/api/stats/anime/:animeId/cover', async (c) => { app.get('/api/stats/anime/:animeId/cover', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0); const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 404); if (animeId <= 0) return c.body(null, 404);
const art = await tracker.getAnimeCoverArt(animeId); const art = await tracker.getAnimeCoverArt(animeId);
if (!art?.coverBlob) return c.body(null, 404); if (!art?.coverBlob) return c.body(null, 404);
return new Response(new Uint8Array(art.coverBlob), { const bytes = new Uint8Array(art.coverBlob);
return new Response(bytes, {
headers: { headers: {
'Content-Type': 'image/jpeg', 'Content-Type': detectImageContentType(bytes),
'Cache-Control': 'public, max-age=86400', 'Cache-Control': 'public, max-age=86400',
}, },
}); });
@@ -729,9 +1031,10 @@ export function createStatsApp(
art = await tracker.getCoverArt(videoId); art = await tracker.getCoverArt(videoId);
} }
if (!art?.coverBlob) return c.body(null, 404); if (!art?.coverBlob) return c.body(null, 404);
return new Response(new Uint8Array(art.coverBlob), { const bytes = new Uint8Array(art.coverBlob);
return new Response(bytes, {
headers: { headers: {
'Content-Type': 'image/jpeg', 'Content-Type': detectImageContentType(bytes),
'Cache-Control': 'public, max-age=604800', 'Cache-Control': 'public, max-age=604800',
}, },
}); });
@@ -754,8 +1057,9 @@ export function createStatsApp(
app.post('/api/stats/anki/browse', async (c) => { app.post('/api/stats/anki/browse', async (c) => {
const noteId = parseIntQuery(c.req.query('noteId'), 0); const noteId = parseIntQuery(c.req.query('noteId'), 0);
if (noteId <= 0) return c.body(null, 400); if (noteId <= 0) return c.body(null, 400);
const ankiConfig = getAnkiConnectConfig();
try { try {
const response = await fetch('http://127.0.0.1:8765', { const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS), signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
@@ -791,7 +1095,8 @@ export function createStatsApp(
), ),
); );
try { try {
const response = await fetch('http://127.0.0.1:8765', { const ankiConfig = getAnkiConnectConfig();
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS), signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
@@ -807,7 +1112,7 @@ export function createStatsApp(
return c.json( return c.json(
(result.result ?? []).map((note) => ({ (result.result ?? []).map((note) => ({
...note, ...note,
preview: buildAnkiNotePreview(note.fields, options?.ankiConnectConfig), preview: buildAnkiNotePreview(note.fields, ankiConfig),
})), })),
); );
} catch { } catch {
@@ -822,7 +1127,8 @@ export function createStatsApp(
const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN; const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN;
const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : ''; const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : '';
const word = typeof body?.word === 'string' ? body.word.trim() : ''; const word = typeof body?.word === 'string' ? body.word.trim() : '';
const secondaryText = typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : ''; const bodySecondaryText =
typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : '';
const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : ''; const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : '';
const rawMode = c.req.query('mode'); const rawMode = c.req.query('mode');
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence'; const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
@@ -830,18 +1136,51 @@ export function createStatsApp(
if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) { if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
return c.json({ error: 'sourcePath, sentence, startMs, and endMs are required' }, 400); return c.json({ error: 'sourcePath, sentence, startMs, and endMs are required' }, 400);
} }
if (endMs <= startMs) {
return c.json({ error: 'endMs must be greater than startMs' }, 400);
}
if (!existsSync(sourcePath)) { if (!existsSync(sourcePath)) {
return c.json({ error: 'File not found' }, 404); return c.json({ error: 'File not found' }, 404);
} }
const ankiConfig = options?.ankiConnectConfig; const ankiConfig = getAnkiConnectConfig();
if (!ankiConfig) { if (!ankiConfig) {
return c.json({ error: 'AnkiConnect is not configured' }, 500); return c.json({ error: 'AnkiConnect is not configured' }, 500);
} }
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
let retimedSecondaryText = '';
if (mode === 'sentence' && !bodySecondaryText) {
try {
retimedSecondaryText = await (
options?.resolveRetimedSecondarySubtitleText ??
resolveRetimedSecondarySubtitleTextFromSidecar
)({
sourcePath,
startMs,
endMs,
languages: secondarySubtitleLanguages,
alassPath: getStatsMiningAlassPath(),
});
} catch (error) {
statsMiningLogger.warn(
'Failed to resolve retimed secondary subtitle for stats mining:',
error instanceof Error ? error.message : String(error),
);
}
}
const secondaryText =
bodySecondaryText ||
retimedSecondaryText ||
resolveSecondarySubtitleTextFromSidecar({
sourcePath,
startMs,
endMs,
languages: secondarySubtitleLanguages,
});
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765'); const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
const mediaGen = new MediaGenerator(); const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
const audioPadding = ankiConfig.media?.audioPadding ?? 0; const audioPadding = ankiConfig.media?.audioPadding ?? 0;
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30; const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
@@ -865,7 +1204,9 @@ export function createStatsApp(
imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false; imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false;
const audioPromise = generateAudio const audioPromise = generateAudio
? mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding) ? timeMiningPhase(mode, 'generateAudio', () =>
mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding),
)
: Promise.resolve(null); : Promise.resolve(null);
const createImagePromise = (animatedLeadInSeconds = 0): Promise<Buffer | null> => { const createImagePromise = (animatedLeadInSeconds = 0): Promise<Buffer | null> => {
@@ -874,22 +1215,26 @@ export function createStatsApp(
} }
if (imageType === 'avif') { if (imageType === 'avif') {
return mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, { return timeMiningPhase(mode, 'generateAnimatedImage', () =>
mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, {
fps: ankiConfig.media?.animatedFps ?? 10, fps: ankiConfig.media?.animatedFps ?? 10,
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640, maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
maxHeight: ankiConfig.media?.animatedMaxHeight, maxHeight: ankiConfig.media?.animatedMaxHeight,
crf: ankiConfig.media?.animatedCrf ?? 35, crf: ankiConfig.media?.animatedCrf ?? 35,
leadingStillDuration: animatedLeadInSeconds, leadingStillDuration: animatedLeadInSeconds,
}); }),
);
} }
const midpointSec = (startSec + clampedEndSec) / 2; const midpointSec = (startSec + clampedEndSec) / 2;
return mediaGen.generateScreenshot(sourcePath, midpointSec, { return timeMiningPhase(mode, 'generateScreenshot', () =>
mediaGen.generateScreenshot(sourcePath, midpointSec, {
format: ankiConfig.media?.imageFormat ?? 'jpg', format: ankiConfig.media?.imageFormat ?? 'jpg',
quality: ankiConfig.media?.imageQuality ?? 92, quality: ankiConfig.media?.imageQuality ?? 92,
maxWidth: ankiConfig.media?.imageMaxWidth, maxWidth: ankiConfig.media?.imageMaxWidth,
maxHeight: ankiConfig.media?.imageMaxHeight, maxHeight: ankiConfig.media?.imageMaxHeight,
}); }),
);
}; };
const imagePromise = const imagePromise =
@@ -899,6 +1244,25 @@ export function createStatsApp(
const errors: string[] = []; const errors: string[] = [];
let noteId: number; let noteId: number;
let effectiveDeckNamePromise: Promise<string> | null = null;
const getEffectiveDeckNameForRequest = (): Promise<string> => {
effectiveDeckNamePromise ??= getEffectiveMiningDeckName(ankiConfig);
return effectiveDeckNamePromise;
};
const moveNoteToConfiguredDeck = async (id: number): Promise<void> => {
const deckName = await getEffectiveDeckNameForRequest();
if (!deckName) {
return;
}
try {
const cardIds = await timeMiningPhase(mode, 'findCards', () =>
client.findCards(`nid:${id}`),
);
await timeMiningPhase(mode, 'changeDeck', () => client.changeDeck(cardIds, deckName));
} catch (err) {
errors.push(`deck: ${(err as Error).message}`);
}
};
if (mode === 'word') { if (mode === 'word') {
if (!options?.addYomitanNote) { if (!options?.addYomitanNote) {
@@ -906,7 +1270,12 @@ export function createStatsApp(
} }
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([ const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
options.addYomitanNote(word), timeMiningPhase(
'word',
'addYomitanNote',
() => options.addYomitanNote!(word),
(noteId) => (typeof noteId === 'number' ? { noteId } : {}),
),
audioPromise, audioPromise,
imagePromise, imagePromise,
]); ]);
@@ -921,6 +1290,7 @@ export function createStatsApp(
} }
noteId = yomitanResult.value; noteId = yomitanResult.value;
await moveNoteToConfiguredDeck(noteId);
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null; const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
if (audioResult.status === 'rejected') if (audioResult.status === 'rejected')
errors.push(`audio: ${(audioResult.reason as Error).message}`); errors.push(`audio: ${(audioResult.reason as Error).message}`);
@@ -928,10 +1298,19 @@ export function createStatsApp(
errors.push(`image: ${(imageResult.reason as Error).message}`); errors.push(`image: ${(imageResult.reason as Error).message}`);
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null; let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
if (syncAnimatedImageToWordAudio && generateImage) { let noteInfo: StatsServerNoteInfo | null = null;
if (audioBuffer || (syncAnimatedImageToWordAudio && generateImage)) {
try { try {
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[]; const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
const noteInfo = noteInfoResult[0] ?? null; noteInfo = noteInfoResult[0] ?? null;
} catch (err) {
if (syncAnimatedImageToWordAudio && generateImage) {
errors.push(`image: ${(err as Error).message}`);
}
}
}
if (syncAnimatedImageToWordAudio && generateImage) {
try {
const animatedLeadInSeconds = noteInfo const animatedLeadInSeconds = noteInfo
? await resolveAnimatedImageLeadInSeconds({ ? await resolveAnimatedImageLeadInSeconds({
config: ankiConfig, config: ankiConfig,
@@ -946,22 +1325,27 @@ export function createStatsApp(
errors.push(`image: ${(err as Error).message}`); errors.push(`image: ${(err as Error).message}`);
} }
} }
if (generateAudio && !audioBuffer && audioResult.status === 'fulfilled') {
errors.push('audio: no audio generated');
}
if (generateImage && !imageBuffer) {
errors.push('image: no image generated');
}
const mediaFields: Record<string, string> = {}; const mediaFields: Record<string, string> = {};
const timestamp = Date.now(); const timestamp = Date.now();
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence'; const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio'; const audioFieldName = getStatsWordMiningAudioFieldName(ankiConfig, noteInfo);
const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
mediaFields[sentenceFieldName] = highlightedSentence; mediaFields[sentenceFieldName] = highlightedSentence;
if (secondaryText) {
mediaFields[ankiConfig.fields?.translation ?? 'SelectionText'] = secondaryText;
}
if (audioBuffer) { if (audioBuffer) {
const audioFilename = `subminer_audio_${timestamp}.mp3`; const audioFilename = `subminer_audio_${timestamp}.mp3`;
try { try {
await client.storeMediaFile(audioFilename, audioBuffer); await timeMiningPhase('word', 'uploadAudio', () =>
client.storeMediaFile(audioFilename, audioBuffer),
);
mediaFields[audioFieldName] = `[sound:${audioFilename}]`; mediaFields[audioFieldName] = `[sound:${audioFilename}]`;
} catch (err) { } catch (err) {
errors.push(`audio upload: ${(err as Error).message}`); errors.push(`audio upload: ${(err as Error).message}`);
@@ -972,7 +1356,9 @@ export function createStatsApp(
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg'); const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
const imageFilename = `subminer_image_${timestamp}.${imageExt}`; const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
try { try {
await client.storeMediaFile(imageFilename, imageBuffer); await timeMiningPhase('word', 'uploadImage', () =>
client.storeMediaFile(imageFilename, imageBuffer),
);
mediaFields[imageFieldName] = `<img src="${imageFilename}">`; mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
} catch (err) { } catch (err) {
errors.push(`image upload: ${(err as Error).message}`); errors.push(`image upload: ${(err as Error).message}`);
@@ -1000,7 +1386,9 @@ export function createStatsApp(
if (Object.keys(mediaFields).length > 0) { if (Object.keys(mediaFields).length > 0) {
try { try {
await client.updateNoteFields(noteId, mediaFields); await timeMiningPhase('word', 'updateNoteFields', () =>
client.updateNoteFields(noteId, mediaFields),
);
} catch (err) { } catch (err) {
errors.push(`update fields: ${(err as Error).message}`); errors.push(`update fields: ${(err as Error).message}`);
} }
@@ -1009,32 +1397,24 @@ export function createStatsApp(
return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) }); return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) });
} }
const [audioResult, imageResult] = await Promise.allSettled([audioPromise, imagePromise]);
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
if (audioResult.status === 'rejected')
errors.push(`audio: ${(audioResult.reason as Error).message}`);
if (imageResult.status === 'rejected')
errors.push(`image: ${(imageResult.reason as Error).message}`);
const wordFieldName = getConfiguredWordFieldName(ankiConfig); const wordFieldName = getConfiguredWordFieldName(ankiConfig);
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence'; const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText'; const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio';
const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? ''; const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? '';
const fields: Record<string, string> = { const fields: Record<string, string> = {
[sentenceFieldName]: highlightedSentence, [sentenceFieldName]: mode === 'sentence' ? sentence : highlightedSentence,
}; };
if (secondaryText) { if (mode === 'sentence' && secondaryText) {
fields[translationFieldName] = secondaryText; fields[translationFieldName] = secondaryText;
} }
if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) { if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) {
if (word) { if (mode === 'sentence') {
fields[wordFieldName] = sentence;
} else if (word) {
fields[wordFieldName] = word; fields[wordFieldName] = word;
} }
if (mode === 'sentence') { if (mode === 'sentence') {
@@ -1045,23 +1425,62 @@ export function createStatsApp(
} }
const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic'; const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic';
const deck = ankiConfig.deck ?? 'Default';
const tags = ankiConfig.tags ?? ['SubMiner']; const tags = ankiConfig.tags ?? ['SubMiner'];
try { const addNotePromise = timeMiningPhase(
noteId = await client.addNote(deck, model, fields, tags); mode,
} catch (err) { 'addNote',
return c.json({ error: `Failed to add note: ${(err as Error).message}` }, 502); async () =>
client.addNote((await getEffectiveDeckNameForRequest()) || 'Default', model, fields, tags),
(id) => ({
noteId: id,
}),
);
const [audioResult, imageResult, addNoteResult] = await Promise.allSettled([
audioPromise,
imagePromise,
addNotePromise,
]);
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
if (audioResult.status === 'rejected')
errors.push(`audio: ${(audioResult.reason as Error).message}`);
if (imageResult.status === 'rejected')
errors.push(`image: ${(imageResult.reason as Error).message}`);
if (addNoteResult.status === 'rejected') {
return c.json(
{ error: `Failed to add note: ${(addNoteResult.reason as Error).message}` },
502,
);
} }
noteId = addNoteResult.value;
await moveNoteToConfiguredDeck(noteId);
const mediaFields: Record<string, string> = {}; const mediaFields: Record<string, string> = {};
const timestamp = Date.now(); const timestamp = Date.now();
let noteInfo: StatsServerNoteInfo | null = null;
if (audioBuffer) {
try {
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
noteInfo = noteInfoResult[0] ?? null;
} catch {
noteInfo = null;
}
}
if (audioBuffer) { if (audioBuffer) {
const audioFilename = `subminer_audio_${timestamp}.mp3`; const audioFilename = `subminer_audio_${timestamp}.mp3`;
try { try {
await client.storeMediaFile(audioFilename, audioBuffer); await timeMiningPhase(mode, 'uploadAudio', () =>
mediaFields[audioFieldName] = `[sound:${audioFilename}]`; client.storeMediaFile(audioFilename, audioBuffer),
);
const audioValue = `[sound:${audioFilename}]`;
for (const fieldName of getStatsDirectMiningAudioFieldNames(ankiConfig, noteInfo, mode)) {
mediaFields[fieldName] = audioValue;
}
} catch (err) { } catch (err) {
errors.push(`audio upload: ${(err as Error).message}`); errors.push(`audio upload: ${(err as Error).message}`);
} }
@@ -1071,7 +1490,9 @@ export function createStatsApp(
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg'); const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
const imageFilename = `subminer_image_${timestamp}.${imageExt}`; const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
try { try {
await client.storeMediaFile(imageFilename, imageBuffer); await timeMiningPhase(mode, 'uploadImage', () =>
client.storeMediaFile(imageFilename, imageBuffer),
);
mediaFields[imageFieldName] = `<img src="${imageFilename}">`; mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
} catch (err) { } catch (err) {
errors.push(`image upload: ${(err as Error).message}`); errors.push(`image upload: ${(err as Error).message}`);
@@ -1099,7 +1520,9 @@ export function createStatsApp(
if (Object.keys(mediaFields).length > 0) { if (Object.keys(mediaFields).length > 0) {
try { try {
await client.updateNoteFields(noteId, mediaFields); await timeMiningPhase(mode, 'updateNoteFields', () =>
client.updateNoteFields(noteId, mediaFields),
);
} catch (err) { } catch (err) {
errors.push(`update fields: ${(err as Error).message}`); errors.push(`update fields: ${(err as Error).message}`);
} }
@@ -1139,9 +1562,17 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
knownWordCachePath: config.knownWordCachePath, knownWordCachePath: config.knownWordCachePath,
mpvSocketPath: config.mpvSocketPath, mpvSocketPath: config.mpvSocketPath,
ankiConnectConfig: config.ankiConnectConfig, ankiConnectConfig: config.ankiConnectConfig,
getAnkiConnectConfig: config.getAnkiConnectConfig,
getYomitanAnkiDeckName: config.getYomitanAnkiDeckName,
secondarySubtitleLanguages: config.secondarySubtitleLanguages,
getSecondarySubtitleLanguages: config.getSecondarySubtitleLanguages,
statsMiningAlassPath: config.statsMiningAlassPath,
getStatsMiningAlassPath: config.getStatsMiningAlassPath,
resolveRetimedSecondarySubtitleText: config.resolveRetimedSecondarySubtitleText,
anilistRateLimiter: config.anilistRateLimiter, anilistRateLimiter: config.anilistRateLimiter,
addYomitanNote: config.addYomitanNote, addYomitanNote: config.addYomitanNote,
resolveAnkiNoteId: config.resolveAnkiNoteId, resolveAnkiNoteId: config.resolveAnkiNoteId,
resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords,
}); });
const bunRuntime = globalThis as typeof globalThis & { const bunRuntime = globalThis as typeof globalThis & {
@@ -151,6 +151,56 @@ test('syncYomitanDefaultAnkiServer injects force override when enabled', async (
assert.match(scriptValue, /forceOverride = true/); assert.match(scriptValue, /forceOverride = true/);
}); });
test('syncYomitanDefaultAnkiServer updates the active profile Anki deck', async () => {
const optionsFull = {
profileCurrent: 0,
profiles: [
{
options: {
anki: {
server: 'http://127.0.0.1:8766',
cardFormats: [
{ type: 'term', deck: 'Default', model: 'Mining Note', fields: {} },
{ type: 'kanji', deck: 'Kanji', model: 'Kanji Note', fields: {} },
],
terms: { deck: 'Default', model: 'Legacy Note', fields: {} },
},
},
},
],
};
let savedOptions: typeof optionsFull | null = null;
const deps = createDeps((script) =>
runInjectedYomitanScript(script, (action, params) => {
if (action === 'optionsGetFull') {
return JSON.parse(JSON.stringify(optionsFull));
}
if (action === 'setAllSettings') {
savedOptions = (params as { value: typeof optionsFull }).value;
return true;
}
throw new Error(`Unexpected action: ${action}`);
}),
);
const synced = await syncYomitanDefaultAnkiServer(
'http://127.0.0.1:8766',
deps,
{
error: () => undefined,
info: () => undefined,
},
{ deck: 'Minecraft', forceOverride: true },
);
assert.equal(synced, true);
assert.ok(savedOptions);
const saved = savedOptions as typeof optionsFull;
assert.equal(saved.profiles[0]?.options.anki.cardFormats[0]?.deck, 'Minecraft');
assert.equal(saved.profiles[0]?.options.anki.cardFormats[1]?.deck, 'Kanji');
assert.equal(saved.profiles[0]?.options.anki.terms.deck, 'Minecraft');
});
test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => { test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => {
const deps = createDeps(async () => { const deps = createDeps(async () => {
throw new Error('execute failed'); throw new Error('execute failed');
@@ -1783,6 +1783,7 @@ export async function syncYomitanDefaultAnkiServer(
logger: LoggerLike, logger: LoggerLike,
options?: { options?: {
forceOverride?: boolean; forceOverride?: boolean;
deck?: string;
}, },
): Promise<boolean> { ): Promise<boolean> {
const normalizedTargetServer = serverUrl.trim(); const normalizedTargetServer = serverUrl.trim();
@@ -1790,6 +1791,7 @@ export async function syncYomitanDefaultAnkiServer(
return false; return false;
} }
const forceOverride = options?.forceOverride === true; const forceOverride = options?.forceOverride === true;
const normalizedTargetDeck = options?.deck?.trim() ?? '';
const isReady = await ensureYomitanParserWindow(deps, logger); const isReady = await ensureYomitanParserWindow(deps, logger);
const parserWindow = deps.getYomitanParserWindow(); const parserWindow = deps.getYomitanParserWindow();
@@ -1819,6 +1821,7 @@ export async function syncYomitanDefaultAnkiServer(
}); });
const targetServer = ${JSON.stringify(normalizedTargetServer)}; const targetServer = ${JSON.stringify(normalizedTargetServer)};
const targetDeck = ${JSON.stringify(normalizedTargetDeck)};
const forceOverride = ${forceOverride ? 'true' : 'false'}; const forceOverride = ${forceOverride ? 'true' : 'false'};
const optionsFull = await invoke("optionsGetFull", undefined); const optionsFull = await invoke("optionsGetFull", undefined);
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : []; const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
@@ -1843,9 +1846,8 @@ export async function syncYomitanDefaultAnkiServer(
const currentServerRaw = targetProfile.options.anki.server; const currentServerRaw = targetProfile.options.anki.server;
const currentServer = typeof currentServerRaw === "string" ? currentServerRaw.trim() : ""; const currentServer = typeof currentServerRaw === "string" ? currentServerRaw.trim() : "";
if (currentServer === targetServer) { let changed = false;
return { updated: false, matched: true, reason: "already-target", currentServer, targetServer }; if (currentServer !== targetServer) {
}
const canReplaceCurrent = const canReplaceCurrent =
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765"; forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
if (!canReplaceCurrent) { if (!canReplaceCurrent) {
@@ -1853,8 +1855,45 @@ export async function syncYomitanDefaultAnkiServer(
} }
targetProfile.options.anki.server = targetServer; targetProfile.options.anki.server = targetServer;
changed = true;
}
if (targetDeck) {
const cardFormats = Array.isArray(targetProfile.options.anki.cardFormats)
? targetProfile.options.anki.cardFormats
: [];
for (const cardFormat of cardFormats) {
if (
!cardFormat ||
typeof cardFormat !== "object" ||
cardFormat.type !== "term" ||
cardFormat.enabled === false
) {
continue;
}
const currentDeck = typeof cardFormat.deck === "string" ? cardFormat.deck.trim() : "";
if (currentDeck !== targetDeck) {
cardFormat.deck = targetDeck;
changed = true;
}
}
const terms = targetProfile.options.anki.terms;
if (terms && typeof terms === "object") {
const currentTermDeck = typeof terms.deck === "string" ? terms.deck.trim() : "";
if (currentTermDeck !== targetDeck) {
terms.deck = targetDeck;
changed = true;
}
}
}
if (!changed) {
return { updated: false, matched: true, reason: "already-target", currentServer, targetServer, targetDeck };
}
await invoke("setAllSettings", { value: optionsFull, source: "subminer" }); await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
return { updated: true, matched: true, currentServer, targetServer }; return { updated: true, matched: true, currentServer, targetServer, targetDeck };
})(); })();
`; `;
+57 -18
View File
@@ -1833,6 +1833,31 @@ function getCurrentAutoplaySubtitlePayload(): SubtitleData | null {
return payload; return payload;
} }
async function resolveSentenceSearchHeadwords(term: string): Promise<string[]> {
const fallback = term.trim() ? [term.trim()] : [];
try {
const tokenized = tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(term) : null;
const tokens = tokenized?.tokens ?? [];
if (tokens.length === 0) return fallback;
const seen = new Set<string>();
const headwords: string[] = [];
for (const token of tokens) {
const headword = (token.headword || token.surface).trim();
if (!headword || seen.has(headword)) continue;
seen.add(headword);
headwords.push(headword);
}
return headwords.length > 0 ? headwords : fallback;
} catch (error) {
logger.debug(
'Failed to resolve sentence-search headwords:',
error instanceof Error ? error.message : String(error),
);
return fallback;
}
}
function signalCurrentSubtitleAutoplayReady(): void { function signalCurrentSubtitleAutoplayReady(): void {
autoplayReadyGate.flushPendingAutoplayReadySignal(); autoplayReadyGate.flushPendingAutoplayReadySignal();
const payload = getCurrentAutoplaySubtitlePayload(); const payload = getCurrentAutoplaySubtitlePayload();
@@ -2240,6 +2265,18 @@ const configHotReloadRuntime = createConfigHotReloadRuntime(
buildConfigHotReloadRuntimeMainDepsHandler(), buildConfigHotReloadRuntimeMainDepsHandler(),
); );
async function getCurrentYomitanAnkiDeckNameForRuntime(): Promise<string> {
await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
return getYomitanCurrentAnkiDeckNameCore(getYomitanParserRuntimeDeps(), {
error: (message, ...args) => {
logger.error(message, ...args);
},
info: (message, ...args) => {
logger.info(message, ...args);
},
});
}
const configSettingsRuntime = createConfigSettingsRuntime({ const configSettingsRuntime = createConfigSettingsRuntime({
fields: configSettingsFields, fields: configSettingsFields,
getConfigPath: () => configService.getConfigPath(), getConfigPath: () => configService.getConfigPath(),
@@ -2250,17 +2287,7 @@ const configSettingsRuntime = createConfigSettingsRuntime({
onHotReloadApplied: applyConfigHotReloadDiff, onHotReloadApplied: applyConfigHotReloadDiff,
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url, defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: (url) => new AnkiConnectClient(url), createAnkiClient: (url) => new AnkiConnectClient(url),
getYomitanAnkiDeckName: async () => { getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime,
await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
return getYomitanCurrentAnkiDeckNameCore(getYomitanParserRuntimeDeps(), {
error: (message, ...args) => {
logger.error(message, ...args);
},
info: (message, ...args) => {
logger.info(message, ...args);
},
});
},
getSettingsWindow: () => appState.configSettingsWindow, getSettingsWindow: () => appState.configSettingsWindow,
setSettingsWindow: (window) => { setSettingsWindow: (window) => {
appState.configSettingsWindow = window as BrowserWindow | null; appState.configSettingsWindow = window as BrowserWindow | null;
@@ -4441,14 +4468,20 @@ const startLocalStatsServer = (): void => {
tracker, tracker,
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'), knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
mpvSocketPath: appState.mpvSocketPath, mpvSocketPath: appState.mpvSocketPath,
ankiConnectConfig: getResolvedConfig().ankiConnect, getAnkiConnectConfig: () => getResolvedConfig().ankiConnect,
getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime,
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
getStatsMiningAlassPath: () => getResolvedConfig().subsync.alass_path,
anilistRateLimiter, anilistRateLimiter,
resolveAnkiNoteId: (noteId: number) => resolveAnkiNoteId: (noteId: number) =>
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
resolveSentenceSearchHeadwords,
addYomitanNote: async (word: string) => { addYomitanNote: async (word: string) => {
const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765'; const ankiConnectConfig = getResolvedConfig().ankiConnect;
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
forceOverride: true, forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
deck: ankiConnectConfig.deck,
}); });
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
if (result.noteId && result.duplicateNoteIds.length > 0) { if (result.noteId && result.duplicateNoteIds.length > 0) {
@@ -5640,7 +5673,7 @@ async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
return extension; return extension;
} }
let lastSyncedYomitanAnkiServer: string | null = null; let lastSyncedYomitanAnkiSettingsKey: string | null = null;
function getPreferredYomitanAnkiServerUrl(): string { function getPreferredYomitanAnkiServerUrl(): string {
return getPreferredYomitanAnkiServerUrlRuntime(getResolvedConfig().ankiConnect); return getPreferredYomitanAnkiServerUrlRuntime(getResolvedConfig().ankiConnect);
@@ -5671,7 +5704,10 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
} }
const targetUrl = getPreferredYomitanAnkiServerUrl().trim(); const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) { const ankiConnectConfig = getResolvedConfig().ankiConnect;
const targetDeck = ankiConnectConfig?.deck?.trim() ?? '';
const targetSettingsKey = `${targetUrl}\n${targetDeck}`;
if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) {
return; return;
} }
@@ -5687,12 +5723,15 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
}, },
}, },
{ {
forceOverride: shouldForceOverrideYomitanAnkiServer(getResolvedConfig().ankiConnect), forceOverride: ankiConnectConfig
? shouldForceOverrideYomitanAnkiServer(ankiConnectConfig)
: false,
deck: targetDeck,
}, },
); );
if (synced) { if (synced) {
lastSyncedYomitanAnkiServer = targetUrl; lastSyncedYomitanAnkiSettingsKey = targetSettingsKey;
} }
} }
+15
View File
@@ -237,6 +237,21 @@ test('warm tokenization release reuses current subtitle payload instead of synth
assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/); assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/);
}); });
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
const source = readMainSource();
const startStatsServerBlock = source.match(
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
)?.groups?.body;
const addYomitanNoteBlock = startStatsServerBlock?.match(
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(addYomitanNoteBlock);
assert.match(addYomitanNoteBlock, /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/);
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
});
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => { test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
const source = readMainSource(); const source = readMainSource();
const actionBlock = source.match( const actionBlock = source.match(
@@ -1,6 +1,37 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { composeJellyfinRuntimeHandlers } from './jellyfin-runtime-composer'; import {
composeJellyfinRuntimeHandlers,
createRestartJellyfinRemoteSessionAfterSetupLoginHandler,
} from './jellyfin-runtime-composer';
test('setup login restart uses auto-connect path without an active remote session', async () => {
const startOptions: Array<{ explicit?: boolean } | undefined> = [];
const restart = createRestartJellyfinRemoteSessionAfterSetupLoginHandler({
getCurrentSession: () => null,
startJellyfinRemoteSession: async (options) => {
startOptions.push(options);
},
});
await restart();
assert.deepEqual(startOptions, [undefined]);
});
test('setup login restart explicitly refreshes an active remote session', async () => {
const startOptions: Array<{ explicit?: boolean } | undefined> = [];
const restart = createRestartJellyfinRemoteSessionAfterSetupLoginHandler({
getCurrentSession: () => ({ stop: () => {} }),
startJellyfinRemoteSession: async (options) => {
startOptions.push(options);
},
});
await restart();
assert.deepEqual(startOptions, [{ explicit: true }]);
});
test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers', () => { test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers', () => {
let activePlayback: unknown = null; let activePlayback: unknown = null;
@@ -153,6 +153,16 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
openJellyfinSetupWindow: ReturnType<typeof createOpenJellyfinSetupWindowHandler>; openJellyfinSetupWindow: ReturnType<typeof createOpenJellyfinSetupWindowHandler>;
}>; }>;
export function createRestartJellyfinRemoteSessionAfterSetupLoginHandler(deps: {
getCurrentSession: () => unknown | null;
startJellyfinRemoteSession: (options?: { explicit?: boolean }) => Promise<void>;
}) {
return async (): Promise<void> => {
const hasActiveSession = deps.getCurrentSession() !== null;
await deps.startJellyfinRemoteSession(hasActiveSession ? { explicit: true } : undefined);
};
}
export function composeJellyfinRuntimeHandlers( export function composeJellyfinRuntimeHandlers(
options: JellyfinRuntimeComposerOptions, options: JellyfinRuntimeComposerOptions,
): JellyfinRuntimeComposerResult { ): JellyfinRuntimeComposerResult {
@@ -268,12 +278,19 @@ export function composeJellyfinRuntimeHandlers(
const maybeFocusExistingJellyfinSetupWindow = createMaybeFocusExistingJellyfinSetupWindowHandler( const maybeFocusExistingJellyfinSetupWindow = createMaybeFocusExistingJellyfinSetupWindowHandler(
options.maybeFocusExistingJellyfinSetupWindowMainDeps, options.maybeFocusExistingJellyfinSetupWindowMainDeps,
); );
const restartJellyfinRemoteSessionAfterSetupLogin =
createRestartJellyfinRemoteSessionAfterSetupLoginHandler({
getCurrentSession: () => options.startJellyfinRemoteSessionMainDeps.getCurrentSession(),
startJellyfinRemoteSession: (startOptions) => startJellyfinRemoteSession(startOptions),
});
const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler( const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler(
createBuildOpenJellyfinSetupWindowMainDepsHandler({ createBuildOpenJellyfinSetupWindowMainDepsHandler({
...options.openJellyfinSetupWindowMainDeps, ...options.openJellyfinSetupWindowMainDeps,
maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow, maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow,
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(), getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
getJellyfinClientInfo: () => getJellyfinClientInfo(), getJellyfinClientInfo: () => getJellyfinClientInfo(),
restartRemoteSession: () => restartJellyfinRemoteSessionAfterSetupLogin(),
stopRemoteSession: () => stopJellyfinRemoteSession(),
})(), })(),
); );
@@ -1,6 +1,10 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config'; import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import { resolveConfig } from '../../config/resolve';
import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { createConfigSettingsRuntime } from './config-settings-runtime'; import { createConfigSettingsRuntime } from './config-settings-runtime';
@@ -10,7 +14,13 @@ test('config settings runtime exposes inferred Yomitan Anki deck lookup', async
fields: [], fields: [],
getConfigPath: () => '/tmp/config.jsonc', getConfigPath: () => '/tmp/config.jsonc',
getRawConfig: () => ({}), getRawConfig: () => ({}),
getConfig: () => deepCloneConfig(DEFAULT_CONFIG), getConfig: () => ({
...deepCloneConfig(DEFAULT_CONFIG),
ankiConnect: {
...deepCloneConfig(DEFAULT_CONFIG).ankiConnect,
deck: 'Configured',
},
}),
getWarnings: () => [], getWarnings: () => [],
reloadConfigStrict: () => reloadConfigStrict: () =>
({ ({
@@ -48,3 +58,62 @@ test('config settings runtime exposes inferred Yomitan Anki deck lookup', async
assert.ok(handler); assert.ok(handler);
assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Mining' }); assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Mining' });
}); });
test('config settings runtime persists inferred Yomitan Anki deck when config deck is empty', async () => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-settings-'));
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(configPath, '{"ankiConnect":{"deck":""}}\n', 'utf-8');
try {
let rawConfig = { ankiConnect: { deck: '' } };
let resolvedConfig = resolveConfig(rawConfig).resolved;
const runtime = createConfigSettingsRuntime({
fields: [],
getConfigPath: () => configPath,
getRawConfig: () => rawConfig,
getConfig: () => resolvedConfig,
getWarnings: () => [],
reloadConfigStrict: () => {
rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
resolvedConfig = resolveConfig(rawConfig).resolved;
return {
ok: true,
config: resolvedConfig,
warnings: [],
path: configPath,
};
},
getSettingsWindow: () => null,
setSettingsWindow: () => undefined,
createSettingsWindow: () => ({}) as never,
settingsHtmlPath: '/tmp/settings.html',
openPath: async () => '',
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: () =>
({
deckNames: async () => [],
fieldNamesForDeck: async () => [],
modelNamesForDeck: async () => [],
modelNames: async () => [],
modelFieldNames: async () => [],
}) as never,
getYomitanAnkiDeckName: async () => 'Minecraft',
ipcMain: {
handle: (channel, listener) => {
handlers.set(channel, listener);
},
},
ipcChannels: IPC_CHANNELS.request,
});
runtime.registerHandlers();
const handler = handlers.get(IPC_CHANNELS.request.getConfigSettingsYomitanAnkiDeckName);
assert.ok(handler);
assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Minecraft' });
assert.equal(JSON.parse(fs.readFileSync(configPath, 'utf-8')).ankiConnect.deck, 'Minecraft');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+26 -1
View File
@@ -193,13 +193,38 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
}; };
} }
function persistInferredYomitanDeckIfEmpty(deckName: string): void {
const normalizedDeckName = deckName.trim();
const configuredDeckName = deps.getConfig().ankiConnect?.deck?.trim() ?? '';
if (!normalizedDeckName || configuredDeckName) {
return;
}
const result = savePatch({
operations: [
{
op: 'set',
path: 'ankiConnect.deck',
value: normalizedDeckName,
},
],
});
if (!result.ok) {
deps.log?.(
`Failed to persist inferred Yomitan Anki deck: ${result.error ?? 'unknown error'}`,
);
}
}
async function getYomitanAnkiDeckName(): Promise<ConfigSettingsAnkiDeckResult> { async function getYomitanAnkiDeckName(): Promise<ConfigSettingsAnkiDeckResult> {
if (!deps.getYomitanAnkiDeckName) { if (!deps.getYomitanAnkiDeckName) {
return { ok: true, value: '' }; return { ok: true, value: '' };
} }
try { try {
const value = await deps.getYomitanAnkiDeckName(); const value = await deps.getYomitanAnkiDeckName();
return { ok: true, value: typeof value === 'string' ? value.trim() : '' }; const deckName = typeof value === 'string' ? value.trim() : '';
persistInferredYomitanDeckIfEmpty(deckName);
return { ok: true, value: deckName };
} catch (error) { } catch (error) {
return { return {
ok: false, ok: false,
@@ -40,6 +40,10 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
clearStoredSession: () => calls.push('clear-session'), clearStoredSession: () => calls.push('clear-session'),
patchJellyfinConfig: () => calls.push('patch'), patchJellyfinConfig: () => calls.push('patch'),
persistAuthenticatedSession: () => calls.push('persist'), persistAuthenticatedSession: () => calls.push('persist'),
restartRemoteSession: () => {
calls.push('restart-remote');
},
stopRemoteSession: () => calls.push('stop-remote'),
logInfo: (message) => calls.push(`info:${message}`), logInfo: (message) => calls.push(`info:${message}`),
logError: (message) => calls.push(`error:${message}`), logError: (message) => calls.push(`error:${message}`),
showMpvOsd: (message) => calls.push(`osd:${message}`), showMpvOsd: (message) => calls.push(`osd:${message}`),
@@ -95,6 +99,8 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
}, },
deps.getJellyfinClientInfo(), deps.getJellyfinClientInfo(),
); );
await deps.restartRemoteSession?.();
deps.stopRemoteSession?.();
deps.logInfo('ok'); deps.logInfo('ok');
deps.logError('bad', null); deps.logError('bad', null);
deps.showMpvOsd('toast'); deps.showMpvOsd('toast');
@@ -110,6 +116,8 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
'clear-session', 'clear-session',
'patch', 'patch',
'persist', 'persist',
'restart-remote',
'stop-remote',
'info:ok', 'info:ok',
'error:bad', 'error:bad',
'osd:toast', 'osd:toast',
@@ -20,6 +20,10 @@ export function createBuildOpenJellyfinSetupWindowMainDepsHandler(
persistAuthenticatedSession: deps.persistAuthenticatedSession persistAuthenticatedSession: deps.persistAuthenticatedSession
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo) ? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
: undefined, : undefined,
restartRemoteSession: deps.restartRemoteSession
? () => deps.restartRemoteSession?.()
: undefined,
stopRemoteSession: deps.stopRemoteSession ? () => deps.stopRemoteSession?.() : undefined,
logInfo: (message: string) => deps.logInfo(message), logInfo: (message: string) => deps.logInfo(message),
logError: (message: string, error: unknown) => deps.logError(message, error), logError: (message: string, error: unknown) => deps.logError(message, error),
showMpvOsd: (message: string) => deps.showMpvOsd(message), showMpvOsd: (message: string) => deps.showMpvOsd(message),
+13 -1
View File
@@ -160,6 +160,9 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
patchPayload = session; patchPayload = session;
calls.push('patch'); calls.push('patch');
}, },
restartRemoteSession: async () => {
calls.push('restart-remote');
},
logInfo: () => calls.push('info'), logInfo: () => calls.push('info'),
logError: () => calls.push('error'), logError: () => calls.push('error'),
showMpvOsd: (message) => calls.push(`osd:${message}`), showMpvOsd: (message) => calls.push(`osd:${message}`),
@@ -172,7 +175,14 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
'b', 'b',
); );
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(calls, ['save', 'patch', 'info', 'osd:Jellyfin login success', 'reload']); assert.deepEqual(calls, [
'save',
'patch',
'restart-remote',
'info',
'osd:Jellyfin login success',
'reload',
]);
assert.equal(authPassword, 'b'); assert.equal(authPassword, 'b');
assert.deepEqual(savedSession, { accessToken: 'token', userId: 'uid' }); assert.deepEqual(savedSession, { accessToken: 'token', userId: 'uid' });
assert.deepEqual(patchPayload, { assert.deepEqual(patchPayload, {
@@ -329,6 +339,7 @@ test('createHandleJellyfinSetupSubmissionHandler handles logout and done', async
saveStoredSession: () => calls.push('save'), saveStoredSession: () => calls.push('save'),
clearStoredSession: () => calls.push('clear'), clearStoredSession: () => calls.push('clear'),
patchJellyfinConfig: () => calls.push('patch'), patchJellyfinConfig: () => calls.push('patch'),
stopRemoteSession: () => calls.push('stop-remote'),
logInfo: (message) => calls.push(message), logInfo: (message) => calls.push(message),
logError: () => calls.push('error'), logError: () => calls.push('error'),
showMpvOsd: (message) => calls.push(`osd:${message}`), showMpvOsd: (message) => calls.push(`osd:${message}`),
@@ -340,6 +351,7 @@ test('createHandleJellyfinSetupSubmissionHandler handles logout and done', async
assert.equal(await handler('subminer://jellyfin-setup?action=done'), true); assert.equal(await handler('subminer://jellyfin-setup?action=done'), true);
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'clear', 'clear',
'stop-remote',
'Cleared stored Jellyfin auth session.', 'Cleared stored Jellyfin auth session.',
'osd:Jellyfin logged out', 'osd:Jellyfin logged out',
'reload', 'reload',
+10
View File
@@ -425,6 +425,8 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
clearStoredSession: () => void; clearStoredSession: () => void;
patchJellyfinConfig: (session: JellyfinSession) => void; patchJellyfinConfig: (session: JellyfinSession) => void;
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void; persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
restartRemoteSession?: () => Promise<void> | void;
stopRemoteSession?: () => void;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logError: (message: string, error: unknown) => void; logError: (message: string, error: unknown) => void;
showMpvOsd: (message: string) => void; showMpvOsd: (message: string) => void;
@@ -447,6 +449,7 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
if (submission.action === 'logout') { if (submission.action === 'logout') {
try { try {
deps.clearStoredSession(); deps.clearStoredSession();
deps.stopRemoteSession?.();
deps.logInfo('Cleared stored Jellyfin auth session.'); deps.logInfo('Cleared stored Jellyfin auth session.');
deps.showMpvOsd('Jellyfin logged out'); deps.showMpvOsd('Jellyfin logged out');
deps.reloadSetupWindow({ deps.reloadSetupWindow({
@@ -491,6 +494,7 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
deps.saveStoredSession({ accessToken: session.accessToken, userId: session.userId }); deps.saveStoredSession({ accessToken: session.accessToken, userId: session.userId });
deps.patchJellyfinConfig(session); deps.patchJellyfinConfig(session);
} }
await deps.restartRemoteSession?.();
deps.logInfo(`Jellyfin setup saved for ${session.username}.`); deps.logInfo(`Jellyfin setup saved for ${session.username}.`);
deps.showMpvOsd('Jellyfin login success'); deps.showMpvOsd('Jellyfin login success');
deps.reloadSetupWindow({ deps.reloadSetupWindow({
@@ -593,6 +597,8 @@ export function createOpenJellyfinSetupWindowHandler<
clearStoredSession: () => void; clearStoredSession: () => void;
patchJellyfinConfig: (session: JellyfinSession) => void; patchJellyfinConfig: (session: JellyfinSession) => void;
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void; persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
restartRemoteSession?: () => Promise<void> | void;
stopRemoteSession?: () => void;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logError: (message: string, error: unknown) => void; logError: (message: string, error: unknown) => void;
showMpvOsd: (message: string) => void; showMpvOsd: (message: string) => void;
@@ -633,6 +639,10 @@ export function createOpenJellyfinSetupWindowHandler<
persistAuthenticatedSession: deps.persistAuthenticatedSession persistAuthenticatedSession: deps.persistAuthenticatedSession
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo) ? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
: undefined, : undefined,
restartRemoteSession: deps.restartRemoteSession
? () => deps.restartRemoteSession?.()
: undefined,
stopRemoteSession: deps.stopRemoteSession ? () => deps.stopRemoteSession?.() : undefined,
logInfo: (message) => deps.logInfo(message), logInfo: (message) => deps.logInfo(message),
logError: (message, error) => deps.logError(message, error), logError: (message, error) => deps.logError(message, error),
showMpvOsd: (message) => deps.showMpvOsd(message), showMpvOsd: (message) => deps.showMpvOsd(message),
@@ -159,6 +159,30 @@ test('mpv subtitle timing handler runs AniList without timing tracker and passes
assert.deepEqual(calls, ['immersion:line:899:901', 'post-watch:901']); assert.deepEqual(calls, ['immersion:line:899:901', 'post-watch:901']);
}); });
test('mpv subtitle timing handler skips invalid cue pairs until timing is complete', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleTimingHandler({
recordImmersionSubtitleLine: (text, start, end) =>
calls.push(`immersion:${text}:${start}:${end}`),
hasSubtitleTimingTracker: () => true,
recordSubtitleTiming: (text, start, end) => calls.push(`timing:${text}:${start}:${end}`),
maybeRunAnilistPostWatchUpdate: async (options) => {
calls.push(`post-watch:${options?.watchedSeconds}`);
},
logError: () => calls.push('error'),
});
handler({ text: 'line', start: 953.991, end: 953.891 });
handler({ text: 'line', start: 953.991, end: 956.56 });
assert.deepEqual(calls, [
'post-watch:953.991',
'immersion:line:953.991:956.56',
'timing:line:953.991:956.56',
'post-watch:956.56',
]);
});
test('mpv event bindings register all expected events', () => { test('mpv event bindings register all expected events', () => {
const seenEvents: string[] = []; const seenEvents: string[] = [];
const bindHandlers = createBindMpvClientEventHandlers({ const bindHandlers = createBindMpvClientEventHandlers({
@@ -72,7 +72,7 @@ export function createHandleMpvSubtitleTimingHandler(deps: {
Number.isFinite(end) ? end : 0, Number.isFinite(end) ? end : 0,
); );
const options = watchedSeconds > 0 ? { watchedSeconds } : undefined; const options = watchedSeconds > 0 ? { watchedSeconds } : undefined;
if (text.trim()) { if (text.trim() && Number.isFinite(start) && Number.isFinite(end) && end > start) {
deps.recordImmersionSubtitleLine(text, start, end); deps.recordImmersionSubtitleLine(text, start, end);
if (deps.hasSubtitleTimingTracker()) { if (deps.hasSubtitleTimingTracker()) {
deps.recordSubtitleTiming(text, start, end); deps.recordSubtitleTiming(text, start, end);
+6
View File
@@ -34,6 +34,12 @@ test('overlay preload buffers only latest subtitle state until renderer listener
assert.match(source, /onSubtitle:\s*\(callback:[\s\S]+?onSubtitleSetEvent\(callback\);/); assert.match(source, /onSubtitle:\s*\(callback:[\s\S]+?onSubtitleSetEvent\(callback\);/);
}); });
test('overlay preload does not expose the old mining image toast IPC path', () => {
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8');
assert.doesNotMatch(source, /MiningImagePayload|onMiningImage|IPC_CHANNELS\.event\.miningImage/);
});
test('overlay preload exposes queued pointer recovery requests', () => { test('overlay preload exposes queued pointer recovery requests', () => {
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8'); const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8');
+25 -9
View File
@@ -15,6 +15,8 @@ import {
import { writeStatsCliCommandResponse } from './main/runtime/stats-cli-command'; import { writeStatsCliCommandResponse } from './main/runtime/stats-cli-command';
import { import {
createInvokeStatsWordHelperHandler, createInvokeStatsWordHelperHandler,
createReadStatsYomitanDeckNameHandler,
type StatsWordHelperSpawnOptions,
type StatsWordHelperResponse, type StatsWordHelperResponse,
} from './stats-word-helper-client'; } from './stats-word-helper-client';
@@ -58,19 +60,22 @@ async function waitForWordHelperResponse(responsePath: string): Promise<StatsWor
}; };
} }
const invokeStatsWordHelper = createInvokeStatsWordHelperHandler({ const statsWordHelperDeps = {
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)), createTempDir: (prefix: string) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
joinPath: (...parts) => path.join(...parts), joinPath: (...parts: string[]) => path.join(...parts),
spawnHelper: async (options) => { spawnHelper: async (options: StatsWordHelperSpawnOptions) => {
const childArgs = [ const childArgs = [
options.scriptPath, options.scriptPath,
'--stats-word-helper-response-path', '--stats-word-helper-response-path',
options.responsePath, options.responsePath,
'--stats-word-helper-user-data-path', '--stats-word-helper-user-data-path',
options.userDataPath, options.userDataPath,
'--stats-word-helper-word',
options.word,
]; ];
if (options.mode === 'deck-name') {
childArgs.push('--stats-word-helper-read-deck');
} else {
childArgs.push('--stats-word-helper-word', options.word ?? '');
}
const logLevel = readFlagValue(process.argv, '--log-level'); const logLevel = readFlagValue(process.argv, '--log-level');
if (logLevel) { if (logLevel) {
childArgs.push('--log-level', logLevel); childArgs.push('--log-level', logLevel);
@@ -88,10 +93,13 @@ const invokeStatsWordHelper = createInvokeStatsWordHelperHandler({
}); });
}, },
waitForResponse: waitForWordHelperResponse, waitForResponse: waitForWordHelperResponse,
removeDir: (targetPath) => { removeDir: (targetPath: string) => {
fs.rmSync(targetPath, { recursive: true, force: true }); fs.rmSync(targetPath, { recursive: true, force: true });
}, },
}); };
const invokeStatsWordHelper = createInvokeStatsWordHelperHandler(statsWordHelperDeps);
const readStatsYomitanDeckName = createReadStatsYomitanDeckNameHandler(statsWordHelperDeps);
const userDataPath = readFlagValue(process.argv, '--stats-user-data-path')?.trim(); const userDataPath = readFlagValue(process.argv, '--stats-user-data-path')?.trim();
const responsePath = readFlagValue(process.argv, '--stats-response-path')?.trim(); const responsePath = readFlagValue(process.argv, '--stats-response-path')?.trim();
@@ -195,7 +203,15 @@ async function main(): Promise<void> {
staticDir: statsDistPath, staticDir: statsDistPath,
tracker, tracker,
knownWordCachePath, knownWordCachePath,
ankiConnectConfig: config.ankiConnect, getAnkiConnectConfig: () => configService.reloadConfig().ankiConnect,
getYomitanAnkiDeckName: async () =>
await readStatsYomitanDeckName({
helperScriptPath: wordHelperScriptPath,
userDataPath: daemonUserDataPath,
}),
getSecondarySubtitleLanguages: () =>
configService.reloadConfig().secondarySub.secondarySubLanguages,
getStatsMiningAlassPath: () => configService.reloadConfig().subsync.alass_path,
addYomitanNote: async (word: string) => addYomitanNote: async (word: string) =>
await invokeStatsWordHelper({ await invokeStatsWordHelper({
helperScriptPath: wordHelperScriptPath, helperScriptPath: wordHelperScriptPath,
+37 -1
View File
@@ -1,6 +1,9 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createInvokeStatsWordHelperHandler } from './stats-word-helper-client'; import {
createInvokeStatsWordHelperHandler,
createReadStatsYomitanDeckNameHandler,
} from './stats-word-helper-client';
test('word helper client returns note id when helper responds before exit', async () => { test('word helper client returns note id when helper responds before exit', async () => {
const calls: string[] = []; const calls: string[] = [];
@@ -36,6 +39,39 @@ test('word helper client returns note id when helper responds before exit', asyn
]); ]);
}); });
test('word helper client returns Yomitan deck name from helper read-deck mode', async () => {
const calls: string[] = [];
const handler = createReadStatsYomitanDeckNameHandler({
createTempDir: () => '/tmp/stats-word-helper',
joinPath: (...parts) => parts.join('/'),
spawnHelper: async (options) => {
calls.push(
`spawnHelper:${options.scriptPath}:${options.responsePath}:${options.userDataPath}:${options.mode}`,
);
return new Promise<number>((resolve) => setTimeout(() => resolve(0), 20));
},
waitForResponse: async (responsePath) => {
calls.push(`waitForResponse:${responsePath}`);
return { ok: true, deckName: ' Minecraft ' };
},
removeDir: (targetPath) => {
calls.push(`removeDir:${targetPath}`);
},
});
const deckName = await handler({
helperScriptPath: '/tmp/stats-word-helper.js',
userDataPath: '/tmp/SubMiner',
});
assert.equal(deckName, 'Minecraft');
assert.deepEqual(calls, [
'spawnHelper:/tmp/stats-word-helper.js:/tmp/stats-word-helper/response.json:/tmp/SubMiner:deck-name',
'waitForResponse:/tmp/stats-word-helper/response.json',
'removeDir:/tmp/stats-word-helper',
]);
});
test('word helper client throws helper response errors', async () => { test('word helper client throws helper response errors', async () => {
const handler = createInvokeStatsWordHelperHandler({ const handler = createInvokeStatsWordHelperHandler({
createTempDir: () => '/tmp/stats-word-helper', createTempDir: () => '/tmp/stats-word-helper',
+65 -6
View File
@@ -1,18 +1,24 @@
export type StatsWordHelperResponse = { export type StatsWordHelperResponse = {
ok: boolean; ok: boolean;
noteId?: number; noteId?: number;
deckName?: string;
error?: string; error?: string;
}; };
type StatsWordHelperMode = 'add-word' | 'deck-name';
export type StatsWordHelperSpawnOptions = {
scriptPath: string;
responsePath: string;
userDataPath: string;
mode: StatsWordHelperMode;
word?: string;
};
export function createInvokeStatsWordHelperHandler(deps: { export function createInvokeStatsWordHelperHandler(deps: {
createTempDir: (prefix: string) => string; createTempDir: (prefix: string) => string;
joinPath: (...parts: string[]) => string; joinPath: (...parts: string[]) => string;
spawnHelper: (options: { spawnHelper: (options: StatsWordHelperSpawnOptions) => Promise<number>;
scriptPath: string;
responsePath: string;
userDataPath: string;
word: string;
}) => Promise<number>;
waitForResponse: (responsePath: string) => Promise<StatsWordHelperResponse>; waitForResponse: (responsePath: string) => Promise<StatsWordHelperResponse>;
removeDir: (targetPath: string) => void; removeDir: (targetPath: string) => void;
}) { }) {
@@ -29,6 +35,7 @@ export function createInvokeStatsWordHelperHandler(deps: {
scriptPath: options.helperScriptPath, scriptPath: options.helperScriptPath,
responsePath, responsePath,
userDataPath: options.userDataPath, userDataPath: options.userDataPath,
mode: 'add-word',
word: options.word, word: options.word,
}); });
@@ -64,3 +71,55 @@ export function createInvokeStatsWordHelperHandler(deps: {
} }
}; };
} }
export function createReadStatsYomitanDeckNameHandler(deps: {
createTempDir: (prefix: string) => string;
joinPath: (...parts: string[]) => string;
spawnHelper: (options: StatsWordHelperSpawnOptions) => Promise<number>;
waitForResponse: (responsePath: string) => Promise<StatsWordHelperResponse>;
removeDir: (targetPath: string) => void;
}) {
return async (options: { helperScriptPath: string; userDataPath: string }): Promise<string> => {
const tempDir = deps.createTempDir('subminer-stats-word-helper-');
const responsePath = deps.joinPath(tempDir, 'response.json');
try {
const helperExitPromise = deps.spawnHelper({
scriptPath: options.helperScriptPath,
responsePath,
userDataPath: options.userDataPath,
mode: 'deck-name',
});
const startupResult = await Promise.race([
deps
.waitForResponse(responsePath)
.then((response) => ({ kind: 'response' as const, response })),
helperExitPromise.then((status) => ({ kind: 'exit' as const, status })),
]);
let response: StatsWordHelperResponse;
if (startupResult.kind === 'response') {
response = startupResult.response;
} else {
if (startupResult.status !== 0) {
throw new Error(
`Stats word helper exited before response (status ${startupResult.status}).`,
);
}
response = await deps.waitForResponse(responsePath);
}
const exitStatus = await helperExitPromise;
if (exitStatus !== 0) {
throw new Error(`Stats word helper exited with status ${exitStatus}.`);
}
if (!response.ok || typeof response.deckName !== 'string') {
throw new Error(response.error || 'Stats word helper failed.');
}
return response.deckName.trim();
} finally {
deps.removeDir(tempDir);
}
};
}
+34 -38
View File
@@ -7,6 +7,7 @@ import { createLogger, setLogLevel } from './logger';
import { loadYomitanExtension } from './core/services/yomitan-extension-loader'; import { loadYomitanExtension } from './core/services/yomitan-extension-loader';
import { import {
addYomitanNoteViaSearch, addYomitanNoteViaSearch,
getYomitanCurrentAnkiDeckName,
syncYomitanDefaultAnkiServer, syncYomitanDefaultAnkiServer,
} from './core/services/tokenizer/yomitan-parser-runtime'; } from './core/services/tokenizer/yomitan-parser-runtime';
import type { StatsWordHelperResponse } from './stats-word-helper-client'; import type { StatsWordHelperResponse } from './stats-word-helper-client';
@@ -54,13 +55,14 @@ function writeResponse(responsePath: string | undefined, payload: StatsWordHelpe
const responsePath = readFlagValue(process.argv, '--stats-word-helper-response-path')?.trim(); const responsePath = readFlagValue(process.argv, '--stats-word-helper-response-path')?.trim();
const userDataPath = readFlagValue(process.argv, '--stats-word-helper-user-data-path')?.trim(); const userDataPath = readFlagValue(process.argv, '--stats-word-helper-user-data-path')?.trim();
const word = readFlagValue(process.argv, '--stats-word-helper-word'); const word = readFlagValue(process.argv, '--stats-word-helper-word');
const readDeck = process.argv.includes('--stats-word-helper-read-deck');
const logLevel = readFlagValue(process.argv, '--log-level'); const logLevel = readFlagValue(process.argv, '--log-level');
if (logLevel) { if (logLevel) {
setLogLevel(logLevel, 'cli'); setLogLevel(logLevel, 'cli');
} }
if (!userDataPath || !word) { if (!userDataPath || (!word && !readDeck)) {
writeResponse(responsePath, { writeResponse(responsePath, {
ok: false, ok: false,
error: 'Missing stats word helper arguments.', error: 'Missing stats word helper arguments.',
@@ -125,48 +127,42 @@ async function main(): Promise<void> {
throw new Error('Yomitan extension failed to load.'); throw new Error('Yomitan extension failed to load.');
} }
const yomitanDeps = {
getYomitanExt: () => yomitanExt,
getYomitanSession: () => yomitanSession,
getYomitanParserWindow: () => yomitanParserWindow,
setYomitanParserWindow: (window: BrowserWindow | null) => {
yomitanParserWindow = window;
},
getYomitanParserReadyPromise: () => yomitanParserReadyPromise,
setYomitanParserReadyPromise: (promise: Promise<void> | null) => {
yomitanParserReadyPromise = promise;
},
getYomitanParserInitPromise: () => yomitanParserInitPromise,
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => {
yomitanParserInitPromise = promise;
},
};
if (readDeck) {
const deckName = await getYomitanCurrentAnkiDeckName(yomitanDeps, logger);
writeResponse(responsePath, {
ok: true,
deckName,
});
cleanup();
app.exit(0);
return;
}
await syncYomitanDefaultAnkiServer( await syncYomitanDefaultAnkiServer(
config.ankiConnect?.url || 'http://127.0.0.1:8765', config.ankiConnect?.url || 'http://127.0.0.1:8765',
{ yomitanDeps,
getYomitanExt: () => yomitanExt,
getYomitanSession: () => yomitanSession,
getYomitanParserWindow: () => yomitanParserWindow,
setYomitanParserWindow: (window) => {
yomitanParserWindow = window;
},
getYomitanParserReadyPromise: () => yomitanParserReadyPromise,
setYomitanParserReadyPromise: (promise) => {
yomitanParserReadyPromise = promise;
},
getYomitanParserInitPromise: () => yomitanParserInitPromise,
setYomitanParserInitPromise: (promise) => {
yomitanParserInitPromise = promise;
},
},
logger, logger,
{ forceOverride: true }, { forceOverride: true, deck: config.ankiConnect?.deck },
); );
const addResult = await addYomitanNoteViaSearch( const addResult = await addYomitanNoteViaSearch(word!, yomitanDeps, logger);
word!,
{
getYomitanExt: () => yomitanExt,
getYomitanSession: () => yomitanSession,
getYomitanParserWindow: () => yomitanParserWindow,
setYomitanParserWindow: (window) => {
yomitanParserWindow = window;
},
getYomitanParserReadyPromise: () => yomitanParserReadyPromise,
setYomitanParserReadyPromise: (promise) => {
yomitanParserReadyPromise = promise;
},
getYomitanParserInitPromise: () => yomitanParserInitPromise,
setYomitanParserInitPromise: (promise) => {
yomitanParserInitPromise = promise;
},
},
logger,
);
const noteId = addResult.noteId; const noteId = addResult.noteId;
if (typeof noteId !== 'number') { if (typeof noteId !== 'number') {
+19
View File
@@ -30,6 +30,11 @@ const VocabularyTab = lazy(() =>
default: module.VocabularyTab, default: module.VocabularyTab,
})), })),
); );
const SearchTab = lazy(() =>
import('./components/search/SearchTab').then((module) => ({
default: module.SearchTab,
})),
);
const SessionsTab = lazy(() => const SessionsTab = lazy(() =>
import('./components/sessions/SessionsTab').then((module) => ({ import('./components/sessions/SessionsTab').then((module) => ({
default: module.SessionsTab, default: module.SessionsTab,
@@ -183,6 +188,7 @@ export function App() {
<OverviewTab <OverviewTab
onNavigateToMediaDetail={navigateToOverviewMediaDetail} onNavigateToMediaDetail={navigateToOverviewMediaDetail}
onNavigateToSession={navigateToSession} onNavigateToSession={navigateToSession}
isActive={activeTab === 'overview'}
/> />
</section> </section>
) : null} ) : null}
@@ -239,6 +245,19 @@ export function App() {
</Suspense> </Suspense>
</section> </section>
) : null} ) : null}
{mountedTabs.has('search') ? (
<section
id="panel-search"
role="tabpanel"
aria-labelledby="tab-search"
hidden={activeTab !== 'search'}
className="animate-fade-in"
>
<Suspense fallback={<LoadingSurface label="Loading search..." />}>
<SearchTab />
</Suspense>
</section>
) : null}
{mountedTabs.has('sessions') ? ( {mountedTabs.has('sessions') ? (
<section <section
id="panel-sessions" id="panel-sessions"
@@ -29,7 +29,7 @@ export function AnilistSelector({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [linking, setLinking] = useState<number | null>(null); const [linking, setLinking] = useState<number | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus();
@@ -53,7 +53,7 @@ export function AnilistSelector({
const handleInput = (value: string) => { const handleInput = (value: string) => {
setQuery(value); setQuery(value);
clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(value), 400); debounceRef.current = setTimeout(() => doSearch(value), 400);
}; };
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AnimeCard } from './AnimeCard';
test('AnimeCard includes linked AniList id in cover URLs to avoid stale library covers', () => {
const markup = renderToStaticMarkup(
<AnimeCard
anime={{
animeId: 42,
canonicalTitle: 'Test Anime',
anilistId: 21699,
totalSessions: 1,
totalActiveMs: 600_000,
totalCards: 0,
totalTokensSeen: 100,
episodeCount: 1,
episodesTotal: 10,
lastWatchedMs: 1_000,
}}
onClick={() => {}}
/>,
);
assert.match(markup, /\/api\/stats\/anime\/42\/cover\?coverRetry=21699/);
});
+1
View File
@@ -18,6 +18,7 @@ export function AnimeCard({ anime, onClick }: AnimeCardProps) {
<AnimeCoverImage <AnimeCoverImage
animeId={anime.animeId} animeId={anime.animeId}
title={anime.canonicalTitle} title={anime.canonicalTitle}
coverRetryToken={anime.anilistId ?? 0}
className="w-full aspect-[3/4] rounded-t-lg transition-transform duration-200 group-hover:scale-105" className="w-full aspect-[3/4] rounded-t-lg transition-transform duration-200 group-hover:scale-105"
/> />
</div> </div>
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AnimeCoverImage } from './AnimeCoverImage';
import { AnimeHeader } from './AnimeHeader';
test('AnimeCoverImage includes manual relink cover retry tokens', () => {
const markup = renderToStaticMarkup(
<AnimeCoverImage animeId={42} title="Test Anime" coverRetryToken={7} />,
);
assert.match(markup, /\/api\/stats\/anime\/42\/cover\?coverRetry=7/);
});
test('AnimeHeader uses the linked AniList id to avoid stale cached cover art', () => {
const markup = renderToStaticMarkup(
<AnimeHeader
detail={{
animeId: 42,
canonicalTitle: 'Test Anime',
anilistId: 21699,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
description: null,
totalSessions: 0,
totalActiveMs: 0,
totalCards: 0,
totalTokensSeen: 0,
totalLinesSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
episodeCount: 1,
lastWatchedMs: 0,
}}
anilistEntries={[]}
/>,
);
assert.match(markup, /\/api\/stats\/anime\/42\/cover\?coverRetry=21699/);
});
+10 -25
View File
@@ -1,35 +1,20 @@
import { useState } from 'react'; import { RetryingCoverImage } from '../common/RetryingCoverImage';
import { getStatsClient } from '../../hooks/useStatsApi'; import { getStatsClient } from '../../hooks/useStatsApi';
interface AnimeCoverImageProps { interface AnimeCoverImageProps {
animeId: number; animeId: number;
title: string; title: string;
coverRetryToken?: number;
className?: string; className?: string;
} }
export function AnimeCoverImage({ animeId, title, className = '' }: AnimeCoverImageProps) { export function AnimeCoverImage({
const [failed, setFailed] = useState(false); animeId,
const fallbackChar = title.charAt(0) || '?'; title,
coverRetryToken = 0,
className = '',
}: AnimeCoverImageProps) {
const src = getStatsClient().getAnimeCoverUrl(animeId, coverRetryToken);
if (failed) { return <RetryingCoverImage src={src} alt={title} fallbackLabel={title} className={className} />;
return (
<div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
>
{fallbackChar}
</div>
);
}
const src = getStatsClient().getAnimeCoverUrl(animeId);
return (
<img
src={src}
alt={title}
loading="lazy"
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
/>
);
} }
@@ -142,8 +142,13 @@ export function AnimeDetailView({
}: AnimeDetailViewProps) { }: AnimeDetailViewProps) {
const { data, loading, error, reload } = useAnimeDetail(animeId); const { data, loading, error, reload } = useAnimeDetail(animeId);
const [showAnilistSelector, setShowAnilistSelector] = useState(false); const [showAnilistSelector, setShowAnilistSelector] = useState(false);
const [coverRetryToken, setCoverRetryToken] = useState(0);
const knownWordsSummary = useAnimeKnownWords(animeId); const knownWordsSummary = useAnimeKnownWords(animeId);
useEffect(() => {
setCoverRetryToken(0);
}, [animeId]);
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>; if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>; if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>; if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
@@ -161,6 +166,7 @@ export function AnimeDetailView({
<AnimeHeader <AnimeHeader
detail={detail} detail={detail}
anilistEntries={anilistEntries ?? []} anilistEntries={anilistEntries ?? []}
coverRetryToken={coverRetryToken}
onChangeAnilist={() => setShowAnilistSelector(true)} onChangeAnilist={() => setShowAnilistSelector(true)}
/> />
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} /> <AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
@@ -177,6 +183,7 @@ export function AnimeDetailView({
onClose={() => setShowAnilistSelector(false)} onClose={() => setShowAnilistSelector(false)}
onLinked={() => { onLinked={() => {
setShowAnilistSelector(false); setShowAnilistSelector(false);
setCoverRetryToken((value) => value + 1);
reload(); reload();
}} }}
/> />
+9 -1
View File
@@ -4,6 +4,7 @@ import type { AnimeDetailData, AnilistEntry } from '../../types/stats';
interface AnimeHeaderProps { interface AnimeHeaderProps {
detail: AnimeDetailData['detail']; detail: AnimeDetailData['detail'];
anilistEntries: AnilistEntry[]; anilistEntries: AnilistEntry[];
coverRetryToken?: number;
onChangeAnilist?: () => void; onChangeAnilist?: () => void;
} }
@@ -26,19 +27,26 @@ function AnilistButton({ entry }: { entry: AnilistEntry }) {
); );
} }
export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHeaderProps) { export function AnimeHeader({
detail,
anilistEntries,
coverRetryToken = 0,
onChangeAnilist,
}: AnimeHeaderProps) {
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter( const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter(
(t): t is string => t != null && t !== detail.canonicalTitle, (t): t is string => t != null && t !== detail.canonicalTitle,
); );
const uniqueAltTitles = [...new Set(altTitles)]; const uniqueAltTitles = [...new Set(altTitles)];
const hasMultipleEntries = anilistEntries.length > 1; const hasMultipleEntries = anilistEntries.length > 1;
const coverCacheToken = (detail.anilistId ?? 0) * 1_000_000 + coverRetryToken;
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
<AnimeCoverImage <AnimeCoverImage
animeId={detail.animeId} animeId={detail.animeId}
title={detail.canonicalTitle} title={detail.canonicalTitle}
coverRetryToken={coverCacheToken}
className="w-32 h-44 rounded-lg shrink-0" className="w-32 h-44 rounded-lg shrink-0"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
+21 -4
View File
@@ -1,13 +1,18 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { useAnimeLibrary } from '../../hooks/useAnimeLibrary'; import { useAnimeLibrary } from '../../hooks/useAnimeLibrary';
import { formatDuration } from '../../lib/formatters'; import { formatDuration } from '../../lib/formatters';
import {
getLibraryCardSizeStorage,
readLibraryCardSizePreference,
type LibraryCardSize,
writeLibraryCardSizePreference,
} from '../../lib/library-card-size';
import { AnimeCard } from './AnimeCard'; import { AnimeCard } from './AnimeCard';
import { AnimeDetailView } from './AnimeDetailView'; import { AnimeDetailView } from './AnimeDetailView';
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes'; type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
type CardSize = 'sm' | 'md' | 'lg';
const GRID_CLASSES: Record<CardSize, string> = { const GRID_CLASSES: Record<LibraryCardSize, string> = {
sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11', sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11',
md: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-9', md: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-9',
lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7', lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7',
@@ -51,9 +56,21 @@ export function AnimeTab({
const { anime, loading, error } = useAnimeLibrary(); const { anime, loading, error } = useAnimeLibrary();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('lastWatched'); const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
const [cardSize, setCardSize] = useState<CardSize>('md'); const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
readLibraryCardSizePreference(
getLibraryCardSizeStorage(typeof window === 'undefined' ? null : window),
),
);
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null); const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
function handleCardSizeChange(size: LibraryCardSize): void {
setCardSize(size);
writeLibraryCardSizePreference(
getLibraryCardSizeStorage(typeof window === 'undefined' ? null : window),
size,
);
}
useEffect(() => { useEffect(() => {
if (initialAnimeId != null) { if (initialAnimeId != null) {
setSelectedAnimeId(initialAnimeId); setSelectedAnimeId(initialAnimeId);
@@ -113,7 +130,7 @@ export function AnimeTab({
{(['sm', 'md', 'lg'] as const).map((size) => ( {(['sm', 'md', 'lg'] as const).map((size) => (
<button <button
key={size} key={size}
onClick={() => setCardSize(size)} onClick={() => handleCardSizeChange(size)}
className={`px-2 py-1 rounded-md text-xs transition-colors ${ className={`px-2 py-1 rounded-md text-xs transition-colors ${
cardSize === size cardSize === size
? 'bg-ctp-surface2 text-ctp-text shadow-sm' ? 'bg-ctp-surface2 text-ctp-text shadow-sm'
@@ -0,0 +1,32 @@
interface DeleteProgressToastProps {
/** Number of sessions currently being deleted. The toast is hidden when 0. */
count: number;
}
/**
* Fixed-position toast shown while session deletions are in flight.
*
* The per-row delete buttons are only visible on hover, so once the confirm
* dialog closes the user has no signal that a (potentially slow) batch delete
* is still running. This stays on screen, independent of hover, until the work
* finishes.
*/
export function DeleteProgressToast({ count }: DeleteProgressToastProps) {
if (count <= 0) return null;
return (
<div
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
>
<span
aria-hidden="true"
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
/>
<span className="text-sm text-ctp-text">
Deleting {count} session{count === 1 ? '' : 's'}&hellip;
</span>
</div>
);
}
@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { appendCoverRetryToken, getCoverRetryDelayMs } from '../../lib/cover-retry';
interface RetryingCoverImageProps {
src: string;
alt: string;
fallbackLabel: string;
className?: string;
fallbackTextClassName?: string;
loading?: 'eager' | 'lazy';
}
export function RetryingCoverImage({
src,
alt,
fallbackLabel,
className = '',
fallbackTextClassName = 'text-2xl',
loading = 'lazy',
}: RetryingCoverImageProps) {
const [failed, setFailed] = useState(false);
const [retryToken, setRetryToken] = useState(0);
const fallbackChar = fallbackLabel.charAt(0) || '?';
useEffect(() => {
setFailed(false);
setRetryToken(0);
}, [src]);
useEffect(() => {
if (!failed) return;
const timer = setTimeout(() => {
setRetryToken((value) => value + 1);
setFailed(false);
}, getCoverRetryDelayMs(retryToken));
return () => clearTimeout(timer);
}, [failed, retryToken]);
if (failed) {
return (
<div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 ${fallbackTextClassName} font-bold ${className}`}
>
{fallbackChar}
</div>
);
}
return (
<img
src={appendCoverRetryToken(src, retryToken)}
alt={alt}
loading={loading}
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
onLoad={() => setFailed(false)}
/>
);
}
+2 -1
View File
@@ -1,6 +1,6 @@
import { useRef, type KeyboardEvent } from 'react'; import { useRef, type KeyboardEvent } from 'react';
export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'sessions'; export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'search' | 'sessions';
interface Tab { interface Tab {
id: TabId; id: TabId;
@@ -12,6 +12,7 @@ const TABS: Tab[] = [
{ id: 'anime', label: 'Library' }, { id: 'anime', label: 'Library' },
{ id: 'trends', label: 'Trends' }, { id: 'trends', label: 'Trends' },
{ id: 'vocabulary', label: 'Vocabulary' }, { id: 'vocabulary', label: 'Vocabulary' },
{ id: 'search', label: 'Search' },
{ id: 'sessions', label: 'Sessions' }, { id: 'sessions', label: 'Sessions' },
]; ];
+2 -24
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { RetryingCoverImage } from '../common/RetryingCoverImage';
import { resolveMediaCoverApiUrl } from '../../lib/media-library-grouping'; import { resolveMediaCoverApiUrl } from '../../lib/media-library-grouping';
interface CoverImageProps { interface CoverImageProps {
@@ -9,31 +9,9 @@ interface CoverImageProps {
} }
export function CoverImage({ videoId, title, src = null, className = '' }: CoverImageProps) { export function CoverImage({ videoId, title, src = null, className = '' }: CoverImageProps) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
const resolvedSrc = src?.trim() || resolveMediaCoverApiUrl(videoId); const resolvedSrc = src?.trim() || resolveMediaCoverApiUrl(videoId);
useEffect(() => {
setFailed(false);
}, [resolvedSrc]);
if (failed) {
return ( return (
<div <RetryingCoverImage src={resolvedSrc} alt={title} fallbackLabel={title} className={className} />
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
>
{fallbackChar}
</div>
);
}
return (
<img
src={resolvedSrc}
alt={title}
loading="lazy"
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
/>
); );
} }
@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { getCoverImageSrc, type CoverImageMap } from '../../lib/cover-images';
interface CoverThumbnailProps {
animeId: number | null;
videoId: number | null;
title: string;
coverImages: CoverImageMap;
}
export function CoverThumbnail({ animeId, videoId, title, coverImages }: CoverThumbnailProps) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
const fallback = (
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
{fallbackChar}
</div>
);
const src =
animeId != null
? getCoverImageSrc(coverImages, 'anime', animeId)
: getCoverImageSrc(coverImages, 'media', videoId);
useEffect(() => {
setFailed(false);
}, [src]);
if (!src || failed) {
return fallback;
}
return (
<img
src={src}
alt=""
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
onError={() => setFailed(true)}
/>
);
}
+10 -1
View File
@@ -6,6 +6,7 @@ import { StreakCalendar } from './StreakCalendar';
import { RecentSessions } from './RecentSessions'; import { RecentSessions } from './RecentSessions';
import { TrackingSnapshot } from './TrackingSnapshot'; import { TrackingSnapshot } from './TrackingSnapshot';
import { TrendChart } from '../trends/TrendChart'; import { TrendChart } from '../trends/TrendChart';
import { DeleteProgressToast } from '../common/DeleteProgressToast';
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data'; import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
import { apiClient } from '../../lib/api-client'; import { apiClient } from '../../lib/api-client';
import { getStatsClient } from '../../hooks/useStatsApi'; import { getStatsClient } from '../../hooks/useStatsApi';
@@ -19,9 +20,14 @@ import type { SessionSummary } from '../../types/stats';
interface OverviewTabProps { interface OverviewTabProps {
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void; onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void; onNavigateToSession: (sessionId: number) => void;
isActive?: boolean;
} }
export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: OverviewTabProps) { export function OverviewTab({
onNavigateToMediaDetail,
onNavigateToSession,
isActive = true,
}: OverviewTabProps) {
const { data, sessions, setSessions, loading, error } = useOverview(); const { data, sessions, setSessions, loading, error } = useOverview();
const { calendar, loading: calLoading } = useStreakCalendar(90); const { calendar, loading: calLoading } = useStreakCalendar(90);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -152,7 +158,10 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
onDeleteDayGroup={handleDeleteDayGroup} onDeleteDayGroup={handleDeleteDayGroup}
onDeleteAnimeGroup={handleDeleteAnimeGroup} onDeleteAnimeGroup={handleDeleteAnimeGroup}
deletingIds={deletingIds} deletingIds={deletingIds}
isActive={isActive}
/> />
<DeleteProgressToast count={deletingIds.size} />
</div> </div>
); );
} }
@@ -5,7 +5,9 @@ import {
formatNumber, formatNumber,
formatSessionDayLabel, formatSessionDayLabel,
} from '../../lib/formatters'; } from '../../lib/formatters';
import { BASE_URL } from '../../lib/api-client'; import { CoverThumbnail } from './CoverThumbnail';
import { useCoverImages } from '../../hooks/useCoverImages';
import type { CoverImageMap } from '../../lib/cover-images';
import { getSessionDisplayWordCount } from '../../lib/session-word-count'; import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { getSessionNavigationTarget } from '../../lib/stats-navigation'; import { getSessionNavigationTarget } from '../../lib/stats-navigation';
import type { SessionSummary } from '../../types/stats'; import type { SessionSummary } from '../../types/stats';
@@ -18,6 +20,7 @@ interface RecentSessionsProps {
onDeleteDayGroup: (dayLabel: string, daySessions: SessionSummary[]) => void; onDeleteDayGroup: (dayLabel: string, daySessions: SessionSummary[]) => void;
onDeleteAnimeGroup: (sessions: SessionSummary[]) => void; onDeleteAnimeGroup: (sessions: SessionSummary[]) => void;
deletingIds: Set<number>; deletingIds: Set<number>;
isActive?: boolean;
} }
interface AnimeGroup { interface AnimeGroup {
@@ -85,53 +88,20 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
return Array.from(map.values()); return Array.from(map.values());
} }
function CoverThumbnail({
animeId,
videoId,
title,
}: {
animeId: number | null;
videoId: number | null;
title: string;
}) {
const fallbackChar = title.charAt(0) || '?';
const [isFallback, setIsFallback] = useState(false);
if ((!animeId && !videoId) || isFallback) {
return (
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
{fallbackChar}
</div>
);
}
const src =
animeId != null
? `${BASE_URL}/api/stats/anime/${animeId}/cover`
: `${BASE_URL}/api/stats/media/${videoId}/cover`;
return (
<img
src={src}
alt=""
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
onError={() => setIsFallback(true)}
/>
);
}
function SessionItem({ function SessionItem({
session, session,
onNavigateToMediaDetail, onNavigateToMediaDetail,
onNavigateToSession, onNavigateToSession,
onDelete, onDelete,
deleteDisabled, deleteDisabled,
coverImages,
}: { }: {
session: SessionSummary; session: SessionSummary;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void; onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void; onNavigateToSession: (sessionId: number) => void;
onDelete: () => void; onDelete: () => void;
deleteDisabled: boolean; deleteDisabled: boolean;
coverImages: CoverImageMap;
}) { }) {
const displayWordCount = getSessionDisplayWordCount(session); const displayWordCount = getSessionDisplayWordCount(session);
const navigationTarget = getSessionNavigationTarget(session); const navigationTarget = getSessionNavigationTarget(session);
@@ -153,6 +123,7 @@ function SessionItem({
animeId={session.animeId} animeId={session.animeId}
videoId={session.videoId} videoId={session.videoId}
title={session.canonicalTitle ?? 'Unknown'} title={session.canonicalTitle ?? 'Unknown'}
coverImages={coverImages}
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate"> <div className="text-sm font-medium text-ctp-text truncate">
@@ -205,6 +176,7 @@ function AnimeGroupRow({
onDeleteSession, onDeleteSession,
onDeleteAnimeGroup, onDeleteAnimeGroup,
deletingIds, deletingIds,
coverImages,
}: { }: {
group: AnimeGroup; group: AnimeGroup;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void; onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
@@ -212,6 +184,7 @@ function AnimeGroupRow({
onDeleteSession: (session: SessionSummary) => void; onDeleteSession: (session: SessionSummary) => void;
onDeleteAnimeGroup: (group: AnimeGroup) => void; onDeleteAnimeGroup: (group: AnimeGroup) => void;
deletingIds: Set<number>; deletingIds: Set<number>;
coverImages: CoverImageMap;
}) { }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId)); const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId));
@@ -225,6 +198,7 @@ function AnimeGroupRow({
onNavigateToSession={onNavigateToSession} onNavigateToSession={onNavigateToSession}
onDelete={() => onDeleteSession(s)} onDelete={() => onDeleteSession(s)}
deleteDisabled={deletingIds.has(s.sessionId)} deleteDisabled={deletingIds.has(s.sessionId)}
coverImages={coverImages}
/> />
); );
} }
@@ -247,6 +221,7 @@ function AnimeGroupRow({
animeId={group.animeId} animeId={group.animeId}
videoId={mostRecentSession.videoId} videoId={mostRecentSession.videoId}
title={displayTitle} title={displayTitle}
coverImages={coverImages}
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div> <div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
@@ -319,6 +294,7 @@ function AnimeGroupRow({
animeId={s.animeId} animeId={s.animeId}
videoId={s.videoId} videoId={s.videoId}
title={s.canonicalTitle ?? 'Unknown'} title={s.canonicalTitle ?? 'Unknown'}
coverImages={coverImages}
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-subtext1 truncate"> <div className="text-sm font-medium text-ctp-subtext1 truncate">
@@ -377,7 +353,10 @@ export function RecentSessions({
onDeleteDayGroup, onDeleteDayGroup,
onDeleteAnimeGroup, onDeleteAnimeGroup,
deletingIds, deletingIds,
isActive = true,
}: RecentSessionsProps) { }: RecentSessionsProps) {
const coverImages = useCoverImages(sessions, { enabled: isActive });
if (sessions.length === 0) { if (sessions.length === 0) {
return ( return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
@@ -422,6 +401,7 @@ export function RecentSessions({
onDeleteSession={onDeleteSession} onDeleteSession={onDeleteSession}
onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)} onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)}
deletingIds={deletingIds} deletingIds={deletingIds}
coverImages={coverImages}
/> />
))} ))}
</div> </div>
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { formatSentenceSearchMatchCountLabel } from './SearchTab';
const SEARCH_TAB_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'SearchTab.tsx');
test('formatSentenceSearchMatchCountLabel uses singular label for one result', () => {
assert.equal(formatSentenceSearchMatchCountLabel(1), 'Match');
assert.equal(formatSentenceSearchMatchCountLabel(0), 'Matches');
assert.equal(formatSentenceSearchMatchCountLabel(2), 'Matches');
});
test('SearchTab forwards stored secondary subtitle text when mining from search results', () => {
const source = fs.readFileSync(SEARCH_TAB_PATH, 'utf8');
assert.match(source, /buildStatsMineCardParams\(result,\s*searchedWord,\s*mode\)/);
});
test('SearchTab enables headword sentence search by default and forwards the toggle', () => {
const source = fs.readFileSync(SEARCH_TAB_PATH, 'utf8');
assert.match(source, /const \[searchByHeadword,\s*setSearchByHeadword\] = useState\(true\);/);
assert.match(
source,
/apiClient\s*\.\s*searchSentences\(trimmed,\s*SEARCH_LIMIT,\s*searchByHeadword\)/,
);
assert.match(source, /checked=\{searchByHeadword\}/);
assert.match(source, /setSearchByHeadword\(event\.target\.checked\)/);
});
+276
View File
@@ -0,0 +1,276 @@
import { useEffect, useRef, useState } from 'react';
import { apiClient } from '../../lib/api-client';
import {
getSentenceSearchMineAvailability,
renderSentenceWithMatches,
} from '../../lib/sentence-search';
import { buildStatsMineCardParams, getStatsMineCardError } from '../../lib/mining';
import type { SentenceSearchResult } from '../../types/stats';
const SEARCH_LIMIT = 50;
const SEARCH_DEBOUNCE_MS = 160;
type MineMode = 'word' | 'sentence' | 'audio';
type MineStatus = { loading?: boolean; success?: boolean; error?: string };
function formatSegment(ms: number | null): string {
if (ms == null || !Number.isFinite(ms)) return '--:--';
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function resultKey(result: SentenceSearchResult, index: number): string {
return `${result.sessionId}-${result.lineIndex}-${result.segmentStartMs ?? index}`;
}
function statusKey(result: SentenceSearchResult, index: number, mode: MineMode): string {
return `${resultKey(result, index)}-${mode}`;
}
function buttonLabel(
mode: MineMode,
status: MineStatus | undefined,
disabledLabel: string,
): string {
if (status?.loading) return 'Mining...';
if (status?.success) return 'Mined!';
if (disabledLabel) return disabledLabel;
if (mode === 'word') return 'Word';
if (mode === 'audio') return 'Audio';
return 'Sentence';
}
export function formatSentenceSearchMatchCountLabel(count: number): string {
return count === 1 ? 'Match' : 'Matches';
}
export function SearchTab() {
const [query, setQuery] = useState('');
const [searchByHeadword, setSearchByHeadword] = useState(true);
const [results, setResults] = useState<SentenceSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mineStatus, setMineStatus] = useState<Record<string, MineStatus>>({});
const requestRef = useRef(0);
useEffect(() => {
const trimmed = query.trim();
const requestId = ++requestRef.current;
setMineStatus({});
if (!trimmed) {
setResults([]);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
const timer = window.setTimeout(() => {
apiClient
.searchSentences(trimmed, SEARCH_LIMIT, searchByHeadword)
.then((nextResults) => {
if (requestId !== requestRef.current) return;
setResults(nextResults);
})
.catch((err: Error) => {
if (requestId !== requestRef.current) return;
setError(err.message);
setResults([]);
})
.finally(() => {
if (requestId !== requestRef.current) return;
setLoading(false);
});
}, SEARCH_DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);
};
}, [query, searchByHeadword]);
const handleMine = async (
result: SentenceSearchResult,
index: number,
mode: MineMode,
): Promise<void> => {
const availability = getSentenceSearchMineAvailability(result, query);
if (mode === 'sentence' ? !availability.canMineSentence : !availability.canMineWordAudio) {
return;
}
const searchedWord = availability.exactMatch ? query.trim() : '';
const params = buildStatsMineCardParams(result, searchedWord, mode);
if (!params) {
return;
}
const key = statusKey(result, index, mode);
setMineStatus((prev) => ({ ...prev, [key]: { loading: true } }));
try {
const response = await apiClient.mineCard(params);
const responseError = getStatsMineCardError(response);
if (responseError) {
setMineStatus((prev) => ({ ...prev, [key]: { error: responseError } }));
return;
}
setMineStatus((prev) => ({ ...prev, [key]: { success: true } }));
} catch (err) {
setMineStatus((prev) => ({
...prev,
[key]: { error: err instanceof Error ? err.message : String(err) },
}));
}
};
const trimmedQuery = query.trim();
return (
<div className="space-y-4">
<section className="rounded-lg border border-ctp-surface1 bg-ctp-mantle/70 p-4">
<span className="mb-3 block text-xs font-semibold uppercase tracking-[0.18em] text-ctp-overlay1">
Sentence Search
</span>
<div className="flex items-stretch gap-3">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search sentence text or media..."
className="min-w-0 flex-1 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 text-base text-ctp-text placeholder:text-ctp-overlay2 focus:border-ctp-yellow focus:outline-none"
autoComplete="off"
aria-label="Sentence search"
/>
<div className="flex min-w-[5rem] flex-col items-center justify-center rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-2">
<div className="text-xl font-semibold leading-none text-ctp-yellow">
{loading ? '...' : results.length}
</div>
<div className="mt-1 text-[11px] uppercase tracking-wide text-ctp-overlay1">
{loading ? 'Matches' : formatSentenceSearchMatchCountLabel(results.length)}
</div>
</div>
</div>
<label className="mt-3 inline-flex cursor-pointer items-center gap-2 text-xs font-medium text-ctp-overlay1">
<input
type="checkbox"
checked={searchByHeadword}
onChange={(event) => setSearchByHeadword(event.target.checked)}
className="h-4 w-4 rounded border-ctp-surface2 bg-ctp-surface0 text-ctp-yellow focus:ring-ctp-yellow"
/>
Search by headword
</label>
</section>
{error && (
<div className="rounded-lg border border-ctp-red/40 bg-ctp-red/10 p-3 text-sm text-ctp-red">
Error: {error}
</div>
)}
{!trimmedQuery && (
<div className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-6 text-sm text-ctp-overlay2">
Search your tracked subtitle lines.
</div>
)}
{trimmedQuery && !loading && !error && results.length === 0 && (
<div className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-6 text-sm text-ctp-overlay2">
No sentence matches.
</div>
)}
{results.length > 0 && (
<div className="grid gap-3">
{results.map((result, index) => {
const availability = getSentenceSearchMineAvailability(result, trimmedQuery);
const wordStatus = mineStatus[statusKey(result, index, 'word')];
const sentenceStatus = mineStatus[statusKey(result, index, 'sentence')];
const audioStatus = mineStatus[statusKey(result, index, 'audio')];
const wordAudioDisabledReason =
availability.unavailableReason ??
(availability.exactMatch ? '' : 'Exact searched word not found in sentence.');
const sentenceDisabledReason = availability.unavailableReason ?? '';
const errors = [wordStatus?.error, sentenceStatus?.error, audioStatus?.error].filter(
Boolean,
);
return (
<article
key={resultKey(result, index)}
className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/90 p-4 shadow-lg shadow-ctp-crust/20"
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-ctp-text">
{result.animeTitle ?? result.videoTitle}
</div>
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs text-ctp-overlay1">
<span className="max-w-full truncate">{result.videoTitle}</span>
<span>line {result.lineIndex}</span>
<span>session {result.sessionId}</span>
<span>
{formatSegment(result.segmentStartMs)}-{formatSegment(result.segmentEndMs)}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
{availability.exactMatch && (
<button
type="button"
title={wordAudioDisabledReason || 'Create a word card from this sentence'}
className="rounded-md border border-ctp-mauve/50 px-3 py-1.5 text-xs font-medium text-ctp-mauve transition hover:bg-ctp-mauve/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
disabled={wordStatus?.loading || !availability.canMineWordAudio}
onClick={() => void handleMine(result, index, 'word')}
>
{buttonLabel(
'word',
wordStatus,
availability.canMineWordAudio ? '' : 'Unavailable',
)}
</button>
)}
<button
type="button"
title={sentenceDisabledReason || 'Create a sentence card from this line'}
className="rounded-md border border-ctp-green/50 px-3 py-1.5 text-xs font-medium text-ctp-green transition hover:bg-ctp-green/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
disabled={sentenceStatus?.loading || !availability.canMineSentence}
onClick={() => void handleMine(result, index, 'sentence')}
>
{buttonLabel(
'sentence',
sentenceStatus,
availability.canMineSentence ? '' : 'Unavailable',
)}
</button>
{availability.exactMatch && (
<button
type="button"
title={wordAudioDisabledReason || 'Create an audio card from this sentence'}
className="rounded-md border border-ctp-blue/50 px-3 py-1.5 text-xs font-medium text-ctp-blue transition hover:bg-ctp-blue/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
disabled={audioStatus?.loading || !availability.canMineWordAudio}
onClick={() => void handleMine(result, index, 'audio')}
>
{buttonLabel(
'audio',
audioStatus,
availability.canMineWordAudio ? '' : 'Unavailable',
)}
</button>
)}
</div>
</div>
<p className="mt-4 rounded-lg bg-ctp-base/70 px-4 py-3 text-base leading-7 text-ctp-text">
{renderSentenceWithMatches(result.text, trimmedQuery)}
</p>
{errors.length > 0 && <div className="mt-2 text-xs text-ctp-red">{errors[0]}</div>}
</article>
);
})}
</div>
)}
</div>
);
}
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import type { SessionBucket } from '../../lib/session-grouping'; import type { SessionBucket } from '../../lib/session-grouping';
import type { SessionSummary } from '../../types/stats'; import type { SessionSummary } from '../../types/stats';
import { buildBucketDeleteHandler } from './SessionsTab'; import {
buildBucketDeleteHandler,
decrementDeletingSessionCounts,
incrementDeletingSessionCounts,
} from './SessionsTab';
function makeSession(over: Partial<SessionSummary>): SessionSummary { function makeSession(over: Partial<SessionSummary>): SessionSummary {
return { return {
@@ -71,6 +75,84 @@ test('buildBucketDeleteHandler deletes every session in the bucket when confirm
assert.equal(onErrorCalled, false); assert.equal(onErrorCalled, false);
}); });
test('buildBucketDeleteHandler signals deleted session IDs after confirm, before deleting', async () => {
const events: string[] = [];
let startedIds: number[] | null = null;
const bucket = makeBucket([
makeSession({ sessionId: 11 }),
makeSession({ sessionId: 22 }),
makeSession({ sessionId: 33 }),
]);
const handler = buildBucketDeleteHandler({
bucket,
apiClient: {
deleteSessions: async () => {
events.push('delete');
},
},
confirm: () => {
events.push('confirm');
return true;
},
onStart: (ids) => {
startedIds = ids;
events.push('start');
},
onSuccess: () => {
events.push('success');
},
onError: () => {
events.push('error');
},
});
await handler();
assert.deepEqual(events, ['confirm', 'start', 'delete', 'success']);
assert.deepEqual(startedIds, [11, 22, 33]);
});
test('deleting session counts keep rows disabled during overlapping delete flows', () => {
let deleting = new Map<number, number>();
deleting = incrementDeletingSessionCounts(deleting, [11]);
deleting = incrementDeletingSessionCounts(deleting, [11, 22]);
assert.equal(deleting.get(11), 2);
assert.equal(deleting.get(22), 1);
deleting = decrementDeletingSessionCounts(deleting, [11]);
assert.equal(deleting.get(11), 1);
assert.equal(deleting.has(22), true);
deleting = decrementDeletingSessionCounts(deleting, [11, 22]);
assert.equal(deleting.size, 0);
});
test('buildBucketDeleteHandler does not call onStart when confirm returns false', async () => {
let startCalled = false;
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
const handler = buildBucketDeleteHandler({
bucket,
apiClient: { deleteSessions: async () => {} },
confirm: () => false,
onStart: () => {
startCalled = true;
},
onSuccess: () => {},
onError: () => {},
});
await handler();
assert.equal(startCalled, false);
});
test('buildBucketDeleteHandler is a no-op when confirm returns false', async () => { test('buildBucketDeleteHandler is a no-op when confirm returns false', async () => {
let deleteCalled = false; let deleteCalled = false;
let successCalled = false; let successCalled = false;
+47 -6
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useSessions } from '../../hooks/useSessions'; import { useSessions } from '../../hooks/useSessions';
import { SessionRow } from './SessionRow'; import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail'; import { SessionDetail } from './SessionDetail';
import { DeleteProgressToast } from '../common/DeleteProgressToast';
import { apiClient } from '../../lib/api-client'; import { apiClient } from '../../lib/api-client';
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm'; import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters'; import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
@@ -28,6 +29,8 @@ export interface BucketDeleteDeps {
bucket: SessionBucket; bucket: SessionBucket;
apiClient: { deleteSessions: (ids: number[]) => Promise<void> }; apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
confirm: (title: string, count: number) => boolean | Promise<boolean>; confirm: (title: string, count: number) => boolean | Promise<boolean>;
/** Called once confirmation passes, just before the delete request begins. */
onStart?: (sessionIds: number[]) => void;
onSuccess: (deletedIds: number[]) => void; onSuccess: (deletedIds: number[]) => void;
onError: (message: string) => void; onError: (message: string) => void;
} }
@@ -39,12 +42,13 @@ export interface BucketDeleteDeps {
* rendering the full SessionsTab or mocking React state. * rendering the full SessionsTab or mocking React state.
*/ */
export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<void> { export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<void> {
const { bucket, apiClient: client, confirm, onSuccess, onError } = deps; const { bucket, apiClient: client, confirm, onStart, onSuccess, onError } = deps;
return async () => { return async () => {
const title = bucket.representativeSession.canonicalTitle ?? 'this episode'; const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
const ids = bucket.sessions.map((s) => s.sessionId); const ids = bucket.sessions.map((s) => s.sessionId);
try { try {
if (!(await confirm(title, ids.length))) return; if (!(await confirm(title, ids.length))) return;
onStart?.(ids);
await client.deleteSessions(ids); await client.deleteSessions(ids);
onSuccess(ids); onSuccess(ids);
} catch (err) { } catch (err) {
@@ -53,6 +57,29 @@ export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<
}; };
} }
export function incrementDeletingSessionCounts(
prev: ReadonlyMap<number, number>,
ids: number[],
): Map<number, number> {
const next = new Map(prev);
for (const id of ids) next.set(id, (next.get(id) ?? 0) + 1);
return next;
}
export function decrementDeletingSessionCounts(
prev: ReadonlyMap<number, number>,
ids: number[],
): Map<number, number> {
const next = new Map(prev);
for (const id of ids) {
const count = next.get(id);
if (!count) continue;
if (count <= 1) next.delete(id);
else next.set(id, count - 1);
}
return next;
}
interface SessionsTabProps { interface SessionsTabProps {
initialSessionId?: number | null; initialSessionId?: number | null;
onClearInitialSession?: () => void; onClearInitialSession?: () => void;
@@ -70,7 +97,9 @@ export function SessionsTab({
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]); const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null); const [deletingSessionIds, setDeletingSessionIds] = useState<Map<number, number>>(
() => new Map(),
);
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null); const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -119,6 +148,14 @@ export function SessionsTab({
}); });
}; };
const markDeleting = (ids: number[]) => {
setDeletingSessionIds((prev) => incrementDeletingSessionCounts(prev, ids));
};
const unmarkDeleting = (ids: number[]) => {
setDeletingSessionIds((prev) => decrementDeletingSessionCounts(prev, ids));
};
const handleDeleteSession = async (session: SessionSummary) => { const handleDeleteSession = async (session: SessionSummary) => {
let confirmed = false; let confirmed = false;
try { try {
@@ -130,7 +167,7 @@ export function SessionsTab({
if (!confirmed) return; if (!confirmed) return;
setDeleteError(null); setDeleteError(null);
setDeletingSessionId(session.sessionId); markDeleting([session.sessionId]);
try { try {
await apiClient.deleteSession(session.sessionId); await apiClient.deleteSession(session.sessionId);
setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId)); setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId));
@@ -138,7 +175,7 @@ export function SessionsTab({
} catch (err) { } catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.'); setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
} finally { } finally {
setDeletingSessionId(null); unmarkDeleting([session.sessionId]);
} }
}; };
@@ -149,6 +186,7 @@ export function SessionsTab({
bucket, bucket,
apiClient, apiClient,
confirm: confirmBucketDelete, confirm: confirmBucketDelete,
onStart: (ids) => markDeleting(ids),
onSuccess: (ids) => { onSuccess: (ids) => {
const deleted = new Set(ids); const deleted = new Set(ids);
setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId))); setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId)));
@@ -166,6 +204,7 @@ export function SessionsTab({
await handler(); await handler();
} finally { } finally {
setDeletingBucketKey(null); setDeletingBucketKey(null);
unmarkDeleting(bucket.sessions.map((session) => session.sessionId));
} }
}; };
@@ -210,7 +249,7 @@ export function SessionsTab({
setExpandedId(expandedId === s.sessionId ? null : s.sessionId) setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
} }
onDelete={() => void handleDeleteSession(s)} onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId} deleteDisabled={deletingSessionIds.has(s.sessionId)}
onNavigateToMediaDetail={onNavigateToMediaDetail} onNavigateToMediaDetail={onNavigateToMediaDetail}
/> />
{expandedId === s.sessionId && ( {expandedId === s.sessionId && (
@@ -279,7 +318,7 @@ export function SessionsTab({
setExpandedId(expandedId === s.sessionId ? null : s.sessionId) setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
} }
onDelete={() => void handleDeleteSession(s)} onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId} deleteDisabled={deletingSessionIds.has(s.sessionId)}
onNavigateToMediaDetail={onNavigateToMediaDetail} onNavigateToMediaDetail={onNavigateToMediaDetail}
/> />
{expandedId === s.sessionId && ( {expandedId === s.sessionId && (
@@ -305,6 +344,8 @@ export function SessionsTab({
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'} {search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
</div> </div>
)} )}
<DeleteProgressToast count={deletingSessionIds.size} />
</div> </div>
); );
} }
@@ -17,3 +17,10 @@ test('AnimeVisibilityFilter uses title visibility wording', () => {
assert.match(markup, /Title Visibility/); assert.match(markup, /Title Visibility/);
assert.doesNotMatch(markup, /Anime Visibility/); assert.doesNotMatch(markup, /Anime Visibility/);
}); });
test('TrendsTab source labels words per minute without reading speed wording', async () => {
const source = await Bun.file(new URL('./TrendsTab.tsx', import.meta.url)).text();
assert.match(source, /title="Words \/ Min"/);
assert.doesNotMatch(source, /Reading Speed/);
});
+67 -36
View File
@@ -148,7 +148,7 @@ export function TrendsTab() {
onGroupByChange={setGroupBy} onGroupByChange={setGroupBy}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SectionHeader>Activity</SectionHeader> <SectionHeader>Activity (per {groupBy === 'month' ? 'month' : 'day'})</SectionHeader>
<TrendChart <TrendChart
title="Watch Time (min)" title="Watch Time (min)"
data={data.activity.watchTime} data={data.activity.watchTime}
@@ -163,6 +163,72 @@ export function TrendsTab() {
/> />
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" /> <TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" /> <TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
<SectionHeader>Cumulative Totals</SectionHeader>
<TrendChart
title="Watch Time, cumulative (min)"
data={data.progress.watchTime}
color="#8aadf4"
type="line"
/>
<TrendChart
title="Words Seen (cumulative)"
data={data.progress.words}
color="#8bd5ca"
type="line"
/>
<TrendChart
title="New Words Seen (cumulative)"
data={data.progress.newWords}
color="#c6a0f6"
type="line"
/>
<TrendChart
title="Cards Mined (cumulative)"
data={data.progress.cards}
color={cardsMinedColor}
type="line"
/>
<TrendChart
title="Episodes Watched (cumulative)"
data={data.progress.episodes}
color="#91d7e3"
type="line"
/>
<TrendChart
title="Sessions (cumulative)"
data={data.progress.sessions}
color="#b7bdf8"
type="line"
/>
<TrendChart
title="Lookups (cumulative)"
data={data.progress.lookups}
color="#f5bde6"
type="line"
/>
<SectionHeader>Efficiency</SectionHeader>
<TrendChart
title="Words / Min"
data={data.ratios.readingSpeed}
color="#a6da95"
type="line"
/>
<TrendChart
title="Cards / Hour"
data={data.ratios.cardsPerHour}
color={cardsMinedColor}
type="line"
/>
<TrendChart
title="Lookups / 100 Words"
data={data.ratios.lookupsPerHundred}
color="#f5a97f"
type="line"
/>
<SectionHeader>Patterns</SectionHeader>
<TrendChart <TrendChart
title="Watch Time by Day of Week (min)" title="Watch Time by Day of Week (min)"
data={data.patterns.watchTimeByDayOfWeek} data={data.patterns.watchTimeByDayOfWeek}
@@ -176,41 +242,6 @@ export function TrendsTab() {
type="bar" type="bar"
/> />
<SectionHeader>Period Trends</SectionHeader>
<TrendChart
title="Watch Time (min)"
data={data.progress.watchTime}
color="#8aadf4"
type="line"
/>
<TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" type="line" />
<TrendChart title="Words Seen" data={data.progress.words} color="#8bd5ca" type="line" />
<TrendChart
title="New Words Seen"
data={data.progress.newWords}
color="#c6a0f6"
type="line"
/>
<TrendChart
title="Cards Mined"
data={data.progress.cards}
color={cardsMinedColor}
type="line"
/>
<TrendChart
title="Episodes Watched"
data={data.progress.episodes}
color="#91d7e3"
type="line"
/>
<TrendChart title="Lookups" data={data.progress.lookups} color="#f5bde6" type="line" />
<TrendChart
title="Lookups / 100 Words"
data={data.ratios.lookupsPerHundred}
color="#f5a97f"
type="line"
/>
<SectionHeader>Library Cumulative</SectionHeader> <SectionHeader>Library Cumulative</SectionHeader>
<AnimeVisibilityFilter <AnimeVisibilityFilter
animeTitles={animeTitles} animeTitles={animeTitles}
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { buildCrossAnimeWordRows, CrossAnimeWordsTable } from './CrossAnimeWordsTable';
import type { VocabularyEntry } from '../../types/stats';
function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
return {
wordId: 1,
headword: '日本語',
word: '日本語',
reading: 'にほんご',
frequency: 5,
frequencyRank: 100,
animeCount: 2,
partOfSpeech: null,
firstSeen: 0,
lastSeen: 0,
...over,
} as VocabularyEntry;
}
function withLocalStorage<T>(initial: Record<string, string>, run: () => T): T {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map(Object.entries(initial));
const storage = {
get length() {
return values.size;
},
clear() {
values.clear();
},
getItem(key: string) {
return values.get(key) ?? null;
},
key(index: number) {
return Array.from(values.keys())[index] ?? null;
},
removeItem(key: string) {
values.delete(key);
},
setItem(key: string, value: string) {
values.set(key, value);
},
} as Storage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: storage,
});
try {
return run();
} finally {
if (previous) {
Object.defineProperty(globalThis, 'localStorage', previous);
} else {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
}
}
test('cross-title rows can hide kana-only headwords', () => {
const rows = buildCrossAnimeWordRows(
[
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', reading: 'さらに' }),
makeEntry({ wordId: 2, headword: '前に', word: '前に', reading: 'まえに' }),
makeEntry({ wordId: 3, headword: 'バカ', word: 'バカ', reading: 'バカ' }),
],
new Set(),
{ hideKnown: false, hideKanaOnly: true },
);
assert.deepEqual(
rows.map((row) => row.headword),
['前に'],
);
});
test('cross-title table renders a Hide Kana filter button', () => {
const markup = renderToStaticMarkup(
<CrossAnimeWordsTable
words={[makeEntry({ headword: 'さらに', word: 'さらに', reading: 'さらに' })]}
knownWords={new Set()}
/>,
);
assert.match(markup, /Hide Kana/);
});
test('cross-title table uses saved Hide Kana preference on first render', () => {
const markup = withLocalStorage({ 'subminer.stats.crossAnimeWords.hideKanaOnly': 'true' }, () =>
renderToStaticMarkup(
<CrossAnimeWordsTable
words={[
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', reading: 'さらに' }),
makeEntry({ wordId: 2, headword: '前に', word: '前に', reading: 'まえに' }),
]}
knownWords={new Set()}
/>,
),
);
assert.doesNotMatch(markup, />さらに</);
assert.match(markup, />前に</);
});
@@ -1,5 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { PosBadge } from './pos-helpers'; import { PosBadge } from './pos-helpers';
import { isKanaOnlyTokenText } from '../../lib/kana-token';
import { readBooleanPreference, writeBooleanPreference } from '../../lib/preference-storage';
import { fullReading } from '../../lib/reading-utils'; import { fullReading } from '../../lib/reading-utils';
import type { VocabularyEntry } from '../../types/stats'; import type { VocabularyEntry } from '../../types/stats';
@@ -10,22 +12,33 @@ interface CrossAnimeWordsTableProps {
} }
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
const HIDE_KANA_ONLY_STORAGE_KEY = 'subminer.stats.crossAnimeWords.hideKanaOnly';
export function CrossAnimeWordsTable({ interface CrossAnimeWordsOptions {
words, hideKnown: boolean;
knownWords, hideKanaOnly: boolean;
onSelectWord, }
}: CrossAnimeWordsTableProps) {
const [page, setPage] = useState(0);
const [hideKnown, setHideKnown] = useState(true);
const [collapsed, setCollapsed] = useState(false);
function isWordKnown(w: VocabularyEntry, knownWords: Set<string>): boolean {
return knownWords.has(w.headword) || knownWords.has(w.word);
}
function isKanaOnlyWord(w: VocabularyEntry): boolean {
return isKanaOnlyTokenText(w.headword || w.word);
}
export function buildCrossAnimeWordRows(
words: VocabularyEntry[],
knownWords: Set<string>,
options: CrossAnimeWordsOptions,
): VocabularyEntry[] {
const hasKnownData = knownWords.size > 0; const hasKnownData = knownWords.size > 0;
const ranked = useMemo(() => {
let filtered = words.filter((w) => w.animeCount >= 2); let filtered = words.filter((w) => w.animeCount >= 2);
if (hideKnown && hasKnownData) { if (options.hideKnown && hasKnownData) {
filtered = filtered.filter((w) => !knownWords.has(w.headword) && !knownWords.has(w.word)); filtered = filtered.filter((w) => !isWordKnown(w, knownWords));
}
if (options.hideKanaOnly) {
filtered = filtered.filter((w) => !isKanaOnlyWord(w));
} }
const byHeadword = new Map<string, VocabularyEntry>(); const byHeadword = new Map<string, VocabularyEntry>();
@@ -51,7 +64,25 @@ export function CrossAnimeWordsTable({
if (b.animeCount !== a.animeCount) return b.animeCount - a.animeCount; if (b.animeCount !== a.animeCount) return b.animeCount - a.animeCount;
return b.frequency - a.frequency; return b.frequency - a.frequency;
}); });
}, [words, knownWords, hideKnown, hasKnownData]); }
export function CrossAnimeWordsTable({
words,
knownWords,
onSelectWord,
}: CrossAnimeWordsTableProps) {
const [page, setPage] = useState(0);
const [hideKnown, setHideKnown] = useState(true);
const [hideKanaOnly, setHideKanaOnly] = useState(() =>
readBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, false),
);
const [collapsed, setCollapsed] = useState(false);
const hasKnownData = knownWords.size > 0;
const ranked = useMemo(() => {
return buildCrossAnimeWordRows(words, knownWords, { hideKnown, hideKanaOnly });
}, [words, knownWords, hideKnown, hideKanaOnly]);
const hasMultiAnimeWords = words.some((w) => w.animeCount >= 2); const hasMultiAnimeWords = words.some((w) => w.animeCount >= 2);
if (!hasMultiAnimeWords) return null; if (!hasMultiAnimeWords) return null;
@@ -74,10 +105,11 @@ export function CrossAnimeWordsTable({
</span> </span>
Words Across Multiple Titles Words Across Multiple Titles
</button> </button>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center justify-end gap-2">
{hasKnownData && ( {hasKnownData && (
<button <button
type="button" type="button"
aria-pressed={hideKnown}
onClick={() => { onClick={() => {
setHideKnown(!hideKnown); setHideKnown(!hideKnown);
setPage(0); setPage(0);
@@ -91,13 +123,32 @@ export function CrossAnimeWordsTable({
Hide Known Hide Known
</button> </button>
)} )}
<button
type="button"
aria-pressed={hideKanaOnly}
onClick={() => {
const next = !hideKanaOnly;
setHideKanaOnly(next);
writeBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, next);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
hideKanaOnly
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Kana
</button>
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span> <span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
</div> </div>
</div> </div>
{collapsed ? null : ranked.length === 0 ? ( {collapsed ? null : ranked.length === 0 ? (
<div className="text-xs text-ctp-overlay2 mt-3"> <div className="text-xs text-ctp-overlay2 mt-3">
{hideKnown {hideKnown && hasKnownData && !hideKanaOnly
? 'All words that span multiple titles are already known!' ? 'All words that span multiple titles are already known!'
: (hideKnown && hasKnownData) || hideKanaOnly
? 'No words across multiple titles match the active filters.'
: 'No words found across multiple titles.'} : 'No words found across multiple titles.'}
</div> </div>
) : ( ) : (
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import { FrequencyRankTable } from './FrequencyRankTable'; import {
buildFrequencyRankRows,
FrequencyRankTable,
isKanaOnlyTokenText,
} from './FrequencyRankTable';
import type { VocabularyEntry } from '../../types/stats'; import type { VocabularyEntry } from '../../types/stats';
function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry { function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
@@ -20,6 +24,46 @@ function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
} as VocabularyEntry; } as VocabularyEntry;
} }
function withLocalStorage<T>(initial: Record<string, string>, run: () => T): T {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map(Object.entries(initial));
const storage = {
get length() {
return values.size;
},
clear() {
values.clear();
},
getItem(key: string) {
return values.get(key) ?? null;
},
key(index: number) {
return Array.from(values.keys())[index] ?? null;
},
removeItem(key: string) {
values.delete(key);
},
setItem(key: string, value: string) {
values.set(key, value);
},
} as Storage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: storage,
});
try {
return run();
} finally {
if (previous) {
Object.defineProperty(globalThis, 'localStorage', previous);
} else {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
}
}
test('renders headword and reading inline in a single column (no separate Reading header)', () => { test('renders headword and reading inline in a single column (no separate Reading header)', () => {
const entry = makeEntry({}); const entry = makeEntry({});
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
@@ -41,3 +85,79 @@ test('omits reading when reading equals headword', () => {
'should not render any bracketed reading when equal to headword', 'should not render any bracketed reading when equal to headword',
); );
}); });
test('identifies kana-only token text without hiding mixed kanji words', () => {
assert.equal(isKanaOnlyTokenText('さらに'), true);
assert.equal(isKanaOnlyTokenText('バカ'), true);
assert.equal(isKanaOnlyTokenText('カレー'), true);
assert.equal(isKanaOnlyTokenText('前に'), false);
assert.equal(isKanaOnlyTokenText('間違いない'), false);
});
test('frequency rows can hide kana-only headwords', () => {
const rows = buildFrequencyRankRows(
[
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', frequencyRank: 10 }),
makeEntry({
wordId: 2,
headword: '前に',
word: '前に',
reading: 'まえに',
frequencyRank: 20,
}),
makeEntry({ wordId: 3, headword: 'バカ', word: 'バカ', reading: 'バカ', frequencyRank: 30 }),
],
new Set(),
{ hideKnown: false, hideKanaOnly: true },
);
assert.deepEqual(
rows.map((row) => row.headword),
['前に'],
);
});
test('renders a Hide Kana filter button', () => {
const entry = makeEntry({ headword: 'さらに', word: 'さらに', reading: 'さらに' });
const markup = renderToStaticMarkup(
<FrequencyRankTable words={[entry]} knownWords={new Set()} />,
);
assert.match(markup, /Hide Kana/);
});
test('uses saved Hide Kana preference on first render', () => {
const markup = withLocalStorage({ 'subminer.stats.frequencyRank.hideKanaOnly': 'true' }, () =>
renderToStaticMarkup(
<FrequencyRankTable
words={[
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', frequencyRank: 10 }),
makeEntry({
wordId: 2,
headword: '前に',
word: '前に',
reading: 'まえに',
frequencyRank: 20,
}),
]}
knownWords={new Set()}
/>,
),
);
assert.doesNotMatch(markup, />さらに</);
assert.match(markup, />前に</);
});
test('uses saved Hide Known preference on first render', () => {
const markup = withLocalStorage({ 'subminer.stats.frequencyRank.hideKnown': 'false' }, () =>
renderToStaticMarkup(
<FrequencyRankTable
words={[makeEntry({ headword: '日本語', word: '日本語', frequencyRank: 10 })]}
knownWords={new Set(['日本語'])}
/>,
),
);
assert.match(markup, />Most Common Words Seen</);
assert.match(markup, />日本語</);
});
@@ -1,6 +1,8 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { PosBadge } from './pos-helpers'; import { PosBadge } from './pos-helpers';
import { fullReading } from '../../lib/reading-utils'; import { fullReading } from '../../lib/reading-utils';
import { isKanaOnlyTokenText } from '../../lib/kana-token';
import { readBooleanPreference, writeBooleanPreference } from '../../lib/preference-storage';
import type { VocabularyEntry } from '../../types/stats'; import type { VocabularyEntry } from '../../types/stats';
interface FrequencyRankTableProps { interface FrequencyRankTableProps {
@@ -10,22 +12,36 @@ interface FrequencyRankTableProps {
} }
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
const HIDE_KNOWN_STORAGE_KEY = 'subminer.stats.frequencyRank.hideKnown';
const HIDE_KANA_ONLY_STORAGE_KEY = 'subminer.stats.frequencyRank.hideKanaOnly';
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) { interface FrequencyRankOptions {
const [page, setPage] = useState(0); hideKnown: boolean;
const [hideKnown, setHideKnown] = useState(true); hideKanaOnly: boolean;
const [collapsed, setCollapsed] = useState(false); }
const hasKnownData = knownWords.size > 0; export { isKanaOnlyTokenText };
const isWordKnown = (w: VocabularyEntry): boolean => { function isWordKnown(w: VocabularyEntry, knownWords: Set<string>): boolean {
return knownWords.has(w.headword) || knownWords.has(w.word); return knownWords.has(w.headword) || knownWords.has(w.word);
}; }
const ranked = useMemo(() => { function isKanaOnlyWord(w: VocabularyEntry): boolean {
return isKanaOnlyTokenText(w.headword || w.word);
}
export function buildFrequencyRankRows(
words: VocabularyEntry[],
knownWords: Set<string>,
options: FrequencyRankOptions,
): VocabularyEntry[] {
const hasKnownData = knownWords.size > 0;
let filtered = words.filter((w) => w.frequencyRank != null && w.frequencyRank > 0); let filtered = words.filter((w) => w.frequencyRank != null && w.frequencyRank > 0);
if (hideKnown && hasKnownData) { if (options.hideKnown && hasKnownData) {
filtered = filtered.filter((w) => !isWordKnown(w)); filtered = filtered.filter((w) => !isWordKnown(w, knownWords));
}
if (options.hideKanaOnly) {
filtered = filtered.filter((w) => !isKanaOnlyWord(w));
} }
const byHeadword = new Map<string, VocabularyEntry>(); const byHeadword = new Map<string, VocabularyEntry>();
@@ -49,7 +65,23 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
} }
return [...byHeadword.values()].sort((a, b) => a.frequencyRank! - b.frequencyRank!); return [...byHeadword.values()].sort((a, b) => a.frequencyRank! - b.frequencyRank!);
}, [words, knownWords, hideKnown, hasKnownData]); }
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) {
const [page, setPage] = useState(0);
const [hideKnown, setHideKnown] = useState(() =>
readBooleanPreference(HIDE_KNOWN_STORAGE_KEY, true),
);
const [hideKanaOnly, setHideKanaOnly] = useState(() =>
readBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, false),
);
const [collapsed, setCollapsed] = useState(false);
const hasKnownData = knownWords.size > 0;
const ranked = useMemo(() => {
return buildFrequencyRankRows(words, knownWords, { hideKnown, hideKanaOnly });
}, [words, knownWords, hideKnown, hideKanaOnly]);
if (words.every((w) => w.frequencyRank == null)) { if (words.every((w) => w.frequencyRank == null)) {
return ( return (
@@ -81,12 +113,15 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
</span> </span>
{hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'} {hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'}
</button> </button>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center justify-end gap-2">
{hasKnownData && ( {hasKnownData && (
<button <button
type="button" type="button"
aria-pressed={hideKnown}
onClick={() => { onClick={() => {
setHideKnown(!hideKnown); const next = !hideKnown;
setHideKnown(next);
writeBooleanPreference(HIDE_KNOWN_STORAGE_KEY, next);
setPage(0); setPage(0);
}} }}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${ className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
@@ -98,12 +133,33 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
Hide Known Hide Known
</button> </button>
)} )}
<button
type="button"
aria-pressed={hideKanaOnly}
onClick={() => {
const next = !hideKanaOnly;
setHideKanaOnly(next);
writeBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, next);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
hideKanaOnly
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Kana
</button>
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span> <span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
</div> </div>
</div> </div>
{collapsed ? null : ranked.length === 0 ? ( {collapsed ? null : ranked.length === 0 ? (
<div className="text-xs text-ctp-overlay2 mt-3"> <div className="text-xs text-ctp-overlay2 mt-3">
{hideKnown ? 'All ranked words are already in Anki!' : 'No words with frequency data.'} {hideKnown && hasKnownData && !hideKanaOnly
? 'All ranked words are already in Anki!'
: (hideKnown && hasKnownData) || hideKanaOnly
? 'No ranked words match the active filters.'
: 'No words with frequency data.'}
</div> </div>
) : ( ) : (
<> <>
@@ -1,4 +1,6 @@
import { useMemo } from 'react';
import type { KanjiEntry } from '../../types/stats'; import type { KanjiEntry } from '../../types/stats';
import { formatNumber } from '../../lib/formatters';
interface KanjiBreakdownProps { interface KanjiBreakdownProps {
kanji: KanjiEntry[]; kanji: KanjiEntry[];
@@ -6,34 +8,75 @@ interface KanjiBreakdownProps {
onSelectKanji?: (entry: KanjiEntry) => void; onSelectKanji?: (entry: KanjiEntry) => void;
} }
// Heat scale from rare (cool) to very frequent (warm). Catppuccin Macchiato.
const FREQ_TIERS = [
{ min: 0.85, color: 'text-ctp-peach', swatch: 'bg-ctp-peach', label: 'Very frequent' },
{ min: 0.6, color: 'text-ctp-yellow', swatch: 'bg-ctp-yellow', label: 'Frequent' },
{ min: 0.35, color: 'text-ctp-green', swatch: 'bg-ctp-green', label: 'Common' },
{ min: 0.15, color: 'text-ctp-teal', swatch: 'bg-ctp-teal', label: 'Occasional' },
{ min: 0, color: 'text-ctp-subtext0', swatch: 'bg-ctp-subtext0', label: 'Rare' },
] as const;
function tierFor(intensity: number) {
return FREQ_TIERS.find((tier) => intensity >= tier.min) ?? FREQ_TIERS[FREQ_TIERS.length - 1]!;
}
export function KanjiBreakdown({ export function KanjiBreakdown({
kanji, kanji,
selectedKanjiId = null, selectedKanjiId = null,
onSelectKanji, onSelectKanji,
}: KanjiBreakdownProps) { }: KanjiBreakdownProps) {
if (kanji.length === 0) return null; const { totalOccurrences, maxLogFreq } = useMemo(() => {
let total = 0;
let maxFreq = 1;
for (const entry of kanji) {
total += entry.frequency;
if (entry.frequency > maxFreq) maxFreq = entry.frequency;
}
return { totalOccurrences: total, maxLogFreq: Math.log(maxFreq + 1) };
}, [kanji]);
const maxFreq = kanji.reduce((max, entry) => Math.max(max, entry.frequency), 1); if (kanji.length === 0) return null;
return ( return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-3">Kanji Encountered</h3> <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-ctp-text">
Kanji Encountered
<span className="ml-2 font-normal text-ctp-subtext0">
{formatNumber(kanji.length)} unique · {formatNumber(totalOccurrences)} seen
</span>
</h3>
<div className="flex items-center gap-1.5 text-[11px] text-ctp-subtext0">
<span>rare</span>
<div className="flex items-center gap-1">
{[...FREQ_TIERS].reverse().map((tier) => (
<span
key={tier.label}
className={`h-2 w-2 rounded-full ${tier.swatch}`}
title={tier.label}
/>
))}
</div>
<span>frequent</span>
</div>
</div>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{kanji.map((k) => { {kanji.map((k) => {
const ratio = k.frequency / maxFreq; // Log scale keeps the heavily-skewed frequency distribution readable.
const opacity = Math.max(0.3, ratio); const intensity = maxLogFreq > 0 ? Math.log(k.frequency + 1) / maxLogFreq : 0;
const tier = tierFor(intensity);
const selected = selectedKanjiId === k.kanjiId;
return ( return (
<button <button
type="button" type="button"
key={k.kanji} key={k.kanji}
className={`cursor-pointer rounded px-1 text-lg text-ctp-teal transition ${ className={`cursor-pointer rounded-md px-1.5 py-0.5 text-xl leading-none font-medium transition-colors duration-150 ${tier.color} ${
selectedKanjiId === k.kanjiId selected ? 'bg-ctp-surface2 ring-1 ring-ctp-lavender' : 'hover:bg-ctp-surface1'
? 'bg-ctp-teal/10 ring-1 ring-ctp-teal'
: 'hover:bg-ctp-surface1/80'
}`} }`}
style={{ opacity }} title={`${k.kanji} — seen ${formatNumber(k.frequency)}×`}
title={`${k.kanji} — seen ${k.frequency}x`}
aria-label={`${k.kanji} — seen ${k.frequency} times`} aria-label={`${k.kanji} — seen ${k.frequency} times`}
aria-pressed={selected}
onClick={() => onSelectKanji?.(k)} onClick={() => onSelectKanji?.(k)}
> >
{k.kanji} {k.kanji}
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { KanjiDetailPanel } from './KanjiDetailPanel';
test('KanjiDetailPanel uses the centered detail modal layout', () => {
const markup = renderToStaticMarkup(
createElement(KanjiDetailPanel, { kanjiId: 1, onClose: () => {} }),
);
assert.match(
markup,
/class="[^"]*fixed[^"]*inset-0[^"]*z-40[^"]*flex[^"]*items-center[^"]*justify-center[^"]*p-4/,
);
assert.match(
markup,
/class="[^"]*relative[^"]*flex[^"]*max-h-\[85vh\][^"]*w-full[^"]*max-w-2xl[^"]*flex-col/,
);
assert.doesNotMatch(markup, /class="[^"]*absolute[^"]*right-0[^"]*top-0[^"]*h-full/);
});
@@ -5,6 +5,7 @@ import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../
import type { VocabularyOccurrenceEntry } from '../../types/stats'; import type { VocabularyOccurrenceEntry } from '../../types/stats';
const OCCURRENCES_PAGE_SIZE = 50; const OCCURRENCES_PAGE_SIZE = 50;
const MEDIA_APPEARANCES_LIMIT = 5;
interface KanjiDetailPanelProps { interface KanjiDetailPanelProps {
kanjiId: number | null; kanjiId: number | null;
@@ -21,6 +22,22 @@ function formatSegment(ms: number | null): string {
return `${minutes}:${String(seconds).padStart(2, '0')}`; return `${minutes}:${String(seconds).padStart(2, '0')}`;
} }
function highlightKanji(text: string, kanji: string) {
if (!kanji) return text;
const parts = text.split(kanji);
if (parts.length === 1) return text;
return parts.flatMap((part, idx) =>
idx === 0
? [part]
: [
<mark key={idx} className="rounded bg-ctp-teal/20 px-0.5 font-semibold text-ctp-teal">
{kanji}
</mark>,
part,
],
);
}
export function KanjiDetailPanel({ export function KanjiDetailPanel({
kanjiId, kanjiId,
onClose, onClose,
@@ -34,6 +51,7 @@ export function KanjiDetailPanel({
const [occError, setOccError] = useState<string | null>(null); const [occError, setOccError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
const [occLoaded, setOccLoaded] = useState(false); const [occLoaded, setOccLoaded] = useState(false);
const [showAllAnime, setShowAllAnime] = useState(false);
const requestIdRef = useRef(0); const requestIdRef = useRef(0);
useEffect(() => { useEffect(() => {
@@ -43,6 +61,7 @@ export function KanjiDetailPanel({
setOccLoadingMore(false); setOccLoadingMore(false);
setOccError(null); setOccError(null);
setHasMore(false); setHasMore(false);
setShowAllAnime(false);
requestIdRef.current++; requestIdRef.current++;
}, [kanjiId]); }, [kanjiId]);
@@ -87,15 +106,15 @@ export function KanjiDetailPanel({
}; };
return ( return (
<div className="fixed inset-0 z-40"> <div className="fixed inset-0 z-40 flex items-center justify-center p-4">
<button <button
type="button" type="button"
aria-label="Close kanji detail panel" aria-label="Close kanji detail panel"
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
onClick={onClose} onClick={onClose}
/> />
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl"> <aside className="relative flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-ctp-surface1 bg-ctp-mantle shadow-2xl">
<div className="flex h-full flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4"> <div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1"> <div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
@@ -148,10 +167,13 @@ export function KanjiDetailPanel({
{data.animeAppearances.length > 0 && ( {data.animeAppearances.length > 0 && (
<section> <section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2"> <h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Anime Appearances Media Appearances
</h3> </h3>
<div className="space-y-1.5"> <div className="space-y-1.5">
{data.animeAppearances.map((a) => ( {(showAllAnime
? data.animeAppearances
: data.animeAppearances.slice(0, MEDIA_APPEARANCES_LIMIT)
).map((a) => (
<button <button
key={a.animeId} key={a.animeId}
type="button" type="button"
@@ -168,6 +190,19 @@ export function KanjiDetailPanel({
</button> </button>
))} ))}
</div> </div>
{data.animeAppearances.length > MEDIA_APPEARANCES_LIMIT && (
<button
type="button"
className="mt-2 w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-teal hover:text-ctp-teal"
onClick={() => setShowAllAnime((prev) => !prev)}
>
{showAllAnime
? 'Show less'
: `Show ${formatNumber(
data.animeAppearances.length - MEDIA_APPEARANCES_LIMIT,
)} more`}
</button>
)}
</section> </section>
)} )}
@@ -237,7 +272,7 @@ export function KanjiDetailPanel({
session {occ.sessionId} session {occ.sessionId}
</div> </div>
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text"> <p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
{occ.text} {highlightKanji(occ.text, data.detail.kanji)}
</p> </p>
</article> </article>
))} ))}
@@ -36,7 +36,6 @@ export function VocabularyTab({
}: VocabularyTabProps) { }: VocabularyTabProps) {
const { words, kanji, knownWords, loading, error } = useVocabulary(); const { words, kanji, knownWords, loading, error } = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null); const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const [hideNames, setHideNames] = useState(false); const [hideNames, setHideNames] = useState(false);
const [showExclusionManager, setShowExclusionManager] = useState(false); const [showExclusionManager, setShowExclusionManager] = useState(false);
@@ -116,14 +115,7 @@ export function VocabularyTab({
/> />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center justify-end gap-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search words..."
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
{hasNames && ( {hasNames && (
<button <button
type="button" type="button"
@@ -178,12 +170,7 @@ export function VocabularyTab({
onSelectWord={handleSelectWord} onSelectWord={handleSelectWord}
/> />
<WordList <WordList words={filteredWords} selectedKey={null} onSelectWord={handleSelectWord} />
words={filteredWords}
selectedKey={null}
onSelectWord={handleSelectWord}
search={search}
/>
<KanjiBreakdown <KanjiBreakdown
kanji={kanji} kanji={kanji}
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const WORD_DETAIL_PANEL_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'WordDetailPanel.tsx',
);
test('WordDetailPanel uses the shared stats mining payload builder', () => {
const source = fs.readFileSync(WORD_DETAIL_PANEL_PATH, 'utf8');
assert.match(source, /buildStatsMineCardParams/);
assert.match(source, /getStatsMineCardUnavailableReason/);
assert.match(source, /buildStatsMineCardParams\(\s*occ,\s*data!\.detail\.headword,\s*mode\s*\)/);
});
test('WordDetailPanel shows partial media mining errors instead of silent success', () => {
const source = fs.readFileSync(WORD_DETAIL_PANEL_PATH, 'utf8');
assert.match(source, /getStatsMineCardError/);
assert.match(source, /const responseError = getStatsMineCardError\(result\);/);
});
test('WordDetailPanel uses the wider centered detail modal layout', () => {
const source = fs.readFileSync(WORD_DETAIL_PANEL_PATH, 'utf8');
assert.match(source, /fixed inset-0 z-40 flex items-center justify-center p-4/);
assert.match(source, /relative flex max-h-\[85vh\] w-full max-w-2xl flex-col/);
});

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