commit 2eeb674586b528135b7674888f973c0e56bcf4fb parent 6f2d48322fbb80c12c42cdd03040f6784a49d62c Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st> Date: Thu, 24 Sep 2026 19:28:40 -0400 video page: one module per chore card VideoPanel.tsx (1,839 lines) becomes the assembly (455): the card order and the gating booleans, unchanged. Each chore card's body moves verbatim, with its state and its server-action imports, to components/cards/<Card>.tsx; PersistedSourceVideo stays with SourceVideoSection and DeleteFileButton with FilesList (each one's only caller). The file-name helpers and the VideoFile type go to cards/videoFiles.ts, Heading (used by both per-file rows) to cards/Heading.tsx; VideoNavStrip and PipelineStatusStrip, which are not cards, sit beside VideoPanel. VideoPanel re-exports VideoFile and FetchedWindow for the two pages. videoActions.ts is untouched. lib/videoChoreCards.ts hand-lists the fifteen chore cards (id, component, when shown, why it is a chore and not an operation) beside the registry's videoOperationPanels.ts, the video page's twin of the channel page's bookends; its test pins VideoPanel's render order to the list. Labels: every aria-label / label / id / role literal extracted from the old file and from the new tree, sorted, is identical (md5 3f89e5c1). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Diffstat:
22 files changed, 1752 insertions(+), 1424 deletions(-)
diff --git a/editor/app/channels/[slug]/videos/[id]/components/PipelineStatusStrip.tsx b/editor/app/channels/[slug]/videos/[id]/components/PipelineStatusStrip.tsx @@ -0,0 +1,51 @@ +"use client"; + +export function PipelineStatusStrip({ + downloaded, + transcribed, + downloadFailed, +}: { + downloaded: boolean; + transcribed: boolean; + downloadFailed: boolean; +}) { + return ( + <div + role="status" + aria-label="pipeline status" + className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground" + > + <StatusDot + on={downloaded} + label="Downloaded" + tone={downloadFailed && !downloaded ? "danger" : undefined} + /> + <StatusDot on={transcribed} label="Transcribed" /> + </div> + ); +} + +function StatusDot({ + on, + label, + tone, +}: { + on: boolean; + label: string; + tone?: "danger"; +}) { + const color = on + ? "bg-success" + : tone === "danger" + ? "bg-destructive" + : "bg-muted"; + return ( + <span className="inline-flex items-center gap-1.5"> + <span + aria-hidden="true" + className={`inline-block w-2 h-2 rounded-full ${color}`} + /> + <span>{label}</span> + </span> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoNavStrip.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoNavStrip.tsx @@ -0,0 +1,60 @@ +"use client"; + +import Link from "next/link"; + +export function VideoNavStrip({ + prevHref, + nextHref, + position, + videoId, +}: { + prevHref?: string; + nextHref?: string; + position?: { index: number; total: number }; + // The umtool front door, when UMTOOL_URL is set on the editor. One link: + // "open in umtool" scaffolds a report video from this exact video. + umtoolUrl?: string | null; + videoId: string; +}) { + return ( + <div className="flex items-center justify-between gap-3 text-sm"> + {prevHref ? ( + <Link + href={prevHref} + aria-label="previous video" + className="px-2 py-1 rounded border border-border hover:bg-muted" + > + ◀ prev + </Link> + ) : ( + <span + aria-hidden="true" + className="px-2 py-1 rounded border border-border text-muted-foreground" + > + ◀ prev + </span> + )} + <span className="font-mono text-xs text-muted-foreground truncate"> + {position + ? `${position.index + 1} / ${position.total} · ${videoId}` + : videoId} + </span> + {nextHref ? ( + <Link + href={nextHref} + aria-label="next video" + className="px-2 py-1 rounded border border-border hover:bg-muted" + > + next ▶ + </Link> + ) : ( + <span + aria-hidden="true" + className="px-2 py-1 rounded border border-border text-muted-foreground" + > + next ▶ + </span> + )} + </div> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx @@ -1,61 +1,52 @@ "use client"; -import Link from "next/link"; -import { useActionState, useState, useTransition } from "react"; -import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; -import { - AUDIO_FORMAT_VALUES, - type AudioFormat, - type ChannelHandling, +// THE ASSEMBLY, not the cards. Each chore card's body — its state, its server +// action, its copy — is its own module under ./cards/ (enumerated, with why each +// is a chore and not an operation, in ../lib/videoChoreCards.ts). What stays here +// is what only the page as a whole knows: the ORDER of the cards and the gating +// booleans derived from the file list, the download outcome and the coverage. +// The registry-driven operation panels (digest, diarization, attribution) render +// after this component, in page.tsx. + +import type { + AudioFormat, + ChannelHandling, } from "yt-dlp-transcript-common/lib/channelConfig"; import type { DownloadOutcomeRecord } from "yt-dlp-transcript-common/lib/downloadOutcome"; import type { SavedVideoPointer } from "yt-dlp-transcript-common/lib/savedVideo"; -import type { ClipProvenance } from "yt-dlp-transcript-common/lib/clipWindow"; import type { AvailabilityHistoryEntry } from "yt-dlp-transcript-common/lib/availability"; import type { SubtitleProvenance } from "yt-dlp-transcript-common/lib/subtitleProvenance"; -import { formatBytes, formatDuration } from "yt-dlp-transcript-common/lib/format"; -import { - AUDIO_EXTS, - VIDEO_CONTAINER_EXTS, -} from "yt-dlp-transcript-common/lib/mediaFiles"; -import { QueueControl } from "../../../../../components/QueueControl"; -import { cancelJobAction } from "../../../../../jobs/actions"; +import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import { PipelineStageCard } from "../../../components/PipelineStageCard"; -import { MediaPlayer } from "./MediaPlayer"; -import { TextFilePreview } from "./TextFilePreview"; -import { - deleteVideoDirAction, - deleteVideoFileAction, - downloadVideoPipelineAction, - markVideoUntranscribableAction, - setPrimaryTranscriptAction, - redownloadIncompleteTranscriptAction, - redownloadToArchiveAction, - toggleDoNotCleanAction, - toggleExcludeTruncatedCheckAction, - transcodeAudioAction, - transcribeOneAction, - unpersistVideoAction, - whisperVideoAction, - type DeleteDirActionResult, -} from "../videoActions"; +import { VideoNavStrip } from "./VideoNavStrip"; +import { PipelineStatusStrip } from "./PipelineStatusStrip"; +import { AvailabilityHistoryList } from "./cards/AvailabilityHistoryList"; +import { DeleteVideoDirSection } from "./cards/DeleteVideoDirSection"; +import { DoNotCleanSection } from "./cards/DoNotCleanSection"; +import { DownloadOutcomeBadge } from "./cards/DownloadOutcomeBadge"; +import { ExcludeTruncatedCheckSection } from "./cards/ExcludeTruncatedCheckSection"; +import { FetchedWindows, type FetchedWindow } from "./cards/FetchedWindows"; +import { FilesList } from "./cards/FilesList"; +import { IncompleteTranscriptBanner } from "./cards/IncompleteTranscriptBanner"; +import { MarkUntranscribableSection } from "./cards/MarkUntranscribableSection"; +import { PerFileTranscodeRow } from "./cards/PerFileTranscodeRow"; +import { PerFileTranscribeRow } from "./cards/PerFileTranscribeRow"; +import { RedownloadSection } from "./cards/RedownloadSection"; +import { ShortAudioBanner } from "./cards/ShortAudioBanner"; +import { SourceVideoSection } from "./cards/SourceVideoSection"; +import { TranscriptSourceSection } from "./cards/TranscriptSourceSection"; import { - DOWNLOAD_FORMAT_LABELS, - DOWNLOAD_FORMAT_PRESETS, -} from "yt-dlp-transcript-common/ytdlp/downloadFormat"; - -export type VideoFile = { - name: string; - size: number; - mtime: number; -}; - -const WHISPER_FILENAME = "transcript.json"; -const CANONICAL_VTT = "transcript.en.vtt"; - -function isTranscriptVttName(name: string): boolean { - return /^transcript\.[^.]+\.vtt$/.test(name); -} + CANONICAL_VTT, + WHISPER_FILENAME, + isAudioFile, + isTranscodeSource, + isTranscriptVttName, + type VideoFile, +} from "./cards/videoFiles"; + +// Both pages import these types from here; they are defined beside the cards +// that use them. +export type { FetchedWindow, VideoFile }; type Props = { slug: string; @@ -102,52 +93,6 @@ type Props = { umtoolUrl?: string | null; }; -// One list, shared with the detection predicates in lib/mediaFiles.ts — these -// were duplicated here and drifting. isAudioFile deliberately stays on -// AUDIO_EXTS alone: audio.mp4 is a wrong-format cleanup target, not something -// this panel offers a transcribe button for (editor/e2e/video-page.spec.ts:426). -const AUDIO_EXT_SET = new Set(AUDIO_EXTS); -const VIDEO_EXT_SET = new Set(VIDEO_CONTAINER_EXTS); - -const TEXT_EXTS = new Set(["vtt", "json", "txt", "srt", "log"]); - -function fileExt(name: string): string { - const dot = name.lastIndexOf("."); - return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ""; -} - -function isAudioFile(name: string): boolean { - return AUDIO_EXT_SET.has(fileExt(name)); -} - -// Files we can transcode FROM: any media file ffmpeg can demux to audio. -// Includes video containers so a kept source (e.g. audio.mp4 when -// keepSourceVideo=true) can be re-extracted to audio.<targetFormat> to -// overwrite a truncated or corrupt prior extraction. -function isTranscodeSource(name: string): boolean { - if (!name.startsWith("audio.")) return false; - if (name.startsWith("audio.tmp-")) return false; - const ext = fileExt(name); - return AUDIO_EXT_SET.has(ext) || VIDEO_EXT_SET.has(ext); -} - -function audioExt(name: string): string { - const dot = name.lastIndexOf("."); - return dot >= 0 ? name.slice(dot + 1) : ""; -} - -function mediaKind(name: string): "audio" | "video" | "text" | null { - const ext = fileExt(name); - if (AUDIO_EXT_SET.has(ext)) return "audio"; - if (VIDEO_EXT_SET.has(ext)) return "video"; - if (TEXT_EXTS.has(ext)) return "text"; - return null; -} - -function mediaUrl(slug: string, videoId: string, filename: string): string { - return `/api/channels/${encodeURIComponent(slug)}/videos/${encodeURIComponent(videoId)}/files/${encodeURIComponent(filename)}`; -} - export function VideoPanel({ slug, videoId, @@ -508,1332 +453,3 @@ export function VideoPanel({ </div> ); } - -function VideoNavStrip({ - prevHref, - nextHref, - position, - videoId, -}: { - prevHref?: string; - nextHref?: string; - position?: { index: number; total: number }; - // The umtool front door, when UMTOOL_URL is set on the editor. One link: - // "open in umtool" scaffolds a report video from this exact video. - umtoolUrl?: string | null; - videoId: string; -}) { - return ( - <div className="flex items-center justify-between gap-3 text-sm"> - {prevHref ? ( - <Link - href={prevHref} - aria-label="previous video" - className="px-2 py-1 rounded border border-border hover:bg-muted" - > - ◀ prev - </Link> - ) : ( - <span - aria-hidden="true" - className="px-2 py-1 rounded border border-border text-muted-foreground" - > - ◀ prev - </span> - )} - <span className="font-mono text-xs text-muted-foreground truncate"> - {position - ? `${position.index + 1} / ${position.total} · ${videoId}` - : videoId} - </span> - {nextHref ? ( - <Link - href={nextHref} - aria-label="next video" - className="px-2 py-1 rounded border border-border hover:bg-muted" - > - next ▶ - </Link> - ) : ( - <span - aria-hidden="true" - className="px-2 py-1 rounded border border-border text-muted-foreground" - > - next ▶ - </span> - )} - </div> - ); -} - -function PipelineStatusStrip({ - downloaded, - transcribed, - downloadFailed, -}: { - downloaded: boolean; - transcribed: boolean; - downloadFailed: boolean; -}) { - return ( - <div - role="status" - aria-label="pipeline status" - className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground" - > - <StatusDot - on={downloaded} - label="Downloaded" - tone={downloadFailed && !downloaded ? "danger" : undefined} - /> - <StatusDot on={transcribed} label="Transcribed" /> - </div> - ); -} - -function StatusDot({ - on, - label, - tone, -}: { - on: boolean; - label: string; - tone?: "danger"; -}) { - const color = on - ? "bg-success" - : tone === "danger" - ? "bg-destructive" - : "bg-muted"; - return ( - <span className="inline-flex items-center gap-1.5"> - <span - aria-hidden="true" - className={`inline-block w-2 h-2 rounded-full ${color}`} - /> - <span>{label}</span> - </span> - ); -} - -type DownloadMode = "pipeline" | "whisper"; - -function RedownloadSection({ - slug, - videoId, - handling, - defaultQueueKey, - existingQueues, - noAudio, -}: { - slug: string; - videoId: string; - handling: ChannelHandling; - defaultQueueKey: string; - existingQueues: string[]; - noAudio: boolean; -}) { - const [mode, setMode] = useState<DownloadMode>("pipeline"); - const [queueKey, setQueueKey] = useState(defaultQueueKey); - // "" = inherit the channel/global default; otherwise a DownloadFormatPreset. - const [downloadFormat, setDownloadFormat] = useState(""); - - const desc = (() => { - if (mode === "whisper") { - return noAudio - ? "Download the audio and run whisper-cli, in one job. Skips any VTT attempt." - : "Run whisper-cli on this video's audio. Skips the download phase since audio is already on disk."; - } - if (handling === "youtube") { - return noAudio - ? "Try the VTT download first; fall back to audio + whisper if no subs are available." - : "Re-run the pipeline: try VTT, then fall back to audio + whisper if needed."; - } - return noAudio - ? "Download audio (and run any channel-configured audio checks)." - : "Re-run the download pipeline for this video."; - })(); - - const verb = mode === "whisper" - ? "Audio + Whisper" - : noAudio - ? "Run download pipeline" - : "Re-run download pipeline"; - const runningLabel = mode === "whisper" - ? "Transcribing…" - : "Running pipeline…"; - const actionLabel = `${verb} for ${videoId}`; - const trigger = mode === "whisper" - ? () => whisperVideoAction(slug, videoId, queueKey) - : () => - downloadVideoPipelineAction( - slug, - videoId, - queueKey, - downloadFormat || undefined, - ); - - return ( - <div className="flex flex-col gap-3"> - <p className="text-sm text-muted-foreground">{desc}</p> - <label className="flex items-center gap-2 text-sm"> - Mode - <select - value={mode} - onChange={(e) => setMode(e.target.value as DownloadMode)} - aria-label="download mode" - className="rounded border border-border bg-card px-2 py-1 text-sm" - > - <option value="pipeline">Designated pipeline</option> - <option value="whisper">Audio + Whisper (skip pipeline)</option> - </select> - </label> - {mode === "pipeline" && ( - <label className="flex items-center gap-2 text-sm"> - Format - <select - value={downloadFormat} - onChange={(e) => setDownloadFormat(e.target.value)} - aria-label="download format" - className="rounded border border-border bg-card px-2 py-1 text-sm" - > - <option value="">Inherit (channel / global)</option> - {DOWNLOAD_FORMAT_PRESETS.map((p) => ( - <option key={p} value={p}> - {DOWNLOAD_FORMAT_LABELS[p]} - </option> - ))} - </select> - </label> - )} - <StreamActionLog - trigger={trigger} - cancelAction={cancelJobAction} - buttonLabel={verb} - runningLabel={runningLabel} - label={actionLabel} - extraControls={ - <QueueControl - value={queueKey} - onChange={setQueueKey} - defaultQueueKey={defaultQueueKey} - existingQueues={existingQueues} - actionLabel={actionLabel} - /> - } - /> - </div> - ); -} - -function PerFileTranscribeRow({ - slug, - videoId, - file, - existingQueues, - hasTranscript, - replaceAutoSubs = false, -}: { - slug: string; - videoId: string; - file: VideoFile; - existingQueues: string[]; - hasTranscript: boolean; - // The existing transcript is YouTube ASR: transcribing is offered (and - // labelled as a replacement) rather than blocked. - replaceAutoSubs?: boolean; -}) { - const [transcribeQueue, setTranscribeQueue] = useState(""); - // `hasTranscript` is SERVER-derived (VideoPanel :360) and this row's own run - // is what writes transcript.json — so the run's own router.refresh() used to - // swap this panel for the "already present" line and take the log with it. - // Same shape as the two banners: the row survives (it is keyed by file name), - // the message and the run panel are separate slots, and the panel stays - // mounted — disabled, since the transcript now exists — once a run has - // started here. With no run it is still absent whenever a transcript is - // already present, so the DOM is unchanged. - const [ranHere, setRanHere] = useState(false); - - return ( - <div className="flex flex-col gap-2 rounded border border-border p-3"> - <Heading - title={ - replaceAutoSubs - ? `Replace auto-captions with an AI transcript (${file.name})` - : `Transcribe ${file.name}` - } - desc={ - hasTranscript - ? "A transcript already exists for this video. Delete transcript.json (or transcript.en.vtt) on disk to re-run." - : replaceAutoSubs - ? "This video's only transcript is YouTube's speech recognition. Run whisper-cli on this audio file to write a transcript.json, which takes precedence everywhere. The auto-caption VTT is kept on disk as a backup." - : "Run whisper-cli on this audio file. Writes transcript.json next to it." - } - /> - {hasTranscript ? ( - <p className="text-sm text-muted-foreground"> - Transcribe disabled — transcript already present. - </p> - ) : null} - {hasTranscript && !ranHere ? null : ( - <StreamActionLog - key="transcribe-run" - disabled={hasTranscript} - trigger={() => { - setRanHere(true); - return transcribeOneAction( - slug, - videoId, - file.name, - transcribeQueue, - ); - }} - cancelAction={cancelJobAction} - buttonLabel={ - replaceAutoSubs - ? `Replace auto-captions (${file.name})` - : `Transcribe ${file.name}` - } - runningLabel="Transcribing…" - label={`Transcribe ${file.name}`} - extraControls={ - <QueueControl - value={transcribeQueue} - onChange={setTranscribeQueue} - defaultQueueKey="" - existingQueues={existingQueues} - actionLabel={`Transcribe ${file.name}`} - /> - } - /> - )} - </div> - ); -} - -function PerFileTranscodeRow({ - slug, - videoId, - file, - existingQueues, - channelAudioFormat, -}: { - slug: string; - videoId: string; - file: VideoFile; - existingQueues: string[]; - channelAudioFormat?: AudioFormat; -}) { - const ext = audioExt(file.name); - const transcodeTargets = AUDIO_FORMAT_VALUES.filter((f) => f !== ext); - const preferredTarget = - channelAudioFormat && transcodeTargets.includes(channelAudioFormat) - ? channelAudioFormat - : transcodeTargets[0] ?? "opus"; - const [target, setTarget] = useState<AudioFormat>(preferredTarget); - const [transcodeQueue, setTranscodeQueue] = useState(""); - - if (transcodeTargets.length === 0) return null; - return ( - <div className="flex flex-col gap-2 rounded border border-border p-3"> - <Heading - title={`Transcode ${file.name}`} - desc="Re-encode this audio file with ffmpeg. The source file is kept; the output is named audio.<format>." - /> - <label className="flex items-center gap-2 text-sm"> - Target format - <select - value={target} - onChange={(e) => setTarget(e.target.value as AudioFormat)} - aria-label={`transcode target for ${file.name}`} - className="rounded border border-border bg-card px-2 py-1 text-sm" - > - {transcodeTargets.map((t) => ( - <option key={t} value={t}> - {t} - </option> - ))} - </select> - </label> - <StreamActionLog - trigger={() => - transcodeAudioAction(slug, videoId, file.name, target, transcodeQueue) - } - cancelAction={cancelJobAction} - buttonLabel={`Transcode ${file.name} → ${target}`} - runningLabel="Transcoding…" - label={`Transcode ${file.name} to ${target}`} - extraControls={ - <QueueControl - value={transcodeQueue} - onChange={setTranscodeQueue} - defaultQueueKey="" - existingQueues={existingQueues} - actionLabel={`Transcode ${file.name} to ${target}`} - /> - } - /> - </div> - ); -} - -function FilesList({ - slug, - videoId, - files, -}: { - slug: string; - videoId: string; - files: VideoFile[]; -}) { - if (files.length === 0) { - return <p className="text-sm text-muted-foreground">Directory is empty.</p>; - } - return ( - <ul - aria-label={`files for ${videoId}`} - className="flex flex-col gap-3" - > - {files.map((f) => { - const kind = mediaKind(f.name); - return ( - <li - key={f.name} - className="border border-border rounded-md p-3 flex flex-col gap-2" - > - <div className="flex items-baseline justify-between gap-3 flex-wrap"> - <span - className="font-mono text-sm" - aria-label={`file name ${f.name}`} - > - {f.name} - </span> - <span className="text-xs text-muted-foreground"> - {formatBytes(f.size)} · {new Date(f.mtime).toLocaleString()} - </span> - </div> - {kind === "audio" || kind === "video" ? ( - <MediaPlayer - src={mediaUrl(slug, videoId, f.name)} - kind={kind} - filename={f.name} - /> - ) : kind === "text" ? ( - <TextFilePreview - src={mediaUrl(slug, videoId, f.name)} - filename={f.name} - /> - ) : null} - <DeleteFileButton slug={slug} videoId={videoId} filename={f.name} /> - </li> - ); - })} - </ul> - ); -} - -const PROVENANCE_LABEL: Record<SubtitleProvenance, string> = { - asr: "YouTube auto-captions", - manual: "manual captions", - unknown: "unknown source", -}; - -function TranscriptSourceSection({ - slug, - videoId, - vttTracks, - primaryVtt, - hasWhisper, - vttProvenance, -}: { - slug: string; - videoId: string; - vttTracks: string[]; - primaryVtt: string | null; - hasWhisper: boolean; - vttProvenance: Record<string, SubtitleProvenance>; -}) { - const [pending, startTransition] = useTransition(); - const [busyFile, setBusyFile] = useState<string | null>(null); - const [error, setError] = useState<string | null>(null); - - const setPrimary = (filename: string) => { - setError(null); - setBusyFile(filename); - startTransition(async () => { - const res = await setPrimaryTranscriptAction(slug, videoId, filename); - if (!res.ok) setError(res.error); - setBusyFile(null); - }); - }; - - return ( - <div className="flex flex-col gap-3"> - <p className="text-sm text-muted-foreground"> - Pick which subtitle track is this video's primary transcript. The - chosen track is copied to <code>{CANONICAL_VTT}</code> — the canonical - name the index and viewer read. Reversible: delete{" "} - <code>{CANONICAL_VTT}</code> in Files to fall back to the automatic - English pick, or choose another track to switch. - </p> - {hasWhisper && ( - <p - className="text-sm text-warning" - aria-label="whisper precedence note" - > - A whisper transcript (<code>{WHISPER_FILENAME}</code>) is present and - takes precedence over any VTT when indexing. - </p> - )} - <ul aria-label="transcript tracks" className="flex flex-col gap-2"> - {vttTracks.map((name) => { - const isPrimary = name === primaryVtt; - return ( - <li - key={name} - aria-label={`transcript track ${name}`} - className="flex items-center justify-between gap-3 rounded border border-border px-3 py-2" - > - <span className="flex items-center gap-2 font-mono text-sm"> - {name} - {vttProvenance[name] && ( - <span - aria-label={`transcript provenance ${name}`} - className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-sans font-medium uppercase tracking-wide text-muted-foreground" - > - {PROVENANCE_LABEL[vttProvenance[name]]} - </span> - )} - {isPrimary && ( - <span - aria-label={`primary transcript ${name}`} - className="rounded bg-success-soft text-success px-1.5 py-0.5 text-[10px] font-sans font-medium uppercase tracking-wide" - > - Primary - </span> - )} - </span> - {!isPrimary && ( - <button - type="button" - disabled={pending} - aria-label={`set ${name} as primary transcript`} - onClick={() => setPrimary(name)} - className="shrink-0 px-2 py-1 rounded border border-border text-xs hover:bg-muted disabled:opacity-50" - > - {pending && busyFile === name - ? "Setting…" - : "Set as transcript"} - </button> - )} - </li> - ); - })} - </ul> - {error && ( - <span - className="text-sm text-destructive" - aria-label="set primary transcript error" - > - {error} - </span> - )} - </div> - ); -} - -function MarkUntranscribableSection({ - slug, - videoId, -}: { - slug: string; - videoId: string; -}) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<string | null>(null); - return ( - <div className="flex flex-col gap-2"> - <p className="text-sm text-muted-foreground"> - Writes an empty transcript.json so the pipeline stops retrying. Removes - this video from the failed list. Reversible — just delete - transcript.json. - </p> - <button - type="button" - disabled={pending} - aria-label={`mark video ${videoId} untranscribable`} - onClick={() => { - setError(null); - startTransition(async () => { - const res = await markVideoUntranscribableAction(slug, videoId); - if (!res.ok) setError(res.error); - }); - }} - className="self-start px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" - > - {pending ? "Marking…" : "Mark untranscribable"} - </button> - {error && ( - <span - className="text-sm text-destructive" - aria-label="mark untranscribable error" - > - {error} - </span> - )} - </div> - ); -} - -function SourceVideoSection({ - slug, - videoId, - handling, - defaultQueueKey, - existingQueues, - savedVideo, -}: { - slug: string; - videoId: string; - handling: ChannelHandling; - defaultQueueKey: string; - existingQueues: string[]; - savedVideo: SavedVideoPointer | null; -}) { - const [queueKey, setQueueKey] = useState(defaultQueueKey); - // Set the moment a persist run STARTS from this surface. That run's own - // router.refresh() lands the saved-video pointer, and the persisted view used - // to REPLACE this whole subtree — destroying the StreamActionLog below and the - // log the operator was reading (plans/FACTS.md, "A run log lives in the - // panel's React state"). One return, two fixed slots, and the run panel stays - // mounted once it has been used; with no run it is still absent whenever the - // video is already persisted, so the DOM is unchanged. - const [ranHere, setRanHere] = useState(false); - - if (handling !== "transcribe") { - return ( - <p className="text-sm text-muted-foreground"> - Source-video persistence applies to transcribe-handling channels only. - </p> - ); - } - - return ( - <div className="flex flex-col gap-3"> - {savedVideo ? ( - <PersistedSourceVideo - slug={slug} - videoId={videoId} - savedVideo={savedVideo} - /> - ) : ( - <p className="text-sm text-muted-foreground"> - Re-fetch this video's full source container and move it into the - saved-video store, without disturbing the existing transcript. Use - this to archive a video that was downloaded audio-only. (Videos in a - channel's keep-latest window persist automatically on download.) - </p> - )} - {savedVideo && !ranHere ? null : ( - <StreamActionLog - key="persist-run" - // Mounted after a run so its log survives, but NOT actionable once the - // pointer is on disk: re-running would re-fetch a container the store - // already has. - disabled={!!savedVideo} - trigger={() => { - setRanHere(true); - return redownloadToArchiveAction(slug, videoId, queueKey); - }} - cancelAction={cancelJobAction} - buttonLabel="Persist source video" - runningLabel="Archiving…" - label={`Persist source video for ${videoId}`} - extraControls={ - <QueueControl - value={queueKey} - onChange={setQueueKey} - defaultQueueKey={defaultQueueKey} - existingQueues={existingQueues} - actionLabel={`Persist source video for ${videoId}`} - /> - } - /> - )} - </div> - ); -} - -// A window of source media this editor fetched because another tool asked. -export type FetchedWindow = { - file: string; - from: number; - to: number; - bytes: number; - provenance: ClipProvenance | null; -}; - -// WHO ASKED, FOR WHAT, AND WHY — the three things a `clips/` directory has to -// be able to answer months later, when the only other record (the job log) has -// been pruned. Modelled on PersistedSourceVideo below, which answers the same -// question for a whole container. -function FetchedWindows({ windows }: { windows: FetchedWindow[] }) { - return ( - <div className="flex flex-col gap-3"> - <p className="text-sm text-muted-foreground"> - Seconds of this video's source media, fetched through the managed - download path because a tool asked for them, and kept in{" "} - <code>clips/</code> so the next tool reuses them instead of paying for - the same bytes again. - </p> - <ul className="flex flex-col gap-2"> - {windows.map((w) => { - const p = w.provenance; - const what = [p?.manifest, p?.clipId].filter(Boolean).join("/"); - return ( - <li - key={w.file} - className="flex flex-col gap-0.5 text-sm border-t border-border pt-2 first:border-t-0 first:pt-0" - > - <span className="font-mono tabular-nums"> - {w.from.toFixed(2)}–{w.to.toFixed(2)}{" "} - <span className="text-muted-foreground"> - ({formatDuration(w.to - w.from)}) · {formatBytes(w.bytes)} - </span> - </span> - <span className="text-xs text-muted-foreground"> - {p ? ( - <> - requested by {p.requestedBy} - {what ? ` for ${what}` : ""} - {p.reason ? ` · \u201c${p.reason}\u201d` : ""} - {p.fetchedAt - ? ` · ${new Date(p.fetchedAt).toLocaleString()}` - : ""} - </> - ) : ( - "no provenance sidecar — requester unknown" - )} - </span> - </li> - ); - })} - </ul> - {/* A WINDOW IS NOT A DOWNLOAD. Thirty seconds of a stream is not the - video, and nothing downstream treats it as one: the fetch writes no - metadata.info.json, so this video stays exactly as undownloaded (and - as absent from the index) as it was. */} - <p className="text-xs text-muted-foreground"> - Fetched windows do not make this video downloaded: they carry no - metadata, so the video's pipeline state and its place in the index - are unchanged. - </p> - {/* WHAT THEY COST, AND THAT NOTHING RECLAIMS IT. The retention sweep is - pointer-driven and never sees a window; the cleanup lanes are about - `audio.*`. The only thing that removes one is Evict fetched windows on - /storage, and it evicts BY AGE — which surfaces have to say, because a - window a report still cites looks identical to one nothing wants. */} - <p aria-label="clip windows total" className="text-xs text-muted-foreground"> - {formatBytes(windows.reduce((n, w) => n + w.bytes, 0))} in total, - counted in this channel's media size. Nothing prunes a window on - its own — Storage evicts them by age. - </p> - </div> - ); -} - -// The persisted-details view: a sibling of the run panel above, never a -// replacement for it. -function PersistedSourceVideo({ - slug, - videoId, - savedVideo, -}: { - slug: string; - videoId: string; - savedVideo: SavedVideoPointer; -}) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<string | null>(null); - return ( - <> - <p className="text-sm text-muted-foreground"> - The source container for this video is persisted to the saved-video - store, so the Clean-audio sweep can reclaim its <code>audio.*</code>{" "} - without losing the original. Transcription falls back to it when no - audio is on disk. - </p> - <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm"> - <dt className="text-muted-foreground">File</dt> - <dd className="font-mono break-all">{savedVideo.file}</dd> - <dt className="text-muted-foreground">Size</dt> - <dd className="tabular-nums">{formatBytes(savedVideo.bytes)}</dd> - <dt className="text-muted-foreground">Stored</dt> - <dd>{new Date(savedVideo.storedAt).toLocaleString()}</dd> - {savedVideo.keepReason && ( - <> - <dt className="text-muted-foreground">Reason</dt> - <dd>{savedVideo.keepReason}</dd> - </> - )} - {/* WHO ASKED. `keepReason` cannot say it — it stays override/pin so the - retention prune leaves this container alone — so "why is this 4 GB - file here" needs its own line. */} - {savedVideo.origin && ( - <> - <dt className="text-muted-foreground">Requested by</dt> - <dd> - {savedVideo.origin.requestedBy} - {[savedVideo.origin.manifest, savedVideo.origin.clipId] - .filter(Boolean) - .join("/") - ? ` for ${[savedVideo.origin.manifest, savedVideo.origin.clipId] - .filter(Boolean) - .join("/")}` - : ""} - {savedVideo.origin.reason - ? ` \u2014 \u201c${savedVideo.origin.reason}\u201d` - : ""} - </dd> - </> - )} - {savedVideo.sha256 && ( - <> - <dt className="text-muted-foreground">sha256</dt> - <dd className="font-mono text-xs break-all"> - {savedVideo.sha256} - </dd> - </> - )} - <dt className="text-muted-foreground">Location</dt> - <dd className="font-mono text-xs break-all">{savedVideo.dir}</dd> - </dl> - <p className="text-xs text-muted-foreground"> - Unpersist moves the container back into this video's data dir - (where it appears under Files and plays inline) and removes the - pointer. - </p> - <button - type="button" - disabled={pending} - aria-label={`unpersist source video ${videoId}`} - onClick={() => { - setError(null); - startTransition(async () => { - const res = await unpersistVideoAction(slug, videoId); - if (!res.ok) setError(res.error); - }); - }} - className="self-start px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted disabled:opacity-50" - > - {pending ? "Restoring…" : "Unpersist (move back to data dir)"} - </button> - {error && ( - <span - className="text-sm text-destructive" - aria-label="unpersist error" - > - {error} - </span> - )} - </> - ); -} - -function DoNotCleanSection({ - slug, - videoId, - doNotClean, -}: { - slug: string; - videoId: string; - doNotClean: boolean; -}) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<string | null>(null); - const next = !doNotClean; - return ( - <div className="flex flex-col gap-2"> - <p className="text-sm text-muted-foreground"> - {doNotClean - ? "This video is marked “do not clean”. The transcribed-audio and extra-format cleanup jobs will skip it, so its media is preserved for archiving. Reversible — allow cleanup to remove the marker." - : "Mark this video “do not clean” so the cleanup jobs never delete its audio (e.g. a deleted or members-only upload you want to keep). Reversible at any time."} - </p> - <button - type="button" - disabled={pending} - aria-label={ - next - ? `mark video ${videoId} do not clean` - : `allow cleanup for video ${videoId}` - } - onClick={() => { - setError(null); - startTransition(async () => { - const res = await toggleDoNotCleanAction(slug, videoId, next); - if (!res.ok) setError(res.error); - }); - }} - className="self-start px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" - > - {pending - ? "Saving…" - : doNotClean - ? "Allow cleanup" - : "Mark do not clean"} - </button> - {error && ( - <span - className="text-sm text-destructive" - aria-label="do not clean error" - > - {error} - </span> - )} - </div> - ); -} - -function ExcludeTruncatedCheckSection({ - slug, - videoId, - excluded, -}: { - slug: string; - videoId: string; - excluded: boolean; -}) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<string | null>(null); - const next = !excluded; - return ( - <div className="flex flex-col gap-2"> - <p className="text-sm text-muted-foreground"> - {excluded - ? "This video is excluded from the truncated-transcript check. It won't be flagged as incomplete/short even when its transcript covers only part of the runtime. Reversible at any time." - : "Exclude this video from the truncated-transcript check — useful when the back half legitimately has no speech (so low coverage is expected, not a broken download). Reversible at any time."} - </p> - <button - type="button" - disabled={pending} - aria-label={ - next - ? `exclude video ${videoId} from truncated check` - : `include video ${videoId} in truncated check` - } - onClick={() => { - setError(null); - startTransition(async () => { - const res = await toggleExcludeTruncatedCheckAction( - slug, - videoId, - next, - ); - if (!res.ok) setError(res.error); - }); - }} - className="self-start px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" - > - {pending - ? "Saving…" - : excluded - ? "Re-enable check" - : "Exclude from check"} - </button> - {error && ( - <span - className="text-sm text-destructive" - aria-label="truncated check error" - > - {error} - </span> - )} - </div> - ); -} - -function DeleteFileButton({ - slug, - videoId, - filename, -}: { - slug: string; - videoId: string; - filename: string; -}) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<string | null>(null); - return ( - <div className="flex items-center gap-2"> - <button - type="button" - disabled={pending} - aria-label={`delete file ${filename}`} - onClick={() => { - if (!confirm(`Delete ${filename}?`)) return; - setError(null); - startTransition(async () => { - const res = await deleteVideoFileAction(slug, videoId, filename); - if (!res.ok) setError(res.error); - }); - }} - className="self-start px-2 py-1 rounded border border-destructive/30 text-destructive text-xs hover:bg-destructive-soft disabled:opacity-50" - > - {pending ? "Deleting…" : "Delete file"} - </button> - {error && ( - <span - className="text-xs text-destructive" - aria-label={`delete error ${filename}`} - > - {error} - </span> - )} - </div> - ); -} - -function DeleteVideoDirSection({ - slug, - videoId, -}: { - slug: string; - videoId: string; -}) { - const action = deleteVideoDirAction.bind(null, slug, videoId); - const [state, formAction] = useActionState<DeleteDirActionResult, FormData>( - action, - undefined, - ); - return ( - <div className="flex flex-col gap-3"> - <p className="text-sm text-muted-foreground"> - Removes data/{videoId}/ and all of its contents. Type the video id to - confirm. - </p> - <form - action={formAction} - aria-label={`delete video directory ${videoId}`} - className="flex flex-wrap items-start gap-2" - > - <input - name="confirmId" - required - placeholder={videoId} - aria-label="confirm video id" - className="rounded border border-border bg-card px-2 py-1 text-sm font-mono" - /> - <button - type="submit" - className="px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90" - > - Delete directory - </button> - {state?.error && ( - <p - className="basis-full text-sm text-destructive" - aria-label="delete directory error" - > - {state.error} - </p> - )} - </form> - </div> - ); -} - -function AvailabilityHistoryList({ - entries, -}: { - entries: AvailabilityHistoryEntry[]; -}) { - if (entries.length === 0) { - return ( - <p - aria-label="availability history empty" - className="text-sm text-muted-foreground" - > - No availability changes recorded. - </p> - ); - } - // Reverse-chronological (most recent first). - const ordered = [...entries].reverse(); - return ( - <ul - aria-label="availability history" - className="flex flex-col gap-1 text-sm" - > - {ordered.map((e, i) => ( - <li - key={`${e.observedAt}-${i}`} - aria-label={`availability history ${e.availability}`} - className="flex flex-wrap items-baseline gap-x-2" - > - <span className="text-muted-foreground tabular-nums"> - {new Date(e.observedAt).toLocaleString()} - </span> - <span className="font-medium text-foreground"> - {e.availability} - </span> - <span className="text-xs text-muted-foreground">({e.source})</span> - </li> - ))} - </ul> - ); -} - -function DownloadOutcomeBadge({ - outcome, -}: { - outcome: DownloadOutcomeRecord; -}) { - if (outcome.status === "ok") return null; - const tone = - outcome.status === "failed" - ? "border-destructive/30 bg-destructive-soft text-destructive" - : "border-warning/30 bg-warning-soft text-warning"; - const label = ((): string => { - switch (outcome.status) { - case "ok-with-cookies": - return "Retried with cookies"; - case "ok-auto-transcribed": - return "Auto-transcribed (no subs available)"; - case "failed": { - const lastAttempt = outcome.attempts[outcome.attempts.length - 1]; - const cls = lastAttempt?.availabilityClass; - return cls ? `Download failed: ${cls}` : "Download failed"; - } - case "failed-corrupt-source": - return "Corrupt source — download couldn't finish"; - case "corrupt-full-source": - return "Corrupt full source — download completed but audio is malformed (file kept)"; - case "failed-short-audio": - return "Truncated download — audio far shorter than the video (file kept)"; - case "skipped-filtered": { - const f = outcome.filter; - const why = f?.reason ? `: ${f.reason}` : ""; - // The outcome records what happened, not a verdict — whether this video - // is SETTLED is derived from the channel's metadata scan and its current - // patterns, and the Diagnostics bucket is where that answer lives. - return f?.name === "titleFilter" - ? `Filtered out by the download filter${why}` - : `Skipped by filter${why}`; - } - // NOT a download, and the badge has to say so plainly: this video's media - // was never fetched and never will be while the filter stands. What is - // here is its live chat, which is why the video is in the corpus at all. - case "chat-only": - return "Chat only (filtered livestream) — live chat kept, no media and no transcript"; - default: - return outcome.status; - } - })(); - const lastErr = - outcome.status === "failed" - ? outcome.attempts[outcome.attempts.length - 1]?.error - : undefined; - return ( - <div - role="status" - aria-label="download outcome" - className={`rounded border px-3 py-2 text-sm ${tone}`} - > - <div className="flex flex-wrap items-baseline justify-between gap-2"> - <span className="font-medium">{label}</span> - <span className="text-xs opacity-70"> - {outcome.attempts.length} attempt - {outcome.attempts.length === 1 ? "" : "s"} ·{" "} - {new Date(outcome.finishedAt).toLocaleString()} - </span> - </div> - {lastErr && ( - <details className="mt-1"> - <summary className="cursor-pointer text-xs opacity-80"> - error tail - </summary> - <pre className="mt-1 whitespace-pre-wrap text-xs font-mono opacity-90"> - {lastErr} - </pre> - </details> - )} - </div> - ); -} - -// `coverage` is null when the transcript is NOT flagged as truncated — the -// banner is always rendered and draws nothing in that case, rather than the -// parent branching on the condition. The run offered here is the one that -// CLEARS the flag, so a parent branch would unmount this component (and the -// run's log with it) on the router.refresh() StreamActionLog fires when the run -// ends. `ranHere` is what keeps it: once a run has started from this surface -// the banner stays on screen with its log, whatever the condition now says. -// See plans/FACTS.md, "A run log lives in the panel's React state". -function IncompleteTranscriptBanner({ - slug, - videoId, - coverage, - defaultQueueKey, - existingQueues, -}: { - slug: string; - videoId: string; - coverage: { - lastCueEnd: number; - duration: number; - coverage: number | null; - } | null; - defaultQueueKey: string; - existingQueues: string[]; -}) { - const [queueKey, setQueueKey] = useState(defaultQueueKey); - const [ranHere, setRanHere] = useState(false); - if (!coverage && !ranHere) return null; - const pct = - !coverage || coverage.coverage === null - ? null - : Math.round(coverage.coverage * 1000) / 10; - const actionLabel = `Re-download & re-transcribe ${videoId}`; - return ( - <div - // The alert role and its label belong to the WARNING, not to the box that - // outlives it: once the run has cleared the flag this wrapper holds only - // the log, and announcing that as an alert named "incomplete transcript" - // would be a lie to a screen reader (and would keep answering - // getByLabel("incomplete transcript") for a video that is now fine — - // truncated-check.spec.ts:69 asserts exactly that it does not). While the - // flag holds, both attributes are exactly as they were. - {...(coverage - ? { role: "alert", "aria-label": "incomplete transcript" } - : {})} - className="flex flex-col gap-3 rounded border px-3 py-2 text-sm border-warning/30 bg-warning-soft text-warning" - > - {coverage ? ( - <div className="flex flex-col gap-1"> - <span className="font-medium">Transcript looks truncated</span> - <span> - The transcript covers only {formatDuration(coverage.lastCueEnd)} of{" "} - {formatDuration(coverage.duration)} - {pct !== null ? ` (${pct}%)` : ""}. The audio download likely stopped - early. Re-download the audio and re-transcribe to fix it — this - deletes the truncated audio first so the full file is fetched. - </span> - </div> - ) : null} - <StreamActionLog - key="run" - // Kept for its log, not for a second run: the transcript is no longer - // truncated once the flag is gone. - disabled={!coverage} - trigger={() => { - setRanHere(true); - return redownloadIncompleteTranscriptAction(slug, videoId, queueKey); - }} - cancelAction={cancelJobAction} - buttonLabel="Re-download & re-transcribe" - runningLabel="Re-downloading…" - label={actionLabel} - extraControls={ - <QueueControl - value={queueKey} - onChange={setQueueKey} - defaultQueueKey={defaultQueueKey} - existingQueues={existingQueues} - actionLabel={actionLabel} - /> - } - /> - </div> - ); -} - -// The download completed but the audio was far shorter than the video — the -// source served a truncated stream (e.g. a CDN-truncated HLS rung). The stub is -// kept on disk; re-downloading with a different format (Original) fetches the -// full audio. Offers a one-click re-download forcing the Original format. -// `flagged` is the parent's condition, passed down rather than branched on for -// the same reason as IncompleteTranscriptBanner above: the re-download offered -// here is what clears the flag, and unmounting this component on the refresh -// that lands the new download outcome would take the run's log with it. -function ShortAudioBanner({ - slug, - videoId, - flagged, - shortAudio, - defaultQueueKey, - existingQueues, -}: { - slug: string; - videoId: string; - flagged: boolean; - shortAudio?: { - audioDurationSec: number; - expectedDurationSec: number; - coverage: number; - }; - defaultQueueKey: string; - existingQueues: string[]; -}) { - const [queueKey, setQueueKey] = useState(defaultQueueKey); - const [ranHere, setRanHere] = useState(false); - if (!flagged && !ranHere) return null; - const actionLabel = `Re-download ${videoId} as Original`; - return ( - <div - // Conditional for the same reason as IncompleteTranscriptBanner above: the - // alert belongs to the warning, and the warning is gone once the - // re-download has cleared the flag. - {...(flagged ? { role: "alert", "aria-label": "short audio" } : {})} - className="flex flex-col gap-3 rounded border px-3 py-2 text-sm border-warning/30 bg-warning-soft text-warning" - > - {flagged ? ( - <div className="flex flex-col gap-1"> - <span className="font-medium"> - Download was truncated at the source - </span> - <span> - {shortAudio - ? `The downloaded audio is only ${formatDuration(shortAudio.audioDurationSec)} of ${formatDuration(shortAudio.expectedDurationSec)} (${Math.round(shortAudio.coverage * 100)}%). ` - : "The downloaded audio is far shorter than the video. "} - The source served a truncated stream for the selected format, so it - was flagged before transcription (the file is kept, not - transcribed). Re-download with the <strong>Original</strong> format - to fetch the full audio. - </span> - </div> - ) : null} - <StreamActionLog - key="run" - // Kept for its log, not for a second run: the download that cleared the - // flag is the one whose output this is. - disabled={!flagged} - trigger={() => { - setRanHere(true); - return downloadVideoPipelineAction(slug, videoId, queueKey, "original"); - }} - cancelAction={cancelJobAction} - buttonLabel="Re-download as Original" - runningLabel="Re-downloading…" - label={actionLabel} - extraControls={ - <QueueControl - value={queueKey} - onChange={setQueueKey} - defaultQueueKey={defaultQueueKey} - existingQueues={existingQueues} - actionLabel={actionLabel} - /> - } - /> - </div> - ); -} - -function Heading({ title, desc }: { title: string; desc: string }) { - return ( - <div> - <h3 className="text-base font-semibold">{title}</h3> - <p className="text-sm text-muted-foreground">{desc}</p> - </div> - ); -} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/AvailabilityHistoryList.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/AvailabilityHistoryList.tsx @@ -0,0 +1,44 @@ +"use client"; + +import type { AvailabilityHistoryEntry } from "yt-dlp-transcript-common/lib/availability"; + +export function AvailabilityHistoryList({ + entries, +}: { + entries: AvailabilityHistoryEntry[]; +}) { + if (entries.length === 0) { + return ( + <p + aria-label="availability history empty" + className="text-sm text-muted-foreground" + > + No availability changes recorded. + </p> + ); + } + // Reverse-chronological (most recent first). + const ordered = [...entries].reverse(); + return ( + <ul + aria-label="availability history" + className="flex flex-col gap-1 text-sm" + > + {ordered.map((e, i) => ( + <li + key={`${e.observedAt}-${i}`} + aria-label={`availability history ${e.availability}`} + className="flex flex-wrap items-baseline gap-x-2" + > + <span className="text-muted-foreground tabular-nums"> + {new Date(e.observedAt).toLocaleString()} + </span> + <span className="font-medium text-foreground"> + {e.availability} + </span> + <span className="text-xs text-muted-foreground">({e.source})</span> + </li> + ))} + </ul> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/DeleteVideoDirSection.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/DeleteVideoDirSection.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useActionState } from "react"; +import { + deleteVideoDirAction, + type DeleteDirActionResult, +} from "../../videoActions"; + +export function DeleteVideoDirSection({ + slug, + videoId, +}: { + slug: string; + videoId: string; +}) { + const action = deleteVideoDirAction.bind(null, slug, videoId); + const [state, formAction] = useActionState<DeleteDirActionResult, FormData>( + action, + undefined, + ); + return ( + <div className="flex flex-col gap-3"> + <p className="text-sm text-muted-foreground"> + Removes data/{videoId}/ and all of its contents. Type the video id to + confirm. + </p> + <form + action={formAction} + aria-label={`delete video directory ${videoId}`} + className="flex flex-wrap items-start gap-2" + > + <input + name="confirmId" + required + placeholder={videoId} + aria-label="confirm video id" + className="rounded border border-border bg-card px-2 py-1 text-sm font-mono" + /> + <button + type="submit" + className="px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90" + > + Delete directory + </button> + {state?.error && ( + <p + className="basis-full text-sm text-destructive" + aria-label="delete directory error" + > + {state.error} + </p> + )} + </form> + </div> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/DoNotCleanSection.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/DoNotCleanSection.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { + toggleDoNotCleanAction, +} from "../../videoActions"; + +export function DoNotCleanSection({ + slug, + videoId, + doNotClean, +}: { + slug: string; + videoId: string; + doNotClean: boolean; +}) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState<string | null>(null); + const next = !doNotClean; + return ( + <div className="flex flex-col gap-2"> + <p className="text-sm text-muted-foreground"> + {doNotClean + ? "This video is marked “do not clean”. The transcribed-audio and extra-format cleanup jobs will skip it, so its media is preserved for archiving. Reversible — allow cleanup to remove the marker." + : "Mark this video “do not clean” so the cleanup jobs never delete its audio (e.g. a deleted or members-only upload you want to keep). Reversible at any time."} + </p> + <button + type="button" + disabled={pending} + aria-label={ + next + ? `mark video ${videoId} do not clean` + : `allow cleanup for video ${videoId}` + } + onClick={() => { + setError(null); + startTransition(async () => { + const res = await toggleDoNotCleanAction(slug, videoId, next); + if (!res.ok) setError(res.error); + }); + }} + className="self-start px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" + > + {pending + ? "Saving…" + : doNotClean + ? "Allow cleanup" + : "Mark do not clean"} + </button> + {error && ( + <span + className="text-sm text-destructive" + aria-label="do not clean error" + > + {error} + </span> + )} + </div> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/DownloadOutcomeBadge.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/DownloadOutcomeBadge.tsx @@ -0,0 +1,81 @@ +"use client"; + +import type { DownloadOutcomeRecord } from "yt-dlp-transcript-common/lib/downloadOutcome"; + +export function DownloadOutcomeBadge({ + outcome, +}: { + outcome: DownloadOutcomeRecord; +}) { + if (outcome.status === "ok") return null; + const tone = + outcome.status === "failed" + ? "border-destructive/30 bg-destructive-soft text-destructive" + : "border-warning/30 bg-warning-soft text-warning"; + const label = ((): string => { + switch (outcome.status) { + case "ok-with-cookies": + return "Retried with cookies"; + case "ok-auto-transcribed": + return "Auto-transcribed (no subs available)"; + case "failed": { + const lastAttempt = outcome.attempts[outcome.attempts.length - 1]; + const cls = lastAttempt?.availabilityClass; + return cls ? `Download failed: ${cls}` : "Download failed"; + } + case "failed-corrupt-source": + return "Corrupt source — download couldn't finish"; + case "corrupt-full-source": + return "Corrupt full source — download completed but audio is malformed (file kept)"; + case "failed-short-audio": + return "Truncated download — audio far shorter than the video (file kept)"; + case "skipped-filtered": { + const f = outcome.filter; + const why = f?.reason ? `: ${f.reason}` : ""; + // The outcome records what happened, not a verdict — whether this video + // is SETTLED is derived from the channel's metadata scan and its current + // patterns, and the Diagnostics bucket is where that answer lives. + return f?.name === "titleFilter" + ? `Filtered out by the download filter${why}` + : `Skipped by filter${why}`; + } + // NOT a download, and the badge has to say so plainly: this video's media + // was never fetched and never will be while the filter stands. What is + // here is its live chat, which is why the video is in the corpus at all. + case "chat-only": + return "Chat only (filtered livestream) — live chat kept, no media and no transcript"; + default: + return outcome.status; + } + })(); + const lastErr = + outcome.status === "failed" + ? outcome.attempts[outcome.attempts.length - 1]?.error + : undefined; + return ( + <div + role="status" + aria-label="download outcome" + className={`rounded border px-3 py-2 text-sm ${tone}`} + > + <div className="flex flex-wrap items-baseline justify-between gap-2"> + <span className="font-medium">{label}</span> + <span className="text-xs opacity-70"> + {outcome.attempts.length} attempt + {outcome.attempts.length === 1 ? "" : "s"} ·{" "} + {new Date(outcome.finishedAt).toLocaleString()} + </span> + </div> + {lastErr && ( + <details className="mt-1"> + <summary className="cursor-pointer text-xs opacity-80"> + error tail + </summary> + <pre className="mt-1 whitespace-pre-wrap text-xs font-mono opacity-90"> + {lastErr} + </pre> + </details> + )} + </div> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/ExcludeTruncatedCheckSection.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/ExcludeTruncatedCheckSection.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { + toggleExcludeTruncatedCheckAction, +} from "../../videoActions"; + +export function ExcludeTruncatedCheckSection({ + slug, + videoId, + excluded, +}: { + slug: string; + videoId: string; + excluded: boolean; +}) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState<string | null>(null); + const next = !excluded; + return ( + <div className="flex flex-col gap-2"> + <p className="text-sm text-muted-foreground"> + {excluded + ? "This video is excluded from the truncated-transcript check. It won't be flagged as incomplete/short even when its transcript covers only part of the runtime. Reversible at any time." + : "Exclude this video from the truncated-transcript check — useful when the back half legitimately has no speech (so low coverage is expected, not a broken download). Reversible at any time."} + </p> + <button + type="button" + disabled={pending} + aria-label={ + next + ? `exclude video ${videoId} from truncated check` + : `include video ${videoId} in truncated check` + } + onClick={() => { + setError(null); + startTransition(async () => { + const res = await toggleExcludeTruncatedCheckAction( + slug, + videoId, + next, + ); + if (!res.ok) setError(res.error); + }); + }} + className="self-start px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" + > + {pending + ? "Saving…" + : excluded + ? "Re-enable check" + : "Exclude from check"} + </button> + {error && ( + <span + className="text-sm text-destructive" + aria-label="truncated check error" + > + {error} + </span> + )} + </div> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/FetchedWindows.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/FetchedWindows.tsx @@ -0,0 +1,82 @@ +"use client"; + +import type { ClipProvenance } from "yt-dlp-transcript-common/lib/clipWindow"; +import { formatBytes, formatDuration } from "yt-dlp-transcript-common/lib/format"; + +// A window of source media this editor fetched because another tool asked. +export type FetchedWindow = { + file: string; + from: number; + to: number; + bytes: number; + provenance: ClipProvenance | null; +}; + +// WHO ASKED, FOR WHAT, AND WHY — the three things a `clips/` directory has to +// be able to answer months later, when the only other record (the job log) has +// been pruned. Modelled on PersistedSourceVideo (SourceVideoSection.tsx), which answers the same +// question for a whole container. +export function FetchedWindows({ windows }: { windows: FetchedWindow[] }) { + return ( + <div className="flex flex-col gap-3"> + <p className="text-sm text-muted-foreground"> + Seconds of this video's source media, fetched through the managed + download path because a tool asked for them, and kept in{" "} + <code>clips/</code> so the next tool reuses them instead of paying for + the same bytes again. + </p> + <ul className="flex flex-col gap-2"> + {windows.map((w) => { + const p = w.provenance; + const what = [p?.manifest, p?.clipId].filter(Boolean).join("/"); + return ( + <li + key={w.file} + className="flex flex-col gap-0.5 text-sm border-t border-border pt-2 first:border-t-0 first:pt-0" + > + <span className="font-mono tabular-nums"> + {w.from.toFixed(2)}–{w.to.toFixed(2)}{" "} + <span className="text-muted-foreground"> + ({formatDuration(w.to - w.from)}) · {formatBytes(w.bytes)} + </span> + </span> + <span className="text-xs text-muted-foreground"> + {p ? ( + <> + requested by {p.requestedBy} + {what ? ` for ${what}` : ""} + {p.reason ? ` · \u201c${p.reason}\u201d` : ""} + {p.fetchedAt + ? ` · ${new Date(p.fetchedAt).toLocaleString()}` + : ""} + </> + ) : ( + "no provenance sidecar — requester unknown" + )} + </span> + </li> + ); + })} + </ul> + {/* A WINDOW IS NOT A DOWNLOAD. Thirty seconds of a stream is not the + video, and nothing downstream treats it as one: the fetch writes no + metadata.info.json, so this video stays exactly as undownloaded (and + as absent from the index) as it was. */} + <p className="text-xs text-muted-foreground"> + Fetched windows do not make this video downloaded: they carry no + metadata, so the video's pipeline state and its place in the index + are unchanged. + </p> + {/* WHAT THEY COST, AND THAT NOTHING RECLAIMS IT. The retention sweep is + pointer-driven and never sees a window; the cleanup lanes are about + `audio.*`. The only thing that removes one is Evict fetched windows on + /storage, and it evicts BY AGE — which surfaces have to say, because a + window a report still cites looks identical to one nothing wants. */} + <p aria-label="clip windows total" className="text-xs text-muted-foreground"> + {formatBytes(windows.reduce((n, w) => n + w.bytes, 0))} in total, + counted in this channel's media size. Nothing prunes a window on + its own — Storage evicts them by age. + </p> + </div> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/FilesList.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/FilesList.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { formatBytes } from "yt-dlp-transcript-common/lib/format"; +import { MediaPlayer } from "../MediaPlayer"; +import { TextFilePreview } from "../TextFilePreview"; +import { + deleteVideoFileAction, +} from "../../videoActions"; +import { mediaKind, mediaUrl, type VideoFile } from "./videoFiles"; + +export function FilesList({ + slug, + videoId, + files, +}: { + slug: string; + videoId: string; + files: VideoFile[]; +}) { + if (files.length === 0) { + return <p className="text-sm text-muted-foreground">Directory is empty.</p>; + } + return ( + <ul + aria-label={`files for ${videoId}`} + className="flex flex-col gap-3" + > + {files.map((f) => { + const kind = mediaKind(f.name); + return ( + <li + key={f.name} + className="border border-border rounded-md p-3 flex flex-col gap-2" + > + <div className="flex items-baseline justify-between gap-3 flex-wrap"> + <span + className="font-mono text-sm" + aria-label={`file name ${f.name}`} + > + {f.name} + </span> + <span className="text-xs text-muted-foreground"> + {formatBytes(f.size)} · {new Date(f.mtime).toLocaleString()} + </span> + </div> + {kind === "audio" || kind === "video" ? ( + <MediaPlayer + src={mediaUrl(slug, videoId, f.name)} + kind={kind} + filename={f.name} + /> + ) : kind === "text" ? ( + <TextFilePreview + src={mediaUrl(slug, videoId, f.name)} + filename={f.name} + /> + ) : null} + <DeleteFileButton slug={slug} videoId={videoId} filename={f.name} /> + </li> + ); + })} + </ul> + ); +} + +function DeleteFileButton({ + slug, + videoId, + filename, +}: { + slug: string; + videoId: string; + filename: string; +}) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState<string | null>(null); + return ( + <div className="flex items-center gap-2"> + <button + type="button" + disabled={pending} + aria-label={`delete file ${filename}`} + onClick={() => { + if (!confirm(`Delete ${filename}?`)) return; + setError(null); + startTransition(async () => { + const res = await deleteVideoFileAction(slug, videoId, filename); + if (!res.ok) setError(res.error); + }); + }} + className="self-start px-2 py-1 rounded border border-destructive/30 text-destructive text-xs hover:bg-destructive-soft disabled:opacity-50" + > + {pending ? "Deleting…" : "Delete file"} + </button> + {error && ( + <span + className="text-xs text-destructive" + aria-label={`delete error ${filename}`} + > + {error} + </span> + )} + </div> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/Heading.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/Heading.tsx @@ -0,0 +1,8 @@ +export function Heading({ title, desc }: { title: string; desc: string }) { + return ( + <div> + <h3 className="text-base font-semibold">{title}</h3> + <p className="text-sm text-muted-foreground">{desc}</p> + </div> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/IncompleteTranscriptBanner.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/IncompleteTranscriptBanner.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from "react"; +import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import { formatDuration } from "yt-dlp-transcript-common/lib/format"; +import { QueueControl } from "../../../../../../components/QueueControl"; +import { cancelJobAction } from "../../../../../../jobs/actions"; +import { + redownloadIncompleteTranscriptAction, +} from "../../videoActions"; + +// `coverage` is null when the transcript is NOT flagged as truncated — the +// banner is always rendered and draws nothing in that case, rather than the +// parent branching on the condition. The run offered here is the one that +// CLEARS the flag, so a parent branch would unmount this component (and the +// run's log with it) on the router.refresh() StreamActionLog fires when the run +// ends. `ranHere` is what keeps it: once a run has started from this surface +// the banner stays on screen with its log, whatever the condition now says. +// See plans/FACTS.md, "A run log lives in the panel's React state". +export function IncompleteTranscriptBanner({ + slug, + videoId, + coverage, + defaultQueueKey, + existingQueues, +}: { + slug: string; + videoId: string; + coverage: { + lastCueEnd: number; + duration: number; + coverage: number | null; + } | null; + defaultQueueKey: string; + existingQueues: string[]; +}) { + const [queueKey, setQueueKey] = useState(defaultQueueKey); + const [ranHere, setRanHere] = useState(false); + if (!coverage && !ranHere) return null; + const pct = + !coverage || coverage.coverage === null + ? null + : Math.round(coverage.coverage * 1000) / 10; + const actionLabel = `Re-download & re-transcribe ${videoId}`; + return ( + <div + // The alert role and its label belong to the WARNING, not to the box that + // outlives it: once the run has cleared the flag this wrapper holds only + // the log, and announcing that as an alert named "incomplete transcript" + // would be a lie to a screen reader (and would keep answering + // getByLabel("incomplete transcript") for a video that is now fine — + // truncated-check.spec.ts:69 asserts exactly that it does not). While the + // flag holds, both attributes are exactly as they were. + {...(coverage + ? { role: "alert", "aria-label": "incomplete transcript" } + : {})} + className="flex flex-col gap-3 rounded border px-3 py-2 text-sm border-warning/30 bg-warning-soft text-warning" + > + {coverage ? ( + <div className="flex flex-col gap-1"> + <span className="font-medium">Transcript looks truncated</span> + <span> + The transcript covers only {formatDuration(coverage.lastCueEnd)} of{" "} + {formatDuration(coverage.duration)} + {pct !== null ? ` (${pct}%)` : ""}. The audio download likely stopped + early. Re-download the audio and re-transcribe to fix it — this + deletes the truncated audio first so the full file is fetched. + </span> + </div> + ) : null} + <StreamActionLog + key="run" + // Kept for its log, not for a second run: the transcript is no longer + // truncated once the flag is gone. + disabled={!coverage} + trigger={() => { + setRanHere(true); + return redownloadIncompleteTranscriptAction(slug, videoId, queueKey); + }} + cancelAction={cancelJobAction} + buttonLabel="Re-download & re-transcribe" + runningLabel="Re-downloading…" + label={actionLabel} + extraControls={ + <QueueControl + value={queueKey} + onChange={setQueueKey} + defaultQueueKey={defaultQueueKey} + existingQueues={existingQueues} + actionLabel={actionLabel} + /> + } + /> + </div> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/MarkUntranscribableSection.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/MarkUntranscribableSection.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { + markVideoUntranscribableAction, +} from "../../videoActions"; + +export function MarkUntranscribableSection({ + slug, + videoId, +}: { + slug: string; + videoId: string; +}) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState<string | null>(null); + return ( + <div className="flex flex-col gap-2"> + <p className="text-sm text-muted-foreground"> + Writes an empty transcript.json so the pipeline stops retrying. Removes + this video from the failed list. Reversible — just delete + transcript.json. + </p> + <button + type="button" + disabled={pending} + aria-label={`mark video ${videoId} untranscribable`} + onClick={() => { + setError(null); + startTransition(async () => { + const res = await markVideoUntranscribableAction(slug, videoId); + if (!res.ok) setError(res.error); + }); + }} + className="self-start px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" + > + {pending ? "Marking…" : "Mark untranscribable"} + </button> + {error && ( + <span + className="text-sm text-destructive" + aria-label="mark untranscribable error" + > + {error} + </span> + )} + </div> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/PerFileTranscodeRow.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/PerFileTranscodeRow.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import { + AUDIO_FORMAT_VALUES, + type AudioFormat, +} from "yt-dlp-transcript-common/lib/channelConfig"; +import { QueueControl } from "../../../../../../components/QueueControl"; +import { cancelJobAction } from "../../../../../../jobs/actions"; +import { + transcodeAudioAction, +} from "../../videoActions"; +import { Heading } from "./Heading"; +import { audioExt, type VideoFile } from "./videoFiles"; + +export function PerFileTranscodeRow({ + slug, + videoId, + file, + existingQueues, + channelAudioFormat, +}: { + slug: string; + videoId: string; + file: VideoFile; + existingQueues: string[]; + channelAudioFormat?: AudioFormat; +}) { + const ext = audioExt(file.name); + const transcodeTargets = AUDIO_FORMAT_VALUES.filter((f) => f !== ext); + const preferredTarget = + channelAudioFormat && transcodeTargets.includes(channelAudioFormat) + ? channelAudioFormat + : transcodeTargets[0] ?? "opus"; + const [target, setTarget] = useState<AudioFormat>(preferredTarget); + const [transcodeQueue, setTranscodeQueue] = useState(""); + + if (transcodeTargets.length === 0) return null; + return ( + <div className="flex flex-col gap-2 rounded border border-border p-3"> + <Heading + title={`Transcode ${file.name}`} + desc="Re-encode this audio file with ffmpeg. The source file is kept; the output is named audio.<format>." + /> + <label className="flex items-center gap-2 text-sm"> + Target format + <select + value={target} + onChange={(e) => setTarget(e.target.value as AudioFormat)} + aria-label={`transcode target for ${file.name}`} + className="rounded border border-border bg-card px-2 py-1 text-sm" + > + {transcodeTargets.map((t) => ( + <option key={t} value={t}> + {t} + </option> + ))} + </select> + </label> + <StreamActionLog + trigger={() => + transcodeAudioAction(slug, videoId, file.name, target, transcodeQueue) + } + cancelAction={cancelJobAction} + buttonLabel={`Transcode ${file.name} → ${target}`} + runningLabel="Transcoding…" + label={`Transcode ${file.name} to ${target}`} + extraControls={ + <QueueControl + value={transcodeQueue} + onChange={setTranscodeQueue} + defaultQueueKey="" + existingQueues={existingQueues} + actionLabel={`Transcode ${file.name} to ${target}`} + /> + } + /> + </div> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/PerFileTranscribeRow.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/PerFileTranscribeRow.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from "react"; +import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import { QueueControl } from "../../../../../../components/QueueControl"; +import { cancelJobAction } from "../../../../../../jobs/actions"; +import { + transcribeOneAction, +} from "../../videoActions"; +import { Heading } from "./Heading"; +import type { VideoFile } from "./videoFiles"; + +export function PerFileTranscribeRow({ + slug, + videoId, + file, + existingQueues, + hasTranscript, + replaceAutoSubs = false, +}: { + slug: string; + videoId: string; + file: VideoFile; + existingQueues: string[]; + hasTranscript: boolean; + // The existing transcript is YouTube ASR: transcribing is offered (and + // labelled as a replacement) rather than blocked. + replaceAutoSubs?: boolean; +}) { + const [transcribeQueue, setTranscribeQueue] = useState(""); + // `hasTranscript` is SERVER-derived (VideoPanel, the Transcribe card) and this row's own run + // is what writes transcript.json — so the run's own router.refresh() used to + // swap this panel for the "already present" line and take the log with it. + // Same shape as the two banners: the row survives (it is keyed by file name), + // the message and the run panel are separate slots, and the panel stays + // mounted — disabled, since the transcript now exists — once a run has + // started here. With no run it is still absent whenever a transcript is + // already present, so the DOM is unchanged. + const [ranHere, setRanHere] = useState(false); + + return ( + <div className="flex flex-col gap-2 rounded border border-border p-3"> + <Heading + title={ + replaceAutoSubs + ? `Replace auto-captions with an AI transcript (${file.name})` + : `Transcribe ${file.name}` + } + desc={ + hasTranscript + ? "A transcript already exists for this video. Delete transcript.json (or transcript.en.vtt) on disk to re-run." + : replaceAutoSubs + ? "This video's only transcript is YouTube's speech recognition. Run whisper-cli on this audio file to write a transcript.json, which takes precedence everywhere. The auto-caption VTT is kept on disk as a backup." + : "Run whisper-cli on this audio file. Writes transcript.json next to it." + } + /> + {hasTranscript ? ( + <p className="text-sm text-muted-foreground"> + Transcribe disabled — transcript already present. + </p> + ) : null} + {hasTranscript && !ranHere ? null : ( + <StreamActionLog + key="transcribe-run" + disabled={hasTranscript} + trigger={() => { + setRanHere(true); + return transcribeOneAction( + slug, + videoId, + file.name, + transcribeQueue, + ); + }} + cancelAction={cancelJobAction} + buttonLabel={ + replaceAutoSubs + ? `Replace auto-captions (${file.name})` + : `Transcribe ${file.name}` + } + runningLabel="Transcribing…" + label={`Transcribe ${file.name}`} + extraControls={ + <QueueControl + value={transcribeQueue} + onChange={setTranscribeQueue} + defaultQueueKey="" + existingQueues={existingQueues} + actionLabel={`Transcribe ${file.name}`} + /> + } + /> + )} + </div> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/RedownloadSection.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/RedownloadSection.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useState } from "react"; +import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import type { ChannelHandling } from "yt-dlp-transcript-common/lib/channelConfig"; +import { + DOWNLOAD_FORMAT_LABELS, + DOWNLOAD_FORMAT_PRESETS, +} from "yt-dlp-transcript-common/ytdlp/downloadFormat"; +import { QueueControl } from "../../../../../../components/QueueControl"; +import { cancelJobAction } from "../../../../../../jobs/actions"; +import { + downloadVideoPipelineAction, + whisperVideoAction, +} from "../../videoActions"; + +type DownloadMode = "pipeline" | "whisper"; + +export function RedownloadSection({ + slug, + videoId, + handling, + defaultQueueKey, + existingQueues, + noAudio, +}: { + slug: string; + videoId: string; + handling: ChannelHandling; + defaultQueueKey: string; + existingQueues: string[]; + noAudio: boolean; +}) { + const [mode, setMode] = useState<DownloadMode>("pipeline"); + const [queueKey, setQueueKey] = useState(defaultQueueKey); + // "" = inherit the channel/global default; otherwise a DownloadFormatPreset. + const [downloadFormat, setDownloadFormat] = useState(""); + + const desc = (() => { + if (mode === "whisper") { + return noAudio + ? "Download the audio and run whisper-cli, in one job. Skips any VTT attempt." + : "Run whisper-cli on this video's audio. Skips the download phase since audio is already on disk."; + } + if (handling === "youtube") { + return noAudio + ? "Try the VTT download first; fall back to audio + whisper if no subs are available." + : "Re-run the pipeline: try VTT, then fall back to audio + whisper if needed."; + } + return noAudio + ? "Download audio (and run any channel-configured audio checks)." + : "Re-run the download pipeline for this video."; + })(); + + const verb = mode === "whisper" + ? "Audio + Whisper" + : noAudio + ? "Run download pipeline" + : "Re-run download pipeline"; + const runningLabel = mode === "whisper" + ? "Transcribing…" + : "Running pipeline…"; + const actionLabel = `${verb} for ${videoId}`; + const trigger = mode === "whisper" + ? () => whisperVideoAction(slug, videoId, queueKey) + : () => + downloadVideoPipelineAction( + slug, + videoId, + queueKey, + downloadFormat || undefined, + ); + + return ( + <div className="flex flex-col gap-3"> + <p className="text-sm text-muted-foreground">{desc}</p> + <label className="flex items-center gap-2 text-sm"> + Mode + <select + value={mode} + onChange={(e) => setMode(e.target.value as DownloadMode)} + aria-label="download mode" + className="rounded border border-border bg-card px-2 py-1 text-sm" + > + <option value="pipeline">Designated pipeline</option> + <option value="whisper">Audio + Whisper (skip pipeline)</option> + </select> + </label> + {mode === "pipeline" && ( + <label className="flex items-center gap-2 text-sm"> + Format + <select + value={downloadFormat} + onChange={(e) => setDownloadFormat(e.target.value)} + aria-label="download format" + className="rounded border border-border bg-card px-2 py-1 text-sm" + > + <option value="">Inherit (channel / global)</option> + {DOWNLOAD_FORMAT_PRESETS.map((p) => ( + <option key={p} value={p}> + {DOWNLOAD_FORMAT_LABELS[p]} + </option> + ))} + </select> + </label> + )} + <StreamActionLog + trigger={trigger} + cancelAction={cancelJobAction} + buttonLabel={verb} + runningLabel={runningLabel} + label={actionLabel} + extraControls={ + <QueueControl + value={queueKey} + onChange={setQueueKey} + defaultQueueKey={defaultQueueKey} + existingQueues={existingQueues} + actionLabel={actionLabel} + /> + } + /> + </div> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/ShortAudioBanner.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/ShortAudioBanner.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; +import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import { formatDuration } from "yt-dlp-transcript-common/lib/format"; +import { QueueControl } from "../../../../../../components/QueueControl"; +import { cancelJobAction } from "../../../../../../jobs/actions"; +import { + downloadVideoPipelineAction, +} from "../../videoActions"; + +// The download completed but the audio was far shorter than the video — the +// source served a truncated stream (e.g. a CDN-truncated HLS rung). The stub is +// kept on disk; re-downloading with a different format (Original) fetches the +// full audio. Offers a one-click re-download forcing the Original format. +// `flagged` is the parent's condition, passed down rather than branched on for +// the same reason as IncompleteTranscriptBanner (IncompleteTranscriptBanner.tsx): the re-download offered +// here is what clears the flag, and unmounting this component on the refresh +// that lands the new download outcome would take the run's log with it. +export function ShortAudioBanner({ + slug, + videoId, + flagged, + shortAudio, + defaultQueueKey, + existingQueues, +}: { + slug: string; + videoId: string; + flagged: boolean; + shortAudio?: { + audioDurationSec: number; + expectedDurationSec: number; + coverage: number; + }; + defaultQueueKey: string; + existingQueues: string[]; +}) { + const [queueKey, setQueueKey] = useState(defaultQueueKey); + const [ranHere, setRanHere] = useState(false); + if (!flagged && !ranHere) return null; + const actionLabel = `Re-download ${videoId} as Original`; + return ( + <div + // Conditional for the same reason as IncompleteTranscriptBanner (IncompleteTranscriptBanner.tsx): the + // alert belongs to the warning, and the warning is gone once the + // re-download has cleared the flag. + {...(flagged ? { role: "alert", "aria-label": "short audio" } : {})} + className="flex flex-col gap-3 rounded border px-3 py-2 text-sm border-warning/30 bg-warning-soft text-warning" + > + {flagged ? ( + <div className="flex flex-col gap-1"> + <span className="font-medium"> + Download was truncated at the source + </span> + <span> + {shortAudio + ? `The downloaded audio is only ${formatDuration(shortAudio.audioDurationSec)} of ${formatDuration(shortAudio.expectedDurationSec)} (${Math.round(shortAudio.coverage * 100)}%). ` + : "The downloaded audio is far shorter than the video. "} + The source served a truncated stream for the selected format, so it + was flagged before transcription (the file is kept, not + transcribed). Re-download with the <strong>Original</strong> format + to fetch the full audio. + </span> + </div> + ) : null} + <StreamActionLog + key="run" + // Kept for its log, not for a second run: the download that cleared the + // flag is the one whose output this is. + disabled={!flagged} + trigger={() => { + setRanHere(true); + return downloadVideoPipelineAction(slug, videoId, queueKey, "original"); + }} + cancelAction={cancelJobAction} + buttonLabel="Re-download as Original" + runningLabel="Re-downloading…" + label={actionLabel} + extraControls={ + <QueueControl + value={queueKey} + onChange={setQueueKey} + defaultQueueKey={defaultQueueKey} + existingQueues={existingQueues} + actionLabel={actionLabel} + /> + } + /> + </div> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/SourceVideoSection.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/SourceVideoSection.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import type { ChannelHandling } from "yt-dlp-transcript-common/lib/channelConfig"; +import type { SavedVideoPointer } from "yt-dlp-transcript-common/lib/savedVideo"; +import { formatBytes } from "yt-dlp-transcript-common/lib/format"; +import { QueueControl } from "../../../../../../components/QueueControl"; +import { cancelJobAction } from "../../../../../../jobs/actions"; +import { + redownloadToArchiveAction, + unpersistVideoAction, +} from "../../videoActions"; + +export function SourceVideoSection({ + slug, + videoId, + handling, + defaultQueueKey, + existingQueues, + savedVideo, +}: { + slug: string; + videoId: string; + handling: ChannelHandling; + defaultQueueKey: string; + existingQueues: string[]; + savedVideo: SavedVideoPointer | null; +}) { + const [queueKey, setQueueKey] = useState(defaultQueueKey); + // Set the moment a persist run STARTS from this surface. That run's own + // router.refresh() lands the saved-video pointer, and the persisted view used + // to REPLACE this whole subtree — destroying the StreamActionLog below and the + // log the operator was reading (plans/FACTS.md, "A run log lives in the + // panel's React state"). One return, two fixed slots, and the run panel stays + // mounted once it has been used; with no run it is still absent whenever the + // video is already persisted, so the DOM is unchanged. + const [ranHere, setRanHere] = useState(false); + + if (handling !== "transcribe") { + return ( + <p className="text-sm text-muted-foreground"> + Source-video persistence applies to transcribe-handling channels only. + </p> + ); + } + + return ( + <div className="flex flex-col gap-3"> + {savedVideo ? ( + <PersistedSourceVideo + slug={slug} + videoId={videoId} + savedVideo={savedVideo} + /> + ) : ( + <p className="text-sm text-muted-foreground"> + Re-fetch this video's full source container and move it into the + saved-video store, without disturbing the existing transcript. Use + this to archive a video that was downloaded audio-only. (Videos in a + channel's keep-latest window persist automatically on download.) + </p> + )} + {savedVideo && !ranHere ? null : ( + <StreamActionLog + key="persist-run" + // Mounted after a run so its log survives, but NOT actionable once the + // pointer is on disk: re-running would re-fetch a container the store + // already has. + disabled={!!savedVideo} + trigger={() => { + setRanHere(true); + return redownloadToArchiveAction(slug, videoId, queueKey); + }} + cancelAction={cancelJobAction} + buttonLabel="Persist source video" + runningLabel="Archiving…" + label={`Persist source video for ${videoId}`} + extraControls={ + <QueueControl + value={queueKey} + onChange={setQueueKey} + defaultQueueKey={defaultQueueKey} + existingQueues={existingQueues} + actionLabel={`Persist source video for ${videoId}`} + /> + } + /> + )} + </div> + ); +} + +// The persisted-details view: a sibling of the run panel above, never a +// replacement for it. +function PersistedSourceVideo({ + slug, + videoId, + savedVideo, +}: { + slug: string; + videoId: string; + savedVideo: SavedVideoPointer; +}) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState<string | null>(null); + return ( + <> + <p className="text-sm text-muted-foreground"> + The source container for this video is persisted to the saved-video + store, so the Clean-audio sweep can reclaim its <code>audio.*</code>{" "} + without losing the original. Transcription falls back to it when no + audio is on disk. + </p> + <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm"> + <dt className="text-muted-foreground">File</dt> + <dd className="font-mono break-all">{savedVideo.file}</dd> + <dt className="text-muted-foreground">Size</dt> + <dd className="tabular-nums">{formatBytes(savedVideo.bytes)}</dd> + <dt className="text-muted-foreground">Stored</dt> + <dd>{new Date(savedVideo.storedAt).toLocaleString()}</dd> + {savedVideo.keepReason && ( + <> + <dt className="text-muted-foreground">Reason</dt> + <dd>{savedVideo.keepReason}</dd> + </> + )} + {/* WHO ASKED. `keepReason` cannot say it — it stays override/pin so the + retention prune leaves this container alone — so "why is this 4 GB + file here" needs its own line. */} + {savedVideo.origin && ( + <> + <dt className="text-muted-foreground">Requested by</dt> + <dd> + {savedVideo.origin.requestedBy} + {[savedVideo.origin.manifest, savedVideo.origin.clipId] + .filter(Boolean) + .join("/") + ? ` for ${[savedVideo.origin.manifest, savedVideo.origin.clipId] + .filter(Boolean) + .join("/")}` + : ""} + {savedVideo.origin.reason + ? ` \u2014 \u201c${savedVideo.origin.reason}\u201d` + : ""} + </dd> + </> + )} + {savedVideo.sha256 && ( + <> + <dt className="text-muted-foreground">sha256</dt> + <dd className="font-mono text-xs break-all"> + {savedVideo.sha256} + </dd> + </> + )} + <dt className="text-muted-foreground">Location</dt> + <dd className="font-mono text-xs break-all">{savedVideo.dir}</dd> + </dl> + <p className="text-xs text-muted-foreground"> + Unpersist moves the container back into this video's data dir + (where it appears under Files and plays inline) and removes the + pointer. + </p> + <button + type="button" + disabled={pending} + aria-label={`unpersist source video ${videoId}`} + onClick={() => { + setError(null); + startTransition(async () => { + const res = await unpersistVideoAction(slug, videoId); + if (!res.ok) setError(res.error); + }); + }} + className="self-start px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted disabled:opacity-50" + > + {pending ? "Restoring…" : "Unpersist (move back to data dir)"} + </button> + {error && ( + <span + className="text-sm text-destructive" + aria-label="unpersist error" + > + {error} + </span> + )} + </> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/TranscriptSourceSection.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/TranscriptSourceSection.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useState, useTransition } from "react"; +import type { SubtitleProvenance } from "yt-dlp-transcript-common/lib/subtitleProvenance"; +import { + setPrimaryTranscriptAction, +} from "../../videoActions"; +import { CANONICAL_VTT, WHISPER_FILENAME } from "./videoFiles"; + +const PROVENANCE_LABEL: Record<SubtitleProvenance, string> = { + asr: "YouTube auto-captions", + manual: "manual captions", + unknown: "unknown source", +}; + +export function TranscriptSourceSection({ + slug, + videoId, + vttTracks, + primaryVtt, + hasWhisper, + vttProvenance, +}: { + slug: string; + videoId: string; + vttTracks: string[]; + primaryVtt: string | null; + hasWhisper: boolean; + vttProvenance: Record<string, SubtitleProvenance>; +}) { + const [pending, startTransition] = useTransition(); + const [busyFile, setBusyFile] = useState<string | null>(null); + const [error, setError] = useState<string | null>(null); + + const setPrimary = (filename: string) => { + setError(null); + setBusyFile(filename); + startTransition(async () => { + const res = await setPrimaryTranscriptAction(slug, videoId, filename); + if (!res.ok) setError(res.error); + setBusyFile(null); + }); + }; + + return ( + <div className="flex flex-col gap-3"> + <p className="text-sm text-muted-foreground"> + Pick which subtitle track is this video's primary transcript. The + chosen track is copied to <code>{CANONICAL_VTT}</code> — the canonical + name the index and viewer read. Reversible: delete{" "} + <code>{CANONICAL_VTT}</code> in Files to fall back to the automatic + English pick, or choose another track to switch. + </p> + {hasWhisper && ( + <p + className="text-sm text-warning" + aria-label="whisper precedence note" + > + A whisper transcript (<code>{WHISPER_FILENAME}</code>) is present and + takes precedence over any VTT when indexing. + </p> + )} + <ul aria-label="transcript tracks" className="flex flex-col gap-2"> + {vttTracks.map((name) => { + const isPrimary = name === primaryVtt; + return ( + <li + key={name} + aria-label={`transcript track ${name}`} + className="flex items-center justify-between gap-3 rounded border border-border px-3 py-2" + > + <span className="flex items-center gap-2 font-mono text-sm"> + {name} + {vttProvenance[name] && ( + <span + aria-label={`transcript provenance ${name}`} + className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-sans font-medium uppercase tracking-wide text-muted-foreground" + > + {PROVENANCE_LABEL[vttProvenance[name]]} + </span> + )} + {isPrimary && ( + <span + aria-label={`primary transcript ${name}`} + className="rounded bg-success-soft text-success px-1.5 py-0.5 text-[10px] font-sans font-medium uppercase tracking-wide" + > + Primary + </span> + )} + </span> + {!isPrimary && ( + <button + type="button" + disabled={pending} + aria-label={`set ${name} as primary transcript`} + onClick={() => setPrimary(name)} + className="shrink-0 px-2 py-1 rounded border border-border text-xs hover:bg-muted disabled:opacity-50" + > + {pending && busyFile === name + ? "Setting…" + : "Set as transcript"} + </button> + )} + </li> + ); + })} + </ul> + {error && ( + <span + className="text-sm text-destructive" + aria-label="set primary transcript error" + > + {error} + </span> + )} + </div> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/videoFiles.ts b/editor/app/channels/[slug]/videos/[id]/components/cards/videoFiles.ts @@ -0,0 +1,63 @@ +import { + AUDIO_EXTS, + VIDEO_CONTAINER_EXTS, +} from "yt-dlp-transcript-common/lib/mediaFiles"; + +export type VideoFile = { + name: string; + size: number; + mtime: number; +}; + +export const WHISPER_FILENAME = "transcript.json"; +export const CANONICAL_VTT = "transcript.en.vtt"; + +export function isTranscriptVttName(name: string): boolean { + return /^transcript\.[^.]+\.vtt$/.test(name); +} + +// One list, shared with the detection predicates in lib/mediaFiles.ts — these +// were duplicated here and drifting. isAudioFile deliberately stays on +// AUDIO_EXTS alone: audio.mp4 is a wrong-format cleanup target, not something +// this panel offers a transcribe button for (editor/e2e/video-page.spec.ts:426). +const AUDIO_EXT_SET = new Set(AUDIO_EXTS); +const VIDEO_EXT_SET = new Set(VIDEO_CONTAINER_EXTS); + +const TEXT_EXTS = new Set(["vtt", "json", "txt", "srt", "log"]); + +export function fileExt(name: string): string { + const dot = name.lastIndexOf("."); + return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ""; +} + +export function isAudioFile(name: string): boolean { + return AUDIO_EXT_SET.has(fileExt(name)); +} + +// Files we can transcode FROM: any media file ffmpeg can demux to audio. +// Includes video containers so a kept source (e.g. audio.mp4 when +// keepSourceVideo=true) can be re-extracted to audio.<targetFormat> to +// overwrite a truncated or corrupt prior extraction. +export function isTranscodeSource(name: string): boolean { + if (!name.startsWith("audio.")) return false; + if (name.startsWith("audio.tmp-")) return false; + const ext = fileExt(name); + return AUDIO_EXT_SET.has(ext) || VIDEO_EXT_SET.has(ext); +} + +export function audioExt(name: string): string { + const dot = name.lastIndexOf("."); + return dot >= 0 ? name.slice(dot + 1) : ""; +} + +export function mediaKind(name: string): "audio" | "video" | "text" | null { + const ext = fileExt(name); + if (AUDIO_EXT_SET.has(ext)) return "audio"; + if (VIDEO_EXT_SET.has(ext)) return "video"; + if (TEXT_EXTS.has(ext)) return "text"; + return null; +} + +export function mediaUrl(slug: string, videoId: string, filename: string): string { + return `/api/channels/${encodeURIComponent(slug)}/videos/${encodeURIComponent(videoId)}/files/${encodeURIComponent(filename)}`; +} diff --git a/editor/app/channels/[slug]/videos/[id]/lib/videoChoreCards.test.ts b/editor/app/channels/[slug]/videos/[id]/lib/videoChoreCards.test.ts @@ -0,0 +1,41 @@ +// Run with: node_modules/.bin/tsx --test "app/**/*.test.ts" (from editor/). +// +// VideoPanel's JSX is literal; this pins that the chore cards it renders, in +// the order it renders them, are exactly VIDEO_CHORE_CARDS. Read as source text +// rather than imported: the cards are client components (StreamActionLog, +// next/link, server-action references) and a node test cannot load them. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const listSrc = readFileSync(path.join(here, "videoChoreCards.ts"), "utf8"); +const panelSrc = readFileSync( + path.join(here, "../components/VideoPanel.tsx"), + "utf8", +); + +const listed = [...listSrc.matchAll(/^\s+component: (\w+),$/gm)].map((m) => m[1]); + +test("the hand-list names fifteen distinct chore cards", () => { + assert.equal(listed.length, 15); + assert.equal(new Set(listed).size, listed.length); +}); + +test("VideoPanel renders exactly the listed cards, in list order", () => { + const known = new Set(listed); + const rendered = [...panelSrc.matchAll(/<(\w+)\b/g)] + .map((m) => m[1]) + .filter((name) => known.has(name)); + assert.deepEqual(rendered, listed); +}); + +test("every card VideoPanel imports from ./cards is listed", () => { + const imported = [ + ...panelSrc.matchAll(/^import \{ (\w+)(?:, type \w+)? \} from "\.\/cards\/\w+";$/gm), + ].map((m) => m[1]); + assert.deepEqual([...imported].sort(), [...listed].sort()); +}); diff --git a/editor/app/channels/[slug]/videos/[id]/lib/videoChoreCards.ts b/editor/app/channels/[slug]/videos/[id]/lib/videoChoreCards.ts @@ -0,0 +1,149 @@ +// THE VIDEO PAGE'S CHORES, hand-listed, in the order VideoPanel renders them. +// +// The video page draws two kinds of card. The OPERATION panels (digest, +// diarization, attribution) are enumerated from the registry by +// videoOperationPanels.ts and rendered after VideoPanel. Everything VideoPanel +// itself draws is a CHORE: a per-video action or marker that no registry entry +// owns. This list is the video page's twin of the channel page's hand-listed +// bookends (configure/playlist … cleanup/diagnostics/storage/danger around +// GROUP_STAGES in common/views/pipeline/stageStatus.ts): enumerated in ONE +// place beside the registry's panels, and deliberately NOT folded into the +// registry — they are not operations, and inventing entries for them would give +// the registry states and outputs it has no way to compute. +// +// Two rows are the per-video run surface of the EXTERNAL operations (download, +// transcription). They are hand-listed for the reason videoOperations.ts gives +// for leaving the externals out of its reader: these cards already are their +// per-video surface, with actions of their own. +// +// VideoPanel keeps its JSX literal (the gating booleans read better inline); +// videoChoreCards.test.ts pins that its render order is exactly this list, so +// a card added there without a row here fails a test rather than going unlisted. +// Not a card, so not listed: VideoNavStrip, PipelineStatusStrip, the umtool +// link and the one-line "media archived" status, which is the archive-media +// card's marker restated at the top. +// +// No directive: a plain data module. Nothing server-side imports it; a server +// component that did would get client references for `component`, not values. + +import type { ComponentType } from "react"; +import { AvailabilityHistoryList } from "../components/cards/AvailabilityHistoryList"; +import { DeleteVideoDirSection } from "../components/cards/DeleteVideoDirSection"; +import { DoNotCleanSection } from "../components/cards/DoNotCleanSection"; +import { DownloadOutcomeBadge } from "../components/cards/DownloadOutcomeBadge"; +import { ExcludeTruncatedCheckSection } from "../components/cards/ExcludeTruncatedCheckSection"; +import { FetchedWindows } from "../components/cards/FetchedWindows"; +import { FilesList } from "../components/cards/FilesList"; +import { IncompleteTranscriptBanner } from "../components/cards/IncompleteTranscriptBanner"; +import { MarkUntranscribableSection } from "../components/cards/MarkUntranscribableSection"; +import { PerFileTranscodeRow } from "../components/cards/PerFileTranscodeRow"; +import { PerFileTranscribeRow } from "../components/cards/PerFileTranscribeRow"; +import { RedownloadSection } from "../components/cards/RedownloadSection"; +import { ShortAudioBanner } from "../components/cards/ShortAudioBanner"; +import { SourceVideoSection } from "../components/cards/SourceVideoSection"; +import { TranscriptSourceSection } from "../components/cards/TranscriptSourceSection"; + +export type VideoChoreCard = { + // The PipelineStageCard id where the chore has a card; a banner's own name + // where it does not. + id: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- a list of heterogeneous props + component: ComponentType<any>; + // When VideoPanel draws it (the gate is VideoPanel's; this is its gloss). + shown: string; + // Why it is a chore, not an operation. + why: string; +}; + +export const VIDEO_CHORE_CARDS: readonly VideoChoreCard[] = [ + { + id: "download-outcome", + component: DownloadOutcomeBadge, + shown: "a download-outcome.json exists and is not plain ok", + why: "a report of the last download attempt; it runs nothing", + }, + { + id: "incomplete-transcript", + component: IncompleteTranscriptBanner, + shown: "always mounted; draws only while coverage says truncated, or after a run here", + why: "a repair of the download operation's output, not an output of its own", + }, + { + id: "short-audio", + component: ShortAudioBanner, + shown: "always mounted; draws only while the outcome is failed-short-audio, or after a run here", + why: "a repair of the download operation's output, not an output of its own", + }, + { + id: "availability-history", + component: AvailabilityHistoryList, + shown: "always", + why: "a read-only history the sync writes; nothing to run", + }, + { + id: "download", + component: RedownloadSection, + shown: "always", + why: "the external download operation's per-video run surface (videoOperations.ts leaves externals out)", + }, + { + id: "transcode", + component: PerFileTranscodeRow, + shown: "one row per transcodable source file, when there is one", + why: "a per-file media utility; the transcode operation was retired 2026-08-30", + }, + { + id: "transcribe", + component: PerFileTranscribeRow, + shown: "always; one row per audio file", + why: "the external transcription operation's per-video run surface (videoOperations.ts leaves externals out)", + }, + { + id: "transcript-source", + component: TranscriptSourceSection, + shown: "at least one transcript.<lang>.vtt is on disk", + why: "an operator choice of which caption track is canonical; produces no derived data", + }, + { + id: "mark-untranscribable", + component: MarkUntranscribableSection, + shown: "no transcript yet", + why: "a marker that stops the pipeline retrying; it is a verdict, not work", + }, + { + id: "source-video-persistence", + component: SourceVideoSection, + shown: "always (transcribe-handling channels get the controls)", + why: "a storage move into the saved-video store; not derived from the transcript", + }, + { + id: "fetched-windows", + component: FetchedWindows, + shown: "clips/ holds at least one window", + why: "a read-only listing of a cache another tool filled", + }, + { + id: "archive-media", + component: DoNotCleanSection, + shown: "always", + why: "a do-not-clean marker the cleanup lanes read", + }, + { + id: "truncated-check", + component: ExcludeTruncatedCheckSection, + shown: "always", + why: "an opt-out marker the truncated check reads", + }, + { + id: "files", + component: FilesList, + shown: "always", + why: "the directory listing, with per-file preview and delete", + }, + { + id: "danger", + component: DeleteVideoDirSection, + shown: "always", + why: "deletes the whole video directory", + }, +];