"use server"; import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { AUDIO_FORMAT_VALUES, type AudioFormat, } from "yt-dlp-transcript-common/lib/channelConfig"; import { TRANSCRIPTION_QUEUE, channelQueueKey, resolveQueueKey, } from "yt-dlp-transcript-common/lib/queueKeys"; import { countNotYetTranscribed, readChannelStat, } from "yt-dlp-transcript-common/controller/channels"; import { runWhisperBatch } from "yt-dlp-transcript-common/controller/whisperBatch"; import { clearFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions"; import { cleanAudioFromTranscribed } from "yt-dlp-transcript-common/controller/cleanAudioFromTranscribed"; import { diarizeAll } from "yt-dlp-transcript-common/controller/diarizeAll"; import { purgeSupersededAutoSubs } from "yt-dlp-transcript-common/controller/purgeSupersededAutoSubs"; import { checkKeptDeleted } from "yt-dlp-transcript-common/controller/checkKeptDeleted"; import { cleanExtraAudioFormats } from "yt-dlp-transcript-common/controller/cleanExtraAudioFormats"; import { removeWrongFormatAudio } from "yt-dlp-transcript-common/controller/removeWrongFormatAudio"; import { verifyTranscripts } from "yt-dlp-transcript-common/controller/verifyTranscripts"; import { runManagedFunction, type StreamActionResult, } from "yt-dlp-transcript-common/jobs/streamCommand"; import type { ReplayBucket } from "yt-dlp-transcript-common/jobs/jobSpec"; import { makeTaskTracker } from "yt-dlp-transcript-common/jobs/taskHooks"; function sanitizeAudioFormat(f: AudioFormat | undefined): AudioFormat | undefined { if (f === undefined) return undefined; return AUDIO_FORMAT_VALUES.includes(f) ? f : undefined; } export async function transcribeMissingAction( slug: string, queueKey?: string, reverse?: boolean, audioFormat?: AudioFormat, strictAudioFormat?: boolean, shardTotal?: number, shardIndex?: number, ): Promise { const paths = getPaths(); const fmt = sanitizeAudioFormat(audioFormat); return runManagedFunction({ kind: "whisper-all", queueKey: resolveQueueKey(TRANSCRIPTION_QUEUE, queueKey), paths, channelSlug: slug, spec: { kind: "whisper-all", slug, params: { queueKey, reverse, audioFormat, strictAudioFormat, shardTotal, shardIndex, }, }, fn: async (onLog, signal, setProgress, ctx) => { const stat = await readChannelStat(paths, slug); if (stat) { setProgress({ metric: "transcripts", initial: stat.transcriptCount, target: stat.downloadCount, }); } const result = await runWhisperBatch({ channelSlug: slug, paths, reverse: reverse === true, audioFormat: fmt, strictAudioFormat: fmt !== undefined && strictAudioFormat === true, shardTotal, shardIndex, setProgress, progressBaseline: stat?.transcriptCount, onLog, signal, drainSignal: ctx.drainSignal, tracker: makeTaskTracker(ctx, onLog), }); onLog( `Whisper batch: ${result.succeeded} succeeded, ${result.failed} failed, ${result.skipped} skipped, ${result.attempted} attempted.`, ); safeRevalidate([`/channels/${slug}`]); }, }); } export async function transcribeBucketAction( slug: string, ids: string[], queueKey?: string, audioFormat?: AudioFormat, strictAudioFormat?: boolean, // The snapshot bucket these ids came from (downloadedNoTranscript). Passed by // the named bucket control so the job is replayable; omitted by ad-hoc // selections. bucketKey?: ReplayBucket, ): Promise { const paths = getPaths(); const fmt = sanitizeAudioFormat(audioFormat); const cleaned = Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean))); if (cleaned.length === 0) { return { ok: false, error: "No video ids supplied" }; } return runManagedFunction({ kind: "whisper-bucket-downloaded-no-transcript", queueKey: resolveQueueKey(TRANSCRIPTION_QUEUE, queueKey), paths, channelSlug: slug, spec: bucketKey ? { kind: "whisper-bucket-downloaded-no-transcript", slug, bucket: bucketKey, params: { queueKey, audioFormat, strictAudioFormat }, } : undefined, fn: async (onLog, signal, setProgress, ctx) => { const stat = await readChannelStat(paths, slug); if (stat) { const remaining = await countNotYetTranscribed(paths, slug, cleaned); setProgress({ metric: "transcripts", initial: stat.transcriptCount, target: stat.transcriptCount + remaining, }); } const result = await runWhisperBatch({ channelSlug: slug, paths, audioFormat: fmt, strictAudioFormat: fmt !== undefined && strictAudioFormat === true, ids: cleaned, onLog, signal, drainSignal: ctx.drainSignal, tracker: makeTaskTracker(ctx, onLog), }); onLog( `Whisper bucket: ${result.succeeded} succeeded, ${result.failed} failed, ${result.skipped} skipped, ${result.attempted} attempted.`, ); safeRevalidate([`/channels/${slug}`]); }, }); } // Replace-auto-captions lane, transcribe half. Same batch machinery as // transcribeBucketAction, with the lane flag set so runWhisperBatch treats only // transcript.json as "already transcribed" (an English VTT no longer counts) and // re-verifies each id's provenance before touching it. The superseded VTT is // left on disk — the Cleanup stage's purge is the only thing that removes it. export async function transcribeAutoSubsBucketAction( slug: string, ids: string[], queueKey?: string, audioFormat?: AudioFormat, strictAudioFormat?: boolean, ): Promise { const paths = getPaths(); const fmt = sanitizeAudioFormat(audioFormat); const cleaned = Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean))); if (cleaned.length === 0) { return { ok: false, error: "No video ids supplied" }; } return runManagedFunction({ kind: "whisper-bucket-auto-subs", queueKey: resolveQueueKey(TRANSCRIPTION_QUEUE, queueKey), paths, channelSlug: slug, spec: { kind: "whisper-bucket-auto-subs", slug, bucket: "downloadedAutoSubsOnly", params: { queueKey, audioFormat, strictAudioFormat }, }, fn: async (onLog, signal, _setProgress, ctx) => { // No setProgress here: the channel's transcriptCount already counts these // videos (their VTT is a transcript), so a "transcripts" progress range // would be a flat, meaningless bar. const result = await runWhisperBatch({ channelSlug: slug, paths, audioFormat: fmt, strictAudioFormat: fmt !== undefined && strictAudioFormat === true, ids: cleaned, replaceAutoSubs: true, onLog, signal, drainSignal: ctx.drainSignal, tracker: makeTaskTracker(ctx, onLog), }); onLog( `Replace auto-captions: ${result.succeeded} succeeded, ${result.failed} failed, ${result.skipped} skipped, ${result.attempted} attempted.`, ); safeRevalidate([`/channels/${slug}`]); }, }); } export async function clearFailedTranscriptionsAction( slug: string, queueKey?: string, ): Promise { const paths = getPaths(); return runManagedFunction({ kind: "clear-failed-transcriptions", queueKey: resolveQueueKey(channelQueueKey(slug), queueKey), paths, channelSlug: slug, spec: { kind: "clear-failed-transcriptions", slug, params: { queueKey } }, fn: async (onLog) => { const { cleared } = await clearFailedTranscriptions(paths, slug); onLog( `Cleared ${cleared} entr${cleared === 1 ? "y" : "ies"} from failed-transcriptions; they'll be retried next "Transcribe missing".`, ); safeRevalidate([`/channels/${slug}`]); }, }); } export async function cleanExtraAudioFormatsAction( slug: string, queueKey?: string, ): Promise { const paths = getPaths(); return runManagedFunction({ kind: "clean-extra-audio-formats", queueKey: resolveQueueKey(channelQueueKey(slug), queueKey), paths, channelSlug: slug, spec: { kind: "clean-extra-audio-formats", slug, params: { queueKey } }, fn: async (onLog, signal) => { const result = await cleanExtraAudioFormats({ channelSlug: slug, paths, onLog, signal, }); onLog( `Clean extra audio formats: ${result.removedFiles} file(s) removed across ${result.cleanedDirs} dir(s); inspected ${result.inspected}.`, ); safeRevalidate([`/channels/${slug}`]); }, }); } // Channel-wide sweep: delete every finalized audio file not in the target // format, including the failed-extract orphans cleanExtraAudioFormats skips // (dirs with no target file). Respects the per-video "do not clean" marker. export async function removeWrongFormatAudioAction( slug: string, queueKey?: string, ): Promise { const paths = getPaths(); return runManagedFunction({ kind: "remove-wrong-format-audio", queueKey: resolveQueueKey(channelQueueKey(slug), queueKey), paths, channelSlug: slug, spec: { kind: "remove-wrong-format-audio", slug, params: { queueKey } }, fn: async (onLog, signal) => { const result = await removeWrongFormatAudio({ channelSlug: slug, paths, onLog, signal, }); onLog( `Remove wrong-format audio: ${result.removedFiles} file(s) removed across ${result.cleanedDirs} dir(s); inspected ${result.inspected}.`, ); safeRevalidate([`/channels/${slug}`]); }, }); } export async function cleanAudioAction( slug: string, queueKey?: string, ): Promise { const paths = getPaths(); return runManagedFunction({ kind: "clean-audio-transcribed", queueKey: resolveQueueKey(channelQueueKey(slug), queueKey), paths, channelSlug: slug, spec: { kind: "clean-audio-transcribed", slug, params: { queueKey } }, fn: async (onLog, signal) => { await cleanAudioFromTranscribed({ channelSlug: slug, paths, onLog, signal, }); safeRevalidate([`/channels/${slug}`]); }, }); } // Capture speaker turns for this channel's videos that still have audio and no // diarization.json. Runs on the channel queue alongside the Clean-audio sweep, // deliberately: the two are the paired halves of the same decision — this one // captures the perishable input, that one destroys it, and letting them // interleave is what the cleanup guard exists to make safe. export async function diarizeChannelAction( slug: string, queueKey?: string, ): Promise { const paths = getPaths(); return runManagedFunction({ kind: "diarize-channel", queueKey: resolveQueueKey(channelQueueKey(slug), queueKey), paths, channelSlug: slug, spec: { kind: "diarize-channel", slug, params: { queueKey } }, fn: async (onLog, signal) => { const result = await diarizeAll({ paths, channelSlugs: [slug], onLog, signal, }); onLog( `Diarize ${slug}: ${result.diarized} diarized, ${result.alreadyExists} already done, ` + `${result.noAudio} without audio, ${result.failed} failed.`, ); safeRevalidate([`/channels/${slug}`]); }, }); } // Delete the YouTube auto-caption VTTs that our own transcript superseded (the // supersededAutoSubs bucket). Manual only — never auto-queued — and the single // irreversible step in the lane, so it lives next to the Clean-audio sweep and // honors the same do-not-clean marker. Scoped to English ASR-provenance tracks: // the controller re-sniffs every file before removing it. export async function purgeSupersededAutoSubsAction( slug: string, queueKey?: string, ): Promise { const paths = getPaths(); return runManagedFunction({ kind: "purge-superseded-auto-subs", queueKey: resolveQueueKey(channelQueueKey(slug), queueKey), paths, channelSlug: slug, spec: { kind: "purge-superseded-auto-subs", slug, bucket: "supersededAutoSubs", params: { queueKey }, }, fn: async (onLog, signal) => { await purgeSupersededAutoSubs({ channelSlug: slug, paths, onLog, signal, }); safeRevalidate([`/channels/${slug}`]); }, }); } // Re-probe the channel's keep-latest window for source deletion and pin any // gone videos (do-not-clean) so they survive even after rolling out of the // window. Mirrors cleanAudioAction's managed-job shape. Also driven by the sync // scheduler (editor/app/scheduler/runTick.ts). export async function checkKeptDeletedAction( slug: string, queueKey?: string, ): Promise { const paths = getPaths(); return runManagedFunction({ kind: "check-kept-deleted", queueKey: resolveQueueKey(channelQueueKey(slug), queueKey), paths, channelSlug: slug, spec: { kind: "check-kept-deleted", slug, params: { queueKey } }, fn: async (onLog, signal) => { const result = await checkKeptDeleted({ channelSlug: slug, paths, onLog, signal, }); onLog( `Kept-deletion check: inspected ${result.kept}, ${result.deleted} gone from source, pinned ${result.pinned}.`, ); safeRevalidate([`/channels/${slug}`]); }, }); } export type VerifyResult = | { ok: true; duplicates: string[]; missing: string[] } | { ok: false; error: string }; export async function verifyAction(slug: string): Promise { try { const result = await verifyTranscripts({ channelSlug: slug, paths: getPaths(), }); return { ok: true, ...result }; } catch (e) { return { ok: false, error: (e as Error).message }; } }