"use client"; import Link from "next/link"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import type { StorageActionKind, StorageRow, StorageRowsPayload, } from "yt-dlp-transcript-common/views/storage"; import { cancelJobAction } from "../../jobs/actions"; import { addStorageLocationAction, deleteStorageLocationAction, editStorageLocationAction, mountStorageLocationAction, refreshStorageLocationAction, repointStorageLocationAction, } from "../actions"; import { LocationForm } from "./LocationForm"; import { SavedVideosStoreCard } from "./SavedVideosStoreCard"; import { ClipWindowsCard } from "./ClipWindowsCard"; // THE LOCATIONS, ONE CARD EACH, AND WHAT MAY BE DONE TO THEM. // // Every judgement on this screen was already made on the server: // `common/views/storage.ts` decided each row's status, its counts, which of the // five actions are offered and the sentence explaining each one that is not. // This file renders that and calls the actions. It contains no rule — which is // the point of the views layer, and what makes those rules unit-testable // without a browser. // // TYPE-ONLY IMPORTS FROM views/storage. It is a "use client" file: the storage // controller and `lib/storageVolumes.ts` both import execa, and a value import // of either would fail `next build` with `node:child_process` in the client // bundle. // // ⚠️ THE RE-POINT PANEL IS NEVER 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) — and that refresh re-renders this list with the // location's status flipped from `mounted-elsewhere` to `available`, i.e. with // the Re-point action no longer offered. A naive `{offered && }` would // therefore delete the log of the run that just succeeded, at the exact moment // the operator wants to read it. Same shape as StorageStage: a `ranHere` flag // set inside the trigger keeps the panel mounted, and the now-cleared condition // is fed to `disabled` so the panel that stayed for its log is not a second Run // button. export function StorageLocationsTable({ payload }: { payload: StorageRowsPayload }) { const router = useRouter(); const [adding, setAdding] = useState(false); return (
{payload.rows.length === 0 && (

No storage locations yet. A location is a named place a channel’s media may live — usually a second drive. Add one below, then move a channel onto it from its Storage panel.

)} {payload.rows.map((row) => ( router.refresh()} /> ))}
{/* AFTER THE LOCATIONS, BEFORE THE FORM. The store is a thing that lives on a location, so it reads after the list of them — and before "Add a location", which is the page's trailing affordance. */} {payload.savedVideos && ( )} {/* THE OTHER CORPUS-WIDE PILE OF BYTES, beside the store and for the same reason: it is not a fact about any one location (windows are wherever the channels are, on every drive at once) and it is the only control that removes one. The total is the rows' own clip figures summed — computed here rather than carried on the payload, because it is a fold of numbers the payload already has. */} n + r.clipsBytes, 0)} />

Add a location

{adding ? ( { setAdding(false); router.refresh(); }} onCancel={() => setAdding(false)} /> ) : (
{/* The DISCLOSURE, not the submit. `add storage location` is the form's own button (LocationForm), and two controls sharing one accessible name is a screen reader reading the same sentence for two different acts. */}
)}
); } function actionOf(row: StorageRow, kind: StorageActionKind) { return row.actions.find((a) => a.kind === kind); } function LocationCard({ row, onChanged, }: { row: StorageRow; onChanged: () => void; }) { const [editing, setEditing] = useState(false); const [note, setNote] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [ranRepointHere, setRanRepointHere] = useState(false); const refresh = actionOf(row, "refresh"); const repoint = actionOf(row, "repoint"); const mount = actionOf(row, "mount"); const edit = actionOf(row, "edit"); const del = actionOf(row, "delete"); async function run(fn: () => Promise<{ ok: true; note?: string } | { ok: false; error: string }>) { setBusy(true); setError(null); setNote(null); const result = await fn(); setBusy(false); if (result.ok) { setNote(result.note ?? null); onChanged(); } else { setError(result.error); } } return (

{row.label}

{row.statusLabel} {row.isDefault && ( default )} {row.autoRepoint && ( auto re-point )}
{/* "location path" and "volume identity", NOT "location root" and "location identity". The form on this same page labels its fields "location root" and "location id", and Playwright's getByLabel is a SUBSTRING match resolved against every match: "location root" would have been ambiguous with the read-out of the same name, and "location id" is contained in "location identity". Filling the form targeted this read-only
instead — the first e2e run failed exactly there. Two accessible names where one contains the other are a trap for a screen reader user for the same reason. */}
Root
{row.root}
Volume
{row.kind === "internal" ? "the corpus volume" : (row.identity ?? "unknown — nothing here can ask (no findmnt, or a container)")}
Channels
{/* THE COUNT AND THE LINK ARE TWO ELEMENTS, not one. `location channels` is the read-out's accessible name and the suite asserts its exact text; a link folded into it appends "— list them" to that text and breaks every caller. The link gets its own `
` on the next row of the grid. */}
{row.channelsText}
{row.channels.total > 0 && ( <>
Browse
{/* THE ROW'S OWN LIST, largest first. A summary that cannot be opened is a number the operator has to go and re-derive by hand, which on this page is opening 71 channel pages. */} the {row.channels.total} channel(s) on this location
)}
Media
{row.bytesText} {/* THE INTERNAL ROW'S BREAKDOWN (release 17): every channel's text and clip windows stay on the corpus volume, plus the media of the channels in place. A location row's figure is its media tier alone, and carries no breakdown. */} {row.tiersText && ( {row.tiersText} )} {/* THE CLIPS SHARE, inside the figure and never beside it as a second total: on the internal row (clips/ is never tiered), it is already in the number above; what this adds is that some of it is a CACHE nothing prunes. Absent when there are none. */} {row.clipsText && ( {row.clipsText} )}
Free
{row.freeBytes === undefined ? "—" : formatBytes(row.freeBytes)}
Last probe
{formatAge(row.lastProbeAgeMs)}
{row.notAnswering && (

{row.notAnswering}

)} {row.warning && (

{row.warning}

)} {row.busy && (

{row.busy}

)} {note && (

{note}

)} {error && (

{error}

)} {row.kind === "internal" ? (

{actionOf(row, "edit")?.withheld}

) : (
{mount?.offered && ( )}
)} {row.kind !== "internal" && del && !del.offered && (

{del.withheld}

)} {row.kind !== "internal" && editing && ( editStorageLocationAction(row.id, draft)} onDone={() => { setEditing(false); onChanged(); }} onCancel={() => setEditing(false)} /> )} {(repoint?.offered || ranRepointHere) && (

Re-point

The volume is mounted at a different place than this location’s root. Re-pointing rewrites each channel’s media{" "} symlink and its mediaDir, then the root.{" "} No bytes move — the media is already there.

{ setRanRepointHere(true); return repointStorageLocationAction(row.id, repoint?.newRoot ?? ""); }} cancelAction={cancelJobAction} buttonLabel={repoint?.label ?? "Re-point"} runningLabel="Re-pointing…" label="Re-point" disabled={!repoint?.offered} />
)} {row.kind !== "internal" && repoint && !repoint.offered && !ranRepointHere && row.status !== "available" && (

{repoint.withheld}

)}
); } // "4 s ago" / "3 m ago". Deliberately coarse: the number is there to say // whether the row is answering about now or about the last time anybody looked, // and a second-accurate age of a ten-second memo would be false precision. function formatAge(ms: number): string { if (ms < 1_000) return "just now"; const s = Math.round(ms / 1000); if (s < 90) return `${s} s ago`; const m = Math.round(s / 60); if (m < 90) return `${m} m ago`; return `${Math.round(m / 60)} h ago`; }