From 5326ad32f5fd1fb6c8f0f862fee98fcef816b700 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 27 Jun 2026 19:20:18 -0700 Subject: [PATCH] feat(youtube): add mediaCache mode and safer stream media extraction (#130) --- .../youtube-media-generation-reliability.md | 7 + config.example.jsonc | 6 +- docs-site/configuration.md | 18 +- docs-site/public/config.example.jsonc | 6 +- docs-site/youtube-integration.md | 7 + release/release-notes.md | 68 --- src/anki-integration.test.ts | 356 ++++++++++++ src/anki-integration.ts | 172 +++++- .../card-creation-manual-update.test.ts | 7 +- src/anki-integration/card-creation.test.ts | 260 ++++++++- src/anki-integration/card-creation.ts | 174 ++++-- src/anki-integration/media-source.test.ts | 236 +++++++- src/anki-integration/media-source.ts | 372 ++++++++++++ .../note-update-workflow.test.ts | 56 ++ src/anki-integration/note-update-workflow.ts | 29 +- .../pending-youtube-media-queue.test.ts | 193 +++++++ .../pending-youtube-media-queue.ts | 391 +++++++++++++ src/anki-integration/pending-youtube-media.ts | 53 ++ src/config/config.test.ts | 52 ++ src/config/definitions/defaults-core.ts | 4 + src/config/definitions/options-core.ts | 18 + src/config/definitions/shared.ts | 1 + src/config/resolve/core-domains.ts | 33 ++ src/config/settings/registry.test.ts | 14 + src/config/settings/registry.ts | 1 + src/core/services/anki-jimaku.ts | 7 + src/core/services/overlay-runtime-init.ts | 21 + src/core/services/youtube/media-cache.test.ts | 540 ++++++++++++++++++ src/core/services/youtube/media-cache.ts | 307 ++++++++++ src/main.ts | 87 +++ src/main/dependencies.ts | 6 + src/main/main-wiring.test.ts | 42 ++ .../runtime/app-lifecycle-actions.test.ts | 5 +- src/main/runtime/app-lifecycle-actions.ts | 2 + .../app-lifecycle-main-cleanup.test.ts | 4 + .../runtime/app-lifecycle-main-cleanup.ts | 2 + .../startup-lifecycle-composer.test.ts | 1 + .../configured-status-notification.test.ts | 39 +- .../runtime/configured-status-notification.ts | 9 +- .../runtime/overlay-notifications-runtime.ts | 32 +- .../overlay-runtime-options-main-deps.ts | 6 + src/main/runtime/overlay-runtime-options.ts | 14 + .../runtime/youtube-playback-runtime.test.ts | 132 +++++ src/main/runtime/youtube-playback-runtime.ts | 13 + src/media-generator.test.ts | 135 ++++- src/media-generator.ts | 150 ++++- src/media-input.ts | 97 ++++ src/settings/settings-controls.test.ts | 29 + src/settings/settings-controls.ts | 2 +- src/types/config.ts | 5 + src/types/integrations.ts | 8 + src/types/settings.ts | 1 + 52 files changed, 4065 insertions(+), 165 deletions(-) create mode 100644 changes/youtube-media-generation-reliability.md delete mode 100644 release/release-notes.md create mode 100644 src/anki-integration/pending-youtube-media-queue.test.ts create mode 100644 src/anki-integration/pending-youtube-media-queue.ts create mode 100644 src/anki-integration/pending-youtube-media.ts create mode 100644 src/core/services/youtube/media-cache.test.ts create mode 100644 src/core/services/youtube/media-cache.ts create mode 100644 src/media-input.ts diff --git a/changes/youtube-media-generation-reliability.md b/changes/youtube-media-generation-reliability.md new file mode 100644 index 00000000..8c1635c9 --- /dev/null +++ b/changes/youtube-media-generation-reliability.md @@ -0,0 +1,7 @@ +type: fixed +area: youtube + +- Improved YouTube card media generation by sending safer ffmpeg request options for resolved streams and skipping stale stream maps, including cached YouTube files. +- Added `youtube.mediaCache.mode` with `direct` and `background` modes so YouTube card audio/image extraction can optionally use a background yt-dlp media cache when direct stream extraction is unreliable; background mode now announces cache download start/readiness through queued overlay/OSD notifications, creates text-only cards while the cache downloads, queues media updates for the mined note IDs, fills audio/image fields once the cached file is ready, and caps background downloads at `youtube.mediaCache.maxHeight` 720p by default while honoring height changes between downloads. +- Cleaned stale YouTube background media cache files on startup and before new background downloads, and stopped in-flight background downloads when switching back to direct mode. +- Hardened YouTube media cache downloads with IPv4/extractor retry flags and made failed background downloads notify users and clear queued media updates instead of leaving them pending silently. diff --git a/config.example.jsonc b/config.example.jsonc index 7a276ce8..55ce4c1b 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -618,7 +618,11 @@ "primarySubLanguages": [ "ja", "jpn" - ] // Comma-separated primary subtitle language priority for managed subtitle auto-selection. + ], // Comma-separated primary subtitle language priority for managed subtitle auto-selection. + "mediaCache": { + "mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background + "maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited. + } // Media cache setting. }, // Defaults for managed subtitle language preferences and YouTube subtitle loading. // ========================================== diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 99a9faad..b2a8f99f 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -1520,14 +1520,24 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher ```json { "youtube": { - "primarySubLanguages": ["ja", "jpn"] + "primarySubLanguages": ["ja", "jpn"], + "mediaCache": { + "mode": "direct", + "maxHeight": 720 + } } } ``` -| Option | Values | Description | -| --------------------- | -------- | ------------------------------------------------------------------------------------------------ | -| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) | +| Option | Values | Description | +| ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ | +| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) | +| `mediaCache.mode` | `direct` \| `background` | YouTube card audio/image extraction mode (default `direct`) | +| `mediaCache.maxHeight` | number | Maximum background cache download height. Set `0` for unlimited (default `720`) | + +`mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded. Playback and subtitle loading do not wait for that download. Use background mode if direct card media generation hits YouTube `403` errors from expiring stream URLs. + +Background cache downloads are capped by `mediaCache.maxHeight`, which defaults to 720p; set it to `0` to let yt-dlp choose the best available height. Downloads use IPv4 and yt-dlp retry flags to reduce YouTube throttling failures. SubMiner announces when the background cache download starts and when the cache is ready, using the configured notification surface; overlay and OSD messages queue until the overlay or mpv is ready. If you mine cards before the cache is ready, SubMiner creates the text fields immediately, queues the audio/image work for those note IDs, shows a status notification, and fills the media fields once the cached file is ready. If the cache download fails, SubMiner shows a failure notification, shows queued-card failure notifications, and clears the pending updates. Current launcher behavior: diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 7a276ce8..55ce4c1b 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -618,7 +618,11 @@ "primarySubLanguages": [ "ja", "jpn" - ] // Comma-separated primary subtitle language priority for managed subtitle auto-selection. + ], // Comma-separated primary subtitle language priority for managed subtitle auto-selection. + "mediaCache": { + "mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background + "maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited. + } // Media cache setting. }, // Defaults for managed subtitle language preferences and YouTube subtitle loading. // ========================================== diff --git a/docs-site/youtube-integration.md b/docs-site/youtube-integration.md index 28d2e14a..a5ce157a 100644 --- a/docs-site/youtube-integration.md +++ b/docs-site/youtube-integration.md @@ -88,6 +88,12 @@ SubMiner handles several YouTube subtitle formats transparently: For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred. +## Card Media Cache + +By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready. + +Background cache downloads use IPv4 and retry flags to reduce YouTube throttling failures. If the background download still fails, SubMiner shows a cache failure notification, shows queued-card failure notifications, and clears those pending updates so cards are not left waiting silently. + ## Configuration Reference ### Primary Subtitle Languages @@ -131,6 +137,7 @@ Precedence: CLI flag > environment variable > `config.jsonc` > built-in default. - **No subtitles found**: The video may not have Japanese subtitles. Open the picker with `Ctrl+Alt+C` to see all available tracks. - **yt-dlp not found**: Install `yt-dlp` and ensure it is on `PATH`, or set `SUBMINER_YTDLP_BIN` to the binary path. - **Probe timeout**: `yt-dlp` has a 15-second timeout per operation. Slow connections or rate-limited IPs may hit this. Retry or update `yt-dlp`. +- **Card media `403` errors**: Switch `youtube.mediaCache.mode` from `"direct"` to `"background"` so card media is generated from a local `yt-dlp` cache instead of ffmpeg reading an expiring YouTube stream URL. - **Auto-caption quality**: YouTube auto-generated captions vary in quality. Manual subtitles (when available) are always preferred. - **`ytsearch:` targets**: `subminer ytsearch:"keyword"` plays the first search result. Subtitle availability depends on the matched video. - **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently. diff --git a/release/release-notes.md b/release/release-notes.md deleted file mode 100644 index 6ee812a8..00000000 --- a/release/release-notes.md +++ /dev/null @@ -1,68 +0,0 @@ -## Highlights -### Changed - -- **Subtitle Delay Keybindings:** Overlay subtitle delay controls now match mpv's native bindings. - - `z`, `Z`, and `x` adjust subtitle delay; `Ctrl+Shift+Left/Right` step to the adjacent subtitle and show the current delay on the OSD. - - Removes the old SubMiner-specific adjacent-cue delay action in favor of mpv's built-in `sub-step`. - -- **Update Notifications:** New installs now default to overlay-only update notifications instead of also sending a system notification. - -### Fixed - -- **Anki — Highlight Word:** The mined word is now correctly bolded in Kiku sentence and sentence-furigana fields even when the source Yomitan sentence did not already contain bold markup. - -- **Anki — Lapis/Kiku Word Cards:** Word cards enriched through SubMiner now correctly include the word-and-sentence marker, restoring sentence context on the card front. - -- **Anki — Windows:** Fixed two issues that surfaced after background app launches on Windows. - - Audio clip and image generation now works correctly by recreating missing FFmpeg temp directories before export. - - Known-word cache refreshes no longer fail when no deck is configured. - -- **Desktop Notifications:** Restored the SubMiner app icon on system notifications that do not supply their own image. - -- **Dictionary — Windows:** The character dictionary auto-sync on `SubMiner mpv` shortcut launches can now fall back to mpv's current video path when app media state is not yet ready. - -- **Overlay — macOS Yomitan Popup:** Fixed focus and dismiss behavior for the Yomitan popup on macOS. - - Popup focus is correctly restored after mining a card or reloading the popup. - - Clicking on transparent overlay space now properly closes the popup and passes the click through to mpv, with no hide/reappear cycle. - -- **Overlay — Linux Startup:** Fixed several edge cases that could leave the overlay unresponsive or drop subtitles at startup when auto-pause was active. - - The overlay stays interactive during the initial render measurement gap. - - Subtitles paint as plain text immediately on cache misses, before tokenization finishes. - - Temporarily empty subtitle state is now re-parsed correctly before warm readiness resumes playback. - -- **Overlay — Playlist Advance:** The visible overlay now stays interactive when mpv advances to the next playlist item, including when the next episode loads after the warm transition delay. - -- **Overlay — Windows:** Fixed shaky subtitle-bar hover and click behavior when a video connects to an already-running background SubMiner instance. - -- **Stats — AniList Search:** Manual AniList linking from the stats anime page now searches only the anime title, dropping any generated "Season N" suffix that was causing failed lookups. - -- **Updates — Linux:** Improved Linux update reliability for managed support assets. - - Updates now correctly install and refresh both the launcher runtime plugin copy and the rofi theme alongside AppImage and launcher updates. - - Support-asset refreshes no longer touch unrelated SubMiner data directories, and plugin copies are staged safely before replacing the live runtime plugin. - - Fresh installs now auto-install the managed runtime plugin and rofi theme from the bundled app on first launcher playback if either asset is missing. - -### Docs - -- **Linux Updates:** Documented how Linux update flows manage the launcher runtime plugin and rofi theme, and that the first launcher playback auto-installs any missing managed support assets from the bundled app. - -## What's Changed - -- Replace subtitle delay actions with native mpv keybindings by @ksyasuda in #120 -- fix(stats): strip Season N suffix from AniList title searches by @ksyasuda in #121 -- fix(overlay): preserve visible state across playlist item transitions by @ksyasuda in #124 -- fix(overlay): restore macOS Yomitan popup focus without breaking click-away by @ksyasuda in #125 -- fix(linux): auto-install managed plugin copy; include in asset updates by @ksyasuda in #127 -- Fix Windows Anki startup and overlay regressions by @ksyasuda in #128 - -## Installation - -See the README and docs/installation guide for full setup steps. - -## Assets - -- Linux: `SubMiner.AppImage` -- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip` -- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip` -- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher - -Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`. diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 77b757c5..f6a5ab7b 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -5,6 +5,7 @@ import * as os from 'os'; import * as path from 'path'; import { AnkiIntegration } from './anki-integration'; import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge'; +import type { MediaInput } from './media-input'; import { AnkiConnectConfig } from './types'; type TestOverlayNotificationPayload = { @@ -24,6 +25,13 @@ interface IntegrationTestContext { stateDir: string; } +function describeMediaInputForTest(input: MediaInput): string { + if (typeof input === 'string') { + return input; + } + return `${input.path}:${input.source ?? 'raw'}`; +} + function createIntegrationTestContext( options: { highlightEnabled?: boolean; @@ -527,6 +535,354 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']); }); +test('AnkiIntegration applies ready YouTube cache media to every queued note id', async () => { + const osdMessages: string[] = []; + const updatedNotes: Array<{ noteId: number; fields: Record }> = []; + const storedMedia: string[] = []; + const mediaInputs: string[] = []; + + const integration = new AnkiIntegration( + { + fields: { + image: 'Picture', + }, + media: { + imageFormat: 'jpg', + }, + behavior: { + notificationType: 'osd', + }, + }, + {} as never, + {} as never, + (text) => { + osdMessages.push(text); + }, + ); + + const internals = integration as unknown as { + client: { + notesInfo: (noteIds: number[]) => Promise; + updateNoteFields: (noteId: number, fields: Record) => Promise; + storeMediaFile: (filename: string, data: Buffer) => Promise; + }; + mediaGenerator: { + generateAudio: ( + path: MediaInput, + startTime: number, + endTime: number, + audioPadding?: number, + audioStreamIndex?: number, + ) => Promise; + generateScreenshot: (path: MediaInput) => Promise; + }; + queuePendingYoutubeMediaUpdate: (job: { + sourceUrl: string; + noteId: number; + startTime: number; + endTime: number; + label: string | number; + audioStreamIndex?: number; + audioFieldName?: string; + imageFieldName?: string; + generateAudio: boolean; + generateImage: boolean; + }) => void; + }; + internals.client = { + notesInfo: async (noteIds) => + noteIds.map((noteId) => ({ + noteId, + fields: { + SentenceAudio: { value: '' }, + Picture: { value: '' }, + }, + })), + updateNoteFields: async (noteId, fields) => { + updatedNotes.push({ noteId, fields }); + }, + storeMediaFile: async (filename) => { + storedMedia.push(filename); + }, + }; + internals.mediaGenerator = { + generateAudio: async (mediaPath, _startTime, _endTime, _audioPadding, audioStreamIndex) => { + mediaInputs.push( + `audio:${describeMediaInputForTest(mediaPath)}:${audioStreamIndex ?? 'auto'}`, + ); + return Buffer.from('audio'); + }, + generateScreenshot: async (mediaPath) => { + mediaInputs.push(`image:${describeMediaInputForTest(mediaPath)}`); + return Buffer.from('image'); + }, + }; + internals.queuePendingYoutubeMediaUpdate({ + sourceUrl: 'https://www.youtube.com/watch?v=abc123', + noteId: 101, + startTime: 10, + endTime: 12, + label: 'first', + audioStreamIndex: 22, + audioFieldName: 'SentenceAudio', + imageFieldName: 'Picture', + generateAudio: true, + generateImage: true, + }); + internals.queuePendingYoutubeMediaUpdate({ + sourceUrl: 'https://youtu.be/abc123', + noteId: 202, + startTime: 20, + endTime: 22, + label: 'second', + audioStreamIndex: 23, + audioFieldName: 'SentenceAudio', + imageFieldName: 'Picture', + generateAudio: true, + generateImage: true, + }); + + await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv'); + + assert.deepEqual(mediaInputs, [ + 'audio:/tmp/media.mkv:youtube-cache:auto', + 'image:/tmp/media.mkv:youtube-cache', + 'audio:/tmp/media.mkv:youtube-cache:auto', + 'image:/tmp/media.mkv:youtube-cache', + ]); + assert.deepEqual( + updatedNotes.map((update) => update.noteId), + [101, 202], + ); + const firstUpdate = updatedNotes[0]; + const secondUpdate = updatedNotes[1]; + assert.ok(firstUpdate); + assert.ok(secondUpdate); + assert.match(firstUpdate.fields.SentenceAudio ?? '', /^\[sound:audio_/); + assert.match(firstUpdate.fields.Picture ?? '', /^ + message.includes('YouTube media cache ready. Adding media to 2 queued cards.'), + ), + true, + ); +}); + +test('AnkiIntegration reports partial queued YouTube media updates separately from failures', async () => { + const osdMessages: string[] = []; + const updatedNotes: Array<{ noteId: number; fields: Record }> = []; + const notifications: Array<{ noteId: number; label: string | number; suffix?: string }> = []; + + const integration = new AnkiIntegration( + { + fields: { + image: 'Picture', + }, + media: { + imageFormat: 'jpg', + }, + behavior: { + notificationType: 'osd', + }, + }, + {} as never, + {} as never, + (text) => { + osdMessages.push(text); + }, + ); + + const internals = integration as unknown as { + client: { + notesInfo: (noteIds: number[]) => Promise; + updateNoteFields: (noteId: number, fields: Record) => Promise; + storeMediaFile: () => Promise; + }; + mediaGenerator: { + generateAudio: () => Promise; + generateScreenshot: () => Promise; + }; + queuePendingYoutubeMediaUpdate: (job: { + sourceUrl: string; + noteId: number; + startTime: number; + endTime: number; + label: string | number; + audioFieldName?: string; + imageFieldName?: string; + generateAudio: boolean; + generateImage: boolean; + }) => void; + showNotification: (noteId: number, label: string | number, suffix?: string) => Promise; + }; + internals.client = { + notesInfo: async (noteIds) => + noteIds.map((noteId) => ({ + noteId, + fields: { + SentenceAudio: { value: '' }, + Picture: { value: '' }, + }, + })), + updateNoteFields: async (noteId, fields) => { + updatedNotes.push({ noteId, fields }); + }, + storeMediaFile: async () => undefined, + }; + internals.mediaGenerator = { + generateAudio: async () => { + throw new Error('audio stream not found'); + }, + generateScreenshot: async () => Buffer.from('image'), + }; + internals.showNotification = async (noteId, label, suffix) => { + notifications.push({ noteId, label, suffix }); + }; + + internals.queuePendingYoutubeMediaUpdate({ + sourceUrl: 'https://www.youtube.com/watch?v=partial', + noteId: 303, + startTime: 10, + endTime: 12, + label: 'partial', + audioFieldName: 'SentenceAudio', + imageFieldName: 'Picture', + generateAudio: true, + generateImage: true, + }); + + await integration.handleYoutubeMediaCacheReady('https://youtu.be/partial', '/tmp/media.mkv'); + + assert.equal(updatedNotes.length, 1); + assert.match(updatedNotes[0]?.fields.Picture ?? '', /^`; - miscInfoFilename = imageFilename; + const imageField = config.fields?.image; + if (imageBuffer && imageField) { + await this.deps.client.storeMediaFile(imageFilename, imageBuffer); + mediaFields[imageField] = ``; + miscInfoFilename = imageFilename; + } + } catch (error) { + log.error('Failed to generate sentence image:', (error as Error).message); + errors.push('image'); } - } catch (error) { - log.error('Failed to generate sentence image:', (error as Error).message); - errors.push('image'); } - if (this.deps.getConfig().fields?.miscInfo) { + if (config.fields?.miscInfo) { const miscInfo = this.deps.formatMiscInfoPattern(miscInfoFilename || '', startTime); if (miscInfo && resolvedMiscInfoField) { mediaFields[resolvedMiscInfoField] = miscInfo; @@ -704,7 +778,6 @@ export class CardCreationService { } } - const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence; const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined; await this.deps.showNotification(noteId, label, errorSuffix); return true; @@ -740,7 +813,7 @@ export class CardCreationService { } private async mediaGenerateAudio( - videoPath: string, + videoPath: MediaInput, startTime: number, endTime: number, ): Promise { @@ -754,12 +827,15 @@ export class CardCreationService { startTime, endTime, this.deps.getConfig().media?.audioPadding, - mpvClient.currentAudioStreamIndex ?? undefined, + resolveAudioStreamIndexForMediaGeneration( + videoPath, + mpvClient.currentAudioStreamIndex ?? undefined, + ), ); } private async generateImageBuffer( - videoPath: string, + videoPath: MediaInput, startTime: number, endTime: number, animatedLeadInSeconds = 0, diff --git a/src/anki-integration/media-source.test.ts b/src/anki-integration/media-source.test.ts index 7fec8987..9c32a88f 100644 --- a/src/anki-integration/media-source.test.ts +++ b/src/anki-integration/media-source.test.ts @@ -1,7 +1,33 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { resolveMediaGenerationInputPath } from './media-source'; +import * as mediaSource from './media-source'; + +const { resolveMediaGenerationInputPath } = mediaSource; + +type StructuredMediaInput = { + path: string; + source: string; + singleResolvedStream: boolean; + inputOptions?: { + reconnect?: boolean; + userAgent?: string; + headers?: Record; + }; +}; + +type StructuredMediaResolver = ( + mpvClient: Parameters[0], + kind?: Parameters[1], + options?: { + getCachedMediaPath?: ( + currentVideoPath: string, + kind: Parameters[1], + ) => Promise; + remoteCacheMode?: 'optional' | 'required'; + logDebug?: (message: string) => void; + }, +) => Promise; test('resolveMediaGenerationInputPath keeps local file paths', async () => { const result = await resolveMediaGenerationInputPath({ @@ -62,3 +88,211 @@ test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream assert.equal(result, 'https://www.youtube.com/watch?v=abc123'); }); + +test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URLs', async () => { + const resolver = ( + mediaSource as typeof mediaSource & { + resolveMediaGenerationInput?: StructuredMediaResolver; + } + ).resolveMediaGenerationInput; + assert.equal(typeof resolver, 'function'); + + const edlSource = [ + 'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm', + '!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4', + ].join(';'); + + const result = await resolver!( + { + currentVideoPath: 'https://www.youtube.com/watch?v=abc123', + requestProperty: async (name: string) => { + if (name === 'stream-open-filename') return edlSource; + if (name === 'user-agent') return 'Mozilla/5.0'; + if (name === 'http-header-fields') { + return ['Cookie: SID=secret', 'Referer: https://www.youtube.com/', 'X-Test: ok']; + } + return null; + }, + }, + 'audio', + ); + + assert.equal(result?.path, 'https://audio.example/videoplayback?mime=audio%2Fwebm'); + assert.equal(result?.singleResolvedStream, true); + assert.equal(result?.inputOptions?.reconnect, true); + assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0'); + assert.deepEqual(result?.inputOptions?.headers, { + Referer: 'https://www.youtube.com/', + 'X-Test': 'ok', + }); +}); + +test('resolveMediaGenerationInput reads file-local mpv request options', async () => { + const resolver = ( + mediaSource as typeof mediaSource & { + resolveMediaGenerationInput?: StructuredMediaResolver; + } + ).resolveMediaGenerationInput; + assert.equal(typeof resolver, 'function'); + + const result = await resolver!( + { + currentVideoPath: 'https://www.youtube.com/watch?v=abc123', + requestProperty: async (name: string) => { + if (name === 'stream-open-filename') { + return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123'; + } + if (name === 'file-local-options/user-agent') return 'SubMiner Test Agent'; + if (name === 'options/http-header-fields') return ['X-Shared: ok']; + if (name === 'file-local-options/http-header-fields') { + return ['Cookie: SID=secret', 'Referer: https://m.youtube.com/', 'X-Local: yes']; + } + return null; + }, + }, + 'video', + ); + + assert.equal(result?.path, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123'); + assert.equal(result?.singleResolvedStream, true); + assert.equal(result?.inputOptions?.userAgent, 'SubMiner Test Agent'); + assert.deepEqual(result?.inputOptions?.headers, { + 'X-Shared': 'ok', + Referer: 'https://m.youtube.com/', + 'X-Local': 'yes', + Origin: 'https://www.youtube.com', + }); +}); + +test('resolveMediaGenerationInput prefers a ready cached media file for YouTube extraction', async () => { + const resolver = ( + mediaSource as typeof mediaSource & { + resolveMediaGenerationInput?: StructuredMediaResolver; + } + ).resolveMediaGenerationInput; + assert.equal(typeof resolver, 'function'); + + const result = await resolver!( + { + currentVideoPath: 'https://www.youtube.com/watch?v=abc123', + requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123', + }, + 'video', + { + getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/abc123/media.mkv', + }, + ); + + assert.equal(result?.path, '/tmp/subminer-youtube-media-cache/abc123/media.mkv'); + assert.equal(result?.source, 'youtube-cache'); + assert.equal(result?.singleResolvedStream, false); + assert.equal(result?.inputOptions, undefined); +}); + +test('resolveMediaGenerationInput debug-logs sanitized YouTube cache hits', async () => { + const resolver = ( + mediaSource as typeof mediaSource & { + resolveMediaGenerationInput?: StructuredMediaResolver; + } + ).resolveMediaGenerationInput; + assert.equal(typeof resolver, 'function'); + const logs: string[] = []; + + const result = await resolver!( + { + currentVideoPath: 'https://www.youtube.com/watch?v=abc123&signature=secret', + requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123', + }, + 'video', + { + getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/abc123/media.mkv', + logDebug: (message) => logs.push(message), + }, + ); + + assert.equal(result?.source, 'youtube-cache'); + assert.match(logs.join('\n'), /kind=video source=youtube-cache/); + assert.match( + logs.join('\n'), + /input=local:\/tmp\/subminer-youtube-media-cache\/abc123\/media\.mkv/, + ); + assert.match(logs.join('\n'), /current=remote:www\.youtube\.com/); + assert.doesNotMatch(logs.join('\n'), /signature=secret|videoplayback/); +}); + +test('resolveMediaGenerationInput does not fall back to direct remote streams when cache is required', async () => { + const resolver = ( + mediaSource as typeof mediaSource & { + resolveMediaGenerationInput?: StructuredMediaResolver; + } + ).resolveMediaGenerationInput; + assert.equal(typeof resolver, 'function'); + + const result = await resolver!( + { + currentVideoPath: 'https://www.youtube.com/watch?v=abc123', + requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123', + }, + 'video', + { + getCachedMediaPath: async () => null, + remoteCacheMode: 'required', + }, + ); + + assert.equal(result, null); +}); + +test('resolveMediaGenerationInput falls back when optional cache lookup fails', async () => { + const resolver = ( + mediaSource as typeof mediaSource & { + resolveMediaGenerationInput?: StructuredMediaResolver; + } + ).resolveMediaGenerationInput; + assert.equal(typeof resolver, 'function'); + + const result = await resolver!( + { + currentVideoPath: 'https://www.youtube.com/watch?v=abc123', + requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123', + }, + 'video', + { + getCachedMediaPath: async () => { + throw new Error('cache unavailable'); + }, + }, + ); + + assert.equal(result?.path, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123'); + assert.equal(result?.source, 'stream-open-filename'); +}); + +test('resolveMediaGenerationInput debug-logs sanitized required-cache misses', async () => { + const resolver = ( + mediaSource as typeof mediaSource & { + resolveMediaGenerationInput?: StructuredMediaResolver; + } + ).resolveMediaGenerationInput; + assert.equal(typeof resolver, 'function'); + const logs: string[] = []; + + const result = await resolver!( + { + currentVideoPath: 'https://www.youtube.com/watch?v=abc123&signature=secret', + requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123', + }, + 'video', + { + getCachedMediaPath: async () => null, + remoteCacheMode: 'required', + logDebug: (message) => logs.push(message), + }, + ); + + assert.equal(result, null); + assert.match(logs.join('\n'), /kind=video source=cache-miss/); + assert.match(logs.join('\n'), /mode=required/); + assert.match(logs.join('\n'), /current=remote:www\.youtube\.com/); + assert.doesNotMatch(logs.join('\n'), /signature=secret|videoplayback|googlevideo/); +}); diff --git a/src/anki-integration/media-source.ts b/src/anki-integration/media-source.ts index 21f98386..c8e2bbfa 100644 --- a/src/anki-integration/media-source.ts +++ b/src/anki-integration/media-source.ts @@ -1,7 +1,57 @@ import { isRemoteMediaPath } from '../jimaku/utils'; +import type { MediaInput, MediaInputOptions } from '../media-input'; import type { MpvClient } from '../types/runtime'; export type MediaGenerationKind = 'audio' | 'video'; +export type MediaGenerationInputSource = + | 'current-path' + | 'stream-open-filename' + | 'edl-stream' + | 'youtube-cache'; + +export interface ResolvedMediaGenerationInput { + path: string; + kind: MediaGenerationKind; + source: MediaGenerationInputSource; + singleResolvedStream: boolean; + inputOptions?: MediaInputOptions; +} + +export interface MediaGenerationInputResolverOptions { + getCachedMediaPath?: ( + currentVideoPath: string, + kind: MediaGenerationKind, + ) => Promise; + remoteCacheMode?: 'optional' | 'required'; + logDebug?: (message: string) => void; +} + +export function resolveAudioStreamIndexForMediaGeneration( + input: MediaInput, + audioStreamIndex: number | null | undefined, +): number | undefined { + if (typeof input === 'object' && 'source' in input && input.source === 'youtube-cache') { + return undefined; + } + return audioStreamIndex ?? undefined; +} + +const BLOCKED_HTTP_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization']); +const HTTP_HEADER_FIELD_PROPERTY_NAMES = [ + 'http-header-fields', + 'options/http-header-fields', + 'file-local-options/http-header-fields', +] as const; +const USER_AGENT_PROPERTY_NAMES = [ + 'file-local-options/user-agent', + 'options/user-agent', + 'user-agent', +] as const; +const REFERRER_PROPERTY_NAMES = [ + 'file-local-options/referrer', + 'options/referrer', + 'referrer', +] as const; function trimToNonEmptyString(value: unknown): string | null { if (typeof value !== 'string') { @@ -11,6 +61,17 @@ function trimToNonEmptyString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function normalizeHeaderName(value: string): string | null { + const trimmed = value.trim(); + if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(trimmed)) { + return null; + } + if (BLOCKED_HTTP_HEADER_NAMES.has(trimmed.toLowerCase())) { + return null; + } + return trimmed; +} + function extractUrlsFromMpvEdlSource(source: string): string[] { const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms); return [...matches] @@ -53,6 +114,317 @@ function resolvePreferredUrlFromMpvEdlSource( return kind === 'audio' ? (urls[0] ?? null) : (urls[urls.length - 1] ?? null); } +function getHostname(value: string): string | null { + try { + return new URL(value).hostname.toLowerCase(); + } catch { + return null; + } +} + +function matchesHost(hostname: string, expectedHost: string): boolean { + return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`); +} + +function isGoogleVideoMediaPath(value: string): boolean { + const host = getHostname(value); + return Boolean(host && matchesHost(host, 'googlevideo.com')); +} + +function describeMediaPathForDebugLog(value: string): string { + try { + const url = new URL(value); + if (url.protocol === 'http:' || url.protocol === 'https:') { + return `remote:${url.hostname.toLowerCase() || 'unknown'}`; + } + return `${url.protocol.replace(/:$/, '')}:`; + } catch { + // Not a URL; treat as a local file path below. + } + + if (value.startsWith('edl://')) { + return 'edl:'; + } + + return `local:${value}`; +} + +function logMediaResolutionDebug( + options: MediaGenerationInputResolverOptions, + message: string, +): void { + if (!options.logDebug) { + return; + } + try { + options.logDebug(`[media-source] ${message}`); + } catch { + // Debug logging should not affect media generation. + } +} + +function logResolvedMediaGenerationInput( + options: MediaGenerationInputResolverOptions, + currentVideoPath: string, + result: ResolvedMediaGenerationInput, +): void { + logMediaResolutionDebug( + options, + [ + `kind=${result.kind}`, + `source=${result.source}`, + `input=${describeMediaPathForDebugLog(result.path)}`, + `current=${describeMediaPathForDebugLog(currentVideoPath)}`, + `singleResolvedStream=${result.singleResolvedStream}`, + ].join(' '), + ); +} + +function logMediaGenerationInputMiss( + options: MediaGenerationInputResolverOptions, + kind: MediaGenerationKind, + currentVideoPath: string, + reason: string, +): void { + logMediaResolutionDebug( + options, + [ + `kind=${kind}`, + 'source=cache-miss', + `reason=${reason}`, + `mode=${options.remoteCacheMode ?? 'optional'}`, + `current=${describeMediaPathForDebugLog(currentVideoPath)}`, + ].join(' '), + ); +} + +function setHeaderIfMissing(headers: Record, name: string, value: string): void { + const lowerName = name.toLowerCase(); + if (!Object.keys(headers).some((existing) => existing.toLowerCase() === lowerName)) { + headers[name] = value; + } +} + +function parseMpvHeaderField(value: string): [string, string] | null { + const separatorIndex = value.indexOf(':'); + if (separatorIndex <= 0) { + return null; + } + + const name = normalizeHeaderName(value.slice(0, separatorIndex)); + const headerValue = trimToNonEmptyString(value.slice(separatorIndex + 1)); + if (!name || !headerValue) { + return null; + } + return [name, headerValue.replace(/[\r\n]+/g, ' ')]; +} + +function toHeaderFields(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === 'string'); + } + if (typeof value === 'string') { + return value + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + } + return []; +} + +async function requestOptionalMpvProperty( + mpvClient: Pick, + name: string, +): Promise { + if (!mpvClient.requestProperty) { + return null; + } + + try { + return await mpvClient.requestProperty(name); + } catch { + return null; + } +} + +async function requestFirstNonEmptyStringProperty( + mpvClient: Pick, + names: readonly string[], +): Promise { + for (const name of names) { + const value = trimToNonEmptyString(await requestOptionalMpvProperty(mpvClient, name)); + if (value) { + return value; + } + } + return null; +} + +async function resolveRemoteInputOptions( + mpvClient: Pick, + resolvedPath: string, +): Promise { + if (!isRemoteMediaPath(resolvedPath) || !mpvClient.requestProperty) { + return undefined; + } + + const headers: Record = {}; + for (const propertyName of HTTP_HEADER_FIELD_PROPERTY_NAMES) { + const mpvHeaderFields = toHeaderFields( + await requestOptionalMpvProperty(mpvClient, propertyName), + ); + for (const field of mpvHeaderFields) { + const parsed = parseMpvHeaderField(field); + if (parsed) { + headers[parsed[0]] = parsed[1]; + } + } + } + + const userAgent = await requestFirstNonEmptyStringProperty(mpvClient, USER_AGENT_PROPERTY_NAMES); + const referrer = await requestFirstNonEmptyStringProperty(mpvClient, REFERRER_PROPERTY_NAMES); + if (referrer) { + setHeaderIfMissing(headers, 'Referer', referrer); + } + if (isGoogleVideoMediaPath(resolvedPath)) { + setHeaderIfMissing(headers, 'Referer', 'https://www.youtube.com/'); + setHeaderIfMissing(headers, 'Origin', 'https://www.youtube.com'); + } + + return { + reconnect: true, + ...(userAgent ? { userAgent } : {}), + ...(Object.keys(headers).length > 0 ? { headers } : {}), + }; +} + +async function toResolvedMediaGenerationInput( + mpvClient: Pick, + path: string, + kind: MediaGenerationKind, + source: MediaGenerationInputSource, + singleResolvedStream: boolean, +): Promise { + const inputOptions = await resolveRemoteInputOptions(mpvClient, path); + return { + path, + kind, + source, + singleResolvedStream, + ...(inputOptions ? { inputOptions } : {}), + }; +} + +export async function resolveMediaGenerationInput( + mpvClient: Pick | null | undefined, + kind: MediaGenerationKind = 'video', + options: MediaGenerationInputResolverOptions = {}, +): Promise { + const currentVideoPath = trimToNonEmptyString(mpvClient?.currentVideoPath); + if (!currentVideoPath) { + logMediaResolutionDebug(options, `kind=${kind} source=none reason=no-current-video`); + return null; + } + + if (!isRemoteMediaPath(currentVideoPath)) { + const result: ResolvedMediaGenerationInput = { + path: currentVideoPath, + kind, + source: 'current-path', + singleResolvedStream: false, + }; + logResolvedMediaGenerationInput(options, currentVideoPath, result); + return result; + } + + let cachedPath: string | null = null; + if (options.getCachedMediaPath) { + try { + cachedPath = trimToNonEmptyString(await options.getCachedMediaPath(currentVideoPath, kind)); + } catch { + cachedPath = null; + } + } + if (cachedPath) { + const result: ResolvedMediaGenerationInput = { + path: cachedPath, + kind, + source: 'youtube-cache', + singleResolvedStream: false, + }; + logResolvedMediaGenerationInput(options, currentVideoPath, result); + return result; + } + + if (options.remoteCacheMode === 'required') { + logMediaGenerationInputMiss(options, kind, currentVideoPath, 'required-cache-unavailable'); + return null; + } + + if (!mpvClient?.requestProperty) { + const result: ResolvedMediaGenerationInput = { + path: currentVideoPath, + kind, + source: 'current-path', + singleResolvedStream: false, + }; + logResolvedMediaGenerationInput(options, currentVideoPath, result); + return result; + } + + try { + const streamOpenFilename = trimToNonEmptyString( + await mpvClient.requestProperty('stream-open-filename'), + ); + if (streamOpenFilename?.startsWith('edl://')) { + const preferredUrl = resolvePreferredUrlFromMpvEdlSource(streamOpenFilename, kind); + if (preferredUrl) { + const result = await toResolvedMediaGenerationInput( + mpvClient, + preferredUrl, + kind, + 'edl-stream', + true, + ); + logResolvedMediaGenerationInput(options, currentVideoPath, result); + return result; + } + const result = await toResolvedMediaGenerationInput( + mpvClient, + streamOpenFilename, + kind, + 'stream-open-filename', + false, + ); + logResolvedMediaGenerationInput(options, currentVideoPath, result); + return result; + } + if (streamOpenFilename) { + const result = await toResolvedMediaGenerationInput( + mpvClient, + streamOpenFilename, + kind, + 'stream-open-filename', + isRemoteMediaPath(streamOpenFilename), + ); + logResolvedMediaGenerationInput(options, currentVideoPath, result); + return result; + } + } catch { + // Fall back to the current path when mpv does not expose a resolved stream URL. + } + + const result = await toResolvedMediaGenerationInput( + mpvClient, + currentVideoPath, + kind, + 'current-path', + false, + ); + logResolvedMediaGenerationInput(options, currentVideoPath, result); + return result; +} + export async function resolveMediaGenerationInputPath( mpvClient: Pick | null | undefined, kind: MediaGenerationKind = 'video', diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts index 0649281d..107e43c7 100644 --- a/src/anki-integration/note-update-workflow.test.ts +++ b/src/anki-integration/note-update-workflow.test.ts @@ -409,3 +409,59 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing assert.deepEqual(imageContext, sidebarContext); assert.equal(miscInfoStartTime, 10); }); + +test('NoteUpdateWorkflow queues media updates when YouTube cache is pending', async () => { + const harness = createWorkflowHarness(); + const queuedUpdates: Array<{ + noteId: number; + noteInfo: NoteUpdateWorkflowNoteInfo; + context?: SubtitleMiningContext; + label: string | number; + }> = []; + const mediaCalls: string[] = []; + + harness.deps.client.notesInfo = async () => + [ + { + noteId: 42, + fields: { + Expression: { value: 'taberu' }, + Sentence: { value: '' }, + SentenceAudio: { value: '' }, + Picture: { value: '' }, + }, + }, + ] satisfies NoteUpdateWorkflowNoteInfo[]; + harness.deps.getConfig = () => ({ + fields: { + sentence: 'Sentence', + image: 'Picture', + }, + media: { + generateAudio: true, + generateImage: true, + }, + behavior: {}, + }); + harness.deps.generateAudio = async () => { + mediaCalls.push('audio'); + return Buffer.from('audio'); + }; + harness.deps.generateImage = async () => { + mediaCalls.push('image'); + return Buffer.from('image'); + }; + harness.deps.queuePendingYoutubeMediaUpdate = async (job) => { + queuedUpdates.push(job); + return true; + }; + + await harness.workflow.execute(42); + + assert.deepEqual(mediaCalls, []); + assert.equal(queuedUpdates.length, 1); + assert.equal(queuedUpdates[0]?.noteId, 42); + assert.equal(queuedUpdates[0]?.label, 'taberu'); + assert.equal(queuedUpdates[0]?.context, undefined); + assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]); +}); diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts index e60efa35..e8a762e7 100644 --- a/src/anki-integration/note-update-workflow.ts +++ b/src/anki-integration/note-update-workflow.ts @@ -85,6 +85,12 @@ export interface NoteUpdateWorkflowDeps { ) => Promise; formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; consumeSubtitleMiningContext?: () => SubtitleMiningContext | null; + queuePendingYoutubeMediaUpdate?: (job: { + noteId: number; + noteInfo: NoteUpdateWorkflowNoteInfo; + context?: SubtitleMiningContext; + label: string | number; + }) => Promise; addConfiguredTagsToNote: (noteId: number) => Promise; showNotification: (noteId: number, label: string | number) => Promise; showOsdNotification: (message: string) => void; @@ -195,6 +201,7 @@ export class NoteUpdateWorkflow { sentenceField, config.fields?.sentence, ); + const noteLabel = hasExpressionText ? expressionText : noteId; const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText(); if (sentenceField && currentSubtitleText) { @@ -227,7 +234,19 @@ export class NoteUpdateWorkflow { } } - if (config.media?.generateAudio) { + const generateAudio = config.media?.generateAudio !== false; + const generateImage = config.media?.generateImage !== false; + const mediaCacheQueued = + (generateAudio || generateImage) && this.deps.queuePendingYoutubeMediaUpdate + ? await this.deps.queuePendingYoutubeMediaUpdate({ + noteId, + noteInfo, + context: subtitleMiningContext ?? undefined, + label: noteLabel, + }) + : false; + + if (!mediaCacheQueued && generateAudio) { try { const audioFilename = this.deps.generateAudioFilename(); const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined); @@ -252,7 +271,7 @@ export class NoteUpdateWorkflow { } } - if (config.media?.generateImage) { + if (!mediaCacheQueued && generateImage) { try { const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo); const imageFilename = this.deps.generateImageFilename(); @@ -287,7 +306,7 @@ export class NoteUpdateWorkflow { } } - if (config.fields?.miscInfo) { + if (!mediaCacheQueued && config.fields?.miscInfo) { const miscInfo = this.deps.formatMiscInfoPattern( miscInfoFilename || '', subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(), @@ -305,8 +324,8 @@ export class NoteUpdateWorkflow { if (updatePerformed) { await this.deps.client.updateNoteFields(noteId, updatedFields); await this.deps.addConfiguredTagsToNote(noteId); - this.deps.logInfo('Updated card fields for:', hasExpressionText ? expressionText : noteId); - await this.deps.showNotification(noteId, hasExpressionText ? expressionText : noteId); + this.deps.logInfo('Updated card fields for:', noteLabel); + await this.deps.showNotification(noteId, noteLabel); } if (shouldRunFieldGrouping && hasExpressionText && duplicateNoteId !== null) { diff --git a/src/anki-integration/pending-youtube-media-queue.test.ts b/src/anki-integration/pending-youtube-media-queue.test.ts new file mode 100644 index 00000000..f2cca786 --- /dev/null +++ b/src/anki-integration/pending-youtube-media-queue.test.ts @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { AnkiConnectConfig } from '../types/anki'; +import { + PendingYoutubeMediaQueue, + type PendingYoutubeMediaQueueDeps, +} from './pending-youtube-media-queue'; + +function createDeps( + overrides: Partial = {}, +): PendingYoutubeMediaQueueDeps { + const warnings: unknown[][] = []; + const deps: PendingYoutubeMediaQueueDeps & { warnings: unknown[][] } = { + client: { + notesInfo: async () => [], + updateNoteFields: async () => {}, + storeMediaFile: async () => {}, + }, + mediaGenerator: { + generateAudio: async () => Buffer.from('audio'), + generateScreenshot: async () => Buffer.from('image'), + generateAnimatedImage: async () => Buffer.from('image'), + }, + getConfig: () => + ({ + media: { generateAudio: true, generateImage: true }, + fields: {}, + }) as AnkiConnectConfig, + getCurrentVideoPath: () => 'https://www.youtube.com/watch?v=abc123', + getCachedMediaPath: async () => null, + shouldRequireRemoteMediaCache: () => true, + getSubtitleMediaRange: () => ({ startTime: 1, endTime: 2 }), + getResolvedSentenceAudioFieldName: () => 'SentenceAudio', + resolveConfiguredFieldName: () => 'Picture', + mergeFieldValue: (_existing, newValue) => newValue, + getAnimatedImageLeadInSeconds: async () => 0, + generateAudioFilename: () => 'audio.mp3', + generateImageFilename: () => 'image.webp', + formatMiscInfoPatternForMediaPath: () => '', + showStatusNotification: () => {}, + showNotification: async () => {}, + logInfo: () => {}, + logWarn: (...args) => { + warnings.push(args); + }, + logError: () => {}, + warnings, + ...overrides, + }; + return deps; +} + +test('PendingYoutubeMediaQueue treats cache lookup failures as an immediate generation fallback', async () => { + const deps = createDeps({ + getCachedMediaPath: async () => { + throw new Error('cache unavailable'); + }, + }); + const queue = new PendingYoutubeMediaQueue(deps); + + const queued = await queue.queueFromNote({ + noteId: 42, + noteInfo: { noteId: 42, fields: {} }, + label: 'demo', + }); + + assert.equal(queued, false); + assert.deepEqual((deps as typeof deps & { warnings: unknown[][] }).warnings, [ + [ + 'Failed to read YouTube cache state; falling back to immediate media generation:', + 'cache unavailable', + ], + ]); +}); + +test('PendingYoutubeMediaQueue drains matching queued jobs when the cache download fails', async () => { + const statusMessages: string[] = []; + const notifications: Array<{ noteId: number; label: string | number; suffix?: string }> = []; + const updatedNotes: number[] = []; + const deps = createDeps({ + client: { + notesInfo: async (noteIds) => + noteIds.map((noteId) => ({ + noteId, + fields: { + SentenceAudio: { value: '' }, + Picture: { value: '' }, + }, + })), + updateNoteFields: async (noteId) => { + updatedNotes.push(noteId); + }, + storeMediaFile: async () => {}, + }, + showStatusNotification: (message) => { + statusMessages.push(message); + }, + showNotification: async (noteId, label, suffix) => { + notifications.push({ noteId, label, suffix }); + }, + }); + const queue = new PendingYoutubeMediaQueue(deps); + + queue.enqueue({ + sourceUrl: 'https://www.youtube.com/watch?v=abc123', + noteId: 42, + startTime: 1, + endTime: 2, + label: 'queued', + generateAudio: true, + generateImage: true, + }); + + await queue.handleFailed('https://youtu.be/abc123'); + await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv'); + + assert.deepEqual(updatedNotes, []); + assert.deepEqual(notifications, [{ noteId: 42, label: 'queued', suffix: 'media cache failed' }]); + assert.equal( + statusMessages.includes('YouTube media cache failed. Media was not added to 1 queued card.'), + true, + ); +}); + +test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queuing from notes', async () => { + const updatedNotes: Array<{ noteId: number; fields: Record }> = []; + const storedMedia: string[] = []; + const deps = createDeps({ + client: { + notesInfo: async (noteIds) => + noteIds.map((noteId) => ({ + noteId, + fields: { + SentenceAudio: { value: '' }, + Picture: { value: '' }, + }, + })), + updateNoteFields: async (noteId, fields) => { + updatedNotes.push({ noteId, fields }); + }, + storeMediaFile: async (filename) => { + storedMedia.push(filename); + }, + }, + getConfig: () => ({ media: {}, fields: { image: 'Picture' } }) as AnkiConnectConfig, + }); + const queue = new PendingYoutubeMediaQueue(deps); + + const queued = await queue.queueFromNote({ + noteId: 42, + noteInfo: { noteId: 42, fields: {} }, + label: 'demo', + }); + await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv'); + + assert.equal(queued, true); + assert.equal(updatedNotes.length, 1); + assert.equal(storedMedia.length, 2); + assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio\.mp3\]$/); + assert.match(updatedNotes[0]?.fields.Picture ?? '', /^$/); +}); + +test('PendingYoutubeMediaQueue only announces a download once per source while jobs collect', () => { + const statusMessages: string[] = []; + const deps = createDeps({ + showStatusNotification: (message) => { + statusMessages.push(message); + }, + }); + const queue = new PendingYoutubeMediaQueue(deps); + + queue.enqueue({ + sourceUrl: 'https://youtu.be/abc123', + noteId: 1, + startTime: 1, + endTime: 2, + label: 'first', + generateAudio: true, + generateImage: false, + }); + queue.enqueue({ + sourceUrl: 'https://www.youtube.com/watch?v=abc123', + noteId: 2, + startTime: 3, + endTime: 4, + label: 'second', + generateAudio: false, + generateImage: true, + }); + + assert.equal(statusMessages.length, 1); +}); diff --git a/src/anki-integration/pending-youtube-media-queue.ts b/src/anki-integration/pending-youtube-media-queue.ts new file mode 100644 index 00000000..40745d20 --- /dev/null +++ b/src/anki-integration/pending-youtube-media-queue.ts @@ -0,0 +1,391 @@ +import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; +import type { MediaInput } from '../media-input'; +import type { MediaGenerator } from '../media-generator'; +import type { AnkiConnectConfig } from '../types/anki'; +import type { SubtitleMiningContext } from '../types/subtitle'; +import { youtubeMediaUrlsMatch, type PendingYoutubeMediaUpdate } from './pending-youtube-media'; +import type { MediaGenerationInputResolverOptions } from './media-source'; + +type PendingYoutubeMediaUpdateResult = 'updated' | 'partial' | 'failed'; + +export interface PendingYoutubeMediaQueueReadyOptions { + notifyNoQueued?: boolean; +} + +export interface PendingYoutubeMediaQueueFailedOptions { + notifyStatus?: boolean; +} + +export interface PendingYoutubeMediaNoteInfo { + noteId: number; + fields: Record; +} + +export interface PendingYoutubeMediaQueueDeps { + client: { + notesInfo(noteIds: number[]): Promise; + updateNoteFields(noteId: number, fields: Record): Promise; + storeMediaFile(filename: string, data: Buffer): Promise; + }; + mediaGenerator: Pick< + MediaGenerator, + 'generateAudio' | 'generateScreenshot' | 'generateAnimatedImage' + >; + getConfig: () => AnkiConnectConfig; + getCurrentVideoPath: () => string | undefined; + getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null; + shouldRequireRemoteMediaCache: () => boolean; + getSubtitleMediaRange: (context?: SubtitleMiningContext) => { + startTime: number; + endTime: number; + }; + getResolvedSentenceAudioFieldName: (noteInfo: PendingYoutubeMediaNoteInfo) => string | null; + resolveConfiguredFieldName: ( + noteInfo: PendingYoutubeMediaNoteInfo, + ...preferredNames: (string | undefined)[] + ) => string | null; + mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string; + getAnimatedImageLeadInSeconds: (noteInfo: PendingYoutubeMediaNoteInfo) => Promise; + generateAudioFilename: () => string; + generateImageFilename: () => string; + formatMiscInfoPatternForMediaPath: ( + fallbackFilename: string, + startTimeSeconds: number | undefined, + mediaPath: string, + mediaTitle?: string, + ) => string; + showStatusNotification: (message: string) => void; + showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise; + logInfo: (message: string, ...args: unknown[]) => void; + logWarn: (message: string, ...args: unknown[]) => void; + logError: (message: string, ...args: unknown[]) => void; +} + +function trimToNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function shouldGenerateAudio(config: AnkiConnectConfig): boolean { + return config.media?.generateAudio !== false; +} + +function shouldGenerateImage(config: AnkiConnectConfig): boolean { + return config.media?.generateImage !== false; +} + +export class PendingYoutubeMediaQueue { + private updates: PendingYoutubeMediaUpdate[] = []; + + constructor(private readonly deps: PendingYoutubeMediaQueueDeps) {} + + enqueue(job: PendingYoutubeMediaUpdate): void { + if (!job.generateAudio && !job.generateImage) { + return; + } + const isFirstQueuedForSource = !this.updates.some((existing) => + youtubeMediaUrlsMatch(existing.sourceUrl, job.sourceUrl), + ); + this.updates.push(job); + this.deps.logInfo('Queued YouTube media update for note:', job.noteId); + if (isFirstQueuedForSource) { + this.deps.showStatusNotification( + 'YouTube media cache is still downloading. Card media will be added when the cache is ready.', + ); + } + } + + async queueFromNote(job: { + noteId: number; + noteInfo: PendingYoutubeMediaNoteInfo; + context?: SubtitleMiningContext; + label: string | number; + }): Promise { + const sourceUrl = trimToNonEmptyString(this.deps.getCurrentVideoPath()); + const getCachedMediaPath = this.deps.getCachedMediaPath; + if (!sourceUrl || this.deps.shouldRequireRemoteMediaCache() !== true || !getCachedMediaPath) { + return false; + } + + let cachedPath: string | null = null; + try { + cachedPath = await getCachedMediaPath(sourceUrl, 'video'); + } catch (error) { + this.deps.logWarn( + 'Failed to read YouTube cache state; falling back to immediate media generation:', + error instanceof Error ? error.message : String(error), + ); + return false; + } + if (cachedPath) { + return false; + } + + const config = this.deps.getConfig(); + const mediaRange = this.deps.getSubtitleMediaRange(job.context); + this.enqueue({ + sourceUrl, + noteId: job.noteId, + startTime: mediaRange.startTime, + endTime: mediaRange.endTime, + label: job.label, + audioFieldName: this.deps.getResolvedSentenceAudioFieldName(job.noteInfo) ?? undefined, + imageFieldName: + this.deps.resolveConfiguredFieldName( + job.noteInfo, + config.fields?.image, + DEFAULT_ANKI_CONNECT_CONFIG.fields.image, + ) ?? undefined, + miscInfoFieldName: + this.deps.resolveConfiguredFieldName(job.noteInfo, config.fields?.miscInfo) ?? undefined, + generateAudio: shouldGenerateAudio(config), + generateImage: shouldGenerateImage(config), + }); + return true; + } + + async handleReady( + sourceUrl: string, + cachedPath: string, + options: PendingYoutubeMediaQueueReadyOptions = {}, + ): Promise { + const jobs = this.takeMatchingUpdates(sourceUrl); + if (jobs.length === 0) { + if (options.notifyNoQueued !== false) { + this.deps.showStatusNotification('YouTube media cache ready.'); + } + return; + } + + this.deps.showStatusNotification( + `YouTube media cache ready. Adding media to ${jobs.length} queued card${ + jobs.length === 1 ? '' : 's' + }.`, + ); + + let updatedCount = 0; + let partialCount = 0; + let failedCount = 0; + for (const job of jobs) { + try { + const result = await this.applyUpdate(job, cachedPath); + if (result === 'updated') { + updatedCount += 1; + } else if (result === 'partial') { + partialCount += 1; + } else { + failedCount += 1; + } + } catch (error) { + failedCount += 1; + this.deps.logError( + 'Failed to apply queued YouTube media update:', + error instanceof Error ? error.message : String(error), + ); + } + } + + if (partialCount > 0 || failedCount > 0) { + this.deps.showStatusNotification( + `Queued YouTube media finished with ${updatedCount} updated, ${partialCount} partial, and ${failedCount} failed.`, + ); + } + } + + async handleFailed( + sourceUrl: string, + options: PendingYoutubeMediaQueueFailedOptions = {}, + ): Promise { + const jobs = this.takeMatchingUpdates(sourceUrl); + if (jobs.length === 0) { + if (options.notifyStatus !== false) { + this.deps.showStatusNotification('YouTube media cache failed.'); + } + return; + } + + if (options.notifyStatus !== false) { + this.deps.showStatusNotification( + `YouTube media cache failed. Media was not added to ${jobs.length} queued card${ + jobs.length === 1 ? '' : 's' + }.`, + ); + } + this.deps.logWarn('Discarding queued YouTube media updates after cache failure:', jobs.length); + + for (const job of jobs) { + try { + await this.deps.showNotification(job.noteId, job.label, 'media cache failed'); + } catch (error) { + this.deps.logWarn( + 'Failed to show queued YouTube media failure notification:', + error instanceof Error ? error.message : String(error), + ); + } + } + } + + private takeMatchingUpdates(sourceUrl: string): PendingYoutubeMediaUpdate[] { + const matched: PendingYoutubeMediaUpdate[] = []; + const remaining: PendingYoutubeMediaUpdate[] = []; + for (const job of this.updates) { + if (youtubeMediaUrlsMatch(job.sourceUrl, sourceUrl)) { + matched.push(job); + } else { + remaining.push(job); + } + } + this.updates = remaining; + return matched; + } + + private async applyUpdate( + job: PendingYoutubeMediaUpdate, + cachedPath: string, + ): Promise { + const notesInfoResult = await this.deps.client.notesInfo([job.noteId]); + const notesInfo = notesInfoResult as unknown as PendingYoutubeMediaNoteInfo[]; + const noteInfo = notesInfo[0]; + if (!noteInfo) { + this.deps.logWarn('Queued YouTube media target note not found:', job.noteId); + return 'failed'; + } + + const config = this.deps.getConfig(); + const mediaFields: Record = {}; + const errors: string[] = []; + let miscInfoFilename: string | null = null; + const cachedMediaInput: MediaInput = { + path: cachedPath, + source: 'youtube-cache', + }; + + if (job.generateAudio) { + try { + const audioFilename = this.deps.generateAudioFilename(); + const audioBuffer = await this.deps.mediaGenerator.generateAudio( + cachedMediaInput, + job.startTime, + job.endTime, + config.media?.audioPadding, + undefined, + ); + if (audioBuffer) { + await this.deps.client.storeMediaFile(audioFilename, audioBuffer); + const audioField = + job.audioFieldName || this.deps.getResolvedSentenceAudioFieldName(noteInfo) || null; + if (audioField) { + const existingAudio = noteInfo.fields[audioField]?.value || ''; + mediaFields[audioField] = this.deps.mergeFieldValue( + existingAudio, + `[sound:${audioFilename}]`, + config.behavior?.overwriteAudio !== false, + ); + } + miscInfoFilename = audioFilename; + } + } catch (error) { + errors.push('audio'); + this.deps.logError('Failed to generate queued YouTube audio:', (error as Error).message); + } + } + + if (job.generateImage) { + try { + const imageFilename = this.deps.generateImageFilename(); + const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo); + const imageBuffer = await this.generateImageFromInput( + cachedMediaInput, + job.startTime, + job.endTime, + animatedLeadInSeconds, + ); + if (imageBuffer) { + await this.deps.client.storeMediaFile(imageFilename, imageBuffer); + const imageField = + job.imageFieldName || + this.deps.resolveConfiguredFieldName( + noteInfo, + config.fields?.image, + DEFAULT_ANKI_CONNECT_CONFIG.fields.image, + ); + if (imageField) { + const existingImage = noteInfo.fields[imageField]?.value || ''; + mediaFields[imageField] = this.deps.mergeFieldValue( + existingImage, + ``, + config.behavior?.overwriteImage !== false, + ); + } else { + this.deps.logWarn( + 'Image field not found on queued YouTube media note, skipping image update', + ); + } + miscInfoFilename = imageFilename; + } + } catch (error) { + errors.push('image'); + this.deps.logError('Failed to generate queued YouTube image:', (error as Error).message); + } + } + + if (config.fields?.miscInfo && miscInfoFilename) { + const miscInfoField = + job.miscInfoFieldName || + this.deps.resolveConfiguredFieldName(noteInfo, config.fields.miscInfo); + const miscInfo = this.deps.formatMiscInfoPatternForMediaPath( + miscInfoFilename, + job.startTime, + job.sourceUrl, + ); + if (miscInfoField && miscInfo) { + mediaFields[miscInfoField] = miscInfo; + } + } + + if (Object.keys(mediaFields).length === 0) { + return 'failed'; + } + + await this.deps.client.updateNoteFields(job.noteId, mediaFields); + const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined; + await this.deps.showNotification(job.noteId, job.label, errorSuffix); + this.deps.logInfo('Applied queued YouTube media update for note:', job.noteId); + return errors.length === 0 ? 'updated' : 'partial'; + } + + private async generateImageFromInput( + videoPath: MediaInput, + startTime: number, + endTime: number, + animatedLeadInSeconds = 0, + ): Promise { + const config = this.deps.getConfig(); + if (config.media?.imageType === 'avif') { + return this.deps.mediaGenerator.generateAnimatedImage( + videoPath, + startTime, + endTime, + config.media?.audioPadding, + { + fps: config.media?.animatedFps, + maxWidth: config.media?.animatedMaxWidth, + maxHeight: config.media?.animatedMaxHeight, + crf: config.media?.animatedCrf, + leadingStillDuration: animatedLeadInSeconds, + }, + ); + } + + const timestamp = startTime + (endTime - startTime) / 2; + return this.deps.mediaGenerator.generateScreenshot(videoPath, timestamp, { + format: config.media?.imageFormat as 'jpg' | 'png' | 'webp', + quality: config.media?.imageQuality, + maxWidth: config.media?.imageMaxWidth, + maxHeight: config.media?.imageMaxHeight, + }); + } +} diff --git a/src/anki-integration/pending-youtube-media.ts b/src/anki-integration/pending-youtube-media.ts new file mode 100644 index 00000000..830f7adf --- /dev/null +++ b/src/anki-integration/pending-youtube-media.ts @@ -0,0 +1,53 @@ +export interface PendingYoutubeMediaUpdate { + sourceUrl: string; + noteId: number; + startTime: number; + endTime: number; + label: string | number; + audioFieldName?: string; + imageFieldName?: string; + miscInfoFieldName?: string; + generateAudio: boolean; + generateImage: boolean; +} + +function trimToNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function getYoutubeVideoId(rawUrl: string): string | null { + try { + const parsed = new URL(rawUrl); + const host = parsed.hostname.toLowerCase(); + if (host === 'youtu.be' || host.endsWith('.youtu.be')) { + return trimToNonEmptyString(parsed.pathname.replace(/^\/+/, '').split('/')[0]); + } + if (host === 'youtube.com' || host.endsWith('.youtube.com')) { + const watchId = trimToNonEmptyString(parsed.searchParams.get('v')); + if (watchId) { + return watchId; + } + const parts = parsed.pathname.split('/').filter(Boolean); + if (parts[0] === 'shorts' || parts[0] === 'embed' || parts[0] === 'live') { + return trimToNonEmptyString(parts[1]); + } + } + } catch { + return null; + } + return null; +} + +export function youtubeMediaUrlsMatch(a: string, b: string): boolean { + if (a.trim() === b.trim()) { + return true; + } + + const aVideoId = getYoutubeVideoId(a); + const bVideoId = getYoutubeVideoId(b); + return Boolean(aVideoId && bVideoId && aVideoId === bVideoId); +} diff --git a/src/config/config.test.ts b/src/config/config.test.ts index b823c4c0..79687015 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -80,6 +80,8 @@ test('loads defaults when config is missing', () => { assert.equal('remoteControlDeviceName' in config.jellyfin, false); assert.equal('deviceId' in config.jellyfin, false); assert.equal('clientVersion' in config.jellyfin, false); + assert.equal(config.youtube.mediaCache.mode, 'direct'); + assert.equal(config.youtube.mediaCache.maxHeight, 720); assert.equal(config.ai.enabled, false); assert.equal(config.ai.apiKeyCommand, ''); assert.equal(config.texthooker.openBrowser, false); @@ -1750,6 +1752,56 @@ test('parses global shortcuts and startup settings', () => { assert.equal(config.youtubeSubgen.fixWithAi, true); }); +test('parses YouTube media cache config and warns on invalid values', () => { + const validDir = makeTempDir(); + fs.writeFileSync( + path.join(validDir, 'config.jsonc'), + `{ + "youtube": { + "mediaCache": { + "mode": "background", + "maxHeight": 480 + } + } + }`, + 'utf-8', + ); + + const validService = new ConfigService(validDir); + assert.equal(validService.getConfig().youtube.mediaCache.mode, 'background'); + assert.equal(validService.getConfig().youtube.mediaCache.maxHeight, 480); + + const invalidDir = makeTempDir(); + fs.writeFileSync( + path.join(invalidDir, 'config.jsonc'), + `{ + "youtube": { + "mediaCache": { + "mode": "always", + "maxHeight": -1 + } + } + }`, + 'utf-8', + ); + + const invalidService = new ConfigService(invalidDir); + assert.equal( + invalidService.getConfig().youtube.mediaCache.mode, + DEFAULT_CONFIG.youtube.mediaCache.mode, + ); + assert.equal( + invalidService.getConfig().youtube.mediaCache.maxHeight, + DEFAULT_CONFIG.youtube.mediaCache.maxHeight, + ); + assert.ok( + invalidService.getWarnings().some((warning) => warning.path === 'youtube.mediaCache.mode'), + ); + assert.ok( + invalidService.getWarnings().some((warning) => warning.path === 'youtube.mediaCache.maxHeight'), + ); +}); + test('parses controller settings with logical bindings and tuning knobs', () => { const dir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index f17b79fc..a0adc8fc 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -111,6 +111,10 @@ export const CORE_DEFAULT_CONFIG: Pick< }, youtube: { primarySubLanguages: ['ja', 'jpn'], + mediaCache: { + mode: 'direct', + maxHeight: 720, + }, }, subsync: { alass_path: '', diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index c86a7174..70957d9e 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -119,6 +119,24 @@ export function buildCoreConfigOptionRegistry( description: 'Comma-separated primary subtitle language priority for managed subtitle auto-selection.', }, + { + path: 'youtube.mediaCache.mode', + kind: 'enum', + enumValues: ['direct', 'background'], + enumLabels: { + direct: 'Direct stream extraction', + background: 'Background media cache', + }, + defaultValue: defaultConfig.youtube.mediaCache.mode, + description: 'How YouTube card audio/images are extracted.', + }, + { + path: 'youtube.mediaCache.maxHeight', + kind: 'number', + defaultValue: defaultConfig.youtube.mediaCache.maxHeight, + description: + 'Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.', + }, { path: 'controller.enabled', kind: 'boolean', diff --git a/src/config/definitions/shared.ts b/src/config/definitions/shared.ts index 76aadf38..82d68a51 100644 --- a/src/config/definitions/shared.ts +++ b/src/config/definitions/shared.ts @@ -32,6 +32,7 @@ export interface ConfigOptionRegistryEntry { * `osd` and `osd-system` in NOTIFICATION_TYPE_VALUES. */ enumValues?: readonly string[]; + enumLabels?: Record; /** * Optional settings UI subset when legacy/runtime-valid enum options should remain * editable in config files but hidden from new UI choices, for example diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 203998cb..cc1e167b 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -297,6 +297,39 @@ export function applyCoreDomainConfig(context: ResolveContext): void { 'Expected string array.', ); } + + if (isObject(src.youtube.mediaCache)) { + const mode = src.youtube.mediaCache.mode; + if (mode === 'direct' || mode === 'background') { + resolved.youtube.mediaCache.mode = mode; + } else if (mode !== undefined) { + warn( + 'youtube.mediaCache.mode', + mode, + resolved.youtube.mediaCache.mode, + "Expected 'direct' or 'background'.", + ); + } + + const maxHeight = asNumber(src.youtube.mediaCache.maxHeight); + if (maxHeight !== undefined && Number.isInteger(maxHeight) && maxHeight >= 0) { + resolved.youtube.mediaCache.maxHeight = maxHeight; + } else if (src.youtube.mediaCache.maxHeight !== undefined) { + warn( + 'youtube.mediaCache.maxHeight', + src.youtube.mediaCache.maxHeight, + resolved.youtube.mediaCache.maxHeight, + 'Expected a whole number at least 0.', + ); + } + } else if (src.youtube.mediaCache !== undefined) { + warn( + 'youtube.mediaCache', + src.youtube.mediaCache, + resolved.youtube.mediaCache, + 'Expected object.', + ); + } } if (isObject(src.subsync)) { diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index c5cccb8b..7ed51405 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -165,6 +165,20 @@ test('settings registry exposes specialized controls for config-assisted inputs' assert.equal(field('discordPresence.presenceStyle').control, 'select'); }); +test('settings registry exposes YouTube media cache mode as a labeled select', () => { + const mediaCacheMode = field('youtube.mediaCache.mode'); + const mediaCacheMaxHeight = field('youtube.mediaCache.maxHeight'); + + assert.equal(mediaCacheMode.control, 'select'); + assert.deepEqual(mediaCacheMode.enumValues, ['direct', 'background']); + assert.deepEqual(mediaCacheMode.enumLabels, { + direct: 'Direct stream extraction', + background: 'Background media cache', + }); + assert.equal(mediaCacheMaxHeight.control, 'number'); + assert.equal(mediaCacheMaxHeight.defaultValue, 720); +}); + test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => { const primaryVisible = fields .filter( diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index a815379a..7aaed909 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -720,6 +720,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField { ...(option?.settingsEnumValues || option?.enumValues ? { enumValues: option.settingsEnumValues ?? option.enumValues } : {}), + ...(option?.enumLabels ? { enumLabels: option.enumLabels } : {}), restartBehavior: restartBehaviorForPath(leaf.path), advanced: leaf.path.startsWith('controller.') || diff --git a/src/core/services/anki-jimaku.ts b/src/core/services/anki-jimaku.ts index 625afeab..b2dc19c1 100644 --- a/src/core/services/anki-jimaku.ts +++ b/src/core/services/anki-jimaku.ts @@ -40,6 +40,11 @@ export interface AnkiJimakuIpcRuntimeOptions { getAnkiIntegration: () => AnkiIntegration | null; setAnkiIntegration: (integration: AnkiIntegration | null) => void; getKnownWordCacheStatePath: () => string; + getCachedMediaPath?: ( + currentVideoPath: string, + kind: 'audio' | 'video', + ) => Promise; + shouldRequireRemoteMediaCache?: () => boolean; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: () => ( @@ -107,6 +112,8 @@ export function registerAnkiJimakuIpcRuntime( mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig, undefined, options.showOverlayNotification, + options.getCachedMediaPath, + options.shouldRequireRemoteMediaCache, ); integration.start(); options.setAnkiIntegration(integration); diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts index 29c10944..ef925689 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -25,6 +25,11 @@ type CreateAnkiIntegrationArgs = { data: KikuFieldGroupingRequestData, ) => Promise; knownWordCacheStatePath: string; + getCachedMediaPath?: ( + currentVideoPath: string, + kind: 'audio' | 'video', + ) => Promise; + shouldRequireRemoteMediaCache?: () => boolean; }; export type OverlayWindowTrackerOptions = { @@ -65,6 +70,8 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte args.aiConfig, undefined, args.showOverlayNotification, + args.getCachedMediaPath, + args.shouldRequireRemoteMediaCache, ); } @@ -132,6 +139,11 @@ export function initializeOverlayRuntime( data: KikuFieldGroupingRequestData, ) => Promise; getKnownWordCacheStatePath: () => string; + getCachedMediaPath?: ( + currentVideoPath: string, + kind: 'audio' | 'video', + ) => Promise; + shouldRequireRemoteMediaCache?: () => boolean; shouldStartAnkiIntegration?: () => boolean; createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; backendOverride: string | null; @@ -166,6 +178,11 @@ export function initializeOverlayAnkiIntegration(options: { data: KikuFieldGroupingRequestData, ) => Promise; getKnownWordCacheStatePath: () => string; + getCachedMediaPath?: ( + currentVideoPath: string, + kind: 'audio' | 'video', + ) => Promise; + shouldRequireRemoteMediaCache?: () => boolean; shouldStartAnkiIntegration?: () => boolean; createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; }): boolean { @@ -200,6 +217,10 @@ export function initializeOverlayAnkiIntegration(options: { showOverlayNotification: options.showOverlayNotification, createFieldGroupingCallback: options.createFieldGroupingCallback, knownWordCacheStatePath: options.getKnownWordCacheStatePath(), + ...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}), + ...(options.shouldRequireRemoteMediaCache + ? { shouldRequireRemoteMediaCache: options.shouldRequireRemoteMediaCache } + : {}), }); if (options.shouldStartAnkiIntegration?.() !== false) { integration.start(); diff --git a/src/core/services/youtube/media-cache.test.ts b/src/core/services/youtube/media-cache.test.ts new file mode 100644 index 00000000..ff004a93 --- /dev/null +++ b/src/core/services/youtube/media-cache.test.ts @@ -0,0 +1,540 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import test from 'node:test'; + +import { createYoutubeMediaCacheService } from './media-cache'; + +class FakeYtDlpProcess extends EventEmitter { + killed = false; + stdout = new EventEmitter(); + stderr = new EventEmitter(); + + kill(): boolean { + this.killed = true; + return true; + } +} + +type SpawnCall = { + command: string; + args: string[]; + options?: { stdio?: Array<'ignore' | 'pipe'> }; +}; + +function makeTempCacheRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-media-cache-test-')); +} + +test('YouTube media cache does nothing in direct mode', async () => { + const cacheRoot = makeTempCacheRoot(); + const spawnCalls: SpawnCall[] = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + spawn: (command, args) => { + spawnCalls.push({ command, args }); + return new FakeYtDlpProcess(); + }, + }); + + cache.start('https://youtu.be/demo', { mode: 'direct' }); + + assert.deepEqual(spawnCalls, []); + assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + +test('YouTube media cache exposes the downloaded file after the background job completes', async () => { + const cacheRoot = makeTempCacheRoot(); + const spawnedProcesses: FakeYtDlpProcess[] = []; + const spawnCalls: SpawnCall[] = []; + const readyEvents: Array<{ url: string; path: string }> = []; + const startedEvents: Array<{ url: string }> = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + onDownloadStarted: (event) => { + startedEvents.push(event); + }, + onReady: (event) => { + readyEvents.push(event); + }, + spawn: (command, args, options) => { + spawnCalls.push({ command, args, options }); + const proc = new FakeYtDlpProcess(); + spawnedProcesses.push(proc); + return proc; + }, + }); + + cache.start('https://youtu.be/demo', { mode: 'background' }); + + assert.deepEqual(startedEvents, [{ url: 'https://youtu.be/demo' }]); + assert.equal(spawnCalls.length, 1); + assert.equal(spawnCalls[0]?.command, 'yt-dlp'); + assert.ok(spawnCalls[0]?.args.includes('--no-playlist')); + assert.ok(spawnCalls[0]?.args.includes('--force-ipv4')); + assert.equal(spawnCalls[0]?.args[spawnCalls[0].args.indexOf('--retries') + 1], '5'); + assert.equal(spawnCalls[0]?.args[spawnCalls[0].args.indexOf('--fragment-retries') + 1], '5'); + assert.equal(spawnCalls[0]?.args[spawnCalls[0].args.indexOf('--extractor-retries') + 1], '5'); + assert.ok(spawnCalls[0]?.args.includes('--merge-output-format')); + assert.equal( + spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-f') + 1], + 'bestvideo*[height<=720]+bestaudio/best[height<=720]', + ); + assert.deepEqual(spawnCalls[0]?.options?.stdio, ['ignore', 'ignore', 'ignore']); + assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null); + + const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1]; + assert.equal(typeof outputTemplate, 'string'); + const outputDir = path.dirname(outputTemplate!); + const outputPath = path.join(outputDir, 'media.mkv'); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(outputPath, 'cached media'); + spawnedProcesses[0]?.emit('close', 0); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), outputPath); + assert.deepEqual(readyEvents, [{ url: 'https://youtu.be/demo', path: outputPath }]); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + +test('YouTube media cache clears the active cache when direct mode starts', async () => { + const cacheRoot = makeTempCacheRoot(); + const spawnedProcesses: FakeYtDlpProcess[] = []; + const spawnCalls: SpawnCall[] = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + spawn: (command, args, options) => { + spawnCalls.push({ command, args, options }); + const proc = new FakeYtDlpProcess(); + spawnedProcesses.push(proc); + return proc; + }, + }); + + cache.start('https://youtu.be/background', { mode: 'background' }); + const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1]; + assert.equal(typeof outputTemplate, 'string'); + const outputDir = path.dirname(outputTemplate!); + const outputPath = path.join(outputDir, 'media.mkv'); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(outputPath, 'cached media'); + spawnedProcesses[0]?.emit('close', 0); + + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(await cache.getActiveCachedMediaPath(), outputPath); + + cache.start('https://youtu.be/direct', { mode: 'direct' }); + + assert.equal(await cache.getActiveCachedMediaPath(), null); + assert.equal(await cache.getCachedMediaPath('https://youtu.be/background'), outputPath); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + +test('YouTube media cache cancels the active download when direct mode starts', async () => { + const cacheRoot = makeTempCacheRoot(); + const spawnedProcesses: FakeYtDlpProcess[] = []; + const spawnCalls: SpawnCall[] = []; + const readyEvents: Array<{ url: string; path: string }> = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + onReady: (event) => { + readyEvents.push(event); + }, + spawn: (command, args, options) => { + spawnCalls.push({ command, args, options }); + const proc = new FakeYtDlpProcess(); + spawnedProcesses.push(proc); + return proc; + }, + }); + + cache.start('https://youtu.be/background', { mode: 'background' }); + const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1]; + assert.equal(typeof outputTemplate, 'string'); + const outputDir = path.dirname(outputTemplate!); + const outputPath = path.join(outputDir, 'media.mkv'); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(outputPath, 'cached media'); + + cache.start('https://youtu.be/direct', { mode: 'direct' }); + spawnedProcesses[0]?.emit('close', 0); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(spawnedProcesses[0]?.killed, true); + assert.deepEqual(readyEvents, []); + assert.equal(await cache.getActiveCachedMediaPath(), null); + assert.equal(await cache.getCachedMediaPath('https://youtu.be/background'), null); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + +test('YouTube media cache reports failed background downloads', async () => { + const cacheRoot = makeTempCacheRoot(); + const spawnedProcesses: FakeYtDlpProcess[] = []; + const failedEvents: Array<{ url: string }> = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + onFailed: (event) => { + failedEvents.push(event); + }, + spawn: () => { + const proc = new FakeYtDlpProcess(); + spawnedProcesses.push(proc); + return proc; + }, + }); + + cache.start('https://youtu.be/demo', { mode: 'background' }); + spawnedProcesses[0]?.emit('close', 1); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(failedEvents, [{ url: 'https://youtu.be/demo' }]); + assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + +test('YouTube media cache only reports a failed download once when error is followed by close', async () => { + const cacheRoot = makeTempCacheRoot(); + const spawnedProcesses: FakeYtDlpProcess[] = []; + const failedEvents: Array<{ url: string }> = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + onFailed: (event) => { + failedEvents.push(event); + }, + spawn: () => { + const proc = new FakeYtDlpProcess(); + spawnedProcesses.push(proc); + return proc; + }, + }); + + cache.start('https://youtu.be/demo', { mode: 'background' }); + spawnedProcesses[0]?.emit('error', new Error('spawn failed')); + spawnedProcesses[0]?.emit('close', 1); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(failedEvents, [{ url: 'https://youtu.be/demo' }]); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + +test('YouTube media cache can disable the download height cap', () => { + const cacheRoot = makeTempCacheRoot(); + const spawnCalls: SpawnCall[] = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + spawn: (command, args, options) => { + spawnCalls.push({ command, args, options }); + return new FakeYtDlpProcess(); + }, + }); + + cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 0 }); + + assert.equal(spawnCalls.length, 1); + assert.equal( + spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-f') + 1], + 'bestvideo*+bestaudio/best', + ); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + +test('YouTube media cache applies the configured download height cap', () => { + const cacheRoot = makeTempCacheRoot(); + const spawnCalls: SpawnCall[] = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + spawn: (command, args, options) => { + spawnCalls.push({ command, args, options }); + return new FakeYtDlpProcess(); + }, + }); + + cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 480 }); + + assert.equal(spawnCalls.length, 1); + assert.equal( + spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-f') + 1], + 'bestvideo*[height<=480]+bestaudio/best[height<=480]', + ); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + +test('YouTube media cache restarts ready sessions when the height cap changes', async () => { + const cacheRoot = makeTempCacheRoot(); + const spawnedProcesses: FakeYtDlpProcess[] = []; + const spawnCalls: SpawnCall[] = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + spawn: (command, args, options) => { + spawnCalls.push({ command, args, options }); + const proc = new FakeYtDlpProcess(); + spawnedProcesses.push(proc); + return proc; + }, + }); + + cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 720 }); + const firstOutputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1]; + assert.equal(typeof firstOutputTemplate, 'string'); + const firstOutputDir = path.dirname(firstOutputTemplate!); + const firstOutputPath = path.join(firstOutputDir, 'media.mkv'); + fs.mkdirSync(firstOutputDir, { recursive: true }); + fs.writeFileSync(firstOutputPath, 'cached media'); + spawnedProcesses[0]?.emit('close', 0); + + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), firstOutputPath); + + cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 480 }); + + assert.equal(spawnCalls.length, 2); + assert.equal(fs.existsSync(firstOutputPath), false); + assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null); + assert.equal( + spawnCalls[1]?.args[spawnCalls[1].args.indexOf('-f') + 1], + 'bestvideo*[height<=480]+bestaudio/best[height<=480]', + ); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + +test('YouTube media cache restarts running sessions when the height cap changes', () => { + const cacheRoot = makeTempCacheRoot(); + const spawnedProcesses: FakeYtDlpProcess[] = []; + const spawnCalls: SpawnCall[] = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + spawn: (command, args, options) => { + spawnCalls.push({ command, args, options }); + const proc = new FakeYtDlpProcess(); + spawnedProcesses.push(proc); + return proc; + }, + }); + + cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 720 }); + cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 480 }); + + assert.equal(spawnedProcesses[0]?.killed, true); + assert.equal(spawnCalls.length, 2); + assert.equal( + spawnCalls[1]?.args[spawnCalls[1].args.indexOf('-f') + 1], + 'bestvideo*[height<=480]+bestaudio/best[height<=480]', + ); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + +test('YouTube media cache removes stale files from previous runs on startup', () => { + const cacheRoot = makeTempCacheRoot(); + const staleDir = path.join(cacheRoot, 'stale-session'); + const spawnCalls: SpawnCall[] = []; + + try { + fs.mkdirSync(staleDir, { recursive: true }); + fs.writeFileSync(path.join(staleDir, 'media.mkv'), 'stale cached media'); + + createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + spawn: (command, args) => { + spawnCalls.push({ command, args }); + return new FakeYtDlpProcess(); + }, + }); + + assert.equal(fs.existsSync(staleDir), false); + assert.deepEqual(spawnCalls, []); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + +test('YouTube media cache restarts when a ready cached file was deleted externally', async () => { + const cacheRoot = makeTempCacheRoot(); + const spawnedProcesses: FakeYtDlpProcess[] = []; + const spawnCalls: Array<{ command: string; args: string[] }> = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + spawn: (command, args) => { + spawnCalls.push({ command, args }); + const proc = new FakeYtDlpProcess(); + spawnedProcesses.push(proc); + return proc; + }, + }); + + cache.start('https://youtu.be/demo', { mode: 'background' }); + const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1]; + assert.equal(typeof outputTemplate, 'string'); + const outputDir = path.dirname(outputTemplate!); + const outputPath = path.join(outputDir, 'media.mkv'); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(outputPath, 'cached media'); + spawnedProcesses[0]?.emit('close', 0); + + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), outputPath); + + fs.rmSync(outputPath); + cache.start('https://youtu.be/demo', { mode: 'background' }); + + assert.equal(spawnCalls.length, 2); + assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + +test('YouTube media cache removes stale disk siblings before starting a new cache', () => { + const cacheRoot = makeTempCacheRoot(); + const staleDir = path.join(cacheRoot, 'stale-sibling'); + const spawnCalls: Array<{ command: string; args: string[] }> = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + spawn: (command, args) => { + spawnCalls.push({ command, args }); + return new FakeYtDlpProcess(); + }, + }); + fs.mkdirSync(staleDir, { recursive: true }); + fs.writeFileSync(path.join(staleDir, 'media.mkv'), 'stale cached media'); + + cache.start('https://youtu.be/demo', { mode: 'background' }); + + assert.equal(fs.existsSync(staleDir), false); + assert.equal(spawnCalls.length, 1); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + +test('YouTube media cache drops old sessions when a new background cache starts', async () => { + const cacheRoot = makeTempCacheRoot(); + const spawnedProcesses: FakeYtDlpProcess[] = []; + const spawnCalls: Array<{ command: string; args: string[] }> = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + spawn: (command, args) => { + spawnCalls.push({ command, args }); + const proc = new FakeYtDlpProcess(); + spawnedProcesses.push(proc); + return proc; + }, + }); + + cache.start('https://youtu.be/first', { mode: 'background' }); + const firstOutputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1]; + assert.equal(typeof firstOutputTemplate, 'string'); + const firstOutputDir = path.dirname(firstOutputTemplate!); + fs.mkdirSync(firstOutputDir, { recursive: true }); + fs.writeFileSync(path.join(firstOutputDir, 'media.mkv'), 'cached media'); + + cache.start('https://youtu.be/second', { mode: 'background' }); + + assert.equal(spawnedProcesses[0]?.killed, true); + assert.equal(fs.existsSync(firstOutputDir), false); + assert.equal(await cache.getCachedMediaPath('https://youtu.be/first'), null); + assert.equal(spawnCalls.length, 2); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + +test('YouTube media cache cleanup kills downloads and removes temp files', async () => { + const cacheRoot = makeTempCacheRoot(); + const spawnedProcesses: FakeYtDlpProcess[] = []; + const spawnCalls: Array<{ command: string; args: string[] }> = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + spawn: (command, args) => { + spawnCalls.push({ command, args }); + const proc = new FakeYtDlpProcess(); + spawnedProcesses.push(proc); + return proc; + }, + }); + + cache.start('https://youtu.be/demo', { mode: 'background' }); + const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1]; + assert.equal(typeof outputTemplate, 'string'); + const outputDir = path.dirname(outputTemplate!); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(path.join(outputDir, 'media.mkv'), 'cached media'); + + cache.cleanup(); + + assert.equal(spawnedProcesses[0]?.killed, true); + assert.equal(fs.existsSync(outputDir), false); + assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); diff --git a/src/core/services/youtube/media-cache.ts b/src/core/services/youtube/media-cache.ts new file mode 100644 index 00000000..a2c9c151 --- /dev/null +++ b/src/core/services/youtube/media-cache.ts @@ -0,0 +1,307 @@ +import { spawn as spawnProcess } from 'node:child_process'; +import * as crypto from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { YoutubeMediaCacheMode } from '../../../types/integrations'; +import { getYoutubeYtDlpCommand } from './ytdlp-command'; + +type MediaCacheSessionState = 'running' | 'ready' | 'failed'; + +type SpawnedProcess = EventEmitter & { + killed?: boolean; + kill?: (signal?: NodeJS.Signals | number) => boolean; +}; + +type SpawnProcess = ( + command: string, + args: string[], + options?: { stdio?: Array<'ignore' | 'pipe'> }, +) => SpawnedProcess; + +interface MediaCacheSession { + url: string; + dir: string; + maxHeight: number; + process: SpawnedProcess | null; + readyPath: string | null; + state: MediaCacheSessionState; +} + +export interface YoutubeMediaCacheStartOptions { + mode: YoutubeMediaCacheMode; + maxHeight?: number; +} + +export interface YoutubeMediaCacheServiceDeps { + cacheRoot?: string; + getYtDlpCommand?: () => string; + spawn?: SpawnProcess; + onDownloadStarted?: (event: { url: string }) => void; + onReady?: (event: { url: string; path: string }) => void; + onFailed?: (event: { url: string }) => void; + logInfo?: (message: string) => void; + logWarn?: (message: string) => void; +} + +const MEDIA_FILE_EXTENSIONS = new Set(['.mkv', '.mp4', '.webm', '.m4a', '.mp3', '.opus']); +const DEFAULT_MAX_HEIGHT = 720; + +function cacheKeyForUrl(url: string): string { + return crypto.createHash('sha256').update(url).digest('hex').slice(0, 24); +} + +function isFinalMediaFile(fileName: string): boolean { + if (!fileName.startsWith('media.')) { + return false; + } + if (fileName.endsWith('.part') || fileName.endsWith('.ytdl') || fileName.endsWith('.tmp')) { + return false; + } + return MEDIA_FILE_EXTENSIONS.has(path.extname(fileName).toLowerCase()); +} + +function findReadyMediaPath(dir: string): string | null { + try { + const files = fs.readdirSync(dir); + const mediaFile = files.find(isFinalMediaFile); + return mediaFile ? path.join(dir, mediaFile) : null; + } catch { + return null; + } +} + +function getFormatSelector(maxHeight: number): string { + return maxHeight > 0 + ? `bestvideo*[height<=${maxHeight}]+bestaudio/best[height<=${maxHeight}]` + : 'bestvideo*+bestaudio/best'; +} + +function normalizeMaxHeight(maxHeight: number | undefined): number { + if (maxHeight === undefined) { + return DEFAULT_MAX_HEIGHT; + } + return Number.isInteger(maxHeight) && maxHeight >= 0 ? maxHeight : DEFAULT_MAX_HEIGHT; +} + +function createYtDlpArgs(url: string, outputTemplate: string, maxHeight?: number): string[] { + return [ + '--no-playlist', + '--no-warnings', + '--force-ipv4', + '--retries', + '5', + '--fragment-retries', + '5', + '--extractor-retries', + '5', + '-f', + getFormatSelector(normalizeMaxHeight(maxHeight)), + '--merge-output-format', + 'mkv', + '-o', + outputTemplate, + url, + ]; +} + +export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDeps = {}) { + const cacheRoot = deps.cacheRoot ?? path.join(os.tmpdir(), 'subminer-youtube-media-cache'); + const getYtDlpCommand = deps.getYtDlpCommand ?? getYoutubeYtDlpCommand; + const spawn: SpawnProcess = + deps.spawn ?? + ((command, args, options) => + spawnProcess(command, args, options ?? {}) as unknown as SpawnedProcess); + const sessions = new Map(); + let activeKey: string | null = null; + + const getSessionDir = (url: string): string => path.join(cacheRoot, cacheKeyForUrl(url)); + const removeCacheDir = (dir: string): void => { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // Temp cache cleanup should not block shutdown or playback startup. + } + }; + const removeCacheRootEntriesExcept = (dirsToKeep: string[]): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(cacheRoot, { withFileTypes: true }); + } catch { + return; + } + const keepDirs = new Set(dirsToKeep.map((dir) => path.resolve(dir))); + for (const entry of entries) { + const entryPath = path.join(cacheRoot, entry.name); + if (keepDirs.has(path.resolve(entryPath))) { + continue; + } + removeCacheDir(entryPath); + } + }; + const removeSession = (key: string): void => { + const session = sessions.get(key); + if (!session) { + return; + } + if (session.state === 'running' && session.process?.kill && !session.process.killed) { + session.process.kill(); + } + sessions.delete(key); + if (activeKey === key) { + activeKey = null; + } + removeCacheDir(session.dir); + }; + const removeInactiveSessions = (keyToKeep: string): void => { + for (const key of [...sessions.keys()]) { + if (key !== keyToKeep) { + removeSession(key); + } + } + }; + removeCacheRootEntriesExcept([]); + + const getCachedMediaPath = async (url: string): Promise => { + const key = cacheKeyForUrl(url); + const session = sessions.get(key); + if (session?.readyPath && fs.existsSync(session.readyPath)) { + return session.readyPath; + } + + const readyPath = findReadyMediaPath(session?.dir ?? getSessionDir(url)); + if (readyPath) { + sessions.set(key, { + url, + dir: path.dirname(readyPath), + maxHeight: session?.maxHeight ?? DEFAULT_MAX_HEIGHT, + process: null, + readyPath, + state: 'ready', + }); + return readyPath; + } + + return null; + }; + + const getActiveCachedMediaPath = async (): Promise => { + if (!activeKey) { + return null; + } + const session = sessions.get(activeKey); + return session ? getCachedMediaPath(session.url) : null; + }; + + const start = (url: string, options: YoutubeMediaCacheStartOptions): void => { + if (options.mode !== 'background') { + if (activeKey) { + const activeSession = sessions.get(activeKey); + if (activeSession?.state === 'running') { + removeSession(activeKey); + } + } + activeKey = null; + return; + } + + const key = cacheKeyForUrl(url); + const maxHeight = normalizeMaxHeight(options.maxHeight); + activeKey = key; + const dir = getSessionDir(url); + const existingSession = sessions.get(key); + const canReuseExistingSession = existingSession?.maxHeight === maxHeight; + if (existingSession?.state === 'running' && canReuseExistingSession) { + removeInactiveSessions(key); + removeCacheRootEntriesExcept([existingSession.dir]); + return; + } + if (existingSession) { + if ( + canReuseExistingSession && + existingSession.state === 'ready' && + ((existingSession.readyPath && fs.existsSync(existingSession.readyPath)) || + findReadyMediaPath(existingSession.dir)) + ) { + removeInactiveSessions(key); + removeCacheRootEntriesExcept([existingSession.dir]); + return; + } + removeSession(key); + activeKey = key; + } + removeInactiveSessions(key); + removeCacheRootEntriesExcept([dir]); + + fs.mkdirSync(dir, { recursive: true }); + const outputTemplate = path.join(dir, 'media.%(ext)s'); + const args = createYtDlpArgs(url, outputTemplate, maxHeight); + const child = spawn(getYtDlpCommand(), args, { stdio: ['ignore', 'ignore', 'ignore'] }); + const session: MediaCacheSession = { + url, + dir, + maxHeight, + process: child, + readyPath: null, + state: 'running', + }; + sessions.set(key, session); + deps.logInfo?.(`Started YouTube media cache download for ${url}`); + deps.onDownloadStarted?.({ url }); + + child.once('error', (error) => { + const currentSession = sessions.get(key); + if (currentSession !== session) { + return; + } + session.state = 'failed'; + session.process = null; + deps.logWarn?.( + `YouTube media cache download failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + deps.onFailed?.({ url }); + }); + + child.once('close', (code) => { + const currentSession = sessions.get(key); + if (currentSession !== session) { + return; + } + if (session.state === 'failed') { + return; + } + session.process = null; + if (code === 0) { + const readyPath = findReadyMediaPath(dir); + if (readyPath) { + session.state = 'ready'; + session.readyPath = readyPath; + deps.logInfo?.(`YouTube media cache ready at ${readyPath}`); + deps.onReady?.({ url, path: readyPath }); + return; + } + } + session.state = 'failed'; + deps.logWarn?.(`YouTube media cache download exited without a usable media file.`); + deps.onFailed?.({ url }); + }); + }; + + const cleanup = (): void => { + for (const key of [...sessions.keys()]) { + removeSession(key); + } + activeKey = null; + }; + + return { + cleanup, + getActiveCachedMediaPath, + getCachedMediaPath, + start, + }; +} diff --git a/src/main.ts b/src/main.ts index 90462b2e..5fe7480e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -332,6 +332,7 @@ import { acquireYoutubeSubtitleTrack, acquireYoutubeSubtitleTracks, } from './core/services/youtube/generate'; +import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache'; import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve'; import { probeYoutubeTracks } from './core/services/youtube/track-probe'; import { @@ -1172,6 +1173,53 @@ const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({ }, wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), }); +const youtubeMediaCache = createYoutubeMediaCacheService({ + onDownloadStarted: (event) => { + showConfiguredStatusNotification('YouTube media cache is downloading.', { + id: 'youtube-media-cache-status', + title: 'YouTube media cache', + variant: 'progress', + persistent: true, + }); + logger.info(`YouTube media cache download notification shown for ${event.url}`); + }, + onReady: (event) => { + showConfiguredStatusNotification('YouTube media cache ready.', { + id: 'youtube-media-cache-status', + title: 'YouTube media cache', + variant: 'success', + persistent: false, + }); + void appState.ankiIntegration + ?.handleYoutubeMediaCacheReady(event.url, event.path, { notifyNoQueued: false }) + .catch((error) => { + logger.warn( + `Failed to apply queued YouTube media updates: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + }, + onFailed: (event) => { + showConfiguredStatusNotification('YouTube media cache failed.', { + id: 'youtube-media-cache-status', + title: 'YouTube media cache', + variant: 'error', + persistent: false, + }); + void appState.ankiIntegration + ?.handleYoutubeMediaCacheFailed(event.url, { notifyStatus: false }) + .catch((error) => { + logger.warn( + `Failed to drain queued YouTube media updates after cache failure: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + }, + logInfo: (message) => logger.info(message), + logWarn: (message) => logger.warn(message), +}); const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({ getMpvClient: () => appState.mpvClient, now: () => Date.now(), @@ -1316,6 +1364,12 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({ }, waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs), prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request), + startYoutubeMediaCache: (url) => { + youtubeMediaCache.start(url, { + mode: getResolvedConfig().youtube.mediaCache.mode, + maxHeight: getResolvedConfig().youtube.mediaCache.maxHeight, + }); + }, runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request), logInfo: (message) => logger.info(message), logWarn: (message) => logger.warn(message), @@ -1635,6 +1689,30 @@ function isYoutubePlaybackActiveNow(): boolean { ); } +function shouldRequireYoutubeMediaCacheForCurrentPlayback(): boolean { + return ( + getResolvedConfig().youtube.mediaCache.mode === 'background' && isYoutubePlaybackActiveNow() + ); +} + +async function getCachedYoutubeMediaPathForCurrentPlayback( + currentVideoPath: string, + _kind: 'audio' | 'video', +): Promise { + if (getResolvedConfig().youtube.mediaCache.mode !== 'background') { + return null; + } + if (!isYoutubePlaybackActiveNow()) { + return null; + } + // mpv can expose the resolved stream URL here while the cache key uses the original page URL. + // Keep the active-cache fallback so current playback can still resolve the ready cached file. + return ( + (await youtubeMediaCache.getCachedMediaPath(currentVideoPath)) ?? + (await youtubeMediaCache.getActiveCachedMediaPath()) + ); +} + function reportYoutubeSubtitleFailure(message: string): void { const type = getConfiguredStatusNotificationType(); if (type === 'none') { @@ -2636,6 +2714,7 @@ const overlayNotificationsRuntime = createOverlayNotificationsRuntime({ }); const { flushQueuedOverlayNotifications, + flushQueuedMpvOsdNotifications, openAnkiCardFromNotification, toggleNotificationHistoryPanel, showConfiguredPlaybackFeedback, @@ -3801,6 +3880,7 @@ const { }, stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(), + cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(), cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(), stopDiscordPresenceService: () => { void appState.discordPresenceService?.stop(); @@ -4286,6 +4366,7 @@ const { }, onMpvConnected: () => { maybeStartOverlayLoadingOsd(); + flushQueuedMpvOsdNotifications(); if (appState.sessionBindingsInitialized) { sendMpvCommandRuntime(appState.mpvClient, [ 'script-message', @@ -5731,6 +5812,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ ); }, getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), + getCachedMediaPath: (currentVideoPath, kind) => + getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind), + shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(), showDesktopNotification, showOverlayNotification, createFieldGroupingCallback: () => createFieldGroupingCallback(), @@ -6207,6 +6291,9 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = showOverlayNotification, createFieldGroupingCallback: () => createFieldGroupingCallback(), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), + getCachedMediaPath: (currentVideoPath, kind) => + getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind), + shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(), shouldStartAnkiIntegration: () => !(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)), }, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 5c8c9037..c69b4318 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -126,6 +126,8 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams { getAnkiIntegration: AnkiJimakuIpcRuntimeOptions['getAnkiIntegration']; setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration']; getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath']; + getCachedMediaPath?: AnkiJimakuIpcRuntimeOptions['getCachedMediaPath']; + shouldRequireRemoteMediaCache?: AnkiJimakuIpcRuntimeOptions['shouldRequireRemoteMediaCache']; showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification']; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback']; @@ -317,6 +319,10 @@ export function createAnkiJimakuIpcRuntimeServiceDeps( getAnkiIntegration: params.getAnkiIntegration, setAnkiIntegration: params.setAnkiIntegration, getKnownWordCacheStatePath: params.getKnownWordCacheStatePath, + ...(params.getCachedMediaPath ? { getCachedMediaPath: params.getCachedMediaPath } : {}), + ...(params.shouldRequireRemoteMediaCache + ? { shouldRequireRemoteMediaCache: params.shouldRequireRemoteMediaCache } + : {}), showDesktopNotification: params.showDesktopNotification, showOverlayNotification: params.showOverlayNotification, createFieldGroupingCallback: params.createFieldGroupingCallback, diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 2477b196..ff504d3e 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -514,6 +514,48 @@ test('configured overlay notifications require visible ready overlay window', () assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/); }); +test('YouTube media cache lifecycle routes through configured status notifications', () => { + const source = readMainSource(); + const cacheBlock = source.match( + /const youtubeMediaCache = createYoutubeMediaCacheService\(\{(?[\s\S]*?)\n\}\);\nconst waitForYoutubeMpvConnected/, + )?.groups?.body; + const startCacheBlock = source.match( + /startYoutubeMediaCache:\s*\(url\)\s*=>\s*\{(?[\s\S]*?)\n \},\n runYoutubePlaybackFlow/, + )?.groups?.body; + + assert.ok(cacheBlock); + assert.ok(startCacheBlock); + assert.match( + cacheBlock, + /onDownloadStarted:\s*\(event\)\s*=>\s*\{[\s\S]*showConfiguredStatusNotification\(\s*'YouTube media cache is downloading\.'/, + ); + assert.match(cacheBlock, /id:\s*'youtube-media-cache-status'/); + assert.match(cacheBlock, /variant:\s*'progress'/); + assert.match(cacheBlock, /persistent:\s*true/); + assert.match( + cacheBlock, + /onReady:\s*\(event\)\s*=>\s*\{[\s\S]*showConfiguredStatusNotification\(\s*'YouTube media cache ready\.'/, + ); + assert.match(cacheBlock, /variant:\s*'success'/); + assert.match(cacheBlock, /notifyNoQueued:\s*false/); + assert.match(startCacheBlock, /mode:\s*getResolvedConfig\(\)\.youtube\.mediaCache\.mode/); + assert.match( + startCacheBlock, + /maxHeight:\s*getResolvedConfig\(\)\.youtube\.mediaCache\.maxHeight/, + ); +}); + +test('mpv connection flushes queued configured OSD notifications', () => { + const source = readMainSource(); + const connectedBlock = source.match( + /onMpvConnected:\s*\(\)\s*=>\s*\{(?[\s\S]*?)\n \},\n maybeRunAnilistPostWatchUpdate:/, + )?.groups?.body; + + assert.ok(connectedBlock); + assert.match(source, /flushQueuedMpvOsdNotifications/); + assert.match(connectedBlock, /flushQueuedMpvOsdNotifications\(\);/); +}); + test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => { const source = readMainSource(); const setBlock = source.match( diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index 1ebbac34..fc2817e5 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -41,18 +41,20 @@ test('on will quit cleanup handler runs all cleanup steps', () => { clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), + cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'), cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'), }); cleanup(); - assert.equal(calls.length, 32); + assert.equal(calls.length, 33); assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[calls.length - 1], 'stop-discord-presence'); assert.ok(calls.includes('cleanup-jellyfin-subtitles')); assert.ok(calls.includes('clear-windows-visible-overlay-poll')); assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); assert.ok(calls.includes('cleanup-youtube-subtitles')); + assert.ok(calls.includes('cleanup-youtube-media')); assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); }); @@ -92,6 +94,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping throw new Error('stop failed'); }, cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), + cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'), cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'), }); diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index 9ccc8259..c3891661 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: { clearYomitanSettingsWindow: () => void; stopJellyfinRemoteSession: () => void; cleanupYoutubeSubtitleTempDirs: () => void; + cleanupYoutubeMediaCache: () => void; cleanupJellyfinSubtitleCache: () => void; stopDiscordPresenceService: () => void; }) { @@ -67,6 +68,7 @@ export function createOnWillQuitCleanupHandler(deps: { deps.cleanupJellyfinSubtitleCache(); } deps.cleanupYoutubeSubtitleTempDirs(); + deps.cleanupYoutubeMediaCache(); deps.stopDiscordPresenceService(); }; } diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index a7067f6d..3b0cd6ba 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -70,6 +70,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), + cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'), cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'), }); @@ -92,6 +93,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' assert.ok(calls.includes('destroy-yomitan-settings-window')); assert.ok(calls.includes('stop-jellyfin-remote')); assert.ok(calls.includes('cleanup-youtube-subtitles')); + assert.ok(calls.includes('cleanup-youtube-media')); assert.ok(calls.includes('cleanup-jellyfin-subtitles')); assert.ok(calls.includes('stop-discord-presence')); assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop')); @@ -147,6 +149,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => { clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: () => {}, cleanupYoutubeSubtitleTempDirs: () => {}, + cleanupYoutubeMediaCache: () => {}, cleanupJellyfinSubtitleCache: () => {}, stopDiscordPresenceService: () => {}, }); @@ -197,6 +200,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () = clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: () => {}, cleanupYoutubeSubtitleTempDirs: () => {}, + cleanupYoutubeMediaCache: () => {}, cleanupJellyfinSubtitleCache: () => {}, stopDiscordPresenceService: () => {}, }); diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index 9693ac05..c4593714 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { stopJellyfinRemoteSession: () => void; cleanupYoutubeSubtitleTempDirs: () => void; + cleanupYoutubeMediaCache: () => void; cleanupJellyfinSubtitleCache: () => void; stopDiscordPresenceService: () => void; }) { @@ -142,6 +143,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(), stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(), cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(), + cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(), cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(), stopDiscordPresenceService: () => deps.stopDiscordPresenceService(), }); diff --git a/src/main/runtime/composers/startup-lifecycle-composer.test.ts b/src/main/runtime/composers/startup-lifecycle-composer.test.ts index 62e58076..b2dca84c 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.test.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.test.ts @@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: async () => {}, cleanupYoutubeSubtitleTempDirs: () => {}, + cleanupYoutubeMediaCache: () => {}, cleanupJellyfinSubtitleCache: () => {}, stopDiscordPresenceService: () => {}, }, diff --git a/src/main/runtime/configured-status-notification.test.ts b/src/main/runtime/configured-status-notification.test.ts index 174ed52a..3170e77c 100644 --- a/src/main/runtime/configured-status-notification.test.ts +++ b/src/main/runtime/configured-status-notification.test.ts @@ -28,7 +28,7 @@ test('notifyConfiguredStatus routes both to overlay and system without osd', () ]); }); -test('notifyConfiguredStatus falls back to desktop for pre-overlay both status', () => { +test('notifyConfiguredStatus queues overlay for pre-overlay both status and preserves desktop', () => { const calls: string[] = []; notifyConfiguredStatus('Overlay loading...', { @@ -43,10 +43,10 @@ test('notifyConfiguredStatus falls back to desktop for pre-overlay both status', calls.push(`desktop:${title}:${options.body ?? ''}`), }); - assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']); + assert.deepEqual(calls, ['overlay::Overlay loading...', 'desktop:SubMiner:Overlay loading...']); }); -test('notifyConfiguredStatus falls back to desktop for pre-overlay overlay-only status', () => { +test('notifyConfiguredStatus queues overlay for pre-overlay overlay-only status', () => { const calls: string[] = []; notifyConfiguredStatus('Overlay loading...', { @@ -61,7 +61,7 @@ test('notifyConfiguredStatus falls back to desktop for pre-overlay overlay-only calls.push(`desktop:${title}:${options.body ?? ''}`), }); - assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']); + assert.deepEqual(calls, ['overlay::Overlay loading...']); }); test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => { @@ -97,6 +97,37 @@ test('notifyConfiguredStatus keeps osd-system on legacy surfaces', () => { assert.deepEqual(calls, ['osd:Overlay loading...', 'desktop:SubMiner:Overlay loading...']); }); +test('notifyConfiguredStatus queues osd status when mpv osd is unavailable', () => { + const calls: string[] = []; + + notifyConfiguredStatus( + 'YouTube media cache is downloading.', + { + getNotificationType: () => 'osd', + showOsd: (message) => { + calls.push(`osd:${message}`); + return false; + }, + queueOsd: (message, options) => { + calls.push(`queue:${options.id ?? ''}:${message}`); + }, + showDesktopNotification: (title, options) => + calls.push(`desktop:${title}:${options.body ?? ''}`), + }, + { + id: 'youtube-media-cache-status', + title: 'YouTube media cache', + variant: 'progress', + persistent: true, + }, + ); + + assert.deepEqual(calls, [ + 'osd:YouTube media cache is downloading.', + 'queue:youtube-media-cache-status:YouTube media cache is downloading.', + ]); +}); + test('notifyConfiguredStatus can suppress desktop delivery for progress ticks', () => { const calls: string[] = []; diff --git a/src/main/runtime/configured-status-notification.ts b/src/main/runtime/configured-status-notification.ts index e9a264e6..8b5ac49f 100644 --- a/src/main/runtime/configured-status-notification.ts +++ b/src/main/runtime/configured-status-notification.ts @@ -5,6 +5,7 @@ export interface ConfiguredStatusNotificationDeps { getNotificationType: () => NotificationType | undefined; isOverlayReady?: () => boolean; showOsd: (message: string) => boolean | void; + queueOsd?: (message: string, options: ConfiguredStatusNotificationOptions) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showDesktopNotification: (title: string, options: { body?: string }) => void; } @@ -50,8 +51,7 @@ export function notifyConfiguredStatus( } if (showOverlay) { - const overlayReady = deps.isOverlayReady?.() ?? true; - if (deps.showOverlayNotification && overlayReady) { + if (deps.showOverlayNotification) { deps.showOverlayNotification({ id: options.id, title: options.title ?? 'SubMiner', @@ -65,7 +65,10 @@ export function notifyConfiguredStatus( } if (showOsd) { - deps.showOsd(message); + const shown = deps.showOsd(message); + if (shown === false && delivery !== 'feedback') { + deps.queueOsd?.(message, options); + } } if (desktopEnabled && shouldShowDesktop(type)) { diff --git a/src/main/runtime/overlay-notifications-runtime.ts b/src/main/runtime/overlay-notifications-runtime.ts index b16105f8..5c03ebe1 100644 --- a/src/main/runtime/overlay-notifications-runtime.ts +++ b/src/main/runtime/overlay-notifications-runtime.ts @@ -32,7 +32,7 @@ export interface OverlayNotificationsRuntimeDeps { getMainOverlayWindow: () => BrowserWindow | null; getVisibleOverlayVisible: () => boolean; broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; - showMpvOsd: (message: string) => void; + showMpvOsd: (message: string) => boolean | void; getMpvClient: () => MpvIpcClient | null; getAnkiIntegration: () => AnkiIntegration | null; getRuntimeOptionsManager: () => RuntimeOptionsManager | null; @@ -42,6 +42,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt isVisibleOverlayContentReady: () => boolean; getConfiguredStatusNotificationType: () => NotificationType; flushQueuedOverlayNotifications: () => void; + flushQueuedMpvOsdNotifications: () => void; showOverlayNotification: (payload: OverlayNotificationPayload) => void; dismissOverlayNotification: (id: string) => void; openAnkiCardFromNotification: (noteId: number) => Promise; @@ -95,11 +96,38 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt }); let overlayLoadingOsdController: ReturnType | null = null; + const queuedConfiguredOsdNotifications = new Map< + string, + { message: string; options: ConfiguredStatusNotificationOptions } + >(); function flushQueuedOverlayNotifications(): void { overlayNotificationDelivery.flush(); } + function queueConfiguredOsdNotification( + message: string, + options: ConfiguredStatusNotificationOptions, + ): void { + const key = options.id ?? message; + queuedConfiguredOsdNotifications.set(key, { message, options }); + while (queuedConfiguredOsdNotifications.size > 16) { + const oldestKey = queuedConfiguredOsdNotifications.keys().next().value; + if (typeof oldestKey !== 'string') { + break; + } + queuedConfiguredOsdNotifications.delete(oldestKey); + } + } + + function flushQueuedMpvOsdNotifications(): void { + for (const [key, entry] of [...queuedConfiguredOsdNotifications.entries()]) { + if (deps.showMpvOsd(entry.message) !== false) { + queuedConfiguredOsdNotifications.delete(key); + } + } + } + function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void { overlayNotificationDelivery.send(payload); } @@ -145,6 +173,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt getNotificationType: () => deps.getResolvedConfig().ankiConnect.behavior.notificationType, isOverlayReady: () => isVisibleOverlayContentReady(), showOsd: (text) => deps.showMpvOsd(text), + queueOsd: (text, queueOptions) => queueConfiguredOsdNotification(text, queueOptions), showOverlayNotification, showDesktopNotification: (title, notificationOptions) => showDesktopNotification(title, notificationOptions), @@ -238,6 +267,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt isVisibleOverlayContentReady, getConfiguredStatusNotificationType, flushQueuedOverlayNotifications, + flushQueuedMpvOsdNotifications, showOverlayNotification, dismissOverlayNotification, openAnkiCardFromNotification, diff --git a/src/main/runtime/overlay-runtime-options-main-deps.ts b/src/main/runtime/overlay-runtime-options-main-deps.ts index 32ce7740..d86ce789 100644 --- a/src/main/runtime/overlay-runtime-options-main-deps.ts +++ b/src/main/runtime/overlay-runtime-options-main-deps.ts @@ -41,6 +41,8 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback']; getKnownWordCacheStatePath: () => string; + getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath']; + shouldRequireRemoteMediaCache?: OverlayRuntimeOptionsMainDeps['shouldRequireRemoteMediaCache']; shouldStartAnkiIntegration: () => boolean; bindOverlayOwner?: () => void; releaseOverlayOwner?: () => void; @@ -77,6 +79,10 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { showOverlayNotification: deps.showOverlayNotification, createFieldGroupingCallback: () => deps.createFieldGroupingCallback(), getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(), + ...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}), + ...(deps.shouldRequireRemoteMediaCache + ? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache } + : {}), shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(), bindOverlayOwner: deps.bindOverlayOwner, releaseOverlayOwner: deps.releaseOverlayOwner, diff --git a/src/main/runtime/overlay-runtime-options.ts b/src/main/runtime/overlay-runtime-options.ts index 58abb4a1..83ccb709 100644 --- a/src/main/runtime/overlay-runtime-options.ts +++ b/src/main/runtime/overlay-runtime-options.ts @@ -37,6 +37,11 @@ type OverlayRuntimeOptions = { data: KikuFieldGroupingRequestData, ) => Promise; getKnownWordCacheStatePath: () => string; + getCachedMediaPath?: ( + currentVideoPath: string, + kind: 'audio' | 'video', + ) => Promise; + shouldRequireRemoteMediaCache?: () => boolean; shouldStartAnkiIntegration: () => boolean; bindOverlayOwner?: () => void; releaseOverlayOwner?: () => void; @@ -71,6 +76,11 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { data: KikuFieldGroupingRequestData, ) => Promise; getKnownWordCacheStatePath: () => string; + getCachedMediaPath?: ( + currentVideoPath: string, + kind: 'audio' | 'video', + ) => Promise; + shouldRequireRemoteMediaCache?: () => boolean; shouldStartAnkiIntegration: () => boolean; bindOverlayOwner?: () => void; releaseOverlayOwner?: () => void; @@ -97,6 +107,10 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { showOverlayNotification: deps.showOverlayNotification, createFieldGroupingCallback: deps.createFieldGroupingCallback, getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath, + ...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}), + ...(deps.shouldRequireRemoteMediaCache + ? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache } + : {}), shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration, bindOverlayOwner: deps.bindOverlayOwner, releaseOverlayOwner: deps.releaseOverlayOwner, diff --git a/src/main/runtime/youtube-playback-runtime.test.ts b/src/main/runtime/youtube-playback-runtime.test.ts index 8bcf4178..9fe352c7 100644 --- a/src/main/runtime/youtube-playback-runtime.test.ts +++ b/src/main/runtime/youtube-playback-runtime.test.ts @@ -146,3 +146,135 @@ test('youtube playback runtime resolves the socket path lazily for windows start assert.ok(calls.some((entry) => entry.includes('--input-ipc-server=/tmp/updated.sock'))); }); + +test('youtube playback runtime starts media cache without blocking the subtitle flow', async () => { + const calls: string[] = []; + let resolveCache: (() => void) | undefined; + const cachePromise = new Promise((resolve) => { + resolveCache = resolve; + }); + + const runtime = createYoutubePlaybackRuntime({ + platform: 'linux', + directPlaybackFormat: 'best', + mpvYtdlFormat: 'bestvideo+bestaudio', + autoLaunchTimeoutMs: 2_000, + connectTimeoutMs: 1_000, + getSocketPath: () => '/tmp/mpv.sock', + getMpvConnected: () => true, + invalidatePendingAutoplayReadyFallbacks: () => { + calls.push('invalidate-autoplay'); + }, + setAppOwnedFlowInFlight: (next) => { + calls.push(`app-owned:${next}`); + }, + ensureYoutubePlaybackRuntimeReady: async () => { + calls.push('ensure-runtime-ready'); + }, + resolveYoutubePlaybackUrl: async () => { + throw new Error('linux path should not resolve direct playback url'); + }, + launchWindowsMpv: async () => ({ ok: false }), + waitForYoutubeMpvConnected: async () => true, + prepareYoutubePlaybackInMpv: async ({ url }) => { + calls.push(`prepare:${url}`); + return true; + }, + startYoutubeMediaCache: async (url) => { + calls.push(`cache:${url}`); + await cachePromise; + calls.push('cache-done'); + }, + runYoutubePlaybackFlow: async ({ url, mode }) => { + calls.push(`run-flow:${url}:${mode}`); + }, + logInfo: (message) => { + calls.push(`info:${message}`); + }, + logWarn: (message) => { + calls.push(`warn:${message}`); + }, + schedule: () => 1 as never, + clearScheduled: () => {}, + }); + + await runtime.runYoutubePlaybackFlow({ + url: 'https://youtu.be/demo', + mode: 'download', + source: 'second-instance', + }); + + const prepareIndex = calls.indexOf('prepare:https://youtu.be/demo'); + const cacheIndex = calls.indexOf('cache:https://youtu.be/demo'); + const runFlowIndex = calls.indexOf('run-flow:https://youtu.be/demo:download'); + assert.notEqual(prepareIndex, -1); + assert.notEqual(cacheIndex, -1); + assert.notEqual(runFlowIndex, -1); + assert.ok(prepareIndex < cacheIndex); + assert.ok(cacheIndex < runFlowIndex); + assert.equal(calls.includes('cache-done'), false); + const resolveCacheNow = resolveCache; + assert.ok(resolveCacheNow); + resolveCacheNow(); +}); + +test('youtube playback runtime logs synchronous media cache startup failures', async () => { + const calls: string[] = []; + + const runtime = createYoutubePlaybackRuntime({ + platform: 'linux', + directPlaybackFormat: 'best', + mpvYtdlFormat: 'bestvideo+bestaudio', + autoLaunchTimeoutMs: 2_000, + connectTimeoutMs: 1_000, + getSocketPath: () => '/tmp/mpv.sock', + getMpvConnected: () => true, + invalidatePendingAutoplayReadyFallbacks: () => { + calls.push('invalidate-autoplay'); + }, + setAppOwnedFlowInFlight: (next) => { + calls.push(`app-owned:${next}`); + }, + ensureYoutubePlaybackRuntimeReady: async () => { + calls.push('ensure-runtime-ready'); + }, + resolveYoutubePlaybackUrl: async () => { + throw new Error('linux path should not resolve direct playback url'); + }, + launchWindowsMpv: async () => ({ ok: false }), + waitForYoutubeMpvConnected: async () => true, + prepareYoutubePlaybackInMpv: async ({ url }) => { + calls.push(`prepare:${url}`); + return true; + }, + startYoutubeMediaCache: () => { + calls.push('cache'); + throw new Error('cache exploded'); + }, + runYoutubePlaybackFlow: async ({ url, mode }) => { + calls.push(`run-flow:${url}:${mode}`); + }, + logInfo: (message) => { + calls.push(`info:${message}`); + }, + logWarn: (message) => { + calls.push(`warn:${message}`); + }, + schedule: () => 1 as never, + clearScheduled: () => {}, + }); + + await runtime.runYoutubePlaybackFlow({ + url: 'https://youtu.be/demo', + mode: 'download', + source: 'second-instance', + }); + await Promise.resolve(); + + assert.ok(calls.includes('run-flow:https://youtu.be/demo:download')); + assert.ok( + calls.some((entry) => + entry.startsWith('warn:Failed to start YouTube media cache: cache exploded'), + ), + ); +}); diff --git a/src/main/runtime/youtube-playback-runtime.ts b/src/main/runtime/youtube-playback-runtime.ts index 3d2554fb..61e04aff 100644 --- a/src/main/runtime/youtube-playback-runtime.ts +++ b/src/main/runtime/youtube-playback-runtime.ts @@ -20,6 +20,7 @@ export type YoutubePlaybackRuntimeDeps = { launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise; waitForYoutubeMpvConnected: (timeoutMs: number) => Promise; prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise; + startYoutubeMediaCache?: (url: string) => void | Promise; runYoutubePlaybackFlow: (request: { url: string; mode: NonNullable; @@ -126,6 +127,18 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) { if (!mediaReady) { throw new Error('Timed out waiting for mpv to load the requested YouTube URL.'); } + if (deps.startYoutubeMediaCache) { + void new Promise((resolve) => { + resolve(deps.startYoutubeMediaCache?.(request.url)); + }) + .catch((error) => { + deps.logWarn( + `Failed to start YouTube media cache: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + } await deps.runYoutubePlaybackFlow({ url: request.url, diff --git a/src/media-generator.test.ts b/src/media-generator.test.ts index 0eaa57c9..1bbe9116 100644 --- a/src/media-generator.test.ts +++ b/src/media-generator.test.ts @@ -8,6 +8,10 @@ import { buildAnimatedImageVideoFilter, MediaGenerator } from './media-generator async function withStubbedFfmpeg( run: (generator: MediaGenerator, argsPath: string) => Promise, + options: { + logDebug?: (message: string) => void; + now?: () => number; + } = {}, ): Promise { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-generator-test-')); const binDir = path.join(root, 'bin'); @@ -25,7 +29,7 @@ async function withStubbedFfmpeg( " console.log(' V..... libaom-av1');", ' process.exit(0);', '}', - "fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, `${args.join('\\n')}\\n`, 'utf8');", + "fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, JSON.stringify(args), 'utf8');", 'const outputPath = args.at(-1);', "fs.writeFileSync(outputPath, 'avif', 'utf8');", ].join('\n'), @@ -44,7 +48,7 @@ async function withStubbedFfmpeg( const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS; process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`; process.env.SUBMINER_TEST_FFMPEG_ARGS = argsPath; - const generator = new MediaGenerator(tempDir); + const generator = new MediaGenerator(tempDir, options); try { await run(generator, argsPath); @@ -61,7 +65,7 @@ async function withStubbedFfmpeg( } function readFfmpegArgs(argsPath: string): string[] { - return fs.readFileSync(argsPath, 'utf8').trim().split('\n'); + return JSON.parse(fs.readFileSync(argsPath, 'utf8')) as string[]; } test('buildAnimatedImageVideoFilter holds lead-in until the next frame after the audio boundary', () => { @@ -182,3 +186,128 @@ test('generateAudio recreates missing temp directory before invoking ffmpeg', as assert.equal(fs.existsSync(path.dirname(outputPath!)), true); }); }); + +test('generateAudio adds remote input options before the ffmpeg input', async () => { + await withStubbedFfmpeg(async (generator, argsPath) => { + await generator.generateAudio( + { + path: 'https://rr1---sn.example.googlevideo.com/videoplayback?mime=audio%2Fwebm', + inputOptions: { + reconnect: true, + userAgent: 'Mozilla/5.0', + headers: { + Referer: 'https://www.youtube.com/', + Origin: 'https://www.youtube.com', + }, + }, + }, + 10, + 12, + ); + + const args = readFfmpegArgs(argsPath); + const inputIndex = args.indexOf('-i'); + assert.ok(inputIndex > 0); + assert.ok(args.indexOf('-reconnect') > -1); + assert.ok(args.indexOf('-reconnect') < inputIndex); + assert.equal(args[args.indexOf('-reconnect') + 1], '1'); + assert.equal(args[args.indexOf('-reconnect_streamed') + 1], '1'); + assert.equal(args[args.indexOf('-reconnect_on_network_error') + 1], '1'); + assert.equal(args[args.indexOf('-reconnect_on_http_error') + 1], '403,5xx'); + assert.equal(args[args.indexOf('-reconnect_delay_max') + 1], '5'); + assert.equal(args[args.indexOf('-user_agent') + 1], 'Mozilla/5.0'); + assert.equal( + args[args.indexOf('-headers') + 1], + 'Referer: https://www.youtube.com/\r\nOrigin: https://www.youtube.com\r\n', + ); + }); +}); + +test('generateAudio skips stale audio stream maps for single resolved streams', async () => { + await withStubbedFfmpeg(async (generator, argsPath) => { + await generator.generateAudio( + { + path: 'https://rr1---sn.example.googlevideo.com/videoplayback?mime=audio%2Fwebm', + singleResolvedStream: true, + }, + 10, + 12, + 0, + 22, + ); + + const args = readFfmpegArgs(argsPath); + assert.equal(args.includes('-map'), false); + }); +}); + +test('generateAudio keeps explicit audio stream maps for normal media paths', async () => { + await withStubbedFfmpeg(async (generator, argsPath) => { + await generator.generateAudio('/video.mp4', 10, 12, 0, 2); + + const args = readFfmpegArgs(argsPath); + assert.equal(args[args.indexOf('-map') + 1], '0:2'); + }); +}); + +test('generateAudio debug-logs cached input and completion timing', async () => { + const logs: string[] = []; + const times = [1000, 1052]; + + await withStubbedFfmpeg( + async (generator) => { + await generator.generateAudio( + { + path: '/tmp/subminer-youtube-media-cache/abc123/media.mkv', + source: 'youtube-cache', + }, + 10, + 12, + ); + }, + { + logDebug: (message) => logs.push(message), + now: () => times.shift() ?? 1052, + }, + ); + + assert.match(logs.join('\n'), /\[media-generator\] audio start/); + assert.match(logs.join('\n'), /source=youtube-cache/); + assert.match( + logs.join('\n'), + /input=local:\/tmp\/subminer-youtube-media-cache\/abc123\/media\.mkv/, + ); + assert.match(logs.join('\n'), /\[media-generator\] audio complete/); + assert.match(logs.join('\n'), /elapsedMs=52/); + assert.match(logs.join('\n'), /bytes=4/); +}); + +test('generateAudio debug logs sanitize remote inputs', async () => { + const logs: string[] = []; + const times = [1000, 1003]; + + await withStubbedFfmpeg( + async (generator) => { + await generator.generateAudio( + { + path: 'https://rr1---sn.example.googlevideo.com/videoplayback?signature=secret&expire=123', + inputOptions: { + reconnect: true, + headers: { + Referer: 'https://www.youtube.com/watch?v=abc123', + }, + }, + }, + 10, + 12, + ); + }, + { + logDebug: (message) => logs.push(message), + now: () => times.shift() ?? 1003, + }, + ); + + assert.match(logs.join('\n'), /input=remote:rr1---sn\.example\.googlevideo\.com/); + assert.doesNotMatch(logs.join('\n'), /signature=secret|expire=123|Referer|abc123/); +}); diff --git a/src/media-generator.ts b/src/media-generator.ts index 1ca0bc75..6f51b60a 100644 --- a/src/media-generator.ts +++ b/src/media-generator.ts @@ -21,9 +21,12 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { createLogger } from './logger'; +import { normalizeMediaInput, type MediaInput } from './media-input'; const log = createLogger('media'); +export type { MediaInput, MediaInputOptions } from './media-input'; + function normalizeAnimatedImageFps(fps: number | undefined): number { const fallbackFps = 10; const safeFps = typeof fps === 'number' && Number.isFinite(fps) ? fps : fallbackFps; @@ -69,12 +72,65 @@ export function buildAnimatedImageVideoFilter(options: { return vfParts.join(','); } +export interface MediaGeneratorOptions { + logDebug?: (message: string) => void; + now?: () => number; +} + +function sanitizeDebugToken(value: string, fallback: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return fallback; + } + const sanitized = trimmed.replace(/[^A-Za-z0-9_.:-]+/g, '-').slice(0, 80); + return sanitized || fallback; +} + +function describeMediaInputPathForDebugLog(value: string): string { + try { + const url = new URL(value); + if (url.protocol === 'http:' || url.protocol === 'https:') { + return `remote:${url.hostname.toLowerCase() || 'unknown'}`; + } + return `${url.protocol.replace(/:$/, '')}:`; + } catch { + // Not a URL; treat as a local file path below. + } + + if (value.startsWith('edl://')) { + return 'edl:'; + } + + return `local:${value}`; +} + +function describeMediaInputForDebugLog(input: MediaInput): string { + const pathValue = typeof input === 'string' ? input : input.path; + const sourceValue = typeof input === 'string' ? 'raw' : input.source; + const source = sanitizeDebugToken(sourceValue ?? 'raw', 'raw'); + return `source=${source} input=${describeMediaInputPathForDebugLog(pathValue)}`; +} + +function describeFfmpegFailureForDebugLog(error: ExecFileException): string { + const code = typeof error.code === 'string' || typeof error.code === 'number' ? error.code : null; + const signal = typeof error.signal === 'string' ? error.signal : null; + if (code !== null) { + return `code=${code}`; + } + if (signal) { + return `signal=${signal}`; + } + return `name=${sanitizeDebugToken(error.name || 'Error', 'Error')}`; +} + export class MediaGenerator { private tempDir: string; private notifyIconDir: string; private av1EncoderPromise: Promise | null = null; + private readonly options: MediaGeneratorOptions; - constructor(tempDir?: string) { + constructor(tempDir?: string, options: MediaGeneratorOptions = {}) { + this.options = options; this.tempDir = tempDir || path.join(os.tmpdir(), 'subminer-media'); this.notifyIconDir = path.join(os.tmpdir(), 'subminer-notify'); this.ensureDirectory(this.tempDir); @@ -83,6 +139,28 @@ export class MediaGenerator { this.cleanupOldNotificationIcons(); } + private nowMs(): number { + try { + const value = this.options.now?.() ?? Date.now(); + return Number.isFinite(value) ? value : Date.now(); + } catch { + return Date.now(); + } + } + + private elapsedMs(startedAt: number): number { + return Math.max(0, Math.round(this.nowMs() - startedAt)); + } + + private logMediaDebug(message: string): void { + const logDebug = this.options.logDebug ?? ((line: string) => log.debug(line)); + try { + logDebug(`[media-generator] ${message}`); + } catch { + // Debug logging should not affect media generation. + } + } + private ensureDirectory(dir: string): void { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); @@ -181,7 +259,7 @@ export class MediaGenerator { } async generateAudio( - videoPath: string, + videoPath: MediaInput, startTime: number, endTime: number, padding: number = 0, @@ -190,12 +268,24 @@ export class MediaGenerator { const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0; const start = Math.max(0, startTime - safePadding); const duration = endTime - start + safePadding; + const mediaInput = normalizeMediaInput(videoPath); + const inputDescription = describeMediaInputForDebugLog(videoPath); return new Promise((resolve, reject) => { const outputPath = this.createTempOutputPath('audio', 'mp3'); - const args: string[] = ['-ss', start.toString(), '-t', duration.toString(), '-i', videoPath]; + const startedAt = this.nowMs(); + const args: string[] = [ + '-ss', + start.toString(), + '-t', + duration.toString(), + ...mediaInput.inputArgs, + '-i', + mediaInput.path, + ]; if ( + !mediaInput.singleResolvedStream && typeof audioStreamIndex === 'number' && Number.isInteger(audioStreamIndex) && audioStreamIndex >= 0 @@ -205,8 +295,14 @@ export class MediaGenerator { args.push('-vn', '-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath); + this.logMediaDebug( + `audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`, + ); execFile('ffmpeg', args, { timeout: 30000 }, (error) => { if (error) { + this.logMediaDebug( + `audio failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`, + ); reject(this.ffmpegError('audio generation', error)); return; } @@ -214,6 +310,9 @@ export class MediaGenerator { try { const data = fs.readFileSync(outputPath); fs.unlinkSync(outputPath); + this.logMediaDebug( + `audio complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`, + ); resolve(data); } catch (err) { reject(err); @@ -223,7 +322,7 @@ export class MediaGenerator { } async generateScreenshot( - videoPath: string, + videoPath: MediaInput, timestamp: number, options: { format: 'jpg' | 'png' | 'webp'; @@ -239,8 +338,18 @@ export class MediaGenerator { png: 'png', webp: 'webp', }; + const mediaInput = normalizeMediaInput(videoPath); + const inputDescription = describeMediaInputForDebugLog(videoPath); - const args: string[] = ['-ss', timestamp.toString(), '-i', videoPath, '-vframes', '1']; + const args: string[] = [ + '-ss', + timestamp.toString(), + ...mediaInput.inputArgs, + '-i', + mediaInput.path, + '-vframes', + '1', + ]; const vfParts: string[] = []; if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) { @@ -270,10 +379,17 @@ export class MediaGenerator { return new Promise((resolve, reject) => { const outputPath = this.createTempOutputPath('screenshot', ext); + const startedAt = this.nowMs(); args.push(outputPath); + this.logMediaDebug( + `screenshot start ${inputDescription} timestamp=${timestamp} format=${format} maxWidth=${maxWidth ?? 'none'} maxHeight=${maxHeight ?? 'none'}`, + ); execFile('ffmpeg', args, { timeout: 30000 }, (error) => { if (error) { + this.logMediaDebug( + `screenshot failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`, + ); reject(this.ffmpegError('screenshot generation', error)); return; } @@ -281,6 +397,9 @@ export class MediaGenerator { try { const data = fs.readFileSync(outputPath); fs.unlinkSync(outputPath); + this.logMediaDebug( + `screenshot complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`, + ); resolve(data); } catch (err) { reject(err); @@ -334,7 +453,7 @@ export class MediaGenerator { } async generateAnimatedImage( - videoPath: string, + videoPath: MediaInput, startTime: number, endTime: number, padding: number = 0, @@ -352,10 +471,15 @@ export class MediaGenerator { const start = Math.max(0, startTime - safePadding); const duration = roundDurationUpToNextFrameBoundary(endTime - start + safePadding, clampedFps); const totalLeadingStillDuration = Math.max(0, leadingStillDuration); + const inputDescription = describeMediaInputForDebugLog(videoPath); const clampedCrf = Math.max(0, Math.min(63, crf)); + const encoderDetectionStartedAt = this.nowMs(); const av1Encoder = await this.detectAv1Encoder(); + this.logMediaDebug( + `animated-image encoder ${inputDescription} elapsedMs=${this.elapsedMs(encoderDetectionStartedAt)} encoder=${av1Encoder ?? 'none'}`, + ); if (!av1Encoder) { throw new Error( 'No supported AV1 encoder found for animated AVIF (tried libaom-av1, libsvtav1, librav1e).', @@ -364,6 +488,8 @@ export class MediaGenerator { return new Promise((resolve, reject) => { const outputPath = this.createTempOutputPath('animation', 'avif'); + const mediaInput = normalizeMediaInput(videoPath); + const startedAt = this.nowMs(); const encoderArgs: string[] = ['-c:v', av1Encoder]; if (av1Encoder === 'libaom-av1') { @@ -375,6 +501,9 @@ export class MediaGenerator { encoderArgs.push('-qp', clampedCrf.toString(), '-speed', '8'); } + this.logMediaDebug( + `animated-image start ${inputDescription} start=${start} duration=${duration} padding=${safePadding} fps=${clampedFps} maxWidth=${maxWidth ?? 'none'} maxHeight=${maxHeight ?? 'none'} crf=${clampedCrf} encoder=${av1Encoder}`, + ); execFile( 'ffmpeg', [ @@ -382,8 +511,9 @@ export class MediaGenerator { start.toString(), '-t', duration.toString(), + ...mediaInput.inputArgs, '-i', - videoPath, + mediaInput.path, '-vf', buildAnimatedImageVideoFilter({ fps: clampedFps, @@ -398,6 +528,9 @@ export class MediaGenerator { { timeout: 60000 }, (error) => { if (error) { + this.logMediaDebug( + `animated-image failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`, + ); reject(this.ffmpegError('animation generation', error)); return; } @@ -405,6 +538,9 @@ export class MediaGenerator { try { const data = fs.readFileSync(outputPath); fs.unlinkSync(outputPath); + this.logMediaDebug( + `animated-image complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`, + ); resolve(data); } catch (err) { reject(err); diff --git a/src/media-input.ts b/src/media-input.ts new file mode 100644 index 00000000..52349e19 --- /dev/null +++ b/src/media-input.ts @@ -0,0 +1,97 @@ +export interface MediaInputOptions { + reconnect?: boolean; + userAgent?: string; + headers?: Record; +} + +export type MediaInput = + | string + | { + path: string; + source?: string; + inputOptions?: MediaInputOptions; + singleResolvedStream?: boolean; + }; + +export type NormalizedMediaInput = { + path: string; + inputArgs: string[]; + singleResolvedStream: boolean; +}; + +const BLOCKED_FFMPEG_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization']); + +function trimToNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeHeaderName(value: string): string | null { + const trimmed = value.trim(); + if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(trimmed)) { + return null; + } + if (BLOCKED_FFMPEG_HEADER_NAMES.has(trimmed.toLowerCase())) { + return null; + } + return trimmed; +} + +function serializeFfmpegHeaders(headers: Record | undefined): string | null { + if (!headers) { + return null; + } + + const lines: string[] = []; + for (const [rawName, rawValue] of Object.entries(headers)) { + const name = normalizeHeaderName(rawName); + const value = trimToNonEmptyString(rawValue); + if (!name || !value) { + continue; + } + lines.push(`${name}: ${value.replace(/[\r\n]+/g, ' ')}`); + } + + return lines.length > 0 ? `${lines.join('\r\n')}\r\n` : null; +} + +export function normalizeMediaInput(input: MediaInput): NormalizedMediaInput { + if (typeof input === 'string') { + return { path: input, inputArgs: [], singleResolvedStream: false }; + } + + const inputArgs: string[] = []; + if (input.inputOptions?.reconnect) { + inputArgs.push( + '-reconnect', + '1', + '-reconnect_streamed', + '1', + '-reconnect_on_network_error', + '1', + '-reconnect_on_http_error', + '403,5xx', + '-reconnect_delay_max', + '5', + ); + } + + const userAgent = trimToNonEmptyString(input.inputOptions?.userAgent); + if (userAgent) { + inputArgs.push('-user_agent', userAgent); + } + + const headers = serializeFfmpegHeaders(input.inputOptions?.headers); + if (headers) { + inputArgs.push('-headers', headers); + } + + return { + path: input.path, + inputArgs, + singleResolvedStream: input.singleResolvedStream === true, + }; +} diff --git a/src/settings/settings-controls.test.ts b/src/settings/settings-controls.test.ts index 2193ba15..86125602 100644 --- a/src/settings/settings-controls.test.ts +++ b/src/settings/settings-controls.test.ts @@ -93,3 +93,32 @@ test('select controls show config-only current values without offering them othe restoreDocument(); } }); + +test('select controls use configured option labels', () => { + const restoreDocument = installDocumentStub(); + try { + const field = { + ...createSelectField(), + enumValues: ['direct', 'background'], + enumLabels: { + direct: 'Direct stream extraction', + background: 'Background media cache', + }, + } as ConfigSettingsField & { enumLabels: Record }; + + const control = renderControl(field, { + valueForField: () => 'direct', + valueForPath: () => undefined, + updateDraft: () => {}, + resetDraftPath: () => {}, + setFieldError: () => {}, + }) as HTMLSelectElement; + + assert.deepEqual( + Array.from(control.options).map((option) => option.textContent), + ['Direct stream extraction', 'Background media cache'], + ); + } finally { + restoreDocument(); + } +}); diff --git a/src/settings/settings-controls.ts b/src/settings/settings-controls.ts index bc34fd37..5bf36c62 100644 --- a/src/settings/settings-controls.ts +++ b/src/settings/settings-controls.ts @@ -227,7 +227,7 @@ export function renderControl( for (const enumValue of enumValues) { const option = createElement('option') as HTMLOptionElement; option.value = enumValue; - option.textContent = enumValue; + option.textContent = field.enumLabels?.[enumValue] ?? enumValue; option.selected = enumValue === value; select.append(option); } diff --git a/src/types/config.ts b/src/types/config.ts index ff37ee5d..435513ff 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -16,6 +16,7 @@ import type { StatsConfig, YomitanConfig, YoutubeConfig, + YoutubeMediaCacheMode, YoutubeSubgenConfig, } from './integrations'; import type { @@ -345,6 +346,10 @@ export interface ResolvedConfig { }; youtube: YoutubeConfig & { primarySubLanguages: string[]; + mediaCache: { + mode: YoutubeMediaCacheMode; + maxHeight: number; + }; }; youtubeSubgen: YoutubeSubgenConfig & { whisperBin: string; diff --git a/src/types/integrations.ts b/src/types/integrations.ts index b8da692b..646678a2 100644 --- a/src/types/integrations.ts +++ b/src/types/integrations.ts @@ -122,8 +122,16 @@ export interface AiConfig { requestTimeoutMs?: number; } +export type YoutubeMediaCacheMode = 'direct' | 'background'; + +export interface YoutubeMediaCacheConfig { + mode?: YoutubeMediaCacheMode; + maxHeight?: number; +} + export interface YoutubeConfig { primarySubLanguages?: string[]; + mediaCache?: YoutubeMediaCacheConfig; } export interface YoutubeSubgenConfig { diff --git a/src/types/settings.ts b/src/types/settings.ts index 703462e4..c5ef8f6b 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -43,6 +43,7 @@ export interface ConfigSettingsField { control: ConfigSettingsControl; defaultValue: unknown; enumValues?: readonly string[]; + enumLabels?: Record; restartBehavior: ConfigSettingsRestartBehavior; advanced?: boolean; secret?: boolean;