import Link from "next/link";
import { formatBytes } from "yt-dlp-transcript-common/lib/format";
import {
actionableCleanExtraFormatsBytes,
actionableCleanExtraFormatsCount,
actionableCleanTranscribedBytes,
actionableCleanTranscribedCount,
actionableDigestWarningsCount,
actionableIncompleteTranscriptCount,
actionableMetadataScanCount,
actionableMissingNeverFetchedCount,
actionableShortAudioCount,
actionableUndownloadedCount,
actionableUntranscribedCount,
type ActionableRow,
type ActionableSummary,
} from "../../lib/actionable/loadActionable";
import { InlineActionButton } from "../actions/InlineActionButton";
import { FixAllIncompleteButton } from "./FixAllIncompleteButton";
// The per-channel work sections that used to be a literal array inside
// /actionable/page.tsx. They live here because they belong to the OPERATION,
// not to a page: each one is now rendered by the operation page that runs it
// (and the two cleanup sections by /cleanup). The extension point moved with
// the sections; it did not disappear.
export type SectionConfig = {
id: string;
// The page that renders this section: an operation id for sections that are
// one operation's work, or null for the two cleanup sections (Storage,
// /cleanup's).
operation: "download" | "transcription" | "digest" | "metadata-scan" | null;
// "work" is what the runner or sweep will do; "attention" is what a human
// must look at first — a truncated download is not one the runner can retry.
role: "work" | "attention";
// Which summary list this section draws — declared here so no page
// hand-pairs a config with a list. The old page.tsx paired them by hand, the
// same hand-exhaustiveness as its "nothing pending" gate.
getRows: (summary: ActionableSummary) => ActionableRow[];
title: string;
description: string;
countLabel: string;
emptyLabel: string;
getCount: (row: ActionableRow) => number;
// Optional extra column rendered after the count (used by the cleanup
// sections to show estimated reclaimable disk space).
extraColumn?: {
label: string;
getValue: (row: ActionableRow) => string;
};
primaryAction: (row: ActionableRow) => React.ReactNode | null;
// Optional control rendered in the section header (e.g. an act-on-all button).
headerAction?: React.ReactNode;
};
// A function, not a constant: `headerAction` is a React element, and an element
// built once at module scope would be shared across every request.
export function channelWorkSections(): SectionConfig[] {
return [
{
id: "undownloaded",
operation: "download",
role: "work",
getRows: (s) => s.undownloaded,
title: "Channels with undownloaded videos",
description:
"Playlist entries that have no audio/video on disk yet. Run “Download missing” to fetch them.",
countLabel: "undownloaded",
emptyLabel: "Nothing pending.",
getCount: actionableUndownloadedCount,
primaryAction: (r) => (
),
},
{
id: "metadata-scan",
operation: "metadata-scan",
role: "work",
getRows: (s) => s.metadataScan,
title: "Channels whose download filter is waiting on titles",
description:
"These channels have a download filter and listed videos nobody has read the title of, so the filter cannot decide about them yet. The scan fetches title, description and date — no media, and no video directory — and every non-match is then settled without ever being downloaded.",
countLabel: "unscanned",
emptyLabel: "Every filtered channel is fully scanned.",
getCount: actionableMetadataScanCount,
primaryAction: (r) => (
),
},
{
id: "missing-never-fetched",
operation: "download",
role: "attention",
getRows: (s) => s.missingNeverFetched,
title: "Channels with videos lost before they were ever downloaded",
description:
"Videos that were in the channel listing, were never fetched, and have since left it. Nothing of them exists on disk — only the URL the roster kept, which is enough to attempt a direct-link download. A video that merely went unlisted still downloads; a deleted one will not. Open the channel's Diagnostics stage to try them.",
countLabel: "never fetched",
emptyLabel: "None lost.",
getCount: actionableMissingNeverFetchedCount,
primaryAction: (r) => (
Review
),
},
{
id: "untranscribed",
operation: "transcription",
role: "work",
getRows: (s) => s.untranscribed,
title: "Channels with downloaded videos awaiting transcription",
description:
"Videos with audio on disk but no whisper or yt-vtt transcript yet. Run “Transcribe pending” to whisper them.",
countLabel: "awaiting transcript",
emptyLabel: "Nothing pending.",
getCount: actionableUntranscribedCount,
primaryAction: (r) => (
),
},
{
id: "incomplete-transcripts",
operation: "transcription",
role: "attention",
getRows: (s) => s.incompleteTranscripts,
title: "Channels with incomplete (truncated) transcripts",
description:
"Transcribed videos whose transcript covers only a small fraction of the runtime — the audio download stopped early. “Re-download & re-transcribe” queues a batch fix; “Clear & re-queue” deletes the truncated audio + transcript and hands them to the auto-runners.",
countLabel: "truncated",
emptyLabel: "None detected.",
getCount: actionableIncompleteTranscriptCount,
headerAction: ,
primaryAction: (r) => (
Review
),
},
{
id: "digest-warnings",
operation: "digest",
role: "attention",
getRows: (s) => s.digestWarnings,
title: "Channels with digest passes that need a look",
description:
"Videos where the AI digest pass recorded warnings, or produced nothing usable at all. The second kind is the one worth opening: a total failure deliberately writes no section so the video retries, and “the model proposed nothing” and “the model proposed chapters and every one was rejected by a guard” look identical from outside but need different fixes.",
countLabel: "with warnings",
emptyLabel: "None recorded.",
getCount: actionableDigestWarningsCount,
primaryAction: (r) => (
Review
),
},
{
id: "short-audio",
operation: "download",
role: "attention",
getRows: (s) => s.shortAudio,
title: "Channels with truncated downloads (short audio)",
description:
"Downloads that completed but whose audio is far shorter than the video — the source served a truncated stream, caught before transcription. The stub is kept (not transcribed). “Re-download (corrected format)” deletes it and re-fetches with the per-source default (Original for Odysee).",
countLabel: "truncated",
emptyLabel: "None detected.",
getCount: actionableShortAudioCount,
primaryAction: (r) => (
Review
),
},
{
id: "clean-transcribed-audio",
operation: null,
role: "work",
getRows: (s) => s.cleanTranscribedAudio,
title: "Channels with cleanable transcribed audio",
description:
"Videos that already have a whisper transcript but still keep their audio on disk. Run “Clean audio” to reclaim space. Videos marked “do not clean” are excluded.",
countLabel: "cleanable",
emptyLabel: "Nothing to clean.",
getCount: actionableCleanTranscribedCount,
extraColumn: {
label: "Est. reclaim",
getValue: (r) => `~${formatBytes(actionableCleanTranscribedBytes(r))}`,
},
primaryAction: (r) => (
),
},
{
id: "clean-extra-formats",
operation: null,
role: "work",
getRows: (s) => s.cleanExtraFormats,
title: "Channels with extra audio formats",
description:
"Videos that have the channel's configured audio format plus other leftover audio files. Run “Clean extra formats” to keep only the target format. Videos marked “do not clean” are excluded.",
countLabel: "extra formats",
emptyLabel: "Nothing to clean.",
getCount: actionableCleanExtraFormatsCount,
extraColumn: {
label: "Est. reclaim",
getValue: (r) => `~${formatBytes(actionableCleanExtraFormatsBytes(r))}`,
},
primaryAction: (r) => (
),
},
];
}
// Work first, then attention, declaration order within each. An unknown
// operation id returns [] — which is what makes the cast at the /operations
// call site safe.
export function sectionsFor(
operation: SectionConfig["operation"],
): SectionConfig[] {
const mine = channelWorkSections().filter((s) => s.operation === operation);
return [
...mine.filter((s) => s.role === "work"),
...mine.filter((s) => s.role === "attention"),
];
}