diff --git a/backend/app/workers/transcription.ts b/backend/app/workers/transcription.ts index d59ec525..4a659ea5 100644 --- a/backend/app/workers/transcription.ts +++ b/backend/app/workers/transcription.ts @@ -22,12 +22,24 @@ const capability: JobCapability = { const mongo = await auth.getResource("mongo"); const transcriptionResource = await getTranscriptionResource(auth); - const processSequence = async (sequence: any) => { + const processSequence = async (sequence: any, batchInfo?: { current: number; total: number }) => { if (!sequence || sequence.state !== "ready") { return { status: "skipped", reason: "Sequence not found or not ready" }; } - await job.updateProgress({ stage: "processing", sequenceId: sequence._id.toString() }); + const progressBase = { + sequenceId: sequence._id.toString(), + timeRange: { + start: sequence.start, + end: sequence.end, + }, + ...(batchInfo && { sequence: batchInfo }), + }; + + await job.updateProgress({ + ...progressBase, + stage: "processing", + }); // 2. Mark sequence as processing await mongo({ @@ -38,7 +50,7 @@ const capability: JobCapability = { }); try { - await job.updateProgress({ stage: "fetching_chunks", sequenceId: sequence._id.toString() }); + await job.updateProgress({ ...progressBase, stage: "fetching_chunks" }); // 3. Get all chunks for this sequence using the range const chunks = await mongo({ action: "find", @@ -54,11 +66,11 @@ const capability: JobCapability = { throw new Error("No chunks found for sequence range"); } - await job.updateProgress({ stage: "combining_audio", chunkCount: chunks.length }); + await job.updateProgress({ ...progressBase, stage: "combining_audio", chunkCount: chunks.length }); // 4. Combine chunks into one audio file const combinedAudio = await combineChunks(chunks); - await job.updateProgress({ stage: "transcribing", audioSize: combinedAudio.length }); + await job.updateProgress({ ...progressBase, stage: "transcribing", audioSize: combinedAudio.length }); // 5. Call transcription API const transcript = await transcriptionResource({ action: "transcribe", @@ -72,7 +84,7 @@ const capability: JobCapability = { const filteredSegments = filterSegments(segments); if (filteredSegments.length === 0) { - await job.updateProgress({ stage: "empty_result" }); + await job.updateProgress({ ...progressBase, stage: "empty_result" }); // No speech detected after filtering await mongo({ action: "updateOne", @@ -85,7 +97,7 @@ const capability: JobCapability = { } const duration = filteredSegments[filteredSegments.length - 1].end; - await job.updateProgress({ stage: "saving_result", duration }); + await job.updateProgress({ ...progressBase, stage: "saving_result", duration }); // 7. Save transcription result const transcriptionDoc = { @@ -131,7 +143,7 @@ const capability: JobCapability = { update: { $set: { state: "completed", updatedAt: new Date() } }, }); - await job.updateProgress({ stage: "completed" }); + await job.updateProgress({ ...progressBase, stage: "completed" }); return { status: "success", result: "transcribed" }; } catch (error) { @@ -168,8 +180,8 @@ const capability: JobCapability = { let processedCount = 0; const total = readySequences.length; for (const sequence of readySequences) { - await processSequence(sequence); processedCount++; + await processSequence(sequence, { current: processedCount, total }); await job.updateProgress({ processed: processedCount, total }); } return { status: "success", processed: processedCount }; diff --git a/frontend/src/pages/JobDetailPage.tsx b/frontend/src/pages/JobDetailPage.tsx index 274b9c1a..e3ad9e06 100644 --- a/frontend/src/pages/JobDetailPage.tsx +++ b/frontend/src/pages/JobDetailPage.tsx @@ -312,6 +312,67 @@ export default function JobDetailPage() { )} + {job.progress && ( + + + Progress + + + {job.progress.stage && ( +
+
Stage
+
{job.progress.stage.replace(/_/g, " ")}
+
+ )} + {job.progress.sequence && ( +
+
Sequence
+
+ {job.progress.sequence.current} of {job.progress.sequence.total} +
+
+ )} + {typeof job.progress.processed === "number" && typeof job.progress.total === "number" && ( +
+
Processed
+
+ {job.progress.processed} / {job.progress.total} ({Math.round((job.progress.processed / job.progress.total) * 100)}%) +
+
+ )} + {job.progress.timeRange && ( +
+
Time Range
+
+ {format(new Date(job.progress.timeRange.start), "PPpp")} + + {format(new Date(job.progress.timeRange.end), "PPpp")} +
+
+ )} + {job.progress.duration && ( +
+
Audio Duration
+
+ {Math.floor(job.progress.duration / 60)}m {Math.round(job.progress.duration % 60)}s +
+
+ )} + {job.progress.chunkCount && ( +
+
Chunks
+
{job.progress.chunkCount}
+
+ )} + {job.progress.audioSize && ( +
+
Audio Size
+
{(job.progress.audioSize / 1024 / 1024).toFixed(1)} MB
+
+ )} +
+
+ )} diff --git a/frontend/src/pages/JobsPage.tsx b/frontend/src/pages/JobsPage.tsx index 422c5fcb..0e1ee0f1 100644 --- a/frontend/src/pages/JobsPage.tsx +++ b/frontend/src/pages/JobsPage.tsx @@ -199,6 +199,14 @@ export default function JobsPage() { return `${minutes}m ${seconds}s`; }; + const formatETASeconds = (seconds: number): string => { + if (seconds < 60) return "< 1m"; + if (seconds < 3600) return `~${Math.round(seconds / 60)}m`; + const hours = Math.floor(seconds / 3600); + const mins = Math.round((seconds % 3600) / 60); + return `~${hours}h ${mins}m`; + }; + const getProgressPercentage = (progress: any): number | null => { if (!progress || typeof progress !== "object") return null; if (typeof progress.progress === "number") return progress.progress; @@ -211,6 +219,48 @@ export default function JobsPage() { return null; }; + const calculateETA = (job: JobInfo): string | null => { + if (job.state !== "active") return null; + const progress = job.progress; + if (!progress?.processed || !progress?.total || !job.processedOn) return null; + + const elapsed = (currentTime - job.processedOn) / 1000; // seconds + if (elapsed < 5) return "Calculating..."; + + const processed = progress.processed; + const total = progress.total; + const remaining = total - processed; + + if (remaining <= 0) return null; + + const rate = processed / elapsed; // items per second + if (rate <= 0) return null; + + const etaSeconds = remaining / rate; + return formatETASeconds(etaSeconds); + }; + + const TRANSCRIPTION_STAGES = ["processing", "fetching_chunks", "combining_audio", "transcribing", "saving_result", "completed"] as const; + const STAGE_LABELS: Record = { + processing: "Starting...", + fetching_chunks: "Fetching audio", + combining_audio: "Combining audio", + transcribing: "Transcribing", + saving_result: "Saving", + completed: "Done", + empty_result: "No speech detected", + }; + + const getStageProgress = (stage: string): { current: number; total: number; label: string } | null => { + const idx = TRANSCRIPTION_STAGES.indexOf(stage as any); + if (idx === -1) return null; + return { + current: idx + 1, + total: TRANSCRIPTION_STAGES.length, + label: STAGE_LABELS[stage] || stage, + }; + }; + const handleCancelAll = async () => { if ( !confirm( @@ -424,28 +474,95 @@ export default function JobsPage() { {job.progress ? ( -
+
{(() => { const percentage = getProgressPercentage(job.progress); + const eta = calculateETA(job); + const hasProcessedTotal = typeof job.progress?.processed === "number" && typeof job.progress?.total === "number"; + const stageProgress = job.progress?.stage ? getStageProgress(job.progress.stage) : null; + const timeRange = job.progress?.timeRange as { start: string; end: string } | undefined; + const sequenceInfo = job.progress?.sequence as { current: number; total: number } | undefined; + const chunkCount = job.progress?.chunkCount as number | undefined; + const audioDuration = job.progress?.duration as number | undefined; + const otherFields = typeof job.progress === "object" + ? Object.entries(job.progress).filter(([k]) => + !["processed", "total", "stage", "sequenceId", "timeRange", "sequence", "chunkCount", "duration", "audioSize"].includes(k) + ) + : []; + return ( <> - {percentage !== null && ( - + {percentage !== null ? ( +
+ + + {Math.round(percentage)}% + +
+ ) : stageProgress && ( +
+ + + {stageProgress.current}/{stageProgress.total} + +
+ )} + {sequenceInfo && ( +
+ Loop {sequenceInfo.current} of {sequenceInfo.total} +
+ )} + {timeRange && ( +
+ Range:{" "} + {format(new Date(timeRange.start), "MMM d HH:mm:ss")} → {format(new Date(timeRange.end), "HH:mm:ss")} +
)} -
- {typeof job.progress === "object" ? ( - Object.entries(job.progress) - .slice(0, 3) - .map(([k, v]) => ( + {stageProgress && ( +
+ {stageProgress.label} +
+ )} + {hasProcessedTotal && !sequenceInfo && ( +
+ {job.progress.processed} / {job.progress.total} +
+ )} + {(chunkCount || audioDuration) && ( +
+ {chunkCount && {chunkCount} chunks} + {audioDuration && ( + + {Math.floor(audioDuration / 60)}m {Math.round(audioDuration % 60)}s audio + + )} +
+ )} + {eta && ( +
+ ETA: {eta} +
+ )} + {otherFields.length > 0 && ( +
+ {otherFields.slice(0, 2).map(([k, v]) => { + let displayValue = String(v); + // Format duration/audioSize as readable values + if (k === "duration" && typeof v === "number") { + const mins = Math.floor(v / 60); + const secs = Math.round(v % 60); + displayValue = `${mins}m ${secs}s`; + } else if (k === "audioSize" && typeof v === "number") { + displayValue = `${(v / 1024 / 1024).toFixed(1)} MB`; + } + return (
- {k}:{" "} - {String(v)} + {k}: {displayValue}
- )) - ) : ( - {String(job.progress)} - )} -
+ ); + })} +
+ )} ); })()}