"use client"; import { useState } from "react"; import Link from "next/link"; import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import { formatCount } from "yt-dlp-transcript-common/views/pipeline/tone"; import { cancelJobAction } from "../../jobs/actions"; import { transcribeBucketAction, transcribeAutoSubsBucketAction, diarizeChannelAction, } from "../../channels/[slug]/whisperActions"; import type { CleanupLedger, LedgerRow } from "../lib/loadCleanup"; // THE RELEASE LEDGER. The sieve says what is holding the audio; this says what // to run to get it back, split by WHAT IT COSTS to release — a run of a lane // that is already weeks deep, or a setting that frees the space the moment it // changes. That split is a true property of the data, and it is the sharpest // answer to "what frees space quickest". // // Channels rank by held bytes, descending, inside each group. const ROW_LIMIT = 5; export function ReleaseLedger({ ledger }: { ledger: CleanupLedger }) { const { noTranscript, awaitingDiarization, notCounted, keepLatest, doNotClean } = ledger; const hasRuns = noTranscript.bytes > 0 || awaitingDiarization.bytes > 0; const hasSettings = notCounted.bytes > 0 || keepLatest.bytes > 0 || doNotClean.bytes > 0; if (!hasRuns && !hasSettings) return null; return (
{hasRuns && (
Costs a run {noTranscript.bytes > 0 && ( } /> )} {awaitingDiarization.bytes > 0 && ( ( diarizeChannelAction(row.slug)} cancelAction={cancelJobAction} buttonLabel="Diarize" runningLabel="Diarizing…" label={`Diarize ${row.slug}`} /> )} note={ awaitingDiarization.neverClearsBytes > 0 ? ( ~{formatBytes(awaitingDiarization.neverClearsBytes)} of this will not clear on its own — those videos are over the diarization limit, have no diarizable input, or the lane is not configured to run at all. ) : undefined } /> )}
)} {hasSettings && (
Frees on a setting {/* Stated precisely, and deliberately NOT called a hold. excludeFromCleanup is display-only — the sweep never reads it — so this space is already reclaimable today. Excluding a channel hides it from the total; it does not protect it. */} {notCounted.bytes > 0 && ( )} {keepLatest.bytes > 0 && ( )} {doNotClean.bytes > 0 && ( )}
)}
); } function GateGroup({ n, label, bytes, count, rows, action, note, }: { n: number; label: string; bytes: number; count: number; rows: LedgerRow[]; action: (row: LedgerRow) => React.ReactNode; note?: React.ReactNode; }) { const { shown, hidden, expanded, toggle } = useExpandableList(rows, ROW_LIMIT); return (

{label} — ~{formatBytes(bytes)} · {formatCount(count)} vids

{note && (

{note}

)}
); } // The Transcribe half of the prescription. Two buttons at most, and the second // only when the channel HAS ASR-only videos: those carry an English VTT, so they // are absent from downloadedNoTranscript and are still holding their audio. function TranscribeButtons({ row }: { row: LedgerRow }) { const ids = row.transcribeIds; const auto = row.autoSubsIds; if (!ids) { // Past the id cap — the same control lives on the channel page. return ( on the channel ); } return ( <> {ids.length > 0 && ( transcribeBucketAction( row.slug, ids, undefined, undefined, false, "downloadedNoTranscript", ) } cancelAction={cancelJobAction} buttonLabel={`Transcribe ${ids.length}`} runningLabel="Transcribing…" label={`Transcribe ${row.slug}`} /> )} {auto && auto.length > 0 && ( transcribeAutoSubsBucketAction(row.slug, auto)} cancelAction={cancelJobAction} buttonLabel={`Transcribe ${auto.length} ASR-only`} runningLabel="Transcribing…" label={`Transcribe ASR-only ${row.slug}`} /> )} {ids.length === 0 && (!auto || auto.length === 0) && ( on the channel )} ); } function SettingRow({ label, bytes, meta, hint, rows, }: { label: string; bytes: number; meta: string; hint: string; rows?: LedgerRow[]; }) { const { shown, hidden, expanded, toggle } = useExpandableList( rows ?? [], ROW_LIMIT, ); return (

{label} ~{formatBytes(bytes)} {meta} {hint}

{rows && rows.length > 0 && (
    {shown.map((row) => (
  • {row.name} ~{formatBytes(row.bytes)}
  • ))}
)}
); } // Mirrors the widget's list disclosure (MonitorWidget.tsx) — same rule: `hidden` // is computed against the LIMIT, not against what is shown, so the control keeps // its label once expanded instead of vanishing mid-use. function useExpandableList(items: T[], limit: number) { const [expanded, setExpanded] = useState(false); const hidden = Math.max(0, items.length - limit); return { shown: expanded ? items : items.slice(0, limit), hidden, expanded, toggle: () => setExpanded((e) => !e), }; } function ListOverflowToggle({ hidden, expanded, onToggle, noun, }: { hidden: number; expanded: boolean; onToggle: () => void; noun: string; }) { if (hidden === 0) return null; return (
  • ); } function Eyebrow({ children }: { children: React.ReactNode }) { return (

    {children}

    ); }