From 0d4f58310f87f42e359ee269984a7b3153e6e09e Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Thu, 9 Jul 2026 05:01:55 +0200 Subject: [PATCH] D5: empty VTT captions fall back to whisper transcription When youtube-transcript fetchTranscript() returns empty segments array (parse succeeded but zero captions), treat as fallback trigger instead of returning empty transcript. - tryGetCaptions(): return CaptionResult with empty segments (not null) when segments array is empty. Distinguishes 'no captions available' (null) from 'captions exist but empty' (CaptionResult with []) - downloadYouTube(): check captions.segments.length > 0 before taking fast path. Empty segments fall through to audio download + whisper - Tests: empty array fallback, whitespace-only entries fallback, non-empty segments still use fast path --- src/lib/server/downloader.ts | 4 +- src/tests/downloader.test.ts | 73 ++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/lib/server/downloader.ts b/src/lib/server/downloader.ts index 60241e1..ae2b8d7 100644 --- a/src/lib/server/downloader.ts +++ b/src/lib/server/downloader.ts @@ -53,8 +53,6 @@ async function tryGetCaptions(url: string, _outDir: string): Promise 0) return captions; const { audioPath, title } = await downloadAudio(url, outDir); return { type: 'audio', audioPath, title }; diff --git a/src/tests/downloader.test.ts b/src/tests/downloader.test.ts index 5b56977..60ad143 100644 --- a/src/tests/downloader.test.ts +++ b/src/tests/downloader.test.ts @@ -133,3 +133,76 @@ describe('downloadYouTube', () => { }); }); }); + +describe('empty captions fallback (D5)', () => { + it('falls back to audio when fetchTranscript returns empty segments array', async () => { + mockFetchTranscript.mockResolvedValue([] satisfies TranscriptResponse[]); + mockExecFile.mockImplementation((...args: unknown[]) => { + const allArgs = args.slice(0, -1) as string[]; + const cb = args.at(-1) as (...callbackArgs: unknown[]) => void; + // downloadAudio: yt-dlp -f bestaudio [...] + if (allArgs.includes('-f')) { + const oIdx = allArgs.indexOf('-o'); + const outPattern = oIdx !== -1 ? allArgs[oIdx + 1] : ''; + const outDir = outPattern ? outPattern.substring(0, outPattern.lastIndexOf('/')) : ''; + if (outDir) { + const { mkdirSync, writeFileSync } = require('fs'); + mkdirSync(outDir, { recursive: true }); + writeFileSync(require('path').join(outDir, 'audio.mp4'), 'fake-audio'); + writeFileSync(require('path').join(outDir, 'audio.info.json'), JSON.stringify({ title: 'Empty Captions Video' })); + } + } + cb(null, JSON.stringify({ title: 'Empty Captions Video' }), ''); + }); + + const result = await downloadYouTube('https://youtube.com/watch?v=empty', 'test-empty-1'); + + expect(mockFetchTranscript).toHaveBeenCalledWith('https://youtube.com/watch?v=empty', { + lang: 'en' + }); + expect(result.type).toBe('audio'); + expect(result.title).toBe('Empty Captions Video'); + }); + + it('falls back to audio when all transcript entries are whitespace', async () => { + mockFetchTranscript.mockResolvedValue([ + { text: ' ', offset: 1000, duration: 2000, lang: 'en' }, + { text: ' ', offset: 3000, duration: 1000, lang: 'en' } + ] satisfies TranscriptResponse[]); + mockExecFile.mockImplementation((...args: unknown[]) => { + const allArgs = args.slice(0, -1) as string[]; + const cb = args.at(-1) as (...callbackArgs: unknown[]) => void; + if (allArgs.includes('-f')) { + const oIdx = allArgs.indexOf('-o'); + const outPattern = oIdx !== -1 ? allArgs[oIdx + 1] : ''; + const outDir = outPattern ? outPattern.substring(0, outPattern.lastIndexOf('/')) : ''; + if (outDir) { + const { mkdirSync, writeFileSync } = require('fs'); + mkdirSync(outDir, { recursive: true }); + writeFileSync(require('path').join(outDir, 'audio.mp4'), 'fake-audio'); + writeFileSync(require('path').join(outDir, 'audio.info.json'), JSON.stringify({ title: 'Whitespace Only' })); + } + } + cb(null, JSON.stringify({ title: 'Whitespace Only' }), ''); + }); + + const result = await downloadYouTube('https://youtube.com/watch?v=ws-only', 'test-empty-2'); + + expect(result.type).toBe('audio'); + }); + + it('still uses caption fast path when fetchTranscript returns non-empty segments', async () => { + mockFetchTranscript.mockResolvedValue([ + { text: 'Real caption', offset: 1000, duration: 2000, lang: 'en' } + ] satisfies TranscriptResponse[]); + mockExecFile.mockImplementation((...args: unknown[]) => { + const cb = args.at(-1) as (...callbackArgs: unknown[]) => void; + cb(null, JSON.stringify({ title: 'Real Caption Video' }), ''); + }); + + const result = await downloadYouTube('https://youtube.com/watch?v=has-captions', 'test-has-cap'); + + expect(result.type).toBe('captions'); + expect((result as { type: 'captions'; segments: unknown[] }).segments.length).toBe(1); + }); +});