From 90a548b4e94313b36ded802c2c491db98efdc6bf Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 25 Jun 2026 22:19:07 -0700 Subject: [PATCH] fix: honor YouTube media cache height changes --- .../youtube-media-generation-reliability.md | 2 +- src/core/services/youtube/media-cache.test.ts | 74 +++++++++++++++++++ src/core/services/youtube/media-cache.ts | 10 ++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/changes/youtube-media-generation-reliability.md b/changes/youtube-media-generation-reliability.md index 1ade662c..c0bb424c 100644 --- a/changes/youtube-media-generation-reliability.md +++ b/changes/youtube-media-generation-reliability.md @@ -2,6 +2,6 @@ type: fixed area: youtube - Improved YouTube card media generation by sending safer ffmpeg request options for resolved streams and skipping stale stream maps, including cached YouTube files. -- Added `youtube.mediaCache.mode` with `direct` and `background` modes so YouTube card audio/image extraction can optionally use a background yt-dlp media cache when direct stream extraction is unreliable; background mode now announces cache download start/readiness through queued overlay/OSD notifications, creates text-only cards while the cache downloads, queues media updates for the mined note IDs, fills audio/image fields once the cached file is ready, and caps background downloads at `youtube.mediaCache.maxHeight` 720p by default. +- Added `youtube.mediaCache.mode` with `direct` and `background` modes so YouTube card audio/image extraction can optionally use a background yt-dlp media cache when direct stream extraction is unreliable; background mode now announces cache download start/readiness through queued overlay/OSD notifications, creates text-only cards while the cache downloads, queues media updates for the mined note IDs, fills audio/image fields once the cached file is ready, and caps background downloads at `youtube.mediaCache.maxHeight` 720p by default while honoring height changes between downloads. - Cleaned stale YouTube background media cache files on startup and before new background downloads so only the active cached media file remains. - Hardened YouTube media cache downloads with IPv4/extractor retry flags and made failed background downloads notify users and clear queued media updates instead of leaving them pending silently. diff --git a/src/core/services/youtube/media-cache.test.ts b/src/core/services/youtube/media-cache.test.ts index c9a4c700..8b913cc1 100644 --- a/src/core/services/youtube/media-cache.test.ts +++ b/src/core/services/youtube/media-cache.test.ts @@ -225,6 +225,80 @@ test('YouTube media cache applies the configured download height cap', () => { } }); +test('YouTube media cache restarts ready sessions when the height cap changes', async () => { + const cacheRoot = makeTempCacheRoot(); + const spawnedProcesses: FakeYtDlpProcess[] = []; + const spawnCalls: SpawnCall[] = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + spawn: (command, args, options) => { + spawnCalls.push({ command, args, options }); + const proc = new FakeYtDlpProcess(); + spawnedProcesses.push(proc); + return proc; + }, + }); + + cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 720 }); + const firstOutputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1]; + assert.equal(typeof firstOutputTemplate, 'string'); + const firstOutputDir = path.dirname(firstOutputTemplate!); + const firstOutputPath = path.join(firstOutputDir, 'media.mkv'); + fs.mkdirSync(firstOutputDir, { recursive: true }); + fs.writeFileSync(firstOutputPath, 'cached media'); + spawnedProcesses[0]?.emit('close', 0); + + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), firstOutputPath); + + cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 480 }); + + assert.equal(spawnCalls.length, 2); + assert.equal(fs.existsSync(firstOutputPath), false); + assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null); + assert.equal( + spawnCalls[1]?.args[spawnCalls[1].args.indexOf('-f') + 1], + 'bestvideo*[height<=480]+bestaudio/best[height<=480]', + ); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + +test('YouTube media cache restarts running sessions when the height cap changes', () => { + const cacheRoot = makeTempCacheRoot(); + const spawnedProcesses: FakeYtDlpProcess[] = []; + const spawnCalls: SpawnCall[] = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + spawn: (command, args, options) => { + spawnCalls.push({ command, args, options }); + const proc = new FakeYtDlpProcess(); + spawnedProcesses.push(proc); + return proc; + }, + }); + + cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 720 }); + cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 480 }); + + assert.equal(spawnedProcesses[0]?.killed, true); + assert.equal(spawnCalls.length, 2); + assert.equal( + spawnCalls[1]?.args[spawnCalls[1].args.indexOf('-f') + 1], + 'bestvideo*[height<=480]+bestaudio/best[height<=480]', + ); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + test('YouTube media cache removes stale files from previous runs on startup', () => { const cacheRoot = makeTempCacheRoot(); const staleDir = path.join(cacheRoot, 'stale-session'); diff --git a/src/core/services/youtube/media-cache.ts b/src/core/services/youtube/media-cache.ts index 8453cee2..4d151240 100644 --- a/src/core/services/youtube/media-cache.ts +++ b/src/core/services/youtube/media-cache.ts @@ -24,6 +24,7 @@ type SpawnProcess = ( interface MediaCacheSession { url: string; dir: string; + maxHeight: number; process: SpawnedProcess | null; readyPath: string | null; state: MediaCacheSessionState; @@ -175,6 +176,7 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep sessions.set(key, { url, dir: path.dirname(readyPath), + maxHeight: session?.maxHeight ?? DEFAULT_MAX_HEIGHT, process: null, readyPath, state: 'ready', @@ -199,16 +201,19 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep } const key = cacheKeyForUrl(url); + const maxHeight = normalizeMaxHeight(options.maxHeight); activeKey = key; const dir = getSessionDir(url); const existingSession = sessions.get(key); - if (existingSession?.state === 'running') { + const canReuseExistingSession = existingSession?.maxHeight === maxHeight; + if (existingSession?.state === 'running' && canReuseExistingSession) { removeInactiveSessions(key); removeCacheRootEntriesExcept([existingSession.dir]); return; } if (existingSession) { if ( + canReuseExistingSession && existingSession.state === 'ready' && ((existingSession.readyPath && fs.existsSync(existingSession.readyPath)) || findReadyMediaPath(existingSession.dir)) @@ -225,11 +230,12 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep fs.mkdirSync(dir, { recursive: true }); const outputTemplate = path.join(dir, 'media.%(ext)s'); - const args = createYtDlpArgs(url, outputTemplate, options.maxHeight); + const args = createYtDlpArgs(url, outputTemplate, maxHeight); const child = spawn(getYtDlpCommand(), args, { stdio: ['ignore', 'ignore', 'ignore'] }); const session: MediaCacheSession = { url, dir, + maxHeight, process: child, readyPath: null, state: 'running',