"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 && (
)}
);
}
// 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 (