import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; import path from "node:path"; import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; import { loadDownloadOutcome } from "yt-dlp-transcript-common/lib/downloadOutcome-server"; import { loadAvailability } from "yt-dlp-transcript-common/lib/availability-server"; import { loadMetadataHistory } from "yt-dlp-transcript-common/lib/metadataHistory-server"; import { metadataHistoryView } from "yt-dlp-transcript-common/views/metadataHistoryView"; import { listClipWindows } from "yt-dlp-transcript-common/lib/clipWindow-server"; import { isDoNotClean } from "yt-dlp-transcript-common/lib/doNotClean-server"; import { isExcludedFromTruncatedCheck } from "yt-dlp-transcript-common/lib/excludeTruncatedCheck-server"; import { loadSavedVideo } from "yt-dlp-transcript-common/lib/savedVideo-server"; import { loadArchiveOrgProvenance } from "yt-dlp-transcript-common/lib/archiveOrg-server"; import type { ArchiveOrgProvenance } from "yt-dlp-transcript-common/lib/archiveOrg"; import { loadWaybackProvenance } from "yt-dlp-transcript-common/lib/wayback-server"; import { waybackCaptureDate, type WaybackProvenance } from "yt-dlp-transcript-common/lib/wayback"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { readVideoMetadataForDisplay, type VideoDisplayMetadata, } from "yt-dlp-transcript-common/controller/videoTitles"; import { isTranscriptVtt, resolvePrimaryVtt, } from "yt-dlp-transcript-common/lib/videoStatus"; import { resolveVttProvenance, type SubtitleProvenance, } from "yt-dlp-transcript-common/lib/subtitleProvenance"; import { readTranscriptCoverage } from "yt-dlp-transcript-common/controller/normalizeTranscript"; import { isIncompleteTranscript } from "yt-dlp-transcript-common/lib/transcriptCoverage"; import { platformQueueKey, queueKeyForUrl, } from "yt-dlp-transcript-common/lib/platform"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { resolveSourceVideoQuality } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import { liveJobRows } from "../../../../jobs/active/buildActiveJobs"; import { RunningJobsList } from "../../../../jobs/components/RunningJobsList"; import { VideoPanel, type VideoFile } from "./components/VideoPanel"; import { OperationPanel } from "./components/OperationPanel"; import { TagsPanel } from "./components/TagsPanel"; import { MetadataHistoryDetails } from "./components/MetadataHistoryDetails"; import { RefreshMetadataControl } from "./components/RefreshMetadataControl"; import { loadVideoTags } from "./lib/videoTags"; import { loadVideoOperationPanels } from "./lib/videoOperationPanels"; import { channelTextStall } from "yt-dlp-transcript-common/lib/channelMedia"; import { loadVideoDirFiles } from "../../lib/videoDirFiles"; import { isDriveNotAnswering, onDrive, } from "yt-dlp-transcript-common/lib/storageHealth"; import { MediaNotAnswering } from "../../components/MediaNotAnswering"; export const dynamic = "force-dynamic"; // The directory's files, tiered links included (release 17), each media // link's size asked of the media drive. See lib/videoDirFiles.ts. async function loadVideoDir( slug: string, videoId: string, mediaDir: string | undefined, ): Promise<{ files: VideoFile[] }> { const dir = path.join(getPaths().channelsDir, slug, "data", videoId); return loadVideoDirFiles(dir, mediaDir); } // metadata.info.json when the video was downloaded, else the channel's // metadata-scan.json entry for a listed-but-never-downloaded video — so an // undownloaded video's page is not a bare id. function loadMeta(slug: string, videoId: string): Promise { return readVideoMetadataForDisplay(getPaths(), slug, videoId); } export async function generateMetadata({ params, }: { params: Promise<{ slug: string; id: string }>; }): Promise { const { slug, id } = await params; // The title is TEXT, on the corpus disk (release 17). Only a channel still on // the retired whole-directory layout has its text on another drive: that // drive is not asked while it is not answering, and one that does not answer // within the budget (`storage.health.budgetMs`, 3 s by default) is given up // on. const drive = (await readChannelConfig(getPaths(), slug))?.dataDir?.trim(); let subject = id; try { const meta = await (drive ? onDrive(drive, () => loadMeta(slug, id)) : loadMeta(slug, id)); subject = meta.title ?? id; } catch (err) { if (!isDriveNotAnswering(err)) throw err; } return { title: `${subject} — Video — ${slug}` }; } export default async function VideoDetailPage({ params, }: { params: Promise<{ slug: string; id: string }>; }) { const { slug, id } = await params; const config = await readChannelConfig(getPaths(), slug); if (!config) notFound(); // Everything below reads the video's TEXT, which is on the corpus disk // (release 17): a stalled MEDIA drive does not hold this page — its audio // is listed with its size unknown, and the file route answers 503 for it. // Only a legacy channel (text on its retired `dataDir`) is held here. See // MediaNotAnswering. const stall = channelTextStall(config); if (stall) { return ; } const mediaDir = config.mediaDir?.trim() || undefined; // On a legacy channel EVERY READ BELOW is of a directory on its retired // drive, so they go through the watchdog as one unit: a drive that has not // answered them within the budget (3 s by default) is marked stalled and the // page says so. On every other channel they read the corpus disk directly; // the media links' sizes go through the media drive's watchdog on their own // (lib/videoDirFiles.ts). const loadAll = async () => { const dirData = await loadVideoDir(slug, id, mediaDir); const meta = await loadMeta(slug, id); const videoDir = path.join(getPaths().channelsDir, slug, "data", id); const downloadOutcome = await loadDownloadOutcome(videoDir); const availabilityRecord = await loadAvailability(videoDir); const availabilityHistory = availabilityRecord?.history ?? []; // Every rewrite of metadata.info.json that changed its bytes (release 10 // slice N). One small bounded file, read once; null → nothing drawn. const metadataHistory = metadataHistoryView( await loadMetadataHistory(videoDir), Date.now(), ); const doNotClean = await isDoNotClean(videoDir); const excludedFromTruncatedCheck = await isExcludedFromTruncatedCheck(videoDir); const savedVideo = await loadSavedVideo(videoDir); // Where an archive.org record came from (lib/archiveOrg-server.ts): the // item and its torrent, and a mirror's original. Absent everywhere else. const archiveOrg = await loadArchiveOrgProvenance(videoDir); // What a Wayback Machine copy is a copy of (lib/wayback-server.ts). const wayback = await loadWaybackProvenance(videoDir); // The windows another tool asked this editor to fetch. One readdir of // data//clips/ plus a stat per file — and no per-CHANNEL count anywhere, // because that would be a walk of every video dir to draw one number. const clipWindows = await listClipWindows(videoDir); // Where each subtitle track came from, so the panel can say "YouTube // auto-captions" vs "manual captions" — and offer to replace the former with a // transcript of our own. One 4 KB head read per VTT (see subtitleProvenance). const vttProvenance: Record = {}; for (const f of dirData.files) { if (!isTranscriptVtt(f.name)) continue; vttProvenance[f.name] = await resolveVttProvenance(videoDir, f.name); } const cov = await readTranscriptCoverage(videoDir); const coverage = cov ? { lastCueEnd: cov.cov.lastCueEnd, duration: cov.cov.duration, coverage: cov.cov.coverage, incomplete: !excludedFromTruncatedCheck && isIncompleteTranscript(cov.cov, { isLivestream: cov.isLivestream, }), } : null; // ONE PANEL PER REGISTRY OPERATION, each carrying the state that entry's own // state() reports. The page used to re-derive the digest's freshness here — // its own target resolution and its own per-section fold beside the // registry's — which is how a page and a work list end up describing the same // disk differently. const panels = await loadVideoOperationPanels({ paths: getPaths(), channelSlug: slug, videoId: id, settings: getSettings(), }); // The curated tags on this video, with each one's provenance. Reads tags.json // plus (for rule hits) ONE key out of the transcript index — not a scan. const tagsView = await loadVideoTags(slug, id); return { dirData, meta, videoDir, downloadOutcome, availabilityHistory, metadataHistory, doNotClean, excludedFromTruncatedCheck, savedVideo, archiveOrg, wayback, clipWindows, vttProvenance, coverage, panels, tagsView, }; }; const drive = config.dataDir?.trim(); let loaded: Awaited>; try { loaded = await (drive ? onDrive(drive, loadAll) : loadAll()); } catch (err) { if (!isDriveNotAnswering(err)) throw err; return ( ); } const { dirData, meta, videoDir, downloadOutcome, availabilityHistory, metadataHistory, doNotClean, excludedFromTruncatedCheck, savedVideo, archiveOrg, wayback, clipWindows, vttProvenance, coverage, panels, tagsView, } = loaded; const registry = getRegistry(); const existingQueues = registry.activeQueueNames(); // Through the one builder, so this list has the same progress bars /jobs does // (it used to drop `progress`, `tasks`, `drainable` and the reorder bounds). const activeJobs = await liveJobRows( (j) => j.channelSlug === slug && j.videoId === id, ); const defaultQueueKey = config.platform ? platformQueueKey(config.platform) : queueKeyForUrl(config.url); return (
Channels / {slug} / {id}
{/* h2, not h1. The channel layout above this route owns the page's single h1 (the channel name); a second one would make "the page's heading" ambiguous to a screen reader and to a locator. */}

{meta.title ?? id}

{meta.webpageUrl && ( Open on {new URL(meta.webpageUrl).hostname} )} {meta.uploader && {meta.uploader}} {meta.uploadDate && {formatUploadDate(meta.uploadDate)}} {typeof meta.duration === "number" && ( {formatDuration(meta.duration)} )} {meta.source === "scan" && ( from the listing scan — not downloaded )}
{archiveOrg && } {wayback && } {meta.description && (
Description

{meta.description}

)} {metadataHistory && } {/* Re-read the metadata above from the source (no subtitles, no media). Offered only for a video already in the archive (a data// with files — a listed, never-fetched video's page has none, and the action refuses it rather than create one), and not for an archive.org record or a Wayback copy: their metadata is not yt-dlp's, and the action refuses them too. */} {dirData.files.length > 0 && !archiveOrg && !wayback && ( )}
f.name))} vttProvenance={vttProvenance} handling={config.handling} defaultQueueKey={defaultQueueKey} existingQueues={existingQueues} downloadOutcome={downloadOutcome} availabilityHistory={availabilityHistory} channelAudioFormat={config.audioFormat} doNotClean={doNotClean} excludedFromTruncatedCheck={excludedFromTruncatedCheck} savedVideo={savedVideo} sourceVideoQuality={resolveSourceVideoQuality({ channel: config.sourceVideoQuality, global: getSettings().sourceVideoQuality, })} clipWindows={clipWindows.map((w) => ({ file: w.file, from: w.from, to: w.to, bytes: w.bytes, provenance: w.provenance, }))} coverage={coverage} /> {/* The external operations' per-video surface is the pipeline stage cards INSIDE VideoPanel above (download, transcode, transcription). These are the registry's — one panel per entry, and a fifth entry would appear here without a new page. */} {panels.map((p) => ( ))}
); } // "Archived on archive.org: · torrent", and for a mirror "Originally on // YouTube: (uploaded )". Terse, under the header line. function ArchiveOrgProvenanceLine({ prov }: { prov: ArchiveOrgProvenance }) { const link = "underline hover:text-foreground"; return (
Archived on archive.org:{" "} {prov.item.title ?? prov.identifier} {prov.file ? ` / ${prov.file}` : ""} {prov.torrentUrl && ( <> {" · "} torrent )} {prov.mirror && ( Originally on YouTube:{" "} {prov.mirror.url} {prov.mirror.uploadDate && ` (uploaded ${formatUploadDate(prov.mirror.uploadDate)})`} )}
); } // "Archived copy (Wayback Machine, ) of ". function WaybackProvenanceLine({ prov }: { prov: WaybackProvenance }) { const link = "underline hover:text-foreground"; return ( ); } function formatUploadDate(s: string): string { // yt-dlp emits YYYYMMDD. Render as YYYY-MM-DD; pass through anything else. if (/^\d{8}$/.test(s)) { return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`; } return s; } function formatDuration(seconds: number): string { const s = Math.round(seconds); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; return `${m}:${String(sec).padStart(2, "0")}`; }