feat(stats): add live-action library support and harden stats runtime

- Add TMDB metadata, linking, filtering, and live-action library merging
- Harden stats request validation, lifecycle handling, and compiled runtime coverage
- Fix Anki media synchronization and configuration validation
This commit is contained in:
2026-09-21 00:18:32 -07:00
158 changed files with 6623 additions and 1119 deletions
+8
View File
@@ -13,6 +13,9 @@ on:
required: true
APPLE_TEAM_ID:
required: true
# Project TMDB key baked into release artifacts; builds stay valid without it.
SUBMINER_TMDB_API_KEY:
required: false
permissions:
contents: read
@@ -69,6 +72,8 @@ jobs:
- name: Build AppImage
run: bun run build:appimage
env:
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
- name: Build unversioned AppImage
run: |
@@ -168,6 +173,7 @@ jobs:
- name: Build signed + notarized macOS artifacts
run: bun run build:mac
env:
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
@@ -248,6 +254,8 @@ jobs:
- name: Build unsigned Windows artifacts
run: bun run build:win:unsigned
env:
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
- name: Smoke packaged runtime assets
shell: bash
+1
View File
@@ -27,6 +27,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
release:
needs: [package]
+10 -13
View File
@@ -93,12 +93,20 @@ jobs:
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
lua -v
- name: Test suite (source)
run: bun run test:fast
- name: Launcher unit and script suites
run: bun run test:launcher:unit:src && bun run test:scripts
- name: Environment suite
run: bun run test:env
- name: Upload launcher smoke artifacts (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: launcher-smoke
path: .tmp/launcher-smoke/**
if-no-files-found: ignore
- name: Coverage suite (maintained source lane)
run: bun run test:coverage:src
@@ -112,17 +120,6 @@ jobs:
- name: Stats UI tests
run: bun run test:stats
- name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src
- name: Upload launcher smoke artifacts (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: launcher-smoke
path: .tmp/launcher-smoke/**
if-no-files-found: ignore
- name: Build (bundle)
run: bun run build
+1
View File
@@ -28,6 +28,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
release:
needs: [package]
+4
View File
@@ -0,0 +1,4 @@
type: internal
area: ci
- Removed duplicate source and launcher smoke executions from the reusable quality gate while preserving every distinct test lane and failure artifact.
+4
View File
@@ -0,0 +1,4 @@
type: internal
area: verification
- Replaced mislabeled dist source reruns with a small Electron-runtime smoke check for compiled stats startup, HTTP service, native SQLite, port conflicts, and cleanup.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Added `ankiConnect.fields.wordAudio` to read word audio separately from the generated sentence-audio destination, fixing animated images that start moving immediately when `fields.audio` points to `SentenceAudio`.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: config
- Validate direct AnkiConnect, Kiku, and Senren settings before admitting them to runtime config, with warnings and defaults for invalid values.
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Reject malformed resource IDs and partly invalid ID lists before stats library mutations or cover backfills run.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: stats
- Stats server startup reports port conflicts without crashing SubMiner, shares concurrent startup requests, and shows in-app startup errors through configured status notifications.
- Background stop cancels pending background startup without disconnecting foreground-only dashboards. Shutdown bounds the wait for active HTTP requests and awaits tracker finalization before exit, with a deadline for forced application exit.
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Closing the overlay while media timing review is still loading now cancels setup and modal retries, restores playback if the review paused it, and cleans up the hidden preview player.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Treat `ankiConnect.media.maxMediaDuration: 0` as unlimited for stats dashboard mining, matching overlay mining and configuration.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Cancel pending Linux overlay window replacements during teardown so a delayed close callback cannot reopen the overlay.
+6
View File
@@ -0,0 +1,6 @@
type: changed
breaking: true
area: stats
- Reject requests from untrusted browser origins and hosts before stats data, media, or Anki operations run, and require JSON for mutation bodies.
- Load the in-app stats overlay from the local server so it uses the same origin protection as the browser dashboard.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: stats
- Documented provider reassignment, merge compatibility, and TMDB credential command caching and retry cooldown.
+8
View File
@@ -0,0 +1,8 @@
type: added
area: stats
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB. Release builds include a project key, so it works out of the box; `tmdb.apiKey` (or `tmdb.apiKeyCommand`) overrides it, and is required when running from source.
- Unlinked titles that AniList cannot match are looked up on TMDB automatically when the parsed filename matches a Japanese live-action title exactly; otherwise use the new **Link to TMDB** action on a title to pick it by hand.
- Entries linked to the same TMDB title are merged into one card even when they came from different season folders, and the Library kind selector gained a Live Action option alongside Anime and YouTube.
- Provider reassignment preserves the previous link and artwork if the replacement download fails, and refreshes completion totals when the episode count changes. Merges and sync keep conflicting AniList and TMDB identities separate, and the merge dialog explains when a selection mixes the two instead of failing.
- TMDB credential commands cache successful output and wait 30 seconds before retrying failed or empty output, using the bundled key in the meantime when available.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: anki
- Documented the separate word-audio mapping for animated-image synchronization and that existing images need regeneration to pick up the corrected freeze.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: yomitan
- Updated bundled Yomitan with upstream 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval.
+13 -2
View File
@@ -547,7 +547,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart.
// ==========================================
@@ -568,6 +568,7 @@
"fields": {
"word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
"image": "Picture", // Card field that receives the captured screenshot or animated image.
"sentence": "Sentence", // Card field that receives the source sentence text.
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
@@ -596,7 +597,7 @@
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. 0 disables the cap.
}, // Media setting.
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
@@ -679,6 +680,16 @@
"maxSearchResults": 10 // Maximum TsukiHime search results returned.
}, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
// ==========================================
// TMDB
// TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
// Hot-reload: TMDB changes apply to the next TMDB request.
// ==========================================
"tmdb": {
"apiKey": "", // Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).
"apiKeyCommand": "" // Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
}, // TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
// ==========================================
// YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
+2
View File
@@ -71,6 +71,8 @@ SubMiner fetches cover art from AniList for display in the stats dashboard. When
A no-match result is cached for 5 minutes before SubMiner retries, preventing repeated API calls for unrecognized media.
When AniList has no match, SubMiner tries [TMDB](/configuration#tmdb) next so live-action dramas and movies get a poster and synopsis too. See [Immersion tracking](/immersion-tracking#library) for how live-action entries are grouped.
If the automatic match is wrong, use **Change AniList Entry** on a title in the stats Library. Relinking rewrites the cached art for every episode of that title, and both the detail view and the Library grid pick up the new cover right away: the grid refetches after a relink, and cover responses carry an ETag and are revalidated on each request instead of being cached for a day.
## Rate limiting
+10 -1
View File
@@ -19,6 +19,8 @@ This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [La
AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the port in AnkiConnect's settings, update `ankiConnect.url` in your SubMiner config.
AnkiConnect and Kiku/Senren settings follow the [configuration validation rules](/configuration#configuration-file): invalid values produce a warning and fall back to the option's default. Use JSON booleans such as `true`, not strings such as `"true"`, and a positive number for `ankiConnect.pollingRate`.
## Auto-enrichment transport
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, and image fields automatically. Two detection methods are available:
@@ -125,6 +127,7 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn
"fields": {
"word": "Expression", // mined word / expression text
"audio": "SentenceAudio", // sentence audio clip cut from the video
"wordAudio": "ExpressionAudio", // existing Yomitan word audio, read for animation sync
"image": "Picture", // screenshot or animated clip
"sentence": "Sentence", // subtitle text
"miscInfo": "MiscInfo" // metadata (filename, timestamp)
@@ -136,6 +139,8 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn
Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error.
`fields.wordAudio` selects the existing dictionary-audio field used to calculate the animated image's opening freeze. This mapping only reads audio; `fields.audio` still controls where generated sentence audio is written. See [config.example.jsonc](/config.example.jsonc) for defaults.
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
@@ -183,6 +188,8 @@ Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMine
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
Overlay and stats-dashboard mining use the same `media.maxMediaDuration` limit. See the [configuration example](/config.example.jsonc) for its default and how to disable the cap.
Set `media.reviewTiming` to `true` to pause playback and check the clip before its media is generated. It applies to word, sentence, and audio cards.
The review opens on the subtitle range plus your configured audio padding. Subtitles usually hang around after the dialogue has stopped, so once the waveform loads, an untouched clip end pulls back to just after the last speech in the line. The Line end rail still marks the original subtitle timing, Reset puts it back, and a line whose speech runs right through its end is left alone.
@@ -207,6 +214,8 @@ Confirming writes the combined lines to the sentence field. Reset drops the adde
Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session.
If SubMiner closes the overlay while a timing review is still loading, it cancels pending setup and modal retries and restores playback if the review paused it. A new timing review can start after the overlay reopens.
### Screenshots (static)
A single frame is captured at the current playback position.
@@ -241,7 +250,7 @@ SubMiner can produce an animated AVIF spanning the subtitle duration instead of
}
```
Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing word-audio duration, so the motion starts together with the sentence audio.
Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing audio duration in `fields.wordAudio`, so the motion starts together with the sentence audio. The freeze is baked into the image when mined; changing the mapping does not repair previously generated images.
### Behavior options
+29 -1
View File
@@ -157,6 +157,7 @@ The configuration file includes several main sections:
- [**Anime Browser**](#anime-browser) - Extension repositories and stream preferences for the anime browser
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download
- [**TMDB**](#tmdb) - Posters and synopses for live-action dramas and movies in the stats Library
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**AniList**](#anilist) - Optional post-watch progress updates
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile
@@ -978,6 +979,7 @@ This example is intentionally compact. The option table below documents availabl
| `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.audio` | string | Card field for the generated sentence audio clip (default: `ExpressionAudio`). Set this to a dedicated field such as `SentenceAudio` so it does not collide with the word audio Yomitan writes. |
| `fields.wordAudio` | string | Existing word-audio field read for the animated image's opening freeze. Independent of the sentence-audio destination in `fields.audio`; this mapping does not write audio. See [config.example.jsonc](/config.example.jsonc) for defaults. |
| `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
@@ -998,7 +1000,7 @@ This example is intentionally compact. The option table below documents availabl
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| `media.maxMediaDuration` | number (seconds) | Maximum generated clip duration for overlay and stats-dashboard mining. See the [configuration example](/config.example.jsonc) for the default and disabling the cap. |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
@@ -1188,6 +1190,32 @@ The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift
See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting.
### TMDB
TMDB (The Movie Database) supplies posters, synopses, and show grouping for live-action dramas and movies in the stats [Library](/immersion-tracking#library). AniList only covers anime, so TMDB is what gives live-action titles a cover and a description.
Release builds ship with a project TMDB key, so nothing needs to be configured. Set your own key to use your own quota, or when running SubMiner from source, where no key is bundled. Create one for free under **Settings > API** on [themoviedb.org](https://www.themoviedb.org/settings/api); either the short API key or the long "API Read Access Token" works.
```json
{
"tmdb": {
"apiKey": "",
"apiKeyCommand": "cat ~/.tmdb_key"
}
}
```
| Option | Values | Description |
| -------------------- | ------ | -------------------------------------------------------------------------------------------------- |
| `tmdb.apiKey` | string | Your own TMDB API key or read access token; overrides the bundled key (default: empty) |
| `tmdb.apiKeyCommand` | string | Shell command that prints the key to stdout, used instead of `apiKey` to keep it out of the config |
Successful `apiKeyCommand` output is cached for the running client until `tmdb.apiKey` or `tmdb.apiKeyCommand` changes. Failed or empty command output uses the bundled key when available and waits 30 seconds before the next request can retry the command. Changing either credential setting resets this cooldown.
Changes apply to the next TMDB request without a restart.
This product uses the TMDB API but is not endorsed or certified by TMDB.
### Japanese subtitle generation
Open the standalone modal with `Ctrl+Shift+G`, configurable through `shortcuts.openSubtitleGeneration`, or use the subtitle sidebar button. See [shortcuts](/shortcuts) for the shared mpv and overlay keybindings.
+36 -4
View File
@@ -31,12 +31,42 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_
The same immersion data powers the stats dashboard.
The browser dashboard and in-app stats overlay both load from the local HTTP server.
The server accepts loopback hosts only and rejects requests from other browser origins,
including opaque origins such as `file://`. API clients without a browser origin can
still use the local API. Mutation requests with a body must use `application/json`;
bodyless deletion and Anki browse requests remain supported. Requests rejected by the
host or origin checks receive `403`; mutation bodies without a JSON content type
receive `415`.
Use the loopback dashboard URL directly. Reverse-proxied dashboards and Tailscale
Serve URLs are unsupported because their host or browser origin is not the local
server's origin. SSH stats synchronization is unchanged.
Scripts sending a JSON body must include the content type. For example, this
requests a duplicate-line cleanup preview without changing the database. Replace
the port if you configured a different `stats.serverPort`:
```bash
curl http://127.0.0.1:6969/api/stats/maintenance/duplicate-lines \
-H 'Content-Type: application/json' \
-d '{"dryRun":true}'
```
- In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`).
- Launcher command: run `subminer stats` to start the local stats server on demand (it also opens the dashboard in your browser when `stats.autoOpenBrowser` is enabled; the default is `false`).
- Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon.
- Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
SubMiner waits for the local server to bind before reporting that the dashboard is available. If another process already uses the configured port, the command reports the startup error and the desktop app stays open. Opening the in-app dashboard also reports startup failures through your configured status notifications.
`subminer stats -s` stops a background stats server or cancels a pending background start. It leaves a foreground-only server running, so an open in-app dashboard stays connected. Shutdown gives active HTTP requests one second to finish before closing their connections and finalizing stats.
### Stats API resource IDs
Resource IDs in URLs must be positive safe integers written as decimal digits without leading zeros, fractions, or exponent notation. ID lists in JSON bodies must contain positive safe integer numbers. Invalid IDs or list entries return `400` before any mutation; bulk requests do not apply just the valid subset. Pagination limits keep their existing rounding and bounds.
### Dashboard tabs
#### Overview
@@ -53,14 +83,16 @@ Local files and Jellyfin items with detected season numbers are split into seaso
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.
**Live-action dramas and movies.** Anime covers come from AniList, which has no live-action titles. A title that AniList cannot match is looked up on [TMDB](/configuration#tmdb) instead (release builds bundle a key; source builds need your own): only a Japanese-language, non-animated result whose known titles match the parsed filename exactly is accepted, and it supplies the poster, synopsis, English and Japanese titles, and episode count. If nothing matches automatically, open the title and use **Link to TMDB** to search and pick it by hand. A TMDB show spans all of its seasons, so entries that resolve to the same TMDB title are merged into one card regardless of the season folder they came from, and the merged season titles are remembered so later episodes land on the same card. The **All Titles** / **Anime** / **Live Action** / **YouTube** selector above the grid narrows the Library to one kind, and a title's detail view shows whether it is a drama or a movie. Linking a title to AniList again turns it back into an anime entry. Changing providers downloads the replacement cover before saving the new link; a failed download leaves the previous link and artwork intact. A title without a cover clears the previous artwork. Automatic TMDB matching leaves existing AniList links unchanged.
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. Use **All Titles**, **Anime**, or **YouTube** above the grid to filter the library. Channel pages show tracked videos and their stats without AniList controls. Existing channel entries are classified as YouTube automatically on startup, preserving viewing history and manual video assignments. Anime and YouTube entries with the same normalized title remain separate, including during stats sync. Channels are excluded from anime metadata matching, season repair, and duplicate recommendations.
When YouTube channel metadata is available, the Library tab groups videos by creator/channel. Use the kind selector above the grid (**All Titles**, **Anime**, **Live Action**, **YouTube**) to filter the library. Channel pages show tracked videos and their stats without AniList controls. Existing channel entries are classified as YouTube automatically on startup, preserving viewing history and manual video assignments. Anime and YouTube entries with the same normalized title remain separate, including during stats sync. Channels are excluded from anime metadata matching, season repair, and duplicate recommendations.
A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available:
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. Anime entries and YouTube channels cannot be merged into each other. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card.
- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry of the same kind, so a YouTube video can only move between channels. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid.
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. AniList-linked and TMDB-linked entries cannot be merged together, and YouTube channels cannot be merged with anime or live-action entries. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card.
- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. Anime and live-action entries are interchangeable here, but a YouTube video can only move between channels. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid.
Once cover art resolves a series to an AniList entry, cards with compatible seasons are folded together automatically only when the searched title exactly matches an AniList title or synonym. A fuzzy result that points at an AniList entry already used by another card appears as a **Possible duplicate** review above the Library grid instead. Choose **Review merge** to compare the cards and pick which one to keep, or **Not duplicates** to dismiss that suggestion permanently. Entries with conflicting explicit season numbers are left alone rather than merged or suggested.
@@ -334,7 +366,7 @@ The exact schema version lives in `SCHEMA_VERSION` (`src/core/services/immersion
Core tables:
- `imm_videos` - video key/title/source metadata
- `imm_anime` - anime/series or YouTube channel metadata (`media_kind`) referenced by videos and lifetime tables
- `imm_anime` - series or YouTube channel metadata referenced by videos and lifetime tables, including the media kind (`anime`, `live_action` or `youtube`) and the AniList or TMDB link
- `imm_anime_title_aliases` - alternate titles that resolve to the same anime row
- `imm_anime_merge_recommendations` - candidate duplicate-series merges surfaced in the dashboard
- `imm_sessions` - session UUID, video reference, timing/status, final denormalized totals
+13 -2
View File
@@ -547,7 +547,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart.
// ==========================================
@@ -568,6 +568,7 @@
"fields": {
"word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
"image": "Picture", // Card field that receives the captured screenshot or animated image.
"sentence": "Sentence", // Card field that receives the source sentence text.
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
@@ -596,7 +597,7 @@
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. 0 disables the cap.
}, // Media setting.
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
@@ -679,6 +680,16 @@
"maxSearchResults": 10 // Maximum TsukiHime search results returned.
}, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
// ==========================================
// TMDB
// TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
// Hot-reload: TMDB changes apply to the next TMDB request.
// ==========================================
"tmdb": {
"apiKey": "", // Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).
"apiKeyCommand": "" // Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
}, // TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
// ==========================================
// YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
+4 -1
View File
@@ -15,7 +15,10 @@
Stable and prerelease workflows share `.github/workflows/package-release.yml`.
Both callers explicitly pass the five required macOS signing/notarization
secrets; `GITHUB_TOKEN` remains automatically available to the reusable workflow.
secrets plus the optional `SUBMINER_TMDB_API_KEY` (the project TMDB key that
`scripts/prepare-build-assets.mjs` stages into `dist/bundled-integration-keys.json`;
artifacts built without it simply require users to set `tmdb.apiKey`).
`GITHUB_TOKEN` remains automatically available to the reusable workflow.
Each platform verifies its ASAR and external resources before signing, then
measures the signed app and installers before upload. Missing runtime assets,
foreign SQLite/Koffi binaries, duplicate UI fonts, demo media, source maps,
+1
View File
@@ -30,6 +30,7 @@ Update checks and startup launcher migration share a serialized update-state sto
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; `main.ts` supplies window creation and subtitle refresh hooks.
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
+1
View File
@@ -30,6 +30,7 @@ Read when: you need to find the owner module for a behavior or test surface
- Immersion sync: `src/core/services/stats-sync/`, bound by `src/main/sync-cli.ts`.
`snapshot-transfer.ts` selects compressed rsync or scp. `transfer-cache.ts` atomically retains the last successfully received snapshot per hashed peer/database identity under the config directory's `sync-transfer-cache/`. Cache copies seed isolated transfer directories; rsync verifies reconstructed files before the existing merge engine runs. The `--make-temp` / `--remove-temp` helpers accept an internal `--transfer-cache` key, with a fallback for older peers that do not recognize it.
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- Anime browser: extension bridge client, sidecar, and stream handling in `src/anime-bridge/`;
the loopback stream proxy separates request transport/retry from response transformation in
+28 -3
View File
@@ -22,9 +22,15 @@ Read when: selecting the right verification lane for a change
pull requests, stable tags, and prerelease tags. Keep common quality steps
there instead of copying them into caller workflows.
- The reusable gate installs Lua and runs `bun run test:env`, so the shipped mpv
plugin tests run for every pull request and tagged release.
plugin tests and launcher smoke run for every pull request and tagged release.
Lua installation uses only the runner's Ubuntu package sources so unrelated
third-party repository failures do not block the gate.
- In the reusable gate, `test:coverage:src` is also the blocking execution of the
discovered `src/**` test lane. The coverage runner returns the failing test's
status, so CI does not rerun that lane through `test:fast`. Launcher unit and
script tests still run separately because they are outside the coverage lane.
- Launcher smoke artifacts are uploaded after `test:env` fails. CI does not rerun
launcher smoke solely to collect the same artifacts.
## Default Handoff Gate
@@ -49,7 +55,7 @@ bun run docs:build
- Internal KB, `AGENTS.md`, or `.agents/skills/**` changes: `bun run test:docs:kb`
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
- Runtime-compat / compiled behavior after `bun run build`: `bun run test:runtime:compat`
- Stats dashboard UI: `bun run test:stats`
- Build/release scripts (`scripts/**`): `bun run test:scripts`
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
@@ -62,11 +68,30 @@ bun run docs:build
## Coverage Reporting
- `bun run test:coverage:src` runs the maintained `test:src` lane through a sharded coverage runner: one Bun coverage process per test file, then merged LCOV output.
- `bun run test:coverage:src` runs the same discovered `bun-src-full` membership as
`test:src` through a sharded coverage runner: one Bun coverage process per test
file, then merged LCOV output.
- A failing coverage shard stops the runner with a nonzero status. Coverage is a
source test gate, not a report-only step.
- Machine-readable output lands at `coverage/test-src/lcov.info`.
- Every reusable quality-gate run uploads that LCOV file as the
`coverage-test-src` artifact.
## Compiled Runtime Smoke
- `bun run test:smoke:dist` and its `test:runtime:compat` alias require an existing
full build and fail with the missing artifact paths when `dist/` or the stats UI
bundle is absent.
- The check runs the emitted stats daemon under Electron's Node runtime. It opens
the production HTTP server, queries the overview endpoint through native
libsql-backed storage, and leaves an HTTP request body unfinished before
shutting the daemon down. It verifies a clean exit and that the port and
ownership state are released despite the unfinished request.
- The check also occupies the configured port, requires startup to fail without
stale ownership state, releases the conflict, and verifies a clean retry.
- This is not a full Electron UI startup check. It does not require a display and
makes no claims about renderer, window, tray, or mpv behavior.
## Dependency Audit Policy
- `bun audit --audit-level high` blocks the reusable quality gate.
@@ -31,7 +31,7 @@ const SYNC_SCHEMA_OBJECTS = [
'imm_lifetime_applied_sessions',
'imm_stats_excluded_words',
'idx_anime_normalized_title',
'idx_anime_kind_title',
'idx_anime_namespace_title',
'idx_anime_anilist_id',
'idx_videos_anime_id',
'idx_sessions_video_started',
+5 -3
View File
@@ -20,12 +20,14 @@ export const IMMERSION_DB_FIXTURE_DDL = `
title_native TEXT,
episodes_total INTEGER,
description TEXT,
media_kind TEXT NOT NULL DEFAULT 'anime',
tmdb_id INTEGER,
tmdb_type TEXT,
metadata_json TEXT,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))
LAST_UPDATE_DATE TEXT
);
CREATE UNIQUE INDEX idx_anime_kind_title ON imm_anime(media_kind, normalized_title_key);
CREATE UNIQUE INDEX idx_anime_namespace_title ON imm_anime((media_kind = 'youtube'), normalized_title_key);
CREATE TABLE imm_videos(
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
video_key TEXT NOT NULL UNIQUE,
+2 -2
View File
@@ -52,7 +52,7 @@
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-process-start-retries.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:smoke:dist": "bun scripts/run-test-lane.mjs bun-src-full",
"test:smoke:dist": "env ELECTRON_RUN_AS_NODE=1 electron scripts/compiled-runtime-smoke.mjs",
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
"test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts",
"test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
@@ -64,7 +64,7 @@
"test:scripts": "bun scripts/run-test-lane.mjs scripts",
"test:stats": "bun scripts/run-test-lane.mjs stats",
"test:env": "bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src",
"test:runtime:compat": "bun run tsc && bun scripts/run-test-lane.mjs bun-src-full",
"test:runtime:compat": "bun run test:smoke:dist",
"test": "bun run test:fast",
"test:config": "bun scripts/run-test-lane.mjs config",
"test:launcher": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src",
+29
View File
@@ -0,0 +1,29 @@
import fs from 'node:fs';
import path from 'node:path';
/**
* Release builds carry a project-owned TMDB key so live-action lookups work
* without user setup. The key is injected from the SUBMINER_TMDB_API_KEY
* environment variable at build time (a GitHub Actions secret in CI) and never
* lives in the repository. The runtime reader is
* src/core/services/tmdb/bundled-api-key.ts; keep the file name in sync.
*/
export const BUNDLED_INTEGRATION_KEYS_FILENAME = 'bundled-integration-keys.json';
export const TMDB_API_KEY_ENV = 'SUBMINER_TMDB_API_KEY';
/**
* Write the bundled keys file into `distDir`, or remove a stale one when no
* key is present so a keyless build never ships an older key by accident.
* Returns the names of the keys staged.
*/
export function stageBundledIntegrationKeys(distDir, env = process.env) {
const outputPath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
const tmdbApiKey = env[TMDB_API_KEY_ENV]?.trim() ?? '';
if (!tmdbApiKey) {
fs.rmSync(outputPath, { force: true });
return [];
}
fs.mkdirSync(distDir, { recursive: true });
fs.writeFileSync(outputPath, `${JSON.stringify({ tmdbApiKey })}\n`, { mode: 0o644 });
return ['tmdb'];
}
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
BUNDLED_INTEGRATION_KEYS_FILENAME,
stageBundledIntegrationKeys,
} from './bundled-integration-keys.mjs';
function withDistDir(work: (distDir: string) => void): void {
const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-bundled-keys-'));
try {
work(distDir);
} finally {
fs.rmSync(distDir, { recursive: true, force: true });
}
}
test('stageBundledIntegrationKeys writes the TMDB key from the environment', () => {
withDistDir((distDir) => {
const staged = stageBundledIntegrationKeys(distDir, { SUBMINER_TMDB_API_KEY: ' abc123 ' });
assert.deepEqual(staged, ['tmdb']);
const written = JSON.parse(
fs.readFileSync(path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME), 'utf8'),
);
assert.deepEqual(written, { tmdbApiKey: 'abc123' });
});
});
test('stageBundledIntegrationKeys removes a stale file when the variable is unset', () => {
withDistDir((distDir) => {
const outputPath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
fs.writeFileSync(outputPath, '{"tmdbApiKey":"old"}');
assert.deepEqual(stageBundledIntegrationKeys(distDir, {}), []);
assert.equal(fs.existsSync(outputPath), false);
assert.deepEqual(stageBundledIntegrationKeys(distDir, { SUBMINER_TMDB_API_KEY: ' ' }), []);
});
});
+345
View File
@@ -0,0 +1,345 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:net';
import { request as httpRequest } from 'node:http';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const START_TIMEOUT_MS = 15_000;
const STOP_TIMEOUT_MS = 5_000;
const POLL_INTERVAL_MS = 25;
const MAX_CHILD_OUTPUT_BYTES = 64 * 1024;
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRootArgIndex = process.argv.indexOf('--repo-root');
const repoRoot = resolve(
repoRootArgIndex === -1 ? join(scriptDir, '..') : (process.argv[repoRootArgIndex + 1] ?? ''),
);
const paths = {
mainEntry: join(repoRoot, 'dist', 'main-entry.js'),
daemonEntry: join(repoRoot, 'dist', 'stats-daemon-runner.js'),
statsServer: join(repoRoot, 'dist', 'core', 'services', 'stats-server.js'),
tracker: join(repoRoot, 'dist', 'core', 'services', 'immersion-tracker-service.js'),
statsIndex: join(repoRoot, 'stats', 'dist', 'index.html'),
};
function requireCompiledArtifacts() {
const missing = Object.values(paths).filter((artifactPath) => !existsSync(artifactPath));
if (missing.length > 0) {
throw new Error(
`Compiled runtime artifacts are missing. Run \`bun run build\` before this check:\n${missing
.map((artifactPath) => ` - ${artifactPath}`)
.join('\n')}`,
);
}
}
function requireElectronNodeRuntime() {
if (!process.versions.electron || process.env.ELECTRON_RUN_AS_NODE !== '1') {
throw new Error(
'This check must run with Electron in Node mode. Use `bun run test:smoke:dist`.',
);
}
}
function delay(ms) {
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
}
function listen(server, port = 0) {
return new Promise((resolvePromise, reject) => {
const onError = (error) => {
server.off('listening', onListening);
reject(error);
};
const onListening = () => {
server.off('error', onError);
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Could not resolve the stats smoke port.'));
return;
}
resolvePromise(address.port);
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(port, '127.0.0.1');
});
}
function closeServer(server) {
return new Promise((resolvePromise, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolvePromise();
});
});
}
async function findAvailablePort() {
const reservation = createServer();
const port = await listen(reservation);
await closeServer(reservation);
return port;
}
function writeSmokeConfig(userDataPath, port) {
writeFileSync(
join(userDataPath, 'config.json'),
`${JSON.stringify({ stats: { serverPort: port } })}\n`,
);
}
function spawnDaemon(userDataPath, responsePath) {
const child = spawn(
process.execPath,
[
paths.daemonEntry,
'--stats-user-data-path',
userDataPath,
'--stats-response-path',
responsePath,
],
{
cwd: repoRoot,
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
},
);
let output = '';
const appendOutput = (chunk) => {
if (output.length >= MAX_CHILD_OUTPUT_BYTES) return;
output += String(chunk);
if (output.length > MAX_CHILD_OUTPUT_BYTES) {
output = `${output.slice(0, MAX_CHILD_OUTPUT_BYTES)}\n[child output truncated]\n`;
}
};
child.stdout.on('data', appendOutput);
child.stderr.on('data', appendOutput);
let exited = false;
const exit = new Promise((resolvePromise) => {
child.once('error', (error) => {
exited = true;
resolvePromise({ code: null, signal: null, error });
});
child.once('exit', (code, signal) => {
exited = true;
resolvePromise({ code, signal, error: null });
});
});
return { child, exit, hasExited: () => exited, getOutput: () => output };
}
async function waitForResponse(responsePath, daemon) {
const deadline = Date.now() + START_TIMEOUT_MS;
while (Date.now() < deadline) {
if (existsSync(responsePath)) {
try {
return JSON.parse(readFileSync(responsePath, 'utf8'));
} catch {
// The daemon may still be finishing the response file write.
}
}
if (daemon.hasExited()) {
const result = await daemon.exit;
throw new Error(
`Stats daemon exited before writing a startup response (${formatExit(result)}).\n${daemon.getOutput()}`,
);
}
await delay(POLL_INTERVAL_MS);
}
throw new Error(`Timed out waiting for stats daemon startup.\n${daemon.getOutput()}`);
}
function formatExit(result) {
if (result.error) return result.error.message;
if (result.signal) return `signal ${result.signal}`;
return `exit code ${result.code}`;
}
async function waitForExit(daemon, timeoutMs) {
return await Promise.race([daemon.exit, delay(timeoutMs).then(() => null)]);
}
async function stopDaemon(daemon) {
if (daemon.hasExited()) {
return await daemon.exit;
}
daemon.child.kill('SIGTERM');
const result = await waitForExit(daemon, STOP_TIMEOUT_MS);
if (result) return result;
daemon.child.kill('SIGKILL');
await daemon.exit;
throw new Error(`Stats daemon did not stop after SIGTERM.\n${daemon.getOutput()}`);
}
async function assertPortCanBind(port) {
const server = createServer();
try {
await listen(server, port);
} finally {
if (server.listening) await closeServer(server);
}
}
async function fetchOverview(url, daemon) {
const deadline = Date.now() + START_TIMEOUT_MS;
let lastError = null;
while (Date.now() < deadline) {
try {
return await fetch(`${url}/api/stats/overview`, {
signal: AbortSignal.timeout(START_TIMEOUT_MS),
});
} catch (error) {
lastError = error;
}
if (daemon.hasExited()) {
const result = await daemon.exit;
throw new Error(
`Stats daemon exited before accepting HTTP requests (${formatExit(result)}).\n${daemon.getOutput()}`,
);
}
await delay(POLL_INTERVAL_MS);
}
throw new Error(
`Timed out waiting for the stats HTTP server: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${daemon.getOutput()}`,
);
}
async function runHealthyStartup(userDataPath, port, responseName) {
writeSmokeConfig(userDataPath, port);
const responsePath = join(userDataPath, responseName);
const statePath = join(userDataPath, 'stats-daemon.json');
const databasePath = join(userDataPath, 'immersion.sqlite');
const daemon = spawnDaemon(userDataPath, responsePath);
let shutdownResult;
let unfinishedRequest;
try {
const startup = await waitForResponse(responsePath, daemon);
assert.deepEqual(startup, { ok: true, url: `http://127.0.0.1:${port}` });
const response = await fetchOverview(startup.url, daemon);
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type') ?? '', /^application\/json\b/);
const overview = await response.json();
assert.equal(typeof overview, 'object');
assert.ok(overview !== null);
assert.ok(Array.isArray(overview.sessions));
assert.ok(Array.isArray(overview.rollups));
assert.equal(typeof overview.hints, 'object');
const dashboard = await fetch(`${startup.url}/?overlay=1`, {
signal: AbortSignal.timeout(START_TIMEOUT_MS),
});
assert.equal(dashboard.status, 200);
assert.match(dashboard.headers.get('content-type') ?? '', /^text\/html\b/);
assert.match(await dashboard.text(), /id="root"/);
assert.ok(existsSync(databasePath), 'The compiled tracker did not create its SQLite database.');
assert.ok(
statSync(databasePath).size > 0,
'The compiled tracker created an empty SQLite file.',
);
// Leave a real request body unfinished so shutdown must bound its drain wait.
unfinishedRequest = httpRequest(new URL('/api/stats/anki/notesInfo', startup.url), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': '1000',
Expect: '100-continue',
},
signal: AbortSignal.timeout(START_TIMEOUT_MS),
});
await new Promise((resolvePromise, reject) => {
unfinishedRequest.on('error', reject);
unfinishedRequest.once('continue', () => {
unfinishedRequest.write('{');
resolvePromise();
});
unfinishedRequest.flushHeaders();
});
} finally {
try {
shutdownResult = await stopDaemon(daemon);
} finally {
unfinishedRequest?.destroy();
}
}
assert.equal(shutdownResult.code, 0, `Stats daemon shutdown failed.\n${daemon.getOutput()}`);
assert.equal(existsSync(statePath), false, 'Stats daemon state remained after shutdown.');
await assertPortCanBind(port);
}
async function runConflictRecovery() {
const userDataPath = mkdtempSync(join(tmpdir(), 'subminer-compiled-conflict-'));
const reservation = createServer();
const port = await listen(reservation);
const responsePath = join(userDataPath, 'conflict-response.json');
const statePath = join(userDataPath, 'stats-daemon.json');
writeSmokeConfig(userDataPath, port);
const daemon = spawnDaemon(userDataPath, responsePath);
const failures = [];
try {
const response = await waitForResponse(responsePath, daemon);
if (response.ok !== false) {
failures.push('The stats daemon reported success while its configured port was occupied.');
}
const result = await waitForExit(daemon, STOP_TIMEOUT_MS);
if (!result) {
failures.push('The stats daemon did not exit after its configured port failed to bind.');
} else if (result.code === 0) {
failures.push(
'The stats daemon exited successfully after its configured port failed to bind.',
);
}
if (existsSync(statePath)) {
failures.push(
'The stats daemon left ownership state behind after its configured port failed to bind.',
);
}
} finally {
await closeServer(reservation);
if (!daemon.hasExited()) {
await stopDaemon(daemon);
}
}
try {
await runHealthyStartup(userDataPath, port, 'recovery-response.json');
} finally {
rmSync(userDataPath, { recursive: true, force: true });
}
assert.deepEqual(failures, []);
}
async function main() {
requireCompiledArtifacts();
requireElectronNodeRuntime();
const userDataPath = mkdtempSync(join(tmpdir(), 'subminer-compiled-runtime-'));
try {
await runHealthyStartup(userDataPath, await findAvailablePort(), 'startup-response.json');
} finally {
rmSync(userDataPath, { recursive: true, force: true });
}
await runConflictRecovery();
process.stdout.write(
`Compiled runtime smoke passed with Electron ${process.versions.electron}, Node ${process.versions.node}, HTTP, and native SQLite.\n`,
);
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
process.exitCode = 1;
});
+28
View File
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
test('compiled runtime smoke fails clearly when build artifacts are missing', () => {
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-compiled-missing-'));
try {
const result = spawnSync(
process.execPath,
['scripts/compiled-runtime-smoke.mjs', '--repo-root', emptyRepo],
{
cwd: path.resolve(import.meta.dir, '..'),
encoding: 'utf8',
},
);
assert.equal(result.status, 1);
assert.match(result.stderr, /Compiled runtime artifacts are missing/);
assert.match(result.stderr, /dist\/main-entry\.js/);
assert.match(result.stderr, /dist\/stats-daemon-runner\.js/);
assert.doesNotMatch(result.stderr, /must run with Electron/);
} finally {
fs.rmSync(emptyRepo, { recursive: true, force: true });
}
});
+13
View File
@@ -3,6 +3,7 @@ import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { stageBundledIntegrationKeys, TMDB_API_KEY_ENV } from './bundled-integration-keys.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
@@ -111,6 +112,17 @@ function buildMacosHelper() {
}
}
// Only the key names are logged, never the values: CI masks secrets, but a
// stray echo would still leak them into local build logs.
function stageIntegrationKeys() {
const staged = stageBundledIntegrationKeys(path.join(repoRoot, 'dist'));
process.stdout.write(
staged.length > 0
? `Staged bundled integration keys: ${staged.join(', ')}\n`
: `No bundled integration keys (${TMDB_API_KEY_ENV} unset)\n`,
);
}
function main() {
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(repoRoot, 'dist', 'fonts'), {
recursive: true,
@@ -121,6 +133,7 @@ function main() {
copySyncUiAssets();
copyAnimeUiAssets();
buildMacosHelper();
stageIntegrationKeys();
}
main();
+34 -2
View File
@@ -1,8 +1,10 @@
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import test from 'node:test';
import { mergeLcovReports, resolveCoverageDir } from './run-coverage-lane';
import { mergeLcovReports, resolveCoverageDir, runCoverageLane } from './run-coverage-lane';
test('mergeLcovReports combines duplicate source-file counters across shard outputs', () => {
const merged = mergeLcovReports([
@@ -72,3 +74,33 @@ test('resolveCoverageDir keeps coverage output inside the repository', () => {
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '../escape']));
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape']));
});
test('runCoverageLane returns a failure when a discovered test fails', () => {
const repoRoot = mkdtempSync(join(tmpdir(), 'subminer-coverage-failure-'));
try {
mkdirSync(join(repoRoot, 'src'));
writeFileSync(
join(repoRoot, 'src', 'failure.test.ts'),
[
"import assert from 'node:assert/strict';",
"import test from 'node:test';",
'',
"test('intentional coverage failure', () => {",
" assert.fail('coverage runner must propagate this failure');",
'});',
'',
].join('\n'),
);
assert.notEqual(
runCoverageLane({
repoRootDir: repoRoot,
argv: ['bun-src-full', '--coverage-dir', 'coverage/test-src'],
stdio: 'pipe',
}),
0,
);
} finally {
rmSync(repoRoot, { recursive: true, force: true });
}
});
+11 -7
View File
@@ -201,14 +201,18 @@ export function mergeLcovReports(reports: string[]): string {
return chunks.length > 0 ? `${chunks.join('\n')}\n` : '';
}
function runCoverageLane(): number {
const laneName = process.argv[2];
export function runCoverageLane(
options: { repoRootDir?: string; argv?: string[]; stdio?: 'inherit' | 'pipe' } = {},
): number {
const repoRootDir = options.repoRootDir ?? repoRoot;
const argv = options.argv ?? process.argv.slice(2);
const laneName = argv[0];
if (laneName === undefined) {
process.stderr.write('Missing coverage lane name\n');
return 1;
}
const coverageDir = resolveCoverageDir(repoRoot, process.argv.slice(3));
const coverageDir = resolveCoverageDir(repoRootDir, argv.slice(1));
const shardRoot = join(coverageDir, '.shards');
mkdirSync(coverageDir, { recursive: true });
rmSync(shardRoot, { recursive: true, force: true });
@@ -216,7 +220,7 @@ function runCoverageLane(): number {
let files: string[];
try {
files = collectLaneFiles(repoRoot, laneName);
files = collectLaneFiles(repoRootDir, laneName);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
return 1;
@@ -230,8 +234,8 @@ function runCoverageLane(): number {
'bun',
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
{
cwd: repoRoot,
stdio: 'inherit',
cwd: repoRootDir,
stdio: options.stdio ?? 'inherit',
},
);
@@ -253,7 +257,7 @@ function runCoverageLane(): number {
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
process.stdout.write(
`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`,
`Merged LCOV written to ${relative(repoRootDir, join(coverageDir, 'lcov.info'))}\n`,
);
return 0;
} finally {
@@ -14,7 +14,8 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {
fields: {
audio: 'ExpressionAudio',
audio: 'SentenceAudio',
wordAudio: 'Pronunciation',
},
media: {
imageType: 'avif',
@@ -25,7 +26,8 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
noteInfo: {
noteId: 42,
fields: {
ExpressionAudio: {
SentenceAudio: { value: '[sound:sentence.mp3]' },
Pronunciation: {
value: '[sound:word.mp3][sound:alt.ogg]',
},
},
@@ -121,3 +123,32 @@ test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled
assert.equal(leadInSeconds, 0);
});
for (const sentenceAudio of ['', '[sound:sentence.mp3]']) {
test(`word audio defaults independently of sentence audio (${sentenceAudio ? 'existing' : 'new'} note)`, async () => {
const retrieved: string[] = [];
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {
fields: { audio: 'SentenceAudio' },
media: { imageType: 'avif' },
},
noteInfo: {
noteId: 42,
fields: {
ExpressionAudio: { value: '[sound:word.mp3]' },
SentenceAudio: { value: sentenceAudio },
},
},
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
preferredNames.find((name) => name !== undefined && name in noteInfo.fields) ?? null,
retrieveMediaFileBase64: async (filename) => {
retrieved.push(filename);
return 'd29yZA==';
},
probeAudioDurationSeconds: async (_buffer, filename) => (filename === 'word.mp3' ? 0.6 : 4),
});
assert.equal(leadInSeconds, 0.6);
assert.deepEqual(retrieved, ['word.mp3']);
});
}
+2 -2
View File
@@ -97,8 +97,8 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
const wordAudioFieldName = resolveConfiguredFieldName(
noteInfo,
config.fields?.audio,
DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
config.fields?.wordAudio,
DEFAULT_ANKI_CONNECT_CONFIG.fields.wordAudio,
);
if (!wordAudioFieldName) {
return 0;
@@ -160,6 +160,52 @@ test('manual clipboard subtitle update replaces audio in the configured field',
);
});
test('manual clipboard mining treats a zero media duration cap as unlimited', async () => {
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
const scenarios = [
{ maxMediaDuration: 0, expectedEnd: 14 },
{ maxMediaDuration: 1, expectedEnd: 13 },
];
for (const scenario of scenarios) {
const { service } = createManualUpdateService({
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Sentence',
audio: 'ExpressionAudio',
},
media: {
generateAudio: true,
generateImage: false,
audioPadding: 0.25,
maxMediaDuration: scenario.maxMediaDuration,
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
mediaGenerator: {
generateAudio: async (_path, start, end, padding) => {
audioRanges.push({ start, end, padding });
return Buffer.from('audio');
},
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
});
await service.updateLastAddedFromClipboard('字幕');
assert.deepEqual(audioRanges.at(-1), {
start: 12,
end: scenario.expectedEnd,
padding: 0.25,
});
}
});
test('manual clipboard word-card update uses configured fields with Lapis and Kiku enabled', async () => {
const { service, updatedFields } = createManualUpdateService({
getConfig: () =>
+8 -7
View File
@@ -21,6 +21,7 @@ import {
resolveAudioStreamIndexForMediaGeneration,
type MediaGenerationInputResolverOptions,
} from './media-source';
import { clampMediaEndTime } from './media-duration';
import { resolveWordCardKind } from './note-field-utils';
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
import { resolveMpvVolumeScale } from './mpv-volume';
@@ -233,11 +234,12 @@ export class CardCreationService {
let rangeEnd = Math.max(...timings.map((entry) => entry.endTime));
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && rangeEnd - rangeStart > maxMediaDuration) {
const cappedRangeEnd = clampMediaEndTime(rangeStart, rangeEnd, maxMediaDuration);
if (cappedRangeEnd !== rangeEnd) {
log.warn(
`Media range ${(rangeEnd - rangeStart).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
);
rangeEnd = rangeStart + maxMediaDuration;
rangeEnd = cappedRangeEnd;
}
this.deps.showOsdNotification('Updating card from clipboard...');
@@ -437,9 +439,7 @@ export class CardCreationService {
}
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) {
endTime = startTime + maxMediaDuration;
}
endTime = clampMediaEndTime(startTime, endTime, maxMediaDuration);
this.deps.showOsdNotification('Marking card as audio card...');
await this.deps.withUpdateProgress('Marking audio card', async () => {
@@ -600,11 +600,12 @@ export class CardCreationService {
}
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) {
const cappedEndTime = clampMediaEndTime(startTime, endTime, maxMediaDuration);
if (cappedEndTime !== endTime) {
log.warn(
`Sentence card media range ${(endTime - startTime).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
);
endTime = startTime + maxMediaDuration;
endTime = cappedEndTime;
}
try {
+10
View File
@@ -0,0 +1,10 @@
/** Zero or a negative cap leaves the requested end time unchanged. */
export function clampMediaEndTime(
startTime: number,
endTime: number,
maxMediaDuration: number,
): number {
return maxMediaDuration > 0 && endTime - startTime > maxMediaDuration
? startTime + maxMediaDuration
: endTime;
}
+17
View File
@@ -19,6 +19,23 @@ test('package scripts expose a sharded maintained source coverage lane with lcov
);
});
test('source and coverage scripts discover the same maintained source lane', () => {
const sourceLane = packageJson.scripts['test:src']?.match(/run-test-lane\.mjs\s+([^\s]+)/)?.[1];
const coverageLane = packageJson.scripts['test:coverage:src']?.match(
/run-coverage-lane\.ts\s+([^\s]+)/,
)?.[1];
assert.equal(sourceLane, 'bun-src-full');
assert.equal(coverageLane, sourceLane);
});
test('environment suite owns launcher smoke execution', () => {
assert.match(
packageJson.scripts['test:env'] ?? '',
/^bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src$/,
);
});
test('ci delegates its gate instead of duplicating quality steps', () => {
assert.match(
ciWorkflow,
+23
View File
@@ -2819,6 +2819,29 @@ test('forces Senren off when Kiku is also enabled and validates Senren fieldGrou
);
});
test('warns and falls back when isSenren.enabled is not boolean', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"isSenren": { "enabled": "true" }
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
assert.equal(
service.getConfig().ankiConnect.isSenren.enabled,
DEFAULT_CONFIG.ankiConnect.isSenren.enabled,
);
assert.ok(
service.getWarnings().some((warning) => warning.path === 'ankiConnect.isSenren.enabled'),
);
});
test('accepts valid ankiConnect knownWords deck object', () => {
const dir = makeTempDir();
fs.writeFileSync(
+2
View File
@@ -43,6 +43,7 @@ const {
anime,
jimaku,
tsukihime,
tmdb,
anilist,
mpv,
yomitan,
@@ -78,6 +79,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
anime,
jimaku,
tsukihime,
tmdb,
anilist,
mpv,
yomitan,
@@ -7,6 +7,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
| 'anime'
| 'jimaku'
| 'tsukihime'
| 'tmdb'
| 'anilist'
| 'mpv'
| 'yomitan'
@@ -30,6 +31,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
fields: {
word: 'Expression',
audio: 'ExpressionAudio',
wordAudio: 'ExpressionAudio',
image: 'Picture',
sentence: 'Sentence',
miscInfo: 'MiscInfo',
@@ -123,6 +125,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
apiBaseUrl: 'https://api.tsukihime.org/v1',
maxSearchResults: 10,
},
tmdb: {
apiKey: '',
apiKeyCommand: '',
},
mpv: {
executablePath: '',
launchMode: 'normal',
+22 -1
View File
@@ -82,6 +82,13 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.fields.audio,
description: 'Card field that receives generated sentence audio.',
},
{
path: 'ankiConnect.fields.wordAudio',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.fields.wordAudio,
description:
'Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.',
},
{
path: 'ankiConnect.fields.image',
kind: 'string',
@@ -288,7 +295,7 @@ export function buildIntegrationConfigOptionRegistry(
path: 'ankiConnect.media.maxMediaDuration',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.media.maxMediaDuration,
description: 'Maximum allowed media clip duration in seconds.',
description: 'Maximum allowed media clip duration in seconds. 0 disables the cap.',
},
{
path: 'ankiConnect.knownWords.matchMode',
@@ -472,6 +479,20 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.tsukihime.maxSearchResults,
description: 'Maximum TsukiHime search results returned.',
},
{
path: 'tmdb.apiKey',
kind: 'string',
defaultValue: defaultConfig.tmdb.apiKey,
description:
'Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).',
},
{
path: 'tmdb.apiKeyCommand',
kind: 'string',
defaultValue: defaultConfig.tmdb.apiKeyCommand,
description:
'Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.',
},
{
path: 'anilist.enabled',
kind: 'boolean',
+9 -1
View File
@@ -144,7 +144,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'],
notes: [
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
'Most other AnkiConnect settings still require restart.',
],
@@ -175,6 +175,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: TsukiHime changes apply to the next TsukiHime request.'],
key: 'tsukihime',
},
{
title: 'TMDB',
description: [
'TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.',
],
notes: ['Hot-reload: TMDB changes apply to the next TMDB request.'],
key: 'tmdb',
},
{
title: 'YouTube Playback Settings',
description: [
+2 -1
View File
@@ -30,6 +30,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.fields.word',
'ankiConnect.fields.audio',
'ankiConnect.fields.wordAudio',
'ankiConnect.fields.image',
'ankiConnect.fields.sentence',
'ankiConnect.fields.miscInfo',
@@ -54,7 +55,7 @@ export function getConfigHotReloadField(path: string): string | null {
// These consumers read the current config when the next operation starts.
if (
['jimaku', 'subsync', 'notifications', 'subtitleGeneration'].some((root) =>
['jimaku', 'tmdb', 'subsync', 'notifications', 'subtitleGeneration'].some((root) =>
pathStartsWith(path, root),
)
) {
+117
View File
@@ -32,6 +32,101 @@ test('media timing review is disabled by default and accepts a boolean override'
assert.deepEqual(enabledContext.warnings, []);
});
test('invalid direct and field-grouping Anki values warn and keep defaults', () => {
const { context, warnings } = makeContext({
enabled: 'true',
url: 8765,
pollingRate: '3000',
deck: ['Mining'],
isKiku: {
enabled: 1,
fieldGrouping: 'sometimes',
deleteDuplicateInAuto: 'false',
},
isSenren: {
enabled: 'true',
fieldGrouping: false,
deleteDuplicateInAuto: 0,
},
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.enabled, DEFAULT_CONFIG.ankiConnect.enabled);
assert.equal(context.resolved.ankiConnect.url, DEFAULT_CONFIG.ankiConnect.url);
assert.equal(context.resolved.ankiConnect.pollingRate, DEFAULT_CONFIG.ankiConnect.pollingRate);
assert.equal(context.resolved.ankiConnect.deck, DEFAULT_CONFIG.ankiConnect.deck);
assert.deepEqual(context.resolved.ankiConnect.isKiku, DEFAULT_CONFIG.ankiConnect.isKiku);
assert.deepEqual(context.resolved.ankiConnect.isSenren, DEFAULT_CONFIG.ankiConnect.isSenren);
assert.deepEqual(
warnings.map((warning) => warning.path),
[
'ankiConnect.enabled',
'ankiConnect.url',
'ankiConnect.pollingRate',
'ankiConnect.deck',
'ankiConnect.isKiku.enabled',
'ankiConnect.isKiku.deleteDuplicateInAuto',
'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.isSenren.enabled',
'ankiConnect.isSenren.deleteDuplicateInAuto',
'ankiConnect.isSenren.fieldGrouping',
],
);
});
test('accepts valid direct and field-grouping Anki values', () => {
const { context, warnings } = makeContext({
enabled: false,
url: 'http://127.0.0.1:9876',
pollingRate: 750,
deck: 'Mining',
isKiku: {
enabled: true,
fieldGrouping: 'manual',
deleteDuplicateInAuto: false,
},
isSenren: {
enabled: false,
fieldGrouping: 'disabled',
deleteDuplicateInAuto: false,
},
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.enabled, false);
assert.equal(context.resolved.ankiConnect.url, 'http://127.0.0.1:9876');
assert.equal(context.resolved.ankiConnect.pollingRate, 750);
assert.equal(context.resolved.ankiConnect.deck, 'Mining');
assert.deepEqual(context.resolved.ankiConnect.isKiku, {
enabled: true,
fieldGrouping: 'manual',
deleteDuplicateInAuto: false,
});
assert.deepEqual(context.resolved.ankiConnect.isSenren, {
enabled: false,
fieldGrouping: 'disabled',
deleteDuplicateInAuto: false,
});
assert.deepEqual(warnings, []);
});
test('ignores unknown Anki keys without warning or admitting them to resolved config', () => {
const { context, warnings } = makeContext({
futureOption: { enabled: true },
isKiku: { futureGroupingOption: 'future' },
isSenren: { futureGroupingOption: 'future' },
});
applyAnkiConnectResolution(context);
assert.equal(Object.hasOwn(context.resolved.ankiConnect, 'futureOption'), false);
assert.equal(Object.hasOwn(context.resolved.ankiConnect.isKiku, 'futureGroupingOption'), false);
assert.equal(Object.hasOwn(context.resolved.ankiConnect.isSenren, 'futureGroupingOption'), false);
assert.deepEqual(warnings, []);
});
test('modern media duration accepts zero as the disabled cap sentinel', () => {
const disabledCap = makeContext({ media: { maxMediaDuration: 0 } });
applyAnkiConnectResolution(disabledCap.context);
@@ -290,6 +385,28 @@ test('accepts ankiConnect.media.syncAnimatedImageToWordAudio override', () => {
);
});
test('word audio mapping defaults and validates independently of sentence audio', () => {
for (const wordAudio of [undefined, 'Pronunciation', 7]) {
const { context, warnings } = makeContext({
fields: {
audio: 'SentenceAudio',
...(wordAudio !== undefined ? { wordAudio } : {}),
},
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.fields.audio, 'SentenceAudio');
assert.equal(
context.resolved.ankiConnect.fields.wordAudio,
typeof wordAudio === 'string' ? wordAudio : DEFAULT_CONFIG.ankiConnect.fields.wordAudio,
);
assert.deepEqual(
warnings.map((warning) => warning.path),
typeof wordAudio === 'number' ? ['ankiConnect.fields.wordAudio'] : [],
);
}
});
test('invalid modern Anki subtrees warn and keep resolved defaults', () => {
const { context, warnings } = makeContext({
fields: { word: 7 },
+3 -3
View File
@@ -19,11 +19,11 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
const media = isObject(ankiConnect.media) ? ankiConnect.media : {};
const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {};
initializeAnkiConnectResolution(context, ankiConnect);
initializeAnkiConnectResolution(context);
applyAnkiModernResolution(context, ankiConnect, behavior, media);
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
applyAnkiKikuResolution(context);
applyAnkiSenrenResolution(context);
applyAnkiKikuResolution(context, ankiConnect);
applyAnkiSenrenResolution(context, ankiConnect);
applyAnkiLapisKikuResolution(context, ankiConnect);
}
+58
View File
@@ -0,0 +1,58 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { asBoolean, asString } from '../shared';
import { applyModernValue, asPositiveNumber } from './modern-value';
export function applyAnkiBaseResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
applyModernValue(
context,
ankiConnect,
'enabled',
'ankiConnect.enabled',
asBoolean,
DEFAULT_CONFIG.ankiConnect.enabled,
(value) => {
context.resolved.ankiConnect.enabled = value;
},
'Expected boolean.',
);
applyModernValue(
context,
ankiConnect,
'url',
'ankiConnect.url',
asString,
DEFAULT_CONFIG.ankiConnect.url,
(value) => {
context.resolved.ankiConnect.url = value;
},
'Expected string.',
);
applyModernValue(
context,
ankiConnect,
'pollingRate',
'ankiConnect.pollingRate',
asPositiveNumber,
DEFAULT_CONFIG.ankiConnect.pollingRate,
(value) => {
context.resolved.ankiConnect.pollingRate = value;
},
'Expected positive number.',
);
applyModernValue(
context,
ankiConnect,
'deck',
'ankiConnect.deck',
asString,
DEFAULT_CONFIG.ankiConnect.deck,
(value) => {
context.resolved.ankiConnect.deck = value;
},
'Expected string.',
);
}
@@ -0,0 +1,53 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { asBoolean, isObject } from '../shared';
import { applyModernValue } from './modern-value';
type FieldGroupingConfigKey = 'isKiku' | 'isSenren';
export function applyFieldGroupingConfigResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
key: FieldGroupingConfigKey,
): void {
const source = ankiConnect[key];
if (!isObject(source)) {
if (source !== undefined) {
context.warn(
`ankiConnect.${key}`,
source,
DEFAULT_CONFIG.ankiConnect[key],
'Expected object.',
);
}
return;
}
for (const booleanKey of ['enabled', 'deleteDuplicateInAuto'] as const) {
applyModernValue(
context,
source,
booleanKey,
`ankiConnect.${key}.${booleanKey}`,
asBoolean,
DEFAULT_CONFIG.ankiConnect[key][booleanKey],
(value) => {
context.resolved.ankiConnect[key][booleanKey] = value;
},
'Expected boolean.',
);
}
applyModernValue(
context,
source,
'fieldGrouping',
`ankiConnect.${key}.fieldGrouping`,
(value) => (value === 'auto' || value === 'manual' || value === 'disabled' ? value : undefined),
DEFAULT_CONFIG.ankiConnect[key].fieldGrouping,
(value) => {
context.resolved.ankiConnect[key].fieldGrouping = value;
},
'Expected auto, manual, or disabled.',
);
}
+1 -54
View File
@@ -1,55 +1,8 @@
import type { ResolveContext } from '../context';
import { isObject } from '../shared';
const LEGACY_KEYS = new Set([
'wordField',
'audioField',
'imageField',
'sentenceField',
'miscInfoField',
'miscInfoPattern',
'generateAudio',
'generateImage',
'imageType',
'imageFormat',
'imageQuality',
'imageMaxWidth',
'imageMaxHeight',
'animatedFps',
'animatedMaxWidth',
'animatedMaxHeight',
'animatedCrf',
'syncAnimatedImageToWordAudio',
'audioPadding',
'fallbackDuration',
'maxMediaDuration',
'overwriteAudio',
'overwriteImage',
'mediaInsertMode',
'highlightWord',
'notificationType',
'autoUpdateNewCards',
]);
export function initializeAnkiConnectResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
const {
knownWords: _knownWordsConfigFromAnkiConnect,
nPlusOne: _nPlusOneConfigFromAnkiConnect,
ai: _ankiAiConfig,
...ankiConnectWithoutKnownWordsOrNPlusOne
} = ankiConnect;
const ankiConnectWithoutLegacy = Object.fromEntries(
Object.entries(ankiConnectWithoutKnownWordsOrNPlusOne).filter(([key]) => !LEGACY_KEYS.has(key)),
);
export function initializeAnkiConnectResolution(context: ResolveContext): void {
context.resolved.ankiConnect = {
...context.resolved.ankiConnect,
...(isObject(ankiConnectWithoutLegacy)
? (ankiConnectWithoutLegacy as Partial<(typeof context.resolved)['ankiConnect']>)
: {}),
fields: {
...context.resolved.ankiConnect.fields,
},
@@ -73,15 +26,9 @@ export function initializeAnkiConnectResolution(
},
isKiku: {
...context.resolved.ankiConnect.isKiku,
...(isObject(ankiConnect.isKiku)
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
: {}),
},
isSenren: {
...context.resolved.ankiConnect.isSenren,
...(isObject(ankiConnect.isSenren)
? (ankiConnect.isSenren as (typeof context.resolved)['ankiConnect']['isSenren'])
: {}),
},
lapisKiku: {
...context.resolved.ankiConnect.lapisKiku,
+6 -16
View File
@@ -1,19 +1,9 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { applyFieldGroupingConfigResolution } from './field-grouping-config';
export function applyAnkiKikuResolution(context: ResolveContext): void {
if (
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'auto' &&
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'manual' &&
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'disabled'
) {
context.warn(
'ankiConnect.isKiku.fieldGrouping',
context.resolved.ankiConnect.isKiku.fieldGrouping,
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping,
'Expected auto, manual, or disabled.',
);
context.resolved.ankiConnect.isKiku.fieldGrouping =
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping;
}
export function applyAnkiKikuResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
applyFieldGroupingConfigResolution(context, ankiConnect, 'isKiku');
}
@@ -7,7 +7,15 @@ export function applyModernFieldsResolution(
context: ResolveContext,
fields: Record<string, unknown>,
): void {
for (const key of ['word', 'audio', 'image', 'sentence', 'miscInfo', 'translation'] as const) {
for (const key of [
'word',
'audio',
'wordAudio',
'image',
'sentence',
'miscInfo',
'translation',
] as const) {
applyModernValue(
context,
fields,
@@ -1,6 +1,7 @@
import type { ResolveContext } from '../context';
import { isObject } from '../shared';
import { applyAiResolution } from './ai';
import { applyAnkiBaseResolution } from './base';
import { applyLapisResolution } from './lapis';
import { applyModernBehaviorResolution } from './modern-behavior';
import { applyModernFieldsResolution } from './modern-fields';
@@ -18,6 +19,7 @@ export function applyAnkiModernResolution(
const fields = isObject(ankiConnect.fields) ? ankiConnect.fields : {};
const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {};
applyAnkiBaseResolution(context, ankiConnect);
applyModernFieldsResolution(context, fields);
applyModernMediaResolution(context, media);
applyModernBehaviorResolution(context, behavior);
+6 -16
View File
@@ -1,21 +1,11 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { applyFieldGroupingConfigResolution } from './field-grouping-config';
export function applyAnkiSenrenResolution(context: ResolveContext): void {
if (
context.resolved.ankiConnect.isSenren.fieldGrouping !== 'auto' &&
context.resolved.ankiConnect.isSenren.fieldGrouping !== 'manual' &&
context.resolved.ankiConnect.isSenren.fieldGrouping !== 'disabled'
) {
context.warn(
'ankiConnect.isSenren.fieldGrouping',
context.resolved.ankiConnect.isSenren.fieldGrouping,
DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping,
'Expected auto, manual, or disabled.',
);
context.resolved.ankiConnect.isSenren.fieldGrouping =
DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping;
}
export function applyAnkiSenrenResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
applyFieldGroupingConfigResolution(context, ankiConnect, 'isSenren');
// Kiku and Senren field grouping write incompatible markup into the same note
// fields, so only one may be active; Kiku wins to preserve pre-existing setups.
+13
View File
@@ -59,6 +59,19 @@ export function applyIntegrationConfig(context: ResolveContext): void {
warn('ai', src.ai, resolved.ai, 'Expected object.');
}
if (isObject(src.tmdb)) {
for (const key of ['apiKey', 'apiKeyCommand'] as const) {
const value = asString(src.tmdb[key]);
if (value !== undefined) {
resolved.tmdb[key] = value;
} else if (src.tmdb[key] !== undefined) {
warn(`tmdb.${key}`, src.tmdb[key], resolved.tmdb[key], 'Expected string.');
}
}
} else if (src.tmdb !== undefined) {
warn('tmdb', src.tmdb, resolved.tmdb, 'Expected object.');
}
if (isObject(src.anilist)) {
const enabled = asBoolean(src.anilist.enabled);
if (enabled !== undefined) {
+4
View File
@@ -290,6 +290,9 @@ test('settings registry routes playback-related integrations into integrations',
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
assert.equal(field('tsukihime.apiBaseUrl').category, 'integrations');
assert.equal(field('tsukihime.apiBaseUrl').section, 'TsukiHime');
assert.equal(field('tmdb.apiKey').category, 'integrations');
assert.equal(field('tmdb.apiKey').section, 'TMDB');
assert.equal(field('tmdb.apiKey').secret, true);
});
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
@@ -379,6 +382,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.fields.word',
'ankiConnect.fields.audio',
'ankiConnect.fields.wordAudio',
'ankiConnect.fields.image',
'ankiConnect.fields.sentence',
'ankiConnect.fields.miscInfo',
+10 -2
View File
@@ -93,7 +93,12 @@ const JSON_OBJECT_FIELDS = new Set([
'subtitleSidebar.css',
]);
export const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
export const SECRET_PATHS = new Set([
'ai.apiKey',
'jimaku.apiKey',
'tmdb.apiKey',
'anilist.accessToken',
]);
const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor']);
const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([
@@ -136,6 +141,7 @@ const SECTION_ORDER = new Map<string, number>(
'AnkiConnect Proxy',
'Aniyomi',
'Jimaku',
'TMDB',
'Subtitle Sync',
'MPV Keybindings',
'Overlay Shortcuts',
@@ -329,6 +335,7 @@ function humanizePath(path: string): string {
.replace(/\bmpv\b/i, 'mpv')
.replace(/\byomitan\b/i, 'Yomitan')
.replace(/\bjimaku\b/i, 'Jimaku')
.replace(/\btmdb\b/i, 'TMDB')
.replace(/\banilist\b/i, 'AniList')
.replace(/\banki\b/i, 'Anki');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
@@ -444,7 +451,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
return { category: 'behavior', section: topSection(path) };
}
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.')) {
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.') || path.startsWith('tmdb.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path.startsWith('anime.')) {
@@ -515,6 +522,7 @@ function topSection(path: string): string {
subsync: 'Subtitle Sync',
texthooker: 'Texthooker',
tsukihime: 'TsukiHime',
tmdb: 'TMDB',
updates: 'Updates',
websocket: 'WebSocket server',
yomitan: 'Yomitan',
+558 -73
View File
@@ -5,8 +5,13 @@ import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import type { AddressInfo } from 'node:net';
import { createStatsApp, startStatsServer } from '../stats-server.js';
import {
createStatsApp,
startNodeHttpServer,
startStatsServerWithRuntime,
} from '../stats-server.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import { INCOMPATIBLE_PROVIDER_MERGE_MESSAGE } from '../immersion-tracker/anime-merge.js';
import {
clearRetimedSecondarySubtitleCache,
resolveRetimedSecondarySubtitleTextFromSidecar,
@@ -307,6 +312,7 @@ function createMockTracker(
getKanjiOccurrences: async () => OCCURRENCES,
getAnimeLibrary: async () => ANIME_LIBRARY,
getAnimeDetail: async (animeId: number) => (animeId === 1 ? ANIME_DETAIL : null),
hasAnime: async (animeId: number) => animeId === 1,
getAnimeEpisodes: async () => ANIME_EPISODES,
getAnimeAnilistEntries: async () => [],
getAnimeWords: async () => ANIME_WORDS,
@@ -440,6 +446,73 @@ async function withFakeAnkiConnect<T>(
}
describe('stats server API routes', () => {
it('rejects untrusted mutation requests before merging anime', async () => {
let merges = 0;
const app = createStatsApp(
createMockTracker({
mergeAnime: async () => {
merges += 1;
return { survivingAnimeId: 1, mergedAnimeIds: [2], movedVideos: 1 };
},
}),
);
const rejectedHeaders: Record<string, string>[] = [
{ Origin: 'https://attacker.example', 'Content-Type': 'text/plain' },
{ Origin: 'https://attacker.example', 'Content-Type': 'application/json' },
{ Origin: 'null', 'Content-Type': 'application/json' },
{ Origin: 'http://localhost:4321', 'Content-Type': 'application/json' },
{ Origin: 'http://localhost/', 'Content-Type': 'application/json' },
{ 'Sec-Fetch-Site': 'cross-site', 'Content-Type': 'application/json' },
{ Host: 'attacker.example', 'Content-Type': 'application/json' },
];
for (const headers of rejectedHeaders) {
const response = await app.request('/api/stats/anime/1/merge', {
method: 'POST',
headers,
body: JSON.stringify({ sourceAnimeIds: [2] }),
});
assert.equal(response.status, 403, JSON.stringify(headers));
}
assert.equal(merges, 0);
for (const origin of [undefined, 'http://localhost']) {
const headers = new Headers({ 'Content-Type': 'application/json; charset=utf-8' });
if (origin) headers.set('Origin', origin);
const response = await app.request('/api/stats/anime/1/merge', {
method: 'POST',
headers,
body: JSON.stringify({ sourceAnimeIds: [2] }),
});
assert.equal(response.status, 200);
}
assert.equal(merges, 2);
});
it('requires JSON for mutation bodies and preserves bodyless deletion', async () => {
let deletions = 0;
const app = createStatsApp(
createMockTracker({
deleteSession: async () => {
deletions += 1;
},
}),
);
const invalid = await app.request('/api/stats/sessions/1', {
method: 'DELETE',
body: '{}',
});
assert.equal(invalid.status, 415);
assert.equal(deletions, 0);
const valid = await app.request('/api/stats/sessions/1', { method: 'DELETE' });
assert.equal(valid.status, 200);
assert.equal(deletions, 1);
const rebound = await app.request('http://attacker.example/api/stats/sessions/1', {
method: 'DELETE',
headers: { Origin: 'http://attacker.example' },
});
assert.equal(rebound.status, 403);
assert.equal(deletions, 1);
});
it('GET /api/stats/overview returns overview data', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/overview');
@@ -1004,6 +1077,23 @@ describe('stats server API routes', () => {
assert.equal(seenLimit, 500);
});
it('GET /api/stats/vocabulary floors fractional pagination limits', async () => {
let seenLimit = 0;
const app = createStatsApp(
createMockTracker({
getVocabularyStats: async (limit?: number) => {
seenLimit = limit ?? 0;
return VOCABULARY_STATS;
},
}),
);
const res = await app.request('/api/stats/vocabulary?limit=12.9');
assert.equal(res.status, 200);
assert.equal(seenLimit, 12);
});
it('GET /api/stats/vocabulary passes excludePos to tracker', async () => {
let seenArgs: unknown[] = [];
const app = createStatsApp(
@@ -1132,7 +1222,7 @@ describe('stats server API routes', () => {
body: JSON.stringify({ dryRun: false, lookbackDays: null }),
});
assert.equal(res.status, 415);
assert.equal(res.status, 403);
assert.equal(cleanupCalls, 0);
});
@@ -1351,7 +1441,7 @@ describe('stats server API routes', () => {
}),
);
for (const anilistId of [-1, 0, 1.5, '12', true, undefined]) {
for (const anilistId of [-1, 0, 1.5, 9_007_199_254_740_992, '12', true, undefined]) {
const res = await app.request('/api/stats/anime/1/anilist', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -1449,6 +1539,74 @@ describe('stats server API routes', () => {
assert.equal(res.status, 404);
});
it('resource routes reject fractional ids before calling dependencies', async () => {
const dependencyCalls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
dependencyCalls.push('fetch');
return new Response('{}', { status: 200 });
};
try {
const app = createStatsApp(
createMockTracker({
getWordDetail: async () => {
dependencyCalls.push('getWordDetail');
return null;
},
getSessionEvents: async () => {
dependencyCalls.push('getSessionEvents');
return [];
},
getEpisodeSessions: async () => {
dependencyCalls.push('getEpisodeSessions');
return [];
},
getAnimeCoverArt: async () => {
dependencyCalls.push('getAnimeCoverArt');
return null;
},
ensureAnimeCoverArt: async () => {
dependencyCalls.push('ensureAnimeCoverArt');
return false;
},
setVideoWatched: async () => {
dependencyCalls.push('setVideoWatched');
},
reassignAnimeAnilist: async () => {
dependencyCalls.push('reassignAnimeAnilist');
},
}),
);
const responses = await Promise.all([
app.request('/api/stats/vocabulary/1.9/detail'),
app.request('/api/stats/sessions/1.9/events'),
app.request('/api/stats/episode/1.9/detail'),
app.request('/api/stats/anime/1.9/cover'),
app.request('/api/stats/media/1.9/watched', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: '{"watched":true}',
}),
app.request('/api/stats/anime/1.9/anilist', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: '{"anilistId":21858}',
}),
app.request('/api/stats/anki/browse?noteId=1.9', { method: 'POST' }),
]);
assert.deepEqual(
responses.map((response) => response.status),
[400, 400, 400, 400, 400, 400, 400],
);
assert.deepEqual(dependencyCalls, []);
} finally {
globalThis.fetch = originalFetch;
}
});
it('POST /api/stats/covers batches stored cover art and backfills missing anime art in the background', async () => {
let ensureCoverArtCalls = 0;
const ensureAnimeCoverArtCalls: number[] = [];
@@ -1505,6 +1663,58 @@ describe('stats server API routes', () => {
assert.deepEqual(ensureAnimeCoverArtCalls, [99999]);
});
it('JSON id lists reject malformed members before side effects', async () => {
const dependencyCalls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
dependencyCalls.push('fetch');
return new Response('{}', { status: 200 });
};
try {
const app = createStatsApp(
createMockTracker({
deleteSessions: async () => {
dependencyCalls.push('deleteSessions');
},
mergeAnime: async () => {
dependencyCalls.push('mergeAnime');
return { survivingAnimeId: 7, mergedAnimeIds: [], movedVideos: 0 };
},
getAnimeCoverArt: async () => {
dependencyCalls.push('getAnimeCoverArt');
return null;
},
ensureAnimeCoverArt: async () => {
dependencyCalls.push('ensureAnimeCoverArt');
return false;
},
}),
);
const request = async (path: string, body: string, method = 'POST'): Promise<Response> =>
await app.request(path, {
method,
headers: { 'Content-Type': 'application/json' },
body,
});
const responses = await Promise.all([
request('/api/stats/sessions', '{"sessionIds":[4,1.9,7]}', 'DELETE'),
request('/api/stats/anime/7/merge', '{"sourceAnimeIds":[8,"9"]}'),
request('/api/stats/covers', '{"animeIds":[1,1.9]}'),
request('/api/stats/anki/notesInfo', '{"noteIds":[1,1.9]}'),
]);
assert.deepEqual(
responses.map((response) => response.status),
[400, 400, 400, 400],
);
assert.deepEqual(dependencyCalls, []);
} finally {
globalThis.fetch = originalFetch;
}
});
it('POST /api/stats/covers limits concurrent missing anime cover backfills', async () => {
let activeBackfills = 0;
let maxActiveBackfills = 0;
@@ -1762,6 +1972,60 @@ describe('stats server API routes', () => {
});
});
it('POST /api/stats/mine-card treats a zero media duration cap as unlimited', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
const scenarios = [
{ maxMediaDuration: 0, expectedEnd: 12 },
{ maxMediaDuration: 1, expectedEnd: 11 },
];
for (const scenario of scenarios) {
const app = createStatsApp(createMockTracker(), {
addYomitanNote: async () => null,
createMediaGenerator: () => ({
generateAudio: async (_path, start, end, padding) => {
audioRanges.push({ start, end, padding });
return Buffer.from('audio');
},
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
}),
ankiConnectConfig: {
deck: 'Mining',
media: {
generateAudio: true,
generateImage: false,
audioPadding: 0.25,
maxMediaDuration: scenario.maxMediaDuration,
},
},
});
const res = await app.request('/api/stats/mine-card?mode=word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 10_000,
endMs: 12_000,
sentence: '猫を見た',
word: '猫',
}),
});
assert.equal(res.status, 502);
assert.deepEqual(audioRanges.at(-1), {
start: 10,
end: scenario.expectedEnd,
padding: 0.25,
});
}
});
});
it('POST /api/stats/mine-card requires a non-empty word in word mode', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
@@ -3262,6 +3526,46 @@ Aligned English subtitle
assert.equal(deleteCalls, 0);
});
it('DELETE /api/stats/sessions rejects a partly invalid id list without deleting', async () => {
let deleteCalls = 0;
const app = createStatsApp(
createMockTracker({
deleteSessions: async () => {
deleteCalls += 1;
},
}),
);
const res = await app.request('/api/stats/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: '{"sessionIds":[4,1.9,7]}',
});
assert.equal(res.status, 400);
assert.equal(deleteCalls, 0);
});
it('DELETE /api/stats/sessions deduplicates valid ids', async () => {
let deletedSessionIds: number[] = [];
const app = createStatsApp(
createMockTracker({
deleteSessions: async (sessionIds: number[]) => {
deletedSessionIds = sessionIds;
},
}),
);
const res = await app.request('/api/stats/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: '{"sessionIds":[4,4,7]}',
});
assert.equal(res.status, 200);
assert.deepEqual(deletedSessionIds, [4, 7]);
});
it('DELETE /api/stats/anime/:animeId deletes the whole library entry', async () => {
let deletedAnimeId: number | null = null;
const app = createStatsApp(
@@ -3295,6 +3599,33 @@ Aligned English subtitle
assert.equal(deleteCalls, 0);
});
it('DELETE /api/stats/anime/:animeId rejects malformed anime ids before deleting', async () => {
let deletedAnimeId: number | null = null;
const app = createStatsApp(
createMockTracker({
deleteAnime: async (animeId: number) => {
deletedAnimeId = animeId;
},
}),
);
for (const animeId of [
'1.9',
'1.0',
'1e2',
'9007199254740992',
'1%0A',
'%201',
'01',
'+1',
'0x1',
]) {
const res = await app.request(`/api/stats/anime/${animeId}`, { method: 'DELETE' });
assert.equal(res.status, 400, `accepted malformed anime id: ${animeId}`);
}
assert.equal(deletedAnimeId, null);
});
it('POST /api/stats/anime/:animeId/merge folds the given entries into the target', async () => {
let merged: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
const app = createStatsApp(
@@ -3400,6 +3731,25 @@ Aligned English subtitle
assert.equal(res.status, 404);
});
it('POST /api/stats/anime/:animeId/merge rejects mixed AniList and TMDB entries as 409', async () => {
const app = createStatsApp(
createMockTracker({
mergeAnime: async () => {
throw new Error(INCOMPATIBLE_PROVIDER_MERGE_MESSAGE);
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/7/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"sourceAnimeIds":[8]}',
});
assert.equal(res.status, 409);
assert.deepEqual(await res.json(), { error: INCOMPATIBLE_PROVIDER_MERGE_MESSAGE });
});
it('PATCH /api/stats/media/:videoId/anime reports an unknown target as 404', async () => {
const app = createStatsApp(
createMockTracker({
@@ -3737,102 +4087,237 @@ Aligned English subtitle
assert.equal(ensureCalls, 1);
});
it('starts the stats server with Bun.serve', () => {
type BunRuntime = {
Bun: {
serve: (options: { fetch: unknown; port: number; hostname: string }) => {
stop: () => void;
};
};
};
const bun = globalThis as typeof globalThis & BunRuntime;
const originalServe = bun.Bun.serve;
let servedWith: { fetch: unknown; port: number; hostname: string } | null = null;
it('starts and stops the stats server with Bun.serve', async () => {
const servedOptions: Array<{ fetch: unknown; port: number; hostname: string }> = [];
let stopCalls = 0;
bun.Bun.serve = (options: { fetch: unknown; port: number; hostname: string }) => {
servedWith = options;
const server = await startStatsServerWithRuntime(
{
port: 3210,
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-start-')),
tracker: createMockTracker(),
},
{
bunServe: (options) => {
servedOptions.push(options);
return {
stop: () => {
stopCalls += 1;
},
};
};
},
},
);
try {
const server = startStatsServer({
port: 3210,
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-start-')),
tracker: createMockTracker(),
});
if (servedWith === null) {
const servedWith = servedOptions[0];
if (!servedWith) {
throw new Error('expected Bun.serve to be called');
}
const servedOptions = servedWith as {
fetch: unknown;
port: number;
hostname: string;
};
assert.equal(servedOptions.port, 3210);
assert.equal(servedOptions.hostname, '127.0.0.1');
assert.equal(typeof servedOptions.fetch, 'function');
assert.equal(servedWith.port, 3210);
assert.equal(servedWith.hostname, '127.0.0.1');
assert.equal(typeof servedWith.fetch, 'function');
server.close();
await Promise.all([server.close(), server.close()]);
assert.equal(stopCalls, 1);
} finally {
bun.Bun.serve = originalServe;
}
});
it('falls back to node:http when Bun.serve is unavailable', () => {
type BunRuntime = {
Bun: {
serve?: (options: { fetch: unknown; port: number; hostname: string }) => {
stop: () => void;
};
};
};
const bun = globalThis as typeof globalThis & BunRuntime;
const originalServe = bun.Bun.serve;
const originalCreateServer = http.createServer;
let listenedWith: { port: number; hostname: string } | null = null;
it('waits for node:http listening and converts startup errors into rejections', async () => {
const app = createStatsApp(createMockTracker());
const listeningServer = http.createServer();
let closeCalls = 0;
bun.Bun.serve = undefined;
(
http as typeof http & {
createServer: typeof http.createServer;
}
).createServer = (() =>
({
listen: (port: number, hostname: string) => {
listenedWith = { port, hostname };
Object.defineProperties(listeningServer, {
listen: {
value: () => listeningServer,
},
close: () => {
close: {
value: (callback?: (error?: Error) => void) => {
closeCalls += 1;
callback?.();
return listeningServer;
},
}) as unknown as ReturnType<typeof http.createServer>) as typeof http.createServer;
},
});
try {
const server = startStatsServer({
let startupSettled = false;
const startup = startNodeHttpServer(
app,
{
port: 3210,
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-events-')),
tracker: createMockTracker(),
},
() => listeningServer,
);
void startup.finally(() => {
startupSettled = true;
});
await Promise.resolve();
assert.equal(startupSettled, false);
listeningServer.emit('listening');
const handle = await startup;
await Promise.all([handle.close(), handle.close()]);
assert.equal(closeCalls, 1);
const failingServer = http.createServer();
Object.defineProperty(failingServer, 'listen', {
value: () => failingServer,
});
const failedStartup = startNodeHttpServer(
app,
{
port: 3210,
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-error-')),
tracker: createMockTracker(),
},
() => failingServer,
);
failingServer.emit('error', Object.assign(new Error('address in use'), { code: 'EADDRINUSE' }));
await assert.rejects(
failedStartup,
(error: NodeJS.ErrnoException) => error.code === 'EADDRINUSE',
);
});
it('starts, rejects address conflicts, and stops through real node:http sockets', async () => {
const app = createStatsApp(createMockTracker());
const server = await startNodeHttpServer(app, {
port: 0,
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-')),
tracker: createMockTracker(),
});
await Promise.all([server.close(), server.close()]);
assert.deepEqual(listenedWith, { port: 0, hostname: '127.0.0.1' });
server.close();
assert.equal(closeCalls, 1);
const blocker = http.createServer();
await new Promise<void>((resolve, reject) => {
blocker.once('error', reject);
blocker.listen(0, '127.0.0.1', resolve);
});
const address = blocker.address();
if (!address || typeof address === 'string') {
throw new Error('expected blocker to listen on a TCP port');
}
try {
await assert.rejects(
startNodeHttpServer(app, {
port: address.port,
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-error-')),
tracker: createMockTracker(),
}),
(error: NodeJS.ErrnoException) => error.code === 'EADDRINUSE',
);
} finally {
bun.Bun.serve = originalServe;
(
http as typeof http & {
createServer: typeof http.createServer;
await new Promise<void>((resolve, reject) => {
blocker.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
).createServer = originalCreateServer;
});
it('enforces request safety through node:http without rejecting bodyless DELETEs', async () => {
await withTempDir(async (staticDir) => {
let deletions = 0;
const tracker = createMockTracker({
deleteSession: async () => {
deletions += 1;
},
});
const listener = http.createServer();
const server = await startNodeHttpServer(
createStatsApp(tracker),
{ port: 0, staticDir, tracker },
(handler) => {
listener.on('request', handler);
return listener;
},
);
try {
const address = listener.address();
assert.ok(address && typeof address !== 'string');
const origin = `http://127.0.0.1:${address.port}`;
const url = `${origin}/api/stats/sessions/1`;
for (const headers of [undefined, { 'Content-Length': '0' }]) {
const response = await fetch(url, { method: 'DELETE', headers });
assert.equal(response.status, 200);
await response.arrayBuffer();
}
assert.equal(deletions, 2);
for (const headers of [
new Headers({ Origin: 'https://attacker.example' }),
new Headers({ Origin: 'null' }),
new Headers({ Host: 'attacker.example' }),
new Headers({ 'Sec-Fetch-Site': 'same-site' }),
]) {
const response = await fetch(url, { method: 'DELETE', headers });
assert.equal(response.status, 403, JSON.stringify(headers));
await response.arrayBuffer();
}
const invalid = await fetch(url, { method: 'DELETE', body: '{}' });
assert.equal(invalid.status, 415);
await invalid.arrayBuffer();
assert.equal(deletions, 2);
const valid = await fetch(url, {
method: 'DELETE',
headers: { Origin: origin, 'Content-Type': 'application/json' },
body: '{}',
});
assert.equal(valid.status, 200);
await valid.arrayBuffer();
assert.equal(deletions, 3);
} finally {
await server.close();
}
});
});
});
it('TMDB reassignment returns 404 for a missing library entry before fetching details', async () => {
const assignments: number[] = [];
let fetches = 0;
const app = createStatsApp(
createMockTracker({
reassignAnimeTmdb: async (animeId: number) => {
assignments.push(animeId);
return { animeId, mergedAnimeIds: [] };
},
}),
{
tmdbClient: {
search: async () => [],
getDetails: async () => {
fetches += 1;
return {
tmdbId: 12,
tmdbType: 'tv',
titleEnglish: 'Drama',
titleNative: null,
description: null,
posterUrl: null,
episodesTotal: 10,
year: null,
originalLanguage: 'ja',
isAnimation: false,
allTitles: ['Drama'],
};
},
},
},
);
const request = (animeId: number) =>
app.request(`/api/stats/anime/${animeId}/tmdb`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tmdbId: 12, tmdbType: 'tv' }),
});
assert.equal((await request(99999)).status, 404);
assert.equal(fetches, 0);
assert.deepEqual(assignments, []);
const response = await request(1);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { ok: true });
assert.equal(fetches, 1);
assert.deepEqual(assignments, [1]);
});
@@ -540,3 +540,200 @@ test('fetchIfMissing re-resolves an unresolved season once AniList publishes the
cleanupDbPath(dbPath);
}
});
for (const linkedToAnilist of [false, true]) {
test(`TMDB fallback preserves AniList identity when linked=${linkedToAnilist}`, async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-01.mkv', {
canonicalTitle: 'Hanzawa Naoki - 01.mkv',
sourcePath: '/tmp/hanzawa-01.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
anilistId: linkedToAnilist ? 42 : null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: null,
parsedTitle: 'Hanzawa Naoki',
parsedSeason: null,
parsedEpisode: 1,
parserSource: 'fallback',
parserConfidence: 1,
parseMetadataJson: null,
});
const fetchCalls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
fetchCalls.push(url);
if (url.startsWith('https://graphql.anilist.co')) {
return createJsonResponse({ data: { Page: { media: [] } } });
}
assert.equal(url, 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
return new Response(new Uint8Array([5, 6, 7]), {
status: 200,
headers: { 'Content-Type': 'image/jpeg' },
});
}) as typeof fetch;
const resolvedTitles: string[] = [];
try {
const fetcher = createCoverArtFetcher(
{ acquire: async () => {}, recordResponse: () => {} },
console,
{
runGuessit: async () => {
throw new Error('guessit unavailable');
},
liveAction: {
async resolveByTitle(title) {
resolvedTitles.push(title);
if (title !== 'Hanzawa Naoki') return null;
return {
tmdbId: 61222,
tmdbType: 'tv',
titleEnglish: 'Hanzawa Naoki',
titleNative: '半沢直樹',
description: 'A banker fights back.',
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
episodesTotal: 10,
year: 2013,
originalLanguage: 'ja',
isAnimation: false,
allTitles: ['Hanzawa Naoki', '半沢直樹'],
};
},
async resolveById() {
return null;
},
},
},
);
const fetched = await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 01.mkv');
const stored = getCoverArt(db, videoId);
const anime = db
.prepare(
'SELECT media_kind AS mediaKind, tmdb_id AS tmdbId, description FROM imm_anime WHERE anime_id = ?',
)
.get(animeId) as { mediaKind: string; tmdbId: number | null; description: string | null };
if (linkedToAnilist) {
assert.equal(fetched, false);
assert.equal(stored?.coverBlob, null);
assert.equal(stored?.coverUrl, null);
assert.equal(anime.mediaKind, 'anime');
assert.equal(anime.tmdbId, null);
assert.deepEqual(resolvedTitles, []);
const requestCount = fetchCalls.length;
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 01.mkv'), false);
assert.equal(fetchCalls.length, requestCount);
return;
}
assert.equal(fetched, true);
// The raw fallback-parser title is tried first, then the tag-stripped one.
assert.deepEqual(resolvedTitles, ['Hanzawa Naoki - 01', 'Hanzawa Naoki']);
assert.equal(stored?.anilistId, null);
assert.equal(stored?.coverUrl, 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
assert.equal(Buffer.from(stored?.coverBlob ?? []).toString('hex'), '050607');
assert.equal(anime.mediaKind, 'live_action');
assert.equal(anime.tmdbId, 61222);
assert.equal(anime.description, 'A banker fights back.');
assert.ok(fetchCalls.some((url) => url.startsWith('https://graphql.anilist.co')));
} finally {
globalThis.fetch = originalFetch;
db.close();
cleanupDbPath(dbPath);
}
});
}
test('fetchIfMissing skips AniList for an entry already linked to TMDB', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-02.mkv', {
canonicalTitle: 'Hanzawa Naoki - 02.mkv',
sourcePath: '/tmp/hanzawa-02.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: null,
parsedTitle: 'Hanzawa Naoki',
parsedSeason: null,
parsedEpisode: 2,
parserSource: 'fallback',
parserConfidence: 1,
parseMetadataJson: null,
});
db.prepare(
"UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 61222, tmdb_type = 'tv' WHERE anime_id = ?",
).run(animeId);
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
assert.equal(String(input), 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
return new Response(new Uint8Array([1]), { status: 200 });
}) as typeof fetch;
const byIdCalls: Array<[string, number]> = [];
try {
const fetcher = createCoverArtFetcher(
{ acquire: async () => {}, recordResponse: () => {} },
console,
{
liveAction: {
async resolveByTitle() {
throw new Error('title search must not run for a linked entry');
},
async resolveById(tmdbType, tmdbId) {
byIdCalls.push([tmdbType, tmdbId]);
return {
tmdbId,
tmdbType,
titleEnglish: 'Hanzawa Naoki',
titleNative: null,
description: null,
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
episodesTotal: 10,
year: null,
originalLanguage: 'ja',
isAnimation: false,
allTitles: [],
};
},
},
},
);
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 02.mkv'), true);
assert.deepEqual(byIdCalls, [['tv', 61222]]);
assert.equal(getCoverArt(db, videoId)?.coverBlob?.length, 1);
} finally {
globalThis.fetch = originalFetch;
db.close();
cleanupDbPath(dbPath);
}
});
+91 -18
View File
@@ -17,6 +17,9 @@ import {
type AnilistQueryExecutor,
type AnilistSeasonResolution,
} from './season-resolver';
import { getVideoTmdbLink, linkAnimeToTmdbTitle } from '../immersion-tracker/live-action-link';
import type { LiveActionMetadataResolver } from '../tmdb/live-action-resolver';
import type { TmdbTitleDetails } from '../tmdb/tmdb-client';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
const NO_MATCH_RETRY_MS = 5 * 60 * 1000;
@@ -40,6 +43,8 @@ interface CoverArtCandidate {
interface CoverArtFetcherOptions {
runGuessit?: GuessAnilistMediaInfoDeps['runGuessit'];
/** Live-action fallback consulted when AniList has no match for a title. */
liveAction?: LiveActionMetadataResolver;
}
export function stripFilenameTags(raw: string): string {
@@ -153,6 +158,60 @@ export function createCoverArtFetcher(
return true;
};
const cacheNoMatch = (db: DatabaseSync, videoId: number): void => {
upsertCoverArt(db, videoId, {
anilistId: null,
coverUrl: null,
coverBlob: null,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
};
// Links the video's library entry to the TMDB title and stores its poster.
const storeLiveActionArt = async (
db: DatabaseSync,
videoId: number,
details: TmdbTitleDetails,
): Promise<boolean> => {
const row = db
.prepare(
`SELECT v.anime_id AS animeId, a.anilist_id AS anilistId
FROM imm_videos v LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.video_id = ?`,
)
.get(videoId) as { animeId: number | null; anilistId: number | null } | undefined;
if (row?.anilistId != null) return false;
if (row?.animeId) {
const link = linkAnimeToTmdbTitle(db, row.animeId, details, { mode: 'auto' });
if (link.mergedAnimeIds.length > 0) {
logger.info(
'cover-art: folded library entries %s into %d (same TMDB title)',
link.mergedAnimeIds.join(','),
link.animeId,
);
}
}
const coverBlob = details.posterUrl ? await downloadImage(details.posterUrl) : null;
upsertCoverArt(db, videoId, {
anilistId: null,
coverUrl: details.posterUrl,
coverBlob,
titleRomaji: null,
titleEnglish: details.titleEnglish,
episodesTotal: details.episodesTotal,
});
logger.info(
'cover-art: linked videoId=%d to TMDB %s/%d "%s"',
videoId,
details.tmdbType,
details.tmdbId,
details.titleEnglish ?? details.titleNative ?? '',
);
return coverBlob !== null;
};
const resolveCanonicalTitle = (
db: DatabaseSync,
videoId: number,
@@ -198,7 +257,7 @@ export function createCoverArtFetcher(
`
SELECT 1 FROM imm_videos v
JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.video_id = ? AND a.media_kind != 'anime'
WHERE v.video_id = ? AND a.media_kind = 'youtube'
`,
)
.get(videoId);
@@ -236,18 +295,31 @@ export function createCoverArtFetcher(
return false;
}
// A live-action entry already knows its TMDB title; AniList has nothing
// to add and would only produce a spurious anime match.
const hasAnilistLink = Boolean(
db
.prepare(
`SELECT 1 FROM imm_videos v JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.video_id = ? AND a.anilist_id IS NOT NULL`,
)
.get(videoId),
);
const tmdbLink = getVideoTmdbLink(db, videoId);
if (tmdbLink && !hasAnilistLink) {
const details = await options.liveAction?.resolveById(tmdbLink.tmdbType, tmdbLink.tmdbId);
if (details) {
return storeLiveActionArt(db, videoId, details);
}
cacheNoMatch(db, videoId);
return false;
}
const effectiveTitle = resolveCanonicalTitle(db, videoId, canonicalTitle);
const cleaned = stripFilenameTags(effectiveTitle);
if (!cleaned) {
logger.warn('cover-art: empty title after stripping tags for videoId=%d', videoId);
upsertCoverArt(db, videoId, {
anilistId: null,
coverUrl: null,
coverBlob: null,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
cacheNoMatch(db, videoId);
return false;
}
@@ -305,15 +377,16 @@ export function createCoverArtFetcher(
const selected = resolution?.media ?? null;
if (!selected) {
logger.info('cover-art: no Anilist results for "%s", caching no-match', searchBase);
upsertCoverArt(db, videoId, {
anilistId: null,
coverUrl: null,
coverBlob: null,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
if (options.liveAction && !hasAnilistLink) {
for (const searchTitle of searchTitles) {
const details = await options.liveAction.resolveByTitle(searchTitle);
if (details) {
return storeLiveActionArt(db, videoId, details);
}
}
}
logger.info('cover-art: no Anilist or TMDB results for "%s", caching no-match', searchBase);
cacheNoMatch(db, videoId);
return false;
}
+11
View File
@@ -410,6 +410,17 @@ test('handleCliCommand ensures background stats server for second-instance --sta
assert.equal(ensured.length, 1);
});
test('handleCliCommand reports unexpected background stats startup failures', async () => {
const startup = Promise.reject(new Error('startup unavailable'));
const { deps, calls, osd } = createDeps({ ensureBackgroundStatsServer: () => startup });
handleCliCommand(makeArgs({ start: true, background: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.includes('error:ensureBackgroundStatsServer failed:'));
assert.ok(osd.includes('Stats server startup failed: startup unavailable'));
});
test('handleCliCommand does not ensure background stats server for foreground --start', () => {
const ensured: number[] = [];
const { deps } = createDeps({
+10 -3
View File
@@ -108,7 +108,7 @@ export interface CliCommandServiceDeps {
mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource;
}) => Promise<void>;
ensureBackgroundStatsServer?: () => void;
ensureBackgroundStatsServer?: () => Promise<void> | void;
printHelp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
@@ -190,7 +190,7 @@ interface AnilistCliRuntime {
interface AppCliRuntime {
stop: () => void;
hasMainWindow: () => boolean;
ensureBackgroundStatsServer?: () => void;
ensureBackgroundStatsServer?: () => Promise<void> | void;
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
@@ -403,7 +403,14 @@ export function handleCliCommand(
}
if (args.start && args.background) {
deps.ensureBackgroundStatsServer?.();
runAsyncWithOsd(
async () => {
await deps.ensureBackgroundStatsServer?.();
},
deps,
'ensureBackgroundStatsServer',
'Stats server startup failed',
);
}
if (args.sessionAction) {
@@ -5564,3 +5564,91 @@ test('getVocabularySummary keeps different known-word snapshots independent', as
cleanupDbPath(dbPath);
}
});
for (const provider of ['anilist', 'tmdb'] as const) {
test(`${provider} reassignment keeps metadata and artwork on download failure, then replaces or clears both`, async () => {
const dbPath = makeDbPath();
const originalFetch = globalThis.fetch;
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const { db } = tracker as unknown as { db: DatabaseSync };
db.exec(`
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'show', 'Show', 1000, 1000);
INSERT INTO imm_videos(video_id, video_key, canonical_title, source_type, anime_id, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'local:/tmp/show.mkv', 'Show', 1, 1, 0, 1000, 1000);
`);
const tmdb = {
tmdbId: 12,
tmdbType: 'tv' as const,
titleEnglish: 'Drama',
titleNative: null,
description: 'New description',
episodesTotal: 10,
};
globalThis.fetch = async () => new Response(new Uint8Array([1, 2, 3]));
if (provider === 'anilist') {
await tracker.reassignAnimeTmdb(1, { ...tmdb, posterUrl: 'https://images.test/old' });
} else {
await tracker.reassignAnimeAnilist(1, {
anilistId: 42,
coverUrl: 'https://images.test/old',
});
}
assert.equal(await tracker.hasAnime(1), true);
assert.equal(await tracker.hasAnime(999), false);
const readMetadata = () =>
db.prepare('SELECT * FROM imm_anime WHERE anime_id = 1').get() as {
media_kind: string;
anilist_id: number | null;
tmdb_id: number | null;
};
const before = readMetadata();
const oldArt = await tracker.getAnimeCoverArt(1);
const snapshot = (value: unknown) =>
JSON.stringify(value, (key, item: unknown) => (key === '_metadata' ? undefined : item));
const reassign = (url: string | null) =>
provider === 'anilist'
? tracker!.reassignAnimeAnilist(1, { anilistId: 99, coverUrl: url })
: tracker!.reassignAnimeTmdb(1, { ...tmdb, posterUrl: url });
for (const failure of ['http', 'network']) {
globalThis.fetch = async () => {
if (failure === 'network') throw new Error('offline');
return new Response(null, { status: 503 });
};
await assert.rejects(reassign('https://images.test/new'));
assert.equal(snapshot(readMetadata()), snapshot(before));
assert.equal(snapshot(await tracker.getAnimeCoverArt(1)), snapshot(oldArt));
}
globalThis.fetch = async () => new Response(new Uint8Array([9, 8, 7]));
if (provider === 'anilist') {
// Retained sessions without lifetime summaries exercise the bootstrap
// inside the reassignment transaction.
db.exec(`INSERT INTO imm_sessions(session_uuid, video_id, started_at_ms, ended_at_ms,
status, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES ('retained-session', 1, '1000', '2000', 2, 1000, 1000, 2000)`);
}
await reassign('https://images.test/new');
const detail = readMetadata();
assert.equal(detail.media_kind, provider === 'anilist' ? 'anime' : 'live_action');
assert.equal(detail.anilist_id, provider === 'anilist' ? 99 : null);
assert.equal(detail.tmdb_id, provider === 'tmdb' ? 12 : null);
if (provider === 'anilist') {
assert.equal((await tracker.getAnimeDetail(1))?.totalActiveMs, 1000);
}
assert.deepEqual(
new Uint8Array((await tracker.getAnimeCoverArt(1))!.coverBlob!),
new Uint8Array([9, 8, 7]),
);
await reassign(null);
assert.equal(await tracker.getAnimeCoverArt(1), null);
assert.equal(readMetadata().media_kind, detail.media_kind);
} finally {
globalThis.fetch = originalFetch;
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
}
+105 -19
View File
@@ -91,6 +91,7 @@ import {
} from './immersion-tracker/query-library';
import {
cleanupVocabularyStats,
clearAnimeCoverArt,
getVideoDurationMs,
markVideoWatched,
upsertCoverArt,
@@ -120,7 +121,7 @@ import {
dismissAnimeMergeRecommendation,
getAnimeMergeRecommendations,
repairLegacySeasonlessAnimeRows,
resolveAnimeAnilistConflict,
resolveAnimeAnilistConflictInTransaction,
type AnimeMergeRecommendation,
} from './immersion-tracker/anime-season-repair';
import {
@@ -129,6 +130,11 @@ import {
type AnimeMergeSummary,
type VideoMoveSummary,
} from './immersion-tracker/anime-merge';
import {
linkAnimeToTmdbTitleInTransaction,
type LiveActionLinkResult,
type LiveActionTitleInput,
} from './immersion-tracker/live-action-link';
import {
buildVideoKey,
deriveCanonicalTitle,
@@ -878,6 +884,10 @@ export class ImmersionTrackerService {
return getAnimeDetail(this.db, animeId);
}
async hasAnime(animeId: number): Promise<boolean> {
return Boolean(this.db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId));
}
async getAnimeEpisodes(animeId: number): Promise<AnimeEpisodeRow[]> {
return getAnimeEpisodes(this.db, animeId);
}
@@ -1160,19 +1170,31 @@ export class ImmersionTrackerService {
coverUrl?: string | null;
},
): Promise<void> {
const coverBlob = await this.downloadReplacementCover(info.coverUrl);
this.requireWriteQueueDrained('reassigning an AniList entry');
this.db.exec('BEGIN IMMEDIATE');
try {
this.db
.prepare('UPDATE imm_anime SET tmdb_id = NULL, tmdb_type = NULL WHERE anime_id = ?')
.run(animeId);
// The user is acting on this entry, so it is the one that survives when
// another row already claims the same AniList id.
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId, {
const repair = resolveAnimeAnilistConflictInTransaction(this.db, animeId, info.anilistId, {
survivor: 'target',
matchConfidence: 'manual',
});
if (repair.anilistAssignmentBlocked) return;
if (repair.anilistAssignmentBlocked) {
this.db.exec('ROLLBACK');
return;
}
this.db
.prepare(
`
UPDATE imm_anime
SET anilist_id = ?,
media_kind = 'anime',
tmdb_id = NULL,
tmdb_type = NULL,
title_romaji = COALESCE(?, title_romaji),
title_english = COALESCE(?, title_english),
title_native = COALESCE(?, title_native),
@@ -1197,7 +1219,7 @@ export class ImmersionTrackerService {
// media ledger exists, only the redistributed and explicitly edited anime
// can have changed.
if (shouldBackfillLifetimeSummaries(this.db)) {
repairLifetimeSummariesFromMedia(this.db);
rebuildLifetimeSummariesInTransaction(this.db);
} else {
const affectedAnimeIds = new Set(repair.affectedAnimeIds);
affectedAnimeIds.add(animeId);
@@ -1205,22 +1227,8 @@ export class ImmersionTrackerService {
recomputeLifetimeGlobalFromSummaries(this.db);
}
// Update cover art for all videos in this anime
if (info.coverUrl) {
const videos = this.db
.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?')
.all(animeId) as Array<{ video_id: number }>;
let coverBlob: Buffer | null = null;
try {
const res = await fetch(info.coverUrl);
if (res.ok) {
coverBlob = Buffer.from(await res.arrayBuffer());
}
} catch {
/* ignore */
}
for (const v of videos) {
upsertCoverArt(this.db, v.video_id, {
this.applyCoverArtToAnimeVideos(animeId, {
anilistId: info.anilistId,
coverUrl: info.coverUrl,
coverBlob,
@@ -1228,7 +1236,85 @@ export class ImmersionTrackerService {
titleEnglish: info.titleEnglish ?? null,
episodesTotal: info.episodesTotal ?? null,
});
} else {
clearAnimeCoverArt(this.db, animeId);
}
this.db.exec('COMMIT');
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
}
/**
* Link a library entry to a TMDB title chosen in the dashboard. Every other
* entry pointing at the same title is folded into this one, and its poster
* replaces the art of every episode.
*/
async reassignAnimeTmdb(
animeId: number,
details: LiveActionTitleInput & { posterUrl: string | null },
): Promise<LiveActionLinkResult> {
const coverBlob = await this.downloadReplacementCover(details.posterUrl);
this.requireWriteQueueDrained('linking a TMDB title');
this.db.exec('BEGIN IMMEDIATE');
try {
const result = linkAnimeToTmdbTitleInTransaction(this.db, animeId, details, {
mode: 'manual',
});
if (details.posterUrl) {
this.applyCoverArtToAnimeVideos(result.animeId, {
anilistId: null,
coverUrl: details.posterUrl,
coverBlob,
titleRomaji: null,
titleEnglish: details.titleEnglish,
episodesTotal: details.episodesTotal,
});
} else {
// The user chose this title deliberately, so art from the previous link
// must not keep standing in for it.
clearAnimeCoverArt(this.db, result.animeId);
}
this.db.exec('COMMIT');
return result;
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
}
private async downloadReplacementCover(url: string | null | undefined): Promise<Buffer | null> {
if (!url) return null;
const response = await fetch(url);
if (!response.ok) throw new Error(`Cover download failed: ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
/** Stores the downloaded replacement against every episode of the entry. */
private applyCoverArtToAnimeVideos(
animeId: number,
art: {
anilistId: number | null;
coverUrl: string;
coverBlob: Buffer | null;
titleRomaji: string | null;
titleEnglish: string | null;
episodesTotal: number | null;
},
): void {
const videos = this.db
.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?')
.all(animeId) as Array<{ video_id: number }>;
for (const v of videos) {
upsertCoverArt(this.db, v.video_id, {
anilistId: art.anilistId,
coverUrl: art.coverUrl,
coverBlob: art.coverBlob,
titleRomaji: art.titleRomaji,
titleEnglish: art.titleEnglish,
episodesTotal: art.episodesTotal,
});
}
}
@@ -0,0 +1,309 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { Database } from '../sqlite.js';
import type { DatabaseSync } from '../sqlite.js';
import { applyPragmas, ensureSchema, getOrCreateAnimeRecord } from '../storage.js';
import { repairLegacySeasonlessAnimeRows } from '../anime-season-repair.js';
import { mergeAnimeRecords, mergeAnimeRecordsInTransaction } from '../anime-merge.js';
import { getVideoTmdbLink, linkAnimeToTmdbTitle } from '../live-action-link.js';
import { getAnimeCoverArt, getCoverArt } from '../query-library.js';
import { clearAnimeCoverArt, upsertCoverArt } from '../query-maintenance.js';
const BASE_MS = 1_700_000_000_000;
function withDb(work: (db: DatabaseSync) => void): void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-live-action-link-'));
const db = new Database(path.join(dir, 'immersion.sqlite'));
try {
applyPragmas(db);
ensureSchema(db);
work(db);
} finally {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
}
}
function insertAnime(
db: DatabaseSync,
animeId: number,
title: string,
anilistId: number | null = null,
) {
db.prepare(
`INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, anilist_id, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(animeId, title.toLowerCase(), title, anilistId, BASE_MS, BASE_MS);
}
function insertEpisode(db: DatabaseSync, videoId: number, animeId: number, season: number | null) {
db.prepare(
`INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, parsed_title, parsed_season, parsed_episode, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, 1, 'Hanzawa Naoki', ?, ?, 1, 1440000, ?, ?)`,
).run(
videoId,
`local:/tmp/${videoId}.mkv`,
animeId,
`Ep ${videoId}`,
season,
videoId,
BASE_MS,
BASE_MS,
);
db.prepare(
`INSERT INTO imm_lifetime_media(video_id, total_sessions, total_active_ms, total_cards, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, 1, 1000, 0, 1, ?, ?, ?, ?)`,
).run(videoId, String(BASE_MS), String(BASE_MS + 1000), BASE_MS, BASE_MS);
}
interface AnimeRowView {
mediaKind: string;
tmdbId: number | null;
tmdbType: string | null;
anilistId: number | null;
titleEnglish: string | null;
titleNative: string | null;
description: string | null;
}
// Copies the selected columns so the driver's row metadata does not leak into
// deep-equality assertions.
function animeRow(db: DatabaseSync, animeId: number): AnimeRowView | undefined {
const row = db
.prepare(
`SELECT media_kind AS mediaKind, tmdb_id AS tmdbId, tmdb_type AS tmdbType, anilist_id AS anilistId,
title_english AS titleEnglish, title_native AS titleNative, description
FROM imm_anime WHERE anime_id = ?`,
)
.get(animeId) as AnimeRowView | undefined;
if (!row) return undefined;
const { mediaKind, tmdbId, tmdbType, anilistId, titleEnglish, titleNative, description } = row;
return { mediaKind, tmdbId, tmdbType, anilistId, titleEnglish, titleNative, description };
}
function animeCount(db: DatabaseSync): number {
return (db.prepare('SELECT COUNT(*) AS n FROM imm_anime').get() as { n: number }).n;
}
function videoOwner(db: DatabaseSync, videoId: number): number | null {
return (
db.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?').get(videoId) as {
animeId: number | null;
}
).animeId;
}
const HANZAWA = {
tmdbId: 61222,
tmdbType: 'tv' as const,
titleEnglish: 'Hanzawa Naoki',
titleNative: '半沢直樹',
description: 'A banker fights back.',
episodesTotal: 10,
};
test('a manual link overwrites metadata, drops the AniList link, and folds other holders in', () => {
withDb((db) => {
insertAnime(db, 1, 'Hanzawa Naoki', 4242);
insertAnime(db, 2, 'Hanzawa Naoki Season 2');
insertEpisode(db, 1, 1, 1);
insertEpisode(db, 2, 2, 2);
db.prepare(
`UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = ?, tmdb_type = 'tv', description = 'old' WHERE anime_id = 2`,
).run(HANZAWA.tmdbId);
const result = linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode: 'manual' });
assert.deepEqual(result, { animeId: 1, mergedAnimeIds: [2] });
assert.deepEqual(animeRow(db, 1), {
mediaKind: 'live_action',
tmdbId: 61222,
tmdbType: 'tv',
anilistId: null,
titleEnglish: 'Hanzawa Naoki',
titleNative: '半沢直樹',
description: 'A banker fights back.',
});
assert.equal(animeRow(db, 2), undefined);
assert.equal(animeCount(db), 1);
assert.equal(videoOwner(db, 2), 1);
assert.deepEqual(getVideoTmdbLink(db, 2), { animeId: 1, tmdbId: 61222, tmdbType: 'tv' });
});
});
test('an automatic link joins the entry that already owns the title and only fills gaps', () => {
withDb((db) => {
insertAnime(db, 1, 'Hanzawa Naoki');
insertEpisode(db, 1, 1, 1);
linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, description: 'kept' }, { mode: 'manual' });
const newcomer = getOrCreateAnimeRecord(db, {
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
seasonScope: 2,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
insertEpisode(db, 2, newcomer, 2);
const result = linkAnimeToTmdbTitle(db, newcomer, HANZAWA, { mode: 'auto' });
assert.deepEqual(result, { animeId: 1, mergedAnimeIds: [newcomer] });
assert.equal(animeRow(db, 1)?.description, 'kept');
assert.equal(animeCount(db), 1);
assert.equal(videoOwner(db, 2), 1);
// The merged-away season title is remembered, so the next episode of that
// season lands on the survivor without a detour through a new row.
const again = getOrCreateAnimeRecord(db, {
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
seasonScope: 2,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
assert.equal(again, 1);
});
});
test('startup season repair leaves multi-season live-action entries alone', () => {
withDb((db) => {
insertAnime(db, 1, 'Hanzawa Naoki');
insertEpisode(db, 1, 1, 1);
insertEpisode(db, 2, 1, 2);
linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode: 'manual' });
repairLegacySeasonlessAnimeRows(db);
assert.equal(animeCount(db), 1);
assert.equal(videoOwner(db, 1), 1);
assert.equal(videoOwner(db, 2), 1);
assert.equal(getVideoTmdbLink(db, 2)?.animeId, 1);
});
});
test('getVideoTmdbLink is null for anime entries and unlinked videos', () => {
withDb((db) => {
insertAnime(db, 1, 'Some Anime', 77);
insertEpisode(db, 1, 1, 1);
assert.equal(getVideoTmdbLink(db, 1), null);
assert.equal(getVideoTmdbLink(db, 99), null);
});
});
test('clearAnimeCoverArt drops every episode cover of the entry and its orphaned blob', () => {
withDb((db) => {
insertAnime(db, 1, 'Hanzawa Naoki');
insertAnime(db, 2, 'Other Show');
insertEpisode(db, 1, 1, 1);
insertEpisode(db, 2, 1, 1);
insertEpisode(db, 3, 2, 1);
const shared = Buffer.from([1, 2, 3]);
for (const videoId of [1, 2]) {
upsertCoverArt(db, videoId, {
anilistId: 4242,
coverUrl: 'https://images.test/a.jpg',
coverBlob: shared,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
}
upsertCoverArt(db, 3, {
anilistId: 99,
coverUrl: 'https://images.test/b.jpg',
coverBlob: Buffer.from([9]),
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
clearAnimeCoverArt(db, 1);
assert.equal(getAnimeCoverArt(db, 1), null);
assert.equal(getCoverArt(db, 3)?.coverBlob?.length, 1);
const blobs = (
db.prepare('SELECT COUNT(*) AS n FROM imm_cover_art_blobs').get() as { n: number }
).n;
assert.equal(blobs, 1);
});
});
for (const targetId of [1, 2, 3]) {
test(`merge rejects mixed providers before moving any source into entry ${targetId}`, () => {
withDb((db) => {
insertAnime(db, 1, 'Anime', 77);
insertAnime(db, 2, 'Drama');
insertAnime(db, 3, 'Unlinked');
insertEpisode(db, 1, 1, 1);
insertEpisode(db, 2, 2, 1);
db.exec(
"UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 12, tmdb_type = 'tv' WHERE anime_id = 2",
);
for (const merge of [mergeAnimeRecords, mergeAnimeRecordsInTransaction]) {
assert.throws(
() => merge(db, targetId, [3, 1, 2]),
/AniList-linked and TMDB-linked library entries cannot be merged/,
);
assert.equal(animeCount(db), 3);
assert.equal(videoOwner(db, 1), 1);
assert.equal(videoOwner(db, 2), 2);
}
});
});
}
for (const mode of ['manual', 'auto'] as const) {
test(`TMDB ${mode} linking rolls back the merge when the survivor update fails`, () => {
withDb((db) => {
insertAnime(db, 1, 'New entry');
insertAnime(db, 2, 'Existing entry');
insertEpisode(db, 1, 1, 1);
insertEpisode(db, 2, 2, 2);
db.prepare(
"UPDATE imm_anime SET tmdb_id = ?, tmdb_type = 'tv', media_kind = 'live_action' WHERE anime_id = 2",
).run(HANZAWA.tmdbId);
db.exec(`CREATE TRIGGER reject_link BEFORE UPDATE ON imm_anime
WHEN NEW.description = 'A banker fights back.'
BEGIN SELECT RAISE(ABORT, 'rejected survivor update'); END`);
assert.throws(
() => linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode }),
/rejected survivor update/,
);
assert.equal(animeCount(db), 2);
assert.equal(videoOwner(db, 1), 1);
assert.equal(videoOwner(db, 2), 2);
assert.equal(animeRow(db, 1)?.tmdbId, null);
assert.equal(animeRow(db, 2)?.tmdbId, HANZAWA.tmdbId);
});
});
}
for (const mode of ['manual', 'auto'] as const) {
test(`${mode} TMDB linking refreshes completion totals without merging records`, () => {
withDb((db) => {
insertAnime(db, 1, 'Hanzawa Naoki');
insertEpisode(db, 1, 1, 1);
const completed = () =>
(
db
.prepare('SELECT anime_completed AS count FROM imm_lifetime_global WHERE global_id = 1')
.get() as { count: number }
).count;
assert.equal(completed(), 0);
const result = linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, episodesTotal: 1 }, { mode });
assert.deepEqual(result.mergedAnimeIds, []);
assert.equal(completed(), 1);
linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, episodesTotal: 2 }, { mode: 'manual' });
assert.equal(completed(), 0);
assert.equal(animeCount(db), 1);
});
});
}
@@ -1,4 +1,4 @@
import type { MediaKind } from '../../../shared/media-kind';
import { shareTitleNamespace, type MediaKind } from '../../../shared/media-kind';
import type { DatabaseSync } from './sqlite';
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
import { toDbTimestamp } from './query-shared';
@@ -6,8 +6,12 @@ import { nowMs } from './time';
/** Thrown when a move names an episode or destination entry that is not there. */
export const UNKNOWN_MOVE_TARGET_MESSAGE = 'Unknown episode or target library entry';
/** Thrown when a merge or move would mix an anime entry with a YouTube channel. */
export const MEDIA_KIND_MISMATCH_MESSAGE = 'Anime and YouTube channel entries cannot be combined';
/** Thrown when a merge would combine an AniList-linked entry with a TMDB-linked one. */
export const INCOMPATIBLE_PROVIDER_MERGE_MESSAGE =
'AniList-linked and TMDB-linked library entries cannot be merged together';
/** Thrown when a merge or move would mix a YouTube channel with an anime or live-action entry. */
export const MEDIA_KIND_MISMATCH_MESSAGE =
'YouTube channels cannot be combined with anime or live-action entries';
export interface AnimeMergeSummary {
/** Library entry that owns every moved episode once the merge finishes. */
@@ -33,6 +37,9 @@ interface AnimeMetadataRow {
title_native: string | null;
episodes_total: number | null;
description: string | null;
media_kind: string;
tmdb_id: number | null;
tmdb_type: string | null;
}
function emptyMergeSummary(survivingAnimeId: number): AnimeMergeSummary {
@@ -55,7 +62,8 @@ function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow
return (db
.prepare(
`
SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description
SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description,
media_kind, tmdb_id, tmdb_type
FROM imm_anime
WHERE anime_id = ?
`,
@@ -137,6 +145,12 @@ function absorbAnimeMetadata(
title_native = COALESCE(title_native, ?),
episodes_total = COALESCE(episodes_total, ?),
description = COALESCE(description, ?),
tmdb_id = COALESCE(tmdb_id, ?),
tmdb_type = CASE WHEN tmdb_id IS NULL THEN ? ELSE tmdb_type END,
media_kind = CASE
WHEN anilist_id IS NULL AND tmdb_id IS NULL AND ? IS NOT NULL THEN ?
ELSE media_kind
END,
LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
@@ -147,6 +161,10 @@ function absorbAnimeMetadata(
source.title_native,
source.episodes_total,
source.description,
source.tmdb_id,
source.tmdb_type,
source.tmdb_id,
source.media_kind,
updatedAt,
targetAnimeId,
);
@@ -172,6 +190,18 @@ export function mergeAnimeRecordsInTransaction(
return summary;
}
// Validate the whole group before moving anything, including when the
// unlinked target would inherit conflicting providers from two sources.
const metadata = [targetAnimeId, ...new Set(sourceAnimeIds)].map((id) =>
readAnimeMetadata(db, id),
);
if (
metadata.some((row) => row?.anilist_id != null) &&
metadata.some((row) => row?.tmdb_id != null)
) {
throw new Error(INCOMPATIBLE_PROVIDER_MERGE_MESSAGE);
}
const updatedAt = toDbTimestamp(nowMs());
const sourceVideosStmt = db.prepare(
'SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ?',
@@ -206,8 +236,8 @@ export function mergeAnimeRecordsInTransaction(
const sourceKind = readMediaKind(db, sourceAnimeId);
if (sourceKind === null) continue;
// A channel folded into an anime would only be recreated on the next
// watch, because title lookups never cross kinds; refuse instead.
if (sourceKind !== targetKind) {
// watch, because title lookups never cross namespaces; refuse instead.
if (!shareTitleNamespace(sourceKind, targetKind)) {
throw new Error(MEDIA_KIND_MISMATCH_MESSAGE);
}
@@ -275,7 +305,8 @@ export function moveVideoToAnime(
}
const previousAnimeId = videoRow.animeId;
if (previousAnimeId !== null && readMediaKind(db, previousAnimeId) !== targetKind) {
const previousKind = previousAnimeId === null ? null : readMediaKind(db, previousAnimeId);
if (previousKind !== null && !shareTitleNamespace(previousKind, targetKind)) {
throw new Error(MEDIA_KIND_MISMATCH_MESSAGE);
}
if (previousAnimeId === targetAnimeId) {
@@ -134,7 +134,7 @@ function getAnimeRow(db: DatabaseSync, animeId: number): AnimeRow | null {
episodes_total,
description
FROM imm_anime
WHERE anime_id = ? AND media_kind = 'anime'
WHERE anime_id = ? AND media_kind != 'youtube'
`,
)
.get(animeId) as AnimeRow | null;
@@ -372,6 +372,18 @@ export function resolveAnimeAnilistConflict(
targetAnimeId: number,
anilistId: number,
options: AnimeAnilistConflictOptions = {},
): AnimeSeasonRepairSummary {
return runInTransaction(db, () =>
resolveAnimeAnilistConflictInTransaction(db, targetAnimeId, anilistId, options),
);
}
/** Caller owns the write transaction. */
export function resolveAnimeAnilistConflictInTransaction(
db: DatabaseSync,
targetAnimeId: number,
anilistId: number,
options: AnimeAnilistConflictOptions = {},
): AnimeSeasonRepairSummary {
if (!getAnimeRow(db, targetAnimeId)) {
const summary = emptySummary();
@@ -392,13 +404,12 @@ export function resolveAnimeAnilistConflict(
if (!conflict) {
return emptySummary();
}
if (!getAnimeRow(db, conflict.animeId)) {
const summary = emptySummary();
summary.anilistAssignmentBlocked = true;
return summary;
}
return runInTransaction(db, () => {
const targetRow = getAnimeRow(db, targetAnimeId);
if (
options.survivor !== 'target' &&
@@ -476,7 +487,6 @@ export function resolveAnimeAnilistConflict(
transferAnilistToAnimeId: targetAnimeId,
overwriteTargetAnilist: true,
});
});
}
function canMergeAnilistConflict(
@@ -0,0 +1,179 @@
import type { DatabaseSync } from './sqlite';
import type { TmdbMediaType } from '../../../shared/media-kind';
import { mergeAnimeRecordsInTransaction } from './anime-merge';
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
import { toDbTimestamp } from './query-shared';
import { nowMs } from './time';
export interface LiveActionTitleInput {
tmdbId: number;
tmdbType: TmdbMediaType;
titleEnglish: string | null;
titleNative: string | null;
description: string | null;
episodesTotal: number | null;
}
export interface LiveActionLinkResult {
/** Library entry that carries the TMDB link once the call finishes. */
animeId: number;
/** Entries folded into `animeId` because they pointed at the same TMDB title. */
mergedAnimeIds: number[];
}
export interface LiveActionLinkOptions {
/**
* `manual`: the user picked this title, so stored titles are overwritten and
* every other holder of the TMDB id is folded into this entry.
* `auto`: an exact filename match, so gaps are filled and the entry joins an
* existing holder rather than displacing it.
*/
mode: 'manual' | 'auto';
}
export interface VideoTmdbLink {
animeId: number;
tmdbId: number;
tmdbType: TmdbMediaType;
}
function findOtherTmdbHolders(
db: DatabaseSync,
animeId: number,
input: Pick<LiveActionTitleInput, 'tmdbId' | 'tmdbType'>,
): number[] {
return (
db
.prepare(
`SELECT anime_id AS animeId
FROM imm_anime
WHERE tmdb_id = ? AND tmdb_type = ? AND anime_id != ?
ORDER BY anime_id ASC`,
)
.all(input.tmdbId, input.tmdbType, animeId) as Array<{ animeId: number }>
).map((row) => row.animeId);
}
/**
* Link a library entry to a TMDB title. Unlike AniList, a TMDB show spans all
* of its seasons, so entries that resolve to the same title are one show and
* are merged regardless of the season each was parsed with.
*/
export function linkAnimeToTmdbTitle(
db: DatabaseSync,
animeId: number,
input: LiveActionTitleInput,
options: LiveActionLinkOptions,
): LiveActionLinkResult {
db.exec('BEGIN IMMEDIATE');
try {
const result = linkAnimeToTmdbTitleInTransaction(db, animeId, input, options);
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
/** Caller owns the write transaction, including any artwork replacement. */
export function linkAnimeToTmdbTitleInTransaction(
db: DatabaseSync,
animeId: number,
input: LiveActionTitleInput,
options: LiveActionLinkOptions,
): LiveActionLinkResult {
const target = db.prepare('SELECT anilist_id FROM imm_anime WHERE anime_id = ?').get(animeId) as
| { anilist_id: number | null }
| undefined;
if (!target) throw new Error('Unknown library entry');
if (target.anilist_id !== null) {
if (options.mode === 'auto')
throw new Error('Cannot automatically replace an AniList identity');
// An explicit reassignment changes providers before compatible rows merge.
db.prepare('UPDATE imm_anime SET anilist_id = NULL WHERE anime_id = ?').run(animeId);
}
const others = findOtherTmdbHolders(db, animeId, input);
let survivor = animeId;
let mergedAnimeIds: number[] = [];
if (others.length > 0) {
if (options.mode === 'manual') {
mergedAnimeIds = mergeAnimeRecordsInTransaction(db, animeId, others).mergedAnimeIds;
} else {
// Keep the entry the user already sees; the newcomer is the transient
// "Show Season 3" row that a fresh season folder just created.
survivor = others[0]!;
mergedAnimeIds = mergeAnimeRecordsInTransaction(db, survivor, [
animeId,
...others.slice(1),
]).mergedAnimeIds;
}
}
const updatedAt = toDbTimestamp(nowMs());
if (options.mode === 'manual') {
db.prepare(
`UPDATE imm_anime
SET media_kind = 'live_action',
tmdb_id = ?,
tmdb_type = ?,
anilist_id = NULL,
title_romaji = NULL,
title_english = ?,
title_native = ?,
episodes_total = ?,
description = ?,
LAST_UPDATE_DATE = ?
WHERE anime_id = ?`,
).run(
input.tmdbId,
input.tmdbType,
input.titleEnglish,
input.titleNative,
input.episodesTotal,
input.description,
updatedAt,
survivor,
);
} else {
db.prepare(
`UPDATE imm_anime
SET media_kind = 'live_action',
tmdb_id = ?,
tmdb_type = ?,
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(
input.tmdbId,
input.tmdbType,
input.titleEnglish,
input.titleNative,
input.episodesTotal,
input.description,
updatedAt,
survivor,
);
}
recomputeLifetimeAnimeAggregatesInTransaction(db);
return { animeId: survivor, mergedAnimeIds };
}
/** The TMDB link of the live-action entry a video belongs to, if any. */
export function getVideoTmdbLink(db: DatabaseSync, videoId: number): VideoTmdbLink | null {
const row = db
.prepare(
`SELECT a.anime_id AS animeId, a.tmdb_id AS tmdbId, a.tmdb_type AS tmdbType
FROM imm_videos v
JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.video_id = ?
AND a.media_kind = 'live_action'
AND a.tmdb_id IS NOT NULL
AND a.tmdb_type IN ('tv', 'movie')`,
)
.get(videoId) as VideoTmdbLink | undefined;
return row ? { animeId: row.animeId, tmdbId: row.tmdbId, tmdbType: row.tmdbType } : null;
}
@@ -35,6 +35,9 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
a.canonical_title AS canonicalTitle,
a.media_kind AS mediaKind,
a.anilist_id AS anilistId,
a.media_kind AS mediaKind,
a.tmdb_id AS tmdbId,
a.tmdb_type AS tmdbType,
COALESCE(lm.total_sessions, 0) AS totalSessions,
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
COALESCE(lm.total_cards, 0) AS totalCards,
@@ -66,6 +69,9 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
a.canonical_title AS canonicalTitle,
a.media_kind AS mediaKind,
a.anilist_id AS anilistId,
a.media_kind AS mediaKind,
a.tmdb_id AS tmdbId,
a.tmdb_type AS tmdbType,
a.title_romaji AS titleRomaji,
a.title_english AS titleEnglish,
a.title_native AS titleNative,
@@ -331,6 +331,29 @@ export async function cleanupVocabularyStats(
};
}
/**
* Drop the cached art of every episode in a library entry. Used when a manual
* relink points at a title with no artwork, so the previous link's cover does
* not keep standing in for it.
*/
export function clearAnimeCoverArt(db: DatabaseSync, animeId: number): void {
const rows = db
.prepare(
`SELECT m.cover_blob_hash AS coverBlobHash
FROM imm_media_art m
JOIN imm_videos v ON v.video_id = m.video_id
WHERE v.anime_id = ?`,
)
.all(animeId) as Array<{ coverBlobHash: string | null }>;
if (rows.length === 0) return;
db.prepare(
'DELETE FROM imm_media_art WHERE video_id IN (SELECT video_id FROM imm_videos WHERE anime_id = ?)',
).run(animeId);
for (const hash of new Set(rows.map((row) => row.coverBlobHash))) {
cleanupUnusedCoverArtBlobHash(db, hash);
}
}
export function upsertCoverArt(
db: DatabaseSync,
videoId: number,
+44 -21
View File
@@ -1,4 +1,4 @@
import type { MediaKind } from '../../../shared/media-kind';
import { sameTitleNamespaceSql, type MediaKind } from '../../../shared/media-kind';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { parseMediaInfo } from '../../../jimaku/utils';
@@ -591,14 +591,19 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
anime_id: number;
} | null)
: null;
// Title lookups stay inside the kind's namespace: a parsed filename may land
// on a TMDB-linked live-action row, but never on a YouTube channel.
const byNormalizedTitle = db
.prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?')
.prepare(
`SELECT anime_id FROM imm_anime
WHERE normalized_title_key = ? AND ${sameTitleNamespaceSql()}`,
)
.get(normalizedTitleKey, mediaKind) as { anime_id: number } | null;
const byTitleAlias = db
.prepare(
`SELECT a.anime_id FROM imm_anime_title_aliases AS alias
JOIN imm_anime AS a ON a.anime_id = alias.anime_id
WHERE alias.normalized_title_key = ? AND a.media_kind = ?`,
WHERE alias.normalized_title_key = ? AND ${sameTitleNamespaceSql('a.media_kind')}`,
)
.get(normalizedTitleKey, mediaKind) as { anime_id: number } | null;
const existing = byAnilistId ?? byNormalizedTitle ?? byTitleAlias;
@@ -611,7 +616,11 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
UPDATE imm_anime
SET
canonical_title = COALESCE(NULLIF(?, ''), canonical_title),
anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE COALESCE(?, anilist_id) END,
anilist_id = CASE
WHEN ? = 'youtube' THEN NULL
WHEN tmdb_id IS NOT NULL THEN anilist_id
ELSE COALESCE(?, anilist_id)
END,
title_romaji = COALESCE(?, title_romaji),
title_english = COALESCE(?, title_english),
title_native = COALESCE(?, title_native),
@@ -889,13 +898,19 @@ function migrateLegacyAnimeMetadata(db: DatabaseSync): void {
}
}
// SQLite cannot drop a table-level UNIQUE constraint. Rebuild with IDs intact
// and foreign keys disabled so dependent history and manual assignments survive.
function migrateAnimeTitleUniqueness(db: DatabaseSync): void {
// SQLite cannot drop a table-level UNIQUE constraint or a column CHECK.
// Rebuild with IDs intact and foreign keys disabled so dependent history and
// manual assignments survive. Two shapes need it: the original
// `normalized_title_key UNIQUE`, and the v0.19.6 `media_kind` column whose
// CHECK only allowed 'anime' and 'youtube'.
const LEGACY_TITLE_UNIQUE_RE = /normalized_title_key TEXT NOT NULL UNIQUE/i;
const LEGACY_MEDIA_KIND_CHECK_RE = /\s*CHECK\s*\(\s*media_kind IN \('anime',\s*'youtube'\)\s*\)/i;
function migrateAnimeTableConstraints(db: DatabaseSync): void {
const schema = db.prepare("SELECT sql FROM sqlite_master WHERE name = 'imm_anime'").get() as {
sql: string;
};
if (/normalized_title_key TEXT NOT NULL UNIQUE/i.test(schema.sql)) {
if (LEGACY_TITLE_UNIQUE_RE.test(schema.sql) || LEGACY_MEDIA_KIND_CHECK_RE.test(schema.sql)) {
const foreignKeys = db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number };
const sequence = db
.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'imm_anime'")
@@ -909,10 +924,8 @@ function migrateAnimeTitleUniqueness(db: DatabaseSync): void {
/CREATE TABLE (?:IF NOT EXISTS )?["`]?imm_anime["`]?/i,
'CREATE TABLE imm_anime_new',
)
.replace(
/normalized_title_key TEXT NOT NULL UNIQUE/i,
'normalized_title_key TEXT NOT NULL',
),
.replace(LEGACY_TITLE_UNIQUE_RE, 'normalized_title_key TEXT NOT NULL')
.replace(LEGACY_MEDIA_KIND_CHECK_RE, ''),
);
db.exec(`INSERT INTO imm_anime_new SELECT * FROM imm_anime;
DROP TABLE imm_anime;
@@ -930,8 +943,11 @@ function migrateAnimeTitleUniqueness(db: DatabaseSync): void {
db.exec(`PRAGMA foreign_keys = ${foreignKeys.foreign_keys}`);
}
}
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_anime_kind_title
ON imm_anime(media_kind, normalized_title_key)`);
// v0.19.6 scoped titles per kind; anime and live-action now share one
// namespace (an entry moves between them when relinked), YouTube is separate.
db.exec(`DROP INDEX IF EXISTS idx_anime_kind_title;
CREATE UNIQUE INDEX IF NOT EXISTS idx_anime_namespace_title
ON imm_anime((media_kind = 'youtube'), normalized_title_key)`);
}
// Older builds can create channel rows with the default anime kind even after
@@ -995,17 +1011,20 @@ export function ensureSchema(db: DatabaseSync): void {
title_native TEXT,
episodes_total INTEGER,
description TEXT,
media_kind TEXT NOT NULL DEFAULT 'anime',
tmdb_id INTEGER,
tmdb_type TEXT,
metadata_json TEXT,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT
);
`);
addColumnIfMissing(
db,
'imm_anime',
'media_kind',
"TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))",
);
// Schema 26: media_kind separates anime, live-action (TMDB link) and YouTube
// channel entries. Kinds are validated in code, not by a CHECK constraint,
// so adding one later does not need a table rebuild.
addColumnIfMissing(db, 'imm_anime', 'media_kind', "TEXT NOT NULL DEFAULT 'anime'");
addColumnIfMissing(db, 'imm_anime', 'tmdb_id', 'INTEGER');
addColumnIfMissing(db, 'imm_anime', 'tmdb_type', 'TEXT');
db.exec(`
CREATE TABLE IF NOT EXISTS imm_videos(
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1549,7 +1568,7 @@ export function ensureSchema(db: DatabaseSync): void {
);
}
migrateAnimeTitleUniqueness(db);
migrateAnimeTableConstraints(db);
classifyYoutubeChannels(db);
migrateSessionEventTimestampsToText(db);
@@ -1565,6 +1584,10 @@ export function ensureSchema(db: DatabaseSync): void {
CREATE INDEX IF NOT EXISTS idx_anime_anilist_id
ON imm_anime(anilist_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_anime_tmdb_id
ON imm_anime(tmdb_id, tmdb_type)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_videos_anime_id
ON imm_videos(anime_id)
+7 -2
View File
@@ -1,6 +1,7 @@
import type { MediaKind } from '../../../shared/media-kind';
import type { MediaKind, TmdbMediaType } from '../../../shared/media-kind';
export const SCHEMA_VERSION = 25;
// 26: live-action entries (TMDB link) and YouTube channels share the media_kind column.
export const SCHEMA_VERSION = 26;
export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
@@ -524,6 +525,8 @@ export interface AnimeLibraryRow {
animeId: number;
canonicalTitle: string;
anilistId: number | null;
tmdbId: number | null;
tmdbType: TmdbMediaType | null;
totalSessions: number;
totalActiveMs: number;
totalCards: number;
@@ -538,6 +541,8 @@ export interface AnimeDetailRow {
animeId: number;
canonicalTitle: string;
anilistId: number | null;
tmdbId: number | null;
tmdbType: TmdbMediaType | null;
titleRomaji: string | null;
titleEnglish: string | null;
titleNative: string | null;
@@ -81,7 +81,7 @@ test('schema 23 channel migration preserves history and manual assignments and i
const history = getAnimeLibrary(db);
// Reproduce the previous schema, including its lack of a media kind column.
db.exec(
'DROP INDEX idx_anime_kind_title; ALTER TABLE imm_anime DROP COLUMN media_kind; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (23, 0)',
'DROP INDEX idx_anime_namespace_title; ALTER TABLE imm_anime DROP COLUMN media_kind; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (23, 0)',
);
ensureSchema(db);
ensureSchema(db);
@@ -240,6 +240,52 @@ test('title identity and aliases never cross media kinds', () => {
}
});
test('anime title lookups land on a same-named live-action entry but never on a channel', () => {
const db = new Database(':memory:');
try {
ensureSchema(db);
const dramaId = createAnime(db, 'Hanzawa Naoki');
db.prepare(
"UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 61222, tmdb_type = 'tv' WHERE anime_id = ?",
).run(dramaId);
// A later season folder parses to the same title with the default anime
// kind and must join the TMDB-linked entry rather than duplicate it.
assert.equal(
getOrCreateAnimeRecord(db, {
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
anilistId: 99,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
}),
dramaId,
);
const row = db
.prepare('SELECT media_kind, anilist_id, tmdb_id FROM imm_anime WHERE anime_id = ?')
.get(dramaId) as { media_kind: string; anilist_id: number | null; tmdb_id: number };
assert.equal(row.media_kind, 'live_action');
assert.equal(row.anilist_id, null);
assert.equal(row.tmdb_id, 61222);
assert.notEqual(
getOrCreateAnimeRecord(db, {
mediaKind: 'youtube',
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
}),
dramaId,
);
} finally {
db.close();
}
});
test('schema 24 title constraint migration preserves referenced data', () => {
const db = new Database(':memory:');
try {
@@ -273,7 +319,9 @@ test('schema 24 title constraint migration preserves referenced data', () => {
title_romaji TEXT, title_english TEXT, title_native TEXT, episodes_total INTEGER,
description TEXT, metadata_json TEXT, CREATED_DATE TEXT, LAST_UPDATE_DATE TEXT,
media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube')));
INSERT INTO imm_anime SELECT * FROM old_anime;
INSERT INTO imm_anime SELECT anime_id, normalized_title_key, canonical_title, anilist_id,
title_romaji, title_english, title_native, episodes_total, description, metadata_json,
CREATED_DATE, LAST_UPDATE_DATE, media_kind FROM old_anime;
DROP TABLE old_anime;
DELETE FROM imm_schema_version;
INSERT INTO imm_schema_version VALUES (24, 0);
@@ -281,6 +329,10 @@ test('schema 24 title constraint migration preserves referenced data', () => {
ensureSchema(db);
ensureSchema(db);
assert.deepEqual(db.prepare('PRAGMA foreign_key_check').all(), []);
// The v0.19.6 CHECK only allowed anime and youtube; live-action must fit now.
db.prepare(
"INSERT INTO imm_anime(normalized_title_key, canonical_title, media_kind, tmdb_id, tmdb_type) VALUES ('drama', 'Drama', 'live_action', 1, 'tv')",
).run();
assert.equal(
(db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number }).foreign_keys,
1,
+3 -1
View File
@@ -6,7 +6,9 @@ import { dispatchSessionAction, type SessionActionExecutorDeps } from './session
function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
const calls: string[] = [];
const deps: SessionActionExecutorDeps = {
toggleStatsOverlay: () => calls.push('stats'),
toggleStatsOverlay: () => {
calls.push('stats');
},
toggleVisibleOverlay: () => calls.push('visible'),
copyCurrentSubtitle: () => calls.push('copy'),
copySubtitleCount: (count) => calls.push(`copy:${count}`),
+2 -2
View File
@@ -3,7 +3,7 @@ import type { SessionActionId } from '../../types/session-bindings';
import type { SessionActionDispatchRequest } from '../../types/runtime';
export interface SessionActionExecutorDeps {
toggleStatsOverlay: () => void;
toggleStatsOverlay: () => Promise<void> | void;
toggleVisibleOverlay: () => void;
copyCurrentSubtitle: () => void;
copySubtitleCount: (count: number) => void;
@@ -51,7 +51,7 @@ export async function dispatchSessionAction(
): Promise<void> {
switch (request.actionId) {
case 'toggleStatsOverlay':
deps.toggleStatsOverlay();
await deps.toggleStatsOverlay();
return;
case 'toggleVisibleOverlay':
deps.toggleVisibleOverlay();
+10 -31
View File
@@ -3,37 +3,13 @@ import type { Hono } from 'hono';
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
import { statsJson, type StatsCoverImagesRequest } from '../../types/stats-http-contract.js';
import type { StatsCoverImage } from '../../types/stats-wire.js';
import { parsePositiveId, parsePositiveIdList } from './stats-server/route-support.js';
type StatsCoverImagePayload = StatsCoverImage | null;
type StatsCoverBatchBody = Partial<Record<keyof StatsCoverImagesRequest, unknown>>;
const MAX_BACKGROUND_ANIME_COVER_FETCHES = 3;
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
if (raw === undefined) return fallback;
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) {
return fallback;
}
const parsed = Math.floor(n);
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
}
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 {
@@ -129,8 +105,11 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer
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 animeIds = body?.animeIds === undefined ? [] : parsePositiveIdList(body.animeIds, 100);
const videoIds = body?.videoIds === undefined ? [] : parsePositiveIdList(body.videoIds, 100);
if (!animeIds || !videoIds) return c.body(null, 400);
animeIds.sort((a, b) => a - b);
videoIds.sort((a, b) => a - b);
const anime: Record<number, StatsCoverImagePayload> = {};
const media: Record<number, StatsCoverImagePayload> = {};
@@ -155,8 +134,8 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer
});
app.get('/api/stats/anime/:animeId/cover', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 404);
const animeId = parsePositiveId(c.req.param('animeId'));
if (animeId === null) return c.body(null, 400);
let art = await tracker.getAnimeCoverArt(animeId);
if (!art?.coverBlob) {
await tracker.ensureAnimeCoverArt(animeId);
@@ -167,8 +146,8 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer
});
app.get('/api/stats/media/:videoId/cover', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 404);
const videoId = parsePositiveId(c.req.param('videoId'));
if (videoId === null) return c.body(null, 400);
let art = await tracker.getCoverArt(videoId);
if (!art?.coverBlob) {
await tracker.ensureCoverArt(videoId);
+76 -19
View File
@@ -3,9 +3,11 @@ import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import { Readable } from 'node:stream';
import type { AnkiConnectConfig } from '../../types.js';
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
import type { TmdbClient } from './tmdb/tmdb-client.js';
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
import type { RetimedSecondarySubtitleInput } from './secondary-subtitle-sidecar.js';
import type { StatsServerMediaGenerator } from './stats-server/mining-support.js';
import { enforceStatsRequestSafety } from './stats-server/request-safety.js';
import {
registerStatsAnalyticsRoutes,
registerStatsIntegrationRoutes,
@@ -37,7 +39,10 @@ function toFetchRequest(req: IncomingMessage): Request {
method,
headers: toFetchHeaders(req.headers),
};
if (method !== 'GET' && method !== 'HEAD') {
const hasBody =
req.headers['transfer-encoding'] !== undefined ||
Number(req.headers['content-length'] ?? 0) > 0;
if (method !== 'GET' && method !== 'HEAD' && hasBody) {
init.body = Readable.toWeb(req) as BodyInit;
init.duplex = 'half';
}
@@ -50,8 +55,26 @@ async function writeFetchResponse(res: ServerResponse, response: Response): Prom
res.end(Buffer.from(await response.arrayBuffer()));
}
function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () => void } {
const server = http.createServer((req, res) => {
export interface StatsServer {
close: () => Promise<void>;
}
const SHUTDOWN_GRACE_MS = 1_000;
type BunServe = (options: {
fetch: (typeof Hono.prototype)['fetch'];
port: number;
hostname: string;
}) => {
stop: () => Promise<void> | void;
};
export function startNodeHttpServer(
app: Hono,
config: StatsServerConfig,
createServer: (listener: http.RequestListener) => http.Server = http.createServer,
): Promise<StatsServer> {
const server = createServer((req, res) => {
void (async () => {
try {
await writeFetchResponse(res, await app.fetch(toFetchRequest(req)));
@@ -61,12 +84,33 @@ function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: ()
}
})();
});
server.listen(config.port, '127.0.0.1');
return {
close: () => {
server.close();
},
return new Promise((resolve, reject) => {
const handleStartupError = (error: Error): void => {
server.removeListener('listening', handleListening);
reject(error);
};
const handleListening = (): void => {
server.removeListener('error', handleStartupError);
let closePromise: Promise<void> | null = null;
resolve({
close: () => {
closePromise ??= new Promise<void>((closeResolve, closeReject) => {
const forceClose = setTimeout(() => server.closeAllConnections(), SHUTDOWN_GRACE_MS);
server.close((error) => {
clearTimeout(forceClose);
if (error) closeReject(error);
else closeResolve();
});
});
return closePromise;
},
});
};
server.once('error', handleStartupError);
server.once('listening', handleListening);
server.listen(config.port, '127.0.0.1');
});
}
export interface StatsServerConfig {
@@ -86,6 +130,7 @@ export interface StatsServerConfig {
input: RetimedSecondarySubtitleInput,
) => Promise<string> | string;
anilistRateLimiter?: AnilistRateLimiter;
tmdbClient?: TmdbClient;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
@@ -108,6 +153,7 @@ export function createStatsApp(
input: RetimedSecondarySubtitleInput,
) => Promise<string> | string;
anilistRateLimiter?: AnilistRateLimiter;
tmdbClient?: TmdbClient;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
@@ -117,6 +163,7 @@ export function createStatsApp(
},
) {
const app = new Hono();
app.use('*', enforceStatsRequestSafety);
registerStatsAnalyticsRoutes(app, tracker, options);
registerStatsLibraryRoutes(app, tracker, options);
registerStatsIntegrationRoutes(app, tracker, options);
@@ -125,7 +172,10 @@ export function createStatsApp(
return app;
}
export function startStatsServer(config: StatsServerConfig): { close: () => void } {
export async function startStatsServerWithRuntime(
config: StatsServerConfig,
runtime: { bunServe: BunServe | null },
): Promise<StatsServer> {
const app = createStatsApp(config.tracker, {
staticDir: config.staticDir,
knownWordCachePath: config.knownWordCachePath,
@@ -139,25 +189,32 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
getStatsMiningAlassPath: config.getStatsMiningAlassPath,
resolveRetimedSecondarySubtitleText: config.resolveRetimedSecondarySubtitleText,
anilistRateLimiter: config.anilistRateLimiter,
tmdbClient: config.tmdbClient,
addYomitanNote: config.addYomitanNote,
resolveAnkiNoteId: config.resolveAnkiNoteId,
resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords,
});
const bunRuntime = globalThis as typeof globalThis & {
Bun?: {
serve?: (options: { fetch: (typeof app)['fetch']; port: number; hostname: string }) => {
stop: () => void;
};
};
};
if (bunRuntime.Bun?.serve) {
const server = bunRuntime.Bun.serve({
if (runtime.bunServe) {
const server = runtime.bunServe({
fetch: app.fetch,
port: config.port,
hostname: '127.0.0.1',
});
return { close: () => server.stop() };
let closePromise: Promise<void> | null = null;
return Promise.resolve({
close: () => {
closePromise ??= Promise.resolve().then(() => server.stop());
return closePromise;
},
});
}
return startNodeHttpServer(app, config);
}
export function startStatsServer(config: StatsServerConfig): Promise<StatsServer> {
const bunRuntime = globalThis as typeof globalThis & {
Bun?: { serve?: BunServe };
};
return startStatsServerWithRuntime(config, { bunServe: bunRuntime.Bun?.serve ?? null });
}
@@ -6,6 +6,7 @@ import {
loadKnownWordsSet,
parseEventTypesQuery,
parseIntQuery,
parsePositiveId,
parseTrendFillEmpty,
parseTrendGroupBy,
parseTrendRange,
@@ -83,8 +84,8 @@ export function registerStatsAnalyticsRoutes(
});
app.get('/api/stats/sessions/:id/timeline', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0);
if (id <= 0) return c.json(statsJson('sessionTimeline', []), 400);
const id = parsePositiveId(c.req.param('id'));
if (id === null) return c.json(statsJson('sessionTimeline', []), 400);
const rawLimit = c.req.query('limit');
const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000);
const timeline = await tracker.getSessionTimeline(id, limit);
@@ -92,8 +93,8 @@ export function registerStatsAnalyticsRoutes(
});
app.get('/api/stats/sessions/:id/events', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0);
if (id <= 0) return c.json(statsJson('sessionEvents', []), 400);
const id = parsePositiveId(c.req.param('id'));
if (id === null) return c.json(statsJson('sessionEvents', []), 400);
const limit = parseIntQuery(c.req.query('limit'), 500, 1000);
const eventTypes = parseEventTypesQuery(c.req.query('types'));
const events = await tracker.getSessionEvents(id, limit, eventTypes);
@@ -101,8 +102,8 @@ export function registerStatsAnalyticsRoutes(
});
app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0);
if (id <= 0) return c.json(statsJson('sessionKnownWordsTimeline', []), 400);
const id = parsePositiveId(c.req.param('id'));
if (id === null) return c.json(statsJson('sessionKnownWordsTimeline', []), 400);
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath) ?? new Set<string>();
@@ -1,19 +1,23 @@
import type { Hono } from 'hono';
import type { Context, Hono } from 'hono';
import type { AnkiConnectConfig } from '../../../types.js';
import {
statsJson,
type StatsAnilistSearchResult,
type StatsAnkiBrowseResponse,
} from '../../../types/stats-http-contract.js';
import { isTmdbMediaType } from '../../../shared/media-kind.js';
import type { AnilistRateLimiter } from '../anilist/rate-limiter.js';
import { TmdbApiKeyMissingError, type TmdbClient } from '../tmdb/tmdb-client.js';
import { registerStatsCoverRoutes } from '../stats-cover-routes.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import {
buildAnkiNotePreview,
countKnownWords,
enrichSessionsWithKnownWordMetrics,
isPositiveSafeInteger,
loadKnownWordsSet,
parseIntQuery,
parsePositiveId,
parsePositiveIdList,
} from './route-support.js';
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
@@ -27,6 +31,7 @@ export function registerStatsIntegrationRoutes(
ankiConnectConfig?: AnkiConnectConfig;
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
anilistRateLimiter?: AnilistRateLimiter;
tmdbClient?: TmdbClient;
resolveAnkiNoteId?: (noteId: number) => number;
},
): void {
@@ -71,6 +76,51 @@ export function registerStatsIntegrationRoutes(
}
});
const tmdbUnavailable = (c: Context, err: unknown) => {
if (err instanceof TmdbApiKeyMissingError) {
return c.json(statsJson('error', { error: err.message }), 503);
}
return c.json(statsJson('error', { error: 'TMDB request failed' }), 502);
};
app.get('/api/stats/tmdb/search', async (c) => {
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json(statsJson('tmdbSearch', []));
const tmdbClient = options?.tmdbClient;
if (!tmdbClient) return c.json(statsJson('tmdbSearch', []));
try {
return c.json(statsJson('tmdbSearch', await tmdbClient.search(query)));
} catch (err) {
return tmdbUnavailable(c, err);
}
});
app.patch('/api/stats/anime/:animeId/tmdb', async (c) => {
const animeId = parsePositiveId(c.req.param('animeId'));
if (animeId === null) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
const tmdbId = body?.tmdbId;
if (
typeof tmdbId !== 'number' ||
!Number.isInteger(tmdbId) ||
tmdbId <= 0 ||
!isTmdbMediaType(body?.tmdbType)
) {
return c.body(null, 400);
}
if (!(await tracker.hasAnime(animeId))) return c.body(null, 404);
const tmdbClient = options?.tmdbClient;
if (!tmdbClient) return c.json(statsJson('error', { error: 'TMDB is not available' }), 503);
try {
const details = await tmdbClient.getDetails(body.tmdbType, tmdbId);
if (!details) return c.body(null, 404);
await tracker.reassignAnimeTmdb(animeId, details);
return c.json(statsJson('reassignAnimeTmdb', { ok: true }));
} catch (err) {
return tmdbUnavailable(c, err);
}
});
app.get('/api/stats/known-words', (c) => {
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) return c.json(statsJson('knownWords', []));
@@ -87,8 +137,8 @@ export function registerStatsIntegrationRoutes(
});
app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) {
const animeId = parsePositiveId(c.req.param('animeId'));
if (animeId === null) {
return c.json(
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
400,
@@ -105,8 +155,8 @@ export function registerStatsIntegrationRoutes(
});
app.get('/api/stats/media/:videoId/known-words-summary', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) {
const videoId = parsePositiveId(c.req.param('videoId'));
if (videoId === null) {
return c.json(
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
400,
@@ -123,14 +173,10 @@ export function registerStatsIntegrationRoutes(
});
app.patch('/api/stats/anime/:animeId/anilist', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
const animeId = parsePositiveId(c.req.param('animeId'));
if (animeId === null) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
if (
typeof body?.anilistId !== 'number' ||
!Number.isInteger(body.anilistId) ||
body.anilistId <= 0
) {
if (!isPositiveSafeInteger(body?.anilistId)) {
return c.body(null, 400);
}
await tracker.reassignAnimeAnilist(animeId, body);
@@ -140,8 +186,8 @@ export function registerStatsIntegrationRoutes(
registerStatsCoverRoutes(app, tracker);
app.get('/api/stats/episode/:videoId/detail', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
const videoId = parsePositiveId(c.req.param('videoId'));
if (videoId === null) return c.body(null, 400);
const rawSessions = await tracker.getEpisodeSessions(videoId);
const words = await tracker.getEpisodeWords(videoId);
const cardEvents = await tracker.getEpisodeCardEvents(videoId);
@@ -154,8 +200,8 @@ export function registerStatsIntegrationRoutes(
});
app.post('/api/stats/anki/browse', async (c) => {
const noteId = parseIntQuery(c.req.query('noteId'), 0);
if (noteId <= 0) return c.body(null, 400);
const noteId = parsePositiveId(c.req.query('noteId'));
if (noteId === null) return c.body(null, 400);
const ankiConfig = getAnkiConnectConfig();
try {
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
@@ -177,19 +223,14 @@ export function registerStatsIntegrationRoutes(
app.post('/api/stats/anki/notesInfo', async (c) => {
const body = await c.req.json().catch(() => null);
const noteIds: number[] = Array.isArray(body?.noteIds)
? body.noteIds.filter(
(id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0,
)
: [];
const noteIds = parsePositiveIdList(body?.noteIds);
if (!noteIds) return c.body(null, 400);
if (noteIds.length === 0) return c.json(statsJson('ankiNotesInfo', []));
const resolvedNoteIds = Array.from(
new Set(
noteIds.map((noteId) => {
const resolvedNoteId = options?.resolveAnkiNoteId?.(noteId);
return Number.isInteger(resolvedNoteId) && (resolvedNoteId as number) > 0
? (resolvedNoteId as number)
: noteId;
return isPositiveSafeInteger(resolvedNoteId) ? resolvedNoteId : noteId;
}),
),
);
@@ -1,6 +1,7 @@
import type { Hono } from 'hono';
import { statsJson } from '../../../types/stats-http-contract.js';
import {
INCOMPATIBLE_PROVIDER_MERGE_MESSAGE,
MEDIA_KIND_MISMATCH_MESSAGE,
UNKNOWN_MOVE_TARGET_MESSAGE,
} from '../immersion-tracker/anime-merge.js';
@@ -8,12 +9,14 @@ import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import {
buildSentenceSearchOptions,
enrichSessionsWithKnownWordMetrics,
isPositiveSafeInteger,
loadKnownWordsSet,
parseBooleanQuery,
parseDuplicateLineCleanupBody,
parseExcludedWordsBody,
parseIntQuery,
parsePositiveId,
parsePositiveIdList,
loadKnownWordsSet,
} from './route-support.js';
export function registerStatsLibraryRoutes(
@@ -116,8 +119,8 @@ export function registerStatsLibraryRoutes(
});
app.get('/api/stats/vocabulary/:wordId/detail', async (c) => {
const wordId = parseIntQuery(c.req.param('wordId'), 0);
if (wordId <= 0) return c.body(null, 400);
const wordId = parsePositiveId(c.req.param('wordId'));
if (wordId === null) return c.body(null, 400);
const detail = await tracker.getWordDetail(wordId);
if (!detail) return c.body(null, 404);
const animeAppearances = await tracker.getWordAnimeAppearances(wordId);
@@ -126,8 +129,8 @@ export function registerStatsLibraryRoutes(
});
app.get('/api/stats/kanji/:kanjiId/detail', async (c) => {
const kanjiId = parseIntQuery(c.req.param('kanjiId'), 0);
if (kanjiId <= 0) return c.body(null, 400);
const kanjiId = parsePositiveId(c.req.param('kanjiId'));
if (kanjiId === null) return c.body(null, 400);
const detail = await tracker.getKanjiDetail(kanjiId);
if (!detail) return c.body(null, 404);
const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId);
@@ -141,8 +144,8 @@ export function registerStatsLibraryRoutes(
});
app.get('/api/stats/media/:videoId', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.json(statsJson('error', null), 400);
const videoId = parsePositiveId(c.req.param('videoId'));
if (videoId === null) return c.json(statsJson('error', null), 400);
const [detail, rawSessions, rollups] = await Promise.all([
tracker.getMediaDetail(videoId),
tracker.getMediaSessions(videoId, 100),
@@ -167,16 +170,16 @@ export function registerStatsLibraryRoutes(
});
app.delete('/api/stats/anime/merge-recommendations/:recommendationId', async (c) => {
const recommendationId = parseIntQuery(c.req.param('recommendationId'), 0);
if (recommendationId <= 0) return c.body(null, 400);
const recommendationId = parsePositiveId(c.req.param('recommendationId'));
if (recommendationId === null) return c.body(null, 400);
const dismissed = await tracker.dismissAnimeMergeRecommendation(recommendationId);
if (!dismissed) return c.body(null, 404);
return c.json(statsJson('dismissAnimeMergeRecommendation', { ok: true }));
});
app.get('/api/stats/anime/:animeId', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
const animeId = parsePositiveId(c.req.param('animeId'));
if (animeId === null) return c.body(null, 400);
const detail = await tracker.getAnimeDetail(animeId);
if (!detail) return c.body(null, 404);
const [episodes, anilistEntries] = await Promise.all([
@@ -187,22 +190,22 @@ export function registerStatsLibraryRoutes(
});
app.get('/api/stats/anime/:animeId/words', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
const animeId = parsePositiveId(c.req.param('animeId'));
const limit = parseIntQuery(c.req.query('limit'), 50, 200);
if (animeId <= 0) return c.body(null, 400);
if (animeId === null) return c.body(null, 400);
return c.json(statsJson('animeWords', await tracker.getAnimeWords(animeId, limit)));
});
app.get('/api/stats/anime/:animeId/rollups', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
const animeId = parsePositiveId(c.req.param('animeId'));
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
if (animeId <= 0) return c.body(null, 400);
if (animeId === null) return c.body(null, 400);
return c.json(statsJson('animeRollups', await tracker.getAnimeDailyRollups(animeId, limit)));
});
app.patch('/api/stats/media/:videoId/watched', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
const videoId = parsePositiveId(c.req.param('videoId'));
if (videoId === null) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
const watched = typeof body?.watched === 'boolean' ? body.watched : true;
await tracker.setVideoWatched(videoId, watched);
@@ -211,49 +214,53 @@ export function registerStatsLibraryRoutes(
app.delete('/api/stats/sessions', async (c) => {
const body = await c.req.json().catch(() => null);
const ids = Array.isArray(body?.sessionIds)
? body.sessionIds.filter(
(id: unknown): id is number => Number.isSafeInteger(id) && (id as number) > 0,
)
: [];
if (ids.length === 0) return c.body(null, 400);
const ids = parsePositiveIdList(body?.sessionIds);
if (!ids || ids.length === 0) return c.body(null, 400);
await tracker.deleteSessions(ids);
return c.json(statsJson('deleteSessions', { ok: true }));
});
app.delete('/api/stats/sessions/:sessionId', async (c) => {
const sessionId = parseIntQuery(c.req.param('sessionId'), 0);
if (sessionId <= 0) return c.body(null, 400);
const sessionId = parsePositiveId(c.req.param('sessionId'));
if (sessionId === null) return c.body(null, 400);
await tracker.deleteSession(sessionId);
return c.json(statsJson('deleteSession', { ok: true }));
});
app.delete('/api/stats/media/:videoId', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
const videoId = parsePositiveId(c.req.param('videoId'));
if (videoId === null) return c.body(null, 400);
await tracker.deleteVideo(videoId);
return c.json(statsJson('deleteVideo', { ok: true }));
});
app.delete('/api/stats/anime/:animeId', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
const animeId = parsePositiveId(c.req.param('animeId'));
if (animeId === null) return c.body(null, 400);
await tracker.deleteAnime(animeId);
return c.json(statsJson('deleteAnime', { ok: true }));
});
app.post('/api/stats/anime/:animeId/merge', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
const animeId = parsePositiveId(c.req.param('animeId'));
if (animeId === null) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
const sourceAnimeIds = parsePositiveIdList(body?.sourceAnimeIds).filter((id) => id !== animeId);
const parsedSourceAnimeIds = parsePositiveIdList(body?.sourceAnimeIds);
if (!parsedSourceAnimeIds) return c.body(null, 400);
const sourceAnimeIds = parsedSourceAnimeIds.filter((id) => id !== animeId);
if (sourceAnimeIds.length === 0) return c.body(null, 400);
let summary: Awaited<ReturnType<typeof tracker.mergeAnime>>;
try {
summary = await tracker.mergeAnime(animeId, sourceAnimeIds);
} catch (error) {
if (error instanceof Error && error.message === MEDIA_KIND_MISMATCH_MESSAGE) {
return c.text(MEDIA_KIND_MISMATCH_MESSAGE, 409);
// Mixing providers or kinds is a rejected request, not a server fault,
// so the dashboard can explain it instead of showing a bare 500.
if (
error instanceof Error &&
(error.message === INCOMPATIBLE_PROVIDER_MERGE_MESSAGE ||
error.message === MEDIA_KIND_MISMATCH_MESSAGE)
) {
return c.json(statsJson('error', { error: error.message }), 409);
}
throw error;
}
@@ -271,11 +278,11 @@ export function registerStatsLibraryRoutes(
});
app.patch('/api/stats/media/:videoId/anime', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
const videoId = parsePositiveId(c.req.param('videoId'));
if (videoId === null) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
const animeId = Number.isSafeInteger(body?.animeId) ? (body.animeId as number) : 0;
if (animeId <= 0) return c.body(null, 400);
const animeId = body?.animeId;
if (!isPositiveSafeInteger(animeId)) return c.body(null, 400);
try {
const summary = await tracker.moveVideoToAnime(videoId, animeId);
return c.json(
@@ -4,6 +4,7 @@ import { basename } from 'node:path';
import { AnkiConnectClient } from '../../../anki-connect.js';
import { getConfiguredWordFieldName } from '../../../anki-field-config.js';
import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/animated-image-sync.js';
import { clampMediaEndTime } from '../../../anki-integration/media-duration.js';
import { MediaGenerator } from '../../../media-generator.js';
import { statsJson } from '../../../types/stats-http-contract.js';
import {
@@ -113,8 +114,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
const startSec = startMs / 1000;
const endSec = endMs / 1000;
const rawDuration = endSec - startSec;
const clampedEndSec = rawDuration > maxMediaDuration ? startSec + maxMediaDuration : endSec;
const clampedEndSec = clampMediaEndTime(startSec, endSec, maxMediaDuration);
const highlightedSentence = word
? sentence.replace(
@@ -0,0 +1,42 @@
import type { MiddlewareHandler } from 'hono';
function isLoopbackUrl(url: URL): boolean {
return (
url.protocol === 'http:' &&
!url.username &&
!url.password &&
['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
);
}
/** Protect the local API even when a browser can reach the loopback listener. */
export const enforceStatsRequestSafety: MiddlewareHandler = async (c, next) => {
const url = new URL(c.req.url);
if (!isLoopbackUrl(url)) return c.body(null, 403);
const host = c.req.header('host');
if (host !== undefined) {
if (!/^(localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(host)) {
return c.body(null, 403);
}
// Node derives the request URL from Host; Bun provides them independently.
try {
if (new URL(`http://${host}`).origin !== url.origin) return c.body(null, 403);
} catch {
return c.body(null, 403);
}
}
// Compare the serialized origin exactly. Opaque origins and malformed values
// containing credentials, paths, or multiple origins must not gain trust.
const origin = c.req.header('origin');
if (origin !== undefined && origin !== url.origin) return c.body(null, 403);
const site = c.req.header('sec-fetch-site');
if (site === 'cross-site' || site === 'same-site') return c.body(null, 403);
if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method) && c.req.raw.body !== null) {
const contentType = c.req.header('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
if (contentType !== 'application/json') return c.body(null, 415);
}
await next();
};
@@ -48,6 +48,16 @@ export function parseIntQuery(
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
}
export function isPositiveSafeInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
}
export function parsePositiveId(raw: string | undefined): number | null {
if (raw === undefined) return null;
const value = Number(raw);
return isPositiveSafeInteger(value) && String(value) === raw ? value : null;
}
export function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' {
return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all'
? raw
@@ -199,16 +209,16 @@ export async function enrichSessionsWithKnownWordMetrics<
);
}
/** Deduplicated positive integer ids from an untrusted JSON body field. */
export function parsePositiveIdList(raw: unknown): number[] {
if (!Array.isArray(raw)) return [];
/** Deduplicated positive safe integer ids from an untrusted JSON body field. */
export function parsePositiveIdList(raw: unknown, maxItems?: number): number[] | null {
if (!Array.isArray(raw)) return null;
const ids = new Set<number>();
for (const value of raw) {
if (Number.isSafeInteger(value) && (value as number) > 0) {
ids.add(value as number);
if (!isPositiveSafeInteger(value)) return null;
ids.add(value);
}
}
return [...ids];
const parsed = [...ids];
return maxItems === undefined ? parsed : parsed.slice(0, maxItems);
}
export function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean {
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Database, type DatabaseSync } from '../immersion-tracker/sqlite';
import { ensureSchema } from '../immersion-tracker/storage';
import { mergeAnime } from './merge-catalog';
import { createEmptyMergeSummary } from './shared';
const identities = {
unlinked: [null, null, null],
anilist: [42, null, null],
tmdb: [null, 12, 'tv'],
otherTmdb: [null, 13, 'tv'],
} as const;
for (const [localKind, remoteKind, sameEntry] of [
['anilist', 'tmdb', false],
['tmdb', 'anilist', false],
['tmdb', 'otherTmdb', false],
['unlinked', 'tmdb', true],
['unlinked', 'anilist', true],
['tmdb', 'unlinked', true],
['tmdb', 'tmdb', true],
['anilist', 'anilist', true],
] as const) {
test(`catalog title match: ${localKind} with ${remoteKind}`, () => {
const local = new Database(':memory:');
const remote = new Database(':memory:');
const adapt = (db: DatabaseSync) => ({
query: (sql: string) => db.prepare(sql),
exec: (sql: string) => {
db.exec(sql);
},
close: () => {
db.close();
},
});
try {
for (const [db, kind] of [
[local, localKind],
[remote, remoteKind],
] as const) {
ensureSchema(db);
db.prepare(
`INSERT INTO imm_anime(normalized_title_key, canonical_title, anilist_id, tmdb_id, tmdb_type, CREATED_DATE, LAST_UPDATE_DATE)
VALUES ('same title', 'Same title', ?, ?, ?, 1000, 1000)`,
).run(...identities[kind]);
}
const summary = createEmptyMergeSummary();
const map = mergeAnime(adapt(local), adapt(remote), summary);
assert.equal(map.get(1) === 1, sameEntry);
assert.equal(summary.animeAdded, sameEntry ? 0 : 1);
const again = createEmptyMergeSummary();
assert.equal(mergeAnime(adapt(local), adapt(remote), again).get(1), map.get(1));
assert.equal(again.animeAdded, 0);
} finally {
local.close();
remote.close();
}
});
}
+54 -9
View File
@@ -1,5 +1,6 @@
import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver';
import { insertRow, tableExists, type SyncMergeSummary } from './shared';
import { sameTitleNamespaceSql } from '../../../shared/media-kind';
const ANIME_COPY_COLUMNS = [
'media_kind',
@@ -11,6 +12,8 @@ const ANIME_COPY_COLUMNS = [
'title_native',
'episodes_total',
'description',
'tmdb_id',
'tmdb_type',
'metadata_json',
'CREATED_DATE',
'LAST_UPDATE_DATE',
@@ -102,8 +105,14 @@ export function mergeAnime(
const byAnilist = local.query(
"SELECT anime_id FROM imm_anime WHERE anilist_id = ? AND media_kind = 'anime'",
);
const byTmdb = local.query(
'SELECT anime_id FROM imm_anime WHERE tmdb_id = ? AND tmdb_type = ? ORDER BY anime_id LIMIT 1',
);
// Anime and live-action rows share a title namespace; YouTube channels are
// looked up on their own, so a same-named anime and channel stay separate.
const byTitleKey = local.query(
'SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?',
`SELECT anime_id, anilist_id, tmdb_id, tmdb_type FROM imm_anime
WHERE normalized_title_key = ? AND ${sameTitleNamespaceSql()}`,
);
// A pre-classification channel can be repaired, but a genuine anime sharing
// its title must remain a separate entry.
@@ -116,16 +125,25 @@ export function mergeAnime(
const releaseChannelAnilistId = local.query(
"UPDATE imm_anime SET anilist_id = NULL WHERE media_kind = 'youtube' AND anilist_id = ?",
);
// A TMDB link only fills in when the local row is unlinked: a row already
// pinned to AniList stays anime, and vice versa, so the two link kinds never
// coexist on one entry. A channel match always becomes a channel.
const fillMissing = local.query(
`UPDATE imm_anime
SET
media_kind = ?,
anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE anilist_id END,
title_romaji = COALESCE(title_romaji, ?),
title_english = COALESCE(title_english, ?),
title_native = COALESCE(title_native, ?),
episodes_total = COALESCE(episodes_total, ?),
description = COALESCE(description, ?)
description = COALESCE(description, ?),
tmdb_id = CASE WHEN anilist_id IS NULL THEN COALESCE(tmdb_id, ?) ELSE tmdb_id END,
tmdb_type = CASE WHEN anilist_id IS NULL AND tmdb_id IS NULL THEN ? ELSE tmdb_type END,
media_kind = CASE
WHEN ? = 'youtube' THEN 'youtube'
WHEN anilist_id IS NULL AND tmdb_id IS NULL THEN ?
ELSE media_kind
END
WHERE anime_id = ?`,
);
@@ -139,10 +157,25 @@ export function mergeAnime(
// incorrectly attached one to a channel.
releaseChannelAnilistId.run(row.anilist_id);
}
const titleMatch = byTitleKey.get(row.normalized_title_key, row.media_kind) as
| SqlRow
| undefined;
const compatibleTitleMatch =
titleMatch &&
((titleMatch.anilist_id === null && titleMatch.tmdb_id === null) ||
(row.anilist_id === null && row.tmdb_id === null) ||
(titleMatch.tmdb_id === null &&
row.tmdb_id === null &&
titleMatch.anilist_id === row.anilist_id) ||
(titleMatch.anilist_id === null &&
row.anilist_id === null &&
titleMatch.tmdb_id === row.tmdb_id &&
titleMatch.tmdb_type === row.tmdb_type));
const existing = ((row.media_kind === 'anime' && row.anilist_id !== null
? byAnilist.get(row.anilist_id)
: undefined) ??
byTitleKey.get(row.normalized_title_key, row.media_kind) ??
(row.tmdb_id !== null ? byTmdb.get(row.tmdb_id, row.tmdb_type) : undefined) ??
(compatibleTitleMatch ? titleMatch : undefined) ??
(row.media_kind === 'youtube' ? legacyChannel.get(row.normalized_title_key) : undefined)) as
| SqlRow
| undefined;
@@ -150,22 +183,34 @@ export function mergeAnime(
const localId = Number(existing.anime_id);
map.set(remoteId, localId);
fillMissing.run(
row.media_kind,
row.media_kind,
row.title_romaji,
row.title_english,
row.title_native,
row.episodes_total,
row.description,
row.tmdb_id,
row.tmdb_type,
row.media_kind,
row.media_kind,
localId,
);
continue;
}
// Conflicting providers can share a title, but the stored title key is
// unique within its namespace.
let titleKey = row.normalized_title_key;
for (let suffix = 1; byTitleKey.get(titleKey, row.media_kind); suffix += 1) {
titleKey = `${row.normalized_title_key}:sync:${suffix}`;
}
const values = ANIME_COPY_COLUMNS.map((column) => {
if (column === 'normalized_title_key') return titleKey;
// No local row matched by anilist_id (checked first in `existing` above)
// or title key, so the remote anilist_id — if any — is free to insert as-is.
const values = ANIME_COPY_COLUMNS.map((column) =>
column === 'anilist_id' && row.media_kind !== 'anime' ? null : row[column],
);
// or title key, so the remote anilist_id is free to insert as-is, except
// that channels never carry one.
if (column === 'anilist_id' && row.media_kind !== 'anime') return null;
return row[column];
});
map.set(remoteId, insertRow(local, 'imm_anime', ANIME_COPY_COLUMNS, values));
summary.animeAdded += 1;
}
+4 -9
View File
@@ -223,13 +223,8 @@ export function scheduleStatsWindowPostShowReconciles(
}
}
export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
query: Record<string, string>;
} {
return {
query: {
overlay: '1',
...(apiBaseUrl ? { apiBase: apiBaseUrl } : {}),
},
};
export function buildStatsWindowUrl(apiBaseUrl: string): string {
const url = new URL('/', apiBaseUrl);
url.searchParams.set('overlay', '1');
return url.toString();
}
+5 -14
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildStatsWindowLoadFileOptions,
buildStatsWindowUrl,
buildStatsWindowOptions,
buildStatsNativeConfirmDialogOptions,
demoteVisibleStatsWindowBelowDialogs,
@@ -168,21 +168,12 @@ test('shouldHideStatsWindowForInput matches Escape and configured bare toggle ke
);
});
test('buildStatsWindowLoadFileOptions enables overlay rendering mode', () => {
assert.deepEqual(buildStatsWindowLoadFileOptions(), {
query: {
overlay: '1',
},
});
test('buildStatsWindowUrl enables overlay rendering on the local HTTP origin', () => {
assert.equal(buildStatsWindowUrl('http://127.0.0.1:6969'), 'http://127.0.0.1:6969/?overlay=1');
});
test('buildStatsWindowLoadFileOptions includes provided stats API base URL', () => {
assert.deepEqual(buildStatsWindowLoadFileOptions('http://127.0.0.1:6123'), {
query: {
overlay: '1',
apiBase: 'http://127.0.0.1:6123',
},
});
test('buildStatsWindowUrl uses the active server port as the document origin', () => {
assert.equal(buildStatsWindowUrl('http://127.0.0.1:6123'), 'http://127.0.0.1:6123/?overlay=1');
});
test('resolveStatsWindowOuterBoundsForContent compensates for Wayland content insets', () => {
+21 -9
View File
@@ -1,9 +1,9 @@
import { BrowserWindow, dialog, ipcMain } from 'electron';
import * as path from 'path';
import { createLogger } from '../../logger.js';
import type { WindowGeometry } from '../../types.js';
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
import {
buildStatsWindowLoadFileOptions,
buildStatsWindowUrl,
buildStatsWindowOptions,
demoteVisibleStatsWindowBelowDialogs,
presentStatsWindow,
@@ -26,17 +26,19 @@ import {
} from './stats-window-layer.js';
let statsWindow: BrowserWindow | null = null;
let statsWindowGeneration = 0;
let toggleRegistered = false;
let nativeDialogLayerRegistered = false;
const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState();
const logger = createLogger('main:stats-window');
export interface StatsWindowOptions {
/** Absolute path to stats/dist/ directory */
staticDir: string;
/** Absolute path to the compiled preload-stats.js */
preloadPath: string;
/** Resolve the active stats API base URL */
getApiBaseUrl?: () => string;
getApiBaseUrl: () => Promise<string> | string;
/** Report server startup failure through the configured notification surface. */
onStartupError?: (error: unknown) => void;
/** Resolve the active stats toggle key from config */
getToggleKey: () => string;
/** Resolve the tracked overlay/mpv bounds */
@@ -179,8 +181,16 @@ function registerStatsNativeDialogLayerHandlers(): void {
* Toggle the stats overlay window: create on first call, then show/hide.
* The React app stays mounted across toggles state is preserved.
*/
export function toggleStatsOverlay(options: StatsWindowOptions): void {
export async function toggleStatsOverlay(options: StatsWindowOptions): Promise<void> {
if (!statsWindow) {
const generation = statsWindowGeneration;
const apiBaseUrl = await Promise.resolve()
.then(() => options.getApiBaseUrl())
.catch((error: unknown) => {
options.onStartupError?.(error);
throw error;
});
if (generation !== statsWindowGeneration || statsWindow) return;
statsWindow = new BrowserWindow(
buildStatsWindowOptions({
preloadPath: options.preloadPath,
@@ -194,8 +204,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
statsWindow?.setTitle(STATS_WINDOW_TITLE);
});
const indexPath = path.join(options.staticDir, 'index.html');
statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(options.getApiBaseUrl?.()));
statsWindow.loadURL(buildStatsWindowUrl(apiBaseUrl));
statsWindow.on('closed', () => {
options.onVisibilityChanged?.(false);
@@ -243,7 +252,9 @@ export function registerStatsOverlayToggle(options: StatsWindowOptions): void {
if (toggleRegistered) return;
toggleRegistered = true;
ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => {
toggleStatsOverlay(options);
void toggleStatsOverlay(options).catch((error: unknown) => {
logger.error('Failed to open stats overlay:', error);
});
});
}
@@ -252,6 +263,7 @@ export function registerStatsOverlayToggle(options: StatsWindowOptions): void {
* Call during app quit.
*/
export function destroyStatsWindow(): void {
statsWindowGeneration += 1;
if (statsWindow && !statsWindow.isDestroyed()) {
statsWindow.destroy();
statsWindow = null;
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { BUNDLED_INTEGRATION_KEYS_FILENAME, readBundledTmdbApiKey } from './bundled-api-key.js';
test('readBundledTmdbApiKey reads the staged key and tolerates a missing or malformed file', () => {
const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-bundled-key-'));
const filePath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
try {
assert.equal(readBundledTmdbApiKey(distDir), null);
fs.writeFileSync(filePath, '{"tmdbApiKey":" abc "}');
assert.equal(readBundledTmdbApiKey(distDir), 'abc');
fs.writeFileSync(filePath, '{"tmdbApiKey":""}');
assert.equal(readBundledTmdbApiKey(distDir), null);
fs.writeFileSync(filePath, 'not json');
assert.equal(readBundledTmdbApiKey(distDir), null);
} finally {
fs.rmSync(distDir, { recursive: true, force: true });
}
});
+20
View File
@@ -0,0 +1,20 @@
import fs from 'node:fs';
import path from 'node:path';
/**
* Release builds stage a project-owned TMDB key into dist/ (see
* scripts/bundled-integration-keys.mjs). Source checkouts and CI builds have no
* such file, and TMDB lookups then depend on the user's own `tmdb.apiKey`.
*/
export const BUNDLED_INTEGRATION_KEYS_FILENAME = 'bundled-integration-keys.json';
export function readBundledTmdbApiKey(distDir: string): string | null {
try {
const raw = fs.readFileSync(path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME), 'utf8');
const parsed = JSON.parse(raw) as { tmdbApiKey?: unknown };
const key = typeof parsed.tmdbApiKey === 'string' ? parsed.tmdbApiKey.trim() : '';
return key.length > 0 ? key : null;
} catch {
return null;
}
}
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createLiveActionMetadataResolver, titlesMatch } from './live-action-resolver.js';
import {
TmdbApiKeyMissingError,
type TmdbClient,
type TmdbSearchResult,
type TmdbTitleDetails,
} from './tmdb-client.js';
const silentLogger = { info: () => {}, warn: () => {} };
function searchResult(over: Partial<TmdbSearchResult> & { tmdbId: number }): TmdbSearchResult {
return {
tmdbType: 'tv',
title: 'Title',
originalTitle: 'Title',
originalLanguage: 'ja',
overview: null,
posterUrl: null,
year: null,
isAnimation: false,
...over,
};
}
function details(over: Partial<TmdbTitleDetails> & { tmdbId: number }): TmdbTitleDetails {
return {
tmdbType: 'tv',
titleEnglish: null,
titleNative: null,
description: null,
posterUrl: null,
episodesTotal: null,
year: null,
originalLanguage: 'ja',
isAnimation: false,
allTitles: [],
...over,
};
}
test('titlesMatch ignores case, width, and punctuation but not extra words', () => {
assert.equal(titlesMatch('Hanzawa Naoki', ['HANZAWA NAOKI!']), true);
assert.equal(titlesMatch('半沢直樹', ['半沢直樹']), true);
assert.equal(titlesMatch('Hanzawa Naoki', ['Hanzawa Naoki Season 2']), false);
assert.equal(titlesMatch('', ['']), false);
});
test('resolveByTitle only accepts a Japanese non-animated result whose known titles match exactly', async () => {
const detailCalls: number[] = [];
const client: TmdbClient = {
async search() {
return [
searchResult({ tmdbId: 1, title: 'Hanzawa Naoki', originalLanguage: 'ko' }),
searchResult({ tmdbId: 2, title: 'Hanzawa Naoki', isAnimation: true }),
searchResult({ tmdbId: 3, title: 'Hanzawa Naoki: The Movie' }),
searchResult({ tmdbId: 4, title: 'Hanzawa Naoki' }),
];
},
async getDetails(_type, tmdbId) {
detailCalls.push(tmdbId);
if (tmdbId === 3) return details({ tmdbId: 3, allTitles: ['Hanzawa Naoki: The Movie'] });
if (tmdbId === 4) return details({ tmdbId: 4, allTitles: ['Hanzawa Naoki', '半沢直樹'] });
return null;
},
};
const resolver = createLiveActionMetadataResolver(client, silentLogger);
const resolved = await resolver.resolveByTitle('hanzawa naoki');
assert.equal(resolved?.tmdbId, 4);
assert.deepEqual(detailCalls, [3, 4]);
});
test('resolveByTitle returns null when nothing matches or the key is missing', async () => {
const noMatch: TmdbClient = {
async search() {
return [searchResult({ tmdbId: 1, title: 'Something Else' })];
},
async getDetails() {
return details({ tmdbId: 1, allTitles: ['Something Else'] });
},
};
assert.equal(
await createLiveActionMetadataResolver(noMatch, silentLogger).resolveByTitle('Hanzawa Naoki'),
null,
);
let infoCount = 0;
const noKey: TmdbClient = {
async search() {
throw new TmdbApiKeyMissingError();
},
async getDetails() {
throw new TmdbApiKeyMissingError();
},
};
const resolver = createLiveActionMetadataResolver(noKey, {
info: () => {
infoCount += 1;
},
warn: () => {},
});
assert.equal(await resolver.resolveByTitle('Hanzawa Naoki'), null);
assert.equal(await resolver.resolveById('tv', 1), null);
assert.equal(infoCount, 1);
});
@@ -0,0 +1,75 @@
import { normalizeTitleIdentity } from '../../utils/title-normalization';
import type { TmdbMediaType } from '../../../shared/media-kind';
import { TmdbApiKeyMissingError, type TmdbClient, type TmdbTitleDetails } from './tmdb-client';
const MAX_DETAIL_LOOKUPS = 3;
/**
* Resolves live-action titles for the automatic cover-art path. Anime is
* AniList's job, so only non-animated Japanese-language results qualify, and
* a candidate must match the parsed title exactly under one of the names TMDB
* knows for it. Fuzzy search hits are never trusted on their own: a stray
* filename would otherwise pin the wrong show to a library entry.
*/
export interface LiveActionMetadataResolver {
resolveByTitle(title: string): Promise<TmdbTitleDetails | null>;
resolveById(tmdbType: TmdbMediaType, tmdbId: number): Promise<TmdbTitleDetails | null>;
}
interface Logger {
info(msg: string, ...args: unknown[]): void;
warn(msg: string, ...args: unknown[]): void;
}
export function titlesMatch(candidate: string, knownTitles: Iterable<string>): boolean {
const key = normalizeTitleIdentity(candidate);
if (!key) return false;
for (const known of knownTitles) {
if (normalizeTitleIdentity(known) === key) return true;
}
return false;
}
export function createLiveActionMetadataResolver(
client: TmdbClient,
logger: Logger,
): LiveActionMetadataResolver {
let warnedMissingKey = false;
const guard = async <T>(work: () => Promise<T>): Promise<T | null> => {
try {
return await work();
} catch (err) {
if (err instanceof TmdbApiKeyMissingError) {
if (!warnedMissingKey) {
warnedMissingKey = true;
logger.info('tmdb: no API key configured, skipping live-action metadata lookups');
}
return null;
}
logger.warn('tmdb: lookup failed: %s', err instanceof Error ? err.message : String(err));
return null;
}
};
return {
resolveByTitle(title) {
return guard(async () => {
const results = await client.search(title);
const candidates = results
.filter((result) => result.originalLanguage === 'ja' && !result.isAnimation)
.slice(0, MAX_DETAIL_LOOKUPS);
for (const candidate of candidates) {
const details = await client.getDetails(candidate.tmdbType, candidate.tmdbId);
if (details && !details.isAnimation && titlesMatch(title, details.allTitles)) {
return details;
}
}
return null;
});
},
resolveById(tmdbType, tmdbId) {
return guard(() => client.getDetails(tmdbType, tmdbId));
},
};
}
+335
View File
@@ -0,0 +1,335 @@
import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { TmdbConfig } from '../../../types/integrations';
import {
TmdbApiKeyMissingError,
createTmdbClient,
createTmdbApiKeyResolver,
resolveTmdbApiKey,
} from './tmdb-client.js';
function commandFixture(t: TestContext) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer tmdb command-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
let nextId = 0;
const quotePath = (value: string) =>
`"${process.platform === 'win32' ? value : value.replace(/["\\$`]/g, '\\$&')}"`;
return {
dir,
command(source: string): string {
const script = path.join(dir, `credential-${nextId++}.cjs`);
fs.writeFileSync(script, source);
return `${quotePath(process.execPath)} ${quotePath(script)}`;
},
};
}
function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { 'content-type': 'application/json' },
});
}
function captureFetch(handler: (url: URL, init?: RequestInit) => Response) {
const calls: Array<{ url: URL; init?: RequestInit }> = [];
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(String(input));
calls.push({ url, init });
return handler(url, init);
}) as typeof fetch;
return { calls, fetchImpl };
}
test('resolveTmdbApiKey prefers the literal key and trims it', async (t) => {
const { command } = commandFixture(t);
assert.equal(
await resolveTmdbApiKey({ apiKey: ' abc ', apiKeyCommand: command('process.exit(3)') }),
'abc',
);
assert.equal(await resolveTmdbApiKey({ apiKey: '', apiKeyCommand: '' }), null);
assert.equal(await resolveTmdbApiKey(undefined), null);
});
test('resolveTmdbApiKey runs apiKeyCommand when no literal key is set', async (t) => {
const { command } = commandFixture(t);
assert.equal(
await resolveTmdbApiKey({ apiKeyCommand: command('process.stdout.write(" from-cmd ")') }),
'from-cmd',
);
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: command('process.exit(3)') }), null);
});
test('resolveTmdbApiKey falls back to the bundled key only when the user set nothing usable', async (t) => {
const { command } = commandFixture(t);
assert.equal(await resolveTmdbApiKey({}, 'bundled'), 'bundled');
assert.equal(await resolveTmdbApiKey({ apiKey: 'mine' }, 'bundled'), 'mine');
assert.equal(
await resolveTmdbApiKey({ apiKeyCommand: command('process.stdout.write("mine")') }, 'bundled'),
'mine',
);
assert.equal(
await resolveTmdbApiKey({ apiKeyCommand: command('process.exit(3)') }, 'bundled'),
'bundled',
);
});
test('search rejects without a key and never touches the network', async () => {
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
const client = createTmdbClient({ resolveApiKey: async () => null, fetch: fetchImpl });
await assert.rejects(client.search('半沢直樹'), TmdbApiKeyMissingError);
assert.equal(calls.length, 0);
});
test('search sends a v3 key as a query parameter and drops people from multi results', async () => {
const { calls, fetchImpl } = captureFetch(() =>
jsonResponse({
results: [
{ media_type: 'person', id: 1, name: 'Sakai Masato' },
{
media_type: 'tv',
id: 61222,
name: 'Hanzawa Naoki',
original_name: '半沢直樹',
original_language: 'ja',
overview: 'A banker fights back.',
poster_path: '/hanzawa.jpg',
first_air_date: '2013-07-07',
genre_ids: [18],
},
{
media_type: 'movie',
id: 9,
title: 'Anime Film',
original_title: 'アニメ映画',
original_language: 'ja',
release_date: '2020-01-01',
genre_ids: [16],
},
],
}),
);
const client = createTmdbClient({ resolveApiKey: async () => 'v3key', fetch: fetchImpl });
const results = await client.search(' 半沢直樹 ');
assert.equal(calls.length, 1);
const url = calls[0]!.url;
assert.equal(url.pathname, '/3/search/multi');
assert.equal(url.searchParams.get('query'), '半沢直樹');
assert.equal(url.searchParams.get('api_key'), 'v3key');
assert.equal((calls[0]!.init?.headers as Record<string, string>).Authorization, undefined);
assert.deepEqual(results, [
{
tmdbId: 61222,
tmdbType: 'tv',
title: 'Hanzawa Naoki',
originalTitle: '半沢直樹',
originalLanguage: 'ja',
overview: 'A banker fights back.',
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
year: 2013,
isAnimation: false,
},
{
tmdbId: 9,
tmdbType: 'movie',
title: 'Anime Film',
originalTitle: 'アニメ映画',
originalLanguage: 'ja',
overview: null,
posterUrl: null,
year: 2020,
isAnimation: true,
},
]);
});
test('a v4 read token travels as a bearer header instead of api_key', async () => {
const v4Token = ['eyJ', 'test-header', '.payload', '.sig'].join('');
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
const client = createTmdbClient({
resolveApiKey: async () => v4Token,
fetch: fetchImpl,
});
await client.search('x');
assert.equal(calls[0]!.url.searchParams.has('api_key'), false);
assert.equal(
(calls[0]!.init?.headers as Record<string, string>).Authorization,
`Bearer ${v4Token}`,
);
});
test('getDetails folds translations and alternative titles into the normalized shape', async () => {
const { calls, fetchImpl } = captureFetch(() =>
jsonResponse({
id: 61222,
name: 'Hanzawa Naoki',
original_name: '半沢直樹',
original_language: 'ja',
overview: 'A banker fights back.',
poster_path: '/hanzawa.jpg',
first_air_date: '2013-07-07',
number_of_episodes: 10,
genres: [{ id: 18, name: 'Drama' }],
alternative_titles: { results: [{ iso_3166_1: 'JP', title: 'Hanzawa Naoki Season 1' }] },
translations: {
translations: [
{ iso_639_1: 'en', data: { name: 'Hanzawa Naoki', overview: 'A banker fights back.' } },
{ iso_639_1: 'ja', data: { name: '半沢直樹', overview: '銀行員の物語' } },
],
},
}),
);
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
const details = await client.getDetails('tv', 61222);
assert.equal(calls[0]!.url.pathname, '/3/tv/61222');
assert.equal(
calls[0]!.url.searchParams.get('append_to_response'),
'alternative_titles,translations',
);
assert.deepEqual(details, {
tmdbId: 61222,
tmdbType: 'tv',
titleEnglish: 'Hanzawa Naoki',
titleNative: '半沢直樹',
description: 'A banker fights back.',
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
episodesTotal: 10,
year: 2013,
originalLanguage: 'ja',
isAnimation: false,
allTitles: ['Hanzawa Naoki', '半沢直樹', 'Hanzawa Naoki Season 1'],
});
});
test('getDetails falls back to the Japanese overview and counts a movie as one episode', async () => {
const { fetchImpl } = captureFetch(() =>
jsonResponse({
id: 5,
title: '半沢直樹',
original_title: '半沢直樹',
original_language: 'ja',
overview: '',
release_date: '2019-03-01',
translations: {
translations: [{ iso_639_1: 'ja', data: { title: '半沢直樹', overview: 'あらすじ' } }],
},
}),
);
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
const details = await client.getDetails('movie', 5);
assert.equal(details?.titleEnglish, null);
assert.equal(details?.titleNative, '半沢直樹');
assert.equal(details?.description, 'あらすじ');
assert.equal(details?.episodesTotal, 1);
});
test('getDetails returns null for an unknown id', async () => {
const { fetchImpl } = captureFetch(() => jsonResponse({ status_message: 'nope' }, 404));
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
assert.equal(await client.getDetails('tv', 1), null);
});
test('client reuses command output across requests and invalidates it when either setting changes', async (t) => {
const fixture = commandFixture(t);
const counter = path.join(fixture.dir, 'calls');
const createCommand = (key: string) =>
fixture.command(`
const fs = require('node:fs');
const path = require('node:path');
fs.appendFileSync(path.join(__dirname, 'calls'), 'x');
process.stdout.write(${JSON.stringify(key)});
`);
let config: TmdbConfig = { apiKeyCommand: createCommand('command-key') };
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
const client = createTmdbClient({
resolveApiKey: createTmdbApiKeyResolver(
() => config,
() => 'bundled',
),
fetch: fetchImpl,
});
await Promise.all([client.search('a'), client.search('b')]);
await client.getDetails('tv', 1);
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
assert.ok(calls.every(({ url }) => url.searchParams.get('api_key') === 'command-key'));
config = { ...config, apiKey: 'literal' };
await client.search('c');
assert.equal(calls.at(-1)?.url.searchParams.get('api_key'), 'literal');
config = { ...config, apiKey: '' };
await client.search('d');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
config = { apiKeyCommand: createCommand('new-key') };
await client.search('e');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xxx');
assert.equal(calls.at(-1)?.url.searchParams.get('api_key'), 'new-key');
});
for (const failure of ['error', 'empty'] as const) {
test(`${failure} command output uses a bounded cooldown before retrying`, async (t) => {
const fixture = commandFixture(t);
const counter = path.join(fixture.dir, 'calls');
const command = fixture.command(`
const fs = require('node:fs');
const path = require('node:path');
const counter = path.join(__dirname, 'calls');
fs.appendFileSync(counter, 'x');
if (fs.readFileSync(counter, 'utf8').length === 1) process.exit(${failure === 'error' ? 1 : 0});
process.stdout.write('recovered');
`);
let now = 1000;
const originalNow = Date.now;
Date.now = () => now;
t.after(() => {
Date.now = originalNow;
});
let bundledKey: string | null = 'bundled';
const resolve = createTmdbApiKeyResolver(
() => ({ apiKeyCommand: command }),
() => bundledKey,
);
assert.deepEqual(await Promise.all([resolve(), resolve()]), ['bundled', 'bundled']);
now += 29_999;
assert.equal(await resolve(), 'bundled');
bundledKey = null;
assert.equal(await resolve(), null);
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
now += 1;
assert.equal(await resolve(), 'recovered');
assert.equal(await resolve(), 'recovered');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
});
}
for (const setting of ['apiKey', 'apiKeyCommand'] as const) {
test(`changing ${setting} clears a failed command cooldown`, async (t) => {
const fixture = commandFixture(t);
const counter = path.join(fixture.dir, 'calls');
const source = `
const fs = require('node:fs');
const path = require('node:path');
fs.appendFileSync(path.join(__dirname, 'calls'), 'x');
process.exit(1);
`;
let config: TmdbConfig = { apiKeyCommand: fixture.command(source) };
const resolve = createTmdbApiKeyResolver(
() => config,
() => 'bundled',
);
assert.equal(await resolve(), 'bundled');
assert.equal(await resolve(), 'bundled');
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
config =
setting === 'apiKey'
? { ...config, apiKey: ' ' }
: { apiKeyCommand: fixture.command(source) };
assert.equal(await resolve(), 'bundled');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
});
}
+311
View File
@@ -0,0 +1,311 @@
import * as childProcess from 'node:child_process';
import type { TmdbMediaType } from '../../../shared/media-kind';
import type { TmdbConfig } from '../../../types/integrations';
import type { StatsTmdbSearchResult } from '../../../types/stats-http-contract';
export const TMDB_API_BASE_URL = 'https://api.themoviedb.org/3';
const TMDB_POSTER_BASE_URL = 'https://image.tmdb.org/t/p/w500';
const REQUEST_TIMEOUT_MS = 8_000;
const API_KEY_COMMAND_RETRY_MS = 30_000;
const ANIMATION_GENRE_ID = 16;
export type TmdbSearchResult = StatsTmdbSearchResult;
export interface TmdbTitleDetails {
tmdbId: number;
tmdbType: TmdbMediaType;
titleEnglish: string | null;
titleNative: string | null;
/** English synopsis, falling back to the Japanese one. */
description: string | null;
posterUrl: string | null;
episodesTotal: number | null;
year: number | null;
originalLanguage: string;
isAnimation: boolean;
/** Every name TMDB knows for the title, used for exact-title matching. */
allTitles: string[];
}
export interface TmdbClient {
search(query: string): Promise<TmdbSearchResult[]>;
getDetails(tmdbType: TmdbMediaType, tmdbId: number): Promise<TmdbTitleDetails | null>;
}
export class TmdbApiKeyMissingError extends Error {
constructor() {
super('TMDB API key not configured. Set tmdb.apiKey or tmdb.apiKeyCommand.');
this.name = 'TmdbApiKeyMissingError';
}
}
export class TmdbRequestError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = 'TmdbRequestError';
}
}
function execCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
childProcess.exec(command, { timeout: 10_000 }, (err, stdout) => {
if (err) {
reject(err);
return;
}
resolve(stdout);
});
});
}
/**
* Resolves the key in priority order: the user's literal `apiKey`, then the
* output of `apiKeyCommand`, then the key bundled into release builds.
*/
export async function resolveTmdbApiKey(
config: TmdbConfig | undefined,
bundledKey: string | null = null,
): Promise<string | null> {
const literal = config?.apiKey?.trim();
if (literal) return literal;
const command = config?.apiKeyCommand?.trim();
if (command) {
try {
const key = (await execCommand(command)).trim();
if (key.length > 0) return key;
} catch {
/* fall through to the bundled key */
}
}
return bundledKey;
}
/** Cache successful command output until either credential setting changes. */
export function createTmdbApiKeyResolver(
getConfig: () => TmdbConfig | undefined,
getBundledKey: () => string | null = () => null,
): () => Promise<string | null> {
let state:
| {
apiKey: string | undefined;
apiKeyCommand: string | undefined;
pending: Promise<string | null> | null;
retryAfterMs: number;
}
| undefined;
return async () => {
const config = getConfig();
if (
!state ||
state.apiKey !== config?.apiKey ||
state.apiKeyCommand !== config?.apiKeyCommand
) {
state = {
apiKey: config?.apiKey,
apiKeyCommand: config?.apiKeyCommand,
pending: null,
retryAfterMs: 0,
};
}
const current = state;
const literal = current.apiKey?.trim();
if (literal) return literal;
if (!current.apiKeyCommand?.trim()) return getBundledKey();
if (Date.now() < current.retryAfterMs) return getBundledKey();
current.pending ??= resolveTmdbApiKey(current).then((key) => {
if (!key) {
current.retryAfterMs = Date.now() + API_KEY_COMMAND_RETRY_MS;
current.pending = null;
}
return key;
});
const key = await current.pending;
return key ?? getBundledKey();
};
}
interface RawSearchItem {
media_type?: string;
id?: number;
name?: string;
original_name?: string;
title?: string;
original_title?: string;
original_language?: string;
overview?: string;
poster_path?: string | null;
first_air_date?: string;
release_date?: string;
genre_ids?: number[];
}
interface RawTranslation {
iso_639_1?: string;
data?: { name?: string; title?: string; overview?: string };
}
interface RawDetails extends RawSearchItem {
number_of_episodes?: number;
genres?: Array<{ id?: number }>;
alternative_titles?: { results?: Array<{ title?: string }>; titles?: Array<{ title?: string }> };
translations?: { translations?: RawTranslation[] };
}
function nonEmpty(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function yearOf(date: string | undefined): number | null {
const year = Number.parseInt(date?.slice(0, 4) ?? '', 10);
return Number.isFinite(year) && year > 0 ? year : null;
}
function posterUrlOf(path: string | null | undefined): string | null {
return path ? `${TMDB_POSTER_BASE_URL}${path}` : null;
}
function mediaTypeOf(value: unknown): TmdbMediaType | null {
return value === 'tv' || value === 'movie' ? value : null;
}
function normalizeSearchItem(item: RawSearchItem): TmdbSearchResult | null {
const tmdbType = mediaTypeOf(item.media_type);
if (!tmdbType || typeof item.id !== 'number') return null;
const title = nonEmpty(item.name) ?? nonEmpty(item.title);
const originalTitle = nonEmpty(item.original_name) ?? nonEmpty(item.original_title) ?? title;
if (!title || !originalTitle) return null;
return {
tmdbId: item.id,
tmdbType,
title,
originalTitle,
originalLanguage: item.original_language ?? '',
overview: nonEmpty(item.overview),
posterUrl: posterUrlOf(item.poster_path),
year: yearOf(item.first_air_date ?? item.release_date),
isAnimation: (item.genre_ids ?? []).includes(ANIMATION_GENRE_ID),
};
}
function normalizeDetails(tmdbType: TmdbMediaType, raw: RawDetails): TmdbTitleDetails | null {
if (typeof raw.id !== 'number') return null;
const localizedTitle = nonEmpty(raw.name) ?? nonEmpty(raw.title);
const originalTitle = nonEmpty(raw.original_name) ?? nonEmpty(raw.original_title);
const originalLanguage = raw.original_language ?? '';
const translations = raw.translations?.translations ?? [];
const translationFor = (language: string) =>
translations.find((entry) => entry.iso_639_1 === language)?.data;
const english = translationFor('en');
const japanese = translationFor('ja');
const englishTitle =
nonEmpty(english?.name) ??
nonEmpty(english?.title) ??
(localizedTitle && localizedTitle !== originalTitle ? localizedTitle : null);
const nativeTitle =
originalLanguage === 'ja'
? originalTitle
: (nonEmpty(japanese?.name) ?? nonEmpty(japanese?.title));
const alternativeTitles = [
...(raw.alternative_titles?.results ?? []),
...(raw.alternative_titles?.titles ?? []),
].map((entry) => nonEmpty(entry.title));
const translatedTitles = translations.map(
(entry) => nonEmpty(entry.data?.name) ?? nonEmpty(entry.data?.title),
);
const allTitles = [
...new Set(
[
localizedTitle,
originalTitle,
englishTitle,
nativeTitle,
...translatedTitles,
...alternativeTitles,
].filter((title): title is string => Boolean(title)),
),
];
return {
tmdbId: raw.id,
tmdbType,
titleEnglish: englishTitle,
titleNative: nativeTitle,
description:
nonEmpty(raw.overview) ?? nonEmpty(english?.overview) ?? nonEmpty(japanese?.overview),
posterUrl: posterUrlOf(raw.poster_path),
episodesTotal:
tmdbType === 'movie'
? 1
: typeof raw.number_of_episodes === 'number' && raw.number_of_episodes > 0
? raw.number_of_episodes
: null,
year: yearOf(raw.first_air_date ?? raw.release_date),
originalLanguage,
isAnimation: (raw.genres ?? []).some((genre) => genre.id === ANIMATION_GENRE_ID),
allTitles,
};
}
// TMDB issues two kinds of credential: a short v3 key that travels as a query
// parameter and a long v4 read token (a JWT) that goes in the Authorization
// header. Users paste whichever the settings page showed them.
function isV4Token(apiKey: string): boolean {
return apiKey.startsWith('eyJ');
}
export function createTmdbClient(deps: {
resolveApiKey: () => Promise<string | null>;
fetch?: typeof fetch;
baseUrl?: string;
}): TmdbClient {
const fetchImpl = deps.fetch ?? fetch;
const baseUrl = (deps.baseUrl ?? TMDB_API_BASE_URL).replace(/\/+$/, '');
async function request<T>(path: string, params: Record<string, string>): Promise<T | null> {
const apiKey = await deps.resolveApiKey();
if (!apiKey) throw new TmdbApiKeyMissingError();
const url = new URL(`${baseUrl}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const headers: Record<string, string> = { Accept: 'application/json' };
if (isV4Token(apiKey)) {
headers.Authorization = `Bearer ${apiKey}`;
} else {
url.searchParams.set('api_key', apiKey);
}
const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
if (res.status === 404) return null;
if (!res.ok) {
throw new TmdbRequestError(
`TMDB request failed: ${res.status} ${res.statusText}`,
res.status,
);
}
return (await res.json()) as T;
}
return {
async search(query) {
const trimmed = query.trim();
if (!trimmed) return [];
const payload = await request<{ results?: RawSearchItem[] }>('/search/multi', {
query: trimmed,
include_adult: 'false',
language: 'en-US',
page: '1',
});
return (payload?.results ?? [])
.map(normalizeSearchItem)
.filter((item): item is TmdbSearchResult => item !== null);
},
async getDetails(tmdbType, tmdbId) {
const raw = await request<RawDetails>(`/${tmdbType}/${tmdbId}`, {
language: 'en-US',
append_to_response: 'alternative_titles,translations',
});
return raw ? normalizeDetails(tmdbType, raw) : null;
},
};
}
+85 -113
View File
@@ -49,10 +49,7 @@ import {
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
updateLinuxMpvFullscreenOverlayRefreshBurst,
} from './main/runtime/linux-mpv-fullscreen-overlay-refresh';
import {
resolveLinuxVisibleOverlayWindowModeAction,
type LinuxVisibleOverlayWindowMode,
} from './main/runtime/linux-visible-overlay-window-mode';
import { createLinuxOverlayModeRuntime } from './main/runtime/linux-overlay-mode-runtime';
import { shouldRunLinuxOverlayZOrderKeepAlive } from './main/runtime/linux-overlay-zorder-keepalive';
import { focusMacOSOverlayWindow } from './main/runtime/macos-overlay-window-focus';
import { activateMacOSApp } from './main/runtime/macos-app-activation';
@@ -422,6 +419,7 @@ import {
writeStatsCliCommandResponse,
} from './main/runtime/stats-cli-command';
import { createStatsServerRuntime } from './main/runtime/stats-server-runtime';
import { createForceQuitHandler } from './main/runtime/app-lifecycle-actions';
import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos';
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
import {
@@ -431,6 +429,9 @@ import {
} from './core/services/anilist/anilist-updater';
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
import { createLiveActionMetadataResolver } from './core/services/tmdb/live-action-resolver';
import { createTmdbClient, createTmdbApiKeyResolver } from './core/services/tmdb/tmdb-client';
import { readBundledTmdbApiKey } from './core/services/tmdb/bundled-api-key';
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
@@ -980,6 +981,8 @@ const reportFatalError = createFatalErrorReporter({
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
// Release builds stage a project TMDB key next to the compiled main process.
const bundledTmdbApiKey = readBundledTmdbApiKey(__dirname);
const statsPreloadPath = path.join(__dirname, 'preload-stats.js');
const statsServerRuntime = createStatsServerRuntime({
userDataPath: USER_DATA_PATH,
@@ -1006,6 +1009,7 @@ const statsServerRuntime = createStatsServerRuntime({
},
getYomitanAnkiDeckName: () => getCurrentYomitanAnkiDeckNameForRuntime(),
getAnilistRateLimiter: () => anilistRateLimiter,
getBundledTmdbApiKey: () => bundledTmdbApiKey,
resolveAnkiNoteId: (noteId) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
trackDuplicateNoteIdsForNote: (noteId, duplicateNoteIds) => {
appState.ankiIntegration?.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
@@ -1027,11 +1031,17 @@ function requestAppQuit(): void {
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
appState.yomitanSettingsWindow = null;
destroyStatsWindow();
stopStatsServer();
void stopStatsServer().catch((error: unknown) => {
logger.warn('Failed to stop stats server while quitting.', error);
});
if (!forceQuitTimer) {
forceQuitTimer = setTimeout(() => {
logger.warn('App quit timed out; forcing process exit.');
app.exit(0);
void createForceQuitHandler({
destroyImmersionTracker: () => appState.immersionTracker?.destroy(),
logError: (error) => logger.error('Failed to finalize stats before forced exit.', error),
exit: () => app.exit(0),
})();
}, 2000);
}
app.quit();
@@ -1698,6 +1708,17 @@ const anilistRateLimiter = createAnilistRateLimiter();
const statsCoverArtFetcher = createCoverArtFetcher(
anilistRateLimiter,
createLogger('main:stats-cover-art'),
{
liveAction: createLiveActionMetadataResolver(
createTmdbClient({
resolveApiKey: createTmdbApiKeyResolver(
() => configService.getConfig().tmdb,
() => bundledTmdbApiKey,
),
}),
createLogger('main:tmdb'),
),
},
);
const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler());
const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntimeMainDepsHandler());
@@ -1992,11 +2013,27 @@ let lastObservedTimePos = 0;
let lastObservedPrimarySubtitleTrackId: number | null = null;
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
null;
let linuxVisibleOverlayWindowMode: LinuxVisibleOverlayWindowMode = 'managed';
let linuxTrackedMpvFullscreen = false;
let linuxTrackedMpvFullscreenChangedAtMs = 0;
let linuxVisibleOverlayOwnerBindingKey: string | null = null;
let linuxVisibleOverlayWindowModeSwitchToken = 0;
const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime({
isEnabled: shouldRunLinuxOverlayZOrderKeepAlive,
isVisible: () => overlayManager.getVisibleOverlayVisible(),
getWindow: () => overlayManager.getMainWindow(),
clearWindow: () => overlayManager.setMainWindow(null),
createWindow: () => {
visibleOverlayInteractionRuntime.resetVisibleOverlayInputState();
createMainWindow();
},
refreshWindow: () => {
const trackedGeometry = overlayGeometryRuntime.getCurrentTrackedOverlayGeometry();
if (trackedGeometry) overlayManager.setOverlayWindowBounds(trackedGeometry);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
void ensureOverlayMpvSubtitlesHidden();
if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
},
now: Date.now,
logDebug: (message) => logger.debug(message),
});
let subtitleSidebarRequestedOpen = false;
const SEEK_THRESHOLD_SECONDS = 3;
const EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
@@ -2838,7 +2875,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
},
hideNonNativeOverlayWhenTargetUnfocused: () =>
shouldRunLinuxOverlayZOrderKeepAlive() &&
linuxVisibleOverlayWindowMode === 'fullscreen-override',
linuxOverlayModeRuntime.mode === 'fullscreen-override',
resolveFallbackBounds: () => {
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
@@ -2884,9 +2921,9 @@ const visibleOverlayInteractionRuntime = createVisibleOverlayInteractionRuntime(
getBackendOverride: () => appState.backendOverride,
getInitialArgs: () => appState.initialArgs,
getOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
getLinuxVisibleOverlayWindowMode: () => linuxVisibleOverlayWindowMode,
getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode,
setLinuxVisibleOverlayOwnerBindingKey: (key) => {
linuxVisibleOverlayOwnerBindingKey = key;
linuxOverlayModeRuntime.ownerBindingKey = key;
},
bindVisibleOverlayToTrackedX11Window: (window) =>
overlayGeometryRuntime.bindVisibleOverlayToTrackedX11Window(window),
@@ -3046,7 +3083,8 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
startTime: range.startTime,
endTime: range.endTime,
}),
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
openModal: (payload, signal) =>
openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload, signal),
onPreviewEnded: (reviewId) => {
// The review may live in either overlay window; the renderer ignores foreign review ids.
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
@@ -4203,6 +4241,7 @@ const {
clearWindowsVisibleOverlayForegroundPollLoop: () =>
visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
linuxOverlayModeRuntime.cancelPendingTransition();
cancelLinuxMpvFullscreenOverlayRefreshBurst = null;
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
},
@@ -4226,8 +4265,8 @@ const {
},
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
getImmersionTracker: () => appState.immersionTracker,
stopStatsServer: () => stopStatsServer(),
clearImmersionTracker: () => {
stopStatsServer();
appState.statsServer = null;
appState.immersionTracker = null;
},
@@ -4316,7 +4355,9 @@ const immersionTrackerStartupMainDeps: Parameters<
const trackerHasChanged =
appState.immersionTracker !== null && appState.immersionTracker !== tracker;
if (trackerHasChanged && appState.statsServer) {
stopStatsServer();
void stopStatsServer().catch((error: unknown) => {
logger.warn('Failed to stop stats server while replacing immersion tracker.', error);
});
appState.statsServer = null;
}
@@ -4327,15 +4368,21 @@ const immersionTrackerStartupMainDeps: Parameters<
if (!appState.statsServer) {
const config = configService.getConfig();
if (config.stats.autoStartServer) {
ensureStatsServerStarted();
void ensureStatsServerStarted().catch((error: unknown) => {
logger.warn('Failed to auto-start stats server.', error);
});
}
}
// Register stats overlay toggle IPC handler (idempotent)
registerStatsOverlayToggle({
staticDir: statsDistPath,
preloadPath: statsPreloadPath,
getApiBaseUrl: () => ensureStatsServerStarted().url,
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
onStartupError: (error) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(
`Stats server startup failed: ${error instanceof Error ? error.message : String(error)}`,
{ title: 'Stats' },
),
getToggleKey: () => configService.getConfig().stats.toggleKey,
resolveBounds: () => overlayGeometryRuntime.getCurrentOverlayGeometry(),
onVisibilityChanged: (visible) => {
@@ -4417,7 +4464,7 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({
await createMecabTokenizerAndCheck();
},
getImmersionTracker: () => appState.immersionTracker,
ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted().url,
ensureStatsServerStarted: async () => (await statsStartupRuntime.ensureStatsServerStarted()).url,
ensureBackgroundStatsServerStarted: () =>
statsStartupRuntime.ensureBackgroundStatsServerStarted(),
stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(),
@@ -4914,7 +4961,7 @@ const {
},
overlayVisibilityRuntime,
syncVisibleOverlayMpvFullscreenMode: (nextFullscreen) =>
syncLinuxVisibleOverlayMpvFullscreenMode(nextFullscreen),
linuxOverlayModeRuntime.sync(nextFullscreen),
getOverlayInteractionActive: () =>
visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() ||
visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(),
@@ -5226,14 +5273,14 @@ const overlayGeometryRuntime = createOverlayGeometryRuntime({
getTrackedWindowNativeId: () => appState.windowTracker?.getTargetWindowNativeId?.(),
getStatsOverlayVisible: () => appState.statsOverlayVisible,
getOverlayForegroundSeparateWindows: () => getOverlayForegroundSeparateWindows(),
getLinuxVisibleOverlayWindowMode: () => linuxVisibleOverlayWindowMode,
getLinuxTrackedMpvFullscreen: () => linuxTrackedMpvFullscreen,
getLinuxTrackedMpvFullscreenChangedAtMs: () => linuxTrackedMpvFullscreenChangedAtMs,
getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode,
getLinuxTrackedMpvFullscreen: () => linuxOverlayModeRuntime.fullscreen,
getLinuxTrackedMpvFullscreenChangedAtMs: () => linuxOverlayModeRuntime.fullscreenChangedAtMs,
syncLinuxVisibleOverlayMpvFullscreenMode: (fullscreen) =>
syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen),
getLinuxVisibleOverlayOwnerBindingKey: () => linuxVisibleOverlayOwnerBindingKey,
linuxOverlayModeRuntime.sync(fullscreen),
getLinuxVisibleOverlayOwnerBindingKey: () => linuxOverlayModeRuntime.ownerBindingKey,
setLinuxVisibleOverlayOwnerBindingKey: (key) => {
linuxVisibleOverlayOwnerBindingKey = key;
linuxOverlayModeRuntime.ownerBindingKey = key;
},
clearVisibleOverlayX11OwnerBinding: (window) =>
visibleOverlayInteractionRuntime.clearVisibleOverlayX11OwnerBinding(window),
@@ -5318,85 +5365,6 @@ function createMainWindow(): BrowserWindow {
return window;
}
function createLinuxVisibleOverlayWindowForCurrentMode(token: number, fullscreen: boolean): void {
if (token !== linuxVisibleOverlayWindowModeSwitchToken) {
return;
}
if (!overlayManager.getVisibleOverlayVisible()) {
return;
}
const existingWindow = overlayManager.getMainWindow();
if (existingWindow && !existingWindow.isDestroyed()) {
return;
}
visibleOverlayInteractionRuntime.resetVisibleOverlayInputState();
createMainWindow();
const trackedGeometry = overlayGeometryRuntime.getCurrentTrackedOverlayGeometry();
if (trackedGeometry) {
overlayManager.setOverlayWindowBounds(trackedGeometry);
}
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
void ensureOverlayMpvSubtitlesHidden();
if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
logger.debug(
`Switched Linux visible overlay window mode to ${linuxVisibleOverlayWindowMode} for mpv fullscreen=${fullscreen}`,
);
}
function syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen: boolean): void {
if (!shouldRunLinuxOverlayZOrderKeepAlive()) {
return;
}
if (linuxTrackedMpvFullscreen !== fullscreen) {
linuxTrackedMpvFullscreenChangedAtMs = Date.now();
}
linuxTrackedMpvFullscreen = fullscreen;
const currentWindow = overlayManager.getMainWindow();
const hasLiveWindow = Boolean(currentWindow && !currentWindow.isDestroyed());
const action = resolveLinuxVisibleOverlayWindowModeAction({
currentMode: linuxVisibleOverlayWindowMode,
fullscreen,
hasLiveWindow,
visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(),
});
linuxVisibleOverlayWindowMode = action.nextMode;
linuxVisibleOverlayOwnerBindingKey = null;
linuxVisibleOverlayWindowModeSwitchToken += 1;
const token = linuxVisibleOverlayWindowModeSwitchToken;
if (!action.shouldCreateWindow && !action.shouldDestroyCurrentWindow) {
return;
}
const previousWindow = currentWindow;
if (action.shouldDestroyCurrentWindow && previousWindow && !previousWindow.isDestroyed()) {
previousWindow.once('closed', () => {
if (overlayManager.getMainWindow() === previousWindow) {
overlayManager.setMainWindow(null);
}
if (action.createWindowTiming === 'after-current-destroyed') {
createLinuxVisibleOverlayWindowForCurrentMode(token, fullscreen);
}
});
previousWindow.hide();
previousWindow.destroy();
}
if (!action.shouldCreateWindow) {
logger.debug(
`Recorded Linux visible overlay window mode ${action.nextMode} for hidden mpv fullscreen=${fullscreen}`,
);
return;
}
if (action.createWindowTiming === 'now') {
createLinuxVisibleOverlayWindowForCurrentMode(token, fullscreen);
}
}
function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler();
if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) {
@@ -5709,11 +5677,15 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
await dispatchSessionActionCore(request, {
toggleStatsOverlay: () =>
toggleStatsOverlayWindow({
staticDir: statsDistPath,
toggleStatsOverlay: async () =>
await toggleStatsOverlayWindow({
preloadPath: statsPreloadPath,
getApiBaseUrl: () => ensureStatsServerStarted().url,
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
onStartupError: (error) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(
`Stats server startup failed: ${error instanceof Error ? error.message : String(error)}`,
{ title: 'Stats' },
),
getToggleKey: () => configService.getConfig().stats.toggleKey,
resolveBounds: () => overlayGeometryRuntime.getCurrentOverlayGeometry(),
onVisibilityChanged: (visible) => {
@@ -6572,8 +6544,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
getLinuxX11FullscreenOverlay: () =>
shouldRunLinuxOverlayZOrderKeepAlive() &&
linuxTrackedMpvFullscreen &&
linuxVisibleOverlayWindowMode === 'fullscreen-override',
linuxOverlayModeRuntime.fullscreen &&
linuxOverlayModeRuntime.mode === 'fullscreen-override',
onVisibleWindowBlurred: () =>
visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh(),
onVisibleWindowFocused: () =>

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