"use client"; import { useState, useTransition } from "react"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import type { AudioFormat } from "yt-dlp-transcript-common/lib/channelConfig"; import { downloadMissingAction, runMetadataScanAction, } from "../../channels/[slug]/pipelineActions"; import { cleanAudioAction, cleanExtraAudioFormatsAction, transcribeMissingAction, } from "../../channels/[slug]/whisperActions"; import { clearIncompleteTranscriptsAction, redownloadIncompleteBucketAction, redownloadShortAudioBucketAction, } from "../../channels/[slug]/incompleteTranscriptActions"; import { backfillChannelAction } from "../../channels/[slug]/backfillActions"; import { digestChannelAction } from "../../channels/[slug]/digestActions"; import { refreshChannelSnapshotAction } from "../../channels/actions"; type Variant = | { kind: "downloadMissing"; slug: string } | { kind: "metadataScan"; slug: string } | { kind: "transcribeMissing"; slug: string; audioFormat?: AudioFormat } | { kind: "redownloadIncomplete"; slug: string } | { kind: "clearIncomplete"; slug: string } | { kind: "redownloadShortAudio"; slug: string } | { kind: "cleanTranscribedAudio"; slug: string } | { kind: "cleanExtraFormats"; slug: string } | { kind: "backfillChannel"; slug: string } // The digest lane's LOCAL half only. The metered lane is behind a settings // gate and a spend cap, so it is never what a one-click "next action" starts. | { kind: "digestChannel"; slug: string } | { kind: "refreshReport"; slug: string }; type Status = | { kind: "idle" } | { kind: "running" } | { kind: "queued"; jobId: string } | { kind: "done"; message?: string } | { kind: "error"; message: string }; const LABEL: Record = { downloadMissing: { idle: "Download missing", running: "Queuing…" }, metadataScan: { idle: "Scan metadata", running: "Queuing…" }, transcribeMissing: { idle: "Transcribe pending", running: "Queuing…" }, redownloadIncomplete: { idle: "Re-download & re-transcribe", running: "Queuing…" }, clearIncomplete: { idle: "Clear & re-queue", running: "Clearing…" }, redownloadShortAudio: { idle: "Re-download (corrected format)", running: "Queuing…" }, cleanTranscribedAudio: { idle: "Clean audio", running: "Queuing…" }, cleanExtraFormats: { idle: "Clean extra formats", running: "Queuing…" }, // "Backfill" is a queue key, not a thing anyone asked for. The real name // depends on which operations are enabled, which only the server knows — so // this is the fallback and `label` overrides it. See sections.tsx. backfillChannel: { idle: "Run derived data", running: "Queuing…" }, digestChannel: { idle: "Digest channel", running: "Queuing…" }, refreshReport: { idle: "Refresh report", running: "Refreshing…" }, }; // Destructive actions pop a confirm() before running. Download/transcribe/ // refresh (and the non-destructive re-download, which overwrites in place) stay // unguarded. const CONFIRM: Partial string>> = { clearIncomplete: (slug) => `Delete the truncated audio + transcript for every flagged video in "${slug}" and enable the auto-download/transcribe runners? The current partial transcripts are removed and re-fetched. This cannot be undone.`, cleanTranscribedAudio: (slug) => `Delete redundant audio from every transcribed video in "${slug}"? Videos marked "do not clean" are skipped. This cannot be undone.`, cleanExtraFormats: (slug) => `Delete non-target audio formats from every video in "${slug}" (keeping the channel's configured format)? Videos marked "do not clean" are skipped. This cannot be undone.`, }; async function runAction(variant: Variant): Promise { if (variant.kind === "downloadMissing") { return downloadMissingAction(variant.slug); } if (variant.kind === "metadataScan") { return runMetadataScanAction(variant.slug); } if (variant.kind === "transcribeMissing") { return transcribeMissingAction( variant.slug, undefined, undefined, variant.audioFormat, ); } if (variant.kind === "redownloadIncomplete") { return redownloadIncompleteBucketAction(variant.slug); } if (variant.kind === "redownloadShortAudio") { return redownloadShortAudioBucketAction(variant.slug); } if (variant.kind === "cleanTranscribedAudio") { return cleanAudioAction(variant.slug); } if (variant.kind === "cleanExtraFormats") { return cleanExtraAudioFormatsAction(variant.slug); } if (variant.kind === "backfillChannel") { return backfillChannelAction(variant.slug); } if (variant.kind === "digestChannel") { return digestChannelAction(variant.slug, "local"); } // refreshReport: returns an ActionResult, not a StreamActionResult — adapt. const result = await refreshChannelSnapshotAction(variant.slug); if (result && "error" in result) { return { ok: false, error: result.error }; } // Still queued after the action's bounded wait: a neutral line, not a red one. if (result && "notice" in result) { return { ok: false, error: result.notice, info: true }; } // The report is on disk (the action waited for its job), so synthesize an // already-complete result. return { ok: true, jobId: "", stream: new ReadableStream(), done: Promise.resolve({ status: "done", jobId: "" } as const), }; } export function InlineActionButton({ variant, // The channel page's single "next action" control asks for `brand`. Brand // appears EXACTLY ONCE per page — that restraint is what pays for the channel // line's boldness — so this is a prop rather than a new default. tone = "default", label, }: { variant: Variant; tone?: "default" | "brand"; // Overrides the idle label (and with it the accessible name) for an action // whose real name is resolved on the server — the derived-data lane, whose // name comes from the operations that are actually enabled. label?: string; }) { const [status, setStatus] = useState({ kind: "idle" }); const [pending, startTransition] = useTransition(); const base = LABEL[variant.kind]; const labels = label ? { ...base, idle: label } : base; const slug = variant.slug; const ariaLabel = `${labels.idle.toLowerCase()} ${slug}`; function handleClick() { const confirmMessage = CONFIRM[variant.kind]?.(slug); if (confirmMessage && !window.confirm(confirmMessage)) return; setStatus({ kind: "running" }); startTransition(async () => { try { // clearIncomplete is a synchronous fs op returning a per-id summary, not // a queued job — show the cleared count rather than a job id. if (variant.kind === "clearIncomplete") { const summary = await clearIncompleteTranscriptsAction(slug); setStatus({ kind: "done", message: `cleared ${summary.succeeded}/${summary.attempted}`, }); return; } const result = await runAction(variant); if (!result.ok) { setStatus({ kind: "error", message: result.error }); return; } if (variant.kind === "refreshReport") { setStatus({ kind: "done" }); return; } // Fire-and-forget: cancel the stream so we don't hold the page open, // the job continues running and can be followed in /jobs. void result.stream.cancel(); setStatus({ kind: "queued", jobId: result.jobId }); } catch (e) { setStatus({ kind: "error", message: (e as Error).message }); } }); } const running = status.kind === "running" || pending; return ( {status.kind === "queued" && ( job {status.jobId} )} {status.kind === "done" && ( {status.message ?? "done"} )} {status.kind === "error" && ( {status.message} )} ); }