Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 596c29638db9d1b659999b6285133aaefd7167d4
parent af19fc627135cb50f6827a174d857eeed54c7c5a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 24 Sep 2026 20:14:23 -0400

Merge one-core/phase-3-s3b — the video page: one module per chore card, VideoPanel is the assembly

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Meditor/CHANGELOG.md | 1+
Aeditor/app/channels/[slug]/videos/[id]/components/PipelineStatusStrip.tsx | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/VideoNavStrip.tsx | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx | 1464+++----------------------------------------------------------------------------
Aeditor/app/channels/[slug]/videos/[id]/components/cards/AvailabilityHistoryList.tsx | 44++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/DeleteVideoDirSection.tsx | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/DoNotCleanSection.tsx | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/DownloadOutcomeBadge.tsx | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/ExcludeTruncatedCheckSection.tsx | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/FetchedWindows.tsx | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/FilesList.tsx | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/Heading.tsx | 8++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/IncompleteTranscriptBanner.tsx | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/MarkUntranscribableSection.tsx | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/PerFileTranscodeRow.tsx | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/PerFileTranscribeRow.tsx | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/RedownloadSection.tsx | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/ShortAudioBanner.tsx | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/SourceVideoSection.tsx | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/TranscriptSourceSection.tsx | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/videoFiles.ts | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/lib/videoChoreCards.test.ts | 41+++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/lib/videoChoreCards.ts | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/one-core-phase-3.md | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
24 files changed, 1852 insertions(+), 1424 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -4,6 +4,7 @@ - **Channel rows no longer scroll over a group's controls on `/channels`.** Scrolled down and to the right, the pinned Slug column of every row painted over the pinned group header and its five station buttons (Sync, Download, Transcribe, Digest and the speaker lane), and took the clicks. The pinned Slug cell and the group header sat at the same stacking level, and the later rows won. The rack now has one named layer order, kept in one file: the Advanced panel, then the column header, then the group header, then the pinned checkbox and Slug cells. Nothing ties any more. The screenshot audit found four more problems, fixed as well. A group header's name and buttons now stay on screen however far the columns scroll across (they used to scroll off to the left). An Advanced panel opened near the bottom or the right edge scrolls itself into view instead of being cut off. The rule above a pinned group header moves with it instead of leaving a gap the rows showed through. On a phone, the column header no longer paints over the selection bar pinned to the bottom of the screen. - **A group's Transcribe works for YouTube channels, and it counts what it queues.** The station used to be disabled for every `youtube`-handling channel with the message "a youtube-handling channel never runs whisper". That was wrong. A YouTube video that came down with no captions is transcription work like any other, and the automatic runner already treats it that way. Transcribe now counts two kinds of video, after the usual members-only, deleted and private exclusions: downloaded videos with no transcript at all, and downloaded videos whose only transcript is YouTube's auto-captions. Pressing it queues exactly those videos, by id, as the channel page does: up to two jobs per channel on the transcription queue. A video downloaded before it went private, members-only or deleted is no longer transcribed by the group button, because it was never in the figure. Pressing it again while either job runs says *already running*. The wording names no method ("…has downloaded audio to transcribe", "…each takes minutes"). **This figure can now be higher than the Transcription band in the same rack on channels with many auto-caption-only videos.** The band counts videos with no transcript at all, while the station counts everything its button would queue. That is intended. - **The editor's atomic JSON, text and binary writes now go one way, and a failed write no longer leaves a temp file behind.** Nineteen JSON write sites and seven text and binary ones each wrote `<file>.tmp-<pid>` and renamed it over the original — the channel roster, maybe-missing and metadata-scan records, the scheduler and auto-queue state, worker defaults, widget presets, the homepage config, relocation markers, shard configs, the duplicate and media-scan reports and their review decisions, the saved-video backup manifest, both cue normalizers, the playlist, the failed-transcriptions list, the X cookie jar, a site's CHANGELOG cut, a saved video copied into its store across drives, and the video page's VTT promote and remark. They now all go through one writer (`common/lib/jsonFile-server.ts`), which gives every write its own temp name and queues writes to the same file one behind another, so two jobs touching one channel's roster at once cannot trip over each other's temp file. A write that fails now removes its temp: the live `.auto-queue/` holds 175 `state.json.tmp-…` files (173 of them empty) from the day `/home` filled up (2026-09-11), each one a failed write the old code left behind; nothing deletes those old ones for you — `find transcripts -name '*.tmp-*'` lists them. Four temp names stay, on purpose: the export build's two page writers `buildIndex.ts` (a streaming page writer) and `buildStats.ts` (a hand-joined array) — folding them is a restructuring, not a swap — and `transcode.ts` / `transcribeOne.ts` name the output file ffmpeg or the transcription app writes, which is not our write to fold. No file's contents change — every writer puts the same bytes on disk it did before, measured over the live corpus. The cookie jar is still created readable only by you. +- **The video page's chore cards are one file each.** Redownload, Transcode, Transcribe, Transcript source, Mark untranscribable, Source video, Fetched windows, Archive media, Truncated check, Files, the Danger zone, the availability history, the download-outcome badge and the two truncation banners each moved out of one 1,839-line `VideoPanel.tsx` into their own module under `videos/[id]/components/cards/`; `VideoPanel` is now just their order and when each shows, and `lib/videoChoreCards.ts` lists them — with why each is a chore and not an operation — beside the registry's operation panels. Nothing on the page changed: same cards, same order, same labels. - **Every channel table and every job-in-flight line is now drawn one way.** The /channels rack, the dashboard's Channels table and the work tables on the operation pages and /cleanup are one table with a column set per page, over one channel row built on the server (which no longer ships a channel's config to the browser); the dashboard's "Needs work" seed is computed by the same code the widget endpoint serves. On the jobs side, /jobs rows, the "Active jobs" cards on channel/video/operation pages, the monitor widget's Active jobs strip and the operations board's "In flight" list are one job row in three sizes, with one rule for which buttons (Retry / Reorder / Drain / Cancel / Force-release) a job gets. **What you might notice:** a work table's report column reads "stale"/"missing" like the rack's instead of a date; the dashboard's Sync button is the rack's; a lane line on /jobs offers Force-release while its runner is running; widget job lines show who asked for the job; an in-flight download on the operations board links to its job page. Nothing a count says moved. - **`site.json`, each channel's `config.json` and the per-video sidecars now have one schema each, and the two config files have generated key tables.** **`SITE.md`** and **`CHANNEL.md`** (new, repo root) list every key with its default and meaning, generated by `common/bin/file-schemas-docs.ts` and checked by a test. Nothing an operator has configured reads or saves differently: every live `site.json` and `config.json`, and a 1,763-file sample of sidecars, read and write back byte-for-byte as before. **Fixed:** a social-channel fetch no longer undoes Configure-form edits made while it was running (it used to write back the whole config it read when it started). Every change to a channel's config now re-reads the file at the moment it saves and changes only its own fields, so a sync stamping its time and a form save made at the same moment both land. Two writes to the same file from the editor no longer share one temporary file. - **`settings.json` has one schema and one writer, and its key table is generated.** Every key, its default, its clamp and its documentation is now one zod schema (`common/lib/settingsSchema.ts`); `getSettings`/`writeSettings` both parse through it, and every settings form saves through one helper (`editor/app/settings/saveSettings.ts`) that merges only what the form changed. **`SETTINGS.md`** (new, repo root) lists every key with its default and what it does, and `settings.json.example` is now the full default object — both generated by `common/bin/settings-example.ts` and checked by a test, so neither can drift. Nothing an operator has configured reads differently. **Fixed:** adding or editing a storage location on `/storage` no longer erases the record of which location the saved-video store is on (`storage.savedVideosLocationId`). 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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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", + }, +]; diff --git a/plans/one-core-phase-3.md b/plans/one-core-phase-3.md @@ -954,6 +954,105 @@ tip: 315 `old-idiom=same`, 0 DIFF/THREW/LEFT, **diff empty** (`w-m-numbers-{before,after}.txt`). - e2e, the same 20 specs, on ports 3411/3410: **140 passed, 0 failed, 8.8 min**. +### Slice 3b, as shipped — the video page's chore cards, one module each (2026-09-24) + +Branch `one-core/phase-3-s3b` off `main` `4130aca1`, one code commit and this record. +Main is merged in once slices P and W are on it; the post-merge gates are added below then. + +**What, and why it is smaller than the inventory said.** Both premises of the 3b inventory +above were stale. + +- *Channel pipeline — nothing to do, and nothing was done.* The nine files under + `channels/[slug]/components/stages/` (3,866 lines) hold no duplicated status logic. + `computeStageStatuses` (`common/views/pipeline/stageStatus.ts:192`) and `computeChannelFlow` + (`channelFlow.ts:220`) are the single fold, each called once, in `channels/[slug]/page.tsx:256` + and `:308`; every stage card takes derived id-lists and sums as props and keeps only form + state. The bookend chores are hand-listed once, `stageOrder` at `page.tsx:278-286` around + `GROUP_STAGES` (`stageStatus.ts:53-106`). The stage half of 3b was already satisfied; no + stage file is touched. +- *Video page — the registry split already existed.* `videoOperationPanels.ts` + + `OperationPanel.tsx` render digest / diarization / attribution after `<VideoPanel>` + (`videos/[id]/page.tsx:254-266`). What was still inline in `VideoPanel.tsx` (1,839 lines) + was ~16 card bodies for video CHORES, which no registry entry owns. 3b split those, and + only those, one module per card, behaviour-free. + +| sha | what | +|---|---| +| `315cec1f` | `video page: one module per chore card` — 15 card modules + `Heading.tsx` + `videoFiles.ts` under `videos/[id]/components/cards/`; `VideoNavStrip.tsx` and `PipelineStatusStrip.tsx` beside `VideoPanel`; `VideoPanel.tsx` 1,839 → 455 (the assembly); `lib/videoChoreCards.ts` (+test) | +| (this commit) | this record, changelog | + +**The move.** Each function moved verbatim with its state hooks, its imports and the +server-action import(s) it calls — the 13 names from `../videoActions` now sit in the card +that uses them (`downloadVideoPipelineAction` in two: `RedownloadSection` and +`ShortAudioBanner`). Children stay with their only caller: `PersistedSourceVideo` in +`SourceVideoSection.tsx`, `DeleteFileButton` in `FilesList.tsx`. Two helpers shared by two +cards got small modules: `cards/Heading.tsx` (both per-file rows) and `cards/videoFiles.ts` +(`VideoFile`, `WHISPER_FILENAME`, `CANONICAL_VTT`, the extension predicates, `mediaUrl`). +`VideoPanel` re-exports `VideoFile` and `FetchedWindow`, so neither page's import changed. +The only edits inside moved bodies are four comments whose "above"/"below"/":360" pointed at +a neighbour that is now in another file. `videoActions.ts` is untouched (slice W owns it). +`git show --numstat`: `VideoPanel.tsx` +40 −1,424; cards + strips +1,522 (the 1,366 moved +lines plus each module's directive and imports); `videoChoreCards.ts` + test +190. + +**Why 455 lines, not ~300.** The `VideoPanel()` function itself — gating booleans, summaries, +and the fifteen cards' `PipelineStageCard` wrappers — is 360 lines, and keeping ORDER and +gating exactly was the rule. Driving the JSX from the hand-list would shorten it and was +allowed, but every card's gate and props differ, so the literal reads better; the list's test +pins the two together instead. + +**`lib/videoChoreCards.ts`** — the video page's twin of the channel page's bookends: fifteen +rows `{id, component, shown, why}` in render order (download-outcome, incomplete-transcript, +short-audio, availability-history, download, transcode, transcribe, transcript-source, +mark-untranscribable, source-video-persistence, fetched-windows, archive-media, +truncated-check, files, danger), each saying why it is a chore and not an operation. The +download and transcribe rows are the EXTERNAL operations' per-video run surface, which +`videoOperations.ts` deliberately leaves out of its reader. Not folded into the registry. Not +listed: the nav strip, the status strip, the umtool link and the one-line "media archived" +status. `videoChoreCards.test.ts` reads `VideoPanel.tsx` as text (a node test cannot load +client components) and pins that the cards it renders, in order, and the cards it imports +from `./cards/`, are exactly the list's. + +**Labels.** `$T/s3b-labels.py` extracts every `aria-label` (literal, template, and both arms +of a conditional), `"aria-label":` spread, `role`, `label`/`buttonLabel`/`runningLabel`/ +`actionLabel`/`title`/`id` literal and every `…stage summary…`/`for ${x}` template. Over the +old `VideoPanel.tsx` and over the new tree (`VideoPanel.tsx`, the two strips, `cards/*`): +126 entries each, sorted, **identical — md5 `3f89e5c1c9f79ad1a04ceea4568f0500` both**. + +**Gates.** tsc (`pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`) clean at +`315cec1f`. common **1723/1723** (unchanged — no common file touched); editor unit **67 → 70** +(+3 `videoChoreCards.test.ts`); `test:scripts` 156 pass + 1 skip of 157. Builds: editor +`next build` exit 0, route table lists `ƒ /api/view/[name]`; export `next build` exit 0. +**e2e** (run 1, at `315cec1f`, from the worktree root, detached, ports 3311/3310): the 19 +specs named in the slice prompt, all present — attribution, auto-report-refresh, +channel-storage, cleanup-holds, digest, do-not-clean, download-format-guard, fetch-window, +incomplete-transcript, reconcile, reconstruct-download-url, saved-videos, storage-locations, +tags, transcript-source, truncated-check, undownloaded, video-page, whisper-video — +**108 passed, 0 failed, exit 0, 9.8 min** (16 min wall, the rest behind the queue lock), first +run, nothing re-run. +**Numbers tool: none for this slice** — no payload, view or file on disk changes; the label +diff above is the equivalent check. + +**Found and left.** +- `VideoNavStrip`'s prop type declares a `umtoolUrl` it never reads (the umtool link is drawn + by `VideoPanel`) — moved as-is. +- `pnpm --filter export run build` runs `build:data` first, which writes a fresh `index.mdb` + under the worktree's own (absent) `transcripts/`, then fails on `compose-site: SITE_ID env + var is required`. Harmless here (0 sites, nothing written or removed under the linked + `export/public`; the stray `transcripts/` was deleted), but it is the wrong command for a + worktree gate — `pnpm --filter export exec next build` is the one release 3 used. +- Commit trailer: `315cec1f` carries `Claude Opus 5.5 (1M context)`, the model that wrote it, + not the rules file's `Claude Fable 5.1`. + +**After merging `main` (`ddad13f4`, slices P and W) — merge `2e322e37`.** Conflicts only in +`editor/CHANGELOG.md` and this file, resolved by keeping both sides in P, W, 3b order; +`main` changed nothing under `videos/[id]/` but W's import lines in `videoActions.ts`, which +3b never touched. Gates on the merged tip: tsc clean; common **1738/1738**; editor unit +**72/72** (main's 69 + 3); `test:scripts` 156 + 1 skip of 157; editor `next build` exit 0 +(`ƒ /api/view/[name]` listed); export `next build` exit 0. **e2e** run 2, the same 19 specs, +detached from the worktree root: **108 passed, 0 failed, exit 0, 6.5 min**, first run. +Labels, `4130aca1`'s `VideoPanel.tsx` against the merged tree: 126 entries each, identical, +md5 `3f89e5c1c9f79ad1a04ceea4568f0500` both. + ## Next release — slice 3b and Phase 4 (inventory kept from 2026-09-23) Slices 3a and 4b shipped in the release above (2026-09-24); the slice 3b bullets and the