diff --git a/changes/youtube-media-generation-reliability.md b/changes/youtube-media-generation-reliability.md index c0bb424c..8c1635c9 100644 --- a/changes/youtube-media-generation-reliability.md +++ b/changes/youtube-media-generation-reliability.md @@ -3,5 +3,5 @@ area: youtube - Improved YouTube card media generation by sending safer ffmpeg request options for resolved streams and skipping stale stream maps, including cached YouTube files. - Added `youtube.mediaCache.mode` with `direct` and `background` modes so YouTube card audio/image extraction can optionally use a background yt-dlp media cache when direct stream extraction is unreliable; background mode now announces cache download start/readiness through queued overlay/OSD notifications, creates text-only cards while the cache downloads, queues media updates for the mined note IDs, fills audio/image fields once the cached file is ready, and caps background downloads at `youtube.mediaCache.maxHeight` 720p by default while honoring height changes between downloads. -- Cleaned stale YouTube background media cache files on startup and before new background downloads so only the active cached media file remains. +- Cleaned stale YouTube background media cache files on startup and before new background downloads, and stopped in-flight background downloads when switching back to direct mode. - Hardened YouTube media cache downloads with IPv4/extractor retry flags and made failed background downloads notify users and clear queued media updates instead of leaving them pending silently. diff --git a/src/core/services/youtube/media-cache.test.ts b/src/core/services/youtube/media-cache.test.ts index b847e744..ff004a93 100644 --- a/src/core/services/youtube/media-cache.test.ts +++ b/src/core/services/youtube/media-cache.test.ts @@ -149,6 +149,49 @@ test('YouTube media cache clears the active cache when direct mode starts', asyn } }); +test('YouTube media cache cancels the active download when direct mode starts', async () => { + const cacheRoot = makeTempCacheRoot(); + const spawnedProcesses: FakeYtDlpProcess[] = []; + const spawnCalls: SpawnCall[] = []; + const readyEvents: Array<{ url: string; path: string }> = []; + + try { + const cache = createYoutubeMediaCacheService({ + cacheRoot, + getYtDlpCommand: () => 'yt-dlp', + onReady: (event) => { + readyEvents.push(event); + }, + spawn: (command, args, options) => { + spawnCalls.push({ command, args, options }); + const proc = new FakeYtDlpProcess(); + spawnedProcesses.push(proc); + return proc; + }, + }); + + cache.start('https://youtu.be/background', { mode: 'background' }); + const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1]; + assert.equal(typeof outputTemplate, 'string'); + const outputDir = path.dirname(outputTemplate!); + const outputPath = path.join(outputDir, 'media.mkv'); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(outputPath, 'cached media'); + + cache.start('https://youtu.be/direct', { mode: 'direct' }); + spawnedProcesses[0]?.emit('close', 0); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(spawnedProcesses[0]?.killed, true); + assert.deepEqual(readyEvents, []); + assert.equal(await cache.getActiveCachedMediaPath(), null); + assert.equal(await cache.getCachedMediaPath('https://youtu.be/background'), null); + } finally { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + } +}); + test('YouTube media cache reports failed background downloads', async () => { const cacheRoot = makeTempCacheRoot(); const spawnedProcesses: FakeYtDlpProcess[] = []; diff --git a/src/core/services/youtube/media-cache.ts b/src/core/services/youtube/media-cache.ts index 8122dd02..a2c9c151 100644 --- a/src/core/services/youtube/media-cache.ts +++ b/src/core/services/youtube/media-cache.ts @@ -197,6 +197,12 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep const start = (url: string, options: YoutubeMediaCacheStartOptions): void => { if (options.mode !== 'background') { + if (activeKey) { + const activeSession = sessions.get(activeKey); + if (activeSession?.state === 'running') { + removeSession(activeKey); + } + } activeKey = null; return; }