"use server"; import { revalidatePath } from "next/cache"; import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { TRANSCRIPTION_QUEUE, resolveQueueKey, } from "yt-dlp-transcript-common/lib/queueKeys"; import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { saveSettings } from "../../settings/saveSettings"; import { startAutoRunner } from "yt-dlp-transcript-common/controller/autoRunner"; import { runManagedFunction, type StreamActionResult, } from "yt-dlp-transcript-common/jobs/streamCommand"; import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotScheduler"; import { makeTaskTracker } from "yt-dlp-transcript-common/jobs/taskHooks"; import { clearIncompleteTranscriptOne, fixIncompleteTranscriptOne, incompleteIdsForChannel, shortAudioIdsForChannel, } from "./lib/fixIncompleteTranscript"; import type { BulkActionSummary } from "./bulkVideoActions"; function dedupeIds(ids: string[]): string[] { return Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean))); } // Turn on + start both auto-queue runners so cleared videos get reprocessed // automatically. Enabling persists across restarts (settings.json); starting // brings the runner up now without a server restart (same path the auto-queue // admin page and /api/auto-queue/control use). NOTE: the runner only picks up a // channel its policy tree actually matches — cleared videos also surface in the // manual "Download missing" / "Transcribe pending" sections on the operation // pages as a fallback. export async function enableAutoRunners(): Promise { const current = getSettings(); if ( !current.autoQueue.transcription.enabled || !current.autoQueue.download.enabled ) { await saveSettings({ autoQueue: { ...current.autoQueue, transcription: { ...current.autoQueue.transcription, enabled: true }, download: { ...current.autoQueue.download, enabled: true }, }, }); } await startAutoRunner("transcription"); await startAutoRunner("download"); } // One-shot batch re-fix: queue a single managed job that removes the truncated // audio, re-downloads, and re-transcribes each flagged video in place (the bulk // version of the per-video redownloadIncompleteTranscriptAction). ids default to // the channel's current incompleteTranscript bucket; replayable so a re-run // re-derives the live bucket. export async function redownloadIncompleteBucketAction( slug: string, ids?: string[], queueKey?: string, ): Promise { const paths = getPaths(); const config = await readChannelConfig(paths, slug); if (!config) return { ok: false, error: `Channel "${slug}" not found` }; const source = ids && ids.length ? ids : await incompleteIdsForChannel(slug, paths); const cleaned = dedupeIds(source); if (cleaned.length === 0) { return { ok: false, error: "No incomplete transcripts to fix.", info: true }; } return runManagedFunction({ kind: "redownload-incomplete-bucket", queueKey: resolveQueueKey(TRANSCRIPTION_QUEUE, queueKey), paths, channelSlug: slug, spec: { kind: "redownload-incomplete-bucket", slug, bucket: "incompleteTranscript", params: { queueKey }, }, fn: async (onLog, signal, _setProgress, ctx) => { const tracker = makeTaskTracker(ctx, onLog); let succeeded = 0; let failed = 0; for (const id of cleaned) { if (ctx.drainSignal?.aborted) { onLog(`Drain requested; stopping before ${id}.`); break; } try { onLog(`Re-downloading & re-transcribing ${id}…`); await fixIncompleteTranscriptOne({ slug, videoId: id, config, paths, onLog, signal, tracker, }); succeeded++; } catch (e) { failed++; onLog(`Failed ${id}: ${(e as Error).message}`); } } onLog( `Re-download incomplete: ${succeeded} fixed, ${failed} failed of ${cleaned.length}.`, ); safeRevalidate([`/channels/${slug}`]); }, }); } // Re-download the channel's short-audio bucket (downloads the duration guard // flagged as truncated at the source). Reuses the same per-video fixer and the // same bucket job kind, parameterized with bucket "shortAudio" so a replayed // re-run re-derives the live members. The re-download deletes the kept stub and // re-fetches with the per-source default format (Original for Odysee), which is // what actually recovers the full audio. export async function redownloadShortAudioBucketAction( slug: string, ids?: string[], queueKey?: string, ): Promise { const paths = getPaths(); const config = await readChannelConfig(paths, slug); if (!config) return { ok: false, error: `Channel "${slug}" not found` }; const source = ids && ids.length ? ids : await shortAudioIdsForChannel(slug, paths); const cleaned = dedupeIds(source); if (cleaned.length === 0) { return { ok: false, error: "No truncated downloads to fix.", info: true }; } return runManagedFunction({ kind: "redownload-incomplete-bucket", queueKey: resolveQueueKey(TRANSCRIPTION_QUEUE, queueKey), paths, channelSlug: slug, spec: { kind: "redownload-incomplete-bucket", slug, bucket: "shortAudio", params: { queueKey }, }, fn: async (onLog, signal, _setProgress, ctx) => { const tracker = makeTaskTracker(ctx, onLog); let succeeded = 0; let failed = 0; for (const id of cleaned) { if (ctx.drainSignal?.aborted) { onLog(`Drain requested; stopping before ${id}.`); break; } try { onLog(`Re-downloading & re-transcribing ${id}…`); await fixIncompleteTranscriptOne({ slug, videoId: id, config, paths, onLog, signal, tracker, }); succeeded++; } catch (e) { failed++; onLog(`Failed ${id}: ${(e as Error).message}`); } } onLog( `Re-download short-audio: ${succeeded} fixed, ${failed} failed of ${cleaned.length}.`, ); safeRevalidate([`/channels/${slug}`]); }, }); } // Clear & release: synchronously delete the truncated audio + transcript for the // flagged videos so they fall back into the normal pending pipeline, then enable // the auto-runners so they reprocess automatically. Destructive — gate every // entry point with a confirm. ids default to the channel's incompleteTranscript // bucket. export async function clearIncompleteTranscriptsAction( slug: string, ids?: string[], opts?: { enableRunners?: boolean }, ): Promise { const paths = getPaths(); const source = ids && ids.length ? ids : await incompleteIdsForChannel(slug, paths); const cleaned = dedupeIds(source); const failures: BulkActionSummary["failures"] = []; let succeeded = 0; for (const id of cleaned) { try { await clearIncompleteTranscriptOne({ slug, videoId: id, paths }); succeeded++; } catch (e) { failures.push({ videoId: id, error: (e as Error).message }); } } if (opts?.enableRunners !== false && cleaned.length > 0) { await enableAutoRunners(); } revalidatePath(`/channels/${slug}`); requestChannelSnapshot(paths, slug); return { ok: failures.length === 0, attempted: cleaned.length, succeeded, failures, }; }