diff --git a/changes/anime-browser.md b/changes/anime-browser.md index 7180a9f6..f0b41edf 100644 --- a/changes/anime-browser.md +++ b/changes/anime-browser.md @@ -19,7 +19,7 @@ area: anime - The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback, disconnecting clients release their active upstream fetch instead of consuming the socket and bandwidth in the background, stalled response bodies retain the upstream inactivity timeout, and only origin-form requests can reach the configured local bridge. - The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state. - A bridge that dies out from under the app (killed, crashed, or stopped mid-operation) no longer leaves the browser failing every request until an app restart: the exit is detected, surfaced in the status bar, and the bridge restarts on the next request. -- "Playing" is only reported once mpv actually configures a video output; when a stream fails to decode, the browser shows mpv's error instead of claiming playback started while no window ever appeared. +- "Playing" is only reported once mpv actually configures a video output; slow streams remain loading while mpv opens them, and failed streams show mpv's error. - The bridge bundle is downloaded from the newest upstream release that ships a bundle for the platform (skipping releases older than the oldest server SubMiner is known to work with), the same way Mangatan fetches it, so all four published bundles (macOS arm64 and x64, Linux x64, Windows x64) work without a maintainer hashing each one first. - The Extensions tab's available list has a language chip row: pick one or more languages to narrow a repository index that otherwise lists every language it knows, or "All" to clear the filter. Rows name the language ("Japanese" instead of `ja`) and the Available heading counts what the filter leaves. - A stream's subtitle tracks are downloaded to a temp directory and loaded into mpv as files rather than streamed from the source URL, so they can serve as the alass reference in Subsync the way Jellyfin subtitles do. The format is detected from the file's own content, a track that fails to download falls back to its URL so the episode still plays, and the directory is removed when the next episode starts or the app exits. diff --git a/changes/anime-playback-loading-docs.md b/changes/anime-playback-loading-docs.md new file mode 100644 index 00000000..5503f009 --- /dev/null +++ b/changes/anime-playback-loading-docs.md @@ -0,0 +1,4 @@ +type: docs +area: anime + +- Document how the anime browser waits for slow streams to start without reporting a premature playback failure. diff --git a/changes/fix-jellyfin-media-identity.md b/changes/fix-jellyfin-media-identity.md new file mode 100644 index 00000000..a50a339b --- /dev/null +++ b/changes/fix-jellyfin-media-identity.md @@ -0,0 +1,7 @@ +type: fixed +area: jellyfin + +- Set the mpv title before loading Jellyfin streams and reject URL-derived titles from metadata lookups, Anki source fields, Discord presence, and stats. +- Keep authenticated stream URLs out of stats identities even when playback metadata has not arrived. +- Remove previously cached credential-bearing Jellyfin stream parser metadata without changing unrelated anime metadata, watch history, or library assignments. +- Use safe media identities for persisted AniList retry keys, discard URL-derived queued searches, and skip updates and retries whose media identity is empty. diff --git a/changes/japanese-subtitle-generation-docs.md b/changes/japanese-subtitle-generation-docs.md index 798592e3..feb75183 100644 --- a/changes/japanese-subtitle-generation-docs.md +++ b/changes/japanese-subtitle-generation-docs.md @@ -2,3 +2,4 @@ type: docs area: subtitles - Explain how dialogue generation retains uncertain audible sections, why songs may also be transcribed, how detected speech starts guide long-passage cuts to reduce early subtitles, and why each passage uses a fresh Whisper process. +- Document automatic loaded subtitle references, English dialogue preference, signs/songs filtering, fallback behavior, and the limits of reference-guided timing. diff --git a/changes/japanese-subtitle-generation.md b/changes/japanese-subtitle-generation.md index d3ee83bb..e4199bda 100644 --- a/changes/japanese-subtitle-generation.md +++ b/changes/japanese-subtitle-generation.md @@ -4,3 +4,5 @@ area: subtitles - Generate local Japanese SRT subtitles with whisper.cpp from a standalone modal opened with Ctrl+Shift+G, the empty subtitle sidebar's generation button, or `subminer generate-subs`, with shared progress reporting, cancellation, safe output files, and automatic loading into the matching mpv video. The sidebar button hides while subtitle lines are loaded. - Configure an existing multilingual model in Settings or choose an official multilingual model, including quantized variants, in the modal or launcher. The modal shows download sizes, speed and accuracy guidance, and a recommended starting model before explicitly downloading a verified SubMiner-managed model. Executable paths are optional overrides; empty fields find whisper-cli, ffmpeg, and ffprobe on PATH. The modal's Local tools check and the launcher name any missing executable and its setting before downloading a model or extracting audio, and generation confirms the destination directory grants write and search permissions up front. - Optionally select Focus on spoken dialogue in the modal and use Download speech detection model to install the separate Silero model with progress and cancellation. The choice lasts for the session; a configured VAD model path sets the default. Retain uncertain audible sections so VAD rejection does not discard dialogue under music, accepting that songs may also be transcribed. Keep passages intact within Whisper's audio window, split longer passages near detected speech starts or quiet pauses with overlapping context to reduce early subtitle timing, and combine overlapping duplicate cues even when punctuation differs. Run each passage in a fresh Whisper process to prevent repeated-character output caused by state carried between files, at the cost of reloading the model per passage. Preserve original media timing and separate repeated dialogue. +- Recommend large-v3-turbo in the model picker when NVIDIA hardware and CUDA device support in the selected Whisper executable are confirmed; recommend small otherwise. Preserve configured and session model selections. Use a wider generation modal to reduce text wrapping and scrolling, while keeping it within smaller player windows. +- Automatically use an eligible embedded or external subtitle track loaded in mpv to guide long-passage cuts, preferring English dialogue and excluding tracks marked signs, songs, forced, or generated. Honor active subtitle delays, retain unreferenced audio, and fall back to audio timing when references cannot be read. Available in the overlay and launcher, with or without VAD. The launcher captures audio selection, reference tracks, and delays before model setup so playback changes during a download do not mix inputs from different times, and rejects an unverified media snapshot before using mpv's selected audio stream. diff --git a/changes/jellyfin-media-identity-docs.md b/changes/jellyfin-media-identity-docs.md new file mode 100644 index 00000000..a0a66904 --- /dev/null +++ b/changes/jellyfin-media-identity-docs.md @@ -0,0 +1,5 @@ +type: docs +area: jellyfin + +- Explain how Jellyfin media titles, unknown-title fallbacks, and stats identities keep stream credentials out of metadata. +- Document AniList rejection of empty media identities and the conditions for clearing cached Jellyfin parser metadata. diff --git a/changes/jimaku-live-action-search.md b/changes/jimaku-live-action-search.md new file mode 100644 index 00000000..2a08bc4a --- /dev/null +++ b/changes/jimaku-live-action-search.md @@ -0,0 +1,4 @@ +type: added +area: jimaku + +- Jimaku modal gains Anime / Live action tabs so searches can pull from Jimaku's live action catalogue instead of only anime entries (Arrow Left / Right switch tabs). diff --git a/changes/media-review-frame-picker-docs.md b/changes/media-review-frame-picker-docs.md new file mode 100644 index 00000000..da48ad59 --- /dev/null +++ b/changes/media-review-frame-picker-docs.md @@ -0,0 +1,4 @@ +type: docs +area: mining + +- Document independent screenshot selection in media timing review. diff --git a/changes/media-review-frame-picker.md b/changes/media-review-frame-picker.md new file mode 100644 index 00000000..f919dbde --- /dev/null +++ b/changes/media-review-frame-picker.md @@ -0,0 +1,4 @@ +type: added +area: mining + +- Choose a still screenshot independently of the audio in media timing review, with a live preview, a separate time slider, and decoded-frame stepping. Local video and seekable remote streams such as Jellyfin are supported. diff --git a/changes/settings-live-save-feedback-docs.md b/changes/settings-live-save-feedback-docs.md new file mode 100644 index 00000000..827a3e99 --- /dev/null +++ b/changes/settings-live-save-feedback-docs.md @@ -0,0 +1,4 @@ +type: docs +area: config + +- Clarified live-setting save feedback, mixed restart warnings, and subtitle-generation reload behavior. diff --git a/changes/settings-live-save-feedback.md b/changes/settings-live-save-feedback.md new file mode 100644 index 00000000..e51b0365 --- /dev/null +++ b/changes/settings-live-save-feedback.md @@ -0,0 +1,5 @@ +type: fixed +area: config + +- Settings marked LIVE now use the same reload policy as save results, fixing false restart warnings for notifications and subtitle generation. +- Mixed saves apply live changes and list only sections with changed fields that require a restart. diff --git a/changes/youtube-library-kind-docs.md b/changes/youtube-library-kind-docs.md new file mode 100644 index 00000000..bdaa80d1 --- /dev/null +++ b/changes/youtube-library-kind-docs.md @@ -0,0 +1,4 @@ +type: docs +area: stats + +- Document YouTube channel filtering and video statistics in the Library. diff --git a/changes/youtube-library-kind.md b/changes/youtube-library-kind.md new file mode 100644 index 00000000..ea0ef58c --- /dev/null +++ b/changes/youtube-library-kind.md @@ -0,0 +1,7 @@ +type: added +area: stats + +- Store YouTube channels as a separate media kind and migrate existing channel entries without changing viewing history or manual video assignments, including channels created after temporarily returning to an older build. +- Add All Titles, Anime, and YouTube Library filters, identify channel pages, and keep channels out of AniList matching, season repair, and duplicate recommendations. +- Keep same-title anime and YouTube records separate in storage and sync while repairing legacy channel classification. +- Refuse merges and video moves that would mix an anime entry with a YouTube channel; the move picker and merge selection only combine entries of the same kind. diff --git a/docs-site/anilist-integration.md b/docs-site/anilist-integration.md index a5a490b2..23a22c76 100644 --- a/docs-site/anilist-integration.md +++ b/docs-site/anilist-integration.md @@ -47,6 +47,8 @@ The update flow: Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff. +Updates are skipped if the media path cannot produce a safe, nonempty identity. Invalid entries are discarded when loading or adding to the retry queue. + | Parameter | Value | | ---------------- | ---------- | | Initial backoff | 30 seconds | diff --git a/docs-site/anime-browser.md b/docs-site/anime-browser.md index 1aa9daf3..f198b7b6 100644 --- a/docs-site/anime-browser.md +++ b/docs-site/anime-browser.md @@ -339,6 +339,10 @@ SubMiner waits until mpv actually configures a video output before reporting success. If mpv gives up instead — a dead host, an undecodable stream — the browser shows mpv's error rather than pretending playback started (a failed load leaves no mpv window, because the player idles windowless). +Slow streams stay in the loading state while mpv is still opening them, even +after the initial playback confirmation deadline. Loading ends when video +appears, mpv reports an error or returns to idle, or another episode replaces +the request. Failed requests appear in a dismissible panel at the bottom of the browser. It explains the failed action and suggests a next step. Expand **Technical diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 3a0814c3..235dd2d6 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -189,6 +189,10 @@ The review opens on the subtitle range plus your configured audio padding. Subti **Adjusting the clip.** Drag either edge to trim, drag the middle to slide the whole clip without changing its length, or click anywhere on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys: 100 ms per press, or 500 ms with Shift. The 100 ms buttons do the same thing. Earlier and Later each reveal two more seconds of timeline without moving the selection. +**Choosing the screenshot.** With still images enabled, drag the screenshot slider or use the Frame buttons to choose a video frame independently of the audio. Earlier and Later reveal more time for both sliders. The image follows the audio midpoint until you pick a frame, then stays fixed while you trim audio. Reset below the image restores automatic selection; the audio Reset affects only the audio. The screenshot slider also supports arrow keys, Home, and End. + +The picker supports local video and seekable remote streams, including Jellyfin, without seeking the main player. A selected frame must load before you can confirm; if it fails, choose another frame or Reset. The picker is hidden when image generation is disabled or animated AVIF is selected. Animated images continue to use the reviewed audio range. + **Keys.** Space previews the selection with a playhead sweeping the clip. The preview ends when the hidden player has actually played the last sample, so Bluetooth output latency does not clip the tail. Enter confirms and Escape cancels. **The waveform.** SubMiner reads a center channel when one carries dialogue and falls back to a mono mix otherwise, keeps only the 250 to 3500 Hz speech band, and draws each slice's loudness against the clip's own noise floor. Steady background music flattens out and dialogue stands up, which makes it much easier to tell adjacent lines apart. The mined subtitle appears as a tinted band with labeled line-start and line-end rails. If waveform analysis fails, the timing controls still work. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 93ed1df7..d3ac0d70 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -61,7 +61,7 @@ The Settings window preserves existing JSONC comments, trailing commas, and unre Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `jimaku.apiKeyCommand` when available. -Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window. +Saving validates the candidate config before writing. Saving only fields marked **LIVE** shows "Saved. Live settings applied." If a save also changes fields that need a restart, the banner lists only the sections containing those changed fields. Live changes still apply in the same save. ## Configuration file @@ -103,7 +103,7 @@ SubMiner watches the active config file (`config.jsonc` or `config.json`) while Hot-reloadable settings include subtitle appearance, sidebar controls, keybindings, shortcuts, notifications, logging level, selected source-language preferences, -Jimaku/Subsync settings, AniSkip settings (`mpv.aniskipEnabled`, `mpv.aniskipButtonKey`), +Jimaku/Subsync and subtitle-generation settings, AniSkip settings (`mpv.aniskipEnabled`, `mpv.aniskipButtonKey`), stats keys (`stats.toggleKey`, `stats.markWatchedKey`), the secondary-subtitle default mode, and the Anki deck, known-word, N+1, field, sentence-card, and Kiku options listed in the reference tables below. @@ -977,7 +977,7 @@ This example is intentionally compact. The option table below documents availabl | `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | | `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to 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.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.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) | diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index 2202c887..230d57a9 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -55,12 +55,12 @@ When older stats already grouped multiple seasons under one series entry, SubMin Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard. -When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section. +When YouTube channel metadata is available, the Library tab groups videos by creator/channel. 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. A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available: -- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card. -- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; 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. 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. 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. @@ -327,14 +327,14 @@ LIMIT ?; - Large-table reads are index-backed for `sample_ms`, session time windows, frequency-ranked words/kanji, and cover-art identity lookups. - Workload-dependent tuning knobs remain at defaults unless you change them: `cache_size`, `mmap_size`, `temp_store`, `auto_vacuum`. -### Schema (v23) +### Schema (v24) The exact schema version lives in `SCHEMA_VERSION` (`src/core/services/immersion-tracker/types.ts`) and is recorded in the `imm_schema_version` table. Core tables: - `imm_videos` - video key/title/source metadata -- `imm_anime` - anime/series metadata referenced by videos and lifetime tables +- `imm_anime` - anime/series or YouTube channel metadata (`media_kind`) referenced by videos and lifetime tables - `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 diff --git a/docs-site/jellyfin-integration.md b/docs-site/jellyfin-integration.md index 4e29be34..2d7704fd 100644 --- a/docs-site/jellyfin-integration.md +++ b/docs-site/jellyfin-integration.md @@ -52,10 +52,13 @@ From then on, pause / resume / seek / stop and audio or subtitle track changes y - **mpv launches automatically.** If mpv isn't already running when you cast, SubMiner starts it with SubMiner defaults and the bundled mpv plugin, so keybindings work right away. - **The overlay is managed by SubMiner,** so your configured `subtitleStyle` controls how subtitles look. Use the [overlay-toggle shortcut](/shortcuts) to hide it for a session. - **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load. +- **Titles and credentials stay separate.** AniList, character dictionaries, Anki source fields, and Discord presence use media titles, never authenticated stream URLs. If a usable title is unavailable, lookups are skipped and source fields show an unknown-media label. Stats identifies Jellyfin videos by server and item ID without the stream URL or API key. - **Direct play first.** When the source allows it and the container is in your direct-play allowlist, SubMiner streams the original file; otherwise it requests a transcoded stream from Jellyfin. - **Japanese subtitles are auto-selected,** preferring Jellyfin's default and embedded tracks over external sidecar files when several match. - **Downloaded subtitles keep their original timing.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages the subtitle files exposed by Jellyfin without letting mpv auto-switch between tracks, resets mpv's subtitle delay to zero, then selects the Japanese track. SubMiner does not compare Japanese and English cue timelines or save an inferred delay. +On startup, SubMiner clears cached anime parser metadata containing both API-key text and Jellyfin stream markers. Metadata containing only one of these is preserved. + ## Settings All Jellyfin options live under **Settings → Integrations → Jellyfin** (open settings from the tray's **Open SubMiner Settings**). The ones that matter for casting: diff --git a/docs-site/jimaku-integration.md b/docs-site/jimaku-integration.md index 7eb4067a..21f3ee36 100644 --- a/docs-site/jimaku-integration.md +++ b/docs-site/jimaku-integration.md @@ -1,6 +1,6 @@ # Jimaku integration -[Jimaku](https://jimaku.cc) is a community subtitle repository for anime, built from files other learners uploaded. SubMiner talks to the Jimaku API, so you search, browse, and download Japanese subtitle files from inside the overlay. No alt-tabbing, no moving files around. A downloaded track loads into mpv right away. +[Jimaku](https://jimaku.cc) is a community subtitle repository for anime and Japanese live action, built from files other learners uploaded. SubMiner talks to the Jimaku API, so you search, browse, and download Japanese subtitle files from inside the overlay. No alt-tabbing, no moving files around. A downloaded track loads into mpv right away. ::: tip Prerequisite: a free API key You need a Jimaku account and an API key (a personal access string) before this feature works. Create an account at [jimaku.cc](https://jimaku.cc), copy your key, and add it to your config as shown under [Configuration](#configuration) below. Without a key, the search modal will report "Jimaku API key not set." @@ -14,21 +14,23 @@ When you open the modal, SubMiner parses the current video filename to extract a From there: -1. **Search** - SubMiner queries the Jimaku API with the parsed title. Results appear as a list of anime entries (Japanese and English names). -2. **Browse entries** - Select an entry to load its available subtitle files, filtered by episode if one was detected. -3. **Browse files** - Files show name, size, and last-modified date. If a language preference is configured, files are sorted accordingly (e.g., Japanese-tagged files first). -4. **Download** - Selecting a file downloads it to the same directory as the video (or a temp directory for remote/streamed media) and loads it into mpv as a new subtitle track. +1. **Pick a catalogue** - The **Anime** and **Live action** tabs at the top of the modal choose which Jimaku catalogue to search. Switching tabs re-runs the current search. The choice persists until SubMiner restarts. +2. **Search** - SubMiner queries the Jimaku API with the parsed title. Results appear as a list of entries (Japanese and English names). +3. **Browse entries** - Select an entry to load its available subtitle files, filtered by episode if one was detected. +4. **Browse files** - Files show name, size, and last-modified date. If a language preference is configured, files are sorted accordingly (e.g., Japanese-tagged files first). +5. **Download** - Selecting a file downloads it to the same directory as the video (or a temp directory for remote/streamed media) and loads it into mpv as a new subtitle track. If no files match the current episode filter, a "Show all files" button lets you broaden the search to all episodes for that entry. ### Modal keyboard shortcuts -| Key | Action | -| ------------------------- | ---------------------------- | -| `Enter` (in text field) | Search | -| `Enter` (in list) | Select entry / download file | -| `Arrow Up` / `Arrow Down` | Navigate entries or files | -| `Escape` | Close modal | +| Key | Action | +| ---------------------------- | --------------------------------------------- | +| `Enter` (in text field) | Search | +| `Enter` (in list) | Select entry / download file | +| `Arrow Up` / `Arrow Down` | Navigate entries or files | +| `Arrow Left` / `Arrow Right` | Switch between the Anime and Live action tabs | +| `Escape` | Close modal | ## Configuration @@ -52,7 +54,7 @@ Add a `jimaku` section to your `config.jsonc`: | `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). | | `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. | | `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. | -| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. | +| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of entries returned per search. | The keyboard shortcut is configured separately under `shortcuts`: diff --git a/docs-site/subtitle-generation.md b/docs-site/subtitle-generation.md index b2b9f684..7f71a66b 100644 --- a/docs-site/subtitle-generation.md +++ b/docs-site/subtitle-generation.md @@ -29,11 +29,21 @@ With speech detection configured, SubMiner keeps detected speech and other audib Passages that fit within Whisper's 30-second audio window stay intact. Longer passages prefer nearby detected speech starts when choosing cuts, falling back to quiet pauses, with a small overlap to provide context. This reduces early subtitles caused by starting a clip well before its dialogue, while keeping all retained audio covered. Matching overlapping cues are combined even when punctuation differs; repeated dialogue at separate times remains separate. SubMiner runs each passage in a fresh Whisper process so decoder state from earlier audio cannot affect later passages. This reloads the model for each passage and can increase generation time. Subtitle cues stay within the supplied audio and retain each passage's position on the original timeline. Progress reports the current passage. -This mode favors retaining dialogue over excluding music, so songs and background sounds may also produce subtitles. It can take longer than transcribing only VAD-approved speech. Whisper can still miss or misrecognize dialogue, and its timestamps remain estimates. Uncheck **Focus on spoken dialogue** to return to full-audio transcription for the session, or clear `vadModelPath` to change the default. A selected detector or model that fails stops generation with an error. Existing subtitles are preserved. +This mode favors retaining dialogue over excluding music, so songs and background sounds may also produce subtitles. It can take longer than transcribing only VAD-approved speech. Whisper can still miss or misrecognize dialogue, and its timestamps remain estimates. Uncheck **Focus on spoken dialogue** to disable VAD for the session, or clear `vadModelPath` to change the default. Without a usable subtitle reference, disabling VAD returns to full-audio transcription. A selected detector or model that fails stops generation with an error. Existing subtitles are preserved. + +## Using loaded subtitles as timing references + +When generating for the video currently open in mpv, SubMiner automatically looks for a dialogue subtitle track among its embedded subtitles and loaded external SRT, ASS/SSA, or WebVTT files. It prefers English, then tracks labeled full or dialogue. Forced tracks, image subtitles, generated subtitles, and tracks whose titles or filenames identify signs, songs, lyrics, karaoke, or opening/ending subtitles are skipped. These checks rely on metadata; an unlabeled signs-only file cannot always be identified. + +The selected reference appears in generation progress. SubMiner reads its timestamps, including the active primary or secondary track's subtitle delay, and uses nearby cue starts to guide cuts in long audio passages. Short passages stay intact. The reference works with or without **Focus on spoken dialogue**. With that option enabled, reference starts take priority over VAD starts when choosing a nearby cut; VAD still helps identify speech. Audio outside reference cues remains eligible for transcription, and Whisper still supplies the Japanese text and final timestamps. The reference is assumed to be timed for the playing video; this does not automatically sync a mistimed reference. + +Unreadable or empty references are skipped in favor of another eligible loaded track. If none can be read, generation uses its normal audio timing. The launcher uses loaded references only when its input matches the video currently open in mpv; standalone generation keeps its existing behavior. It captures reference tracks and delays together with the initial audio selection, before checking or downloading a model. When relying on mpv's selected audio, it stops and asks you to retry if the media changes or cannot be verified during capture. ## Choosing a model -Start with **small** for a balance of Japanese recognition quality and CPU time. This is a general starting recommendation, not a benchmark for your hardware. Tiny and base need less memory and usually finish sooner, with more recognition errors. Medium and large models favor accuracy but need more resources. Large-v3-turbo is optimized for speed compared with large-v3, with some accuracy tradeoff; actual performance depends on your CPU, GPU, whisper.cpp build, and audio. +The modal recommends **large-v3-turbo** when it detects an NVIDIA GPU through `nvidia-smi` and the selected `whisper-cli` discovers an available CUDA device. Otherwise it recommends **small** for a balance of Japanese recognition quality and CPU time. The check works before downloading a model and falls back to small if a tool is missing, fails, times out, or reports an unrecognized result. Vulkan, AMD, and Apple GPU support do not qualify for the turbo recommendation. Checks are cached for up to 30 seconds; changing the Whisper executable path triggers a new check. + +The recommendation labels the model picker and explains the detected support. It does not change your configured model, current selection, external model path, or launcher's model choice. It also does not force a GPU backend during transcription. These are starting recommendations, not hardware benchmarks or guarantees that every model fits in available GPU memory. Tiny and base need less memory and usually finish sooner, with more recognition errors. Medium and large models favor accuracy but need more resources. Large-v3-turbo is optimized for speed compared with large-v3, with some accuracy tradeoff; actual performance depends on your CPU, GPU, whisper.cpp build, and audio. The picker includes whisper.cpp's official multilingual tiny, base, small, medium, large-v1, large-v2, large-v3, and large-v3-turbo downloads, including their available quantized variants. Quantized models use less disk space and memory, with possible accuracy loss. English-only `.en` models are excluded. See the [upstream model list](https://github.com/ggml-org/whisper.cpp/blob/master/models/download-ggml-model.sh) and [Whisper's model guidance](https://github.com/openai/whisper#available-models-and-languages). @@ -47,7 +57,7 @@ A configured external Model Path takes precedence and hides the managed model pi 4. Optionally check **Focus on spoken dialogue** and click **Download speech detection model** if prompted. 5. Click **Generate subtitles**. -The modal shows audio preparation, transcription, and saving progress. Percentages appear when the underlying tool reports them. **Cancel** stops the current operation. Closing the modal lets the job continue; reopening it shows the current progress or result. +The modal adapts to the player window, using a wider layout when space allows and scrolling in smaller windows. It shows audio preparation, transcription, and saving progress. Percentages appear when the underlying tool reports them. **Cancel** stops the current operation. Closing the modal lets the job continue; reopening it shows the current progress or result. **Escape** or **Close** closes the modal using the same focus and overlay restoration as other SubMiner modals. Change or disable its shortcut with `shortcuts.openSubtitleGeneration` in Settings. Ctrl+G remains assigned to field grouping. diff --git a/docs-site/usage.md b/docs-site/usage.md index f31b9a22..a0d72b3d 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -93,6 +93,10 @@ delay. It also works with no running SubMiner app or mpv instance when you provide a file path. Omit the path to use the current mpv file and selected audio track. +When the input matches the current mpv file, generation automatically uses an eligible embedded +or loaded external subtitle track as a timing reference, preferring English dialogue and skipping +tracks marked as signs, songs, or forced. See [timing references](/subtitle-generation#using-loaded-subtitles-as-timing-references). + ```bash subminer generate-subs video.mkv --download-model subminer generate-subs video.mkv --model-path ~/models/ggml-medium.bin diff --git a/docs-site/youtube-integration.md b/docs-site/youtube-integration.md index d29eaa2f..a9c48df6 100644 --- a/docs-site/youtube-integration.md +++ b/docs-site/youtube-integration.md @@ -149,6 +149,10 @@ These settings come from `config.jsonc` (or built-in defaults); there are no CLI - **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently. - **Native mpv secondary rendering**: Stays hidden during YouTube flows so the SubMiner overlay remains the visible secondary subtitle surface. +## Viewing stats + +The stats Library groups tracked YouTube videos by channel when channel metadata is available. Select **YouTube** in the Library filter to see those channels separately from anime. Each channel page lists its videos, watch time, vocabulary, and mined cards. See [Immersion Tracking](/immersion-tracking#library). + ## Related pages - [Usage - YouTube Playback](/usage#youtube-playback) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index e62e26df..795ed49c 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -32,6 +32,8 @@ Update checks and startup launcher migration share a serialized update-state sto - `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection. - `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. +- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps. - `src/renderer/` owns overlay rendering and input behavior. - `src/config/` owns config definitions, defaults, loading, and resolution. - `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel. diff --git a/launcher/commands/generate-subtitles-command.test.ts b/launcher/commands/generate-subtitles-command.test.ts index 9f47632d..1a685534 100644 --- a/launcher/commands/generate-subtitles-command.test.ts +++ b/launcher/commands/generate-subtitles-command.test.ts @@ -136,25 +136,28 @@ test('current mpv generation requires an identifiable selected audio track', asy } }); -test('explicit local file leaves Japanese track selection to the shared generator', async () => { +test('explicit playing file leaves audio selection to the generator but inspects subtitle references', async () => { const f = fixture(); await runGenerateSubtitlesCommand(f.context, f.deps); assert.equal(f.generations[0]?.audioStreamIndex, undefined); assert.equal( f.commands.some((command) => command[1] === 'track-list'), - false, + true, ); }); test('launcher does not load generated subtitles after mpv switches files', async () => { const f = fixture(['generate-subs']); - let pathRequests = 0; + let changed = false; f.deps.mpvCommand = async (_socket, command) => { f.commands.push(command); - if (command[1] === 'path') - return ++pathRequests === 1 ? '/media/episode.mkv' : '/media/next.mkv'; + if (command[1] === 'path') return changed ? '/media/next.mkv' : '/media/episode.mkv'; return [{ type: 'audio', selected: true, 'ff-index': 2 }]; }; + f.deps.generate = async () => { + changed = true; + return '/media/episode.ja.srt'; + }; await runGenerateSubtitlesCommand(f.context, f.deps); assert.equal( f.commands.some((command) => command[0] === 'sub-add'), @@ -163,6 +166,86 @@ test('launcher does not load generated subtitles after mpv switches files', asyn assert.match(f.output.join(''), /Saved Japanese subtitles/); }); +test('current mpv generation rejects tracks when playback changes during capture', async () => { + for (const nextMedia of ['/media/next.mkv', null]) { + const f = fixture(['generate-subs']); + let media: string | null = '/media/episode.mkv'; + let modelChecks = 0; + f.deps.mpvCommand = async (_socket, command) => { + if (command[1] === 'path') return media; + if (command[1] === 'track-list') { + media = nextMedia; + return [{ type: 'audio', selected: true, 'ff-index': 99 }]; + } + return undefined; + }; + f.deps.resolveModel = async () => { + modelChecks += 1; + return { kind: 'external', path: '/models/model.bin' }; + }; + await assert.rejects( + runGenerateSubtitlesCommand(f.context, f.deps), + /mpv media.*Run generate-subs again/, + ); + assert.equal(f.generations.length, 0); + assert.equal(modelChecks, 0); + } +}); + +test('explicit media or audio stream remains usable when the mpv snapshot changes', async () => { + for (const options of [['/media/episode.mkv'], ['--audio-stream', '7']]) { + const f = fixture(['generate-subs', ...options]); + let media = '/media/episode.mkv'; + f.deps.mpvCommand = async (_socket, command) => { + if (command[1] === 'path') return media; + if (command[1] === 'track-list') { + media = '/media/next.mkv'; + return [ + { type: 'audio', selected: true, 'ff-index': 99 }, + { type: 'sub', lang: 'eng', 'ff-index': 100 }, + ]; + } + return undefined; + }; + await runGenerateSubtitlesCommand(f.context, f.deps); + assert.equal(f.generations[0]?.mediaPath, '/media/episode.mkv'); + assert.equal( + f.generations[0]?.audioStreamIndex, + options[0] === '--audio-stream' ? 7 : undefined, + ); + assert.deepEqual(f.generations[0]?.references, []); + } +}); + +test('launcher captures loaded external references only for the matching media', async () => { + for (const matching of [true, false]) { + const f = fixture(); + f.deps.mpvCommand = async (_socket, command) => { + if (command[1] === 'path') return matching ? '/media/episode.mkv' : '/media/other.mkv'; + if (command[1] === 'working-directory') return '/mpv'; + if (command[1] === 'track-list') + return [ + { type: 'sub', external: true, 'external-filename': 'episode.en.signs.ass' }, + { type: 'sub', external: true, 'external-filename': 'episode.en.srt' }, + ]; + return undefined; + }; + await runGenerateSubtitlesCommand(f.context, f.deps); + assert.deepEqual( + f.generations[0]?.references, + matching + ? [ + { + label: 'episode.en.srt', + delaySeconds: 0, + source: { kind: 'external', path: '/mpv/episode.en.srt' }, + }, + ] + : [], + ); + } +}); + test('explicit managed model overrides external config and downloads before generation', async () => { const f = fixture([ 'generate-subs', @@ -191,6 +274,69 @@ test('explicit managed model overrides external config and downloads before gene assert.equal(f.generations.length, 1); }); +test('audio and subtitle timing use the initial mpv snapshot across model setup', async () => { + for (const changeDuring of ['resolve', 'download']) { + const f = fixture(['generate-subs', '--download-model']); + let changed = false; + let trackReads = 0; + f.deps.mpvCommand = async (_socket, command) => { + if (command[1] === 'path') return '/media/episode.mkv'; + if (command[1] === 'track-list') { + trackReads += 1; + return [ + { type: 'audio', selected: true, 'ff-index': changed ? 3 : 2 }, + { type: 'sub', id: 1, title: 'English Full', lang: 'eng', 'ff-index': changed ? 5 : 4 }, + ]; + } + if (command[1] === 'sid') return 1; + if (command[1] === 'sub-delay') return changed ? 9 : 1.5; + return undefined; + }; + f.deps.resolveModel = async () => { + if (changeDuring === 'resolve') changed = true; + return { kind: 'missing', path: '/models/model.bin' }; + }; + f.deps.downloadModel = async () => { + changed = true; + return '/models/model.bin'; + }; + await runGenerateSubtitlesCommand(f.context, f.deps); + assert.equal(f.generations[0]?.audioStreamIndex, 2); + assert.deepEqual(f.generations[0]?.references, [ + { + label: 'English Full', + delaySeconds: 1.5, + source: { kind: 'embedded', streamIndex: 4 }, + }, + ]); + assert.equal(trackReads, 1); + } +}); + +test('explicit audio stream bypasses mpv audio selection while retaining subtitle references', async () => { + const f = fixture(['generate-subs', '--audio-stream', '7']); + f.deps.mpvCommand = async (_socket, command) => { + if (command[1] === 'path') return '/media/episode.mkv'; + if (command[1] === 'track-list') + return [ + { type: 'audio', selected: true, external: true, 'ff-index': 0 }, + { type: 'sub', id: 2, title: 'English Full', lang: 'eng', 'ff-index': 4 }, + ]; + if (command[1] === 'secondary-sid') return 2; + if (command[1] === 'secondary-sub-delay') return -0.5; + return undefined; + }; + await runGenerateSubtitlesCommand(f.context, f.deps); + assert.equal(f.generations[0]?.audioStreamIndex, 7); + assert.deepEqual(f.generations[0]?.references, [ + { + label: 'English Full', + delaySeconds: -0.5, + source: { kind: 'embedded', streamIndex: 4 }, + }, + ]); +}); + test('generation can run standalone and never loads subtitles into another video', async () => { for (const playing of [null, '/media/different.mkv']) { const f = fixture(); diff --git a/launcher/commands/generate-subtitles-command.ts b/launcher/commands/generate-subtitles-command.ts index a98d9bff..335d2e40 100644 --- a/launcher/commands/generate-subtitles-command.ts +++ b/launcher/commands/generate-subtitles-command.ts @@ -8,6 +8,10 @@ import { resolveSubtitleGenerationTools, } from '../../src/core/services/subtitle-generation.js'; import { requireSubtitleGenerationTools } from '../../src/core/services/subtitle-generation-tools.js'; +import { + readSubtitleGenerationReferences, + type SubtitleGenerationReference, +} from '../../src/core/services/subtitle-generation-reference.js'; import { resolveSubtitleGenerationConfig, type SubtitleGenerationProgress, @@ -69,12 +73,9 @@ async function readMpvMedia(socketPath: string, command: GenerationCommandDeps[' return localMediaPath(media, workingDirectory); } -async function readMpvAudioStream( - socketPath: string, - command: GenerationCommandDeps['mpvCommand'], -) { - const tracks = await command(socketPath, ['get_property', 'track-list'], 1000); - for (const track of Array.isArray(tracks) ? tracks : []) { +function selectedMpvAudioStream(value: unknown) { + const tracks: unknown[] = Array.isArray(value) ? value : []; + for (const track of tracks) { if ( typeof track === 'object' && track !== null && @@ -160,11 +161,29 @@ export async function runGenerateSubtitlesCommand( const mediaPath = options.mediaPath ? localMediaPath(options.mediaPath) : currentMedia; if (!mediaPath) throw new Error('Pass a local video file or open one in mpv before running generate-subs.'); + // Capture audio and subtitle timing together before model setup can yield to playback changes. + const matchesCurrentMedia = currentMedia !== null && sameFile(currentMedia, mediaPath); + const tracks = matchesCurrentMedia + ? await deps + .mpvCommand(context.mpvSocketPath, ['get_property', 'track-list'], 1000) + .catch(() => null) + : null; + let references: SubtitleGenerationReference[] = []; + if (matchesCurrentMedia) { + const candidates = await readSubtitleGenerationReferences(tracks, (name) => + deps.mpvCommand(context.mpvSocketPath, ['get_property', name], 1000), + ); + const stillPlaying = await readMpvMedia(context.mpvSocketPath, deps.mpvCommand).catch( + () => null, + ); + if (stillPlaying && sameFile(stillPlaying, mediaPath)) references = candidates; + else if (!options.mediaPath && options.audioStreamIndex === undefined) + throw new Error( + 'The current mpv media changed or could not be verified while reading tracks. Run generate-subs again.', + ); + } const audioStreamIndex = - options.audioStreamIndex ?? - (!options.mediaPath - ? await readMpvAudioStream(context.mpvSocketPath, deps.mpvCommand) - : undefined); + options.audioStreamIndex ?? (!options.mediaPath ? selectedMpvAudioStream(tracks) : undefined); const onProgress = createGenerationProgressReporter(write); // Missing executables fail here, before any model download starts. requireSubtitleGenerationTools(await deps.resolveTools(config)); @@ -183,6 +202,7 @@ export async function runGenerateSubtitlesCommand( modelDirectory, mediaPath, audioStreamIndex, + references, outputPath: options.outputPath ? path.resolve(resolvePathMaybe(options.outputPath)) : undefined, diff --git a/launcher/test-support/immersion-db-schema.test.ts b/launcher/test-support/immersion-db-schema.test.ts index 0320a7c9..68632178 100644 --- a/launcher/test-support/immersion-db-schema.test.ts +++ b/launcher/test-support/immersion-db-schema.test.ts @@ -31,6 +31,7 @@ const SYNC_SCHEMA_OBJECTS = [ 'imm_lifetime_applied_sessions', 'imm_stats_excluded_words', 'idx_anime_normalized_title', + 'idx_anime_kind_title', 'idx_anime_anilist_id', 'idx_videos_anime_id', 'idx_sessions_video_started', diff --git a/launcher/test-support/immersion-db-schema.ts b/launcher/test-support/immersion-db-schema.ts index c99e2cf4..32d68730 100644 --- a/launcher/test-support/immersion-db-schema.ts +++ b/launcher/test-support/immersion-db-schema.ts @@ -12,7 +12,7 @@ export const IMMERSION_DB_FIXTURE_DDL = ` ); CREATE TABLE imm_anime( anime_id INTEGER PRIMARY KEY AUTOINCREMENT, - normalized_title_key TEXT NOT NULL UNIQUE, + normalized_title_key TEXT NOT NULL, canonical_title TEXT NOT NULL, anilist_id INTEGER UNIQUE, title_romaji TEXT, @@ -22,8 +22,10 @@ export const IMMERSION_DB_FIXTURE_DDL = ` description TEXT, metadata_json TEXT, CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT + LAST_UPDATE_DATE TEXT, + media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube')) ); + CREATE UNIQUE INDEX idx_anime_kind_title ON imm_anime(media_kind, normalized_title_key); CREATE TABLE imm_videos( video_id INTEGER PRIMARY KEY AUTOINCREMENT, video_key TEXT NOT NULL UNIQUE, diff --git a/src/anime-bridge/playback-outcome.test.ts b/src/anime-bridge/playback-outcome.test.ts index c56beb0c..778a6be9 100644 --- a/src/anime-bridge/playback-outcome.test.ts +++ b/src/anime-bridge/playback-outcome.test.ts @@ -20,6 +20,8 @@ function createHarness(overrides?: { timeoutMs?: number; probeIntervalMs?: number; readCostMs?: number; + onWait?: (elapsedMs: number, harness: Harness) => void; + isCurrent?: () => boolean; }) { const listeners = new Set<(event: PlaybackEndFileEvent) => void>(); const properties = new Map(); @@ -39,10 +41,12 @@ function createHarness(overrides?: { }, wait: async (ms) => { clock += ms; + overrides?.onWait?.(clock, harness); }, now: () => clock, timeoutMs: overrides?.timeoutMs ?? 1000, probeIntervalMs: overrides?.probeIntervalMs ?? 100, + isCurrent: overrides?.isCurrent, }); const harness: Harness = { @@ -94,6 +98,76 @@ test('times out with a failure when nothing ever starts', async () => { watch.dispose(); }); +test('waits for a slow stream that is still loading after the confirmation deadline', async () => { + const { watch, harness } = createHarness({ + timeoutMs: 300, + onWait: (elapsedMs, state) => { + if (elapsedMs >= 600) state.setProperty('vo-configured', true); + }, + }); + harness.setProperty('idle-active', false); + assert.deepEqual(await watch.wait(), { ok: true }); + assert.equal(harness.elapsed(), 600); + watch.dispose(); +}); + +test('reports a real stream error after the confirmation deadline', async () => { + const { watch, harness } = createHarness({ + timeoutMs: 300, + onWait: (elapsedMs, state) => { + if (elapsedMs >= 600) state.emitEndFile({ reason: 'error', fileError: 'HTTP 503' }); + }, + }); + harness.setProperty('idle-active', false); + assert.deepEqual(await watch.wait(), { + ok: false, + error: 'mpv could not play this stream: HTTP 503', + }); + watch.dispose(); +}); + +test('stops waiting if a slow stream leaves mpv idle', async () => { + const { watch, harness } = createHarness({ + timeoutMs: 300, + onWait: (elapsedMs, state) => { + if (elapsedMs >= 600) state.setProperty('idle-active', true); + }, + }); + harness.setProperty('idle-active', false); + assert.equal((await watch.wait()).ok, false); + assert.equal(harness.elapsed(), 600); + watch.dispose(); +}); + +test('stops watching a slow stream when the request is superseded', async () => { + let current = true; + const { watch, harness } = createHarness({ + timeoutMs: 300, + isCurrent: () => current, + onWait: (elapsedMs) => { + if (elapsedMs >= 600) current = false; + }, + }); + harness.setProperty('idle-active', false); + assert.equal((await watch.wait()).ok, false); + assert.equal(harness.elapsed(), 600); + watch.dispose(); + assert.equal(harness.listenerCount(), 0); +}); + +test('disposing the watcher stops polling a slow stream', async () => { + const { watch, harness } = createHarness({ + timeoutMs: 300, + onWait: (elapsedMs) => { + if (elapsedMs >= 600) watch.dispose(); + }, + }); + harness.setProperty('idle-active', false); + assert.equal((await watch.wait()).ok, false); + assert.equal(harness.elapsed(), 600); + assert.equal(harness.listenerCount(), 0); +}); + test('slow property reads eat the budget instead of extending it', async () => { const { watch, harness } = createHarness({ timeoutMs: 300, @@ -102,7 +176,7 @@ test('slow property reads eat the budget instead of extending it', async () => { }); const outcome = await watch.wait(); assert.equal(outcome.ok, false); - // Two probes: 250 + 50 (the sleep clamped to what was left) then 250 again. + // Property reads and the final idle check all count toward elapsed time. assert.ok(harness.elapsed() >= 300, 'gave up before the timeout'); assert.ok(harness.elapsed() < 900, 'read delays stretched the timeout'); watch.dispose(); diff --git a/src/anime-bridge/playback-outcome.ts b/src/anime-bridge/playback-outcome.ts index c703ca7e..2696dd4e 100644 --- a/src/anime-bridge/playback-outcome.ts +++ b/src/anime-bridge/playback-outcome.ts @@ -26,10 +26,12 @@ export interface WatchPlaybackOutcomeDeps { /** One-shot mpv property read; may reject while the file is still loading. */ readProperty: (name: string) => Promise; wait: (ms: number) => Promise; - /** Injectable clock; the timeout is wall-clock, not a probe count. */ + /** Injectable clock for the initial confirmation deadline. */ now?: () => number; timeoutMs?: number; probeIntervalMs?: number; + /** Stop watching when a newer episode replaces this request or the app closes. */ + isCurrent?: () => boolean; } export interface PlaybackOutcomeWatch { @@ -49,6 +51,7 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu const probeIntervalMs = deps.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS; let failure: PlaybackOutcome | null = null; + let disposed = false; const unsubscribe = deps.onEndFile((event) => { if (event.reason !== 'error') return; failure = { @@ -60,11 +63,11 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu }); async function wait(): Promise { - // Wall-clock, not a probe count: a slow `readProperty` must eat into the - // budget rather than stretch it, and a zero probe interval must still end. + // Use elapsed time rather than probe count before checking whether mpv is + // still active. Slow property reads count toward this initial deadline. const now = deps.now ?? Date.now; const deadline = now() + timeoutMs; - while (now() < deadline) { + while (!disposed && (deps.isCurrent?.() ?? true)) { if (failure) return failure; try { if ((await deps.readProperty('vo-configured')) === true) return { ok: true }; @@ -72,10 +75,17 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu // The property is unreadable while mpv is between files; keep polling. } if (failure) return failure; - // Sleeping past the deadline would only delay the timeout report. + // A deadline without video is not a failure while mpv is still opening + // the stream. Keep waiting for video or a real end-file error in that case. const remaining = deadline - now(); - if (remaining <= 0) break; - await deps.wait(Math.min(probeIntervalMs, remaining)); + if (remaining <= 0) { + try { + if ((await deps.readProperty('idle-active')) !== false) break; + } catch { + break; + } + } + await deps.wait(remaining > 0 ? Math.min(probeIntervalMs, remaining) : probeIntervalMs); } return ( failure ?? { @@ -85,5 +95,11 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu ); } - return { wait, dispose: unsubscribe }; + return { + wait, + dispose: () => { + disposed = true; + unsubscribe(); + }, + }; } diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index ed63f04a..897e3897 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -1527,3 +1527,21 @@ test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key quer assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)'); assert.equal(result.includes('api_key='), false); }); + +test('Anki metadata rejects a credential-bearing media title before metadata arrives', () => { + const integration = new AnkiIntegration( + { metadata: { pattern: '[SubMiner] %f | %F (%t)' } } as never, + {} as never, + { + currentVideoPath: 'https://jellyfin.example/Videos/item/stream?api_key=test-secret', + currentMediaTitle: 'stream?static=true&api_key=test-secret', + currentTimePos: 426, + send: () => true, + } as never, + ); + const privateApi = integration as unknown as { + formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; + }; + const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426); + assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)'); +}); diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 059fcded..b2efb88c 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -32,6 +32,7 @@ import { type MediaTimingReviewRequest, } from './types/anki'; import { AiConfig } from './types/integrations'; +import { sanitizeMediaTitle } from './shared/media-identity'; import type { KnownWordMaturityTier } from './types/subtitle'; import { MpvClient } from './types/runtime'; import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification'; @@ -1117,9 +1118,11 @@ export class AnkiIntegration { return null; } const mediaRange = this.getSubtitleMediaRange(context); - const timestamp = context - ? mediaRange.startTime + (mediaRange.endTime - mediaRange.startTime) / 2 - : this.mpvClient.currentTimePos || 0; + const timestamp = + context?.screenshotTime ?? + (context + ? mediaRange.startTime + (mediaRange.endTime - mediaRange.startTime) / 2 + : this.mpvClient.currentTimePos || 0); if (this.config.media?.imageType === 'avif') { return this.mediaGenerator.generateAnimatedImage( @@ -1165,11 +1168,13 @@ export class AnkiIntegration { } const videoFilename = extractFilenameFromMediaPath(mediaPath); - const resolvedMediaTitle = trimToNonEmptyString(mediaTitle); + const resolvedMediaTitle = sanitizeMediaTitle(mediaTitle); const filenameWithExt = (shouldPreferMediaTitleForMiscInfo(mediaPath, videoFilename) - ? resolvedMediaTitle || videoFilename - : videoFilename || resolvedMediaTitle) || fallbackFilename; + ? resolvedMediaTitle || 'Unknown media' + : sanitizeMediaTitle(videoFilename) || resolvedMediaTitle) || + sanitizeMediaTitle(fallbackFilename) || + 'Unknown media'; const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, ''); const currentTimePos = @@ -1794,6 +1799,8 @@ export class AnkiIntegration { ...request, audioPadding: Math.max(0, this.config.media.audioPadding ?? 0), maxMediaDuration: Math.max(0, this.config.media.maxMediaDuration ?? 30), + screenshotEnabled: + this.config.media.generateImage !== false && this.config.media.imageType !== 'avif', }); } diff --git a/src/anki-integration/card-creation.test.ts b/src/anki-integration/card-creation.test.ts index b1a93438..881ebe5f 100644 --- a/src/anki-integration/card-creation.test.ts +++ b/src/anki-integration/card-creation.test.ts @@ -290,6 +290,9 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws', }); test('CardCreationService uses stream-open-filename for remote media generation', async () => { + let reviewing = false; + const audioRanges: number[][] = []; + const imageTimes: number[] = []; const audioPaths: string[] = []; const imagePaths: string[] = []; const recordMediaPath = (mediaInput: MediaInput): string => @@ -319,6 +322,10 @@ test('CardCreationService uses stream-open-filename for remote media generation' behavior: {}, ai: false, }) as AnkiConnectConfig, + reviewMediaTiming: async () => + reviewing + ? { action: 'confirm', startTime: 0.2, endTime: 0.8, screenshotTime: 3.125 } + : { action: 'use-original' }, getAiConfig: () => ({}), getTimingTracker: () => ({}) as never, getMpvClient: () => @@ -349,16 +356,18 @@ test('CardCreationService uses stream-open-filename for remote media generation' ], updateNoteFields: async () => undefined, storeMediaFile: async () => undefined, - findNotes: async () => [], + findNotes: async () => [42], retrieveMediaFile: async () => '', deleteNotes: async () => undefined, }, mediaGenerator: { - generateAudio: async (path) => { + generateAudio: async (path, start, end, padding) => { + audioRanges.push([start, end, padding ?? -1]); audioPaths.push(recordMediaPath(path)); return Buffer.from('audio'); }, - generateScreenshot: async (path) => { + generateScreenshot: async (path, timestamp) => { + imageTimes.push(timestamp); imagePaths.push(recordMediaPath(path)); return Buffer.from('image'); }, @@ -406,6 +415,14 @@ test('CardCreationService uses stream-open-filename for remote media generation' assert.equal(created, true); assert.deepEqual(audioPaths, [audioUrl]); assert.deepEqual(imagePaths, [videoUrl]); + reviewing = true; + assert.equal(await service.createSentenceCard('テスト', 0, 1), true); + assert.deepEqual(audioRanges.at(-1), [0.2, 0.8, 0]); + assert.equal(imageTimes.at(-1), 3.125); + await service.markLastCardAsAudioCard(); + assert.equal(imageTimes.length, 3); + assert.deepEqual(audioRanges.at(-1), [0.2, 0.8, 0]); + assert.equal(imageTimes.at(-1), 3.125); }); test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => { diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index e15d1fcc..89913576 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -536,6 +536,7 @@ export class CardCreationService { endTime, animatedLeadInSeconds, exactReviewedRange, + timingDecision.action === 'confirm' ? timingDecision.screenshotTime : undefined, ); const imageField = this.deps.getConfig().fields?.image; @@ -796,6 +797,9 @@ export class CardCreationService { generateImage, volumeScale, ...(exactReviewedRange ? { mediaPaddingSeconds: 0 } : {}), + ...(timingDecision.action === 'confirm' && timingDecision.screenshotTime !== undefined + ? { screenshotTime: timingDecision.screenshotTime } + : {}), }); await this.deps.showNotification(noteId, label, 'media queued'); return true; @@ -840,6 +844,7 @@ export class CardCreationService { endTime, 0, exactReviewedRange, + timingDecision.action === 'confirm' ? timingDecision.screenshotTime : undefined, ); const imageField = config.fields?.image; @@ -922,15 +927,16 @@ export class CardCreationService { endTime: number, animatedLeadInSeconds = 0, exactReviewedRange = false, + screenshotTime?: number, ): Promise { const mpvClient = this.deps.getMpvClient(); if (!mpvClient) { return null; } - const timestamp = exactReviewedRange - ? startTime + (endTime - startTime) / 2 - : mpvClient.currentTimePos || 0; + const timestamp = + screenshotTime ?? + (exactReviewedRange ? startTime + (endTime - startTime) / 2 : mpvClient.currentTimePos || 0); if (this.deps.getConfig().media?.imageType === 'avif') { let imageStart = startTime; diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts index 06694d2c..99019886 100644 --- a/src/anki-integration/note-update-workflow.test.ts +++ b/src/anki-integration/note-update-workflow.test.ts @@ -729,6 +729,7 @@ test('NoteUpdateWorkflow uses the combined review sentence for the card and medi startTime: 2, endTime: 7, text: 'previous-line current-line next-line', + screenshotTime: 8, }); harness.deps.generateAudio = async (context) => { audioContexts.push(context); @@ -745,6 +746,7 @@ test('NoteUpdateWorkflow uses the combined review sentence for the card and medi assert.equal(audioContexts[0]?.startTime, 2); assert.equal(audioContexts[0]?.endTime, 7); assert.equal(audioContexts[0]?.mediaPaddingSeconds, 0); + assert.equal(audioContexts[0]?.screenshotTime, 8); }); test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', async () => { diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts index 4cc5e7ee..7d1468b1 100644 --- a/src/anki-integration/note-update-workflow.ts +++ b/src/anki-integration/note-update-workflow.ts @@ -253,6 +253,9 @@ export class NoteUpdateWorkflow { startTime: timingDecision.startTime, endTime: timingDecision.endTime, mediaPaddingSeconds: 0, + ...(timingDecision.screenshotTime !== undefined + ? { screenshotTime: timingDecision.screenshotTime } + : {}), }; } else if (timingDecision.action === 'skip-media') { skipMedia = true; diff --git a/src/anki-integration/pending-youtube-media-queue.test.ts b/src/anki-integration/pending-youtube-media-queue.test.ts index 85db1e5d..4c347063 100644 --- a/src/anki-integration/pending-youtube-media-queue.test.ts +++ b/src/anki-integration/pending-youtube-media-queue.test.ts @@ -51,6 +51,41 @@ function createDeps( return deps; } +test('queued media keeps a chosen screenshot separate from the reviewed audio range', async () => { + const screenshots: number[] = []; + const audioRanges: number[][] = []; + const deps = createDeps(); + deps.client.notesInfo = async () => [{ noteId: 42, fields: { Picture: { value: '' } } }]; + deps.mediaGenerator.generateScreenshot = async (_media, time) => { + screenshots.push(time); + return Buffer.from('image'); + }; + deps.mediaGenerator.generateAudio = async (_media, start, end, padding) => { + audioRanges.push([start, end, padding ?? -1]); + return Buffer.from('audio'); + }; + const queue = new PendingYoutubeMediaQueue(deps); + assert.equal( + await queue.queueFromNote({ + noteId: 42, + noteInfo: { noteId: 42, fields: {} }, + label: 'test', + context: { + source: 'overlay', + text: '字幕', + startTime: 1, + endTime: 2, + mediaPaddingSeconds: 0, + screenshotTime: 3.125, + }, + }), + true, + ); + await queue.handleReady('https://youtu.be/abc123', '/cache/video.mkv'); + assert.deepEqual(screenshots, [3.125]); + assert.deepEqual(audioRanges, [[1, 2, 0]]); +}); + test('PendingYoutubeMediaQueue treats cache lookup failures as an immediate generation fallback', async () => { const deps = createDeps({ getCachedMediaPath: async () => { diff --git a/src/anki-integration/pending-youtube-media-queue.ts b/src/anki-integration/pending-youtube-media-queue.ts index 1a3e659f..59802c6d 100644 --- a/src/anki-integration/pending-youtube-media-queue.ts +++ b/src/anki-integration/pending-youtube-media-queue.ts @@ -147,6 +147,9 @@ export class PendingYoutubeMediaQueue { generateAudio: shouldGenerateAudio(config), generateImage: shouldGenerateImage(config), volumeScale, + ...(job.context?.screenshotTime !== undefined + ? { screenshotTime: job.context.screenshotTime } + : {}), ...(job.context?.mediaPaddingSeconds !== undefined ? { mediaPaddingSeconds: job.context.mediaPaddingSeconds } : {}), @@ -320,6 +323,7 @@ export class PendingYoutubeMediaQueue { job.endTime, animatedLeadInSeconds, job.mediaPaddingSeconds, + job.screenshotTime, ); if (imageBuffer) { await this.deps.client.storeMediaFile(imageFilename, imageBuffer); @@ -381,6 +385,7 @@ export class PendingYoutubeMediaQueue { endTime: number, animatedLeadInSeconds = 0, mediaPaddingSeconds?: number, + screenshotTime?: number, ): Promise { const config = this.deps.getConfig(); if (config.media?.imageType === 'avif') { @@ -399,7 +404,7 @@ export class PendingYoutubeMediaQueue { ); } - const timestamp = startTime + (endTime - startTime) / 2; + const timestamp = screenshotTime ?? startTime + (endTime - startTime) / 2; return this.deps.mediaGenerator.generateScreenshot(videoPath, timestamp, { format: config.media?.imageFormat as 'jpg' | 'png' | 'webp', quality: config.media?.imageQuality, diff --git a/src/anki-integration/pending-youtube-media.ts b/src/anki-integration/pending-youtube-media.ts index 4e0abfb0..dec424bf 100644 --- a/src/anki-integration/pending-youtube-media.ts +++ b/src/anki-integration/pending-youtube-media.ts @@ -11,6 +11,7 @@ export interface PendingYoutubeMediaUpdate { generateImage: boolean; volumeScale?: number; mediaPaddingSeconds?: number; + screenshotTime?: number; } function trimToNonEmptyString(value: unknown): string | null { diff --git a/src/config/hot-reload.ts b/src/config/hot-reload.ts new file mode 100644 index 00000000..4ec55574 --- /dev/null +++ b/src/config/hot-reload.ts @@ -0,0 +1,65 @@ +function pathStartsWith(path: string, prefix: string): boolean { + return path === prefix || path.startsWith(`${prefix}.`); +} + +const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitleSidebar'] as const; + +const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [ + 'secondarySub.defaultMode', + 'mpv.aniskipEnabled', + 'mpv.aniskipButtonKey', + 'ankiConnect.ai.enabled', + 'stats.toggleKey', + 'stats.markWatchedKey', + 'logging.level', + 'logging.rotation', + 'logging.files', + 'youtube.primarySubLanguages', + 'anime.autoOpenJimaku', + 'ankiConnect.deck', + 'ankiConnect.media.normalizeAudio', + 'ankiConnect.media.mirrorMpvVolume', + 'ankiConnect.media.reviewTiming', + 'ankiConnect.behavior.autoUpdateNewCards', + 'ankiConnect.knownWords.highlightEnabled', + 'ankiConnect.knownWords.refreshMinutes', + 'ankiConnect.knownWords.addMinedWordsImmediately', + 'ankiConnect.knownWords.matchMode', + 'ankiConnect.knownWords.decks', + 'ankiConnect.nPlusOne.enabled', + 'ankiConnect.nPlusOne.minSentenceWords', + 'ankiConnect.fields.word', + 'ankiConnect.fields.audio', + 'ankiConnect.fields.image', + 'ankiConnect.fields.sentence', + 'ankiConnect.fields.miscInfo', + 'ankiConnect.isLapis.sentenceCardModel', + 'ankiConnect.isKiku.fieldGrouping', + 'ankiConnect.isSenren.fieldGrouping', + 'ankiConnect.lapisKiku.wordCardKind', +] as const; + +export function getConfigHotReloadField(path: string): string | null { + for (const root of HOT_RELOAD_ROOTS) { + if (pathStartsWith(path, root)) { + return root; + } + } + + for (const hotPath of HOT_RELOAD_EXACT_OR_PREFIX_PATHS) { + if (pathStartsWith(path, hotPath)) { + return hotPath; + } + } + + // These consumers read the current config when the next operation starts. + if ( + ['jimaku', 'subsync', 'notifications', 'subtitleGeneration'].some((root) => + pathStartsWith(path, root), + ) + ) { + return path; + } + + return null; +} diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index fe308d65..86bccc32 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -1,9 +1,9 @@ +import { getConfigHotReloadField } from '../hot-reload'; import type { ResolvedConfig } from '../../types/config'; import type { ConfigSettingsCategory, ConfigSettingsControl, ConfigSettingsField, - ConfigSettingsRestartBehavior, } from '../../types/settings'; import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions'; import { @@ -698,54 +698,6 @@ function compareFields(a: ConfigSettingsField, b: ConfigSettingsField): number { return a.configPath.localeCompare(b.configPath); } -function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior { - if ( - path === 'keybindings' || - pathStartsWith(path, 'shortcuts') || - pathStartsWith(path, 'subtitleStyle') || - pathStartsWith(path, 'subtitleSidebar') || - path === 'secondarySub.defaultMode' || - path === 'ankiConnect.deck' || - path === 'ankiConnect.ai.enabled' || - path === 'ankiConnect.media.normalizeAudio' || - path === 'ankiConnect.media.mirrorMpvVolume' || - path === 'ankiConnect.media.reviewTiming' || - path === 'ankiConnect.behavior.autoUpdateNewCards' || - path === 'ankiConnect.knownWords.highlightEnabled' || - path === 'ankiConnect.knownWords.refreshMinutes' || - path === 'ankiConnect.knownWords.addMinedWordsImmediately' || - path === 'ankiConnect.knownWords.matchMode' || - path === 'ankiConnect.knownWords.decks' || - path === 'ankiConnect.nPlusOne.enabled' || - path === 'ankiConnect.nPlusOne.minSentenceWords' || - path === 'ankiConnect.fields.word' || - path === 'ankiConnect.fields.audio' || - path === 'ankiConnect.fields.image' || - path === 'ankiConnect.fields.sentence' || - path === 'ankiConnect.fields.miscInfo' || - path === 'ankiConnect.isLapis.sentenceCardModel' || - path === 'ankiConnect.isKiku.fieldGrouping' || - path === 'ankiConnect.isSenren.fieldGrouping' || - path === 'ankiConnect.lapisKiku.wordCardKind' || - path === 'mpv.aniskipEnabled' || - path === 'mpv.aniskipButtonKey' || - path === 'stats.toggleKey' || - path === 'stats.markWatchedKey' || - path === 'logging.level' || - path === 'logging.rotation' || - pathStartsWith(path, 'logging.files') || - pathStartsWith(path, 'notifications') || - path === 'anime.autoOpenJimaku' || - path === 'youtube.primarySubLanguages' || - pathStartsWith(path, 'jimaku') || - pathStartsWith(path, 'subsync') || - pathStartsWith(path, 'subtitleGeneration') - ) { - return 'hot-reload'; - } - return 'restart'; -} - function fieldForLeaf(leaf: Leaf): ConfigSettingsField { const option = OPTION_BY_PATH.get(leaf.path); const { category, section } = categoryAndSection(leaf.path); @@ -764,7 +716,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField { ? { enumValues: option.settingsEnumValues ?? option.enumValues } : {}), ...(option?.enumLabels ? { enumLabels: option.enumLabels } : {}), - restartBehavior: restartBehaviorForPath(leaf.path), + restartBehavior: getConfigHotReloadField(leaf.path) ? 'hot-reload' : 'restart', advanced: leaf.path.startsWith('controller.') || leaf.path.startsWith('immersionTracking.retention.') || diff --git a/src/core/services/anilist/anilist-update-queue.test.ts b/src/core/services/anilist/anilist-update-queue.test.ts index 37ba54c8..e4776be7 100644 --- a/src/core/services/anilist/anilist-update-queue.test.ts +++ b/src/core/services/anilist/anilist-update-queue.test.ts @@ -27,6 +27,83 @@ function createLogger() { }; } +test('anilist retry queue migrates stream keys and rejects URL-derived searches', () => { + const queueFile = createTempQueueFile(); + const loggerState = createLogger(); + const key = 'https://example.com/Videos/item/stream?api_key=test-secret::2'; + fs.writeFileSync( + queueFile, + JSON.stringify({ + pending: [ + { + key, + title: 'My Anime', + episode: 2, + createdAt: 1, + attemptCount: 0, + nextAttemptAt: 1, + lastError: null, + }, + ], + deadLetter: [], + }), + ); + const queue = createAnilistUpdateQueue(queueFile, loggerState.logger); + assert.equal(queue.nextReady()?.key, 'jellyfin://example.com/item/item::2'); + assert.equal(fs.readFileSync(queueFile, 'utf8').includes('test-secret'), false); + queue.enqueue('unsafe', 'stream?api_key=test-secret', 3); + assert.equal(queue.getSnapshot().pending, 1); + queue.markSuccess(key); + assert.equal(queue.getSnapshot().pending, 0); +}); + +test('anilist retry queue discards empty normalized identities on load and enqueue', () => { + const queueFile = createTempQueueFile(); + const loggerState = createLogger(); + const invalidKeys = [ + '', + ' ', + '::3', + 'stream?api_key=secret::3', + 'stream%3Fapi_key%3Dsecret::3', + 'https://[invalid::3', + ]; + const item = { + title: 'My Anime', + episode: 3, + createdAt: 1, + attemptCount: 0, + nextAttemptAt: 1, + lastError: null, + }; + const validKey = 'https://example.com/Videos/item/stream?api_key=secret::3'; + fs.writeFileSync( + queueFile, + JSON.stringify({ + pending: [...invalidKeys, validKey].map((key) => ({ ...item, key })), + deadLetter: invalidKeys.map((key) => ({ ...item, key })), + }), + ); + const queue = createAnilistUpdateQueue(queueFile, loggerState.logger); + assert.deepEqual(queue.getSnapshot(), { pending: 1, ready: 1, deadLetter: 0 }); + const persisted = fs.readFileSync(queueFile, 'utf8'); + assert.deepEqual(JSON.parse(persisted), { + pending: [{ ...item, key: 'jellyfin://example.com/item/item::3' }], + deadLetter: [], + }); + for (const key of invalidKeys) { + queue.enqueue(key, 'My Anime', 3); + queue.markFailure(key, 'invalid'); + queue.markSuccess(key); + } + assert.equal(fs.readFileSync(queueFile, 'utf8'), persisted); + queue.markFailure(validKey, 'retry', 10); + assert.equal(queue.nextReady(30_010)?.attemptCount, 1); + queue.markSuccess(validKey); + queue.enqueue(validKey, 'My Anime', 3); + assert.equal(queue.nextReady()?.key, 'jellyfin://example.com/item/item::3'); +}); + test('anilist update queue enqueues, snapshots, and dequeues success', () => { const queueFile = createTempQueueFile(); const loggerState = createLogger(); diff --git a/src/core/services/anilist/anilist-update-queue.ts b/src/core/services/anilist/anilist-update-queue.ts index 6d2f117d..0a1bc20d 100644 --- a/src/core/services/anilist/anilist-update-queue.ts +++ b/src/core/services/anilist/anilist-update-queue.ts @@ -1,5 +1,13 @@ import * as fs from 'fs'; import { ensureDirForFile } from '../../../shared/fs-utils'; +import { sanitizeMediaTitle, toMediaIdentityPath } from '../../../shared/media-identity'; + +function normalizeAnilistRetryKey(key: string): string { + const parts = key.match(/^(.*)::(\d+)$/s); + const identity = toMediaIdentityPath(parts ? (parts[1] ?? '') : key); + if (!identity) return ''; + return parts ? `${identity}::${parts[2]}` : identity; +} const INITIAL_BACKOFF_MS = 30_000; const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000; @@ -105,6 +113,9 @@ export function createAnilistUpdateQueue( isValidPersistedMediaId(item.mediaId) && (typeof item.lastError === 'string' || item.lastError === null), ) + .filter((item) => sanitizeMediaTitle(item.title) !== null) + .map((item) => ({ ...item, key: normalizeAnilistRetryKey(item.key) })) + .filter((item) => item.key !== '') .slice(0, MAX_ITEMS); deadLetter = parsedDeadLetter .filter( @@ -120,7 +131,11 @@ export function createAnilistUpdateQueue( isValidPersistedMediaId(item.mediaId) && (typeof item.lastError === 'string' || item.lastError === null), ) + .filter((item) => sanitizeMediaTitle(item.title) !== null) + .map((item) => ({ ...item, key: normalizeAnilistRetryKey(item.key) })) + .filter((item) => item.key !== '') .slice(0, MAX_ITEMS); + if (JSON.stringify({ pending, deadLetter }) !== JSON.stringify(parsed)) persist(); } catch (error) { logger.error('Failed to load AniList retry queue.', error); } @@ -136,6 +151,9 @@ export function createAnilistUpdateQueue( season: number | null = null, mediaId: number | null = null, ): void { + if (!sanitizeMediaTitle(title)) return; + key = normalizeAnilistRetryKey(key); + if (!key) return; const existing = pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key); if (existing) { @@ -165,6 +183,8 @@ export function createAnilistUpdateQueue( }, markSuccess(key: string): void { + key = normalizeAnilistRetryKey(key); + if (!key) return; const before = pending.length; pending = pending.filter((item) => item.key !== key); if (pending.length !== before) { @@ -173,6 +193,8 @@ export function createAnilistUpdateQueue( }, markFailure(key: string, reason: string, nowMs: number = Date.now()): void { + key = normalizeAnilistRetryKey(key); + if (!key) return; const item = pending.find((candidate) => candidate.key === key); if (!item) { return; diff --git a/src/core/services/anilist/anilist-updater.test.ts b/src/core/services/anilist/anilist-updater.test.ts index d5e961f9..adbd9886 100644 --- a/src/core/services/anilist/anilist-updater.test.ts +++ b/src/core/services/anilist/anilist-updater.test.ts @@ -140,6 +140,62 @@ test('guessAnilistMediaInfo preserves useful guessit alternative title for ambig }); }); +test('guessAnilistMediaInfo uses the display title for authenticated streams', async () => { + const targets: string[] = []; + const result = await guessAnilistMediaInfo( + 'https://jellyfin.example/Videos/item/stream?static=true&api_key=test-secret', + 'My Anime S02E03', + { + runGuessit: async (target) => { + targets.push(target); + throw new Error('use fallback parser'); + }, + }, + ); + assert.deepEqual(targets, ['My Anime S02E03']); + assert.deepEqual(result, { + title: 'My Anime', + season: 2, + episode: 3, + source: 'fallback', + }); +}); + +test('guessAnilistMediaInfo preserves slashes in display titles', async () => { + const title = 'Fate/stay night S01E02'; + for (const mediaPath of [null, title, 'https://example.com/stream?api_key=test-secret']) { + const targets: string[] = []; + await guessAnilistMediaInfo(mediaPath, title, { + runGuessit: async (target) => { + targets.push(target); + return JSON.stringify({ title: 'Fate/stay night', season: 1, episode: 2 }); + }, + }); + assert.deepEqual(targets, [title]); + } +}); + +test('guessAnilistMediaInfo never parses stream URLs or their query-bearing filenames', async () => { + const unsafeInputs = [ + 'https://jellyfin.example/Videos/item/stream?static=true&api_key=test-secret', + 'stream?static=true&api_key=test-secret&MediaSourceId=item', + 'https://user:test-secret@example.com/video.mkv', + ]; + for (const input of unsafeInputs) { + const targets: string[] = []; + const deps = { + runGuessit: async (target: string) => { + targets.push(target); + return JSON.stringify({ title: target }); + }, + }; + assert.equal(await guessAnilistMediaInfo(input, null, deps), null); + assert.equal(await guessAnilistMediaInfo(null, input, deps), null); + assert.equal(await guessAnilistMediaInfo(input, input, deps), null); + assert.deepEqual(targets, []); + } +}); + test('updateAnilistPostWatchProgress updates progress when behind', async () => { const originalFetch = globalThis.fetch; let call = 0; diff --git a/src/core/services/anilist/anilist-updater.ts b/src/core/services/anilist/anilist-updater.ts index e8c8c1c2..2cd14776 100644 --- a/src/core/services/anilist/anilist-updater.ts +++ b/src/core/services/anilist/anilist-updater.ts @@ -2,6 +2,7 @@ import * as childProcess from 'child_process'; import * as path from 'path'; import { parseMediaInfo } from '../../../jimaku/utils'; +import { resolveMediaLookupTarget, sanitizeMediaTitle } from '../../../shared/media-identity'; import type { AnilistRateLimiter } from './rate-limiter'; import { resolveAnilistSeasonMedia } from './season-resolver'; @@ -231,8 +232,9 @@ export async function guessAnilistMediaInfo( mediaTitle: string | null, deps: GuessAnilistMediaInfoDeps = { runGuessit }, ): Promise { - const target = mediaPath ?? mediaTitle; - const guessitTarget = mediaPath ? path.basename(mediaPath) : mediaTitle; + const target = resolveMediaLookupTarget(mediaPath, mediaTitle); + if (!target) return null; + const guessitTarget = target === sanitizeMediaTitle(mediaTitle) ? target : path.basename(target); if (guessitTarget && guessitTarget.trim().length > 0) { try { @@ -260,8 +262,7 @@ export async function guessAnilistMediaInfo( } } - const fallbackTarget = mediaPath ?? mediaTitle; - const parsed = parseMediaInfo(fallbackTarget); + const parsed = parseMediaInfo(target); if (!parsed.title.trim()) { return null; } diff --git a/src/core/services/anilist/cover-art-fetcher.ts b/src/core/services/anilist/cover-art-fetcher.ts index 7f623d05..83c02c3f 100644 --- a/src/core/services/anilist/cover-art-fetcher.ts +++ b/src/core/services/anilist/cover-art-fetcher.ts @@ -193,6 +193,16 @@ export function createCoverArtFetcher( return { async fetchIfMissing(db, videoId, canonicalTitle): Promise { + const channel = 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.media_kind != 'anime' + `, + ) + .get(videoId); + if (channel) return false; const existing = getCoverArt(db, videoId); if (existing?.coverBlob) { return true; diff --git a/src/core/services/anilist/season-resolver.test.ts b/src/core/services/anilist/season-resolver.test.ts index bdbdbfc7..6212c01a 100644 --- a/src/core/services/anilist/season-resolver.test.ts +++ b/src/core/services/anilist/season-resolver.test.ts @@ -117,6 +117,27 @@ function createExecutor( return { execute, searches, relationLookups }; } +test('AniList refuses URL-derived search titles before making a request', async () => { + let requests = 0; + for (const title of [ + 'https://example.com/stream?api_key=test-secret', + 'stream?static=true&api_key=test-secret', + 'stream static true api key test secret', + ]) { + const result = await resolveAnilistSeasonMedia( + { title }, + { + execute: async () => { + requests += 1; + throw new Error('must not send URL-derived searches'); + }, + }, + ); + assert.equal(result, null); + } + assert.equal(requests, 0); +}); + test('stripSeasonSuffix drops release-name season markers', () => { assert.equal(stripSeasonSuffix('Some Show Season 3'), 'Some Show'); assert.equal(stripSeasonSuffix('Some Show S3'), 'Some Show'); diff --git a/src/core/services/anilist/season-resolver.ts b/src/core/services/anilist/season-resolver.ts index c84c5166..14383d7a 100644 --- a/src/core/services/anilist/season-resolver.ts +++ b/src/core/services/anilist/season-resolver.ts @@ -1,3 +1,5 @@ +import { sanitizeMediaTitle } from '../../../shared/media-identity'; + /** * AniList has no concept of "season N": sequels are separate media with their own * titles (Zoku, Kan, 2nd Season, ...). Searching " Season 3" therefore returns @@ -468,7 +470,9 @@ export async function resolveAnilistSeasonMedia( input: ResolveAnilistSeasonMediaInput, deps: ResolveAnilistSeasonMediaDeps, ): Promise<AnilistSeasonResolution | null> { - const searchTitle = stripSeasonSuffix(input.title).trim() || input.title.trim(); + const safeTitle = sanitizeMediaTitle(input.title); + if (!safeTitle) return null; + const searchTitle = stripSeasonSuffix(safeTitle).trim() || safeTitle; if (!searchTitle) return null; const season = diff --git a/src/core/services/anki-jimaku.ts b/src/core/services/anki-jimaku.ts index 8daa4e3a..3a17af6e 100644 --- a/src/core/services/anki-jimaku.ts +++ b/src/core/services/anki-jimaku.ts @@ -215,9 +215,10 @@ export function registerAnkiJimakuIpcRuntime( }, getJimakuMediaInfo: () => options.parseMediaInfo(options.getCurrentMediaPath()), searchJimakuEntries: async (query) => { - logger.info(`[jimaku] search-entries query: "${query.query}"`); + const category = query.category ?? 'anime'; + logger.info(`[jimaku] search-entries query: "${query.query}" category=${category}`); const response = await options.jimakuFetchJson<JimakuEntry[]>('/api/entries/search', { - anime: true, + anime: category === 'anime', query: query.query, }); if (!response.ok) return response; diff --git a/src/core/services/config-hot-reload.test.ts b/src/core/services/config-hot-reload.test.ts index 2628e103..6391bce0 100644 --- a/src/core/services/config-hot-reload.test.ts +++ b/src/core/services/config-hot-reload.test.ts @@ -1,12 +1,47 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { DEFAULT_CONFIG, deepCloneConfig } from '../../config'; +import { buildConfigSettingsRegistry, getConfigValueAtPath } from '../../config/settings/registry'; import { classifyConfigHotReloadDiff, createConfigHotReloadRuntime, type ConfigHotReloadRuntimeDeps, } from './config-hot-reload'; +test('every LIVE settings field is classified without a restart warning', () => { + for (const field of buildConfigSettingsRegistry(DEFAULT_CONFIG)) { + if (field.restartBehavior !== 'hot-reload') continue; + const next = deepCloneConfig(DEFAULT_CONFIG); + const segments = field.configPath.split('.'); + const leaf = segments.pop(); + assert.ok(leaf); + const parent = segments.length ? getConfigValueAtPath(next, segments.join('.')) : next; + assert.ok(parent && typeof parent === 'object', field.configPath); + // The classifier compares structure; validation of field values is tested separately. + Object.defineProperty(parent, leaf, { + value: getConfigValueAtPath(next, field.configPath) === null ? 'changed' : null, + enumerable: true, + }); + const diff = classifyConfigHotReloadDiff(DEFAULT_CONFIG, next); + assert.deepEqual(diff.restartRequiredFields, [], field.configPath); + assert.ok(diff.hotReloadFields.length > 0, field.configPath); + } +}); + +test('live notifications and subtitle generation changes preserve unrelated restart warnings', () => { + const next = deepCloneConfig(DEFAULT_CONFIG); + next.notifications.overlayPosition = 'top'; + next.subtitleGeneration.threads += 1; + next.websocket.port += 1; + + const diff = classifyConfigHotReloadDiff(DEFAULT_CONFIG, next); + assert.deepEqual( + new Set(diff.hotReloadFields), + new Set(['notifications.overlayPosition', 'subtitleGeneration.threads']), + ); + assert.deepEqual(diff.restartRequiredFields, ['websocket.port']); +}); + test('classifyConfigHotReloadDiff separates hot and restart-required fields', () => { const prev = deepCloneConfig(DEFAULT_CONFIG); const next = deepCloneConfig(DEFAULT_CONFIG); @@ -15,7 +50,7 @@ test('classifyConfigHotReloadDiff separates hot and restart-required fields', () const diff = classifyConfigHotReloadDiff(prev, next); assert.deepEqual(diff.hotReloadFields, ['subtitleStyle']); - assert.deepEqual(diff.restartRequiredFields, ['websocket']); + assert.deepEqual(diff.restartRequiredFields, ['websocket.port']); }); test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloadable', () => { @@ -101,7 +136,11 @@ test('classifyConfigHotReloadDiff keeps unsafe nested siblings restart-required' const diff = classifyConfigHotReloadDiff(prev, next); assert.deepEqual(diff.hotReloadFields, []); - assert.deepEqual(diff.restartRequiredFields, ['ankiConnect', 'stats']); + assert.deepEqual(diff.restartRequiredFields, [ + 'ankiConnect.url', + 'ankiConnect.ai.model', + 'stats.serverPort', + ]); }); test('classifyConfigHotReloadDiff treats anime Jimaku auto-open as hot-reloadable', () => { @@ -124,7 +163,7 @@ test('classifyConfigHotReloadDiff keeps other anime settings restart-required', const diff = classifyConfigHotReloadDiff(prev, next); assert.deepEqual(diff.hotReloadFields, ['anime.autoOpenJimaku']); - assert.deepEqual(diff.restartRequiredFields, ['anime']); + assert.deepEqual(diff.restartRequiredFields, ['anime.preferredQuality']); }); test('config hot reload runtime debounces rapid watch events', () => { diff --git a/src/core/services/config-hot-reload.ts b/src/core/services/config-hot-reload.ts index 39cef3bc..9cdaed05 100644 --- a/src/core/services/config-hot-reload.ts +++ b/src/core/services/config-hot-reload.ts @@ -1,3 +1,4 @@ +import { getConfigHotReloadField } from '../../config/hot-reload'; import { type ReloadConfigStrictResult } from '../../config'; import type { ConfigValidationWarning } from '../../types'; import type { ResolvedConfig } from '../../types'; @@ -33,10 +34,6 @@ function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === 'object' && !Array.isArray(value); } -function pathStartsWith(path: string, prefix: string): boolean { - return path === prefix || path.startsWith(`${prefix}.`); -} - function collectChangedPaths(prev: unknown, next: unknown, prefix = ''): string[] { if (isEqual(prev, next)) { return []; @@ -52,61 +49,6 @@ function collectChangedPaths(prev: unknown, next: unknown, prefix = ''): string[ ); } -const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitleSidebar'] as const; - -const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [ - 'secondarySub.defaultMode', - 'mpv.aniskipEnabled', - 'mpv.aniskipButtonKey', - 'ankiConnect.ai.enabled', - 'stats.toggleKey', - 'stats.markWatchedKey', - 'logging.level', - 'logging.rotation', - 'logging.files', - 'youtube.primarySubLanguages', - 'anime.autoOpenJimaku', - 'jimaku', - 'subsync', - 'ankiConnect.deck', - 'ankiConnect.media.normalizeAudio', - 'ankiConnect.media.mirrorMpvVolume', - 'ankiConnect.media.reviewTiming', - 'ankiConnect.behavior.autoUpdateNewCards', - 'ankiConnect.knownWords.highlightEnabled', - 'ankiConnect.knownWords.refreshMinutes', - 'ankiConnect.knownWords.addMinedWordsImmediately', - 'ankiConnect.knownWords.matchMode', - 'ankiConnect.knownWords.decks', - 'ankiConnect.nPlusOne.enabled', - 'ankiConnect.nPlusOne.minSentenceWords', - 'ankiConnect.fields.word', - 'ankiConnect.fields.audio', - 'ankiConnect.fields.image', - 'ankiConnect.fields.sentence', - 'ankiConnect.fields.miscInfo', - 'ankiConnect.isLapis.sentenceCardModel', - 'ankiConnect.isKiku.fieldGrouping', - 'ankiConnect.isSenren.fieldGrouping', - 'ankiConnect.lapisKiku.wordCardKind', -] as const; - -function hotReloadFieldForChangedPath(path: string): string | null { - for (const root of HOT_RELOAD_ROOTS) { - if (pathStartsWith(path, root)) { - return root; - } - } - - for (const hotPath of HOT_RELOAD_EXACT_OR_PREFIX_PATHS) { - if (pathStartsWith(path, hotPath)) { - return hotPath === 'jimaku' || hotPath === 'subsync' ? path : hotPath; - } - } - - return null; -} - function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotReloadDiff { const hotReloadFields: string[] = []; const restartRequiredFields: string[] = []; @@ -114,33 +56,11 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo const changedPaths = collectChangedPaths(prev, next); for (const path of changedPaths) { - const hotReloadField = hotReloadFieldForChangedPath(path); + const hotReloadField = getConfigHotReloadField(path); if (hotReloadField) { hotReloadFieldSet.add(hotReloadField); - } - } - - const keys = new Set([ - ...(Object.keys(prev) as Array<keyof ResolvedConfig>), - ...(Object.keys(next) as Array<keyof ResolvedConfig>), - ]); - - for (const key of keys) { - if ( - key === 'subtitleStyle' || - key === 'keybindings' || - key === 'shortcuts' || - key === 'subtitleSidebar' - ) { - continue; - } - - const changedPathsForKey = changedPaths.filter((path) => pathStartsWith(path, String(key))); - const hasRestartRequiredChange = changedPathsForKey.some( - (path) => !hotReloadFieldForChangedPath(path), - ); - if (hasRestartRequiredChange) { - restartRequiredFields.push(String(key)); + } else { + restartRequiredFields.push(path); } } diff --git a/src/core/services/discord-presence.test.ts b/src/core/services/discord-presence.test.ts index 4932555f..57149b46 100644 --- a/src/core/services/discord-presence.test.ts +++ b/src/core/services/discord-presence.test.ts @@ -91,6 +91,21 @@ test('buildDiscordPresenceActivity shows media title regardless of style', () => } }); +test('buildDiscordPresenceActivity rejects stream URLs supplied as titles', () => { + for (const mediaTitle of [ + 'https://example.com/stream?api_key=test-secret', + 'stream?api_key=test-secret', + ]) { + const activity = buildDiscordPresenceActivity(baseConfig, { + ...baseSnapshot, + mediaPath: 'https://example.com/stream?api_key=test-secret', + mediaTitle, + }); + assert.equal(activity.details, 'Unknown media'); + assert.equal(JSON.stringify(activity).includes('test-secret'), false); + } +}); + test('buildDiscordPresenceActivity never falls back to remote stream URLs', () => { const payload = buildDiscordPresenceActivity(baseConfig, { ...baseSnapshot, diff --git a/src/core/services/discord-presence.ts b/src/core/services/discord-presence.ts index 991eb2c4..9cf3859a 100644 --- a/src/core/services/discord-presence.ts +++ b/src/core/services/discord-presence.ts @@ -1,5 +1,6 @@ import type { DiscordPresenceStylePreset } from '../../types/integrations'; import type { ResolvedConfig } from '../../types'; +import { sanitizeMediaTitle } from '../../shared/media-identity'; export interface DiscordPresenceSnapshot { mediaTitle: string | null; @@ -140,7 +141,7 @@ export function buildDiscordPresenceActivity( const style = resolvePresenceStyle(config.presenceStyle); const status = buildStatus(snapshot); const title = sanitizeText( - snapshot.mediaTitle, + sanitizeMediaTitle(snapshot.mediaTitle), fallbackTitleFromMediaPath(snapshot.mediaPath) || 'Unknown media', ); const details = diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index 8693b657..916c197f 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -3060,7 +3060,27 @@ test('startup repairs existing Jellyfin stream video links to metadata rows', as const titledStreamUrl = 'http://jellyfin.local/Videos/item-10/stream?static=true&api_key=secret-token&MediaSourceId=ms-2'; tracker.handleMediaChange(titledStreamUrl, 'KonoSuba S01E06 Decision! Class Rep'); + tracker.handleMediaTitleUpdate('stream?static=true&api_key=secret-token'); tracker.handleMediaChange(null, null); + // Safety must hold before metadata registration or a startup repair can run. + const liveDb = (tracker as unknown as { db: DatabaseSync }).db; + const persistedRows = liveDb.prepare('SELECT * FROM imm_videos').all(); + assert.equal(JSON.stringify(persistedRows).includes('secret-token'), false); + assert.equal(JSON.stringify(persistedRows).includes('/stream'), false); + // Recreate the old on-disk representation to retain coverage of startup repair. + liveDb + .prepare( + 'UPDATE imm_videos SET video_key = ?, source_url = ?, canonical_title = ? WHERE source_url = ?', + ) + .run( + `remote:${streamUrl}`, + streamUrl, + 'stream?static=true&api_key=secret-token', + 'jellyfin://jellyfin.local/item/item-9', + ); + liveDb + .prepare('UPDATE imm_videos SET video_key = ?, source_url = ? WHERE source_url = ?') + .run(`remote:${titledStreamUrl}`, titledStreamUrl, 'jellyfin://jellyfin.local/item/item-10'); tracker.recordJellyfinPlaybackMetadata({ mediaPath: 'http://jellyfin.local/Videos/item-9/stream?static=true&api_key=secret-token', displayTitle: 'Frieren S01E09 Aura the Guillotine', @@ -3162,6 +3182,90 @@ test('startup repairs existing Jellyfin stream video links to metadata rows', as } }); +test('startup clears leaked parser metadata on safely titled anime without changing assignments', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + tracker.recordJellyfinPlaybackMetadata({ + mediaPath: 'https://jellyfin.example/Videos/item/stream?api_key=test-secret', + displayTitle: 'My Anime S01E01', + itemTitle: 'Episode 1', + seriesTitle: 'My Anime', + seasonNumber: 1, + episodeNumber: 1, + itemId: 'item', + }); + const db = (tracker as unknown as { db: DatabaseSync }).db; + db.prepare('UPDATE imm_anime SET metadata_json = ?').run( + JSON.stringify({ + filename: 'stream?api_key=test-secret', + source: 'guessit', + }), + ); + const before = db.prepare('SELECT video_id, anime_id FROM imm_videos').all(); + tracker.destroy(); + tracker = new Ctor({ dbPath }); + const repairedDb = (tracker as unknown as { db: DatabaseSync }).db; + assert.deepEqual(repairedDb.prepare('SELECT video_id, anime_id FROM imm_videos').all(), before); + assert.deepEqual( + repairedDb.prepare('SELECT canonical_title, metadata_json FROM imm_anime').all(), + [{ canonical_title: 'My Anime Season 1', metadata_json: null }], + ); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +test('Jellyfin metadata cleanup requires both an API key and a stream marker', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + const db = (tracker as unknown as { db: DatabaseSync }).db; + const timestamp = toDbTimestamp(trackerNowMs()); + const cases = [ + { filename: 'stream?api_key=secret', leaked: true }, + { filename: '/STREAM?API_KEY=secret', leaked: true }, + { filename: '/Videos/item?api_key=secret', leaked: true }, + { filename: 'MediaSourceId=item api key secret', leaked: true }, + { filename: 'An API Key Story', leaked: false }, + { filename: 'api_key=ordinary-metadata', leaked: false }, + { filename: 'stream?quality=high', leaked: false }, + { filename: '/Videos/item', leaked: false }, + { filename: 'MediaSourceId=item', leaked: false }, + ]; + for (const [index, entry] of cases.entries()) { + db.prepare( + ` + INSERT INTO imm_anime ( + normalized_title_key, canonical_title, metadata_json, CREATED_DATE, LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?) + `, + ).run( + `show-${index}`, + `Show ${index}`, + JSON.stringify({ filename: entry.filename }), + timestamp, + timestamp, + ); + } + repairJellyfinStreamVideoLinks(db); + assert.deepEqual( + db.prepare('SELECT metadata_json FROM imm_anime ORDER BY anime_id').all(), + cases.map(({ filename, leaked }) => ({ + metadata_json: leaked ? null : JSON.stringify({ filename }), + })), + ); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('Jellyfin link repair removes merged leaked anime rows and sanitizes orphan video titles', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index fb0d8383..fdce8927 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import * as fs from 'node:fs'; import { createLogger } from '../../logger'; +import { sanitizeMediaTitle, toMediaIdentityPath } from '../../shared/media-identity'; import { MediaGenerator } from '../../media-generator'; import type { CoverArtFetcher } from './anilist/cover-art-fetcher'; import { getLocalVideoMetadata, guessAnimeVideoMetadata } from './immersion-tracker/metadata'; @@ -395,7 +396,7 @@ function normalizeMetadataInt(value: number | null | undefined): number | null { function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string { const normalizedItemId = normalizeText(itemId); if (!normalizedItemId) { - return mediaPath; + return toMediaIdentityPath(mediaPath); } try { const parsed = new URL(mediaPath); @@ -1660,11 +1661,11 @@ export class ImmersionTrackerService { } const statsPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId); const displayTitle = - normalizeText(metadata.displayTitle) || - normalizeText(metadata.itemTitle) || + normalizeText(sanitizeMediaTitle(metadata.displayTitle)) || + normalizeText(sanitizeMediaTitle(metadata.itemTitle)) || deriveCanonicalTitle(statsPath); - const itemTitle = normalizeText(metadata.itemTitle) || displayTitle; - const seriesTitle = normalizeText(metadata.seriesTitle); + const itemTitle = normalizeText(sanitizeMediaTitle(metadata.itemTitle)) || displayTitle; + const seriesTitle = normalizeText(sanitizeMediaTitle(metadata.seriesTitle)); const libraryTitle = seriesTitle || itemTitle; const seasonNumber = normalizeMetadataInt(metadata.seasonNumber); const episodeNumber = normalizeMetadataInt(metadata.episodeNumber); @@ -1829,8 +1830,8 @@ export class ImmersionTrackerService { const normalizedPath = buildMediaPathAliasCandidates(rawPath) .map((alias) => this.mediaPathAliases.get(alias)) - .find((alias): alias is string => Boolean(alias)) ?? rawPath; - const normalizedTitle = normalizeText(mediaTitle); + .find((alias): alias is string => Boolean(alias)) ?? toMediaIdentityPath(rawPath); + const normalizedTitle = normalizeText(sanitizeMediaTitle(mediaTitle)); this.logger.info( `handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`, ); @@ -1888,7 +1889,7 @@ export class ImmersionTrackerService { handleMediaTitleUpdate(mediaTitle: string | null): void { if (!this.sessionState) return; - const normalizedTitle = normalizeText(mediaTitle); + const normalizedTitle = normalizeText(sanitizeMediaTitle(mediaTitle)); if (!normalizedTitle) return; this.currentVideoKey = normalizedTitle; this.updateVideoTitleForActiveSession(normalizedTitle); diff --git a/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts b/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts index 96250e0b..1dbdac83 100644 --- a/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts +++ b/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts @@ -13,7 +13,11 @@ import { getOrCreateAnimeRecord, linkVideoToAnimeRecord, } from '../storage.js'; -import { mergeAnimeRecords, moveVideoToAnime } from '../anime-merge.js'; +import { + MEDIA_KIND_MISMATCH_MESSAGE, + mergeAnimeRecords, + moveVideoToAnime, +} from '../anime-merge.js'; import { dismissAnimeMergeRecommendation, getAnimeMergeRecommendations, @@ -940,3 +944,36 @@ test('automatic AniList update onto an entry that already links elsewhere does n ); }); }); + +test('merge and move refuse to mix anime entries with YouTube channels', () => { + withDb((db) => { + insertAnime(db, { animeId: 1, key: 'some anime', title: 'Some Anime', anilistId: 555 }); + insertAnime(db, { animeId: 2, key: 'youtube channel uc123', title: 'Channel' }); + db.prepare("UPDATE imm_anime SET media_kind = 'youtube' WHERE anime_id = 2").run(); + insertEpisode(db, { videoId: 10, animeId: 1 }); + insertEpisode(db, { videoId: 20, animeId: 2 }); + + assert.throws(() => mergeAnimeRecords(db, 1, [2]), { message: MEDIA_KIND_MISMATCH_MESSAGE }); + assert.throws(() => mergeAnimeRecords(db, 2, [1]), { message: MEDIA_KIND_MISMATCH_MESSAGE }); + assert.throws(() => moveVideoToAnime(db, 20, 1), { message: MEDIA_KIND_MISMATCH_MESSAGE }); + assert.throws(() => moveVideoToAnime(db, 10, 2), { message: MEDIA_KIND_MISMATCH_MESSAGE }); + + const rows = db + .prepare( + 'SELECT anime_id AS animeId, media_kind AS mediaKind FROM imm_anime ORDER BY anime_id', + ) + .all() as Array<{ animeId: number; mediaKind: string }>; + assert.deepEqual(rows, [ + { animeId: 1, mediaKind: 'anime' }, + { animeId: 2, mediaKind: 'youtube' }, + ]); + assert.equal( + ( + db.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = 20').get() as { + animeId: number; + } + ).animeId, + 2, + ); + }); +}); diff --git a/src/core/services/immersion-tracker/anime-merge-recommendations.ts b/src/core/services/immersion-tracker/anime-merge-recommendations.ts index 6ac0c096..1ad96e97 100644 --- a/src/core/services/immersion-tracker/anime-merge-recommendations.ts +++ b/src/core/services/immersion-tracker/anime-merge-recommendations.ts @@ -27,7 +27,7 @@ function getAnimeTitles(db: DatabaseSync, animeId: number): AnimeTitleRow | null .prepare( `SELECT canonical_title, title_romaji, title_english, title_native FROM imm_anime - WHERE anime_id = ?`, + WHERE anime_id = ? AND media_kind = 'anime'`, ) .get(animeId) as AnimeTitleRow | null; } @@ -77,6 +77,7 @@ export function shouldRecommendAnilistConflict( conflictAnimeId: number, options: AnimeConflictRecommendationOptions, ): boolean { + if (!getAnimeTitles(db, targetAnimeId) || !getAnimeTitles(db, conflictAnimeId)) return false; if (options.survivor === 'target' || options.matchConfidence === 'manual') return false; if ( !animeSeasonsAreMergeCompatible( @@ -99,6 +100,8 @@ export function recordAnimeMergeRecommendation( secondCandidateAnimeId: number, anilistId: number, ): void { + if (!getAnimeTitles(db, firstCandidateAnimeId) || !getAnimeTitles(db, secondCandidateAnimeId)) + return; const firstAnimeId = Math.min(firstCandidateAnimeId, secondCandidateAnimeId); const secondAnimeId = Math.max(firstCandidateAnimeId, secondCandidateAnimeId); const timestamp = toDbTimestamp(nowMs()); @@ -141,6 +144,8 @@ export function getAnimeMergeRecommendations(db: DatabaseSync): AnimeMergeRecomm second_anime_id AS secondAnimeId FROM imm_anime_merge_recommendations WHERE status = 'pending' + AND first_anime_id IN (SELECT anime_id FROM imm_anime WHERE media_kind = 'anime') + AND second_anime_id IN (SELECT anime_id FROM imm_anime WHERE media_kind = 'anime') ORDER BY recommendation_id ASC`, ) .all() as Array<{ diff --git a/src/core/services/immersion-tracker/anime-merge.ts b/src/core/services/immersion-tracker/anime-merge.ts index 7c347297..d85da0ef 100644 --- a/src/core/services/immersion-tracker/anime-merge.ts +++ b/src/core/services/immersion-tracker/anime-merge.ts @@ -1,3 +1,4 @@ +import type { MediaKind } from '../../../shared/media-kind'; import type { DatabaseSync } from './sqlite'; import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime'; import { toDbTimestamp } from './query-shared'; @@ -5,6 +6,8 @@ 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'; export interface AnimeMergeSummary { /** Library entry that owns every moved episode once the merge finishes. */ @@ -60,8 +63,11 @@ function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow .get(animeId) ?? null) as AnimeMetadataRow | null; } -function animeExists(db: DatabaseSync, animeId: number): boolean { - return Boolean(db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId)); +function readMediaKind(db: DatabaseSync, animeId: number): MediaKind | null { + const row = db + .prepare('SELECT media_kind AS mediaKind FROM imm_anime WHERE anime_id = ?') + .get(animeId) as { mediaKind: MediaKind } | undefined; + return row?.mediaKind ?? null; } function hasAnimeReferences(db: DatabaseSync, animeId: number): boolean { @@ -161,7 +167,8 @@ export function mergeAnimeRecordsInTransaction( sourceAnimeIds: number[], ): AnimeMergeSummary { const summary = emptyMergeSummary(targetAnimeId); - if (!animeExists(db, targetAnimeId)) { + const targetKind = readMediaKind(db, targetAnimeId); + if (targetKind === null) { return summary; } @@ -195,8 +202,13 @@ export function mergeAnimeRecordsInTransaction( const dropAnimeStmt = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?'); for (const sourceAnimeId of new Set(sourceAnimeIds)) { - if (sourceAnimeId === targetAnimeId || !animeExists(db, sourceAnimeId)) { - continue; + if (sourceAnimeId === targetAnimeId) continue; + 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) { + throw new Error(MEDIA_KIND_MISMATCH_MESSAGE); } const sourceMetadata = readAnimeMetadata(db, sourceAnimeId); @@ -257,11 +269,15 @@ export function moveVideoToAnime( const videoRow = db .prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?') .get(videoId) as { animeId: number | null } | null; - if (!videoRow || !animeExists(db, targetAnimeId)) { + const targetKind = readMediaKind(db, targetAnimeId); + if (!videoRow || targetKind === null) { throw new Error(UNKNOWN_MOVE_TARGET_MESSAGE); } const previousAnimeId = videoRow.animeId; + if (previousAnimeId !== null && readMediaKind(db, previousAnimeId) !== targetKind) { + throw new Error(MEDIA_KIND_MISMATCH_MESSAGE); + } if (previousAnimeId === targetAnimeId) { db.prepare( 'UPDATE imm_videos SET anime_assignment_locked = 1, LAST_UPDATE_DATE = ? WHERE video_id = ?', diff --git a/src/core/services/immersion-tracker/anime-season-repair.ts b/src/core/services/immersion-tracker/anime-season-repair.ts index ced3ed18..797575c4 100644 --- a/src/core/services/immersion-tracker/anime-season-repair.ts +++ b/src/core/services/immersion-tracker/anime-season-repair.ts @@ -134,7 +134,7 @@ function getAnimeRow(db: DatabaseSync, animeId: number): AnimeRow | null { episodes_total, description FROM imm_anime - WHERE anime_id = ? + WHERE anime_id = ? AND media_kind = 'anime' `, ) .get(animeId) as AnimeRow | null; @@ -335,7 +335,8 @@ export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRe SELECT a.anime_id AS animeId FROM imm_anime a JOIN imm_videos v ON v.anime_id = a.anime_id - WHERE v.parsed_title IS NOT NULL + WHERE a.media_kind = 'anime' + AND v.parsed_title IS NOT NULL AND TRIM(v.parsed_title) != '' AND v.parsed_season IS NOT NULL AND v.parsed_season > 0 @@ -372,6 +373,11 @@ export function resolveAnimeAnilistConflict( anilistId: number, options: AnimeAnilistConflictOptions = {}, ): AnimeSeasonRepairSummary { + if (!getAnimeRow(db, targetAnimeId)) { + const summary = emptySummary(); + summary.anilistAssignmentBlocked = true; + return summary; + } const conflict = db .prepare( ` @@ -387,6 +393,11 @@ export function resolveAnimeAnilistConflict( return emptySummary(); } + if (!getAnimeRow(db, conflict.animeId)) { + const summary = emptySummary(); + summary.anilistAssignmentBlocked = true; + return summary; + } return runInTransaction(db, () => { const targetRow = getAnimeRow(db, targetAnimeId); if ( diff --git a/src/core/services/immersion-tracker/jellyfin-link-repair.ts b/src/core/services/immersion-tracker/jellyfin-link-repair.ts index 0eeed92a..4b499734 100644 --- a/src/core/services/immersion-tracker/jellyfin-link-repair.ts +++ b/src/core/services/immersion-tracker/jellyfin-link-repair.ts @@ -257,6 +257,30 @@ function repairLeakedJellyfinVideoParseMetadata( return updated.changes; } +function repairLeakedJellyfinAnimeParseMetadata( + db: DatabaseSync, + currentTimestamp: string, +): number { + const updated = db + .prepare( + ` + UPDATE imm_anime + SET metadata_json = NULL, LAST_UPDATE_DATE = ? + WHERE ( + metadata_json LIKE '%api_key=%' + OR lower(metadata_json) LIKE '%api key%' + ) AND ( + lower(metadata_json) LIKE '%stream?%' + OR lower(metadata_json) LIKE '%/stream?%' + OR lower(metadata_json) LIKE '%/videos/%' + OR lower(metadata_json) LIKE '%mediasourceid%' + ) + `, + ) + .run(currentTimestamp); + return updated.changes; +} + export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRepairSummary { const candidates = db .prepare( @@ -290,7 +314,8 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe const currentTimestamp = toDbTimestamp(nowMs()); const repaired = repairLeakedJellyfinAnimeTitles(db, currentTimestamp) + - repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp); + repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp) + + repairLeakedJellyfinAnimeParseMetadata(db, currentTimestamp); summary.repaired += repaired; return summary; } @@ -422,6 +447,7 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe } summary.repaired += repairLeakedJellyfinAnimeTitles(db, currentTimestamp); summary.repaired += repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp); + summary.repaired += repairLeakedJellyfinAnimeParseMetadata(db, currentTimestamp); db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); diff --git a/src/core/services/immersion-tracker/metadata.test.ts b/src/core/services/immersion-tracker/metadata.test.ts index c2707532..da3c7c50 100644 --- a/src/core/services/immersion-tracker/metadata.test.ts +++ b/src/core/services/immersion-tracker/metadata.test.ts @@ -147,6 +147,25 @@ test('getLocalVideoMetadata derives title and falls back to null hash on read er assert.equal(hashFallbackMetadata.hashSha256, null); }); +test('stream stats parsing preserves display titles and never persists transport credentials', async () => { + const targets: string[] = []; + const parsed = await guessAnimeVideoMetadata( + 'https://jellyfin.example/Videos/item/stream?api_key=test-secret', + 'Fate/stay night S01E02', + { + runGuessit: async (target) => { + targets.push(target); + return JSON.stringify({ title: 'Fate/stay night', season: 1, episode: 2 }); + }, + }, + ); + assert.deepEqual(targets, ['Fate/stay night S01E02']); + assert.equal(parsed?.parsedBasename, 'Fate/stay night S01E02'); + assert.equal(parsed?.parsedTitle, 'Fate/stay night'); + assert.equal(JSON.stringify(parsed).includes('test-secret'), false); + assert.equal(JSON.stringify(parsed).includes('/stream'), false); +}); + test('guessAnimeVideoMetadata uses guessit basename output first when available', async () => { const seenTargets: string[] = []; const parsed = await guessAnimeVideoMetadata( diff --git a/src/core/services/immersion-tracker/metadata.ts b/src/core/services/immersion-tracker/metadata.ts index 3b09ce07..02868f36 100644 --- a/src/core/services/immersion-tracker/metadata.ts +++ b/src/core/services/immersion-tracker/metadata.ts @@ -3,6 +3,7 @@ import { spawn as nodeSpawn } from 'node:child_process'; import * as fs from 'node:fs'; import path from 'node:path'; import { parseMediaInfo } from '../../../jimaku/utils'; +import { resolveMediaLookupTarget, sanitizeMediaTitle } from '../../../shared/media-identity'; import { guessAnilistMediaInfo, runGuessit, @@ -184,6 +185,7 @@ export async function guessAnimeVideoMetadata( mediaTitle: string | null, deps: GuessAnimeVideoMetadataDeps = {}, ): Promise<ParsedAnimeVideoGuess | null> { + const lookupTarget = resolveMediaLookupTarget(mediaPath, mediaTitle); const parsed = await guessAnilistMediaInfo(mediaPath, mediaTitle, { runGuessit: deps.runGuessit ?? runGuessit, }); @@ -191,7 +193,12 @@ export async function guessAnimeVideoMetadata( return null; } - const parsedBasename = mediaPath ? path.basename(mediaPath) : null; + const parsedBasename = + lookupTarget === sanitizeMediaTitle(mediaTitle) + ? lookupTarget + : lookupTarget + ? path.basename(lookupTarget) + : null; if (parsed.source === 'guessit') { return { parsedBasename, @@ -207,7 +214,7 @@ export async function guessAnimeVideoMetadata( }; } - const fallbackInfo = parseMediaInfo(mediaPath ?? mediaTitle); + const fallbackInfo = parseMediaInfo(lookupTarget); return { parsedBasename: parsedBasename ?? fallbackInfo.filename ?? null, parsedTitle: parsed.title, diff --git a/src/core/services/immersion-tracker/query-library.ts b/src/core/services/immersion-tracker/query-library.ts index 69240462..cb8f5736 100644 --- a/src/core/services/immersion-tracker/query-library.ts +++ b/src/core/services/immersion-tracker/query-library.ts @@ -33,6 +33,7 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] { SELECT a.anime_id AS animeId, a.canonical_title AS canonicalTitle, + a.media_kind AS mediaKind, a.anilist_id AS anilistId, COALESCE(lm.total_sessions, 0) AS totalSessions, COALESCE(lm.total_active_ms, 0) AS totalActiveMs, @@ -63,6 +64,7 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo SELECT a.anime_id AS animeId, a.canonical_title AS canonicalTitle, + a.media_kind AS mediaKind, a.anilist_id AS anilistId, a.title_romaji AS titleRomaji, a.title_english AS titleEnglish, diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts index d04b3cbd..87e6ac1c 100644 --- a/src/core/services/immersion-tracker/storage.ts +++ b/src/core/services/immersion-tracker/storage.ts @@ -1,3 +1,4 @@ +import type { MediaKind } from '../../../shared/media-kind'; import { createHash } from 'node:crypto'; import path from 'node:path'; import { parseMediaInfo } from '../../../jimaku/utils'; @@ -24,6 +25,7 @@ export interface TrackerPreparedStatements { } export interface AnimeRecordInput { + mediaKind?: MediaKind; parsedTitle: string; canonicalTitle: string; seasonScope?: number | null; @@ -569,6 +571,8 @@ function ensureSubtitleLineEventIndex(db: DatabaseSync): void { } export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput): number { + const mediaKind = input.mediaKind ?? 'anime'; + const anilistId = mediaKind === 'anime' ? input.anilistId : null; const seasonScope = normalizeSeasonScope(input.seasonScope); const identityTitle = buildSeasonScopedAnimeTitle(input.parsedTitle, seasonScope); const canonicalTitle = @@ -580,17 +584,23 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput } const byAnilistId = - input.anilistId !== null - ? (db.prepare('SELECT anime_id FROM imm_anime WHERE anilist_id = ?').get(input.anilistId) as { + anilistId !== null + ? (db + .prepare("SELECT anime_id FROM imm_anime WHERE anilist_id = ? AND media_kind = 'anime'") + .get(anilistId) as { anime_id: number; } | null) : null; const byNormalizedTitle = db - .prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?') - .get(normalizedTitleKey) as { anime_id: number } | null; + .prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?') + .get(normalizedTitleKey, mediaKind) as { anime_id: number } | null; const byTitleAlias = db - .prepare('SELECT anime_id FROM imm_anime_title_aliases WHERE normalized_title_key = ?') - .get(normalizedTitleKey) as { anime_id: number } | null; + .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 = ?`, + ) + .get(normalizedTitleKey, mediaKind) as { anime_id: number } | null; const existing = byAnilistId ?? byNormalizedTitle ?? byTitleAlias; if (existing?.anime_id) { // An alias remembers an intentionally merged-away spelling. Reusing it @@ -601,7 +611,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput UPDATE imm_anime SET canonical_title = COALESCE(NULLIF(?, ''), canonical_title), - anilist_id = COALESCE(?, anilist_id), + anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE COALESCE(?, anilist_id) END, title_romaji = COALESCE(?, title_romaji), title_english = COALESCE(?, title_english), title_native = COALESCE(?, title_native), @@ -611,7 +621,8 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput `, ).run( canonicalTitleUpdate, - input.anilistId, + mediaKind, + anilistId, input.titleRomaji, input.titleEnglish, input.titleNative, @@ -627,6 +638,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput .prepare( ` INSERT INTO imm_anime( + media_kind, normalized_title_key, canonical_title, anilist_id, @@ -636,13 +648,14 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput metadata_json, CREATED_DATE, LAST_UPDATE_DATE - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( + input.mediaKind ?? 'anime', normalizedTitleKey, canonicalTitle, - input.anilistId, + anilistId, input.titleRomaji, input.titleEnglish, input.titleNative, @@ -803,6 +816,7 @@ export function linkYoutubeVideoToAnimeRecord( } const animeId = getOrCreateAnimeRecord(db, { + mediaKind: 'youtube', parsedTitle: identity.parsedTitle, canonicalTitle: identity.canonicalTitle, anilistId: null, @@ -875,6 +889,70 @@ 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 { + 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)) { + 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'") + .get() as { seq: number } | null; + db.exec('PRAGMA foreign_keys = OFF'); + try { + db.exec('BEGIN IMMEDIATE'); + db.exec( + schema.sql + .replace( + /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', + ), + ); + db.exec(`INSERT INTO imm_anime_new SELECT * FROM imm_anime; + DROP TABLE imm_anime; + ALTER TABLE imm_anime_new RENAME TO imm_anime;`); + if (sequence) { + db.prepare("UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = 'imm_anime'").run( + sequence.seq, + ); + } + db.exec('COMMIT'); + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } finally { + 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)`); +} + +// Older builds can create channel rows with the default anime kind even after +// the schema upgrade. Repair classification on every startup without moving videos. +function classifyYoutubeChannels(db: DatabaseSync): void { + db.exec(` + UPDATE imm_anime + SET media_kind = 'youtube', anilist_id = NULL + WHERE media_kind = 'anime' + AND NOT EXISTS (SELECT 1 FROM imm_anime AS channel + WHERE channel.media_kind = 'youtube' + AND channel.normalized_title_key = imm_anime.normalized_title_key) + AND ( + normalized_title_key LIKE 'youtube channel %' + OR CASE WHEN json_valid(metadata_json) + THEN json_extract(metadata_json, '$.source') = 'youtube-channel' + ELSE 0 END + ) + `); +} + export function ensureSchema(db: DatabaseSync): void { db.exec(` CREATE TABLE IF NOT EXISTS imm_schema_version ( @@ -897,6 +975,7 @@ export function ensureSchema(db: DatabaseSync): void { .prepare('SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1') .get() as { schema_version: number } | null; if (currentVersion?.schema_version === SCHEMA_VERSION) { + classifyYoutubeChannels(db); ensureLexicalDailyRollupTables(db); ensureLifetimeSummaryTables(db); ensureStatsExcludedWordsTable(db); @@ -908,7 +987,7 @@ export function ensureSchema(db: DatabaseSync): void { db.exec(` CREATE TABLE IF NOT EXISTS imm_anime( anime_id INTEGER PRIMARY KEY AUTOINCREMENT, - normalized_title_key TEXT NOT NULL UNIQUE, + normalized_title_key TEXT NOT NULL, canonical_title TEXT NOT NULL, anilist_id INTEGER UNIQUE, title_romaji TEXT, @@ -921,6 +1000,12 @@ export function ensureSchema(db: DatabaseSync): void { LAST_UPDATE_DATE TEXT ); `); + addColumnIfMissing( + db, + 'imm_anime', + 'media_kind', + "TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))", + ); db.exec(` CREATE TABLE IF NOT EXISTS imm_videos( video_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1464,6 +1549,8 @@ export function ensureSchema(db: DatabaseSync): void { ); } + migrateAnimeTitleUniqueness(db); + classifyYoutubeChannels(db); migrateSessionEventTimestampsToText(db); ensureLexicalDailyRollupTables(db); diff --git a/src/core/services/immersion-tracker/types.ts b/src/core/services/immersion-tracker/types.ts index 18d3d59a..4cab88c8 100644 --- a/src/core/services/immersion-tracker/types.ts +++ b/src/core/services/immersion-tracker/types.ts @@ -1,4 +1,6 @@ -export const SCHEMA_VERSION = 23; +import type { MediaKind } from '../../../shared/media-kind'; + +export const SCHEMA_VERSION = 25; export const DEFAULT_QUEUE_CAP = 1_000; export const DEFAULT_BATCH_SIZE = 25; export const DEFAULT_FLUSH_INTERVAL_MS = 500; @@ -518,6 +520,7 @@ export interface YoutubeVideoMetadata { } export interface AnimeLibraryRow { + mediaKind: MediaKind; animeId: number; canonicalTitle: string; anilistId: number | null; @@ -531,6 +534,7 @@ export interface AnimeLibraryRow { } export interface AnimeDetailRow { + mediaKind: MediaKind; animeId: number; canonicalTitle: string; anilistId: number | null; diff --git a/src/core/services/immersion-tracker/youtube-kind.test.ts b/src/core/services/immersion-tracker/youtube-kind.test.ts new file mode 100644 index 00000000..8da915f1 --- /dev/null +++ b/src/core/services/immersion-tracker/youtube-kind.test.ts @@ -0,0 +1,310 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Database, type DatabaseSync } from './sqlite'; +import { + ensureSchema, + getOrCreateAnimeRecord, + getOrCreateVideoRecord, + linkYoutubeVideoToAnimeRecord, +} from './storage'; +import { getAnimeDetail, getAnimeLibrary } from './query-library'; +import { updateAnimeAnilistInfo } from './query-maintenance'; +import { + repairLegacySeasonlessAnimeRows, + resolveAnimeAnilistConflict, +} from './anime-season-repair'; +import { + getAnimeMergeRecommendations, + recordAnimeMergeRecommendation, +} from './anime-merge-recommendations'; +import { createCoverArtFetcher } from '../anilist/cover-art-fetcher'; +import { SCHEMA_VERSION, SOURCE_TYPE_REMOTE, type YoutubeVideoMetadata } from './types'; + +const metadata: YoutubeVideoMetadata = { + youtubeVideoId: 'video1', + videoUrl: 'https://www.youtube.com/watch?v=video1', + videoTitle: 'Video title', + videoThumbnailUrl: null, + channelId: 'UC123', + channelName: 'Channel name', + channelUrl: 'https://www.youtube.com/channel/UC123', + channelThumbnailUrl: null, + uploaderId: null, + uploaderUrl: null, + description: null, + metadataJson: null, +}; + +function createVideo(db: DatabaseSync, key: string): number { + return getOrCreateVideoRecord(db, key, { + canonicalTitle: 'Video title', + sourcePath: null, + sourceUrl: metadata.videoUrl, + sourceType: SOURCE_TYPE_REMOTE, + }); +} + +function createAnime( + db: DatabaseSync, + parsedTitle: string, + metadataJson: string | null = null, +): number { + return getOrCreateAnimeRecord(db, { + parsedTitle, + canonicalTitle: parsedTitle, + metadataJson, + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + }); +} + +test('schema 23 channel migration preserves history and manual assignments and is idempotent', () => { + const db = new Database(':memory:'); + try { + ensureSchema(db); + const ids = [ + createAnime(db, 'youtube-channel:UC123'), + createAnime(db, 'youtube-channel-url:https://www.youtube.com/@creator'), + createAnime(db, 'youtube-channel-name:Creator'), + createAnime(db, 'Renamed channel', '{ "source": "youtube-channel" }'), + ]; + const animeId = createAnime(db, 'Anime title', 'legacy non-JSON metadata'); + const videoId = createVideo(db, 'manual'); + db.prepare( + 'UPDATE imm_videos SET anime_id = ?, anime_assignment_locked = 1 WHERE video_id = ?', + ).run(animeId, videoId); + db.prepare( + 'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 123456, 7)', + ).run(animeId); + 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)', + ); + ensureSchema(db); + ensureSchema(db); + for (const id of ids) { + const row = db.prepare('SELECT media_kind FROM imm_anime WHERE anime_id = ?').get(id); + assert.ok(row && typeof row === 'object' && 'media_kind' in row); + assert.equal(row.media_kind, 'youtube'); + } + assert.deepEqual(getAnimeLibrary(db), history); + assert.equal(linkYoutubeVideoToAnimeRecord(db, videoId, metadata), animeId); + const version = db + .prepare('SELECT MAX(schema_version) AS version FROM imm_schema_version') + .get(); + assert.ok(version && typeof version === 'object' && 'version' in version); + assert.equal(version.version, SCHEMA_VERSION); + } finally { + db.close(); + } +}); + +test('channel creation and repeated linking expose youtube in library and detail without losing totals', async () => { + const db = new Database(':memory:'); + try { + ensureSchema(db); + const videoId = createVideo(db, 'first'); + const channelId = linkYoutubeVideoToAnimeRecord(db, videoId, metadata); + assert.ok(channelId); + const secondVideoId = createVideo(db, 'second'); + assert.equal(linkYoutubeVideoToAnimeRecord(db, secondVideoId, metadata), channelId); + db.prepare( + 'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 123456, 7)', + ).run(channelId); + assert.equal(getAnimeLibrary(db)[0]?.mediaKind, 'youtube'); + const detail = getAnimeDetail(db, channelId); + assert.equal(detail?.mediaKind, 'youtube'); + assert.equal(detail?.episodeCount, 2); + assert.equal(detail?.totalActiveMs, 123456); + assert.equal(detail?.totalCards, 7); + + // Even parsed season numbers and a matching anime name must not trigger repairs. + db.prepare('UPDATE imm_videos SET parsed_season = video_id').run(); + assert.equal(repairLegacySeasonlessAnimeRows(db).repaired, 0); + const animeId = createAnime(db, 'Channel name'); + for (const matchConfidence of ['exact', 'weak', 'manual'] as const) { + assert.equal( + resolveAnimeAnilistConflict(db, channelId, 123, { matchConfidence }) + .anilistAssignmentBlocked, + true, + ); + } + updateAnimeAnilistInfo(db, videoId, { + anilistId: 123, + titleRomaji: 'Wrong title', + titleEnglish: null, + titleNative: null, + episodesTotal: 12, + }); + recordAnimeMergeRecommendation(db, channelId, animeId, 123); + assert.deepEqual(getAnimeMergeRecommendations(db), []); + assert.equal(getAnimeDetail(db, channelId)?.anilistId, null); + const fetcher = createCoverArtFetcher( + { + acquire: async () => { + assert.fail('YouTube must not query AniList'); + }, + recordResponse: () => {}, + }, + console, + ); + assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Channel name'), false); + } finally { + db.close(); + } +}); + +test('startup reclassifies channels created by an older build after the schema upgrade', () => { + const db = new Database(':memory:'); + try { + ensureSchema(db); + // An old build omits media_kind when creating a channel in the upgraded DB. + const channelId = createAnime(db, 'youtube-channel:UCnew'); + db.prepare('UPDATE imm_anime SET anilist_id = 321 WHERE anime_id = ?').run(channelId); + const animeId = createAnime(db, 'Regular anime'); + const videoId = createVideo(db, 'older-build'); + db.prepare( + 'UPDATE imm_videos SET anime_id = ?, anime_assignment_locked = 1 WHERE video_id = ?', + ).run(channelId, videoId); + db.prepare( + 'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 120000, 5)', + ).run(channelId); + assert.equal(getAnimeLibrary(db)[0]?.mediaKind, 'anime'); + ensureSchema(db); + const channel = getAnimeLibrary(db)[0]; + assert.equal(channel?.animeId, channelId); + assert.equal(channel?.mediaKind, 'youtube'); + assert.equal( + ( + db.prepare('SELECT anilist_id FROM imm_anime WHERE anime_id = ?').get(channelId) as { + anilist_id: number | null; + } + ).anilist_id, + null, + ); + assert.equal(channel?.totalActiveMs, 120000); + assert.equal(channel?.totalCards, 5); + assert.equal(linkYoutubeVideoToAnimeRecord(db, videoId, metadata), channelId); + const anime = db.prepare('SELECT media_kind FROM imm_anime WHERE anime_id = ?').get(animeId); + assert.ok(anime && typeof anime === 'object' && 'media_kind' in anime); + assert.equal(anime.media_kind, 'anime'); + } finally { + db.close(); + } +}); + +test('title identity and aliases never cross media kinds', () => { + const db = new Database(':memory:'); + try { + ensureSchema(db); + const animeId = createAnime(db, 'Shared title'); + const input = { + mediaKind: 'youtube' as const, + parsedTitle: 'Shared title', + canonicalTitle: 'Shared title', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }; + const channelId = getOrCreateAnimeRecord(db, input); + assert.notEqual(channelId, animeId); + db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = ?').run(channelId); + assert.equal(getOrCreateAnimeRecord(db, input), channelId); + assert.equal( + ( + db.prepare('SELECT anilist_id FROM imm_anime WHERE anime_id = ?').get(channelId) as { + anilist_id: number | null; + } + ).anilist_id, + null, + ); + assert.equal(createAnime(db, 'Shared title'), animeId); + db.prepare( + 'INSERT INTO imm_anime_title_aliases(normalized_title_key, anime_id) VALUES (?, ?)', + ).run('alias title', animeId); + assert.notEqual(getOrCreateAnimeRecord(db, { ...input, parsedTitle: 'Alias title' }), animeId); + assert.throws(() => + db + .prepare( + "INSERT INTO imm_anime(normalized_title_key, canonical_title, media_kind) VALUES ('shared title', 'duplicate', 'youtube')", + ) + .run(), + ); + } finally { + db.close(); + } +}); + +test('schema 24 title constraint migration preserves referenced data', () => { + const db = new Database(':memory:'); + try { + // Reproduce the original table-level uniqueness constraint. + db.exec(`CREATE TABLE imm_anime( + anime_id INTEGER PRIMARY KEY AUTOINCREMENT, + normalized_title_key TEXT NOT NULL UNIQUE, + canonical_title TEXT NOT NULL, + anilist_id INTEGER UNIQUE, + 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')) + )`); + ensureSchema(db); + const animeId = createAnime(db, 'Shared title'); + const videoId = createVideo(db, 'migration-video'); + db.prepare( + 'UPDATE imm_videos SET anime_id = ?, anime_assignment_locked = 1 WHERE video_id = ?', + ).run(animeId, videoId); + db.prepare( + 'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 123, 4)', + ).run(animeId); + // Restore the old constraint while leaving child rows populated. + db.exec(`PRAGMA foreign_keys = OFF; + CREATE TABLE old_anime AS SELECT * FROM imm_anime; + DROP TABLE imm_anime; + CREATE TABLE imm_anime( + anime_id INTEGER PRIMARY KEY AUTOINCREMENT, normalized_title_key TEXT NOT NULL UNIQUE, + canonical_title TEXT NOT NULL, anilist_id INTEGER UNIQUE, + 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; + DROP TABLE old_anime; + DELETE FROM imm_schema_version; + INSERT INTO imm_schema_version VALUES (24, 0); + PRAGMA foreign_keys = ON;`); + ensureSchema(db); + ensureSchema(db); + assert.deepEqual(db.prepare('PRAGMA foreign_key_check').all(), []); + assert.equal( + (db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number }).foreign_keys, + 1, + ); + assert.equal( + ( + db.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?').get(videoId) as { + anime_id: number; + } + ).anime_id, + animeId, + ); + assert.equal( + ( + db + .prepare('SELECT total_active_ms FROM imm_lifetime_anime WHERE anime_id = ?') + .get(animeId) as { total_active_ms: number } + ).total_active_ms, + 123, + ); + db.prepare( + "INSERT INTO imm_anime(normalized_title_key, canonical_title, media_kind) VALUES ('shared title', 'Channel', 'youtube')", + ).run(); + } finally { + db.close(); + } +}); diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 8c2ddeeb..91f16769 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -671,7 +671,34 @@ test('registerIpcHandlers accepts the keep-without-media timing decision', async assert.deepEqual(requests, [{ reviewId: 'review-1', decision: { action: 'skip-media' } }]); }); -test('registerIpcHandlers validates and forwards combined timing review text', async () => { +test('frame IPC validates timestamps and directions', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const requests: unknown[] = []; + registerIpcHandlers( + createRegisterIpcDeps({ + getMediaTimingReviewFrame: async (request) => { + requests.push(request); + return { ok: true }; + }, + }), + registrar, + ); + const frame = handlers.handle.get(IPC_CHANNELS.request.mediaTimingReviewFrame)!; + const valid = { reviewId: 'r', timestamp: 13, direction: 1 }; + assert.deepEqual(await frame({}, valid), { ok: true }); + for (const invalid of [ + null, + {}, + { ...valid, timestamp: NaN }, + { ...valid, timestamp: '13' }, + { ...valid, direction: 2 }, + ]) { + assert.equal(((await frame({}, invalid)) as { ok: boolean }).ok, false); + } + assert.deepEqual(requests, [valid]); +}); + +test('registerIpcHandlers validates and forwards timing review text and screenshot selection', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); const requests: unknown[] = []; registerIpcHandlers( @@ -696,6 +723,7 @@ test('registerIpcHandlers validates and forwards combined timing review text', a startTime: 10, endTime: 12, text: '前の行 対象の行', + screenshotTime: 13, }, }, ), @@ -709,20 +737,23 @@ test('registerIpcHandlers validates and forwards combined timing review text', a startTime: 10, endTime: 12, text: '前の行 対象の行', + screenshotTime: 13, }, }, ]); - assert.deepEqual( - await handler!( - {}, - { - reviewId: 'review-1', - decision: { action: 'confirm', startTime: 10, endTime: 12, text: ' ' }, - }, - ), - { ok: false, message: 'Timing review is unavailable.' }, - ); + for (const invalid of [{ text: ' ' }, { screenshotTime: Infinity }]) { + assert.deepEqual( + await handler!( + {}, + { + reviewId: 'review-1', + decision: { action: 'confirm', startTime: 10, endTime: 12, ...invalid }, + }, + ), + { ok: false, message: 'Timing review is unavailable.' }, + ); + } assert.equal(requests.length, 1); }); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 2a26dac1..ba77264f 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -24,6 +24,8 @@ import type { MediaTimingReviewActionResult, MediaTimingReviewPreviewRequest, MediaTimingReviewResolveRequest, + MediaTimingReviewFrameRequest, + MediaTimingReviewFrameResult, MediaTimingReviewWaveformRequest, MediaTimingReviewWaveformResult, } from '../../types/anki'; @@ -111,6 +113,9 @@ export interface IpcServiceDeps { previewMediaTimingReview?: ( request: MediaTimingReviewPreviewRequest, ) => Promise<MediaTimingReviewActionResult>; + getMediaTimingReviewFrame?: ( + request: MediaTimingReviewFrameRequest, + ) => Promise<MediaTimingReviewFrameResult>; getMediaTimingReviewWaveform?: ( request: MediaTimingReviewWaveformRequest, ) => Promise<MediaTimingReviewWaveformResult>; @@ -269,6 +274,26 @@ function parseMediaTimingReviewWaveformRequest( return parseMediaTimingReviewPreviewRequest(payload); } +function parseMediaTimingReviewFrameRequest( + payload: unknown, +): MediaTimingReviewFrameRequest | null { + if (!payload || typeof payload !== 'object') return null; + const record = payload as Record<string, unknown>; + if ( + typeof record.reviewId !== 'string' || + !record.reviewId || + typeof record.timestamp !== 'number' || + !Number.isFinite(record.timestamp) || + (record.direction !== undefined && record.direction !== -1 && record.direction !== 1) + ) + return null; + return { + reviewId: record.reviewId, + timestamp: record.timestamp, + ...(record.direction !== undefined ? { direction: record.direction as -1 | 1 } : {}), + }; +} + function parseMediaTimingReviewResolveRequest( payload: unknown, ): MediaTimingReviewResolveRequest | null { @@ -291,6 +316,9 @@ function parseMediaTimingReviewResolveRequest( Number.isFinite(decisionRecord.startTime) && typeof decisionRecord.endTime === 'number' && Number.isFinite(decisionRecord.endTime) && + (decisionRecord.screenshotTime === undefined || + (typeof decisionRecord.screenshotTime === 'number' && + Number.isFinite(decisionRecord.screenshotTime))) && (decisionRecord.text === undefined || (typeof decisionRecord.text === 'string' && decisionRecord.text.trim().length > 0)) ) { @@ -300,6 +328,9 @@ function parseMediaTimingReviewResolveRequest( action: 'confirm', startTime: decisionRecord.startTime, endTime: decisionRecord.endTime, + ...(decisionRecord.screenshotTime === undefined + ? {} + : { screenshotTime: decisionRecord.screenshotTime as number }), ...(decisionRecord.text === undefined ? {} : { text: decisionRecord.text }), }, }; @@ -365,6 +396,7 @@ export interface IpcDepsRuntimeOptions { request: YoutubePickerResolveRequest, ) => Promise<YoutubePickerResolveResult>; previewMediaTimingReview?: IpcServiceDeps['previewMediaTimingReview']; + getMediaTimingReviewFrame?: IpcServiceDeps['getMediaTimingReviewFrame']; getMediaTimingReviewWaveform?: IpcServiceDeps['getMediaTimingReviewWaveform']; stopMediaTimingReviewPreview?: IpcServiceDeps['stopMediaTimingReviewPreview']; resolveMediaTimingReview?: IpcServiceDeps['resolveMediaTimingReview']; @@ -463,6 +495,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService runSubsyncManual: options.runSubsyncManual, onYoutubePickerResolve: options.onYoutubePickerResolve, previewMediaTimingReview: options.previewMediaTimingReview, + getMediaTimingReviewFrame: options.getMediaTimingReviewFrame, getMediaTimingReviewWaveform: options.getMediaTimingReviewWaveform, stopMediaTimingReviewPreview: options.stopMediaTimingReviewPreview, resolveMediaTimingReview: options.resolveMediaTimingReview, @@ -613,6 +646,16 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return await deps.getMediaTimingReviewWaveform(request); }, ); + ipc.handle( + IPC_CHANNELS.request.mediaTimingReviewFrame, + async (_event: unknown, payload: unknown) => { + const request = parseMediaTimingReviewFrameRequest(payload); + if (!request || !deps.getMediaTimingReviewFrame) { + return { ok: false, message: 'Screenshot preview is unavailable.' }; + } + return await deps.getMediaTimingReviewFrame(request); + }, + ); ipc.handle( IPC_CHANNELS.request.mediaTimingReviewStopPreview, async (_event: unknown, reviewId: unknown) => { diff --git a/src/core/services/media-timing-frame.test.ts b/src/core/services/media-timing-frame.test.ts new file mode 100644 index 00000000..8818ec10 --- /dev/null +++ b/src/core/services/media-timing-frame.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMediaTimingFrameExtractor, selectMediaTimingFrame } from './media-timing-frame'; + +test('frame stepping follows decoded timestamps with variable frame durations', () => { + const times = [10, 10.041667, 10.125, 10.166667]; + assert.equal(selectMediaTimingFrame(times, 10.05), 10.125); + assert.equal(selectMediaTimingFrame(times, 10.125 - 0.000001, 1), 10.166667); + assert.equal(selectMediaTimingFrame(times, 10.125 - 0.000001, -1), 10.041667); + assert.equal(selectMediaTimingFrame(times, 10, -1), undefined); + assert.equal(selectMediaTimingFrame(times, 10.166667, 1), undefined); + assert.equal(selectMediaTimingFrame([], 10), undefined); +}); + +function fixture(startTime = 0) { + const calls: Array<{ file: string; args: string[] }> = []; + const extractor = createMediaTimingFrameExtractor(async (file, args) => { + calls.push({ file, args }); + if (file === 'ffmpeg') return Buffer.from('image'); + if (args.includes('format=start_time')) + return Buffer.from(JSON.stringify({ format: { start_time: String(startTime) } })); + return Buffer.from( + JSON.stringify({ + frames: [10, 10.04, 10.12, 10.16].map((time) => ({ + best_effort_timestamp_time: String(time + startTime), + })), + }), + ); + }); + return { calls, extractor }; +} + +test('frame extraction normalizes nonzero source start times and reuses the frame index', async () => { + const { calls, extractor } = fixture(5); + const first = await extractor.generate({ media: '/movie.mkv', timestamp: 10.05 }); + assert.ok(Math.abs(first.timestamp - 10.119999) < 0.000001); + assert.equal(first.dataUrl, 'data:image/jpeg;base64,aW1hZ2U='); + await extractor.generate({ media: '/movie.mkv', timestamp: first.timestamp, direction: 1 }); + assert.equal(calls.filter((call) => call.file === 'ffprobe').length, 2); + assert.ok(calls[1]!.args.includes('13.05%17.05')); + extractor.clear(); + await extractor.generate({ media: '/movie.mkv', timestamp: 10.05 }); + assert.equal(calls.filter((call) => call.file === 'ffprobe').length, 4); +}); + +test('cached windows keep source timestamps for ffmpeg and use absolute ffprobe intervals', async () => { + const { calls, extractor } = fixture(); + await extractor.generate({ + media: { path: '/window.mkv', absoluteTimestamps: true }, + timestamp: 10.05, + }); + assert.ok(calls[1]!.args.includes('8.05%12.05')); + assert.equal(calls[1]!.args.includes('-seek_timestamp'), false); + assert.ok(calls[2]!.args.includes('-seek_timestamp')); +}); + +test('remote frame reads carry source headers and do not silently reuse a different input', async () => { + const { calls, extractor } = fixture(); + const media = { + path: 'https://example.test/video', + inputOptions: { headers: { 'X-Emby-Token': 'test-token' } }, + }; + await extractor.generate({ media, timestamp: 10.05 }); + assert.ok(calls.every((call) => call.args.includes('X-Emby-Token: test-token\r\n'))); + await extractor.generate({ + media: { ...media, path: 'https://example.test/other' }, + timestamp: 10.05, + }); + assert.equal(calls.filter((call) => call.file === 'ffprobe').length, 4); +}); diff --git a/src/core/services/media-timing-frame.ts b/src/core/services/media-timing-frame.ts new file mode 100644 index 00000000..9c9e0a11 --- /dev/null +++ b/src/core/services/media-timing-frame.ts @@ -0,0 +1,159 @@ +import { execFile } from 'node:child_process'; +import { normalizeMediaInput, type MediaInput } from '../../media-input'; + +export interface MediaTimingFrameOptions { + media: MediaInput; + timestamp: number; + direction?: -1 | 1; +} + +const FRAME_EPSILON = 0.00001; +const PROBE_RADIUS_SECONDS = 2; + +function run(file: string, args: string[]): Promise<Buffer> { + return new Promise((resolve, reject) => { + execFile( + file, + args, + { encoding: 'buffer', timeout: 30_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true }, + (error, stdout) => { + // Child-process errors contain the input URL, which may contain authentication tokens. + if (error) + reject( + new Error('Screenshot preview is unavailable. Check FFmpeg and the video source.'), + ); + else resolve(stdout); + }, + ); + }); +} + +/** Uses decoded timestamps, rather than an assumed FPS, including for variable-rate video. */ +export function selectMediaTimingFrame( + times: readonly number[], + timestamp: number, + direction?: -1 | 1, +): number | undefined { + if (direction === -1) + return [...times].reverse().find((time) => time < timestamp - FRAME_EPSILON); + if (direction === 1) return times.find((time) => time > timestamp + FRAME_EPSILON); + return times.find((time) => time >= timestamp - FRAME_EPSILON) ?? times.at(-1); +} + +export function createMediaTimingFrameExtractor(execute: typeof run = run) { + let cached: { key: string; start: number; end: number; times: number[] } | null = null; + let source: { key: string; offset: number } | null = null; + let generation = 0; + + async function generate( + options: MediaTimingFrameOptions, + ): Promise<{ dataUrl: string; timestamp: number }> { + const input = normalizeMediaInput(options.media); + const key = JSON.stringify(options.media); + const currentGeneration = generation; + const absolute = typeof options.media !== 'string' && options.media.absoluteTimestamps; + // -seek_timestamp is an ffmpeg option; ffprobe intervals already use stream timestamps. + const probeInputArgs = normalizeMediaInput({ + path: input.path, + ...(typeof options.media !== 'string' ? { inputOptions: options.media.inputOptions } : {}), + }).inputArgs; + let offset = source?.key === key ? source.offset : undefined; + if (offset === undefined) { + const metadata = JSON.parse( + ( + await execute('ffprobe', [ + '-v', + 'error', + ...probeInputArgs, + '-show_entries', + 'format=start_time', + '-of', + 'json', + input.path, + ]) + ).toString(), + ) as { format?: { start_time?: string } }; + const start = Number(metadata.format?.start_time ?? 0); + offset = absolute || !Number.isFinite(start) ? 0 : start; + if (generation === currentGeneration) source = { key, offset }; + } + let times: number[]; + if ( + cached?.key === key && + options.timestamp > cached.start + 0.5 && + options.timestamp < cached.end - 0.5 + ) { + times = cached.times; + } else { + const start = Math.max(0, options.timestamp - PROBE_RADIUS_SECONDS); + const end = options.timestamp + PROBE_RADIUS_SECONDS; + const result = JSON.parse( + ( + await execute('ffprobe', [ + '-v', + 'error', + ...probeInputArgs, + '-read_intervals', + `${start + offset}%${end + offset}`, + '-select_streams', + 'v:0', + '-show_entries', + 'frame=best_effort_timestamp_time', + '-of', + 'json', + input.path, + ]) + ).toString(), + ) as { frames?: { best_effort_timestamp_time?: string }[] }; + times = [ + ...new Set( + (result.frames ?? []) + .map((frame) => Number(frame.best_effort_timestamp_time) - offset) + .filter((time) => Number.isFinite(time) && time >= 0 && time >= start && time <= end), + ), + ].sort((a, b) => a - b); + if (generation === currentGeneration) cached = { key, start, end, times }; + } + const timestamp = selectMediaTimingFrame(times, options.timestamp, options.direction); + if (timestamp === undefined) throw new Error('No adjacent video frame is available here.'); + // Round down by one microsecond so decimal timestamp rounding cannot skip the chosen frame. + const seekTime = Math.max(0, timestamp - 0.000001); + const image = await execute('ffmpeg', [ + '-hide_banner', + '-nostdin', + '-loglevel', + 'error', + '-ss', + String(seekTime), + ...input.inputArgs, + '-i', + input.path, + '-map', + '0:v:0', + '-frames:v', + '1', + '-an', + '-sn', + '-vf', + 'scale=w=640:h=360:force_original_aspect_ratio=decrease', + '-c:v', + 'mjpeg', + '-q:v', + '3', + '-f', + 'image2pipe', + 'pipe:1', + ]); + if (!image.length) throw new Error('No video frame is available here.'); + return { dataUrl: `data:image/jpeg;base64,${image.toString('base64')}`, timestamp: seekTime }; + } + + return { + generate, + clear: () => { + generation += 1; + cached = null; + source = null; + }, + }; +} diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index ce45ad5b..4bda65be 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -1,4 +1,5 @@ import { MpvSubtitleRenderMetrics } from '../../types'; +import { sanitizeMediaTitle } from '../../shared/media-identity'; export type MpvMessage = { event?: string; @@ -337,8 +338,10 @@ export async function dispatchMpvProtocolMessage( } else if (msg.name === 'fullscreen') { deps.emitFullscreenChange({ fullscreen: asBoolean(msg.data, false) }); } else if (msg.name === 'media-title') { + const title = typeof msg.data === 'string' ? sanitizeMediaTitle(msg.data) : null; + if (typeof msg.data === 'string' && msg.data.trim() && !title) return; deps.emitMediaTitleChange({ - title: typeof msg.data === 'string' ? msg.data.trim() : null, + title, }); } else if (msg.name === 'path') { const path = (msg.data as string) || ''; diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index afe6a5bf..be86a33a 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -120,6 +120,21 @@ test('MpvIpcClient emits fullscreen property changes', async () => { assert.deepEqual(events, [{ fullscreen: true }]); }); +test('MpvIpcClient ignores URL-derived titles without replacing known metadata', async () => { + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + const titles: Array<string | null> = []; + client.on('media-title-change', ({ title }) => titles.push(title)); + for (const data of [ + 'My Anime S01E02', + 'https://example.com/stream?api_key=test-secret', + 'stream?api_key=test-secret', + ]) { + await invokeHandleMessage(client, { event: 'property-change', name: 'media-title', data }); + } + assert.equal(client.currentMediaTitle, 'My Anime S01E02'); + assert.deepEqual(titles, ['My Anime S01E02']); +}); + test('MpvIpcClient clears cached media title when media path changes', async () => { const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); diff --git a/src/core/services/stats-server/library-routes.ts b/src/core/services/stats-server/library-routes.ts index f705f1ba..dd3e678a 100644 --- a/src/core/services/stats-server/library-routes.ts +++ b/src/core/services/stats-server/library-routes.ts @@ -1,6 +1,9 @@ import type { Hono } from 'hono'; import { statsJson } from '../../../types/stats-http-contract.js'; -import { UNKNOWN_MOVE_TARGET_MESSAGE } from '../immersion-tracker/anime-merge.js'; +import { + MEDIA_KIND_MISMATCH_MESSAGE, + UNKNOWN_MOVE_TARGET_MESSAGE, +} from '../immersion-tracker/anime-merge.js'; import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; import { buildSentenceSearchOptions, @@ -245,7 +248,15 @@ export function registerStatsLibraryRoutes( const body = await c.req.json().catch(() => null); const sourceAnimeIds = parsePositiveIdList(body?.sourceAnimeIds).filter((id) => id !== animeId); if (sourceAnimeIds.length === 0) return c.body(null, 400); - const summary = await tracker.mergeAnime(animeId, sourceAnimeIds); + 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); + } + throw error; + } // Nothing folded means the target or every source was already gone, so the // caller should not be told the merge succeeded. if (summary.mergedAnimeIds.length === 0) return c.body(null, 404); @@ -281,6 +292,9 @@ export function registerStatsLibraryRoutes( if (error instanceof Error && error.message === UNKNOWN_MOVE_TARGET_MESSAGE) { return c.body(null, 404); } + if (error instanceof Error && error.message === MEDIA_KIND_MISMATCH_MESSAGE) { + return c.text(MEDIA_KIND_MISMATCH_MESSAGE, 409); + } throw error; } }); diff --git a/src/core/services/stats-sync/merge-catalog.ts b/src/core/services/stats-sync/merge-catalog.ts index a58b8d62..692e0c52 100644 --- a/src/core/services/stats-sync/merge-catalog.ts +++ b/src/core/services/stats-sync/merge-catalog.ts @@ -2,6 +2,7 @@ import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver' import { insertRow, tableExists, type SyncMergeSummary } from './shared'; const ANIME_COPY_COLUMNS = [ + 'media_kind', 'normalized_title_key', 'canonical_title', 'anilist_id', @@ -98,11 +99,28 @@ export function mergeAnime( summary: SyncMergeSummary, ): Map<number, number> { const map = new Map<number, number>(); - const byAnilist = local.query('SELECT anime_id FROM imm_anime WHERE anilist_id = ?'); - const byTitleKey = local.query('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?'); + const byAnilist = local.query( + "SELECT anime_id FROM imm_anime WHERE anilist_id = ? AND media_kind = 'anime'", + ); + const byTitleKey = local.query( + 'SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?', + ); + // A pre-classification channel can be repaired, but a genuine anime sharing + // its title must remain a separate entry. + const legacyChannel = local.query(`SELECT anime_id FROM imm_anime + WHERE normalized_title_key = ? AND media_kind = 'anime' AND ( + normalized_title_key LIKE 'youtube channel %' + OR CASE WHEN json_valid(metadata_json) + THEN json_extract(metadata_json, '$.source') = 'youtube-channel' ELSE 0 END + )`); + const releaseChannelAnilistId = local.query( + "UPDATE imm_anime SET anilist_id = NULL WHERE media_kind = 'youtube' AND anilist_id = ?", + ); 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, ?), @@ -116,12 +134,24 @@ export function mergeAnime( `SELECT anime_id, ${ANIME_COPY_COLUMNS.join(', ')} FROM imm_anime`, )) { const remoteId = Number(row.anime_id); - const existing = ((row.anilist_id !== null ? byAnilist.get(row.anilist_id) : undefined) ?? - byTitleKey.get(row.normalized_title_key)) as SqlRow | undefined; + if (row.media_kind === 'anime' && row.anilist_id !== null) { + // AniList identifiers belong to anime, including when an older peer + // incorrectly attached one to a channel. + releaseChannelAnilistId.run(row.anilist_id); + } + 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.media_kind === 'youtube' ? legacyChannel.get(row.normalized_title_key) : undefined)) as + | SqlRow + | undefined; if (existing) { 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, @@ -133,7 +163,9 @@ export function mergeAnime( } // 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) => row[column]); + const values = ANIME_COPY_COLUMNS.map((column) => + column === 'anilist_id' && row.media_kind !== 'anime' ? null : row[column], + ); map.set(remoteId, insertRow(local, 'imm_anime', ANIME_COPY_COLUMNS, values)); summary.animeAdded += 1; } diff --git a/src/core/services/stats-sync/merge-occurrences.test.ts b/src/core/services/stats-sync/merge-occurrences.test.ts index a8dbaffe..efc23760 100644 --- a/src/core/services/stats-sync/merge-occurrences.test.ts +++ b/src/core/services/stats-sync/merge-occurrences.test.ts @@ -98,3 +98,122 @@ for (const legacyOccurrences of [false, true]) { } }); } + +test('sync preserves the YouTube media kind when adding a channel', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-youtube-')); + try { + const localPath = buildDb(dir, 'local.sqlite', { + word: '猫', + seenMs: BASE_MS, + legacyOccurrences: false, + }); + const remotePath = buildDb(dir, 'remote.sqlite', { + word: '犬', + seenMs: BASE_MS, + legacyOccurrences: false, + }); + const remote = new Database(remotePath); + remote.exec("UPDATE imm_anime SET media_kind = 'youtube'"); + remote.close(); + mergeSnapshotIntoDb(localPath, remotePath); + const local = new Database(localPath); + try { + const row = local + .prepare( + "SELECT media_kind FROM imm_anime WHERE normalized_title_key = 'key-remote.sqlite'", + ) + .get(); + assert.ok(row && typeof row === 'object' && 'media_kind' in row); + assert.equal(row.media_kind, 'youtube'); + } finally { + local.close(); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('sync separates same-title kinds and repairs only identifiable legacy channels', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-kinds-')); + const localPath = buildDb(dir, 'local', { + word: 'local', + seenMs: BASE_MS, + legacyOccurrences: false, + }); + const remotePath = buildDb(dir, 'remote', { + word: 'remote', + seenMs: BASE_MS, + legacyOccurrences: false, + }); + try { + const local = new Database(localPath); + local.exec(`UPDATE imm_anime SET normalized_title_key = 'shared', anilist_id = 42; + INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, metadata_json, title_english, LAST_UPDATE_DATE) + VALUES (2, 'legacy', 'Local channel', '{"source":"youtube-channel"}', 'Keep local', '9999999999999'); + UPDATE imm_anime SET anilist_id = 99 WHERE anime_id = 2; + INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, media_kind, anilist_id) + VALUES (3, 'other shared', 'Channel with bad ID', 'youtube', 77);`); + local.close(); + const remote = new Database(remotePath); + remote.exec(`UPDATE imm_anime SET normalized_title_key = 'shared', media_kind = 'youtube', anilist_id = 42; + INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, media_kind, title_english, description, LAST_UPDATE_DATE) + VALUES (2, 'legacy', 'Remote channel', 'youtube', 'Remote title', 'Fill missing', '1'); + INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, media_kind, anilist_id) + VALUES (3, 'other shared', 'Real anime', 'anime', 77);`); + remote.close(); + mergeSnapshotIntoDb(localPath, remotePath); + mergeSnapshotIntoDb(localPath, remotePath); + const db = new Database(localPath); + try { + const rows = db + .prepare( + 'SELECT media_kind FROM imm_anime WHERE normalized_title_key = ? ORDER BY media_kind', + ) + .all('shared'); + assert.deepEqual(rows, [{ media_kind: 'anime' }, { media_kind: 'youtube' }]); + assert.deepEqual( + db + .prepare( + "SELECT media_kind FROM imm_anime WHERE normalized_title_key = 'other shared' ORDER BY media_kind", + ) + .all(), + [{ media_kind: 'anime' }, { media_kind: 'youtube' }], + ); + assert.equal( + ( + db.prepare('SELECT media_kind FROM imm_anime WHERE anilist_id = 77').get() as { + media_kind: string; + } + ).media_kind, + 'anime', + ); + assert.deepEqual( + db + .prepare( + 'SELECT media_kind, anilist_id, title_english, description FROM imm_anime WHERE anime_id = 2', + ) + .all()[0], + { + media_kind: 'youtube', + anilist_id: null, + title_english: 'Keep local', + description: 'Fill missing', + }, + ); + assert.equal( + ( + db + .prepare( + "SELECT a.media_kind FROM imm_videos v JOIN imm_anime a ON a.anime_id = v.anime_id WHERE v.video_key = 'video-remote'", + ) + .get() as { media_kind: string } + ).media_kind, + 'youtube', + ); + } finally { + db.close(); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/src/core/services/subtitle-generation-acceleration.test.ts b/src/core/services/subtitle-generation-acceleration.test.ts new file mode 100644 index 00000000..d14d02fa --- /dev/null +++ b/src/core/services/subtitle-generation-acceleration.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { detectSubtitleGenerationAcceleration } from './subtitle-generation-acceleration'; + +async function fixture(run: (directory: string) => Promise<void>) { + const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-acceleration-test-')); + try { + await run(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +async function executable(directory: string, name: string, body: string) { + const file = path.join(directory, name); + await writeFile(file, `#!${process.execPath}\n${body}`, { mode: 0o755 }); + return file; +} + +const cudaOutput = + 'ggml_cuda_init: found 1 CUDA devices:\nwhisper_model_load: invalid model data (bad magic)\n'; + +test('NVIDIA and CUDA discovery work without downloading or loading a model', () => + fixture(async (directory) => { + await executable(directory, 'nvidia-smi', 'console.log("NVIDIA Test GPU");'); + const whisper = await executable( + directory, + 'whisper-cli', + `const fs = require('node:fs'); + const model = process.argv[process.argv.indexOf('-m') + 1]; + if (fs.readFileSync(model).length !== 4) process.exit(1); + fs.writeFileSync(${JSON.stringify(path.join(directory, 'probe-path'))}, model); + process.stderr.write(${JSON.stringify(cudaOutput)}); process.exit(3);`, + ); + assert.deepEqual( + await detectSubtitleGenerationAcceleration( + { kind: 'found', path: whisper }, + { PATH: directory }, + ), + { kind: 'nvidia-cuda', gpuName: 'NVIDIA Test GPU' }, + ); + const model = await readFile(path.join(directory, 'probe-path'), 'utf8'); + await assert.rejects(readdir(path.dirname(model)), { code: 'ENOENT' }); + })); + +test('CPU-only, Vulkan-only, hidden CUDA devices and incomplete probes fall back safely', () => + fixture(async (directory) => { + await executable(directory, 'nvidia-smi', 'console.log("NVIDIA Test GPU");'); + for (const output of [ + 'usage: --no-gpu disable GPU\ninvalid model data (bad magic)', + 'ggml_vulkan: Found 1 Vulkan devices\ninvalid model data (bad magic)', + 'ggml_cuda_init: found 0 CUDA devices\ninvalid model data (bad magic)', + 'ggml_cuda_init: found 1 CUDA devices\nCUDA error: driver initialization failed', + ]) { + const whisper = await executable( + directory, + 'whisper-cli', + `process.stderr.write(${JSON.stringify(output)}); process.exit(3);`, + ); + assert.deepEqual( + await detectSubtitleGenerationAcceleration( + { kind: 'found', path: whisper }, + { PATH: directory }, + ), + { kind: 'unavailable' }, + ); + } + })); + +test('missing tools, missing NVIDIA devices and driver errors do not recommend turbo', () => + fixture(async (directory) => { + const whisper = await executable( + directory, + 'whisper-cli', + `process.stderr.write(${JSON.stringify(cudaOutput)}); process.exit(3);`, + ); + assert.deepEqual( + await detectSubtitleGenerationAcceleration( + { kind: 'missing', message: 'Not installed' }, + { PATH: directory }, + ), + { kind: 'unavailable' }, + ); + for (const driver of [ + null, + 'process.exit(0);', + 'console.log("NVIDIA GPU"); process.exit(1);', + ]) { + if (driver !== null) await executable(directory, 'nvidia-smi', driver); + assert.deepEqual( + await detectSubtitleGenerationAcceleration( + { kind: 'found', path: whisper }, + { PATH: directory }, + ), + { kind: 'unavailable' }, + ); + } + })); + +test('a hung Whisper probe times out even if it printed a CUDA device', () => + fixture(async (directory) => { + await executable(directory, 'nvidia-smi', 'console.log("NVIDIA Test GPU");'); + const whisper = await executable( + directory, + 'whisper-cli', + `process.stderr.write(${JSON.stringify(cudaOutput)}); setInterval(() => {}, 1000);`, + ); + const started = Date.now(); + assert.deepEqual( + await detectSubtitleGenerationAcceleration( + { kind: 'found', path: whisper }, + { PATH: directory }, + ), + { kind: 'unavailable' }, + ); + assert.ok(Date.now() - started < 6000); + })); diff --git a/src/core/services/subtitle-generation-acceleration.ts b/src/core/services/subtitle-generation-acceleration.ts new file mode 100644 index 00000000..dc5c05ff --- /dev/null +++ b/src/core/services/subtitle-generation-acceleration.ts @@ -0,0 +1,62 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { + SubtitleGenerationAcceleration, + SubtitleGenerationToolStatus, +} from '../../shared/subtitle-generation'; + +function probe(command: string, args: string[], env: NodeJS.ProcessEnv) { + return new Promise<{ code: number; stdout: string; stderr: string } | null>((resolve) => { + execFile( + command, + args, + { env, timeout: 3000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024, windowsHide: true }, + (error, stdout, stderr) => { + const code = error?.code ?? 0; + if (typeof code !== 'number' || error?.killed || error?.signal) { + resolve(null); + return; + } + resolve({ code, stdout, stderr }); + }, + ); + }); +} + +/** Require both a working NVIDIA driver and CUDA device discovery in the selected Whisper binary. */ +export async function detectSubtitleGenerationAcceleration( + whisper: SubtitleGenerationToolStatus, + env: NodeJS.ProcessEnv = process.env, +): Promise<SubtitleGenerationAcceleration> { + const unavailable: SubtitleGenerationAcceleration = { kind: 'unavailable' }; + if (whisper.kind === 'missing') return unavailable; + let directory: string | undefined; + try { + const nvidia = await probe('nvidia-smi', ['--query-gpu=name', '--format=csv,noheader'], env); + const gpuName = nvidia?.stdout.trim().split(/\r?\n/)[0]?.trim(); + if (nvidia?.code !== 0 || !gpuName) return unavailable; + + directory = await mkdtemp(path.join(tmpdir(), 'subminer-cuda-check-')); + const model = path.join(directory, 'probe.bin'); + await writeFile(model, Buffer.alloc(4)); + // whisper.cpp discovers backends before checking model magic. This deliberately invalid + // local file stops before allocating a model or decoding audio, even on a fresh install. + const result = await probe(whisper.path, ['-m', model, '-f', model], env); + if ( + result && + result.code !== 0 && + /ggml_cuda_init:\s+found\s+[1-9]\d*\s+CUDA devices?\b/i.test(result.stderr) && + /invalid model data \(bad magic\)/i.test(result.stderr) + ) { + return { kind: 'nvidia-cuda', gpuName }; + } + return unavailable; + } catch { + // Detection is advisory; unsupported builds and driver failures must not block generation. + return unavailable; + } finally { + if (directory) await rm(directory, { recursive: true, force: true }).catch(() => {}); + } +} diff --git a/src/core/services/subtitle-generation-chunks.test.ts b/src/core/services/subtitle-generation-chunks.test.ts index 71398610..4ea1ba01 100644 --- a/src/core/services/subtitle-generation-chunks.test.ts +++ b/src/core/services/subtitle-generation-chunks.test.ts @@ -2,6 +2,24 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { appendSpeechChunkCues, splitSpeechPassages } from './subtitle-generation-chunks'; +test('reference starts guide long cuts ahead of VAD without dropping unreferenced audio', () => { + const chunks = splitSpeechPassages( + [{ startSeconds: 0, endSeconds: 70 }], + [18], + [19], + [22, 43, 200], + ); + assert.deepEqual(chunks, [ + { startSeconds: 0, endSeconds: 22.25 }, + { startSeconds: 21.75, endSeconds: 43.25 }, + { startSeconds: 42.75, endSeconds: 63.25 }, + { startSeconds: 62.75, endSeconds: 70 }, + ]); + assert.deepEqual(splitSpeechPassages([{ startSeconds: 0, endSeconds: 25 }], [], [], [10, 20]), [ + { startSeconds: 0, endSeconds: 25 }, + ]); +}); + test('long coverage cuts at nearby speech starts instead of leaving a quiet lead-in', () => { const chunks = splitSpeechPassages( [{ startSeconds: 544.418, endSeconds: 581.581 }], diff --git a/src/core/services/subtitle-generation-chunks.ts b/src/core/services/subtitle-generation-chunks.ts index 95c9ed72..170ac9c9 100644 --- a/src/core/services/subtitle-generation-chunks.ts +++ b/src/core/services/subtitle-generation-chunks.ts @@ -5,11 +5,12 @@ const CHUNK_CONTEXT_SECONDS = 0.25; const WHISPER_WINDOW_SECONDS = 30; const PAUSE_SEARCH_SECONDS = 5; -// Prefer detected speech starts, then quiet pauses. Context stays inside retained audio. +// Prefer subtitle timing hints, then detected speech starts and quiet pauses. export function splitSpeechPassages( passages: readonly SpeechPassage[], pauses: readonly number[] = [], speechStarts: readonly number[] = [], + referenceStarts: readonly number[] = [], ): SpeechPassage[] { return passages.flatMap((passage) => { if (passage.endSeconds - passage.startSeconds <= WHISPER_WINDOW_SECONDS) @@ -22,17 +23,19 @@ export function splitSpeechPassages( if (target < passage.endSeconds) { // Starting in a long quiet lead-in can make Whisper place the next line // several seconds early. A nearby VAD start gives the next chunk an anchor. - let nearestSpeechStart: number | undefined; - for (const time of speechStarts) { - if ( - time >= target - PAUSE_SEARCH_SECONDS && - time <= target + PAUSE_SEARCH_SECONDS && - time < passage.endSeconds && - (nearestSpeechStart === undefined || - Math.abs(time - target) < Math.abs(nearestSpeechStart - target)) - ) - nearestSpeechStart = time; - } + const nearestStart = (starts: readonly number[]): number | undefined => { + let nearest: number | undefined; + for (const time of starts) { + if ( + time >= target - PAUSE_SEARCH_SECONDS && + time <= target + PAUSE_SEARCH_SECONDS && + time < passage.endSeconds && + (nearest === undefined || Math.abs(time - target) < Math.abs(nearest - target)) + ) + nearest = time; + } + return nearest; + }; let latestPause: number | undefined; for (const time of pauses) { if ( @@ -42,7 +45,7 @@ export function splitSpeechPassages( ) latestPause = time; } - end = nearestSpeechStart ?? latestPause ?? end; + end = nearestStart(referenceStarts) ?? nearestStart(speechStarts) ?? latestPause ?? end; } chunks.push({ startSeconds: Math.max(passage.startSeconds, boundary - CHUNK_CONTEXT_SECONDS), diff --git a/src/core/services/subtitle-generation-dialogue.ts b/src/core/services/subtitle-generation-dialogue.ts index 744d6a9f..d6782516 100644 --- a/src/core/services/subtitle-generation-dialogue.ts +++ b/src/core/services/subtitle-generation-dialogue.ts @@ -13,6 +13,7 @@ import { parseSpeechPassages, speechPassageCues, SPEECH_PASSAGE_SECONDS, + type SpeechPassage, } from './subtitle-generation-speech'; import type { SubtitleCue } from './subtitle-cue-parser'; import { appendSpeechChunkCues, splitSpeechPassages } from './subtitle-generation-chunks'; @@ -21,46 +22,50 @@ import { findAudiblePassages, mergeSpeechPassages } from './subtitle-generation- export async function transcribeSubtitleDialogue(input: { config: SubtitleGenerationConfig; - tools: SubtitleGenerationToolPaths & { vad: string }; + tools: SubtitleGenerationToolPaths; + referenceStarts?: readonly number[]; modelPath: string; wavPath: string; directory: string; onProgress?: (progress: SubtitleGenerationProgress) => void; signal?: AbortSignal; }): Promise<string> { - const vadModelPath = expandSubtitleGenerationPath(input.config.vadModelPath); - await access(vadModelPath, constants.R_OK); - input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Finding spoken dialogue...' }); - const segmentLines: string[] = []; - await runSubtitleGenerationProcess({ - command: input.tools.vad, - args: [ - '-f', - input.wavPath, - '-vm', - vadModelPath, - '-t', - String(input.config.threads), - '-vt', - '0.3', - '--vad-min-speech-duration-ms', - '100', - '--vad-min-silence-duration-ms', - '500', - '-vp', - '350', - '-vmsd', - String(SPEECH_PASSAGE_SECONDS), - '-np', - ], - signal: input.signal, - // Capture structured result lines separately from the bounded process log. - onLine: (line) => { - if (line.startsWith('Detected ') || line.startsWith('Speech segment ')) - segmentLines.push(line); - }, - }); - const speech = parseSpeechPassages(segmentLines.join('\n')); + let speech: SpeechPassage[] = []; + if (input.tools.vad !== null) { + const vadModelPath = expandSubtitleGenerationPath(input.config.vadModelPath); + await access(vadModelPath, constants.R_OK); + input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Finding spoken dialogue...' }); + const segmentLines: string[] = []; + await runSubtitleGenerationProcess({ + command: input.tools.vad, + args: [ + '-f', + input.wavPath, + '-vm', + vadModelPath, + '-t', + String(input.config.threads), + '-vt', + '0.3', + '--vad-min-speech-duration-ms', + '100', + '--vad-min-silence-duration-ms', + '500', + '-vp', + '350', + '-vmsd', + String(SPEECH_PASSAGE_SECONDS), + '-np', + ], + signal: input.signal, + // Capture structured result lines separately from the bounded process log. + onLine: (line) => { + if (line.startsWith('Detected ') || line.startsWith('Speech segment ')) + segmentLines.push(line); + }, + }); + speech = parseSpeechPassages(segmentLines.join('\n')); + } input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Checking audio coverage...' }); const audible = await findAudiblePassages({ ffmpegPath: input.tools.ffmpeg, @@ -82,6 +87,7 @@ export async function transcribeSubtitleDialogue(input: { detected, pauses, speech.map((passage) => passage.startSeconds), + input.referenceStarts, ); const cues: SubtitleCue[] = []; for (const [index, passage] of passages.entries()) { diff --git a/src/core/services/subtitle-generation-reference.test.ts b/src/core/services/subtitle-generation-reference.test.ts new file mode 100644 index 00000000..6ab02e38 --- /dev/null +++ b/src/core/services/subtitle-generation-reference.test.ts @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + loadSubtitleGenerationReference, + readSubtitleGenerationReferences, + subtitleGenerationReferences, +} from './subtitle-generation-reference'; + +const embedded = { type: 'sub', codec: 'ass', 'ff-index': 2, lang: 'eng' }; + +test('references exclude signs, songs, forced, bitmap and generated tracks even when selected', () => { + const excluded = [ + 'Signs & Songs', + 'SignsSongs', + 'Signs/Songs', + 'S&S', + 'S+S', + 'Forced', + 'Karaoke', + 'OP', + 'ED', + 'English lyrics', + 'Generated Japanese', + ]; + assert.deepEqual( + subtitleGenerationReferences([ + ...excluded.map((title) => ({ ...embedded, title, selected: true })), + { ...embedded, forced: true }, + { ...embedded, codec: 'hdmv_pgs_subtitle' }, + { + ...embedded, + title: 'English', + external: true, + 'external-filename': '/subs/show.en.signs.ass', + }, + { ...embedded, title: 'English Full' }, + ]).map((reference) => reference.label), + ['English Full'], + ); +}); + +test('references rank English dialogue first and resolve loaded external files against mpv cwd', () => { + const refs = subtitleGenerationReferences( + [ + { ...embedded, title: 'French Full', lang: 'fra', selected: true }, + { ...embedded, title: 'English' }, + { type: 'sub', external: true, 'external-filename': 'subs/show.en.full.srt' }, + { type: 'sub', external: true, 'external-filename': 'https://example.com/en.srt' }, + { type: 'sub', 'ff-index': -1 }, + null, + ], + '/mpv', + ); + assert.deepEqual( + refs.map((ref) => ref.label), + ['show.en.full.srt', 'English', 'French Full'], + ); + assert.deepEqual(refs[0]?.source, { kind: 'external', path: '/mpv/subs/show.en.full.srt' }); + assert.deepEqual( + subtitleGenerationReferences([ + { type: 'sub', external: true, 'external-filename': 'relative.en.srt' }, + ]), + [], + ); +}); + +test('reference discovery captures primary and secondary subtitle delays', async () => { + const properties: Record<string, unknown> = { + 'working-directory': '/mpv', + sid: 1, + 'secondary-sid': 2, + 'sub-delay': 1.5, + 'secondary-sub-delay': -2, + }; + const refs = await readSubtitleGenerationReferences( + [ + { ...embedded, id: 1 }, + { ...embedded, id: 2 }, + { ...embedded, id: 3 }, + ], + async (name) => properties[name], + ); + assert.deepEqual( + refs.map((ref) => ref.delaySeconds), + [1.5, -2, 0], + ); +}); + +test('reference extraction retries unreadable tracks, restores audio offset and ignores marked lyrics', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'generation-reference-')); + try { + const ffmpegPath = path.join(directory, 'ffmpeg'); + await writeFile( + ffmpegPath, + `#!${process.execPath} +const args = process.argv.slice(2); +if (args[args.indexOf('-map') + 1] === '0:2') process.exit(1); +require('node:fs').writeFileSync(args.at(-1), '1\\n00:00:10,000 --> 00:00:12,000\\nHello\\n\\n2\\n00:00:20,000 --> 00:00:22,000\\n♪ Song ♪\\n\\n3\\n00:00:30,000 --> 00:00:32,000\\nWorld\\n'); +`, + { mode: 0o755 }, + ); + const refs = subtitleGenerationReferences([{ ...embedded }, { ...embedded, 'ff-index': 3 }]); + const hints = await loadSubtitleGenerationReference({ + references: refs, + mediaPath: '/video.mkv', + ffmpegPath, + directory, + audioOffset: 2.5, + }); + assert.deepEqual(hints, [7.5, 27.5]); + assert.deepEqual( + await loadSubtitleGenerationReference({ + references: refs.slice(0, 1), + mediaPath: '/video.mkv', + ffmpegPath, + directory, + audioOffset: 0, + }), + [], + ); + await assert.rejects( + loadSubtitleGenerationReference({ + references: refs, + mediaPath: '/video.mkv', + ffmpegPath, + directory, + audioOffset: 0, + signal: AbortSignal.abort(), + }), + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/src/core/services/subtitle-generation-reference.ts b/src/core/services/subtitle-generation-reference.ts new file mode 100644 index 00000000..29842d3b --- /dev/null +++ b/src/core/services/subtitle-generation-reference.ts @@ -0,0 +1,169 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation'; +import { parseSrtCues } from './subtitle-cue-parser'; +import { runSubtitleGenerationProcess } from './subtitle-generation-process'; + +export type SubtitleGenerationReference = { + label: string; + delaySeconds: number; + source: { kind: 'embedded'; streamIndex: number } | { kind: 'external'; path: string }; +}; + +// Check both titles and filenames: releases often tag only one of them. +const EXCLUDED = + /(?:^|[^\p{L}\p{N}])(?:signs?(?:songs?)?|songs?|lyrics?|karaoke|forced|s[\s&+_-]*s|op|ed|opening|ending|generated)(?=$|[^\p{L}\p{N}])|看板|歌詞/iu; +const TEXT_CODECS = new Set(['ass', 'ssa', 'subrip', 'srt', 'webvtt', 'mov_text', 'text']); + +/** Rank loaded dialogue tracks, preferring English, then explicitly full tracks. */ +export function subtitleGenerationReferences( + value: unknown, + workingDirectory?: string, + delays: ReadonlyMap<number, number> = new Map(), +): SubtitleGenerationReference[] { + if (!Array.isArray(value)) return []; + const tracks: unknown[] = value; + return tracks + .flatMap((track) => { + if (typeof track !== 'object' || track === null || !('type' in track) || track.type !== 'sub') + return []; + const title = 'title' in track && typeof track.title === 'string' ? track.title : ''; + const filename = + 'external-filename' in track && typeof track['external-filename'] === 'string' + ? track['external-filename'] + : ''; + const name = `${title} ${path.basename(filename)}`; + if (('forced' in track && track.forced === true) || EXCLUDED.test(name)) return []; + if ('codec' in track && typeof track.codec === 'string' && !TEXT_CODECS.has(track.codec)) + return []; + let source: SubtitleGenerationReference['source']; + if ('external' in track && track.external === true) { + let local = filename; + if (local.startsWith('file://')) { + try { + local = fileURLToPath(local); + } catch { + return []; + } + } else if (/^[a-z][a-z\d+.-]*:\/\//i.test(local)) return []; + if (!local || (!path.isAbsolute(local) && !workingDirectory)) return []; + if (!/\.(?:srt|ass|ssa|vtt)$/i.test(local)) return []; + source = { kind: 'external', path: path.resolve(workingDirectory ?? '.', local) }; + } else { + if ( + !('ff-index' in track) || + typeof track['ff-index'] !== 'number' || + !Number.isSafeInteger(track['ff-index']) || + track['ff-index'] < 0 + ) + return []; + source = { kind: 'embedded', streamIndex: track['ff-index'] }; + } + const language = 'lang' in track && typeof track.lang === 'string' ? track.lang : ''; + const english = + /^(?:en|eng|english)(?:[-_]|$)/i.test(language) || + /(?:^|[\s.\[(_-])(?:en|eng|english)(?=$|[\s.\])_-])/i.test(name); + const full = /\b(?:full|dialogue|dialog)\b/i.test(name); + const selected = 'selected' in track && track.selected === true; + const preferred = 'default' in track && track.default === true; + return [ + { + reference: { + label: + title || + path.basename(filename) || + `${language || 'Subtitle'} stream ${source.kind === 'embedded' ? source.streamIndex : ''}`, + source, + delaySeconds: + 'id' in track && typeof track.id === 'number' ? (delays.get(track.id) ?? 0) : 0, + }, + score: + Number(english) * 100 + Number(full) * 20 + Number(selected) * 2 + Number(preferred), + }, + ]; + }) + .sort((a, b) => b.score - a.score) + .map(({ reference }) => reference); +} + +/** Read mpv's path base and active subtitle delays while capturing timing references. */ +export async function readSubtitleGenerationReferences( + tracks: unknown, + requestProperty: (name: string) => Promise<unknown>, +): Promise<SubtitleGenerationReference[]> { + const [directory, primary, secondary, primaryDelay, secondaryDelay] = await Promise.all( + ['working-directory', 'sid', 'secondary-sid', 'sub-delay', 'secondary-sub-delay'].map((name) => + requestProperty(name).catch(() => null), + ), + ); + const delays = new Map<number, number>(); + for (const [id, delay] of [ + [primary, primaryDelay], + [secondary, secondaryDelay], + ]) { + if (typeof id === 'number' && typeof delay === 'number' && Number.isFinite(delay)) + delays.set(id, delay); + } + return subtitleGenerationReferences( + tracks, + typeof directory === 'string' ? directory : undefined, + delays, + ); +} + +/** Reference timestamps are hints on the extracted audio timeline, never a coverage mask. */ +export async function loadSubtitleGenerationReference(input: { + references: readonly SubtitleGenerationReference[]; + mediaPath: string; + ffmpegPath: string; + directory: string; + audioOffset: number; + onProgress?: (progress: SubtitleGenerationProgress) => void; + signal?: AbortSignal; +}): Promise<number[]> { + for (const [index, reference] of input.references.entries()) { + input.signal?.throwIfAborted(); + try { + const output = path.join(input.directory, `reference-${index}.srt`); + const embedded = reference.source.kind === 'embedded'; + await runSubtitleGenerationProcess({ + command: input.ffmpegPath, + args: [ + '-nostdin', + '-hide_banner', + '-loglevel', + 'error', + ...(embedded ? ['-copyts', '-start_at_zero'] : []), + '-i', + reference.source.kind === 'external' ? reference.source.path : input.mediaPath, + '-map', + reference.source.kind === 'embedded' ? `0:${reference.source.streamIndex}` : '0:s:0', + '-c:s', + 'srt', + output, + ], + signal: input.signal, + }); + const starts = parseSrtCues(await readFile(output, 'utf8')) + .filter((cue) => cue.text.trim() && !/[♪♫]/u.test(cue.text) && cue.endTime > cue.startTime) + .map((cue) => cue.startTime + reference.delaySeconds - input.audioOffset) + .filter((time) => Number.isFinite(time) && time >= 0); + if (starts.length === 0) continue; + input.onProgress?.({ + stage: 'extract', + message: `Using subtitle timing reference: ${reference.label}`, + }); + return [...new Set(starts)].sort((a, b) => a - b); + } catch { + input.signal?.throwIfAborted(); + // An optional reference must not prevent transcription. Try the next loaded track. + } + } + if (input.references.length) + input.onProgress?.({ + stage: 'extract', + message: 'No readable subtitle timing reference. Using audio timing.', + }); + return []; +} diff --git a/src/core/services/subtitle-generation.test.ts b/src/core/services/subtitle-generation.test.ts index 4637c7ca..a3c40a45 100644 --- a/src/core/services/subtitle-generation.test.ts +++ b/src/core/services/subtitle-generation.test.ts @@ -95,6 +95,52 @@ test('config parser accepts supported models and rejects unsafe threads and wron assert.deepEqual(warnings, ['whisperPath', 'threads']); }); +test('generation uses reference cuts with and without VAD while preserving media offsets', () => + fixture(async (directory) => { + const input = await generationFixture(directory); + const ffmpegPath = await executable( + directory, + 'reference-ffmpeg', + ` +const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.appendFileSync(${JSON.stringify(input.callsPath)}, JSON.stringify(args) + '\\n'); +if (args.includes('-c:s')) { + fs.writeFileSync(args.at(-1), '1\\n00:00:24,500 --> 00:00:26,500\\nHello\\n\\n2\\n00:00:45,500 --> 00:00:47,500\\nWorld\\n'); +} else if (args.at(-1) === '-') { + process.stdout.write('out_time_us=70000000\\nprogress=end\\n'); +} else fs.writeFileSync(args.at(-1), 'wav'); +`, + ); + const vadPath = await executable( + directory, + 'reference-vad', + "process.stdout.write('Detected 1 speech segments:\\nSpeech segment 0: start = 0.000, end = 7000.000\\n');", + ); + const vadModelPath = path.join(directory, 'vad.bin'); + await writeFile(vadModelPath, 'model'); + for (const vad of [false, true]) { + await writeFile(input.callsPath, ''); + const output = await generateJapaneseSubtitles({ + ...input, + config: { ...input.config, ffmpegPath, vadPath, vadModelPath: vad ? vadModelPath : '' }, + references: [ + { label: 'English Full', delaySeconds: 0, source: { kind: 'embedded', streamIndex: 5 } }, + ], + }); + const calls: string[][] = (await readFile(input.callsPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + const clips = calls.filter((args) => args.includes('-ss')); + assert.equal(clips[0]?.[clips[0].indexOf('-t') + 1], '22.25'); + assert.equal(clips[1]?.[clips[1].indexOf('-ss') + 1], '21.75'); + const srt = await readFile(output, 'utf8'); + assert.match(srt, /00:00:03,500 --> 00:00:04,500/); + assert.match(srt, /00:00:25,250 --> 00:00:26,250/); + } + })); + test('external model path wins and invalid external models never fall back to download', () => fixture(async (directory) => { const input = await generationFixture(directory); diff --git a/src/core/services/subtitle-generation.ts b/src/core/services/subtitle-generation.ts index 0932ac96..00ca08c5 100644 --- a/src/core/services/subtitle-generation.ts +++ b/src/core/services/subtitle-generation.ts @@ -11,6 +11,10 @@ import { runSubtitleGenerationProcess } from './subtitle-generation-process'; import { publishSubtitleGenerationFile } from './subtitle-generation-files'; import { formatTimestamp } from './subtitle-generation-srt'; import { transcribeSubtitleDialogue } from './subtitle-generation-dialogue'; +import { + loadSubtitleGenerationReference, + type SubtitleGenerationReference, +} from './subtitle-generation-reference'; import { requireSubtitleGenerationTools, resolveSubtitleGenerationTools, @@ -174,6 +178,7 @@ export async function generateJapaneseSubtitles(input: { modelDirectory: string; mediaPath: string; audioStreamIndex?: number; + references?: readonly SubtitleGenerationReference[]; outputPath?: string; onProgress?: (progress: SubtitleGenerationProgress) => void; signal?: AbortSignal; @@ -257,11 +262,21 @@ export async function generateJapaneseSubtitles(input: { percent: 0, message: 'Generating Japanese subtitles...', }); + const referenceStarts = await loadSubtitleGenerationReference({ + references: input.references ?? [], + mediaPath, + ffmpegPath: tools.ffmpeg, + directory: temporaryDirectory, + audioOffset: audio.offset, + onProgress: input.onProgress, + signal: input.signal, + }); let srt: string; - if (tools.vad !== null) { + if (tools.vad !== null || referenceStarts.length > 0) { srt = await transcribeSubtitleDialogue({ config: input.config, - tools: { ...tools, vad: tools.vad }, + tools, + referenceStarts, modelPath: model.path, wavPath, directory: temporaryDirectory, diff --git a/src/main.ts b/src/main.ts index 5f7ab4fa..82286d43 100644 --- a/src/main.ts +++ b/src/main.ts @@ -477,6 +477,7 @@ import { MediaTimingPreviewSession } from './core/services/media-timing-preview' import { getSharedRemoteMediaWindowCache } from './core/services/remote-media-window-cache'; import { resolveMediaGenerationInput } from './anki-integration/media-source'; import { generateSpeechWaveform } from './core/services/media-timing-waveform'; +import { createMediaTimingFrameExtractor } from './core/services/media-timing-frame'; import { collectMediaTimingContextLines, createMediaTimingReviewRuntime, @@ -3006,6 +3007,7 @@ function createOverlayHostedModalOpenDeps(): { }; } +const mediaTimingFrameExtractor = createMediaTimingFrameExtractor(); const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({ getMpvClient: () => appState.mpvClient, getCurrentMediaPath: () => @@ -3014,6 +3016,14 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({ configService.getConfig().mpv.executablePath || process.env.SUBMINER_MPV_PATH?.trim() || '', createPreviewSession: () => new MediaTimingPreviewSession(), generateWaveform: (options) => generateSpeechWaveform(options), + generateFrame: (options) => mediaTimingFrameExtractor.generate(options), + clearFrameCache: () => mediaTimingFrameExtractor.clear(), + resolveVideoSource: () => + resolveMediaGenerationInput(appState.mpvClient, 'video', { + getCachedMediaPath: (currentVideoPath, kind) => + getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind), + remoteCacheMode: shouldRequireYoutubeMediaCacheForCurrentPlayback() ? 'required' : 'optional', + }), resolveMediaSource: async () => { const resolved = await resolveMediaGenerationInput(appState.mpvClient, 'audio', { getCachedMediaPath: (currentVideoPath, kind) => @@ -5809,6 +5819,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ }, mainDeps: { previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request), + getMediaTimingReviewFrame: (request) => mediaTimingReviewRuntime.getFrame(request), getMediaTimingReviewWaveform: (request) => mediaTimingReviewRuntime.getWaveform(request), stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId), resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request), diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 49e0c58b..8507210b 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -63,6 +63,7 @@ export interface MainIpcRuntimeServiceDepsParams { handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction']; onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve']; previewMediaTimingReview?: IpcDepsRuntimeOptions['previewMediaTimingReview']; + getMediaTimingReviewFrame?: IpcDepsRuntimeOptions['getMediaTimingReviewFrame']; getMediaTimingReviewWaveform?: IpcDepsRuntimeOptions['getMediaTimingReviewWaveform']; stopMediaTimingReviewPreview?: IpcDepsRuntimeOptions['stopMediaTimingReviewPreview']; resolveMediaTimingReview?: IpcDepsRuntimeOptions['resolveMediaTimingReview']; @@ -266,6 +267,7 @@ export function createMainIpcRuntimeServiceDeps( handleOverlayNotificationAction: params.handleOverlayNotificationAction, onYoutubePickerResolve: params.onYoutubePickerResolve, previewMediaTimingReview: params.previewMediaTimingReview, + getMediaTimingReviewFrame: params.getMediaTimingReviewFrame, getMediaTimingReviewWaveform: params.getMediaTimingReviewWaveform, stopMediaTimingReviewPreview: params.stopMediaTimingReviewPreview, resolveMediaTimingReview: params.resolveMediaTimingReview, diff --git a/src/main/media-runtime.ts b/src/main/media-runtime.ts index a994c93b..7f5bb355 100644 --- a/src/main/media-runtime.ts +++ b/src/main/media-runtime.ts @@ -1,4 +1,5 @@ import { updateCurrentMediaPath } from '../core/services'; +import { sanitizeMediaTitle } from '../shared/media-identity'; import type { SubtitlePosition } from '../types'; @@ -52,17 +53,18 @@ export function createMediaRuntimeService(deps: MediaRuntimeDeps): MediaRuntimeS updateCurrentMediaTitle(mediaTitle: unknown): void { if (typeof mediaTitle === 'string') { - const sanitized = mediaTitle.trim(); - deps.setCurrentMediaTitle(sanitized.length > 0 ? sanitized : null); + const sanitized = sanitizeMediaTitle(mediaTitle); + if (mediaTitle.trim() && !sanitized) return; + deps.setCurrentMediaTitle(sanitized); return; } deps.setCurrentMediaTitle(null); }, resolveMediaPathForJimaku(mediaPath: string | null): string | null { - return mediaPath && deps.isRemoteMediaPath(mediaPath) && deps.getCurrentMediaTitle() - ? deps.getCurrentMediaTitle() - : mediaPath; + return mediaPath && deps.isRemoteMediaPath(mediaPath) + ? sanitizeMediaTitle(deps.getCurrentMediaTitle()) + : sanitizeMediaTitle(mediaPath); }, }; } diff --git a/src/main/runtime/anilist-post-watch.test.ts b/src/main/runtime/anilist-post-watch.test.ts index 06826d11..45c97d01 100644 --- a/src/main/runtime/anilist-post-watch.test.ts +++ b/src/main/runtime/anilist-post-watch.test.ts @@ -9,6 +9,10 @@ import { test('buildAnilistAttemptKey formats media and episode', () => { assert.equal(buildAnilistAttemptKey('/tmp/video.mkv', 3), '/tmp/video.mkv::3'); + assert.equal( + buildAnilistAttemptKey('https://example.com/Videos/item/stream?api_key=test-secret', 3), + 'jellyfin://example.com/item/item::3', + ); }); test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => { @@ -17,6 +21,49 @@ test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => { assert.deepEqual(Array.from(set), ['b', 'c']); }); +test('post-watch rejects empty media identities before attempted keys or update side effects', async () => { + for (const mediaKey of [ + ' ', + 'stream?api_key=secret', + 'stream%3Fapi_key%3Dsecret', + 'https://[invalid', + ]) { + assert.equal(buildAnilistAttemptKey(mediaKey, 3), null); + const calls: string[] = []; + const unexpected = () => assert.fail('invalid identity reached update side effects'); + const handler = createMaybeRunAnilistPostWatchUpdateHandler({ + getInFlight: () => false, + setInFlight: (value) => calls.push(`inflight:${value}`), + getResolvedConfig: () => ({}), + isAnilistTrackingEnabled: () => true, + getCurrentMediaKey: () => mediaKey, + hasMpvClient: () => true, + getTrackedMediaKey: () => mediaKey, + resetTrackedMedia: unexpected, + getWatchedSeconds: () => 1000, + maybeProbeAnilistDuration: async () => 1000, + ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }), + hasAttemptedUpdateKey: unexpected, + processNextAnilistRetryUpdate: unexpected, + refreshAnilistClientSecretState: unexpected, + enqueueRetry: unexpected, + markRetryFailure: unexpected, + markRetrySuccess: unexpected, + refreshRetryQueueState: unexpected, + updateAnilistPostWatchProgress: unexpected, + rememberAttemptedUpdateKey: unexpected, + showMpvOsd: unexpected, + logInfo: unexpected, + logWarn: unexpected, + minWatchSeconds: 600, + minWatchRatio: 0.85, + }); + await handler(); + await handler({ force: true }); + assert.deepEqual(calls, ['inflight:true', 'inflight:false', 'inflight:true', 'inflight:false']); + } +}); + test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => { const calls: string[] = []; const handler = createProcessNextAnilistRetryUpdateHandler({ @@ -335,6 +382,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already ha const attemptedKeys = new Set<string>(); const mediaKey = '/tmp/video.mkv'; const attemptKey = buildAnilistAttemptKey(mediaKey, 1); + assert.ok(attemptKey); const handler = createMaybeRunAnilistPostWatchUpdateHandler({ getInFlight: () => false, setInFlight: (value) => calls.push(`inflight:${value}`), diff --git a/src/main/runtime/anilist-post-watch.ts b/src/main/runtime/anilist-post-watch.ts index 932532b6..e81b355b 100644 --- a/src/main/runtime/anilist-post-watch.ts +++ b/src/main/runtime/anilist-post-watch.ts @@ -1,4 +1,5 @@ import { isYoutubeMediaPath } from './youtube-playback'; +import { toMediaIdentityPath } from '../../shared/media-identity'; type AnilistGuess = { title: string; @@ -31,8 +32,9 @@ type AnilistDurationProbeOptions = { force?: boolean; }; -export function buildAnilistAttemptKey(mediaKey: string, episode: number): string { - return `${mediaKey}::${episode}`; +export function buildAnilistAttemptKey(mediaKey: string, episode: number): string | null { + const identity = toMediaIdentityPath(mediaKey); + return identity ? `${identity}::${episode}` : null; } export function rememberAnilistAttemptedUpdateKey( @@ -214,6 +216,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { } const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode); + if (!attemptKey) return; if (deps.hasAttemptedUpdateKey(attemptKey)) { return; } diff --git a/src/main/runtime/anime-browser-playback.ts b/src/main/runtime/anime-browser-playback.ts index 89ce7c7b..bbe339fb 100644 --- a/src/main/runtime/anime-browser-playback.ts +++ b/src/main/runtime/anime-browser-playback.ts @@ -278,6 +278,7 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions) onEndFile: deps.onPlaybackEndFile, readProperty: deps.readMpvProperty, wait, + isCurrent, }) : null; diff --git a/src/main/runtime/config-settings-runtime.test.ts b/src/main/runtime/config-settings-runtime.test.ts index 77d2e714..85ebbdec 100644 --- a/src/main/runtime/config-settings-runtime.test.ts +++ b/src/main/runtime/config-settings-runtime.test.ts @@ -5,9 +5,91 @@ import os from 'node:os'; import path from 'node:path'; import { DEFAULT_CONFIG, deepCloneConfig } from '../../config'; import { resolveConfig } from '../../config/resolve'; +import { buildConfigSettingsRegistry } from '../../config/settings/registry'; +import type { RawConfig } from '../../types/config'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { createConfigSettingsRuntime } from './config-settings-runtime'; +test('settings saves report live changes and only the sections that actually need restart', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-settings-live-')); + const configPath = path.join(dir, 'config.jsonc'); + let rawConfig: RawConfig = {}; + let resolvedConfig = resolveConfig(rawConfig).resolved; + const applied: string[][] = []; + const runtime = createConfigSettingsRuntime({ + fields: buildConfigSettingsRegistry(DEFAULT_CONFIG), + getConfigPath: () => configPath, + getRawConfig: () => rawConfig, + getConfig: () => resolvedConfig, + getWarnings: () => [], + reloadConfigStrict: () => { + rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const result = resolveConfig(rawConfig); + resolvedConfig = result.resolved; + return { ok: true, config: resolvedConfig, warnings: result.warnings, path: configPath }; + }, + onHotReloadApplied: (diff) => { + applied.push(diff.hotReloadFields); + }, + getSettingsWindow: () => null, + setSettingsWindow: () => {}, + createSettingsWindow: () => { + throw new Error('Save must not open a window'); + }, + settingsHtmlPath: '/tmp/settings.html', + openPath: async () => '', + defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url, + createAnkiClient: () => { + throw new Error('Save must not query Anki'); + }, + ipcMain: { handle: () => {} }, + ipcChannels: IPC_CHANNELS.request, + }); + + try { + const live = runtime.savePatch({ + operations: [ + { op: 'set', path: 'notifications.overlayPosition', value: 'top' }, + { + op: 'set', + path: 'subtitleGeneration.threads', + value: DEFAULT_CONFIG.subtitleGeneration.threads + 1, + }, + ], + }); + assert.equal(live.ok, true); + assert.deepEqual(live.restartRequiredFields, []); + assert.deepEqual(live.restartRequiredSections, []); + assert.deepEqual( + new Set(live.hotReloadFields), + new Set(['notifications.overlayPosition', 'subtitleGeneration.threads']), + ); + assert.deepEqual(applied, [live.hotReloadFields]); + + const mixed = runtime.savePatch({ + operations: [ + { op: 'set', path: 'ankiConnect.deck', value: 'Mining' }, + { op: 'set', path: 'ankiConnect.url', value: 'http://127.0.0.1:9999' }, + ], + }); + assert.equal(mixed.ok, true); + assert.deepEqual(mixed.hotReloadFields, ['ankiConnect.deck']); + assert.deepEqual(mixed.restartRequiredSections, ['AnkiConnect']); + + const reset = runtime.savePatch({ + operations: [ + { op: 'reset', path: 'notifications.overlayPosition' }, + { op: 'reset', path: 'subtitleGeneration.threads' }, + ], + }); + assert.equal(reset.ok, true); + assert.deepEqual(reset.restartRequiredSections, []); + assert.deepEqual(new Set(reset.hotReloadFields), new Set(live.hotReloadFields)); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('config settings runtime exposes inferred Yomitan Anki deck lookup', async () => { const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>(); const runtime = createConfigSettingsRuntime({ diff --git a/src/main/runtime/immersion-media.ts b/src/main/runtime/immersion-media.ts index 35ddd276..744d12f1 100644 --- a/src/main/runtime/immersion-media.ts +++ b/src/main/runtime/immersion-media.ts @@ -1,3 +1,5 @@ +import { toMediaIdentityPath } from '../../shared/media-identity'; + type ResolvedConfigLike = { immersionTracking?: { dbPath?: string | null; @@ -119,7 +121,7 @@ export function createImmersionMediaRuntime(deps: ImmersionMediaRuntimeDeps): { const mediaState = await getCurrentMpvMediaStateForTracker(); if (mediaState.path) { deps.logInfo( - `Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ${mediaState.path}`, + `Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ${toMediaIdentityPath(mediaState.path)}`, ); tracker.handleMediaChange(mediaState.path, mediaState.title); return; diff --git a/src/main/runtime/jellyfin-playback-launch.test.ts b/src/main/runtime/jellyfin-playback-launch.test.ts index f6d2916c..46d1e86b 100644 --- a/src/main/runtime/jellyfin-playback-launch.test.ts +++ b/src/main/runtime/jellyfin-playback-launch.test.ts @@ -103,6 +103,7 @@ test('playback handler drives mpv commands and playback state', async () => { ['set_property', 'sub-visibility', 'no'], ['set_property', 'secondary-sub-visibility', 'no'], ['script-message', 'subminer-managed-subtitles-loading'], + ['set_property', 'force-media-title', 'Episode 1'], [ 'loadfile', 'https://stream.example/video.m3u8', @@ -110,7 +111,6 @@ test('playback handler drives mpv commands and playback state', async () => { -1, 'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no,start=1.2', ], - ['set_property', 'force-media-title', 'Episode 1'], ]); assert.equal(scheduled.length, 0); assert.equal( @@ -437,6 +437,8 @@ test('playback handler publishes Jellyfin title before loading tokenized stream assert.ok(titleIndex >= 0); assert.ok(loadIndex >= 0); assert.ok(titleIndex < loadIndex); + const mpvTitleIndex = timeline.indexOf('cmd:set_property:force-media-title'); + assert.ok(mpvTitleIndex >= 0 && mpvTitleIndex < loadIndex); assert.equal(timeline[titleIndex]?.includes('api_key'), false); }); diff --git a/src/main/runtime/jellyfin-playback-launch.ts b/src/main/runtime/jellyfin-playback-launch.ts index 9b1ad344..68589c85 100644 --- a/src/main/runtime/jellyfin-playback-launch.ts +++ b/src/main/runtime/jellyfin-playback-launch.ts @@ -221,11 +221,12 @@ export function createPlayJellyfinItemInMpvHandler(deps: { }); deps.setLastProgressAtMs(0); deps.sendMpvCommand(['script-message', 'subminer-managed-subtitles-loading']); + // Set mpv's title before loadfile can emit a URL-derived media-title event. + deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]); deps.sendMpvCommand(['loadfile', playbackUrl, 'replace', -1, loadfileOptions]); if (params.setQuitOnDisconnectArm !== false) { deps.armQuitOnDisconnect(); } - deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]); await awaitBestEffortPlaybackHook(() => deps.preloadExternalSubtitles({ diff --git a/src/main/runtime/media-timing-review-frame.test.ts b/src/main/runtime/media-timing-review-frame.test.ts new file mode 100644 index 00000000..9d7a9ddf --- /dev/null +++ b/src/main/runtime/media-timing-review-frame.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { MediaTimingReviewOpenPayload } from '../../types/anki'; +import type { MediaTimingFrameOptions } from '../../core/services/media-timing-frame'; +import { + createMediaTimingReviewRuntime, + type MediaTimingReviewRuntimeDeps, +} from './media-timing-review'; + +async function start( + options: Partial<MediaTimingReviewRuntimeDeps> = {}, + screenshotEnabled = true, +) { + let opened!: (payload: MediaTimingReviewOpenPayload) => void; + const payloadPromise = new Promise<MediaTimingReviewOpenPayload>((resolve) => { + opened = resolve; + }); + const frames: MediaTimingFrameOptions[] = []; + const runtime = createMediaTimingReviewRuntime({ + getMpvClient: () => ({ + connected: true, + currentVideoPath: '/video.mkv', + send: () => {}, + requestProperty: async (name) => (name === 'duration' ? 100 : true), + }), + getCurrentMediaPath: () => '/video.mkv', + getMpvExecutablePath: () => '', + createPreviewSession: () => ({ + start: async () => {}, + play: async () => {}, + stop: async () => {}, + onPlaybackEnded: () => {}, + dispose: () => {}, + }), + generateWaveform: async () => [0, 1], + resolveVideoSource: async () => ({ path: '/video.mkv' }), + generateFrame: async (options) => { + frames.push(options); + return { dataUrl: 'data:image/jpeg;base64,AA==', timestamp: options.timestamp }; + }, + openModal: async (payload) => { + opened(payload); + return true; + }, + showStatus: () => {}, + ...options, + }); + const decision = runtime.requestReview({ + kind: 'word', + text: '字幕', + startTime: 10, + endTime: 12, + audioPadding: 0, + maxMediaDuration: 30, + screenshotEnabled, + }); + return { runtime, decision, payload: await payloadPromise, frames }; +} + +test('review accepts a screenshot outside the audio range and validates media bounds', async () => { + const { runtime, decision, payload, frames } = await start(); + for (const timestamp of [NaN, Infinity, -1, 100]) { + assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp })).ok, false); + assert.equal( + runtime.resolveReview({ + reviewId: payload.reviewId, + decision: { action: 'confirm', startTime: 10, endTime: 11, screenshotTime: timestamp }, + }).ok, + false, + ); + } + assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 13 })).ok, true); + assert.equal(frames[0]?.timestamp, 13); + assert.equal( + runtime.resolveReview({ + reviewId: payload.reviewId, + decision: { action: 'confirm', startTime: 10, endTime: 11, screenshotTime: 13 }, + }).ok, + true, + ); + assert.deepEqual(await decision, { + action: 'confirm', + startTime: 10, + endTime: 11, + screenshotTime: 13, + }); +}); + +test('screenshot preview reuses the remote audio window and its absolute timestamps', async () => { + let downloads = 0; + const remote = 'https://example.test/video'; + const { runtime, payload, decision, frames } = await start({ + resolveMediaSource: async () => ({ path: remote }), + resolveVideoSource: async () => ({ path: remote }), + acquireMediaWindow: async () => { + downloads += 1; + return { + path: '/window.mkv', + sourcePath: remote, + startTime: 7, + endTime: 15, + audioStreamIndex: null, + media: { path: '/window.mkv', absoluteTimestamps: true }, + }; + }, + }); + await runtime.getWaveform({ reviewId: payload.reviewId, startTime: 8, endTime: 14 }); + assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 })).ok, true); + assert.equal(downloads, 1); + assert.deepEqual(frames[0]?.media, { path: '/window.mkv', absoluteTimestamps: true }); + await runtime.dispose(); + await decision; +}); + +test('split video streams never read an audio-only cache as a video source', async () => { + const video = { + path: 'https://example.test/video', + inputOptions: { headers: { 'X-Test': 'value' } }, + }; + const { runtime, payload, decision, frames } = await start({ + resolveMediaSource: async () => ({ path: 'https://example.test/audio' }), + resolveVideoSource: async () => video, + }); + await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 }); + assert.deepEqual(frames[0]?.media, video); + await runtime.dispose(); + await decision; +}); + +test('disabled screenshots and missing video inputs do not trigger extraction', async () => { + for (const [enabled, options] of [ + [false, {}], + [true, { resolveVideoSource: async () => null }], + ] as const) { + const { runtime, payload, decision, frames } = await start(options, enabled); + assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 })).ok, false); + assert.equal(frames.length, 0); + await runtime.dispose(); + await decision; + } +}); + +test('closing a review invalidates in-flight frame results and clears the frame index', async () => { + let finish!: (value: { dataUrl: string; timestamp: number }) => void; + let clearCount = 0; + const { runtime, payload, decision } = await start({ + generateFrame: () => + new Promise((resolve) => { + finish = resolve; + }), + clearFrameCache: () => { + clearCount += 1; + }, + }); + const frame = runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 }); + await runtime.dispose(); + finish({ dataUrl: 'data:image/jpeg;base64,AA==', timestamp: 11 }); + assert.equal((await frame).stale, true); + await decision; + assert.equal(clearCount, 1); +}); diff --git a/src/main/runtime/media-timing-review.ts b/src/main/runtime/media-timing-review.ts index b78a23cb..c611a0c3 100644 --- a/src/main/runtime/media-timing-review.ts +++ b/src/main/runtime/media-timing-review.ts @@ -7,10 +7,13 @@ import type { MediaTimingReviewPreviewRequest, MediaTimingReviewRequest, MediaTimingReviewResolveRequest, + MediaTimingReviewFrameRequest, + MediaTimingReviewFrameResult, MediaTimingReviewWaveformRequest, MediaTimingReviewWaveformResult, } from '../../types/anki'; import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform'; +import type { MediaTimingFrameOptions } from '../../core/services/media-timing-frame'; import { isRemoteMediaWindowSourcePath, type RemoteMediaWindow, @@ -59,6 +62,8 @@ interface ActiveReview { mediaPath: string; /** What the waveform reads when no cached window is available. */ waveformMedia: MediaInput; + videoSource: ReviewMediaSource | null; + frameInFlight: boolean; audioStreamIndex?: number; /** Remote source to download windows of; null for local media or without a cache. */ windowSource: RemoteMediaWindowSource | null; @@ -81,6 +86,11 @@ export interface MediaTimingReviewRuntimeDeps { generateWaveform: (options: SpeechWaveformOptions) => Promise<number[]>; /** Resolves the FFmpeg-readable stream URL and headers behind the current media path. */ resolveMediaSource?: () => Promise<ReviewMediaSource | null>; + resolveVideoSource?: () => Promise<ReviewMediaSource | null>; + generateFrame?: ( + options: MediaTimingFrameOptions, + ) => Promise<{ dataUrl: string; timestamp: number }>; + clearFrameCache?: () => void; /** Downloads (or reuses) a local window of a remote source covering the range. */ acquireMediaWindow?: ( source: RemoteMediaWindowSource, @@ -227,6 +237,9 @@ export function buildMediaTimingReviewPayload( timelineEndTime, ...(duration !== null && duration > 0 ? { mediaDuration: duration } : {}), maxMediaDuration, + ...(request.screenshotEnabled !== undefined + ? { screenshotEnabled: request.screenshotEnabled } + : {}), }; } @@ -335,13 +348,15 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep return { action: 'use-original' }; } - const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource] = await Promise.all([ - mpvClient.requestProperty?.('pause').catch(() => null) ?? null, - mpvClient.requestProperty?.('duration').catch(() => null) ?? null, - mpvClient.requestProperty?.('aid').catch(() => null) ?? null, - mpvClient.requestProperty?.('volume').catch(() => null) ?? null, - deps.resolveMediaSource?.().catch(() => null) ?? null, - ]); + const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] = + await Promise.all([ + mpvClient.requestProperty?.('pause').catch(() => null) ?? null, + mpvClient.requestProperty?.('duration').catch(() => null) ?? null, + mpvClient.requestProperty?.('aid').catch(() => null) ?? null, + mpvClient.requestProperty?.('volume').catch(() => null) ?? null, + deps.resolveMediaSource?.().catch(() => null) ?? null, + request.screenshotEnabled ? (deps.resolveVideoSource?.().catch(() => null) ?? null) : null, + ]); const pauseState = booleanProperty(pauseRaw); mpvClient.send({ command: ['set_property', 'pause', 'yes'] }); pendingPauseRestore = pauseState === false ? mpvClient : null; @@ -383,6 +398,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep payload, mediaPath, waveformMedia: inputOptions ? { path: sourcePath, inputOptions } : sourcePath, + videoSource, + frameInFlight: false, ...(audioStreamIndex !== undefined ? { audioStreamIndex } : {}), windowSource, window: null, @@ -527,6 +544,58 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep } } + async function getFrame( + request: MediaTimingReviewFrameRequest, + ): Promise<MediaTimingReviewFrameResult> { + const current = active; + if (!current || request.reviewId !== current.payload.reviewId) return staleReviewResult(); + if (!current.payload.screenshotEnabled || !deps.generateFrame || !current.videoSource) { + return { ok: false, message: 'Screenshot preview is unavailable for this media.' }; + } + if ( + !Number.isFinite(request.timestamp) || + request.timestamp < 0 || + (current.payload.mediaDuration !== undefined && + request.timestamp >= current.payload.mediaDuration) || + (request.direction !== undefined && request.direction !== -1 && request.direction !== 1) + ) { + return { ok: false, message: 'The screenshot time is invalid.' }; + } + if (current.frameInFlight) + return { ok: false, message: 'A screenshot preview is already loading.' }; + current.frameInFlight = true; + try { + // Reuse the audio window only when it contains this same video source (not split streams). + const range = { + startTime: Math.max(0, Math.min(current.payload.timelineStartTime, request.timestamp - 2)), + endTime: Math.min( + current.payload.mediaDuration ?? Infinity, + Math.max(current.payload.timelineEndTime, request.timestamp + 2), + ), + }; + const window = + current.windowSource?.path === current.videoSource.path + ? await ensureWindow(current, range) + : null; + if (active !== current) return staleReviewResult(); + const frame = await deps.generateFrame({ + media: window?.media ?? current.videoSource, + timestamp: request.timestamp, + ...(request.direction !== undefined ? { direction: request.direction } : {}), + }); + if (active !== current) return staleReviewResult(); + return { ok: true, ...frame }; + } catch { + if (active !== current) return staleReviewResult(); + return { + ok: false, + message: 'Screenshot preview unavailable. Try another time or reset to the midpoint.', + }; + } finally { + current.frameInFlight = false; + } + } + async function stopPreview(reviewId: string): Promise<MediaTimingReviewActionResult> { const current = active; if (!current || reviewId !== current.payload.reviewId) { @@ -550,13 +619,23 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep return staleReviewResult(); } if (request.decision.action === 'confirm') { - const { startTime, endTime, text } = request.decision; + const { startTime, endTime, text, screenshotTime } = request.decision; if (!isValidMediaTimingRange(current.payload, startTime, endTime)) { return { ok: false, message: 'The selected timing range is invalid.' }; } if (text !== undefined && (typeof text !== 'string' || text.trim().length === 0)) { return { ok: false, message: 'The combined sentence text is invalid.' }; } + if ( + screenshotTime !== undefined && + (!current.payload.screenshotEnabled || + !Number.isFinite(screenshotTime) || + screenshotTime < 0 || + (current.payload.mediaDuration !== undefined && + screenshotTime >= current.payload.mediaDuration)) + ) { + return { ok: false, message: 'The screenshot time is invalid.' }; + } } current.resolve(request.decision); return { ok: true }; @@ -566,6 +645,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep const current = active; active = null; if (!current) return; + deps.clearFrameCache?.(); void current.preview?.session.then((session) => session.dispose()).catch(() => {}); if (current.restorePlayback && current.mpvClient.connected) { current.mpvClient.send({ command: ['set_property', 'pause', 'no'] }); @@ -582,6 +662,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep requestReview, previewRange, getWaveform, + getFrame, stopPreview, resolveReview, dispose, diff --git a/src/main/runtime/subtitle-generation-runtime.test.ts b/src/main/runtime/subtitle-generation-runtime.test.ts index 60a74264..1c0b7bbc 100644 --- a/src/main/runtime/subtitle-generation-runtime.test.ts +++ b/src/main/runtime/subtitle-generation-runtime.test.ts @@ -23,6 +23,7 @@ function fixture(overrides: Partial<SubtitleGenerationRuntimeDeps> = {}) { getModelDirectory: () => '/models', getMpvClient: () => client, onProgress: () => {}, + detectAcceleration: async () => ({ kind: 'unavailable' }), resolveModel: async () => ({ kind: 'external', path: '/models/local.bin' }), resolveTools: async (config) => ({ ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' }, @@ -71,6 +72,27 @@ test('generation preserves the output without attaching it to a different video' assert.deepEqual(subject.commands, []); }); +test('generation selects loaded dialogue references and excludes the signs track', async () => { + const subject = fixture({ + generate: async (input) => { + assert.deepEqual(input.references, [ + { label: 'English Full', delaySeconds: 0, source: { kind: 'embedded', streamIndex: 5 } }, + ]); + return '/video/generated.srt'; + }, + }); + const request = subject.client.requestProperty; + subject.client.requestProperty = async (name) => + name === 'track-list' + ? [ + { type: 'audio', selected: true, 'ff-index': 3 }, + { type: 'sub', lang: 'eng', title: 'Signs & Songs', 'ff-index': 4 }, + { type: 'sub', lang: 'eng', title: 'English Full', 'ff-index': 5 }, + ] + : request(name); + assert.equal((await subject.runtime.start()).ok, true); +}); + test('mpv load failure still reports where the generated subtitles were saved', async () => { const subject = fixture(); subject.client.request = async () => ({ error: 'loading failed' }); @@ -182,6 +204,40 @@ test('external model paths prevent managed selection, including unreadable overr await assert.rejects(runtime.selectModel('medium'), /Clear Model Path/); }); +test('CUDA recommendations preserve selected and configured models and follow the Whisper path', async () => { + let whisperPath = '/cuda/whisper-cli'; + const checked: string[] = []; + const subject = fixture({ + getConfig: () => ({ ...DEFAULT_SUBTITLE_GENERATION_CONFIG, managedModel: 'medium' }), + resolveTools: async () => ({ + ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' }, + ffprobe: { kind: 'found', path: '/usr/bin/ffprobe' }, + whisper: { kind: 'found', path: whisperPath }, + vad: null, + }), + detectAcceleration: async (whisper) => { + assert.equal(whisper.kind, 'found'); + if (whisper.kind !== 'found') throw new Error('Expected a Whisper executable'); + checked.push(whisper.path); + return whisper.path.startsWith('/cuda/') + ? { kind: 'nvidia-cuda', gpuName: 'NVIDIA Test GPU' } + : { kind: 'unavailable' }; + }, + }); + const initial = await subject.runtime.getStatus(); + assert.deepEqual(initial.acceleration, { kind: 'nvidia-cuda', gpuName: 'NVIDIA Test GPU' }); + assert.equal(initial.managedModel, 'medium'); + const selected = await subject.runtime.selectModel('small'); + assert.equal(selected.managedModel, 'small'); + assert.equal(selected.acceleration.kind, 'nvidia-cuda'); + assert.deepEqual(checked, ['/cuda/whisper-cli']); + whisperPath = '/cpu/whisper-cli'; + const changed = await subject.runtime.getStatus(); + assert.equal(changed.acceleration.kind, 'unavailable'); + assert.equal(changed.managedModel, 'small'); + assert.deepEqual(checked, ['/cuda/whisper-cli', '/cpu/whisper-cli']); +}); + test('status reports the speech detector only while dialogue mode is on', async () => { const { runtime } = fixture({ resolveVadModel: async () => ({ kind: 'managed', path: '/models/ggml-silero-v6.2.0.bin' }), diff --git a/src/main/runtime/subtitle-generation-runtime.ts b/src/main/runtime/subtitle-generation-runtime.ts index 3d521827..94176c83 100644 --- a/src/main/runtime/subtitle-generation-runtime.ts +++ b/src/main/runtime/subtitle-generation-runtime.ts @@ -1,4 +1,6 @@ import path from 'node:path'; +import { readSubtitleGenerationReferences } from '../../core/services/subtitle-generation-reference'; +import { detectSubtitleGenerationAcceleration } from '../../core/services/subtitle-generation-acceleration'; import { SUBTITLE_GENERATION_VAD_MODEL } from '../../shared/subtitle-generation-vad-model'; import { downloadSubtitleGenerationVadModel, @@ -35,6 +37,7 @@ export interface SubtitleGenerationRuntimeDeps { download?: typeof downloadSubtitleGenerationModel; resolveModel?: typeof resolveSubtitleGenerationModel; resolveTools?: typeof resolveSubtitleGenerationTools; + detectAcceleration?: typeof detectSubtitleGenerationAcceleration; downloadVad?: typeof downloadSubtitleGenerationVadModel; resolveVadModel?: typeof resolveSubtitleGenerationVadModel; } @@ -80,6 +83,13 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD let lastResult: SubtitleGenerationResult | null = null; let selectedModel: SubtitleGenerationModelId | null = null; let vadEnabled: boolean | null = null; + let accelerationCheck: + | { + path: string; + expires: number; + result: ReturnType<typeof detectSubtitleGenerationAcceleration>; + } + | undefined; function getConfig(): SubtitleGenerationConfig { const config = deps.getConfig(); return { @@ -127,6 +137,20 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD async function getStatus(): Promise<SubtitleGenerationStatus> { const config = getConfig(); + const tools = await (deps.resolveTools ?? resolveSubtitleGenerationTools)(config); + const whisperPath = tools.whisper.kind === 'found' ? tools.whisper.path : ''; + if ( + !accelerationCheck || + accelerationCheck.path !== whisperPath || + (!controller && Date.now() >= accelerationCheck.expires) + ) { + accelerationCheck = { + path: whisperPath, + expires: Date.now() + 30_000, + result: (deps.detectAcceleration ?? detectSubtitleGenerationAcceleration)(tools.whisper), + }; + } + const acceleration = await accelerationCheck.result; const model = await (deps.resolveModel ?? resolveSubtitleGenerationModel)( config, deps.getModelDirectory(), @@ -142,7 +166,8 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD ), }, // Session toggles decide whether the speech detector executable is required. - tools: await (deps.resolveTools ?? resolveSubtitleGenerationTools)(config), + tools, + acceleration, managedModel: config.managedModel, externalModelPath: config.modelPath.trim() || null, mediaPath, @@ -214,7 +239,11 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD const mediaPath = await currentLocalMedia(client); if (!client || !mediaPath) throw new Error('Open a local video or audio file in mpv first.'); - const audioStreamIndex = selectedAudioIndex(await client.requestProperty('track-list')); + const tracks = await client.requestProperty('track-list'); + const audioStreamIndex = selectedAudioIndex(tracks); + const references = await readSubtitleGenerationReferences(tracks, (name) => + client.requestProperty(name), + ); if ((await currentLocalMedia(client)) !== mediaPath) throw new Error('The current media changed. Start generation again.'); signal.throwIfAborted(); @@ -223,6 +252,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD modelDirectory: deps.getModelDirectory(), mediaPath, audioStreamIndex, + references, onProgress: report, signal, }); diff --git a/src/preload.ts b/src/preload.ts index ac4114d3..d2795ce8 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -73,6 +73,8 @@ import type { MediaTimingReviewOpenPayload, MediaTimingReviewPreviewRequest, MediaTimingReviewResolveRequest, + MediaTimingReviewFrameRequest, + MediaTimingReviewFrameResult, MediaTimingReviewWaveformRequest, } from './types'; import { IPC_CHANNELS } from './shared/ipc/contracts'; @@ -512,6 +514,10 @@ const electronAPI: ElectronAPI = { request: MediaTimingReviewPreviewRequest, ): Promise<MediaTimingReviewActionResult> => ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewPreview, request), + getMediaTimingReviewFrame: ( + request: MediaTimingReviewFrameRequest, + ): Promise<MediaTimingReviewFrameResult> => + ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewFrame, request), getMediaTimingReviewWaveform: (request: MediaTimingReviewWaveformRequest) => ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewWaveform, request), stopMediaTimingReviewPreview: (reviewId: string): Promise<MediaTimingReviewActionResult> => diff --git a/src/renderer/index.html b/src/renderer/index.html index a42e759b..6a55dd60 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -86,10 +86,30 @@ <button id="jimakuClose" class="modal-close" type="button">Close</button> </div> <div class="modal-body"> + <div class="jimaku-tabs" role="tablist"> + <button + id="jimakuTabAnime" + class="jimaku-tab active" + type="button" + role="tab" + aria-selected="true" + > + Anime + </button> + <button + id="jimakuTabLiveAction" + class="jimaku-tab" + type="button" + role="tab" + aria-selected="false" + > + Live action + </button> + </div> <div class="jimaku-form"> <label class="jimaku-field"> <span>Title</span> - <input id="jimakuTitle" type="text" placeholder="Anime title" /> + <input id="jimakuTitle" type="text" placeholder="Title" /> </label> <label class="jimaku-field"> <span>Season</span> @@ -281,6 +301,57 @@ </div> <blockquote id="mediaTimingReviewText" class="media-timing-review-text"></blockquote> + <div id="mediaTimingReviewFramePicker" class="media-timing-frame-picker hidden"> + <div class="media-timing-frame-image-shell"> + <img + id="mediaTimingReviewFrameImage" + class="hidden" + alt="Selected card screenshot" + /> + </div> + <div class="media-timing-frame-controls"> + <div class="media-timing-frame-heading"> + <label for="mediaTimingReviewFrameSlider">Card screenshot</label> + <output id="mediaTimingReviewFrameTime" for="mediaTimingReviewFrameSlider" + >—</output + > + </div> + <input + id="mediaTimingReviewFrameSlider" + type="range" + min="0" + max="1" + step="0.001" + value="0" + aria-label="Screenshot time" + /> + <div class="media-timing-frame-buttons"> + <button + id="mediaTimingReviewFramePrevious" + type="button" + aria-label="Previous video frame" + > + ← Frame + </button> + <button + id="mediaTimingReviewFrameNext" + type="button" + aria-label="Next video frame" + > + Frame → + </button> + <button + id="mediaTimingReviewFrameReset" + type="button" + aria-label="Reset screenshot" + > + Reset + </button> + </div> + <p id="mediaTimingReviewFrameStatus" role="status"></p> + </div> + </div> + <div class="media-timing-review-readout" aria-live="polite"> <div> <span>Starts</span> diff --git a/src/renderer/modals/jimaku.test.ts b/src/renderer/modals/jimaku.test.ts index d3328b87..e8e59ed7 100644 --- a/src/renderer/modals/jimaku.test.ts +++ b/src/renderer/modals/jimaku.test.ts @@ -119,6 +119,8 @@ test('successful Jimaku subtitle selection closes modal', async () => { classList: jimakuBroadenButtonClassList, addEventListener: () => {}, }, + jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} }, + jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} }, }, state, }; @@ -147,3 +149,427 @@ test('successful Jimaku subtitle selection closes modal', async () => { Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); } }); + +test('switching to the Live action tab re-runs the search with the live action category', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + + const searchQueries: Array<{ query: string; category?: string }> = []; + const electronAPI = { + jimakuSearchEntries: async (query: { query: string; category?: string }) => { + searchQueries.push(query); + return { ok: true, data: [] }; + }, + } as unknown as ElectronAPI; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { electronAPI }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + activeElement: null, + createElement: () => createElementStub(), + }, + }); + + try { + const state = createRendererState(); + state.jimakuModalOpen = true; + const animeTabClassList = createClassList(['active']); + const liveActionTabClassList = createClassList(); + const status = { textContent: '', style: { color: '' } }; + + const ctx = { + dom: { + overlay: { classList: createClassList(['interactive']) }, + jimakuModal: { classList: createClassList(), setAttribute: () => {} }, + jimakuTitleInput: { value: 'Shinzanmono' }, + jimakuSeasonInput: { value: '' }, + jimakuEpisodeInput: { value: '3' }, + jimakuSearchButton: { addEventListener: () => {} }, + jimakuCloseButton: { addEventListener: () => {} }, + jimakuStatus: status, + jimakuEntriesSection: { classList: createClassList(['hidden']) }, + jimakuEntriesList: createListStub(), + jimakuFilesSection: { classList: createClassList(['hidden']) }, + jimakuFilesList: createListStub(), + jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} }, + jimakuTabAnimeButton: { classList: animeTabClassList, setAttribute: () => {} }, + jimakuTabLiveActionButton: { classList: liveActionTabClassList, setAttribute: () => {} }, + }, + state, + }; + + const jimakuModal = createJimakuModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + }); + + jimakuModal.handleJimakuKeydown({ + key: 'ArrowRight', + preventDefault: () => {}, + } as KeyboardEvent); + await flushAsyncWork(); + + assert.equal(state.jimakuActiveTab, 'liveAction'); + assert.equal(liveActionTabClassList.contains('active'), true); + assert.equal(animeTabClassList.contains('active'), false); + assert.deepEqual(searchQueries, [{ query: 'Shinzanmono', category: 'liveAction' }]); + assert.equal(status.textContent, 'No live action entries found. Try the Anime tab.'); + + // Same tab again is a no-op: no duplicate request. + jimakuModal.handleJimakuKeydown({ + key: 'ArrowRight', + preventDefault: () => {}, + } as KeyboardEvent); + await flushAsyncWork(); + assert.equal(searchQueries.length, 1); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + +test('a slow reply from a superseded search does not overwrite the newer results', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + + const pending: Array<(entries: unknown[]) => void> = []; + const electronAPI = { + jimakuSearchEntries: () => + new Promise((resolve) => { + pending.push((entries) => resolve({ ok: true, data: entries })); + }), + jimakuListFiles: async () => ({ ok: true, data: [] }), + } as unknown as ElectronAPI; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { electronAPI }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + activeElement: null, + createElement: () => createElementStub(), + }, + }); + + try { + const state = createRendererState(); + state.jimakuModalOpen = true; + + const ctx = { + dom: { + overlay: { classList: createClassList(['interactive']) }, + jimakuModal: { classList: createClassList(), setAttribute: () => {} }, + jimakuTitleInput: { value: 'Shinzanmono' }, + jimakuSeasonInput: { value: '' }, + jimakuEpisodeInput: { value: '' }, + jimakuSearchButton: { addEventListener: () => {} }, + jimakuCloseButton: { addEventListener: () => {} }, + jimakuStatus: { textContent: '', style: { color: '' } }, + jimakuEntriesSection: { classList: createClassList(['hidden']) }, + jimakuEntriesList: createListStub(), + jimakuFilesSection: { classList: createClassList(['hidden']) }, + jimakuFilesList: createListStub(), + jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} }, + jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} }, + jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} }, + }, + state, + }; + + const jimakuModal = createJimakuModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + }); + + // Anime -> Live action -> Anime, all before any reply arrives. + jimakuModal.handleJimakuKeydown({ + key: 'ArrowRight', + preventDefault: () => {}, + } as KeyboardEvent); + jimakuModal.handleJimakuKeydown({ + key: 'ArrowLeft', + preventDefault: () => {}, + } as KeyboardEvent); + await flushAsyncWork(); + assert.equal(pending.length, 2); + + // The stale live action reply lands after the newer anime search was issued. + pending[0]!([{ id: 1, name: 'Stale live action entry' }]); + await flushAsyncWork(); + assert.equal(state.jimakuEntries.length, 0); + + pending[1]!([ + { id: 2, name: 'Anime A' }, + { id: 3, name: 'Anime B' }, + ]); + await flushAsyncWork(); + assert.deepEqual( + state.jimakuEntries.map((entry) => entry.id), + [2, 3], + ); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + +test('closing the modal discards an in-flight search reply', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + + let resolveSearch!: (entries: unknown[]) => void; + let listFilesCalls = 0; + const electronAPI = { + jimakuSearchEntries: () => + new Promise((resolve) => { + resolveSearch = (entries) => resolve({ ok: true, data: entries }); + }), + jimakuListFiles: async () => { + listFilesCalls += 1; + return { ok: true, data: [] }; + }, + notifyOverlayModalClosed: () => {}, + } as unknown as ElectronAPI; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { electronAPI }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + activeElement: null, + createElement: () => createElementStub(), + }, + }); + + try { + const state = createRendererState(); + state.jimakuModalOpen = true; + + const ctx = { + dom: { + overlay: { classList: createClassList(['interactive']) }, + jimakuModal: { classList: createClassList(), setAttribute: () => {} }, + jimakuTitleInput: { value: 'Shinzanmono' }, + jimakuSeasonInput: { value: '' }, + jimakuEpisodeInput: { value: '' }, + jimakuSearchButton: { addEventListener: () => {} }, + jimakuCloseButton: { addEventListener: () => {} }, + jimakuStatus: { textContent: '', style: { color: '' } }, + jimakuEntriesSection: { classList: createClassList(['hidden']) }, + jimakuEntriesList: createListStub(), + jimakuFilesSection: { classList: createClassList(['hidden']) }, + jimakuFilesList: createListStub(), + jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} }, + jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} }, + jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} }, + }, + state, + }; + + const jimakuModal = createJimakuModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + }); + + jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent); + await flushAsyncWork(); + jimakuModal.closeJimakuModal(); + + // A single entry would normally auto-select and fetch its files. + resolveSearch([{ id: 7, name: 'Only entry' }]); + await flushAsyncWork(); + + assert.equal(state.jimakuEntries.length, 0); + assert.equal(state.currentEntryId, null); + assert.equal(listFilesCalls, 0); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + +test('a slow files reply for a previously selected entry is ignored', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + + const pending = new Map<number, (files: unknown[]) => void>(); + const electronAPI = { + jimakuListFiles: (query: { entryId: number }) => + new Promise((resolve) => { + pending.set(query.entryId, (files) => resolve({ ok: true, data: files })); + }), + } as unknown as ElectronAPI; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { electronAPI }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + activeElement: null, + createElement: () => createElementStub(), + }, + }); + + try { + const state = createRendererState(); + state.jimakuModalOpen = true; + state.jimakuEntries = [ + { id: 1, name: 'Entry A' }, + { id: 2, name: 'Entry B' }, + ]; + + const ctx = { + dom: { + overlay: { classList: createClassList(['interactive']) }, + jimakuModal: { classList: createClassList(), setAttribute: () => {} }, + jimakuTitleInput: { value: '' }, + jimakuSeasonInput: { value: '' }, + jimakuEpisodeInput: { value: '' }, + jimakuSearchButton: { addEventListener: () => {} }, + jimakuCloseButton: { addEventListener: () => {} }, + jimakuStatus: { textContent: '', style: { color: '' } }, + jimakuEntriesSection: { classList: createClassList() }, + jimakuEntriesList: createListStub(), + jimakuFilesSection: { classList: createClassList(['hidden']) }, + jimakuFilesList: createListStub(), + jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} }, + jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} }, + jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} }, + }, + state, + }; + + const jimakuModal = createJimakuModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + }); + + // Select entry A, then move to entry B before A's files arrive. + jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent); + jimakuModal.handleJimakuKeydown({ + key: 'ArrowDown', + preventDefault: () => {}, + } as KeyboardEvent); + jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent); + await flushAsyncWork(); + assert.equal(state.currentEntryId, 2); + + pending.get(1)!([ + { name: 'a.srt', url: 'https://jimaku.cc/a.srt', size: 1, last_modified: '' }, + ]); + await flushAsyncWork(); + assert.equal(state.jimakuFiles.length, 0); + + pending.get(2)!([ + { name: 'b1.srt', url: 'https://jimaku.cc/b1.srt', size: 1, last_modified: '' }, + { name: 'b2.srt', url: 'https://jimaku.cc/b2.srt', size: 1, last_modified: '' }, + ]); + await flushAsyncWork(); + assert.deepEqual( + state.jimakuFiles.map((file) => file.name), + ['b1.srt', 'b2.srt'], + ); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + +test('media info arriving after the modal closed does not fill inputs or search', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + + let resolveMediaInfo!: (info: unknown) => void; + let searchCalls = 0; + const electronAPI = { + getJimakuMediaInfo: () => + new Promise((resolve) => { + resolveMediaInfo = resolve; + }), + jimakuSearchEntries: async () => { + searchCalls += 1; + return { ok: true, data: [] }; + }, + notifyOverlayModalClosed: () => {}, + } as unknown as ElectronAPI; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { electronAPI }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + activeElement: null, + createElement: () => createElementStub(), + }, + }); + + try { + const state = createRendererState(); + const titleInput = { value: '' }; + const status = { textContent: '', style: { color: '' } }; + + const ctx = { + dom: { + overlay: { classList: createClassList() }, + jimakuModal: { classList: createClassList(['hidden']), setAttribute: () => {} }, + jimakuTitleInput: titleInput, + jimakuSeasonInput: { value: '' }, + jimakuEpisodeInput: { value: '' }, + jimakuSearchButton: { addEventListener: () => {} }, + jimakuCloseButton: { addEventListener: () => {} }, + jimakuStatus: status, + jimakuEntriesSection: { classList: createClassList(['hidden']) }, + jimakuEntriesList: createListStub(), + jimakuFilesSection: { classList: createClassList(['hidden']) }, + jimakuFilesList: createListStub(), + jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} }, + jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} }, + jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} }, + }, + state, + }; + + const jimakuModal = createJimakuModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + }); + + jimakuModal.openJimakuModal(); + await flushAsyncWork(); + jimakuModal.closeJimakuModal(); + + resolveMediaInfo({ + title: 'Shinzanmono', + season: 1, + episode: 3, + confidence: 'high', + filename: 'Shinzanmono S01E03.mkv', + rawTitle: 'Shinzanmono S01E03', + }); + await flushAsyncWork(); + + assert.equal(titleInput.value, ''); + assert.equal(searchCalls, 0); + assert.equal(status.textContent, 'Loading media info...'); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); diff --git a/src/renderer/modals/jimaku.ts b/src/renderer/modals/jimaku.ts index e6278dcb..086baf92 100644 --- a/src/renderer/modals/jimaku.ts +++ b/src/renderer/modals/jimaku.ts @@ -4,6 +4,7 @@ import type { JimakuEntry, JimakuFileEntry, JimakuMediaInfo, + JimakuSearchCategory, } from '../../types'; import type { ModalStateReader, RendererContext } from '../context'; @@ -21,7 +22,12 @@ export function createJimakuModal( : 'rgba(255, 255, 255, 0.8)'; } + // Bumped whenever the lists are reset (new search, tab switch, open, close) + // so any in-flight entries or files reply for the old state is discarded. + let searchGeneration = 0; + function resetJimakuLists(): void { + searchGeneration += 1; ctx.state.jimakuEntries = []; ctx.state.jimakuFiles = []; ctx.state.selectedEntryIndex = 0; @@ -35,6 +41,33 @@ export function createJimakuModal( ctx.dom.jimakuBroadenButton.classList.add('hidden'); } + function renderTabs(): void { + const liveActionActive = ctx.state.jimakuActiveTab === 'liveAction'; + const active = liveActionActive + ? ctx.dom.jimakuTabLiveActionButton + : ctx.dom.jimakuTabAnimeButton; + const inactive = liveActionActive + ? ctx.dom.jimakuTabAnimeButton + : ctx.dom.jimakuTabLiveActionButton; + active.classList.add('active'); + active.setAttribute('aria-selected', 'true'); + inactive.classList.remove('active'); + inactive.setAttribute('aria-selected', 'false'); + } + + // Tabs map to Jimaku's anime / live-action catalogues, so switching re-runs + // the search server-side instead of filtering a shared result list. + function setActiveTab(tab: JimakuSearchCategory): void { + if (ctx.state.jimakuActiveTab === tab) return; + ctx.state.jimakuActiveTab = tab; + renderTabs(); + if (getSearchQuery().query) { + void performJimakuSearch(); + } else { + resetJimakuLists(); + } + } + function formatEntryLabel(entry: JimakuEntry): string { if (entry.english_name && entry.english_name !== entry.name) { return `${entry.name} / ${entry.english_name}`; @@ -133,9 +166,12 @@ export function createJimakuModal( setJimakuStatus('Searching Jimaku...'); ctx.state.currentEpisodeFilter = episode; + const category = ctx.state.jimakuActiveTab; + const generation = searchGeneration; const response: JimakuApiResponse<JimakuEntry[]> = await window.electronAPI.jimakuSearchEntries( - { query }, + { query, category }, ); + if (generation !== searchGeneration) return; if (!response.ok) { const retry = response.error.retryAfter ? ` Retry after ${response.error.retryAfter.toFixed(1)}s.` @@ -148,7 +184,11 @@ export function createJimakuModal( ctx.state.selectedEntryIndex = 0; if (ctx.state.jimakuEntries.length === 0) { - setJimakuStatus('No entries found.'); + setJimakuStatus( + category === 'anime' + ? 'No anime entries found. Try the Live action tab.' + : 'No live action entries found. Try the Anime tab.', + ); return; } @@ -167,12 +207,15 @@ export function createJimakuModal( ctx.dom.jimakuFilesList.innerHTML = ''; ctx.dom.jimakuFilesSection.classList.add('hidden'); + const generation = searchGeneration; const response: JimakuApiResponse<JimakuFileEntry[]> = await window.electronAPI.jimakuListFiles( { entryId, episode, }, ); + // The user may have picked another entry or reset the modal meanwhile. + if (generation !== searchGeneration || ctx.state.currentEntryId !== entryId) return; if (!response.ok) { const retry = response.error.retryAfter ? ` Retry after ${response.error.retryAfter.toFixed(1)}s.` @@ -262,10 +305,15 @@ export function createJimakuModal( setJimakuStatus('Loading media info...'); resetJimakuLists(); + renderTabs(); + // Media info can resolve after the user already closed the modal or + // started their own search; a stale reply must not touch the inputs. + const generation = searchGeneration; window.electronAPI .getJimakuMediaInfo() .then((info: JimakuMediaInfo) => { + if (generation !== searchGeneration) return; ctx.dom.jimakuTitleInput.value = info.title || ''; ctx.dom.jimakuSeasonInput.value = info.season ? String(info.season) : ''; ctx.dom.jimakuEpisodeInput.value = info.episode ? String(info.episode) : ''; @@ -280,6 +328,7 @@ export function createJimakuModal( } }) .catch(() => { + if (generation !== searchGeneration) return; setJimakuStatus('Failed to load media info.', true); }); } @@ -315,6 +364,18 @@ export function createJimakuModal( return true; } + if (e.key === 'ArrowLeft') { + e.preventDefault(); + setActiveTab('anime'); + return true; + } + + if (e.key === 'ArrowRight') { + e.preventDefault(); + setActiveTab('liveAction'); + return true; + } + if (e.key === 'ArrowDown') { e.preventDefault(); if (ctx.state.jimakuFiles.length > 0) { @@ -367,6 +428,12 @@ export function createJimakuModal( ctx.dom.jimakuCloseButton.addEventListener('click', () => { closeJimakuModal(); }); + ctx.dom.jimakuTabAnimeButton.addEventListener('click', () => { + setActiveTab('anime'); + }); + ctx.dom.jimakuTabLiveActionButton.addEventListener('click', () => { + setActiveTab('liveAction'); + }); ctx.dom.jimakuBroadenButton.addEventListener('click', () => { if (ctx.state.currentEntryId !== null) { ctx.dom.jimakuBroadenButton.classList.add('hidden'); diff --git a/src/renderer/modals/media-timing-frame-picker.test.ts b/src/renderer/modals/media-timing-frame-picker.test.ts new file mode 100644 index 00000000..84c22d46 --- /dev/null +++ b/src/renderer/modals/media-timing-frame-picker.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setTimeout as tick } from 'node:timers/promises'; +import { createMediaTimingFramePicker } from './media-timing-frame-picker'; +import type { MediaTimingReviewFrameRequest, MediaTimingReviewFrameResult } from '../../types/anki'; + +function fixture() { + const requests: Array<{ + request: MediaTimingReviewFrameRequest; + resolve: (result: MediaTimingReviewFrameResult) => void; + }> = []; + let stale = false; + const picker = createMediaTimingFramePicker({ + debounceMs: 0, + load: (request) => new Promise((resolve) => requests.push({ request, resolve })), + onChange: () => {}, + onStale: () => { + stale = true; + picker.close(); + }, + }); + const complete = async (index: number, result: number | MediaTimingReviewFrameResult) => { + requests[index]!.resolve( + typeof result === 'number' + ? { ok: true, timestamp: result, dataUrl: `data:image/jpeg;base64,${result}` } + : result, + ); + await tick(0); + }; + return { picker, requests, complete, isStale: () => stale }; +} + +test('a chosen frame stays fixed until Reset restores midpoint tracking', async () => { + const { picker, requests, complete } = fixture(); + picker.open('r', true, 11); + await tick(5); + await complete(0, 11); + assert.equal(picker.getScreenshotTime(), undefined); + picker.choose(13); + await tick(5); + await complete(1, 13); + picker.updateMidpoint(12); + await tick(5); + assert.equal(picker.getScreenshotTime(), 13); + assert.equal(requests.length, 2); + picker.reset(); + await tick(5); + assert.equal(requests[2]?.request.timestamp, 12); + await complete(2, 12); + assert.equal(picker.getScreenshotTime(), undefined); + picker.updateMidpoint(14); + await tick(5); + assert.equal(requests[3]?.request.timestamp, 14); + await complete(3, 14); + picker.close(); +}); + +test('scrubbing keeps only the latest requested frame', async () => { + const { picker, requests, complete } = fixture(); + picker.open('r', true, 11); + await tick(5); + picker.choose(12); + picker.choose(13); + picker.choose(14); + await tick(5); + assert.equal(requests.length, 1); + assert.equal(picker.getState().blockConfirm, true); + await complete(0, 11); + await tick(5); + assert.equal(picker.getState().timestamp, undefined); + assert.equal(requests[1]?.request.timestamp, 14); + await complete(1, 14); + assert.equal(picker.getScreenshotTime(), 14); + assert.equal(picker.getState().blockConfirm, false); + picker.close(); +}); + +test('failed manual previews block confirmation; Reset allows the default fallback', async () => { + const { picker, complete } = fixture(); + picker.open('r', true, 11); + await tick(5); + await complete(0, { ok: false }); + assert.equal(picker.getState().blockConfirm, false); + picker.choose(12); + await tick(5); + await complete(1, { ok: false }); + assert.equal(picker.getState().blockConfirm, true); + picker.reset(); + assert.equal(picker.getState().blockConfirm, false); + picker.close(); +}); + +test('replacing a review ignores old frames; stale responses close the current review', async () => { + const { picker, requests, complete, isStale } = fixture(); + picker.open('old', true, 11); + await tick(5); + picker.close(); + picker.open('new', true, 22); + await tick(5); + await complete(0, 11); + await tick(5); + assert.equal(picker.getState().timestamp, undefined); + assert.equal(requests[1]?.request.reviewId, 'new'); + await complete(1, { ok: false, stale: true }); + assert.equal(isStale(), true); + assert.equal(picker.getState().enabled, false); +}); + +test('disabled screenshots perform no extraction', async () => { + const { picker, requests } = fixture(); + picker.open('r', false, 11); + picker.updateMidpoint(12); + await tick(5); + assert.equal(requests.length, 0); + picker.close(); +}); diff --git a/src/renderer/modals/media-timing-frame-picker.ts b/src/renderer/modals/media-timing-frame-picker.ts new file mode 100644 index 00000000..41138862 --- /dev/null +++ b/src/renderer/modals/media-timing-frame-picker.ts @@ -0,0 +1,129 @@ +import type { MediaTimingReviewFrameRequest, MediaTimingReviewFrameResult } from '../../types/anki'; + +export interface MediaTimingFramePickerState { + enabled: boolean; + manual: boolean; + loading: boolean; + timestamp?: number; + requestedTime?: number; + dataUrl?: string; + message: string; + blockConfirm: boolean; +} + +/** Coalesces scrubbing into one active extraction and the latest requested frame. */ +export function createMediaTimingFramePicker(options: { + load: (request: MediaTimingReviewFrameRequest) => Promise<MediaTimingReviewFrameResult>; + onChange: (state: MediaTimingFramePickerState) => void; + onStale: () => void; + debounceMs?: number; +}) { + let reviewId: string | null = null; + let midpoint = 0; + let sequence = 0; + let inFlight = false; + let pending: (MediaTimingReviewFrameRequest & { sequence: number }) | null = null; + let timer: ReturnType<typeof setTimeout> | null = null; + let state: MediaTimingFramePickerState = emptyState(); + + function emptyState(): MediaTimingFramePickerState { + return { enabled: false, manual: false, loading: false, message: '', blockConfirm: false }; + } + + function publish(): void { + options.onChange({ ...state }); + } + + async function drain(): Promise<void> { + if (inFlight || !pending) return; + const request = pending; + pending = null; + inFlight = true; + try { + const result = await options.load(request); + if (reviewId !== request.reviewId || sequence !== request.sequence) return; + if (result.stale) { + options.onStale(); + return; + } + if ( + !result.ok || + !Number.isFinite(result.timestamp) || + !result.dataUrl?.startsWith('data:image/jpeg;base64,') + ) { + throw new Error(result.message ?? 'Screenshot preview is unavailable.'); + } + state.timestamp = result.timestamp; + state.dataUrl = result.dataUrl; + state.blockConfirm = false; + state.message = state.manual + ? 'Selected frame stays fixed when you trim audio.' + : 'Following the audio midpoint.'; + } catch (error) { + if (reviewId !== request.reviewId || sequence !== request.sequence) return; + state.message = error instanceof Error ? error.message : 'Screenshot preview is unavailable.'; + state.blockConfirm = state.manual; + } finally { + inFlight = false; + if (reviewId === request.reviewId && sequence === request.sequence) { + state.loading = false; + publish(); + } + if (timer === null) void drain(); + } + } + + function request(timestamp: number, direction?: -1 | 1): void { + if (!reviewId || !state.enabled) return; + sequence += 1; + pending = { reviewId, timestamp, ...(direction ? { direction } : {}), sequence }; + state.requestedTime = timestamp; + state.loading = true; + state.blockConfirm = state.manual; + state.message = 'Loading screenshot…'; + publish(); + if (timer !== null) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + void drain(); + }, options.debounceMs ?? 120); + } + + function close(): void { + sequence += 1; + reviewId = null; + pending = null; + if (timer !== null) clearTimeout(timer); + timer = null; + state = emptyState(); + publish(); + } + + return { + open(id: string, enabled: boolean, time: number) { + close(); + reviewId = id; + midpoint = time; + state.enabled = enabled; + publish(); + if (enabled) request(time); + }, + updateMidpoint(time: number) { + if (midpoint === time) return; + midpoint = time; + if (!state.manual) request(time); + }, + choose(time: number, direction?: -1 | 1) { + if (!Number.isFinite(time) || time < 0) return; + state.manual = true; + request(time, direction); + }, + reset() { + state.manual = false; + request(midpoint); + }, + close, + getState: () => ({ ...state }), + getScreenshotTime: () => (state.manual && !state.blockConfirm ? state.timestamp : undefined), + }; +} diff --git a/src/renderer/modals/media-timing-review.ts b/src/renderer/modals/media-timing-review.ts index 5d9ed85d..52a755b6 100644 --- a/src/renderer/modals/media-timing-review.ts +++ b/src/renderer/modals/media-timing-review.ts @@ -1,3 +1,4 @@ +import { createMediaTimingFramePicker } from './media-timing-frame-picker'; import type { MediaTimingReviewContextLine, MediaTimingReviewDecision, @@ -252,6 +253,45 @@ export function createMediaTimingReviewModal( isModalLayer: ctx.platform.isModalLayer, }); const previewRequest = createMediaTimingPreviewRequestGuard(); + const framePicker = createMediaTimingFramePicker({ + load: (request) => window.electronAPI.getMediaTimingReviewFrame(request), + onChange: () => renderFramePicker(), + onStale: () => closeResolvedReview(), + }); + + function renderFramePicker(): void { + const state = framePicker.getState(); + const dom = ctx.dom; + dom.mediaTimingReviewFramePicker.classList.toggle('hidden', !state.enabled); + dom.mediaTimingReviewFramePicker.setAttribute('aria-busy', String(state.loading)); + dom.mediaTimingReviewFrameSlider.min = String(timelineStart); + dom.mediaTimingReviewFrameSlider.max = String(Math.max(timelineStart, timelineEnd - 0.001)); + const shownTime = state.loading ? state.requestedTime : state.timestamp; + if (shownTime !== undefined) dom.mediaTimingReviewFrameSlider.value = String(shownTime); + dom.mediaTimingReviewFrameTime.textContent = + shownTime === undefined ? '—' : formatMediaTimingTimestamp(shownTime); + dom.mediaTimingReviewFrameSlider.setAttribute( + 'aria-valuetext', + dom.mediaTimingReviewFrameTime.textContent, + ); + dom.mediaTimingReviewFrameStatus.textContent = state.message; + dom.mediaTimingReviewFrameImage.classList.toggle('hidden', !state.dataUrl); + if (state.dataUrl) dom.mediaTimingReviewFrameImage.src = state.dataUrl; + else dom.mediaTimingReviewFrameImage.removeAttribute('src'); + dom.mediaTimingReviewFramePrevious.disabled = + resolveInFlight || + state.loading || + state.timestamp === undefined || + state.timestamp <= timelineStart; + dom.mediaTimingReviewFrameNext.disabled = + resolveInFlight || + state.loading || + state.timestamp === undefined || + state.timestamp >= timelineEnd - 0.001; + dom.mediaTimingReviewFrameSlider.disabled = resolveInFlight; + dom.mediaTimingReviewFrameReset.disabled = resolveInFlight || !state.manual; + dom.mediaTimingReviewConfirm.disabled = resolveInFlight || state.blockConfirm; + } function setStatus(message: string, isError = false): void { ctx.dom.mediaTimingReviewStatus.textContent = message; @@ -308,6 +348,8 @@ export function createMediaTimingReviewModal( } function renderSelection(): void { + framePicker.updateMidpoint(selectionStart + (selectionEnd - selectionStart) / 2); + renderFramePicker(); const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart); const startPercent = ((selectionStart - timelineStart) / span) * 100; const endPercent = ((selectionEnd - timelineStart) / span) * 100; @@ -690,6 +732,7 @@ export function createMediaTimingReviewModal( window.electronAPI.notifyOverlayModalClosed('media-timing-review'); options.syncSettingsModalSubtitleSuppression(); payload = null; + framePicker.close(); if (!options.modalStateReader.isAnyModalOpen()) { ctx.dom.overlay.classList.remove('interactive'); if (ctx.platform.shouldToggleMouseIgnore) { @@ -701,6 +744,7 @@ export function createMediaTimingReviewModal( async function resolveReview(decision: MediaTimingReviewDecision): Promise<void> { if (!payload || resolveInFlight) return; resolveInFlight = true; + renderFramePicker(); stopPreview(); const controls = ctx.dom.mediaTimingReviewModal.querySelectorAll<HTMLButtonElement>('button'); controls.forEach((button) => { @@ -733,12 +777,14 @@ export function createMediaTimingReviewModal( } function confirmSelection(): void { - if (!payload) return; + if (!payload || framePicker.getState().blockConfirm) return; + const screenshotTime = framePicker.getScreenshotTime(); const includesAdjacentLines = previousCount > 0 || nextCount > 0; void resolveReview({ action: 'confirm', startTime: selectionStart, endTime: selectionEnd, + ...(screenshotTime !== undefined ? { screenshotTime } : {}), ...(includesAdjacentLines ? { text: currentLineSelection().sentence } : {}), }); } @@ -818,6 +864,11 @@ export function createMediaTimingReviewModal( nextPayload.noteId !== undefined ? 'Delete card' : "Don't create card"; setStatus(''); showEditor(); + framePicker.open( + nextPayload.reviewId, + nextPayload.screenshotEnabled === true, + selectionStart + (selectionEnd - selectionStart) / 2, + ); renderSelection(); renderSentence(); ctx.state.mediaTimingReviewModalOpen = true; @@ -892,6 +943,28 @@ export function createMediaTimingReviewModal( } function wireDomEvents(): void { + ctx.dom.mediaTimingReviewFrameSlider.addEventListener('input', () => { + if (!resolveInFlight) framePicker.choose(Number(ctx.dom.mediaTimingReviewFrameSlider.value)); + }); + const stepFrame = (direction: -1 | 1) => { + const state = framePicker.getState(); + if (!resolveInFlight && !state.loading && state.timestamp !== undefined) + framePicker.choose(state.timestamp, direction); + }; + ctx.dom.mediaTimingReviewFrameSlider.addEventListener('keydown', (event) => { + if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') { + event.preventDefault(); + stepFrame(-1); + } else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') { + event.preventDefault(); + stepFrame(1); + } + }); + ctx.dom.mediaTimingReviewFramePrevious.addEventListener('click', () => stepFrame(-1)); + ctx.dom.mediaTimingReviewFrameNext.addEventListener('click', () => stepFrame(1)); + ctx.dom.mediaTimingReviewFrameReset.addEventListener('click', () => { + if (!resolveInFlight) framePicker.reset(); + }); const track = ctx.dom.mediaTimingReviewSelectionTrack; track.addEventListener('pointerdown', beginDrag); track.addEventListener('pointermove', (event) => { diff --git a/src/renderer/modals/subtitle-generation-view.test.ts b/src/renderer/modals/subtitle-generation-view.test.ts index fa06f034..87908449 100644 --- a/src/renderer/modals/subtitle-generation-view.test.ts +++ b/src/renderer/modals/subtitle-generation-view.test.ts @@ -3,10 +3,25 @@ import test from 'node:test'; import { describeGenerationModel, describeGenerationProgress, + describeGenerationRecommendation, describeGenerationTools, describeGenerationVad, } from './subtitle-generation-view'; +test('only confirmed NVIDIA CUDA support recommends turbo', () => { + assert.deepEqual(describeGenerationRecommendation({ kind: 'unavailable' }), { + model: 'small', + text: 'NVIDIA CUDA acceleration was not confirmed. small is recommended.', + }); + assert.deepEqual( + describeGenerationRecommendation({ kind: 'nvidia-cuda', gpuName: 'NVIDIA RTX 5070 Ti' }), + { + model: 'large-v3-turbo', + text: 'NVIDIA CUDA is available with NVIDIA RTX 5070 Ti. large-v3-turbo is recommended.', + }, + ); +}); + test('missing tools block generation and list every install instruction', () => { const found = { kind: 'found', path: '/usr/bin/tool' } as const; assert.deepEqual( diff --git a/src/renderer/modals/subtitle-generation-view.ts b/src/renderer/modals/subtitle-generation-view.ts index 1a5fd725..b03fd0f5 100644 --- a/src/renderer/modals/subtitle-generation-view.ts +++ b/src/renderer/modals/subtitle-generation-view.ts @@ -1,11 +1,23 @@ import { missingSubtitleGenerationTools, + recommendedSubtitleGenerationModel, + type SubtitleGenerationAcceleration, type SubtitleGenerationModelStatus, type SubtitleGenerationProgress, type SubtitleGenerationTools, } from '../../shared/subtitle-generation'; import type { SubtitleGenerationStatus } from '../../shared/subtitle-generation-ipc'; +export function describeGenerationRecommendation(acceleration: SubtitleGenerationAcceleration) { + return { + model: recommendedSubtitleGenerationModel(acceleration), + text: + acceleration.kind === 'nvidia-cuda' + ? `NVIDIA CUDA is available with ${acceleration.gpuName}. large-v3-turbo is recommended.` + : 'NVIDIA CUDA acceleration was not confirmed. small is recommended.', + }; +} + export function describeGenerationTools(tools: SubtitleGenerationTools) { const missing = missingSubtitleGenerationTools(tools); if (missing.length > 0) return { ready: false, text: missing.join(' ') }; diff --git a/src/renderer/modals/subtitle-generation.ts b/src/renderer/modals/subtitle-generation.ts index 42f8d11c..fb4fcffa 100644 --- a/src/renderer/modals/subtitle-generation.ts +++ b/src/renderer/modals/subtitle-generation.ts @@ -1,7 +1,6 @@ import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation'; import { SUBTITLE_GENERATION_MODELS, - RECOMMENDED_SUBTITLE_GENERATION_MODEL, formatSubtitleGenerationModelSize, getSubtitleGenerationModel, isSubtitleGenerationModelId, @@ -16,6 +15,7 @@ import { createModalFocusGuard } from './modal-focus-guard'; import { describeGenerationModel, describeGenerationProgress, + describeGenerationRecommendation, describeGenerationTools, describeGenerationVad, } from './subtitle-generation-view'; @@ -70,8 +70,7 @@ export function createSubtitleGenerationModal( for (const model of SUBTITLE_GENERATION_MODELS) { const option = document.createElement('option'); option.value = model.id; - const recommended = model.id === RECOMMENDED_SUBTITLE_GENERATION_MODEL ? ' (recommended)' : ''; - option.textContent = `${model.id}${recommended} · ${formatSubtitleGenerationModelSize(model.size)}`; + option.textContent = `${model.id} · ${formatSubtitleGenerationModelSize(model.size)}`; dom.modelSelect.append(option); } @@ -103,10 +102,15 @@ export function createSubtitleGenerationModal( dom.modelPicker.classList.toggle('hidden', !snapshot || Boolean(snapshot.externalModelPath)); dom.modelSelect.disabled = busy || checking || !snapshot || Boolean(snapshot.externalModelPath); if (snapshot) { + const recommendation = describeGenerationRecommendation(snapshot.acceleration); + for (const option of dom.modelSelect.options) { + if (!isSubtitleGenerationModelId(option.value)) continue; + const model = getSubtitleGenerationModel(option.value); + const recommended = model.id === recommendation.model ? ' (recommended)' : ''; + option.textContent = `${model.id}${recommended} · ${formatSubtitleGenerationModelSize(model.size)}`; + } dom.modelSelect.value = snapshot.managedModel; - dom.modelDescription.textContent = getSubtitleGenerationModel( - snapshot.managedModel, - ).description; + dom.modelDescription.textContent = `${getSubtitleGenerationModel(snapshot.managedModel).description} ${recommendation.text}`; } dom.download.classList.toggle('hidden', !model?.download); dom.download.textContent = snapshot diff --git a/src/renderer/state.ts b/src/renderer/state.ts index fbe6d6ad..fdff7d63 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -7,6 +7,7 @@ import type { TsukihimeEntry, TsukihimeSubtitleFile, JimakuEntry, + JimakuSearchCategory, JimakuFileEntry, KikuDuplicateCardInfo, KikuFieldGroupingChoice, @@ -43,6 +44,7 @@ export type RendererState = { persistedSubtitlePosition: SubtitlePosition; jimakuModalOpen: boolean; + jimakuActiveTab: JimakuSearchCategory; jimakuEntries: JimakuEntry[]; jimakuFiles: JimakuFileEntry[]; selectedEntryIndex: number; @@ -177,6 +179,7 @@ export function createRendererState(): RendererState { persistedSubtitlePosition: { yPercent: 10 }, jimakuModalOpen: false, + jimakuActiveTab: 'anime', jimakuEntries: [], jimakuFiles: [], selectedEntryIndex: 0, diff --git a/src/renderer/style.css b/src/renderer/style.css index c519ec08..16e1f1f1 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -819,12 +819,14 @@ body:focus-visible, grid-template-columns: 1fr 120px auto; } +.jimaku-tabs, .tsukihime-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; } +.jimaku-tab, .tsukihime-tab { min-width: 0; min-height: 34px; @@ -842,6 +844,8 @@ body:focus-visible, text-overflow: ellipsis; } +.jimaku-tab:hover, +.jimaku-tab:focus-visible, .tsukihime-tab:hover, .tsukihime-tab:focus-visible { border-color: rgba(138, 173, 244, 0.48); @@ -849,6 +853,7 @@ body:focus-visible, outline: none; } +.jimaku-tab.active, .tsukihime-tab.active { border-color: rgba(238, 212, 159, 0.62); background: rgba(238, 212, 159, 0.16); @@ -1397,7 +1402,7 @@ body:focus-visible, .media-timing-review-content { width: min(720px, calc(100vw - 32px)); - max-height: min(700px, calc(100vh - 32px)); + max-height: min(900px, calc(100vh - 32px)); overflow: auto; gap: 0; padding: 0; @@ -1475,6 +1480,86 @@ body:focus-visible, padding: 14px 20px 18px; } +.media-timing-frame-picker { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; + align-items: center; + margin: 0 0 16px; + padding: 12px; + border: 1px solid var(--ctp-surface1); + border-radius: 10px; + background: var(--ctp-mantle); +} + +.media-timing-frame-picker.hidden { + display: none; +} +.media-timing-frame-image-shell { + aspect-ratio: 16 / 9; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 6px; + background: var(--ctp-crust); +} +.media-timing-frame-image-shell img { + width: 100%; + height: 100%; + object-fit: contain; +} +.media-timing-frame-picker[aria-busy='true'] img { + opacity: 0.55; +} +.media-timing-frame-heading { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 12px; +} +.media-timing-frame-heading output { + color: var(--ctp-teal); + font-variant-numeric: tabular-nums; +} +.media-timing-frame-controls input { + width: 100%; + margin: 12px 0; + accent-color: var(--ctp-teal); +} +.media-timing-frame-buttons { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.media-timing-frame-buttons button { + padding: 5px 8px; + border: 1px solid var(--ctp-surface2); + border-radius: 6px; + background: var(--ctp-surface0); + color: var(--ctp-text); + font-size: 11px; + cursor: pointer; +} +.media-timing-frame-buttons button:disabled { + opacity: 0.45; + cursor: default; +} +.media-timing-frame-controls p { + margin: 8px 0 0; + min-height: 2.6em; + color: var(--ctp-subtext0); + font-size: 11px; +} +@media (max-width: 520px) { + .media-timing-frame-picker { + grid-template-columns: 1fr; + } + .media-timing-frame-image-shell { + max-height: 180px; + } +} + .media-timing-review-sentence-header { display: flex; align-items: center; @@ -3032,7 +3117,7 @@ iframe[id^='yomitan-popup'], } .subtitle-generation-content { - width: min(580px, 92%); + width: min(860px, 92%); max-height: 92%; border-top: 3px solid var(--ctp-green); } diff --git a/src/renderer/utils/dom.ts b/src/renderer/utils/dom.ts index 014b9923..9729f0e3 100644 --- a/src/renderer/utils/dom.ts +++ b/src/renderer/utils/dom.ts @@ -21,6 +21,8 @@ export type RendererDom = { jimakuFilesSection: HTMLDivElement; jimakuFilesList: HTMLUListElement; jimakuBroadenButton: HTMLButtonElement; + jimakuTabAnimeButton: HTMLButtonElement; + jimakuTabLiveActionButton: HTMLButtonElement; tsukihimeModal: HTMLDivElement; tsukihimeTitleInput: HTMLInputElement; @@ -45,6 +47,14 @@ export type RendererDom = { youtubePickerStatus: HTMLDivElement; youtubePickerTracks: HTMLUListElement; + mediaTimingReviewFramePicker: HTMLDivElement; + mediaTimingReviewFrameImage: HTMLImageElement; + mediaTimingReviewFrameSlider: HTMLInputElement; + mediaTimingReviewFrameTime: HTMLElement; + mediaTimingReviewFrameStatus: HTMLElement; + mediaTimingReviewFramePrevious: HTMLButtonElement; + mediaTimingReviewFrameNext: HTMLButtonElement; + mediaTimingReviewFrameReset: HTMLButtonElement; mediaTimingReviewModal: HTMLDivElement; mediaTimingReviewKind: HTMLDivElement; mediaTimingReviewText: HTMLElement; @@ -223,6 +233,8 @@ export function resolveRendererDom(): RendererDom { jimakuFilesSection: getRequiredElement<HTMLDivElement>('jimakuFilesSection'), jimakuFilesList: getRequiredElement<HTMLUListElement>('jimakuFiles'), jimakuBroadenButton: getRequiredElement<HTMLButtonElement>('jimakuBroaden'), + jimakuTabAnimeButton: getRequiredElement<HTMLButtonElement>('jimakuTabAnime'), + jimakuTabLiveActionButton: getRequiredElement<HTMLButtonElement>('jimakuTabLiveAction'), tsukihimeModal: getRequiredElement<HTMLDivElement>('tsukihimeModal'), tsukihimeTitleInput: getRequiredElement<HTMLInputElement>('tsukihimeTitle'), @@ -251,6 +263,24 @@ export function resolveRendererDom(): RendererDom { youtubePickerStatus: getRequiredElement<HTMLDivElement>('youtubePickerStatus'), youtubePickerTracks: getRequiredElement<HTMLUListElement>('youtubePickerTracks'), + mediaTimingReviewFramePicker: getRequiredElement<HTMLDivElement>( + 'mediaTimingReviewFramePicker', + ), + mediaTimingReviewFrameImage: getRequiredElement<HTMLImageElement>( + 'mediaTimingReviewFrameImage', + ), + mediaTimingReviewFrameSlider: getRequiredElement<HTMLInputElement>( + 'mediaTimingReviewFrameSlider', + ), + mediaTimingReviewFrameTime: getRequiredElement<HTMLElement>('mediaTimingReviewFrameTime'), + mediaTimingReviewFrameStatus: getRequiredElement<HTMLElement>('mediaTimingReviewFrameStatus'), + mediaTimingReviewFramePrevious: getRequiredElement<HTMLButtonElement>( + 'mediaTimingReviewFramePrevious', + ), + mediaTimingReviewFrameNext: getRequiredElement<HTMLButtonElement>('mediaTimingReviewFrameNext'), + mediaTimingReviewFrameReset: getRequiredElement<HTMLButtonElement>( + 'mediaTimingReviewFrameReset', + ), mediaTimingReviewModal: getRequiredElement<HTMLDivElement>('mediaTimingReviewModal'), mediaTimingReviewKind: getRequiredElement<HTMLDivElement>('mediaTimingReviewKind'), mediaTimingReviewText: getRequiredElement<HTMLElement>('mediaTimingReviewText'), diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 8c7c8c7a..5cff1964 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -166,6 +166,7 @@ export const IPC_CHANNELS = { animeBrowserRemoveRepo: 'anime-browser:remove-repo', mediaTimingReviewPreview: 'media-timing-review:preview', mediaTimingReviewWaveform: 'media-timing-review:waveform', + mediaTimingReviewFrame: 'media-timing-review:frame', mediaTimingReviewStopPreview: 'media-timing-review:stop-preview', mediaTimingReviewResolve: 'media-timing-review:resolve', }, diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts index 512a1b5a..40f47721 100644 --- a/src/shared/ipc/validators.ts +++ b/src/shared/ipc/validators.ts @@ -394,7 +394,14 @@ export function parseKikuMergePreviewRequest(value: unknown): KikuMergePreviewRe export function parseJimakuSearchQuery(value: unknown): JimakuSearchQuery | null { if (!isObject(value) || typeof value.query !== 'string') return null; - return { query: value.query }; + if ( + value.category !== undefined && + value.category !== 'anime' && + value.category !== 'liveAction' + ) { + return null; + } + return { query: value.query, category: value.category }; } export function parseJimakuFilesQuery(value: unknown): JimakuFilesQuery | null { diff --git a/src/shared/media-identity.test.ts b/src/shared/media-identity.test.ts new file mode 100644 index 00000000..85ebaa83 --- /dev/null +++ b/src/shared/media-identity.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { sanitizeMediaTitle, toMediaIdentityPath } from './media-identity'; + +test('media identity separates authenticated transport URLs from titles and stats keys', () => { + const url = + 'https://user:password@jellyfin.example/Videos/item-1/stream?api_key=test-secret#token'; + assert.equal(sanitizeMediaTitle(url), null); + assert.equal(sanitizeMediaTitle('stream?static=true&api_key=test-secret'), null); + assert.equal(sanitizeMediaTitle('stream static true api key test secret'), null); + assert.equal(sanitizeMediaTitle('stream%3Fapi_key%3Dtest-secret'), null); + assert.equal(sanitizeMediaTitle(' My Anime S01E02 '), 'My Anime S01E02'); + assert.equal(toMediaIdentityPath(url), 'jellyfin://jellyfin.example/item/item-1'); + assert.equal( + toMediaIdentityPath('https://example.com/base/Videos/item-1/master.m3u8?token=secret'), + 'jellyfin://example.com/item/item-1', + ); + assert.equal(toMediaIdentityPath('stream?api_key=test-secret'), ''); + assert.equal(toMediaIdentityPath('/media/My Anime S01E02.mkv'), '/media/My Anime S01E02.mkv'); +}); + +test('remote stats identities drop credentials while preserving YouTube video identity', () => { + assert.match( + toMediaIdentityPath('https://user:password@example.com/video.mkv?signature=secret#secret'), + /^https:\/\/example\.com\/video\.mkv#query-[a-f0-9]{64}$/, + ); + assert.notEqual( + toMediaIdentityPath('https://example.com/video?id=1'), + toMediaIdentityPath('https://example.com/video?id=2'), + ); + const identity = toMediaIdentityPath('https://example.com/video?id=1'); + assert.equal(toMediaIdentityPath(identity), identity); + assert.equal( + toMediaIdentityPath('https://www.youtube.com/watch?v=video-id&token=secret'), + 'https://www.youtube.com/watch?v=video-id', + ); +}); diff --git a/src/shared/media-identity.ts b/src/shared/media-identity.ts new file mode 100644 index 00000000..68b84f56 --- /dev/null +++ b/src/shared/media-identity.ts @@ -0,0 +1,58 @@ +import { createHash } from 'node:crypto'; + +const URL_SCHEME = /[a-z][a-z0-9+.-]*:\/\//i; +const QUERY_PAIR = /[?&][^=\s&#]+=/; +const CREDENTIAL_LABEL = /\b(?:api[_ -]?key|access[_ -]?token|x[_ -]?emby[_ -]?token)\b/i; + +/** mpv can report the URL or its query-bearing basename before metadata arrives. */ +export function sanitizeMediaTitle(value: string | null | undefined): string | null { + const title = value?.trim(); + if (!title) return null; + let decoded = title; + try { + decoded = decodeURIComponent(title); + } catch { + // Ordinary titles can contain a literal percent sign. + } + if (URL_SCHEME.test(decoded) || QUERY_PAIR.test(decoded) || CREDENTIAL_LABEL.test(decoded)) { + return null; + } + return title; +} + +export function resolveMediaLookupTarget( + mediaPath: string | null, + mediaTitle: string | null, +): string | null { + return sanitizeMediaTitle(mediaPath) ?? sanitizeMediaTitle(mediaTitle); +} + +/** Persistent identity, never a URL to use for authenticated media retrieval. */ +export function toMediaIdentityPath(mediaPath: string): string { + const value = mediaPath.trim(); + if (!URL_SCHEME.test(value)) return sanitizeMediaTitle(value) ?? ''; + try { + const url = new URL(value); + const jellyfinItem = url.pathname.match( + /\/Videos\/([^/]+)\/(?:stream(?:\.[^/]*)?|master\.m3u8)\/?$/i, + ); + if (jellyfinItem) return `jellyfin://${url.host}/item/${jellyfinItem[1]}`; + url.username = ''; + url.password = ''; + const query = url.search; + const queryFingerprint = /^#query-[a-f0-9]{64}$/.test(url.hash) ? url.hash : ''; + const youtubeId = /^(?:www\.|m\.)?youtube\.com$/i.test(url.hostname) + ? url.searchParams.get('v') + : null; + url.search = ''; + url.hash = queryFingerprint; + if (youtubeId) url.searchParams.set('v', youtubeId); + else if (query) { + // Different query-selected videos must not collapse into one stats entry. + url.hash = `query-${createHash('sha256').update(query).digest('hex')}`; + } + return url.toString(); + } catch { + return ''; + } +} diff --git a/src/shared/media-kind.ts b/src/shared/media-kind.ts new file mode 100644 index 00000000..5899bb58 --- /dev/null +++ b/src/shared/media-kind.ts @@ -0,0 +1,3 @@ +export const MEDIA_KINDS = ['anime', 'youtube'] as const; + +export type MediaKind = (typeof MEDIA_KINDS)[number]; diff --git a/src/shared/subtitle-generation-ipc.ts b/src/shared/subtitle-generation-ipc.ts index ebf30d1d..c508bf85 100644 --- a/src/shared/subtitle-generation-ipc.ts +++ b/src/shared/subtitle-generation-ipc.ts @@ -1,4 +1,5 @@ import type { + SubtitleGenerationAcceleration, SubtitleGenerationConfig, SubtitleGenerationModelStatus, SubtitleGenerationProgress, @@ -13,6 +14,7 @@ export interface SubtitleGenerationStatus { model: SubtitleGenerationModelStatus; vad: { enabled: boolean; model: SubtitleGenerationModelStatus }; tools: SubtitleGenerationTools; + acceleration: SubtitleGenerationAcceleration; managedModel: SubtitleGenerationConfig['managedModel']; externalModelPath: string | null; mediaPath: string | null; diff --git a/src/shared/subtitle-generation-model-catalog.ts b/src/shared/subtitle-generation-model-catalog.ts index 9c0583d3..b889030a 100644 --- a/src/shared/subtitle-generation-model-catalog.ts +++ b/src/shared/subtitle-generation-model-catalog.ts @@ -45,7 +45,7 @@ const MODEL_CATALOG = { id: 'small', size: 487601967, sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b', - description: 'Recommended starting point for accuracy and processing time.', + description: 'Balances accuracy and processing time with modest memory requirements.', }, 'small-q5_1': { id: 'small-q5_1', diff --git a/src/shared/subtitle-generation.ts b/src/shared/subtitle-generation.ts index 55609910..7da146f2 100644 --- a/src/shared/subtitle-generation.ts +++ b/src/shared/subtitle-generation.ts @@ -41,6 +41,16 @@ export type SubtitleGenerationToolStatus = | { kind: 'found'; path: string } | { kind: 'missing'; message: string }; +export type SubtitleGenerationAcceleration = + | { kind: 'nvidia-cuda'; gpuName: string } + | { kind: 'unavailable' }; + +export function recommendedSubtitleGenerationModel(acceleration: SubtitleGenerationAcceleration) { + return acceleration.kind === 'nvidia-cuda' + ? 'large-v3-turbo' + : RECOMMENDED_SUBTITLE_GENERATION_MODEL; +} + /** Executables generation depends on. `vad` is null unless dialogue mode is on. */ export interface SubtitleGenerationTools { ffmpeg: SubtitleGenerationToolStatus; diff --git a/src/types/anki.ts b/src/types/anki.ts index 3329e305..2874b1a4 100644 --- a/src/types/anki.ts +++ b/src/types/anki.ts @@ -21,6 +21,8 @@ export interface MediaTimingReviewRequest { noteId?: number; audioPadding: number; maxMediaDuration: number; + /** Still screenshots only; animated images continue to follow the audio range. */ + screenshotEnabled?: boolean; } /** A subtitle line adjacent to the mined one that the review can pull onto the card. */ @@ -32,7 +34,13 @@ export interface MediaTimingReviewContextLine { export type MediaTimingReviewDecision = /** `text` is set when the review combined adjacent lines into the card sentence. */ - | { action: 'confirm'; startTime: number; endTime: number; text?: string } + | { + action: 'confirm'; + startTime: number; + endTime: number; + text?: string; + screenshotTime?: number; + } | { action: 'use-original' } | { action: 'skip-media' } | { action: 'discard' }; @@ -53,6 +61,19 @@ export interface MediaTimingReviewOpenPayload { timelineEndTime: number; mediaDuration?: number; maxMediaDuration: number; + screenshotEnabled?: boolean; +} + +export interface MediaTimingReviewFrameRequest { + reviewId: string; + timestamp: number; + /** Step to the adjacent decoded frame instead of seeking to a time. */ + direction?: -1 | 1; +} + +export interface MediaTimingReviewFrameResult extends MediaTimingReviewActionResult { + dataUrl?: string; + timestamp?: number; } export interface MediaTimingReviewPreviewRequest { diff --git a/src/types/integrations.ts b/src/types/integrations.ts index d7a282a3..0152e3d0 100644 --- a/src/types/integrations.ts +++ b/src/types/integrations.ts @@ -224,8 +224,12 @@ export interface JimakuMediaInfo { rawTitle: string; } +export type JimakuSearchCategory = 'anime' | 'liveAction'; + export interface JimakuSearchQuery { query: string; + // Which Jimaku catalogue to search; defaults to anime when omitted. + category?: JimakuSearchCategory; } export interface JimakuEntryFlags { diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 9c999a54..f2242b53 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -8,6 +8,8 @@ import type { MediaTimingReviewOpenPayload, MediaTimingReviewPreviewRequest, MediaTimingReviewResolveRequest, + MediaTimingReviewFrameRequest, + MediaTimingReviewFrameResult, MediaTimingReviewWaveformRequest, MediaTimingReviewWaveformResult, } from './anki'; @@ -553,6 +555,9 @@ export interface ElectronAPI { previewMediaTimingReview: ( request: MediaTimingReviewPreviewRequest, ) => Promise<MediaTimingReviewActionResult>; + getMediaTimingReviewFrame: ( + request: MediaTimingReviewFrameRequest, + ) => Promise<MediaTimingReviewFrameResult>; getMediaTimingReviewWaveform: ( request: MediaTimingReviewWaveformRequest, ) => Promise<MediaTimingReviewWaveformResult>; diff --git a/src/types/stats-wire.ts b/src/types/stats-wire.ts index 40ff0865..c65e1d9f 100644 --- a/src/types/stats-wire.ts +++ b/src/types/stats-wire.ts @@ -1,3 +1,4 @@ +import type { MediaKind } from '../shared/media-kind'; export interface SessionSummary { sessionId: number; canonicalTitle: string | null; @@ -240,6 +241,7 @@ export const EventType = { export type EventType = (typeof EventType)[keyof typeof EventType]; export interface AnimeLibraryItem { + mediaKind: MediaKind; animeId: number; canonicalTitle: string; anilistId: number | null; @@ -261,6 +263,7 @@ export interface AnilistEntry { export interface AnimeDetailData { detail: { + mediaKind: MediaKind; animeId: number; canonicalTitle: string; anilistId: number | null; diff --git a/src/types/subtitle.ts b/src/types/subtitle.ts index 40de3151..64b0226d 100644 --- a/src/types/subtitle.ts +++ b/src/types/subtitle.ts @@ -246,6 +246,8 @@ export interface SubtitleMiningContext { capturedAtMs?: number; /** Explicit generator padding. Confirmed timing-review ranges set this to zero. */ mediaPaddingSeconds?: number; + /** Independent still screenshot selected during media timing review. */ + screenshotTime?: number; } export interface SubtitleHoverTokenPayload { diff --git a/stats/src/components/anime/AnimeCard.test.tsx b/stats/src/components/anime/AnimeCard.test.tsx index bcbd3c00..7d087b31 100644 --- a/stats/src/components/anime/AnimeCard.test.tsx +++ b/stats/src/components/anime/AnimeCard.test.tsx @@ -7,6 +7,7 @@ test('AnimeCard includes linked AniList id in cover URLs to avoid stale library const markup = renderToStaticMarkup( <AnimeCard anime={{ + mediaKind: 'anime', animeId: 42, canonicalTitle: 'Test Anime', anilistId: 21699, diff --git a/stats/src/components/anime/AnimeCard.tsx b/stats/src/components/anime/AnimeCard.tsx index c22658e7..fdb67be7 100644 --- a/stats/src/components/anime/AnimeCard.tsx +++ b/stats/src/components/anime/AnimeCard.tsx @@ -48,7 +48,8 @@ export function AnimeCard({ <div className="p-3"> <div className="text-sm font-medium text-ctp-text truncate">{anime.canonicalTitle}</div> <div className="text-xs text-ctp-overlay2 mt-1"> - {anime.episodeCount} episode{anime.episodeCount !== 1 ? 's' : ''} + {anime.episodeCount} {anime.mediaKind === 'youtube' ? 'video' : 'episode'} + {anime.episodeCount !== 1 ? 's' : ''} </div> <div className="text-xs text-ctp-overlay2"> {formatDuration(anime.totalActiveMs)} · {formatNumber(anime.totalCards)} cards diff --git a/stats/src/components/anime/AnimeCoverImage.test.tsx b/stats/src/components/anime/AnimeCoverImage.test.tsx index dfaf927f..bed2148c 100644 --- a/stats/src/components/anime/AnimeCoverImage.test.tsx +++ b/stats/src/components/anime/AnimeCoverImage.test.tsx @@ -16,6 +16,7 @@ test('AnimeHeader uses the linked AniList id to avoid stale cached cover art', ( const markup = renderToStaticMarkup( <AnimeHeader detail={{ + mediaKind: 'anime', animeId: 42, canonicalTitle: 'Test Anime', anilistId: 21699, diff --git a/stats/src/components/anime/AnimeDetailView.tsx b/stats/src/components/anime/AnimeDetailView.tsx index df1405ce..b185675b 100644 --- a/stats/src/components/anime/AnimeDetailView.tsx +++ b/stats/src/components/anime/AnimeDetailView.tsx @@ -168,7 +168,7 @@ export function AnimeDetailView({ if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>; if (error) return <div className="text-ctp-red p-4">Error: {error}</div>; - if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>; + if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Library entry not found</div>; const { detail, episodes, anilistEntries } = data; @@ -226,6 +226,7 @@ export function AnimeDetailView({ <AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} /> <EpisodeList episodes={episodes} + mediaKind={detail.mediaKind} animeId={animeId} onEpisodeMoved={(removedPreviousAnime) => { onEpisodeMoved?.(); @@ -237,7 +238,7 @@ export function AnimeDetailView({ /> <AnimeWatchChart animeId={animeId} /> <AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} /> - {showAnilistSelector && ( + {detail.mediaKind === 'anime' && showAnilistSelector && ( <AnilistSelector animeId={animeId} initialQuery={detail.canonicalTitle} diff --git a/stats/src/components/anime/AnimeDialogAccessibility.test.tsx b/stats/src/components/anime/AnimeDialogAccessibility.test.tsx index ea5dbea6..5f202307 100644 --- a/stats/src/components/anime/AnimeDialogAccessibility.test.tsx +++ b/stats/src/components/anime/AnimeDialogAccessibility.test.tsx @@ -46,6 +46,7 @@ function installDom(): () => void { function libraryItem(animeId: number, title: string): AnimeLibraryItem { return { + mediaKind: 'anime', animeId, canonicalTitle: title, anilistId: null, diff --git a/stats/src/components/anime/AnimeHeader.test.tsx b/stats/src/components/anime/AnimeHeader.test.tsx index 9f542a37..3d19d0ca 100644 --- a/stats/src/components/anime/AnimeHeader.test.tsx +++ b/stats/src/components/anime/AnimeHeader.test.tsx @@ -6,6 +6,7 @@ import { confirmAnimeDelete, setDeleteConfirmPresenter } from '../../lib/delete- import type { AnimeDetailData } from '../../types/stats'; const DETAIL: AnimeDetailData['detail'] = { + mediaKind: 'anime', animeId: 3, canonicalTitle: 'Project Radio Noise Season 2', anilistId: 20661, @@ -69,3 +70,16 @@ test('confirmAnimeDelete spells out how much data the entry deletion removes', a assert.match(seen[1] ?? '', /3 episodes/); assert.match(seen[0] ?? '', /every session and stat/); }); + +test('YouTube channel headers show videos and omit all AniList controls', () => { + const markup = renderToStaticMarkup( + <AnimeHeader + detail={{ ...DETAIL, mediaKind: 'youtube' }} + anilistEntries={[{ anilistId: 20661, titleRomaji: null, titleEnglish: null, season: null }]} + onChangeAnilist={() => {}} + />, + ); + assert.match(markup, /YouTube channel/); + assert.match(markup, /video/); + assert.doesNotMatch(markup, /AniList|anilist\.co|episode/); +}); diff --git a/stats/src/components/anime/AnimeHeader.tsx b/stats/src/components/anime/AnimeHeader.tsx index a6871cc3..bbedb268 100644 --- a/stats/src/components/anime/AnimeHeader.tsx +++ b/stats/src/components/anime/AnimeHeader.tsx @@ -37,6 +37,7 @@ export function AnimeHeader({ onDeleteAnime, isDeletingAnime = false, }: AnimeHeaderProps) { + const isYoutube = detail.mediaKind === 'youtube'; const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter( (t): t is string => t != null && t !== detail.canonicalTitle, ); @@ -61,33 +62,36 @@ export function AnimeHeader({ </div> )} <div className="text-sm text-ctp-subtext0 mt-2"> - {detail.episodeCount} episode{detail.episodeCount !== 1 ? 's' : ''} + {isYoutube ? 'YouTube channel · ' : ''} + {detail.episodeCount} {isYoutube ? 'video' : 'episode'} + {detail.episodeCount !== 1 ? 's' : ''} </div> <div className="flex flex-wrap gap-1.5 mt-2"> - {anilistEntries.length > 0 ? ( - hasMultipleEntries ? ( - anilistEntries.map((entry) => <AnilistButton key={entry.anilistId} entry={entry} />) - ) : ( + {!isYoutube && + (anilistEntries.length > 0 ? ( + hasMultipleEntries ? ( + anilistEntries.map((entry) => <AnilistButton key={entry.anilistId} entry={entry} />) + ) : ( + <a + href={`https://anilist.co/anime/${anilistEntries[0]!.anilistId}`} + target="_blank" + rel="noopener noreferrer" + className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors" + > + View on AniList <span className="text-[10px]">{'\u2197'}</span> + </a> + ) + ) : detail.anilistId ? ( <a - href={`https://anilist.co/anime/${anilistEntries[0]!.anilistId}`} + href={`https://anilist.co/anime/${detail.anilistId}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors" > View on AniList <span className="text-[10px]">{'\u2197'}</span> </a> - ) - ) : detail.anilistId ? ( - <a - href={`https://anilist.co/anime/${detail.anilistId}`} - target="_blank" - rel="noopener noreferrer" - className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors" - > - View on AniList <span className="text-[10px]">{'\u2197'}</span> - </a> - ) : null} - {onChangeAnilist && ( + ) : null)} + {!isYoutube && onChangeAnilist && ( <button type="button" onClick={onChangeAnilist} diff --git a/stats/src/components/anime/AnimeMergeFlow.test.tsx b/stats/src/components/anime/AnimeMergeFlow.test.tsx index 64c7a808..f4644e8b 100644 --- a/stats/src/components/anime/AnimeMergeFlow.test.tsx +++ b/stats/src/components/anime/AnimeMergeFlow.test.tsx @@ -45,6 +45,7 @@ function installDom(): () => void { function libraryItem(animeId: number, title: string, episodeCount: number): AnimeLibraryItem { return { + mediaKind: 'anime', animeId, canonicalTitle: title, anilistId: null, @@ -68,7 +69,9 @@ function findButton(container: Element, label: string): HTMLElement { /** Library cards only expose aria-pressed while selection mode is on. */ function cardButtons(container: Element): HTMLButtonElement[] { - return [...container.querySelectorAll('button[aria-pressed]')] as unknown as HTMLButtonElement[]; + return [ + ...container.querySelectorAll('.grid button[aria-pressed]'), + ] as unknown as HTMLButtonElement[]; } function mergeButton(container: Element): HTMLButtonElement { diff --git a/stats/src/components/anime/AnimeOverviewStats.tsx b/stats/src/components/anime/AnimeOverviewStats.tsx index 434a6a08..f03b4523 100644 --- a/stats/src/components/anime/AnimeOverviewStats.tsx +++ b/stats/src/components/anime/AnimeOverviewStats.tsx @@ -53,19 +53,19 @@ export function AnimeOverviewStats({ detail, knownWordsSummary }: AnimeOverviewS label="Watch Time" value={formatDuration(detail.totalActiveMs)} color="text-ctp-blue" - tooltip="Total active watch time for this anime" + tooltip="Total active watch time for this title" /> <Metric label="Sessions" value={String(detail.totalSessions)} color="text-ctp-peach" - tooltip="Number of immersion sessions on this anime" + tooltip="Number of immersion sessions on this title" /> <Metric - label="Episodes" + label={detail.mediaKind === 'youtube' ? 'Videos' : 'Episodes'} value={String(detail.episodeCount)} color="text-ctp-yellow" - tooltip="Number of completed episodes for this anime" + tooltip={`Number of tracked ${detail.mediaKind === 'youtube' ? 'videos' : 'episodes'} for this title`} /> <Metric label="Words Seen" @@ -81,7 +81,7 @@ export function AnimeOverviewStats({ detail, knownWordsSummary }: AnimeOverviewS label="Cards Mined" value={formatNumber(detail.totalCards)} color="text-ctp-cards-mined" - tooltip="Anki cards created from subtitle lines in this anime" + tooltip="Anki cards created from subtitle lines in this title" /> <Metric label="Lookups" @@ -109,7 +109,7 @@ export function AnimeOverviewStats({ detail, knownWordsSummary }: AnimeOverviewS label="Known Words" value={`${knownPct}%`} color="text-ctp-green" - tooltip={`${formatNumber(knownWordsSummary!.knownWordCount)} known out of ${formatNumber(knownWordsSummary!.totalUniqueWords)} unique words in this anime`} + tooltip={`${formatNumber(knownWordsSummary!.knownWordCount)} known out of ${formatNumber(knownWordsSummary!.totalUniqueWords)} unique words in this title`} /> ) : ( <Metric diff --git a/stats/src/components/anime/AnimeTab.test.tsx b/stats/src/components/anime/AnimeTab.test.tsx index c02d9590..9118b288 100644 --- a/stats/src/components/anime/AnimeTab.test.tsx +++ b/stats/src/components/anime/AnimeTab.test.tsx @@ -45,6 +45,7 @@ function installDom(): () => void { function libraryItem(anilistId: number | null): AnimeLibraryItem { return { + mediaKind: 'anime', animeId: 7, canonicalTitle: 'Test Anime Season 2', anilistId, @@ -61,6 +62,7 @@ function libraryItem(anilistId: number | null): AnimeLibraryItem { function detailData(anilistId: number | null): AnimeDetailData { return { detail: { + mediaKind: 'anime', animeId: 7, canonicalTitle: 'Test Anime Season 2', anilistId, @@ -177,3 +179,55 @@ test('AnimeTab refetches the library after the AniList entry is relinked', async uninstallDom(); } }); + +test('Library kind filter separates YouTube channels from anime and updates totals', async () => { + const uninstallDom = installDom(); + const original = { + getAnimeLibrary: apiClient.getAnimeLibrary, + getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations, + }; + apiClient.getAnimeLibrary = async () => [ + { ...libraryItem(null), totalActiveMs: 60_000 }, + { + ...libraryItem(null), + animeId: 8, + mediaKind: 'youtube', + canonicalTitle: 'Language Channel', + totalActiveMs: 120_000, + }, + ]; + apiClient.getAnimeMergeRecommendations = async () => ({ recommendations: [] }); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + try { + await act(async () => { + root.render(<AnimeTab />); + }); + assert.match(container.textContent ?? '', /Test Anime Season 2/); + assert.match(container.textContent ?? '', /Language Channel/); + assert.match(container.textContent ?? '', /2 titles · 3m/); + await act(async () => { + findButton(container, 'YouTube').click(); + }); + assert.doesNotMatch(container.textContent ?? '', /Test Anime Season 2/); + assert.match(container.textContent ?? '', /Language Channel/); + assert.match(container.textContent ?? '', /1 channel · 2m/); + assert.equal(findButton(container, 'YouTube').getAttribute('aria-pressed'), 'true'); + await act(async () => { + findButton(container, 'Anime').click(); + }); + assert.match(container.textContent ?? '', /Test Anime Season 2/); + assert.doesNotMatch(container.textContent ?? '', /Language Channel/); + await act(async () => { + findButton(container, 'All Titles').click(); + }); + assert.match(container.textContent ?? '', /Language Channel/); + } finally { + await act(async () => { + root.unmount(); + }); + Object.assign(apiClient, original); + uninstallDom(); + } +}); diff --git a/stats/src/components/anime/AnimeTab.tsx b/stats/src/components/anime/AnimeTab.tsx index a6cc45b6..149d6876 100644 --- a/stats/src/components/anime/AnimeTab.tsx +++ b/stats/src/components/anime/AnimeTab.tsx @@ -1,3 +1,4 @@ +import { MEDIA_KINDS, type MediaKind } from '../../../../src/shared/media-kind'; import { useState, useMemo, useEffect } from 'react'; import { useAnimeLibrary } from '../../hooks/useAnimeLibrary'; import { formatDuration } from '../../lib/formatters'; @@ -67,6 +68,7 @@ export function AnimeTab({ clearRecommendation, } = useAnimeLibrary(); const [search, setSearch] = useState(''); + const [mediaKind, setMediaKind] = useState<MediaKind | 'all'>('all'); const [sortKey, setSortKey] = useState<SortKey>('lastWatched'); const [cardSize, setCardSize] = useState<LibraryCardSize>(() => readLibraryCardSizePreference( @@ -108,16 +110,18 @@ export function AnimeTab({ }, [initialAnimeId, onClearInitialAnime]); const filtered = useMemo(() => { + const entries = anime.filter((entry) => mediaKind === 'all' || entry.mediaKind === mediaKind); const base = search.trim() - ? anime.filter((a) => a.canonicalTitle.toLowerCase().includes(search.toLowerCase())) - : anime; + ? entries.filter((a) => a.canonicalTitle.toLowerCase().includes(search.toLowerCase())) + : entries; return sortAnime(base, sortKey); - }, [anime, search, sortKey]); + }, [anime, search, sortKey, mediaKind]); - const totalMs = anime.reduce((sum, a) => sum + a.totalActiveMs, 0); + const totalMs = filtered.reduce((sum, a) => sum + a.totalActiveMs, 0); const checkedEntries = checkedAnimeIds .map((animeId) => anime.find((entry) => entry.animeId === animeId)) .filter((entry): entry is (typeof anime)[number] => entry !== undefined); + const mixedKindsChecked = new Set(checkedEntries.map((entry) => entry.mediaKind)).size > 1; const hydratedRecommendations = recommendations .map((recommendation) => ({ ...recommendation, @@ -125,7 +129,12 @@ export function AnimeTab({ .map((animeId) => anime.find((entry) => entry.animeId === animeId)) .filter((entry): entry is (typeof anime)[number] => entry !== undefined), })) - .filter((recommendation) => recommendation.entries.length >= 2); + .filter( + (recommendation) => + recommendation.entries.length >= 2 && + (mediaKind === 'all' || + recommendation.entries.every((entry) => entry.mediaKind === mediaKind)), + ); const activeRecommendation = hydratedRecommendations[0] ?? null; const reviewEntries = (reviewAnimeIds ?? []) .map((animeId) => anime.find((entry) => entry.animeId === animeId)) @@ -155,7 +164,31 @@ export function AnimeTab({ return ( <div className="space-y-4"> - <div className="flex items-center gap-3"> + <div className="flex flex-wrap items-center gap-3"> + <div + className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1" + aria-label="Media kind" + role="group" + > + {(['all', ...MEDIA_KINDS] as const).map((kind) => ( + <button + key={kind} + type="button" + aria-pressed={mediaKind === kind} + onClick={() => { + setMediaKind(kind); + exitSelectionMode(); + }} + className={`px-3 py-1.5 rounded-md text-xs transition-colors ${ + mediaKind === kind + ? 'bg-ctp-surface2 text-ctp-text' + : 'text-ctp-overlay2 hover:text-ctp-subtext0' + }`} + > + {kind === 'all' ? 'All Titles' : kind === 'youtube' ? 'YouTube' : 'Anime'} + </button> + ))} + </div> <input type="text" placeholder="Search library..." @@ -170,7 +203,7 @@ export function AnimeTab({ > {SORT_OPTIONS.map((opt) => ( <option key={opt.key} value={opt.key}> - {opt.label} + {opt.key === 'episodes' && mediaKind === 'youtube' ? 'Videos' : opt.label} </option> ))} </select> @@ -202,7 +235,15 @@ export function AnimeTab({ {selectionMode ? 'Cancel' : 'Select'} </button> <div className="text-xs text-ctp-overlay2 shrink-0"> - {filtered.length} titles · {formatDuration(totalMs)} + {filtered.length}{' '} + {mediaKind === 'youtube' + ? filtered.length === 1 + ? 'channel' + : 'channels' + : filtered.length === 1 + ? 'title' + : 'titles'}{' '} + · {formatDuration(totalMs)} </div> </div> @@ -227,11 +268,13 @@ export function AnimeTab({ <div className="text-xs text-ctp-overlay2"> {checkedEntries.length === 0 ? 'Pick the duplicate entries to combine' - : `${checkedEntries.length} selected`} + : mixedKindsChecked + ? 'Anime and YouTube channels cannot be combined' + : `${checkedEntries.length} selected`} </div> <button type="button" - disabled={checkedEntries.length < 2} + disabled={checkedEntries.length < 2 || mixedKindsChecked} onClick={() => setShowMergeDialog(true)} className="px-3 py-1.5 rounded-lg bg-ctp-blue/15 border border-ctp-blue/40 text-xs text-ctp-blue hover:bg-ctp-blue/25 transition-colors disabled:opacity-40 disabled:cursor-not-allowed" > diff --git a/stats/src/components/anime/EpisodeList.tsx b/stats/src/components/anime/EpisodeList.tsx index daddafdc..adf05b3b 100644 --- a/stats/src/components/anime/EpisodeList.tsx +++ b/stats/src/components/anime/EpisodeList.tsx @@ -6,6 +6,7 @@ import { buildLookupRateDisplay } from '../../lib/yomitan-lookup'; import { EpisodeDetail } from './EpisodeDetail'; import { LibraryEntryPicker } from './LibraryEntryPicker'; import type { AnimeEpisode } from '../../types/stats'; +import type { MediaKind } from '../../../../src/shared/media-kind'; /** * Row actions that only appear on hover. Keyboard focus and pointers with no @@ -17,6 +18,8 @@ const HOVER_REVEALED = interface EpisodeListProps { episodes: AnimeEpisode[]; + /** Kind of the owning entry; the move picker only offers entries of the same kind. */ + mediaKind?: MediaKind; /** Entry these episodes currently belong to; excluded from the move picker. */ animeId?: number; onEpisodeDeleted?: () => void; @@ -27,11 +30,13 @@ interface EpisodeListProps { export function EpisodeList({ episodes: initialEpisodes, + mediaKind = 'anime', animeId, onEpisodeDeleted, onEpisodeMoved, onOpenDetail, }: EpisodeListProps) { + const isYoutube = mediaKind === 'youtube'; const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null); const [episodes, setEpisodes] = useState(initialEpisodes); const [movingEpisode, setMovingEpisode] = useState<AnimeEpisode | null>(null); @@ -90,7 +95,7 @@ export function EpisodeList({ return ( <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <div className="flex items-center justify-between mb-3"> - <h3 className="text-sm font-semibold text-ctp-text">Episodes</h3> + <h3 className="text-sm font-semibold text-ctp-text">{isYoutube ? 'Videos' : 'Episodes'}</h3> <span className="text-xs text-ctp-overlay2"> {watchedCount}/{episodes.length} watched </span> @@ -178,7 +183,7 @@ export function EpisodeList({ onOpenDetail(ep.videoId); }} className="px-2 py-1 rounded border border-ctp-surface2 text-[11px] text-ctp-blue hover:border-ctp-blue/50 hover:bg-ctp-blue/10 transition-colors" - title="Open episode details" + title={isYoutube ? 'Open video details' : 'Open episode details'} > Details </button> @@ -218,8 +223,8 @@ export function EpisodeList({ void handleDeleteEpisode(ep.videoId, ep.canonicalTitle); }} className={`w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red focus-visible:text-ctp-red hover:bg-ctp-red/10 transition-colors text-xs flex items-center justify-center ${HOVER_REVEALED}`} - title="Delete episode" - aria-label="Delete episode" + title={isYoutube ? 'Delete video' : 'Delete episode'} + aria-label={isYoutube ? 'Delete video' : 'Delete episode'} > {'\u2715'} </button> @@ -243,6 +248,7 @@ export function EpisodeList({ <LibraryEntryPicker heading={`Move "${movingEpisode.canonicalTitle}" To`} excludeAnimeIds={animeId != null ? [animeId] : []} + mediaKind={mediaKind} busyAnimeId={moveTargetId} error={moveError} onSelect={(entry) => void handleMoveEpisode(movingEpisode.videoId, entry.animeId)} diff --git a/stats/src/components/anime/EpisodeMove.test.tsx b/stats/src/components/anime/EpisodeMove.test.tsx index 57a40966..acebee95 100644 --- a/stats/src/components/anime/EpisodeMove.test.tsx +++ b/stats/src/components/anime/EpisodeMove.test.tsx @@ -63,6 +63,7 @@ function episode(videoId: number, title: string): AnimeEpisode { function libraryItem(animeId: number, title: string): AnimeLibraryItem { return { + mediaKind: 'anime', animeId, canonicalTitle: title, anilistId: null, diff --git a/stats/src/components/anime/LibraryEntryPicker.tsx b/stats/src/components/anime/LibraryEntryPicker.tsx index 222d9519..c4c27aea 100644 --- a/stats/src/components/anime/LibraryEntryPicker.tsx +++ b/stats/src/components/anime/LibraryEntryPicker.tsx @@ -4,11 +4,14 @@ import { formatDuration } from '../../lib/formatters'; import { useModalFocus } from '../../hooks/useModalFocus'; import { AnimeCoverImage } from './AnimeCoverImage'; import type { AnimeLibraryItem } from '../../types/stats'; +import type { MediaKind } from '../../../../src/shared/media-kind'; interface LibraryEntryPickerProps { heading: string; /** Entries that cannot be picked, typically the one being moved away from. */ excludeAnimeIds?: number[]; + /** When set, only entries of this kind are offered. */ + mediaKind?: MediaKind; initialQuery?: string; busyAnimeId?: number | null; error?: string | null; @@ -19,6 +22,7 @@ interface LibraryEntryPickerProps { export function LibraryEntryPicker({ heading, excludeAnimeIds = [], + mediaKind, initialQuery = '', busyAnimeId = null, error = null, @@ -69,9 +73,10 @@ export function LibraryEntryPicker({ const term = query.trim().toLowerCase(); return (entries ?? []) .filter((entry) => !excluded.has(entry.animeId)) + .filter((entry) => mediaKind === undefined || entry.mediaKind === mediaKind) .filter((entry) => !term || entry.canonicalTitle.toLowerCase().includes(term)) .sort((a, b) => b.lastWatchedMs - a.lastWatchedMs); - }, [entries, excluded, query]); + }, [entries, excluded, mediaKind, query]); return ( <div @@ -150,8 +155,8 @@ export function LibraryEntryPicker({ <div className="min-w-0 flex-1"> <div className="text-sm text-ctp-text truncate">{entry.canonicalTitle}</div> <div className="text-xs text-ctp-overlay2 mt-0.5"> - {entry.episodeCount} episode{entry.episodeCount !== 1 ? 's' : ''} ·{' '} - {formatDuration(entry.totalActiveMs)} + {entry.episodeCount} {entry.mediaKind === 'youtube' ? 'video' : 'episode'} + {entry.episodeCount !== 1 ? 's' : ''} · {formatDuration(entry.totalActiveMs)} </div> </div> {busyAnimeId === entry.animeId ? ( diff --git a/stats/src/lib/yomitan-lookup.test.tsx b/stats/src/lib/yomitan-lookup.test.tsx index 145f39d2..f364becc 100644 --- a/stats/src/lib/yomitan-lookup.test.tsx +++ b/stats/src/lib/yomitan-lookup.test.tsx @@ -116,6 +116,7 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => { detail={{ animeId: 1, canonicalTitle: 'Anime', + mediaKind: 'anime', anilistId: null, titleRomaji: null, titleEnglish: null,