import path from "node:path"; import fs from "fs-extra"; import type { Paths } from "../lib/paths"; import { isVideoDownloaded, isVideoTranscribed, readVideoFiles, } from "../lib/videoStatus"; import { transcribeWithWorker } from "./transcribeOne"; import { TranscribeError } from "./transcribeError"; import type { TaskTracker } from "../jobs/taskHooks"; const { pathExists, appendFile } = fs; // One video's gate-checks + transcription, factored out of runWhisperBatch so // the per-channel batch (whisperBatch.ts) and the cross-channel auto-runner // (autoRunner.ts) share IDENTICAL skip/fail rules. It acquires a worker lease // via transcribeWithWorker (parking until a slot frees), records genuine // failures to the channel's failed-transcriptions file, and never throws for the // expected skip/fail cases — it reports them through the returned result. export type TranscribeOneOutcome = "transcribed" | "skipped" | "failed"; export type TranscribeOneResult = { // True once the video cleared every gate and a transcription was attempted. // Mirrors runWhisperBatch's `attempted++` (which counts paused/cancelled/ // no-audio outcomes too — they increment attempted then fall into skipped). attempted: boolean; outcome: TranscribeOneOutcome; }; export type TranscribeOneOptions = { paths: Paths; channelSlug: string; videoId: string; // Resolved audio filename (e.g. "audio.mp3"); under strict mode the video is // skipped unless exactly this file exists. audioFilename: string; strict?: boolean; // Optional in-memory failed-transcriptions set for the pre-attempt skip check // (the batch pre-reads it once per run). When omitted, the on-disk failure // list still governs future runs via the append on failure below. failedSet?: ReadonlySet; tracker?: TaskTracker; // Human label for the per-task progress row. Defaults to the videoId (fine for // a channel-scoped batch); the cross-channel auto-runner passes // "/" so each task shows which channel it's on. taskLabel?: string; onLog?: (msg: string) => void; signal?: AbortSignal; drainSignal?: AbortSignal; // Forwarded to the worker-pool acquire: auto-runner units pass true so they // yield a free slot to any manual (foreground) transcription waiting on it. background?: boolean; // Replace-auto-captions lane: the caller has established that this video's // ONLY transcript is a YouTube ASR VTT (isAutoSubsOnly), so the usual // "already transcribed" gate — which counts any English VTT — must not skip // it. With this set, only OUR transcript.json counts as done. The VTT stays // on disk; pickIndexTranscript prefers transcript.json once whisper writes it. replaceAutoSubs?: boolean; }; const skip = (): TranscribeOneResult => ({ attempted: false, outcome: "skipped" }); export async function transcribeOneFromQueue({ paths, channelSlug, videoId, audioFilename, strict = false, failedSet, tracker, taskLabel, onLog, signal, drainSignal, background, replaceAutoSubs = false, }: TranscribeOneOptions): Promise { const log = onLog ?? ((m: string) => console.log(m)); const channelDir = path.join(paths.channelsDir, channelSlug); const dataDir = path.join(channelDir, "data"); const failureListFile = path.join(channelDir, "failed-transcriptions"); const videoPath = path.join(dataDir, videoId); if (signal?.aborted || drainSignal?.aborted) return skip(); if (failedSet?.has(videoId)) { log(`Skipping previously failed transcription for ${videoId}`); return skip(); } // A transcript already exists if either whisper has run (transcript.json) OR // yt-dlp wrote auto/manual English subs (transcript.en.vtt, or a regional/auto // fallback like en-US). Without the VTT branch, videos on a youtube-handling // channel with only VTT auto-subs end up attempted -> fail with "no audio file // found" -> get added to failed-transcriptions on every run. const videoFiles = await readVideoFiles(videoPath); const alreadyTranscribed = replaceAutoSubs ? videoFiles.hasWhisper : isVideoTranscribed(videoFiles); if (alreadyTranscribed) { log(`Transcription for ${videoId} already exists`); return skip(); } // No real audio file: a download/source problem, not a transcription // candidate. Skip (don't fail) so a re-download lets it transcribe later. // isVideoDownloaded counts a VTT as an artifact, so the replace-auto-captions // lane must insist on actual audio — otherwise an ASR-only video with no audio // would reach whisper and fail with "no audio file found". const downloaded = replaceAutoSubs ? videoFiles.audioFiles.length > 0 : isVideoDownloaded(videoFiles); if (!downloaded) { log(`Skipping ${videoId}: not downloaded (no audio file)`); return skip(); } if (strict && !(await pathExists(path.join(videoPath, audioFilename)))) { log(`Skipping ${videoId}: no ${audioFilename} (strict mode)`); return skip(); } try { // transcribeWithWorker acquires a pool worker (parking until one frees, or // forever while all are disabled), retries across workers on transport // failures, and tracks the per-video task. const outcome = await transcribeWithWorker({ paths, videoDir: videoPath, videoId, audioFilename, strictAudio: strict, tracker, taskId: videoId, taskLabel: taskLabel ?? videoId, onLog: log, signal, drainSignal, background, }); if (outcome === "paused") { // Parakeet was paused with progress cached; leave the video untranscribed // (not failed) so the next run resumes it. log(`Transcribe ${videoId} paused (will resume next run)`); return { attempted: true, outcome: "skipped" }; } return { attempted: true, outcome: "transcribed" }; } catch (err) { // Cancelled or drained while parked/in-flight: a skip, not a failure. if ( signal?.aborted || drainSignal?.aborted || (err as Error)?.name === "AbortError" ) { log(`Transcribe ${videoId} cancelled`); return { attempted: true, outcome: "skipped" }; } // A missing audio file is a download/source problem, not a transcription // failure — skip it (it transcribes once a good audio file exists) rather // than permanently recording it in failed-transcriptions. if (err instanceof TranscribeError && err.failureClass === "no-audio") { log(`Skipping ${videoId}: no audio file (download/source problem)`); return { attempted: true, outcome: "skipped" }; } log(`FAILED TO TRANSCRIBE ${videoId}: ${String(err)}`); await appendFile(failureListFile, `${videoId}\n`); return { attempted: true, outcome: "failed" }; } }