Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 261f28fa9b033c642f631797dfda4e3668463603
parent 2e81d8b218c4c7f0403a39e1b3e516ba93271279
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 11:32:29 -0400

editor: a channel says where its media is, on every page that counts it

A Storage stage beside Cleanup and Diagnostics — a channel CHORE, not an
operation, so it is hand-listed at the call site and deliberately absent from
GROUP_STAGES. It draws the location, the status, the audio bytes the loaded
snapshot already carries (null renders "—", never 0: a zero would claim a
measurement nobody took), and the free space on the volume the media is on RIGHT
NOW — the platter for a relocated channel, the corpus disk otherwise.

Neither run panel is ever unmounted by its own result. StreamActionLog holds its
log in React state and calls router.refresh() the instant a run ends, and that
refresh re-renders this panel with `relocated` FLIPPED — so `{!relocated &&
<MoveOut/>}` would delete the log of the move that just succeeded, at the moment
the operator wants to read it. Both halves take the documented shape: the
condition is passed down, each holds a `ranHere` set inside its own trigger,
each returns null only while `!condition && !ranHere`, and each puts its log
last, keyed, with the cleared condition fed to `disabled` so the panel that
stays for its log is not a second Run button.

The Move button is gated behind a preview of the SAME root — "is there space"
is not a question this panel lets anyone skip, and editing the input makes the
confirmation stale because those numbers were measured against another volume.

The badge is one helper, three surfaces, and only three appearances for five
statuses: in-place draws NOTHING (a badge on every row saying "normal" hides the
two that matter), ok is neutral, and unreachable / in-transition / inconsistent
are all red — each is "do not trust what this channel's dirs say right now". It
is on /channels and the dashboard because an unmounted drive reads as "nothing
downloaded" to every other number on those rows.

In ChannelForm the location is a read-only line, not an input: `config.dataDir`
is written only by the relocate job, on success, after the bytes are verified.
A text box would let config and disk disagree with a keystroke — which is the
`inconsistent` status, and the thing inspectChannelMedia refuses to guess past.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Aeditor/app/channels/[slug]/components/stages/StorageStage.tsx | 311+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/lib/stageStatus.ts | 46++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/page.tsx | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/components/ChannelForm.tsx | 27+++++++++++++++++++++++++++
Meditor/app/channels/components/ChannelsTable.tsx | 24++++++++++++++++++------
Meditor/app/channels/page.tsx | 25+++++++++++++++++++++++++
Aeditor/app/components/MediaLocationBadge.tsx | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/components/dashboard/ChannelsTable.tsx | 16++++++++++------
Meditor/app/components/dashboard/types.ts | 8++++++++
Meditor/app/page.tsx | 22++++++++++++++++++++++
10 files changed, 628 insertions(+), 12 deletions(-)

diff --git a/editor/app/channels/[slug]/components/stages/StorageStage.tsx b/editor/app/channels/[slug]/components/stages/StorageStage.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useState } from "react"; +import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import { formatBytes } from "yt-dlp-transcript-common/lib/format"; +import type { ChannelMediaLocation } from "yt-dlp-transcript-common/lib/channelMedia"; +import type { RelocationPreview } from "yt-dlp-transcript-common/controller/relocateChannelMedia"; +import { MediaLocationBadge } from "../../../../components/MediaLocationBadge"; +import { cancelJobAction } from "../../../../jobs/actions"; +import { + moveChannelMediaBackAction, + previewRelocationAction, + relocateChannelMediaAction, +} from "../../storageActions"; + +// WHERE THIS CHANNEL'S MEDIA LIVES, and the two buttons that change it. +// +// The move itself is common/controller/relocateChannelMedia.ts; what this panel +// owns is the operator's decision. Three numbers are enough to make it: how much +// there is to move, how much room is free where it is now, and — once a root is +// named — how much room is free there. The third is what the preview is for, and +// it is the reason the Move button is gated behind one: "is there space" is not +// a question this panel should let anyone skip. +// +// ⚠️ NEITHER RUN PANEL IS EVER UNMOUNTED BY ITS OWN RESULT. +// StreamActionLog holds its streamed log in React state and calls +// router.refresh() the instant a run ends (plans/FACTS.md, "a run log lives in +// the panel's React state"). That refresh re-renders this panel from the server +// with `location.relocated` FLIPPED — so a naive `{!relocated && <MoveOut/>}` +// would delete the log of the move that just succeeded, at the exact moment the +// operator wants to read it. Both halves therefore follow the documented shape: +// the parent renders them unconditionally and passes the CONDITION down; each +// holds a `ranHere` flag set inside its own trigger; each returns null only +// while `!condition && !ranHere`; and each puts its log LAST, keyed, in a fixed +// slot, with the now-cleared condition fed to StreamActionLog's `disabled` so +// the panel that stays for its log is not a second Run button. + +type Props = { + slug: string; + location: ChannelMediaLocation; + // Audio bytes this channel holds, from the loaded snapshot — NOT a walk. Null + // when the snapshot predates the field (or there is no snapshot), and rendered + // as "—" rather than "0": a zero here would claim a measurement nobody took. + mediaBytes: number | null; + // Free space on the volume the media is on RIGHT NOW — the platter for a + // relocated channel, the corpus disk otherwise. + freeBytes: number; + volumeDir: string; + // Why both buttons are off, or null when they are live. Running/queued jobs + // for this channel, or a relocation marker left by an interrupted move. + blockedReason: string | null; +}; + +export function StorageStage({ + slug, + location, + mediaBytes, + freeBytes, + volumeDir, + blockedReason, +}: Props) { + return ( + <div className="flex flex-col gap-6"> + <section className="flex flex-col gap-2"> + <div className="flex items-center gap-3"> + <h3 className="text-base font-semibold">Location</h3> + <MediaLocationBadge media={location} /> + </div> + <dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm"> + <dt className="text-muted-foreground">Media path</dt> + <dd className="font-mono text-xs break-all" aria-label="media path"> + {location.relocated && location.target + ? location.target + : location.dataDir} + </dd> + <dt className="text-muted-foreground">Read as</dt> + <dd className="font-mono text-xs break-all"> + {location.dataDir} + {location.relocated ? " (symlink)" : ""} + </dd> + <dt className="text-muted-foreground">Audio on disk</dt> + <dd aria-label="media bytes"> + {mediaBytes === null ? "—" : formatBytes(mediaBytes)} + </dd> + <dt className="text-muted-foreground">Free on that volume</dt> + <dd aria-label="free on media volume"> + {formatBytes(freeBytes)}{" "} + <span className="text-xs text-muted-foreground font-mono"> + ({volumeDir}) + </span> + </dd> + </dl> + {location.detail && ( + <p className="text-sm text-muted-foreground">{location.detail}</p> + )} + {mediaBytes === null && ( + <p className="text-xs text-muted-foreground"> + No audio total in this channel&rsquo;s report yet — refresh the + report for a figure. The preview below measures the real tree + regardless, and it is the number the move acts on. + </p> + )} + </section> + + {blockedReason && ( + <p + role="status" + className="text-sm rounded border border-border bg-muted px-3 py-2" + > + {blockedReason} + </p> + )} + + <MoveOut + key="move-out" + slug={slug} + canMoveOut={!location.relocated} + blockedReason={blockedReason} + /> + <MoveBack + key="move-back" + slug={slug} + relocated={location.relocated} + target={location.target} + blockedReason={blockedReason} + /> + </div> + ); +} + +function MoveOut({ + slug, + canMoveOut, + blockedReason, +}: { + slug: string; + canMoveOut: boolean; + blockedReason: string | null; +}) { + const [root, setRoot] = useState(""); + const [preview, setPreview] = useState<RelocationPreview | null>(null); + // The root the preview above describes. Edit the input and the confirmation + // goes stale — the numbers were measured against a different volume, and + // letting them authorise a move to this one is exactly the mistake the gate + // exists to prevent. + const [previewedRoot, setPreviewedRoot] = useState(""); + const [previewError, setPreviewError] = useState<string | null>(null); + const [previewing, setPreviewing] = useState(false); + const [ranHere, setRanHere] = useState(false); + + if (!canMoveOut && !ranHere) return null; + + const trimmed = root.trim(); + const confirmed = trimmed !== "" && trimmed === previewedRoot.trim(); + const disabled = !canMoveOut || !confirmed || blockedReason !== null; + + async function runPreview() { + setPreviewing(true); + setPreviewError(null); + const result = await previewRelocationAction(slug, root); + setPreviewing(false); + if (result.ok) { + setPreview(result.preview); + setPreviewedRoot(root); + } else { + setPreview(null); + setPreviewedRoot(""); + setPreviewError(result.error); + } + } + + return ( + <section className="flex flex-col gap-2"> + <div> + <h3 className="text-base font-semibold">Move media to…</h3> + <p className="text-sm text-muted-foreground"> + Copies <code>data/</code> to <code>&lt;root&gt;/{slug}/data</code>, + verifies it, and leaves a symlink behind so every reader, yt-dlp and + the index keep working unchanged. The source is not touched until the + copy verifies. + </p> + </div> + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Destination root</span> + <input + type="text" + name="mediaRoot" + aria-label="destination root" + value={root} + onChange={(e) => setRoot(e.target.value)} + placeholder="/mnt/platter/archilyzer-media" + disabled={!canMoveOut} + className="rounded border border-border bg-card px-2 py-1 text-sm font-mono" + /> + <span className="text-xs text-muted-foreground"> + An absolute directory that already exists. One root holds many + channels; each gets its own <code>&lt;slug&gt;/data</code> under it. + </span> + </label> + <div> + <button + type="button" + onClick={runPreview} + disabled={!canMoveOut || trimmed === "" || previewing} + className="px-3 py-1.5 rounded-md border border-border text-sm font-medium disabled:opacity-50" + > + {previewing ? "Checking…" : "Preview"} + </button> + </div> + {previewError && ( + <p role="alert" className="text-sm text-destructive"> + {previewError} + </p> + )} + {preview && ( + <dl + aria-label="relocation preview" + className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm rounded border border-border bg-muted/40 px-3 py-2" + > + <dt className="text-muted-foreground">To move</dt> + <dd aria-label="bytes to move"> + {formatBytes(preview.bytesToMove)} in{" "} + {preview.files.toLocaleString()} file(s) + </dd> + <dt className="text-muted-foreground">Target</dt> + <dd className="font-mono text-xs break-all">{preview.target}</dd> + <dt className="text-muted-foreground">Free there</dt> + <dd aria-label="free on destination"> + {formatBytes(preview.freeOnRoot)} + </dd> + <dt className="text-muted-foreground">Free here</dt> + <dd>{formatBytes(preview.freeOnSource)}</dd> + </dl> + )} + {preview && preview.freeOnRoot < preview.bytesToMove && ( + <p role="alert" className="text-sm text-destructive"> + The destination has less free space than the media needs. The job will + refuse before copying anything. + </p> + )} + {preview?.sameDevice && ( + <p className="text-sm text-muted-foreground"> + That root is on the same volume the corpus is already on, so the move + frees nothing. + </p> + )} + {preview?.existingPartial && ( + <p className="text-sm text-muted-foreground"> + A partial copy from an earlier attempt is already at the target; this + run resumes it rather than starting over. + </p> + )} + {!confirmed && trimmed !== "" && ( + <p className="text-xs text-muted-foreground"> + Preview this root to enable the move. + </p> + )} + <StreamActionLog + key="move-out-log" + trigger={() => { + setRanHere(true); + return relocateChannelMediaAction(slug, root); + }} + cancelAction={cancelJobAction} + buttonLabel="Move media" + runningLabel="Moving media…" + label="Move media" + disabled={disabled} + /> + </section> + ); +} + +function MoveBack({ + slug, + relocated, + target, + blockedReason, +}: { + slug: string; + relocated: boolean; + target: string | undefined; + blockedReason: string | null; +}) { + const [ranHere, setRanHere] = useState(false); + if (!relocated && !ranHere) return null; + return ( + <section className="flex flex-col gap-2"> + <div> + <h3 className="text-base font-semibold">Move back in place</h3> + <p className="text-sm text-muted-foreground"> + {relocated + ? `Copies ${target ?? "the target"} back into the channel dir, verifies it, replaces the symlink with a real directory and clears the recorded location.` + : "This channel's media is in place."} + </p> + </div> + <StreamActionLog + key="move-back-log" + trigger={() => { + setRanHere(true); + return moveChannelMediaBackAction(slug); + }} + cancelAction={cancelJobAction} + buttonLabel="Move back in place" + runningLabel="Moving back…" + label="Move back in place" + disabled={!relocated || blockedReason !== null} + /> + </section> + ); +} diff --git a/editor/app/channels/[slug]/lib/stageStatus.ts b/editor/app/channels/[slug]/lib/stageStatus.ts @@ -10,6 +10,10 @@ import { reachableOperationWork, type OperationGroup, } from "yt-dlp-transcript-common/lib/operations"; +// TYPE ONLY. channelMedia.ts imports node:fs, and this module is imported by +// three client components (AttentionStrip, NextAction, OverviewPanel) for its +// types. A type import is erased, a value import would not be. +import type { ChannelMediaLocation } from "yt-dlp-transcript-common/lib/channelMedia"; export type SnapshotBuckets = ChannelSnapshot["buckets"]; @@ -59,6 +63,11 @@ export type StageId = | "speakers" | "cleanup" | "diagnostics" + // WHERE THE MEDIA PHYSICALLY IS (plans/relocate-channel-media.md). A channel + // CHORE like cleanup and diagnostics, not an operation — nothing registers it + // and no group owns it — so it is hand-listed at the call site alongside them + // and is deliberately absent from GROUP_STAGES below. + | "storage" | "danger"; // THE STAGES EACH OPERATION GROUP OWNS, in the group's own order. @@ -168,6 +177,12 @@ export type ComputeStageStatusesInput = { // generic name, because a caller that only needs tone and counts should not // have to resolve the registry. backfillKindIds?: ReadonlyArray<string>; + // Where this channel's media actually is, from inspectChannelMedia. Optional + // because the two stats it costs belong to the caller that already has the + // config in hand, and a caller that only wants tone and counts should not + // have to do I/O to get them — an omitted location reads as "in place", which + // is what every channel was before relocation existed. + media?: ChannelMediaLocation | null; }; export function computeStageStatuses( @@ -180,6 +195,7 @@ export function computeStageStatuses( runningJobs, backfillEnabled = true, backfillKindIds, + media, } = input; const buckets = normalizeBuckets(snapshot.buckets); @@ -557,6 +573,35 @@ export function computeStageStatuses( }), }; + // WHERE THE MEDIA IS. The only stage whose tone comes from a filesystem fact + // rather than from a count: an unreachable channel is a channel whose numbers + // everywhere else on this page are about to be wrong (an unmounted drive reads + // as "nothing downloaded"), so this card is red the moment inspect() says so + // and neutral the rest of the time. "in-place" is not an achievement, so it is + // never "ok" — the fallback tone for a healthy relocation is neutral too. + const mediaStatus = media?.status ?? "in-place"; + const storage: StageStatus = { + id: "storage", + title: "Storage", + pending: 0, + failed: 0, + running: mediaStatus === "in-transition", + defaultOpen: true, + summary: + mediaStatus === "in-place" + ? "Media is in the channel directory." + : mediaStatus === "ok" + ? `Media relocated to ${media?.target ?? "another drive"}.` + : (media?.detail ?? mediaStatus), + tone: + mediaStatus === "in-transition" + ? "running" + : mediaStatus === "unreachable" || + mediaStatus === "inconsistent" + ? "danger" + : "neutral", + }; + const danger: StageStatus = { id: "danger", title: "Danger zone", @@ -577,6 +622,7 @@ export function computeStageStatuses( speakers, cleanup, diagnostics, + storage, danger, }; } diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -38,6 +38,11 @@ import { } from "yt-dlp-transcript-common/controller/shard"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { + inspectChannelMedia, + readRelocationMarker, +} from "yt-dlp-transcript-common/lib/channelMedia"; +import { getFreeBytes } from "yt-dlp-transcript-common/lib/diskSpace"; +import { platformQueueKey, queueKeyForUrl, TRANSCRIPTION_QUEUE, @@ -66,6 +71,7 @@ import { PlaylistStage } from "./components/stages/PlaylistStage"; import { TranscribeStage } from "./components/stages/TranscribeStage"; import { DigestStage } from "./components/stages/DigestStage"; import { SpeakersStage } from "./components/stages/SpeakersStage"; +import { StorageStage } from "./components/stages/StorageStage"; import { getOperation, backfillLaneOperations, @@ -202,6 +208,14 @@ export default async function ChannelDetailPage({ path.join(paths.channelsDir, slug, "playlist"), ); + // WHERE THE MEDIA IS. Two stats and one small JSON read, with the config + // already in hand so nothing re-reads it — explicitly not the corpus walk + // noCorpusWalkInRenderPaths.test.ts bans. It is read on every render of this + // page rather than cached because an unmounted drive is exactly the kind of + // fact that must never be served stale: the whole point of the badge is that + // it is true NOW. + const media = await inspectChannelMedia(paths, slug, config); + const buckets = normalizeBuckets(snapshot.buckets); const undownloadedIds = snapshot.undownloadedIds ?? []; const excludedFromDownload = normalizeExcludedFromDownload( @@ -233,6 +247,7 @@ export default async function ChannelDetailPage({ runningJobs, backfillEnabled: laneOperations.length > 0, backfillKindIds: laneOperations.map((k) => k.id), + media, }); // THE MIDDLE COMES FROM THE REGISTRY, in OPERATION_GROUP_ORDER. Same array as @@ -253,6 +268,7 @@ export default async function ChannelDetailPage({ ...OPERATION_GROUP_ORDER.flatMap((g) => GROUP_STAGES[g]), "cleanup", "diagnostics", + "storage", "danger", ]; @@ -493,6 +509,47 @@ export default async function ChannelDetailPage({ downloadDefaultQueueKey={platformDefaultQueueKey} /> ); + case "storage": { + // The free-space figure names the volume the media is on RIGHT NOW — + // the platter for a relocated channel, the corpus disk otherwise — so + // it answers "can this channel keep downloading", not "how full is + // /home". getFreeBytes returns Infinity for a path it cannot statfs, + // which formatBytes renders "∞"; that is the honest answer for an + // unmounted target and is why the status line above it is the thing to + // read first. + const volumeDir = + media.status === "ok" && media.target ? media.target : paths.channelsDir; + const [freeBytes, marker] = await Promise.all([ + getFreeBytes(volumeDir), + readRelocationMarker(paths, slug), + ]); + const activeJobs = runningJobs.filter( + (j) => j.status === "running" || j.status === "queued", + ).length; + // The SAME two conditions storageActions.ts refuses on, stated here as + // prose so the button is off with a reason rather than off and silent — + // and stated in the action too, because a disabled button is a courtesy + // and the server is the guard. + const blockedReason = + activeJobs > 0 + ? `Finish or cancel ${activeJobs} running/queued job(s) for this channel before moving its media.` + : marker + ? `A relocation (${marker.direction}) to ${marker.target} is in flight, or was interrupted at phase "${marker.phase}". A channel in transition is not moved again from here — the running job finishes it, and an interrupted one is resumed by removing ${".relocating.json"} from the channel dir only once you have confirmed nothing is copying.` + : null; + return ( + <StorageStage + slug={slug} + location={media} + // From the loaded snapshot, not a walk. Null (rendered "—") when the + // snapshot predates the field or does not exist: a 0 would claim a + // measurement nobody took. + mediaBytes={snapshot.totalAudioBytes ?? null} + freeBytes={freeBytes} + volumeDir={volumeDir} + blockedReason={blockedReason} + /> + ); + } case "danger": return ( <div className="flex flex-col gap-4"> diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx @@ -645,6 +645,33 @@ export function ChannelForm({ placeholder="(global default)" hint="Per-channel override for where this channel's persisted source videos live (e.g. a larger disk). Blank uses the global SAVED_VIDEOS_DIR default." /> + {/* READ-ONLY, AND NOT AN INPUT — the one field on this page that is a + record of the disk rather than an instruction to it. + `config.dataDir` is written ONLY by the relocate job, on success, + after the bytes are copied, verified and the symlink is in place. + An editable text box here would let the two disagree with a + keystroke: type a path nothing was moved to and every reader + follows a `data/` link that still points somewhere else, which + inspectChannelMedia reports as `inconsistent` and refuses to guess + past. So the move is the only writer, and this line just says what + it wrote. (It is also not in CHANNEL_FORM_FIELDS, so saving this + form preserves it rather than clearing it.) */} + <div className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Media location</span> + <span + aria-label="media location" + className="font-mono text-xs break-all rounded border border-border bg-muted px-2 py-1" + > + {c?.dataDir?.trim() + ? c.dataDir.trim() + : "In the channel directory (data/)"} + </span> + <span className="text-xs text-muted-foreground"> + Where this channel&rsquo;s downloaded media actually lives. Change it + from the Storage panel, which copies and verifies the bytes before + recording anything here. + </span> + </div> </Section> </> )} diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx @@ -18,6 +18,10 @@ import { ChannelBuildToggle } from "./ChannelBuildToggle"; import { ChannelSyncButton } from "./ChannelSyncButton"; import { ChannelSyncToggle } from "./ChannelSyncToggle"; import { InlineActionButton } from "../../components/actions/InlineActionButton"; +import { + MediaLocationBadge, + type MediaBadgeInput, +} from "../../components/MediaLocationBadge"; // A row is a stat plus its pipeline bands, in column order. The bands are // projected on the server from the same snapshot the counts come from, so a @@ -28,6 +32,11 @@ export type ChannelRow = ChannelStat & { // projected from that report, so its age is the caveat on all of them — which // is why it belongs beside them rather than on a page of its own. report: { generatedAt: string | null; state: "current" | "stale" | "missing" }; + // Where this channel's media physically is, from inspectChannelMedia on the + // server. Null for an in-place channel — the overwhelming majority — so the + // badge column is empty for them and the two that matter stand out. See + // components/MediaLocationBadge.tsx. + media: MediaBadgeInput | null; }; // A column heading for one pipeline. Comes off the operation registry on the @@ -411,12 +420,15 @@ function ChannelTableRow({ } > <Td className="font-mono"> - <Link - href={`/channels/${c.slug}`} - className="underline hover:text-foreground" - > - {c.slug} - </Link> + <span className="inline-flex items-center gap-1.5"> + <Link + href={`/channels/${c.slug}`} + className="underline hover:text-foreground" + > + {c.slug} + </Link> + <MediaLocationBadge media={c.media} compact /> + </span> </Td> <Td>{c.config.name ?? ""}</Td> <Td>{c.config.handling}</Td> diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -6,6 +6,7 @@ import { type ChannelBrief, } from "yt-dlp-transcript-common/controller/channels"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia"; import { getSite, listSiteIds, @@ -139,8 +140,24 @@ export default async function ChannelsPage({ // change exists to remove. const snapshots = new Map(briefs.map((b) => [b.slug, b.snapshot])); const briefBySlug = new Map(briefs.map((b) => [b.slug, b])); + // WHERE EACH CHANNEL'S MEDIA IS. Two stats and a small JSON read per channel, + // with the config already in hand from the brief — dozens of channels, so a + // few hundred syscalls, and explicitly not a corpus walk. It is done here + // rather than skipped because an unmounted drive makes every OTHER number on + // this row wrong (an unreachable data/ reads as "nothing downloaded"), and a + // page of confidently wrong counts with no marking is the failure this badge + // exists to prevent. Only a non-in-place location is carried into the row. + const mediaBySlug = new Map( + await Promise.all( + briefs.map( + async (b) => + [b.slug, await inspectChannelMedia(paths, b.slug, b.config)] as const, + ), + ), + ); const all: ChannelRow[] = stats.map((stat) => { const brief = briefBySlug.get(stat.slug); + const media = mediaBySlug.get(stat.slug); return { ...stat, pipelines: buildChannelBands(snapshots.get(stat.slug) ?? null, ids), @@ -148,6 +165,14 @@ export default async function ChannelsPage({ generatedAt: brief?.snapshot?.generatedAt ?? null, state: brief ? reportStateOf(brief) : ("missing" as const), }, + media: + media && media.status !== "in-place" + ? { + status: media.status, + target: media.target, + detail: media.detail, + } + : null, }; }); // Scope to the active site's membership; "all sites" shows the full pool. diff --git a/editor/app/components/MediaLocationBadge.tsx b/editor/app/components/MediaLocationBadge.tsx @@ -0,0 +1,104 @@ +import type { + ChannelMediaLocation, + ChannelMediaStatus, +} from "yt-dlp-transcript-common/lib/channelMedia"; + +// THE ONE RENDERING OF "where is this channel's media, and can we reach it". +// +// Three surfaces draw it — the channel page's Storage panel, the /channels +// table and the dashboard's channels table — and two of those are client +// components, so this file must stay import-clean: the only thing it takes from +// common/lib/channelMedia.ts is a TYPE, which is erased. Nothing here touches +// the filesystem; the inspect() call that produces the location happens on the +// server, once per row, and only its result travels. +// +// WHAT THE FIVE STATUSES LOOK LIKE, and why there are only three appearances: +// +// in-place → NOTHING. The overwhelming majority of channels are in place, +// and a badge on every row saying "normal" is noise that makes +// the two that matter harder to see, not easier. +// ok → neutral. Relocated and reachable is a fact worth stating (the +// bytes are not on the corpus disk) but it is not a problem. +// everything → red. unreachable, in-transition and inconsistent are all +// else "do not trust what this channel's dirs say right now": the +// first because the drive is not mounted, the second because a +// move is half-done, the third because disk and config disagree +// and nothing here is willing to guess which one is right. +// +// The `detail` string is the operator's prose from inspect() — the drive path, +// the phase, the disagreement — and it goes on `title` so a row badge carries +// the reason without spending a column on it. + +// The prop shape, deliberately narrower than ChannelMediaLocation: a row only +// needs what it draws, so a server page can project three fields onto a client +// component instead of serializing a whole location per channel. +export type MediaBadgeInput = Pick< + ChannelMediaLocation, + "status" | "target" | "detail" +>; + +export type MediaBadgeTone = "neutral" | "danger"; + +export type MediaBadge = { + label: string; + title: string; + tone: MediaBadgeTone; +}; + +const LABELS: Record<ChannelMediaStatus, string | null> = { + "in-place": null, + ok: "Media relocated", + unreachable: "Media unreachable", + "in-transition": "Media moving", + inconsistent: "Media inconsistent", +}; + +// Null means "draw nothing" — an in-place channel, or no location at all (a +// caller that could not inspect). Both are the same instruction to a renderer. +export function mediaBadgeOf( + media: MediaBadgeInput | null | undefined, +): MediaBadge | null { + if (!media) return null; + const label = LABELS[media.status] ?? null; + if (!label) return null; + const where = media.target ? ` to ${media.target}` : ""; + return { + label: media.status === "ok" ? `${label}${where}` : label, + // The detail carries the reason; the target alone is the fallback so a + // location written by an older inspect() still says where it points. + title: media.detail ?? `${label}${where}`, + tone: media.status === "ok" ? "neutral" : "danger", + }; +} + +const TONE_CLASS: Record<MediaBadgeTone, string> = { + neutral: "border-border bg-muted text-muted-foreground", + danger: "border-destructive bg-destructive/10 text-destructive", +}; + +// `compact` drops the target from the label: a table cell wants the four-word +// state, and the full path is one hover away on the title. +export function MediaLocationBadge({ + media, + compact = false, +}: { + media: MediaBadgeInput | null | undefined; + compact?: boolean; +}) { + const badge = mediaBadgeOf(media); + if (!badge) return null; + const label = + compact && media?.status === "ok" ? (LABELS.ok as string) : badge.label; + return ( + <span + title={badge.title} + aria-label={`media location: ${badge.label}`} + className={ + "inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium whitespace-nowrap " + + TONE_CLASS[badge.tone] + } + > + {label} + </span> + ); +} diff --git a/editor/app/components/dashboard/ChannelsTable.tsx b/editor/app/components/dashboard/ChannelsTable.tsx @@ -7,6 +7,7 @@ import { prioritizeChannelDownloadAction } from "../../operations/actions"; import { InlineActionButton } from "../actions/InlineActionButton"; import { fmtTime } from "../../widget/lib/relativeTime"; import type { DashboardChannel } from "./types"; +import { MediaLocationBadge } from "../MediaLocationBadge"; // The enriched channels table: the plain slug/handling/videos list plus a // relative "last sync" that ticks live, and per-row inline actions (Sync, @@ -52,12 +53,15 @@ export function ChannelsTable({ className="border-t border-border align-top" > <td className="px-3 py-2 font-mono"> - <Link - href={`/channels/${c.slug}`} - className="underline underline-offset-2 hover:text-brand transition-colors" - > - {c.slug} - </Link> + <span className="inline-flex items-center gap-1.5"> + <Link + href={`/channels/${c.slug}`} + className="underline underline-offset-2 hover:text-brand transition-colors" + > + {c.slug} + </Link> + <MediaLocationBadge media={c.media} compact /> + </span> </td> <td className="px-3 py-2">{c.handling}</td> <td className="px-3 py-2 text-right tabular-nums"> diff --git a/editor/app/components/dashboard/types.ts b/editor/app/components/dashboard/types.ts @@ -1,3 +1,5 @@ +import type { MediaBadgeInput } from "../MediaLocationBadge"; + // Enriched per-channel row for the dashboard cockpit's Channels table. Built // server-side in app/page.tsx from the actionable summary and passed straight // through (not into client state), so a global AutoRefresh re-seeds it live. @@ -19,4 +21,10 @@ export type DashboardChannel = { // Videos with no transcript are NOT in it: they are classified as waiting on // transcription and counted separately. digestReachable: number; + // Where this channel's media physically is, when that is not "in the channel + // directory". Null for an in-place channel — the overwhelming majority — so + // the badge marks only the rows whose other numbers may not be trustworthy: + // an unmounted drive reads as "nothing downloaded" to every count on this + // row. See app/components/MediaLocationBadge.tsx. + media: MediaBadgeInput | null; }; diff --git a/editor/app/page.tsx b/editor/app/page.tsx @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import type { Metadata } from "next"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { getSite, @@ -67,6 +68,21 @@ export default async function Dashboard({ // Enriched channels table rows (pass-through, not client state, so a global // AutoRefresh re-seeds them live). + // WHERE EACH CHANNEL'S MEDIA IS — two stats and a small JSON read per row, + // with the config already in hand. Not a corpus walk. A row whose drive is + // not mounted reports every other number as zero, which is exactly why the + // dashboard has to be able to say so rather than drawing a confident zero. + const mediaBySlug = new Map( + await Promise.all( + rows.map( + async (r) => + [ + r.channel.slug, + await inspectChannelMedia(paths, r.channel.slug, r.channel.config), + ] as const, + ), + ), + ); const channels: DashboardChannel[] = rows.map((r) => ({ slug: r.channel.slug, handling: r.channel.config.handling, @@ -78,6 +94,12 @@ export default async function Dashboard({ undownloaded: actionableUndownloadedCount(r), untranscribed: actionableUntranscribedCount(r), digestReachable: actionableDigestReachableCount(r), + media: (() => { + const m = mediaBySlug.get(r.channel.slug); + return m && m.status !== "in-place" + ? { status: m.status, target: m.target, detail: m.detail } + : null; + })(), })); // "Needs work" payload, same shape the widget endpoint the cockpit polls