import type { Metadata } from "next"; import type { ArchiveManifestEntry } from "yt-dlp-transcript-common/lib/archiveOptions"; import { ArrowDownToLine, FileText, MessagesSquare } from "lucide-react"; import { currentSite } from "../lib/site"; import { readArchiveManifest } from "../lib/archives"; import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema"; import { NotOnCitedSite } from "../components/reports/parts"; export const metadata: Metadata = { title: "Downloads" }; function humanBytes(n: number): string { if (n <= 0) return "0 B"; if (n < 1024) return `${n} B`; const units = ["KB", "MB", "GB"]; let v = n / 1024; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`; } function countLabel(kind: ArchiveManifestEntry["kind"], n: number): string { const noun = kind === "transcripts" ? "transcript" : "chat log"; return `${n.toLocaleString()} ${noun}${n === 1 ? "" : "s"}`; } // A scope slug with no channelTitle (e.g. "rekietalaw-rumble") is humanized into // a readable heading rather than shown as a raw slug. function humanizeScope(scope: string): string { return scope .split(/[-_]/) .filter(Boolean) .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); } // One channel's holdings: the manifest carries transcripts and live chat as // separate entries that share a scope, so the page groups them back into a // single record. Counts/bytes sum only the AVAILABLE holdings (an oversize // bundle with no overflow URL isn't downloadable, so it shouldn't inflate the // "grab the whole channel" figures). type ChannelGroup = { scope: string; title: string; entries: ArchiveManifestEntry[]; videoCount: number; bytes: number; availableCount: number; }; const KIND_ORDER: Record = { transcripts: 0, "live-chat": 1, }; function isAvailable(e: ArchiveManifestEntry): boolean { return !e.oversize || !!e.url; } function groupByChannel(entries: ArchiveManifestEntry[]): ChannelGroup[] { const byScope = new Map(); for (const e of entries) { let group = byScope.get(e.scope); if (!group) { group = { scope: e.scope, title: "", entries: [], videoCount: 0, bytes: 0, availableCount: 0, }; byScope.set(e.scope, group); } group.entries.push(e); if (!group.title && e.channelTitle) group.title = e.channelTitle; if (isAvailable(e)) { group.videoCount += e.videoCount; group.bytes += e.bytes; group.availableCount += 1; } } const groups = [...byScope.values()]; for (const g of groups) { if (!g.title) { g.title = g.scope === "all" ? "Everything" : humanizeScope(g.scope); } g.entries.sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind]); } return groups; } // One holding within a channel record: a transcript or live-chat bundle. Left is // the kind (icon + label) over a "call number" subline — the item count and the // archive filename, in mono. Right is the pull action: a padded download tab // showing the size, or, for an oversize bundle with no overflow host, the reason // it isn't served. Both variants share the same row padding so nothing butts the // card edge. function HoldingRow({ entry }: { entry: ArchiveManifestEntry }) { const Icon = entry.kind === "transcripts" ? FileText : MessagesSquare; const kindLabel = entry.kind === "transcripts" ? "Transcripts" : "Live chat"; const href = entry.url ?? `/archives/${entry.filename}`; return (
{kindLabel}
{countLabel(entry.kind, entry.videoCount)} · {entry.filename}
{isAvailable(entry) ? ( {humanBytes(entry.bytes)} ) : (
Too large to host {humanBytes(entry.bytes)}
)}
); } // A channel rendered as a catalog record: a masthead (title + summed holdings) // over its bundles, hairline-divided. The load-in stagger is a single quiet // accent; it yields to prefers-reduced-motion (see globals.css .record-rise). function ChannelRecord({ group, index, }: { group: ChannelGroup; index: number; }) { const meta = group.availableCount > 0 ? `${group.videoCount.toLocaleString()} video${group.videoCount === 1 ? "" : "s"} · ${humanBytes(group.bytes)}` : "Unavailable"; return (

{group.title}

{meta}

{group.entries.map((e) => ( ))}
); } export default function DownloadsPage() { const site = currentSite(); if (isCitedSite(site)) return ; const manifest = readArchiveManifest(); const entries = manifest?.entries ?? []; if (entries.length === 0) { return (

Downloads

No archives have been published for this site yet. Once a build with archive generation runs, the transcript and live-chat download bundles appear here.

); } const groups = groupByChannel(entries); const available = entries.filter(isAvailable); const channelCount = groups.filter((g) => g.availableCount > 0).length; const bundleCount = available.length; const totalBytes = available.reduce((n, e) => n + e.bytes, 0); return (

Archive · {site.headerTitle}

Take the whole channel with you

Every transcript and live-chat log on this site, bundled per channel as{" "} .zip files. Each bundle holds the compact cues JSON per video plus a channel manifest — grab a whole channel at once.

{channelCount} channel{channelCount === 1 ? "" : "s"} · {bundleCount}{" "} bundle{bundleCount === 1 ? "" : "s"} · {humanBytes(totalBytes)} total

{groups.map((g, i) => ( ))}

Archives refresh on every site build. Transcripts and live chat are packaged separately; channels with no captured live chat have no live-chat bundle.

); }