Files
tonemark/src/routes/api/webhook/[jobId]/+server.ts
T
Giancarmine SalucciandClaude Haiku 4.5 1fc1382414 feat: VTT fallback to whisper with transcript_source tracking
- Set transcript_source to 'captions' when using VTT captions (caption fast path)
- Set transcript_source to 'whisper' when whisper transcription completes via webhook
- Add comprehensive test suite for VTT fallback scenarios
- Tests verify empty/whitespace captions trigger whisper, non-empty captions skip whisper
- Persists transcript_source to database for audit trail

Closes story-d5-vtt-fallback-y-d1d6

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-06 17:24:31 +00:00

93 lines
3.1 KiB
TypeScript

import { json, error } from '@sveltejs/kit';
import { getJob, updateJob, setJobStatus } from '$lib/server/db.js';
import { writeOutputs } from '$lib/server/formatter.js';
import { sendNotification } from '$lib/server/push.js';
import { cleanupJobTmp, cleanupUploadDir } from '$lib/server/downloader.js';
import { emitProgress } from '$lib/server/pipeline.js';
import type { Segment, WhisperJob } from '$lib/types.js';
const WHISPER_JOB_STATUSES = new Set<WhisperJob['status']>([
'queued',
'running',
'done',
'failed',
'cancelled'
]);
function isWhisperJobWebhook(payload: unknown): payload is WhisperJob {
if (!payload || typeof payload !== 'object') return false;
const candidate = payload as Record<string, unknown>;
return (
typeof candidate.id === 'string' &&
typeof candidate.status === 'string' &&
WHISPER_JOB_STATUSES.has(candidate.status as WhisperJob['status'])
);
}
export async function POST({ params, request }) {
const jobId = params.jobId;
const job = getJob(jobId);
if (!job) throw error(404, 'Job not found');
const payload = (await request.json()) as unknown;
if (!isWhisperJobWebhook(payload)) {
// whisper-rtx2080 also fires model lifecycle events to registered job webhooks.
return json({ ok: true, ignored: 'not_a_job_event' });
}
const whisperJob = payload;
// Discard the result if the job was cancelled locally while whisper was running
if (job.status === 'cancelled') {
return json({ ok: true });
}
// Ignore stale callbacks from a previous whisper job after a local retry/reset.
if (job.whisperJobId && whisperJob.id !== job.whisperJobId) {
return json({ ok: true, ignored: 'stale_whisper_job' });
}
// Ignore replayed success callbacks after the transcript is already persisted.
if (job.status === 'done' && job.segmentsJson) {
return json({ ok: true, ignored: 'duplicate_webhook' });
}
if (whisperJob.status === 'failed' || whisperJob.status === 'cancelled') {
const msg = whisperJob.error ?? `Whisper job ${whisperJob.status}`;
updateJob({ id: jobId, status: 'failed', error: msg });
emitProgress(jobId, { type: 'error', message: msg });
return json({ ok: true });
}
try {
setJobStatus(jobId, 'processing', 90);
emitProgress(jobId, { type: 'status', status: 'processing', progress: 90 });
const segments = (whisperJob.segments ?? []) as Segment[];
const paths = await writeOutputs(segments, job.title, jobId);
const outputDir = paths.srt.replace(/\/[^/]+$/, '');
updateJob({
id: jobId,
status: 'done',
progress: 100,
segmentsJson: JSON.stringify(segments),
outputDir,
transcriptSource: 'whisper'
});
emitProgress(jobId, { type: 'done', status: 'done' });
await sendNotification(jobId, '✅ Transcript ready', job.title);
await cleanupJobTmp(jobId);
await cleanupUploadDir(jobId).catch(() => {});
return json({ ok: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
updateJob({ id: jobId, status: 'failed', error: message });
emitProgress(jobId, { type: 'error', message });
return json({ ok: false, error: message }, { status: 500 });
}
}