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
This commit is contained in:
@@ -53,8 +53,6 @@ async function tryGetCaptions(url: string, _outDir: string): Promise<CaptionResu
|
|||||||
try {
|
try {
|
||||||
const transcript = await fetchTranscript(url, { lang: 'en' });
|
const transcript = await fetchTranscript(url, { lang: 'en' });
|
||||||
const segments = transcriptEntriesToSegments(transcript);
|
const segments = transcriptEntriesToSegments(transcript);
|
||||||
if (segments.length === 0) return null;
|
|
||||||
|
|
||||||
const title = await getYouTubeTitle(url);
|
const title = await getYouTubeTitle(url);
|
||||||
return { type: 'captions', segments, title };
|
return { type: 'captions', segments, title };
|
||||||
} catch {
|
} catch {
|
||||||
@@ -109,7 +107,7 @@ export async function downloadYouTube(url: string, jobId: string): Promise<Downl
|
|||||||
await mkdir(outDir, { recursive: true });
|
await mkdir(outDir, { recursive: true });
|
||||||
|
|
||||||
const captions = await tryGetCaptions(url, outDir);
|
const captions = await tryGetCaptions(url, outDir);
|
||||||
if (captions) return captions;
|
if (captions && captions.segments.length > 0) return captions;
|
||||||
|
|
||||||
const { audioPath, title } = await downloadAudio(url, outDir);
|
const { audioPath, title } = await downloadAudio(url, outDir);
|
||||||
return { type: 'audio', audioPath, title };
|
return { type: 'audio', audioPath, title };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user