diff --git a/src/lib/server/pipeline.ts b/src/lib/server/pipeline.ts index 3c2686e..059ee98 100644 --- a/src/lib/server/pipeline.ts +++ b/src/lib/server/pipeline.ts @@ -132,7 +132,8 @@ async function runJob( status: 'done', progress: 100, segmentsJson: JSON.stringify(captionSegments), - outputDir: paths.srt.replace(/\/[^/]+$/, '') + outputDir: paths.srt.replace(/\/[^/]+$/, ''), + transcriptSource: 'captions' }); emitProgress(jobId, { type: 'done' }); const { sendNotification } = await import('./push.js'); diff --git a/src/routes/api/webhook/[jobId]/+server.ts b/src/routes/api/webhook/[jobId]/+server.ts index e7102a4..b1104e5 100644 --- a/src/routes/api/webhook/[jobId]/+server.ts +++ b/src/routes/api/webhook/[jobId]/+server.ts @@ -72,7 +72,8 @@ export async function POST({ params, request }) { status: 'done', progress: 100, segmentsJson: JSON.stringify(segments), - outputDir + outputDir, + transcriptSource: 'whisper' }); emitProgress(jobId, { type: 'done', status: 'done' }); diff --git a/src/tests/vtt-fallback.test.ts b/src/tests/vtt-fallback.test.ts new file mode 100644 index 0000000..240b5c9 --- /dev/null +++ b/src/tests/vtt-fallback.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { rm } from 'fs/promises'; +import type { TranscriptResponse } from 'youtube-transcript'; + +const { mockExecFile, mockFetchTranscript, mockSubmitJob, mockStreamJob, TEST_DATA_DIR } = vi.hoisted(() => { + const dir = `/tmp/tonemark-vtt-fallback-test-${Date.now()}`; + process.env.DATA_DIR = dir; + return { + mockExecFile: vi.fn(), + mockFetchTranscript: vi.fn(), + mockSubmitJob: vi.fn(), + mockStreamJob: vi.fn(), + TEST_DATA_DIR: dir + }; +}); + +vi.mock('child_process', () => ({ + execFile: mockExecFile +})); + +vi.mock('youtube-transcript', () => ({ + fetchTranscript: mockFetchTranscript +})); + +vi.mock('$lib/server/whisper.js', async () => { + const actual = await vi.importActual('$lib/server/whisper.js'); + return { + ...actual, + submitJob: mockSubmitJob, + streamJob: mockStreamJob, + getHealth: vi.fn().mockResolvedValue({ status: 'ok' }), + ensureWhisperRunning: vi.fn().mockResolvedValue(undefined) + }; +}); + +vi.mock('$lib/server/audio.js', () => ({ + prepareAudio: vi.fn().mockResolvedValue({ + wavPath: '/fake/path/audio.wav', + analysis: { meanVolume: -20, maxVolume: -10 } + }), + cleanup: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('$lib/server/formatter.js', () => ({ + writeOutputs: vi.fn().mockResolvedValue({ + srt: '/fake/path/output.srt', + vtt: '/fake/path/output.vtt', + json: '/fake/path/output.json' + }) +})); + +vi.mock('$lib/server/push.js', () => ({ + sendNotification: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('$lib/server/docker.js', () => ({ + ensureWhisperRunning: vi.fn().mockResolvedValue(undefined) +})); + +import { startYouTubeJob } from '$lib/server/pipeline.js'; +import { getJob, createJob, updateJob } from '$lib/server/db.js'; + +describe('VTT fallback — transcript_source field', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExecFile.mockImplementation((...args: unknown[]) => { + const cb = args.at(-1) as (...callbackArgs: unknown[]) => void; + cb(null, JSON.stringify({ title: 'Test Video' }), ''); + }); + }); + + afterEach(async () => { + await rm(TEST_DATA_DIR, { recursive: true, force: true }).catch(() => {}); + }); + + it('sets transcript_source to "captions" when VTT captions are non-empty', async () => { + mockFetchTranscript.mockResolvedValue([ + { text: 'Hello world', offset: 0, duration: 2.5, lang: 'en' }, + { text: 'Test caption', offset: 2.5, duration: 2.5, lang: 'en' } + ] satisfies TranscriptResponse[]); + + const jobId = await startYouTubeJob('https://youtube.com/watch?v=test-captions'); + + // Wait for the caption fast path to complete + await vi.waitFor( + () => { + const job = getJob(jobId); + expect(job?.status).toBe('done'); + }, + { timeout: 5000, interval: 50 } + ); + + const job = getJob(jobId)!; + expect(job.transcriptSource).toBe('captions'); + expect(job.segmentsJson).toBeTruthy(); + expect(job.status).toBe('done'); + }); + + it('stores transcript_source as "whisper" when webhook updates job', async () => { + const job = createJob('https://youtube.com/watch?v=test-empty', 'Empty Captions Job', 'auto'); + + // Simulate the whisper completion webhook setting transcript_source to whisper + updateJob({ + id: job.id, + status: 'done', + progress: 100, + segmentsJson: JSON.stringify([ + { index: 0, start: 0, end: 2, text: 'Whisper output', words: [] } + ]), + outputDir: '/fake/output', + transcriptSource: 'whisper' + }); + + const updated = getJob(job.id)!; + expect(updated.transcriptSource).toBe('whisper'); + expect(updated.status).toBe('done'); + expect(updated.segmentsJson).toBeTruthy(); + }); + + it('stores transcript_source correctly for different transcript modes', async () => { + const job1 = createJob('job-1', 'Captions Job', 'auto'); + updateJob({ id: job1.id, transcriptSource: 'captions' }); + expect(getJob(job1.id)!.transcriptSource).toBe('captions'); + + const job2 = createJob('job-2', 'Whisper Job', 'auto'); + updateJob({ id: job2.id, transcriptSource: 'whisper' }); + expect(getJob(job2.id)!.transcriptSource).toBe('whisper'); + + const job3 = createJob('job-3', 'Unset Job', 'auto'); + expect(getJob(job3.id)!.transcriptSource).toBeNull(); + }); +});