"use client"; import Link from "next/link"; import { Fragment, useEffect, useState } from "react"; import type { ChannelGroupSectionView, StationId, StationWork, } from "yt-dlp-transcript-common/views/channelGroupSections"; import { digestChannelGroupAction, downloadChannelGroupAction, speakersChannelGroupAction, syncChannelGroupAction, transcribeChannelGroupAction, type GroupOpResult, } from "../groupActions"; // The channel page's transit line, collapsed to the five runnable stages and // scoped to one group. // // THE SIGNATURE: you cannot press a stage without the number it will act on // being the label you pressed. Hairline connectors keep the five reading as one // pipeline rather than a toolbar. type Station = { id: StationId; // Fixed for four stations; the speakers station reads its label off the // section (`speakersLabel`), because what that lane is called depends on which // operations are switched on and only the server can ask the registry. label: string | ((section: ChannelGroupSectionView) => string); // What replaces the button once the group's work is provably finished. A dead // button reading "Download 0" invites a click that would do nothing. done: string; // Why this station cannot run for this group at all. notEligible: string; // GPU-weeks at group scale — every stage but Sync confirms first. confirm?: (group: string, count: string) => string; run: (siteId: string, groupId: string) => Promise; }; const STATIONS: Station[] = [ { id: "sync", label: "Sync", done: "Synced", notEligible: "No channel in this group has a URL to sync.", run: syncChannelGroupAction, }, { id: "download", label: "Download", done: "Downloaded", notEligible: "No channel in this group can download (no URL, or posts only).", confirm: (group, count) => `Download ${count} not-yet-downloaded video(s) across every channel in "${group}"? They queue one channel at a time per platform.`, run: downloadChannelGroupAction, }, { id: "transcribe", label: "Transcribe", done: "Transcribed", // Reachable only when every member is a social account: handling does not // decide eligibility (channelGroupSections.ts), the files do. No method is // named — which engine runs is the transcription settings' business. notEligible: "No channel in this group has downloaded audio to transcribe.", confirm: (group, count) => `Transcribe ${count} downloaded video(s) across every channel in "${group}"? They run one at a time on the transcription queue, and each takes minutes.`, run: transcribeChannelGroupAction, }, { id: "digest", label: "Digest", done: "Digested", notEligible: "No channel in this group has a transcript to digest yet.", confirm: (group, count) => `Digest ${count} transcript(s) across every channel in "${group}" on the local lane? On a large group this is hours to days of GPU time.`, run: digestChannelGroupAction, }, { id: "speakers", label: (section) => section.speakersLabel, done: "Speaker work current", notEligible: "No channel in this group has media for a speaker operation to work on.", confirm: (group, count) => `Run every enabled speaker operation over ${count} video(s) across every channel in "${group}"? On a large group this is hours to days of work.`, run: speakersChannelGroupAction, }, ]; type Status = | { kind: "idle" } | { kind: "running"; station: StationId } | { kind: "done"; station: StationId; result: GroupOpResult } | { kind: "error"; station: StationId; message: string }; // The figure printed on the control. Null means the station carries no figure // (Sync — syncAction decides per channel whether it is due, so there is nothing // to promise). function figureOf(work: StationWork, station: StationId): string | null { if (station === "sync") return null; // A floor, not a total: some channels have never reported. Never 0 when the // page cannot say — the house rule. if (work.total === 0 && work.unknown.length > 0) return "—"; if (work.unknown.length > 0) return `${work.total.toLocaleString()}+`; return work.total.toLocaleString(); } function unknownTitle(work: StationWork): string | undefined { if (work.unknown.length === 0) return undefined; return ( `No report yet for ${work.unknown.join(", ")}, so this is a floor — ` + `run Refresh report on those channels for a firm number.` ); } export function ChannelGroupLine({ section, siteId, }: { section: ChannelGroupSectionView; siteId: string; }) { const [status, setStatus] = useState({ kind: "idle" }); // Disabled until mounted, deliberately. A server-rendered button has no // handler until React hydrates, so a click before then fires NOTHING — no // request, no job, no error, nothing to debug. That is the exact failure that // made the digest pilot's job look "un-created". const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); const group = section.group; const name = group.name || group.id; const busy = status.kind === "running"; async function press(station: Station, work: StationWork) { const confirmText = station.confirm?.( name, figureOf(work, station.id) ?? String(work.eligible.length), ); if (confirmText && !window.confirm(confirmText)) return; setStatus({ kind: "running", station: station.id }); try { setStatus({ kind: "done", station: station.id, result: await station.run(siteId, group.id), }); } catch (e) { setStatus({ kind: "error", station: station.id, message: (e as Error).message, }); } } return (
{STATIONS.map((station, i) => { const work = section[station.id]; const label = typeof station.label === "function" ? station.label(section) : station.label; const figure = figureOf(work, station.id); const running = status.kind === "running" && status.station === station.id; return ( {i > 0 && ( /* Hairline connector: five buttons read as a toolbar, five stations on a line read as the pipeline they are. */ ); })} {status.kind === "done" && ( `${s.slug}: ${s.reason}`) .join("\n") } > Queued {status.result.queued.length} · skipped{" "} {status.result.skipped.length} ·{" "} view jobs )} {status.kind === "error" && ( {status.message} )}
); } const CHIP = "px-2 py-1 rounded border border-border text-xs whitespace-nowrap " + "bg-background";