From 028636c76d61692bedf43a9a2f13b7ee61f41d39 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 23 Jun 2026 20:45:28 -0700 Subject: [PATCH] feat(youtube): queue media for background cache and fill fields when rea - Add `youtube.mediaCache.maxHeight` config option (default 720p) - Background mode creates text-only cards while cache downloads, queues media updates, fills audio/image fields once cached file is ready - Announce cache download start and readiness via overlay/OSD notifications - Skip mpv stream indexes when generating audio from a YouTube cached file --- .../youtube-media-generation-reliability.md | 4 +- config.example.jsonc | 3 +- docs-site/configuration.md | 14 +- docs-site/public/config.example.jsonc | 3 +- src/anki-integration.test.ts | 356 ++++++++++++++++++ src/anki-integration.ts | 129 ++++++- src/anki-integration/card-creation.test.ts | 253 +++++++++++++ src/anki-integration/card-creation.ts | 45 ++- src/anki-integration/media-source.test.ts | 110 ++++++ src/anki-integration/media-source.ts | 138 ++++++- .../note-update-workflow.test.ts | 56 +++ src/anki-integration/note-update-workflow.ts | 28 +- .../pending-youtube-media-queue.ts | 332 ++++++++++++++++ src/anki-integration/pending-youtube-media.ts | 53 +++ src/config/config.test.ts | 15 +- src/config/definitions/defaults-core.ts | 1 + src/config/definitions/options-core.ts | 7 + src/config/resolve/core-domains.ts | 12 + src/config/settings/registry.test.ts | 3 + src/core/services/anki-jimaku.ts | 2 + src/core/services/overlay-runtime-init.ts | 7 + src/core/services/youtube/media-cache.test.ts | 66 ++++ src/core/services/youtube/media-cache.ts | 25 +- src/main.ts | 39 ++ src/main/dependencies.ts | 4 + src/main/main-wiring.test.ts | 42 +++ .../configured-status-notification.test.ts | 39 +- .../runtime/configured-status-notification.ts | 9 +- .../runtime/overlay-notifications-runtime.ts | 32 +- .../overlay-runtime-options-main-deps.ts | 4 + src/main/runtime/overlay-runtime-options.ts | 5 + .../runtime/youtube-playback-runtime.test.ts | 76 +++- src/main/runtime/youtube-playback-runtime.ts | 17 +- src/media-generator.test.ts | 68 +++- src/media-generator.ts | 114 +++++- src/media-input.ts | 1 + src/types/config.ts | 1 + src/types/integrations.ts | 1 + 38 files changed, 2047 insertions(+), 67 deletions(-) create mode 100644 src/anki-integration/pending-youtube-media-queue.ts create mode 100644 src/anki-integration/pending-youtube-media.ts diff --git a/changes/youtube-media-generation-reliability.md b/changes/youtube-media-generation-reliability.md index 604356cf..73dccac1 100644 --- a/changes/youtube-media-generation-reliability.md +++ b/changes/youtube-media-generation-reliability.md @@ -1,5 +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. +- 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. diff --git a/config.example.jsonc b/config.example.jsonc index 9c5fa8bd..55ce4c1b 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -620,7 +620,8 @@ "jpn" ], // Comma-separated primary subtitle language priority for managed subtitle auto-selection. "mediaCache": { - "mode": "direct" // How YouTube card audio/images are extracted. Values: direct | background + "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 eb2d5a38..1c32ce98 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -1522,18 +1522,20 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher "youtube": { "primarySubLanguages": ["ja", "jpn"], "mediaCache": { - "mode": "direct" + "mode": "direct", + "maxHeight": 720 } } } ``` -| 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`) | +| 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; card media generation uses the cached file once it is ready and otherwise falls back to direct stream extraction. +`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. 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. 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. Current launcher behavior: diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 9c5fa8bd..55ce4c1b 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -620,7 +620,8 @@ "jpn" ], // Comma-separated primary subtitle language priority for managed subtitle auto-selection. "mediaCache": { - "mode": "direct" // How YouTube card audio/images are extracted. Values: direct | background + "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/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 ?? '', /^`, + 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 9f04ebf9..79687015 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -81,6 +81,7 @@ test('loads defaults when config is missing', () => { 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); @@ -1758,7 +1759,8 @@ test('parses YouTube media cache config and warns on invalid values', () => { `{ "youtube": { "mediaCache": { - "mode": "background" + "mode": "background", + "maxHeight": 480 } } }`, @@ -1767,6 +1769,7 @@ test('parses YouTube media cache config and warns on invalid values', () => { 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( @@ -1774,7 +1777,8 @@ test('parses YouTube media cache config and warns on invalid values', () => { `{ "youtube": { "mediaCache": { - "mode": "always" + "mode": "always", + "maxHeight": -1 } } }`, @@ -1786,9 +1790,16 @@ test('parses YouTube media cache config and warns on invalid values', () => { 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', () => { diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 07d724a4..a0adc8fc 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -113,6 +113,7 @@ export const CORE_DEFAULT_CONFIG: Pick< primarySubLanguages: ['ja', 'jpn'], mediaCache: { mode: 'direct', + maxHeight: 720, }, }, subsync: { diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 7b1647a8..70957d9e 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -130,6 +130,13 @@ export function buildCoreConfigOptionRegistry( 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/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index bd864c78..cc1e167b 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -310,6 +310,18 @@ export function applyCoreDomainConfig(context: ResolveContext): void { "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', diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index acc3765e..7ed51405 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -167,6 +167,7 @@ test('settings registry exposes specialized controls for config-assisted inputs' 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']); @@ -174,6 +175,8 @@ test('settings registry exposes YouTube media cache mode as a labeled select', ( 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', () => { diff --git a/src/core/services/anki-jimaku.ts b/src/core/services/anki-jimaku.ts index d3fdabff..b2dc19c1 100644 --- a/src/core/services/anki-jimaku.ts +++ b/src/core/services/anki-jimaku.ts @@ -44,6 +44,7 @@ export interface AnkiJimakuIpcRuntimeOptions { currentVideoPath: string, kind: 'audio' | 'video', ) => Promise; + shouldRequireRemoteMediaCache?: () => boolean; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: () => ( @@ -112,6 +113,7 @@ export function registerAnkiJimakuIpcRuntime( 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 ed80d09d..ef925689 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -29,6 +29,7 @@ type CreateAnkiIntegrationArgs = { currentVideoPath: string, kind: 'audio' | 'video', ) => Promise; + shouldRequireRemoteMediaCache?: () => boolean; }; export type OverlayWindowTrackerOptions = { @@ -70,6 +71,7 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte undefined, args.showOverlayNotification, args.getCachedMediaPath, + args.shouldRequireRemoteMediaCache, ); } @@ -141,6 +143,7 @@ export function initializeOverlayRuntime( currentVideoPath: string, kind: 'audio' | 'video', ) => Promise; + shouldRequireRemoteMediaCache?: () => boolean; shouldStartAnkiIntegration?: () => boolean; createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; backendOverride: string | null; @@ -179,6 +182,7 @@ export function initializeOverlayAnkiIntegration(options: { currentVideoPath: string, kind: 'audio' | 'video', ) => Promise; + shouldRequireRemoteMediaCache?: () => boolean; shouldStartAnkiIntegration?: () => boolean; createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; }): boolean { @@ -214,6 +218,9 @@ export function initializeOverlayAnkiIntegration(options: { 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 index 2d7573b4..74593214 100644 --- a/src/core/services/youtube/media-cache.test.ts +++ b/src/core/services/youtube/media-cache.test.ts @@ -55,11 +55,19 @@ test('YouTube media cache exposes the downloaded file after the background job c 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(); @@ -70,10 +78,15 @@ test('YouTube media cache exposes the downloaded file after the background job c 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('--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); @@ -88,6 +101,59 @@ test('YouTube media cache exposes the downloaded file after the background job c 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 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 }); } diff --git a/src/core/services/youtube/media-cache.ts b/src/core/services/youtube/media-cache.ts index d3663675..57e37fa2 100644 --- a/src/core/services/youtube/media-cache.ts +++ b/src/core/services/youtube/media-cache.ts @@ -31,17 +31,21 @@ interface MediaCacheSession { 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; 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); @@ -67,12 +71,25 @@ function findReadyMediaPath(dir: string): string | null { } } -function createYtDlpArgs(url: string, outputTemplate: string): string[] { +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', '-f', - 'bestvideo*+bestaudio/best', + getFormatSelector(normalizeMaxHeight(maxHeight)), '--merge-output-format', 'mkv', '-o', @@ -177,7 +194,7 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep const dir = getSessionDir(url); fs.mkdirSync(dir, { recursive: true }); const outputTemplate = path.join(dir, 'media.%(ext)s'); - const args = createYtDlpArgs(url, outputTemplate); + const args = createYtDlpArgs(url, outputTemplate, options.maxHeight); const child = spawn(getYtDlpCommand(), args, { stdio: ['ignore', 'ignore', 'ignore'] }); const session: MediaCacheSession = { url, @@ -188,6 +205,7 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep }; 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); @@ -215,6 +233,7 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep session.state = 'ready'; session.readyPath = readyPath; deps.logInfo?.(`YouTube media cache ready at ${readyPath}`); + deps.onReady?.({ url, path: readyPath }); return; } } diff --git a/src/main.ts b/src/main.ts index a3024002..1dcfe6fb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1174,6 +1174,32 @@ 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) + }`, + ); + }); + }, logInfo: (message) => logger.info(message), logWarn: (message) => logger.warn(message), }); @@ -1324,6 +1350,7 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({ startYoutubeMediaCache: (url) => { youtubeMediaCache.start(url, { mode: getResolvedConfig().youtube.mediaCache.mode, + maxHeight: getResolvedConfig().youtube.mediaCache.maxHeight, }); }, runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request), @@ -1645,6 +1672,12 @@ function isYoutubePlaybackActiveNow(): boolean { ); } +function shouldRequireYoutubeMediaCacheForCurrentPlayback(): boolean { + return ( + getResolvedConfig().youtube.mediaCache.mode === 'background' && isYoutubePlaybackActiveNow() + ); +} + async function getCachedYoutubeMediaPathForCurrentPlayback( currentVideoPath: string, _kind: 'audio' | 'video', @@ -1655,6 +1688,8 @@ async function getCachedYoutubeMediaPathForCurrentPlayback( 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()) @@ -2662,6 +2697,7 @@ const overlayNotificationsRuntime = createOverlayNotificationsRuntime({ }); const { flushQueuedOverlayNotifications, + flushQueuedMpvOsdNotifications, openAnkiCardFromNotification, toggleNotificationHistoryPanel, showConfiguredPlaybackFeedback, @@ -4313,6 +4349,7 @@ const { }, onMpvConnected: () => { maybeStartOverlayLoadingOsd(); + flushQueuedMpvOsdNotifications(); if (appState.sessionBindingsInitialized) { sendMpvCommandRuntime(appState.mpvClient, [ 'script-message', @@ -5760,6 +5797,7 @@ 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(), @@ -6238,6 +6276,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = 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 a368d5f9..c69b4318 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -127,6 +127,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams { setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration']; getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath']; getCachedMediaPath?: AnkiJimakuIpcRuntimeOptions['getCachedMediaPath']; + shouldRequireRemoteMediaCache?: AnkiJimakuIpcRuntimeOptions['shouldRequireRemoteMediaCache']; showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification']; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback']; @@ -319,6 +320,9 @@ export function createAnkiJimakuIpcRuntimeServiceDeps( 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/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 8f0445ec..d86ce789 100644 --- a/src/main/runtime/overlay-runtime-options-main-deps.ts +++ b/src/main/runtime/overlay-runtime-options-main-deps.ts @@ -42,6 +42,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback']; getKnownWordCacheStatePath: () => string; getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath']; + shouldRequireRemoteMediaCache?: OverlayRuntimeOptionsMainDeps['shouldRequireRemoteMediaCache']; shouldStartAnkiIntegration: () => boolean; bindOverlayOwner?: () => void; releaseOverlayOwner?: () => void; @@ -79,6 +80,9 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { 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 c705f302..83ccb709 100644 --- a/src/main/runtime/overlay-runtime-options.ts +++ b/src/main/runtime/overlay-runtime-options.ts @@ -41,6 +41,7 @@ type OverlayRuntimeOptions = { currentVideoPath: string, kind: 'audio' | 'video', ) => Promise; + shouldRequireRemoteMediaCache?: () => boolean; shouldStartAnkiIntegration: () => boolean; bindOverlayOwner?: () => void; releaseOverlayOwner?: () => void; @@ -79,6 +80,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { currentVideoPath: string, kind: 'audio' | 'video', ) => Promise; + shouldRequireRemoteMediaCache?: () => boolean; shouldStartAnkiIntegration: () => boolean; bindOverlayOwner?: () => void; releaseOverlayOwner?: () => void; @@ -106,6 +108,9 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { 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 8c7ba079..9fe352c7 100644 --- a/src/main/runtime/youtube-playback-runtime.test.ts +++ b/src/main/runtime/youtube-playback-runtime.test.ts @@ -204,15 +204,77 @@ test('youtube playback runtime starts media cache without blocking the subtitle 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'), - ); + 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 8abe0749..61e04aff 100644 --- a/src/main/runtime/youtube-playback-runtime.ts +++ b/src/main/runtime/youtube-playback-runtime.ts @@ -128,13 +128,16 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) { 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) - }`, - ); - }); + 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({ diff --git a/src/media-generator.test.ts b/src/media-generator.test.ts index 27747c28..ebcbd60f 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'); @@ -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); @@ -243,3 +247,65 @@ test('generateAudio keeps explicit audio stream maps for normal media paths', as 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 7895f16c..6f51b60a 100644 --- a/src/media-generator.ts +++ b/src/media-generator.ts @@ -72,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); @@ -86,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 }); @@ -194,9 +269,11 @@ export class MediaGenerator { 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 startedAt = this.nowMs(); const args: string[] = [ '-ss', start.toString(), @@ -218,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; } @@ -227,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); @@ -253,6 +339,7 @@ export class MediaGenerator { webp: 'webp', }; const mediaInput = normalizeMediaInput(videoPath); + const inputDescription = describeMediaInputForDebugLog(videoPath); const args: string[] = [ '-ss', @@ -292,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; } @@ -303,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); @@ -374,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).', @@ -387,6 +489,7 @@ 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') { @@ -398,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', [ @@ -422,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; } @@ -429,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 index 12b24aa1..95916217 100644 --- a/src/media-input.ts +++ b/src/media-input.ts @@ -8,6 +8,7 @@ export type MediaInput = | string | { path: string; + source?: string; inputOptions?: MediaInputOptions; singleResolvedStream?: boolean; }; diff --git a/src/types/config.ts b/src/types/config.ts index c33fef82..435513ff 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -348,6 +348,7 @@ export interface ResolvedConfig { primarySubLanguages: string[]; mediaCache: { mode: YoutubeMediaCacheMode; + maxHeight: number; }; }; youtubeSubgen: YoutubeSubgenConfig & { diff --git a/src/types/integrations.ts b/src/types/integrations.ts index eddef8fa..646678a2 100644 --- a/src/types/integrations.ts +++ b/src/types/integrations.ts @@ -126,6 +126,7 @@ export type YoutubeMediaCacheMode = 'direct' | 'background'; export interface YoutubeMediaCacheConfig { mode?: YoutubeMediaCacheMode; + maxHeight?: number; } export interface YoutubeConfig {