mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 05:16:20 -07:00
feat: expand subtitle and media tracking workflows
- Add reference-guided subtitle timing, frame picking, live-action Jimaku search, and YouTube library kinds - Harden Jellyfin media identity handling and live settings feedback - Refresh user-facing documentation and changelog fragments
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: mining
|
||||
|
||||
- Document independent screenshot selection in media timing review.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: config
|
||||
|
||||
- Clarified live-setting save feedback, mixed restart warnings, and subtitle-generation reload behavior.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: stats
|
||||
|
||||
- Document YouTube channel filtering and video statistics in the Library.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,20 +14,22 @@ 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 |
|
||||
| `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`:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>();
|
||||
@@ -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();
|
||||
|
||||
@@ -26,10 +26,12 @@ export interface WatchPlaybackOutcomeDeps {
|
||||
/** One-shot mpv property read; may reject while the file is still loading. */
|
||||
readProperty: (name: string) => Promise<unknown>;
|
||||
wait: (ms: number) => Promise<void>;
|
||||
/** 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<PlaybackOutcome> {
|
||||
// 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
|
||||
+12
-5
@@ -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
|
||||
const timestamp =
|
||||
context?.screenshotTime ??
|
||||
(context
|
||||
? mediaRange.startTime + (mediaRange.endTime - mediaRange.startTime) / 2
|
||||
: this.mpvClient.currentTimePos || 0;
|
||||
: 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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Buffer | null> {
|
||||
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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Buffer | null> {
|
||||
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,
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface PendingYoutubeMediaUpdate {
|
||||
generateImage: boolean;
|
||||
volumeScale?: number;
|
||||
mediaPaddingSeconds?: number;
|
||||
screenshotTime?: number;
|
||||
}
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.') ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AnilistMediaGuess | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -193,6 +193,16 @@ export function createCoverArtFetcher(
|
||||
|
||||
return {
|
||||
async fetchIfMissing(db, videoId, canonicalTitle): Promise<boolean> {
|
||||
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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 "<title> 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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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 = ?',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
for (const invalid of [{ text: ' ' }, { screenshotTime: Infinity }]) {
|
||||
assert.deepEqual(
|
||||
await handler!(
|
||||
{},
|
||||
{
|
||||
reviewId: 'review-1',
|
||||
decision: { action: 'confirm', startTime: 10, endTime: 12, text: ' ' },
|
||||
decision: { action: 'confirm', startTime: 10, endTime: 12, ...invalid },
|
||||
},
|
||||
),
|
||||
{ ok: false, message: 'Timing review is unavailable.' },
|
||||
);
|
||||
}
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) || '';
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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) {
|
||||
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 &&
|
||||
(nearestSpeechStart === undefined ||
|
||||
Math.abs(time - target) < Math.abs(nearestSpeechStart - target))
|
||||
(nearest === undefined || Math.abs(time - target) < Math.abs(nearest - target))
|
||||
)
|
||||
nearestSpeechStart = time;
|
||||
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),
|
||||
|
||||
@@ -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,13 +22,16 @@ 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> {
|
||||
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...' });
|
||||
@@ -60,7 +64,8 @@ export async function transcribeSubtitleDialogue(input: {
|
||||
segmentLines.push(line);
|
||||
},
|
||||
});
|
||||
const speech = parseSpeechPassages(segmentLines.join('\n'));
|
||||
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()) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+11
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -278,6 +278,7 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
|
||||
onEndFile: deps.onPlaybackEndFile,
|
||||
readProperty: deps.readMpvProperty,
|
||||
wait,
|
||||
isCurrent,
|
||||
})
|
||||
: null;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,12 +348,14 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
|
||||
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource] = await Promise.all([
|
||||
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'] });
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
+72
-1
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user