From 236f22662cec4cf96bf05bc85a0f6c9efb714498 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 20 Jun 2026 02:25:32 -0700 Subject: [PATCH] feat(youtube): add mediaCache mode and safer stream media extraction - Add `youtube.mediaCache.mode` config option (`direct` | `background`) - Resolve EDL stream URLs to single audio/video URLs before ffmpeg extraction - Pass reconnect, user-agent, and safe headers to ffmpeg for remote streams - Add background yt-dlp media cache with fallback to direct extraction - Introduce `MediaInput` structured type replacing bare path strings --- .../youtube-media-generation-reliability.md | 5 + config.example.jsonc | 5 +- docs-site/configuration.md | 14 +- docs-site/public/config.example.jsonc | 5 +- release/release-notes.md | 68 ----- src/anki-integration.ts | 31 ++- .../card-creation-manual-update.test.ts | 7 +- src/anki-integration/card-creation.test.ts | 7 +- src/anki-integration/card-creation.ts | 43 ++- src/anki-integration/media-source.test.ts | 126 ++++++++- src/anki-integration/media-source.ts | 256 ++++++++++++++++++ src/config/config.test.ts | 41 +++ src/config/definitions/defaults-core.ts | 3 + src/config/definitions/options-core.ts | 11 + src/config/definitions/shared.ts | 1 + src/config/resolve/core-domains.ts | 21 ++ src/config/settings/registry.test.ts | 11 + src/config/settings/registry.ts | 1 + src/core/services/anki-jimaku.ts | 5 + src/core/services/overlay-runtime-init.ts | 14 + src/core/services/youtube/media-cache.test.ts | 202 ++++++++++++++ src/core/services/youtube/media-cache.ts | 239 ++++++++++++++++ src/main.ts | 31 +++ src/main/dependencies.ts | 2 + .../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 + .../overlay-runtime-options-main-deps.ts | 2 + src/main/runtime/overlay-runtime-options.ts | 9 + .../runtime/youtube-playback-runtime.test.ts | 70 +++++ src/main/runtime/youtube-playback-runtime.ts | 10 + src/media-generator.test.ts | 65 ++++- src/media-generator.ts | 36 ++- src/media-input.ts | 85 ++++++ src/settings/settings-controls.test.ts | 29 ++ src/settings/settings-controls.ts | 2 +- src/types/config.ts | 4 + src/types/integrations.ts | 7 + src/types/settings.ts | 1 + 41 files changed, 1381 insertions(+), 102 deletions(-) create mode 100644 changes/youtube-media-generation-reliability.md delete mode 100644 release/release-notes.md 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..604356cf --- /dev/null +++ b/changes/youtube-media-generation-reliability.md @@ -0,0 +1,5 @@ +type: fixed +area: youtube + +- Improved YouTube card media generation by sending safer ffmpeg request options for resolved streams and skipping stale stream maps. +- 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. diff --git a/config.example.jsonc b/config.example.jsonc index 7a276ce8..9c5fa8bd 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -618,7 +618,10 @@ "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 + } // 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..eb2d5a38 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -1520,14 +1520,20 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher ```json { "youtube": { - "primarySubLanguages": ["ja", "jpn"] + "primarySubLanguages": ["ja", "jpn"], + "mediaCache": { + "mode": "direct" + } } } ``` -| 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.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; card media generation uses the cached file once it is ready and otherwise falls back to direct stream extraction. Current launcher behavior: diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 7a276ce8..9c5fa8bd 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -618,7 +618,10 @@ "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 + } // Media cache setting. }, // Defaults for managed subtitle language preferences and YouTube subtitle loading. // ========================================== 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.ts b/src/anki-integration.ts index 6709d803..756fb375 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -61,7 +61,11 @@ import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow'; import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow'; import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync'; import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime'; -import { resolveMediaGenerationInputPath } from './anki-integration/media-source'; +import { + resolveMediaGenerationInput, + resolveMediaGenerationInputPath, + type MediaGenerationInputResolverOptions, +} from './anki-integration/media-source'; const log = createLogger('anki').child('integration'); @@ -225,6 +229,8 @@ export class AnkiIntegration { private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null; private noteIdRedirects = new Map(); private trackedDuplicateNoteIds = new Map(); + private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null = + null; constructor( config: AnkiConnectConfig, @@ -240,6 +246,7 @@ export class AnkiIntegration { aiConfig: AiConfig = {}, recordCardsMined?: (count: number, noteIds?: number[]) => void, overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void, + getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'], ) { this.config = normalizeAnkiIntegrationConfig(config); this.aiConfig = { ...aiConfig }; @@ -252,6 +259,7 @@ export class AnkiIntegration { this.overlayNotificationCallback = overlayNotificationCallback || null; this.fieldGroupingCallback = fieldGroupingCallback || null; this.recordCardsMinedCallback = recordCardsMined ?? null; + this.getCachedMediaPath = getCachedMediaPath ?? null; this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath); this.pollingRunner = this.createPollingRunner(); this.cardCreationService = this.createCardCreationService(); @@ -295,6 +303,14 @@ export class AnkiIntegration { } } + private getMediaResolverOptions(): MediaGenerationInputResolverOptions { + const options: MediaGenerationInputResolverOptions = {}; + if (this.getCachedMediaPath) { + options.getCachedMediaPath = this.getCachedMediaPath; + } + return options; + } + private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager { return new KnownWordCacheManager({ client: { @@ -377,6 +393,7 @@ export class AnkiIntegration { getAiConfig: () => this.aiConfig, getTimingTracker: () => this.timingTracker, getMpvClient: () => this.mpvClient, + ...(this.getCachedMediaPath ? { getCachedMediaPath: this.getCachedMediaPath } : {}), getDeck: () => this.config.deck, client: { addNote: (deck, modelName, fields, tags) => @@ -839,7 +856,11 @@ export class AnkiIntegration { return null; } - const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'audio'); + const videoPath = await resolveMediaGenerationInput( + mpvClient, + 'audio', + this.getMediaResolverOptions(), + ); if (!videoPath) { return null; } @@ -862,7 +883,11 @@ export class AnkiIntegration { return null; } - const videoPath = await resolveMediaGenerationInputPath(this.mpvClient, 'video'); + const videoPath = await resolveMediaGenerationInput( + this.mpvClient, + 'video', + this.getMediaResolverOptions(), + ); if (!videoPath) { return null; } diff --git a/src/anki-integration/card-creation-manual-update.test.ts b/src/anki-integration/card-creation-manual-update.test.ts index a03714d1..ed771731 100644 --- a/src/anki-integration/card-creation-manual-update.test.ts +++ b/src/anki-integration/card-creation-manual-update.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { CardCreationService } from './card-creation'; +import type { MediaInput } from '../media-generator'; import type { AnkiConnectConfig } from '../types/anki'; type CardCreationDeps = ConstructorParameters[0]; @@ -266,6 +267,8 @@ test('manual clipboard subtitle update skips audio when sentence audio field is test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => { const audioPaths: string[] = []; const imagePaths: string[] = []; + const recordMediaPath = (mediaInput: MediaInput): string => + typeof mediaInput === 'string' ? mediaInput : mediaInput.path; 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', @@ -338,11 +341,11 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote }, mediaGenerator: { generateAudio: async (path) => { - audioPaths.push(path); + audioPaths.push(recordMediaPath(path)); return Buffer.from('audio'); }, generateScreenshot: async (path) => { - imagePaths.push(path); + imagePaths.push(recordMediaPath(path)); return Buffer.from('image'); }, generateAnimatedImage: async () => null, diff --git a/src/anki-integration/card-creation.test.ts b/src/anki-integration/card-creation.test.ts index 022a3775..3a2a72b0 100644 --- a/src/anki-integration/card-creation.test.ts +++ b/src/anki-integration/card-creation.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { CardCreationService } from './card-creation'; +import type { MediaInput } from '../media-generator'; import type { AnkiConnectConfig } from '../types/anki'; test('CardCreationService counts locally created sentence cards', async () => { @@ -287,6 +288,8 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws', test('CardCreationService uses stream-open-filename for remote media generation', async () => { const audioPaths: string[] = []; const imagePaths: string[] = []; + const recordMediaPath = (mediaInput: MediaInput): string => + typeof mediaInput === 'string' ? mediaInput : mediaInput.path; 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', @@ -345,11 +348,11 @@ test('CardCreationService uses stream-open-filename for remote media generation' }, mediaGenerator: { generateAudio: async (path) => { - audioPaths.push(path); + audioPaths.push(recordMediaPath(path)); return Buffer.from('audio'); }, generateScreenshot: async (path) => { - imagePaths.push(path); + imagePaths.push(recordMediaPath(path)); return Buffer.from('image'); }, generateAnimatedImage: async () => null, diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 991d64ac..67bc8312 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -5,11 +5,15 @@ import { } from '../anki-field-config'; import { AnkiConnectConfig } from '../types/anki'; import { createLogger } from '../logger'; +import type { MediaInput } from '../media-input'; import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; import { AiConfig } from '../types/integrations'; import { MpvClient } from '../types/runtime'; import { resolveSentenceBackText } from './ai'; -import { resolveMediaGenerationInputPath } from './media-source'; +import { + resolveMediaGenerationInput, + type MediaGenerationInputResolverOptions, +} from './media-source'; import { shouldMarkWordAndSentenceCard } from './note-field-utils'; const log = createLogger('anki').child('integration.card-creation'); @@ -38,14 +42,14 @@ interface CardCreationClient { interface CardCreationMediaGenerator { generateAudio( - path: string, + path: MediaInput, startTime: number, endTime: number, audioPadding?: number, audioStreamIndex?: number, ): Promise; generateScreenshot( - path: string, + path: MediaInput, timestamp: number, options: { format: 'jpg' | 'png' | 'webp'; @@ -55,7 +59,7 @@ interface CardCreationMediaGenerator { }, ): Promise; generateAnimatedImage( - path: string, + path: MediaInput, startTime: number, endTime: number, audioPadding?: number, @@ -74,6 +78,7 @@ interface CardCreationDeps { getAiConfig: () => AiConfig; getTimingTracker: () => SubtitleTimingTracker; getMpvClient: () => MpvClient; + getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath']; getDeck?: () => string | undefined; client: CardCreationClient; mediaGenerator: CardCreationMediaGenerator; @@ -121,6 +126,14 @@ interface CardCreationDeps { export class CardCreationService { constructor(private readonly deps: CardCreationDeps) {} + private getMediaResolverOptions(): MediaGenerationInputResolverOptions { + const options: MediaGenerationInputResolverOptions = {}; + if (this.deps.getCachedMediaPath) { + options.getCachedMediaPath = this.deps.getCachedMediaPath; + } + return options; + } + private getConfiguredAnkiTags(): string[] { const tags = this.deps.getConfig().tags; if (!Array.isArray(tags)) { @@ -246,11 +259,12 @@ export class CardCreationService { `Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`, ); + const mediaResolverOptions = this.getMediaResolverOptions(); const audioSourcePath = this.deps.getConfig().media?.generateAudio - ? await resolveMediaGenerationInputPath(mpvClient, 'audio') + ? await resolveMediaGenerationInput(mpvClient, 'audio', mediaResolverOptions) : null; const videoPath = this.deps.getConfig().media?.generateImage - ? await resolveMediaGenerationInputPath(mpvClient, 'video') + ? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions) : null; if (this.deps.getConfig().media?.generateAudio) { @@ -522,8 +536,17 @@ export class CardCreationService { try { return await this.deps.withUpdateProgress('Creating sentence card', async () => { - const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video'); - const audioSourcePath = await resolveMediaGenerationInputPath(mpvClient, 'audio'); + const mediaResolverOptions = this.getMediaResolverOptions(); + const videoPath = await resolveMediaGenerationInput( + mpvClient, + 'video', + mediaResolverOptions, + ); + const audioSourcePath = await resolveMediaGenerationInput( + mpvClient, + 'audio', + mediaResolverOptions, + ); if (!videoPath) { this.deps.showOsdNotification('No video loaded'); return false; @@ -740,7 +763,7 @@ export class CardCreationService { } private async mediaGenerateAudio( - videoPath: string, + videoPath: MediaInput, startTime: number, endTime: number, ): Promise { @@ -759,7 +782,7 @@ export class CardCreationService { } 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..4fb13eb2 100644 --- a/src/anki-integration/media-source.test.ts +++ b/src/anki-integration/media-source.test.ts @@ -1,7 +1,31 @@ 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; + }, +) => Promise; test('resolveMediaGenerationInputPath keeps local file paths', async () => { const result = await resolveMediaGenerationInputPath({ @@ -62,3 +86,103 @@ 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); +}); diff --git a/src/anki-integration/media-source.ts b/src/anki-integration/media-source.ts index 21f98386..8b54cfbf 100644 --- a/src/anki-integration/media-source.ts +++ b/src/anki-integration/media-source.ts @@ -1,7 +1,45 @@ import { isRemoteMediaPath } from '../jimaku/utils'; +import type { 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; +} + +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 +49,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 +102,213 @@ 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 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) { + return null; + } + + if (!isRemoteMediaPath(currentVideoPath)) { + return { + path: currentVideoPath, + kind, + source: 'current-path', + singleResolvedStream: false, + }; + } + + const cachedPath = options.getCachedMediaPath + ? trimToNonEmptyString(await options.getCachedMediaPath(currentVideoPath, kind)) + : null; + if (cachedPath) { + return { + path: cachedPath, + kind, + source: 'youtube-cache', + singleResolvedStream: false, + }; + } + + if (!mpvClient?.requestProperty) { + return { + path: currentVideoPath, + kind, + source: 'current-path', + singleResolvedStream: false, + }; + } + + try { + const streamOpenFilename = trimToNonEmptyString( + await mpvClient.requestProperty('stream-open-filename'), + ); + if (streamOpenFilename?.startsWith('edl://')) { + const preferredUrl = resolvePreferredUrlFromMpvEdlSource(streamOpenFilename, kind); + if (preferredUrl) { + return toResolvedMediaGenerationInput(mpvClient, preferredUrl, kind, 'edl-stream', true); + } + return toResolvedMediaGenerationInput( + mpvClient, + streamOpenFilename, + kind, + 'stream-open-filename', + false, + ); + } + if (streamOpenFilename) { + return toResolvedMediaGenerationInput( + mpvClient, + streamOpenFilename, + kind, + 'stream-open-filename', + isRemoteMediaPath(streamOpenFilename), + ); + } + } catch { + // Fall back to the current path when mpv does not expose a resolved stream URL. + } + + return toResolvedMediaGenerationInput(mpvClient, currentVideoPath, kind, 'current-path', false); +} + export async function resolveMediaGenerationInputPath( mpvClient: Pick | null | undefined, kind: MediaGenerationKind = 'video', diff --git a/src/config/config.test.ts b/src/config/config.test.ts index b823c4c0..9f04ebf9 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -80,6 +80,7 @@ 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.ai.enabled, false); assert.equal(config.ai.apiKeyCommand, ''); assert.equal(config.texthooker.openBrowser, false); @@ -1750,6 +1751,46 @@ 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" + } + } + }`, + 'utf-8', + ); + + const validService = new ConfigService(validDir); + assert.equal(validService.getConfig().youtube.mediaCache.mode, 'background'); + + const invalidDir = makeTempDir(); + fs.writeFileSync( + path.join(invalidDir, 'config.jsonc'), + `{ + "youtube": { + "mediaCache": { + "mode": "always" + } + } + }`, + 'utf-8', + ); + + const invalidService = new ConfigService(invalidDir); + assert.equal( + invalidService.getConfig().youtube.mediaCache.mode, + DEFAULT_CONFIG.youtube.mediaCache.mode, + ); + assert.ok( + invalidService.getWarnings().some((warning) => warning.path === 'youtube.mediaCache.mode'), + ); +}); + 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..07d724a4 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -111,6 +111,9 @@ export const CORE_DEFAULT_CONFIG: Pick< }, youtube: { primarySubLanguages: ['ja', 'jpn'], + mediaCache: { + mode: 'direct', + }, }, subsync: { alass_path: '', diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index c86a7174..7b1647a8 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -119,6 +119,17 @@ 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: '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..bd864c78 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -297,6 +297,27 @@ 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'.", + ); + } + } 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..acc3765e 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -165,6 +165,17 @@ 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'); + + assert.equal(mediaCacheMode.control, 'select'); + assert.deepEqual(mediaCacheMode.enumValues, ['direct', 'background']); + assert.deepEqual(mediaCacheMode.enumLabels, { + direct: 'Direct stream extraction', + background: 'Background media cache', + }); +}); + 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..d3fdabff 100644 --- a/src/core/services/anki-jimaku.ts +++ b/src/core/services/anki-jimaku.ts @@ -40,6 +40,10 @@ export interface AnkiJimakuIpcRuntimeOptions { getAnkiIntegration: () => AnkiIntegration | null; setAnkiIntegration: (integration: AnkiIntegration | null) => void; getKnownWordCacheStatePath: () => string; + getCachedMediaPath?: ( + currentVideoPath: string, + kind: 'audio' | 'video', + ) => Promise; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: () => ( @@ -107,6 +111,7 @@ export function registerAnkiJimakuIpcRuntime( mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig, undefined, options.showOverlayNotification, + options.getCachedMediaPath, ); 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..ed80d09d 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -25,6 +25,10 @@ type CreateAnkiIntegrationArgs = { data: KikuFieldGroupingRequestData, ) => Promise; knownWordCacheStatePath: string; + getCachedMediaPath?: ( + currentVideoPath: string, + kind: 'audio' | 'video', + ) => Promise; }; export type OverlayWindowTrackerOptions = { @@ -65,6 +69,7 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte args.aiConfig, undefined, args.showOverlayNotification, + args.getCachedMediaPath, ); } @@ -132,6 +137,10 @@ export function initializeOverlayRuntime( data: KikuFieldGroupingRequestData, ) => Promise; getKnownWordCacheStatePath: () => string; + getCachedMediaPath?: ( + currentVideoPath: string, + kind: 'audio' | 'video', + ) => Promise; shouldStartAnkiIntegration?: () => boolean; createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; backendOverride: string | null; @@ -166,6 +175,10 @@ export function initializeOverlayAnkiIntegration(options: { data: KikuFieldGroupingRequestData, ) => Promise; getKnownWordCacheStatePath: () => string; + getCachedMediaPath?: ( + currentVideoPath: string, + kind: 'audio' | 'video', + ) => Promise; shouldStartAnkiIntegration?: () => boolean; createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; }): boolean { @@ -200,6 +213,7 @@ export function initializeOverlayAnkiIntegration(options: { showOverlayNotification: options.showOverlayNotification, createFieldGroupingCallback: options.createFieldGroupingCallback, knownWordCacheStatePath: options.getKnownWordCacheStatePath(), + ...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}), }); 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..2d7573b4 --- /dev/null +++ b/src/core/services/youtube/media-cache.test.ts @@ -0,0 +1,202 @@ +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[] = []; + + 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' }); + + 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('--merge-output-format')); + 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); + } 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 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..d3663675 --- /dev/null +++ b/src/core/services/youtube/media-cache.ts @@ -0,0 +1,239 @@ +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; + process: SpawnedProcess | null; + readyPath: string | null; + state: MediaCacheSessionState; +} + +export interface YoutubeMediaCacheStartOptions { + mode: YoutubeMediaCacheMode; +} + +export interface YoutubeMediaCacheServiceDeps { + cacheRoot?: string; + getYtDlpCommand?: () => string; + spawn?: SpawnProcess; + logInfo?: (message: string) => void; + logWarn?: (message: string) => void; +} + +const MEDIA_FILE_EXTENSIONS = new Set(['.mkv', '.mp4', '.webm', '.m4a', '.mp3', '.opus']); + +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 createYtDlpArgs(url: string, outputTemplate: string): string[] { + return [ + '--no-playlist', + '--no-warnings', + '-f', + 'bestvideo*+bestaudio/best', + '--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 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; + } + try { + fs.rmSync(session.dir, { recursive: true, force: true }); + } catch { + // Temp cache cleanup should not block shutdown or playback startup. + } + }; + const removeInactiveSessions = (keyToKeep: string): void => { + for (const key of [...sessions.keys()]) { + if (key !== keyToKeep) { + removeSession(key); + } + } + }; + + 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), + 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') { + return; + } + + const key = cacheKeyForUrl(url); + activeKey = key; + const existingSession = sessions.get(key); + if (existingSession?.state === 'running') { + removeInactiveSessions(key); + return; + } + if (existingSession) { + if ( + existingSession.state === 'ready' && + ((existingSession.readyPath && fs.existsSync(existingSession.readyPath)) || + findReadyMediaPath(existingSession.dir)) + ) { + removeInactiveSessions(key); + return; + } + removeSession(key); + activeKey = key; + } + removeInactiveSessions(key); + + const dir = getSessionDir(url); + fs.mkdirSync(dir, { recursive: true }); + const outputTemplate = path.join(dir, 'media.%(ext)s'); + const args = createYtDlpArgs(url, outputTemplate); + const child = spawn(getYtDlpCommand(), args, { stdio: ['ignore', 'ignore', 'ignore'] }); + const session: MediaCacheSession = { + url, + dir, + process: child, + readyPath: null, + state: 'running', + }; + sessions.set(key, session); + deps.logInfo?.(`Started YouTube media cache download for ${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) + }`, + ); + }); + + child.once('close', (code) => { + const currentSession = sessions.get(key); + if (currentSession !== session) { + 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}`); + return; + } + } + session.state = 'failed'; + deps.logWarn?.(`YouTube media cache download exited without a usable media file.`); + }); + }; + + 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..a3024002 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,10 @@ const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({ }, wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), }); +const youtubeMediaCache = createYoutubeMediaCacheService({ + logInfo: (message) => logger.info(message), + logWarn: (message) => logger.warn(message), +}); const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({ getMpvClient: () => appState.mpvClient, now: () => Date.now(), @@ -1316,6 +1321,11 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({ }, waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs), prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request), + startYoutubeMediaCache: (url) => { + youtubeMediaCache.start(url, { + mode: getResolvedConfig().youtube.mediaCache.mode, + }); + }, runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request), logInfo: (message) => logger.info(message), logWarn: (message) => logger.warn(message), @@ -1635,6 +1645,22 @@ function isYoutubePlaybackActiveNow(): boolean { ); } +async function getCachedYoutubeMediaPathForCurrentPlayback( + currentVideoPath: string, + _kind: 'audio' | 'video', +): Promise { + if (getResolvedConfig().youtube.mediaCache.mode !== 'background') { + return null; + } + if (!isYoutubePlaybackActiveNow()) { + return null; + } + return ( + (await youtubeMediaCache.getCachedMediaPath(currentVideoPath)) ?? + (await youtubeMediaCache.getActiveCachedMediaPath()) + ); +} + function reportYoutubeSubtitleFailure(message: string): void { const type = getConfiguredStatusNotificationType(); if (type === 'none') { @@ -3801,6 +3827,7 @@ const { }, stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(), + cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(), cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(), stopDiscordPresenceService: () => { void appState.discordPresenceService?.stop(); @@ -5731,6 +5758,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ ); }, getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), + getCachedMediaPath: (currentVideoPath, kind) => + getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind), showDesktopNotification, showOverlayNotification, createFieldGroupingCallback: () => createFieldGroupingCallback(), @@ -6207,6 +6236,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = showOverlayNotification, createFieldGroupingCallback: () => createFieldGroupingCallback(), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), + getCachedMediaPath: (currentVideoPath, kind) => + getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind), shouldStartAnkiIntegration: () => !(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)), }, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 5c8c9037..a368d5f9 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -126,6 +126,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams { getAnkiIntegration: AnkiJimakuIpcRuntimeOptions['getAnkiIntegration']; setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration']; getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath']; + getCachedMediaPath?: AnkiJimakuIpcRuntimeOptions['getCachedMediaPath']; showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification']; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback']; @@ -317,6 +318,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps( getAnkiIntegration: params.getAnkiIntegration, setAnkiIntegration: params.setAnkiIntegration, getKnownWordCacheStatePath: params.getKnownWordCacheStatePath, + ...(params.getCachedMediaPath ? { getCachedMediaPath: params.getCachedMediaPath } : {}), showDesktopNotification: params.showDesktopNotification, showOverlayNotification: params.showOverlayNotification, createFieldGroupingCallback: params.createFieldGroupingCallback, 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/overlay-runtime-options-main-deps.ts b/src/main/runtime/overlay-runtime-options-main-deps.ts index 32ce7740..8f0445ec 100644 --- a/src/main/runtime/overlay-runtime-options-main-deps.ts +++ b/src/main/runtime/overlay-runtime-options-main-deps.ts @@ -41,6 +41,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback']; getKnownWordCacheStatePath: () => string; + getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath']; shouldStartAnkiIntegration: () => boolean; bindOverlayOwner?: () => void; releaseOverlayOwner?: () => void; @@ -77,6 +78,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { showOverlayNotification: deps.showOverlayNotification, createFieldGroupingCallback: () => deps.createFieldGroupingCallback(), getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(), + ...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}), 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..c705f302 100644 --- a/src/main/runtime/overlay-runtime-options.ts +++ b/src/main/runtime/overlay-runtime-options.ts @@ -37,6 +37,10 @@ type OverlayRuntimeOptions = { data: KikuFieldGroupingRequestData, ) => Promise; getKnownWordCacheStatePath: () => string; + getCachedMediaPath?: ( + currentVideoPath: string, + kind: 'audio' | 'video', + ) => Promise; shouldStartAnkiIntegration: () => boolean; bindOverlayOwner?: () => void; releaseOverlayOwner?: () => void; @@ -71,6 +75,10 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { data: KikuFieldGroupingRequestData, ) => Promise; getKnownWordCacheStatePath: () => string; + getCachedMediaPath?: ( + currentVideoPath: string, + kind: 'audio' | 'video', + ) => Promise; shouldStartAnkiIntegration: () => boolean; bindOverlayOwner?: () => void; releaseOverlayOwner?: () => void; @@ -97,6 +105,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { showOverlayNotification: deps.showOverlayNotification, createFieldGroupingCallback: deps.createFieldGroupingCallback, getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath, + ...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}), 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..8c7ba079 100644 --- a/src/main/runtime/youtube-playback-runtime.test.ts +++ b/src/main/runtime/youtube-playback-runtime.test.ts @@ -146,3 +146,73 @@ 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', + }); + + assert.ok( + calls.indexOf('prepare:https://youtu.be/demo') < calls.indexOf('cache:https://youtu.be/demo'), + ); + assert.ok( + calls.indexOf('cache:https://youtu.be/demo') < + calls.indexOf('run-flow:https://youtu.be/demo:download'), + ); + assert.equal(calls.includes('cache-done'), false); + const resolveCacheNow = resolveCache; + assert.ok(resolveCacheNow); + resolveCacheNow(); +}); diff --git a/src/main/runtime/youtube-playback-runtime.ts b/src/main/runtime/youtube-playback-runtime.ts index 3d2554fb..8abe0749 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,15 @@ 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 Promise.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..27747c28 100644 --- a/src/media-generator.test.ts +++ b/src/media-generator.test.ts @@ -25,7 +25,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'), @@ -61,7 +61,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 +182,64 @@ 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_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'); + }); +}); diff --git a/src/media-generator.ts b/src/media-generator.ts index 1ca0bc75..7895f16c 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; @@ -181,7 +184,7 @@ export class MediaGenerator { } async generateAudio( - videoPath: string, + videoPath: MediaInput, startTime: number, endTime: number, padding: number = 0, @@ -190,12 +193,22 @@ 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); return new Promise((resolve, reject) => { const outputPath = this.createTempOutputPath('audio', 'mp3'); - const args: string[] = ['-ss', start.toString(), '-t', duration.toString(), '-i', videoPath]; + 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 @@ -223,7 +236,7 @@ export class MediaGenerator { } async generateScreenshot( - videoPath: string, + videoPath: MediaInput, timestamp: number, options: { format: 'jpg' | 'png' | 'webp'; @@ -239,8 +252,17 @@ export class MediaGenerator { png: 'png', webp: 'webp', }; + const mediaInput = normalizeMediaInput(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) { @@ -334,7 +356,7 @@ export class MediaGenerator { } async generateAnimatedImage( - videoPath: string, + videoPath: MediaInput, startTime: number, endTime: number, padding: number = 0, @@ -364,6 +386,7 @@ export class MediaGenerator { return new Promise((resolve, reject) => { const outputPath = this.createTempOutputPath('animation', 'avif'); + const mediaInput = normalizeMediaInput(videoPath); const encoderArgs: string[] = ['-c:v', av1Encoder]; if (av1Encoder === 'libaom-av1') { @@ -382,8 +405,9 @@ export class MediaGenerator { start.toString(), '-t', duration.toString(), + ...mediaInput.inputArgs, '-i', - videoPath, + mediaInput.path, '-vf', buildAnimatedImageVideoFilter({ fps: clampedFps, diff --git a/src/media-input.ts b/src/media-input.ts new file mode 100644 index 00000000..12b24aa1 --- /dev/null +++ b/src/media-input.ts @@ -0,0 +1,85 @@ +export interface MediaInputOptions { + reconnect?: boolean; + userAgent?: string; + headers?: Record; +} + +export type MediaInput = + | string + | { + path: 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_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..c33fef82 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,9 @@ export interface ResolvedConfig { }; youtube: YoutubeConfig & { primarySubLanguages: string[]; + mediaCache: { + mode: YoutubeMediaCacheMode; + }; }; youtubeSubgen: YoutubeSubgenConfig & { whisperBin: string; diff --git a/src/types/integrations.ts b/src/types/integrations.ts index b8da692b..eddef8fa 100644 --- a/src/types/integrations.ts +++ b/src/types/integrations.ts @@ -122,8 +122,15 @@ export interface AiConfig { requestTimeoutMs?: number; } +export type YoutubeMediaCacheMode = 'direct' | 'background'; + +export interface YoutubeMediaCacheConfig { + mode?: YoutubeMediaCacheMode; +} + 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;