Archilyzer · Source

archilyzer

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

commit b0b8e6b14be0077b53da53df2ee7156c2bc467a8
parent 043a3aa506835641cee357b2b68852a2f042c83d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 17 Sep 2026 14:56:59 -0400

/storage: the locations, what is on them, and the one click that follows a disk

A server component over the pure view: the shell probes (memoised 10 s, so a
double render is one set of subprocesses), rolls the channels up, and hands
the payload to `buildStorageRows`. The client renders it and calls the
actions; it contains no rule, which is what makes the rules unit-testable
without a browser.

Five actions run inline — their whole output is a sentence. The sixth
(re-point) is a job, because it rewrites every channel symlink on the
location; `lib/repointJob.ts` is its one enqueue, separate from actions.ts
for the reason `channels/lib/relocationJob.ts` is separate: a helper exported
from a "use server" file is an unguarded endpoint.

The edit form drops the stored `volume` whenever the root changes. `volume`
carries the root's path RELATIVE to its mountpoint and a probe builds a
candidate root from it, so a relPath measured against the old root would make
the next Re-point button point every channel at a directory that does not
exist. The next Refresh relearns identity for one findmnt.

Every import the client makes from the storage modules is a TYPE import: the
controller and storageVolumes.ts both pull in execa, and `next build` is what
proves neither reaches the bundle.

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

Diffstat:
Aeditor/app/storage/actions.ts | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/storage/buildStorage.ts | 44++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/storage/components/LocationForm.tsx | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/storage/components/StorageLocationsTable.tsx | 326+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/storage/lib/repointJob.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/storage/page.tsx | 38++++++++++++++++++++++++++++++++++++++
6 files changed, 879 insertions(+), 0 deletions(-)

diff --git a/editor/app/storage/actions.ts b/editor/app/storage/actions.ts @@ -0,0 +1,267 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { + getSettings, + writeSettings, +} from "yt-dlp-transcript-common/lib/settings"; +import type { StorageLocation } from "yt-dlp-transcript-common/lib/storageLocations"; +import { + mountByUuid, + probeLocation, +} from "yt-dlp-transcript-common/lib/storageVolumes"; +import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; +import { + channelsOnLocation, + maybeAutoRepoint, + probeLocationMemo, + recordProbedIdentity, + resetStorageProbeMemo, +} from "yt-dlp-transcript-common/controller/storageLocations"; +import { channelMediaBusyReason } from "../channels/lib/mediaBusy"; +import { enqueueRepointJob } from "./lib/repointJob"; + +// THE SIX THINGS AN OPERATOR MAY DO TO A STORAGE LOCATION. +// +// Five of them are small settings writes or one subprocess and run INLINE: +// their whole output is a sentence, and a queued job with a log would be a +// worse way to show one. The sixth (re-point) rewrites every channel symlink on +// the location and is a job, with a record, a log and a cancel button, like +// every other long action in the editor. + +export type LocationResult = { ok: true; note?: string } | { ok: false; error: string }; + +const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; + +export type LocationDraft = { + id: string; + label: string; + root: string; + autoRepoint: boolean; + makeDefault: boolean; +}; + +function normalizeRoot(root: string): string { + const trimmed = root.trim().replace(/\/+$/, ""); + return trimmed === "" ? root.trim() : trimmed; +} + +function validate(draft: LocationDraft): string | null { + if (!ID_RE.test(draft.id)) { + return `"${draft.id}" is not a valid id: lower-case letters, digits and dashes, starting with a letter or digit, 64 characters at most.`; + } + const root = normalizeRoot(draft.root); + if (!root.startsWith("/")) { + return `The root must be an absolute path (got "${draft.root}"). A relative root would name a different directory in every process that read it.`; + } + return null; +} + +function revalidateStorage(): void { + revalidatePath("/storage"); + revalidatePath("/channels"); + revalidatePath("/"); +} + +export async function addStorageLocationAction( + draft: LocationDraft, +): Promise<LocationResult> { + const problem = validate(draft); + if (problem) return { ok: false, error: problem }; + const settings = getSettings(); + if (settings.storage.locations.some((l) => l.id === draft.id)) { + return { ok: false, error: `A storage location "${draft.id}" already exists.` }; + } + const location: StorageLocation = { + id: draft.id, + label: draft.label.trim() || draft.id, + root: normalizeRoot(draft.root), + autoRepoint: draft.autoRepoint, + }; + const locations = [...settings.storage.locations, location]; + await writeSettings({ + ...settings, + storage: { + locations, + // The FIRST location is the default whether or not the box was ticked: + // with exactly one location, "no default" is never the answer anybody + // wanted, and a blank default silently disables every prefill. + defaultLocationId: + draft.makeDefault || locations.length === 1 + ? draft.id + : settings.storage.defaultLocationId, + }, + }); + revalidateStorage(); + return { ok: true, note: `Added "${location.label}" at ${location.root}.` }; +} + +export async function editStorageLocationAction( + id: string, + draft: LocationDraft, +): Promise<LocationResult> { + const problem = validate({ ...draft, id }); + if (problem) return { ok: false, error: problem }; + const settings = getSettings(); + const existing = settings.storage.locations.find((l) => l.id === id); + if (!existing) return { ok: false, error: `There is no storage location "${id}".` }; + const root = normalizeRoot(draft.root); + + // THE STORED IDENTITY IS DROPPED WHEN THE ROOT CHANGES, and this is the one + // rule in this file that is not obvious. `volume` records the mountpoint the + // root was under and the root's path RELATIVE to it, and a probe computes a + // candidate root from them (`join(newMountpoint, relPath)`). Keep a relPath + // measured against the old root and the next "mounted elsewhere" offers a + // candidate that is wrong by exactly the difference — a Re-point button that + // points every channel at a directory that does not exist. The next Refresh + // relearns identity from the root the operator just typed, which costs one + // findmnt and is always right. + const keepVolume = root === existing.root; + const updated: StorageLocation = { + ...existing, + label: draft.label.trim() || id, + root, + autoRepoint: draft.autoRepoint, + ...(keepVolume && existing.volume ? { volume: existing.volume } : {}), + }; + if (!keepVolume) delete (updated as { volume?: unknown }).volume; + + await writeSettings({ + ...settings, + storage: { + locations: settings.storage.locations.map((l) => (l.id === id ? updated : l)), + defaultLocationId: draft.makeDefault + ? id + : settings.storage.defaultLocationId, + }, + }); + // The memo keys by id and carries the root it answered for, but clearing it + // makes the next render honest rather than merely correct. + resetStorageProbeMemo(); + revalidateStorage(); + return { + ok: true, + note: keepVolume + ? `Saved "${updated.label}".` + : `Saved "${updated.label}" at ${root}. The recorded volume identity was dropped — press Refresh to relearn it.`, + }; +} + +// DELETING A LOCATION MOVES NOTHING, and that is exactly why it is refused +// while channels are on it: their `config.dataDir` would keep naming an +// absolute path on a disk nothing in the corpus remembers the name of, which is +// the situation this page exists to end. +export async function deleteStorageLocationAction( + id: string, +): Promise<LocationResult> { + const settings = getSettings(); + const location = settings.storage.locations.find((l) => l.id === id); + if (!location) return { ok: false, error: `There is no storage location "${id}".` }; + const rollups = await channelsOnLocation({ + paths: getPaths(), + locations: [location], + }); + const roll = rollups[id]; + if (roll && roll.total > 0) { + return { + ok: false, + error: + `${roll.total} channel(s) still have their media under ${location.root} ` + + `(${roll.slugs.slice(0, 5).join(", ")}${roll.slugs.length > 5 ? ", …" : ""}). ` + + `Move them back in place, or onto another location, first.`, + }; + } + const locations = settings.storage.locations.filter((l) => l.id !== id); + await writeSettings({ + ...settings, + storage: { + locations, + defaultLocationId: + settings.storage.defaultLocationId === id + ? (locations[0]?.id ?? "") + : settings.storage.defaultLocationId, + }, + }); + resetStorageProbeMemo(); + revalidateStorage(); + return { ok: true, note: `Deleted "${location.label}".` }; +} + +// A FRESH PROBE, PAST THE MEMO. The operator pressing this has just done +// something physical — plugged the disk in, mounted it — and is asking for an +// answer taken afterwards. +// +// It writes IDENTITY and nothing else: availability is never persisted (a +// refresh that stored "available" would rewrite settings.json, and so bump the +// pulse revision every open tab polls, on every page load). +export async function refreshStorageLocationAction( + id: string, +): Promise<LocationResult> { + const paths = getPaths(); + const settings = getSettings(); + const location = settings.storage.locations.find((l) => l.id === id); + if (!location) return { ok: false, error: `There is no storage location "${id}".` }; + const probe = await probeLocationMemo(location, paths, { refresh: true }); + const wrote = await recordProbedIdentity({ locationId: id, probe }); + + // The opt-in. Off by default, and refused unless the full preflight passes — + // so this either starts the job the operator armed or says why it did not. + const auto = await maybeAutoRepoint({ + paths, + location, + probe, + bins: paths, + isBusy: channelMediaBusyReason, + }); + revalidateStorage(); + if (auto.started) { + return { + ok: true, + note: `Auto re-point started: "${location.label}" → ${auto.newRoot}. Watch it on /jobs.`, + }; + } + const identity = probe.identity.known + ? ` Identity: ${probe.identity.uuid} at ${probe.identity.mountpoint}.${wrote ? " (recorded)" : ""}` + : " Identity unknown — nothing here can ask (no findmnt, or a container)."; + return { ok: true, note: `${location.label}: ${probe.status}.${identity}` }; +} + +// NEVER RETRIED, and the error is surfaced verbatim: a polkit denial under a +// service session is the expected failure and the operator needs to read the +// daemon's own words. +export async function mountStorageLocationAction( + id: string, +): Promise<LocationResult> { + const paths = getPaths(); + const settings = getSettings(); + const location = settings.storage.locations.find((l) => l.id === id); + if (!location) return { ok: false, error: `There is no storage location "${id}".` }; + const uuid = location.volume?.uuid?.trim() ?? ""; + if (!uuid) { + return { + ok: false, + error: `"${location.label}" has no recorded volume identity, so there is nothing to mount by UUID. Mount it from the host and press Refresh.`, + }; + } + const result = await mountByUuid(uuid, paths); + if (!result.ok) return { ok: false, error: result.error ?? "udisksctl failed." }; + // The mount changed the world the memo answered about. + resetStorageProbeMemo(); + const after = await probeLocation(location, paths); + await recordProbedIdentity({ locationId: id, probe: after }).catch(() => {}); + revalidateStorage(); + return { + ok: true, + note: `Mounted ${uuid}${result.mountpoint ? ` at ${result.mountpoint}` : ""}. The location now reads ${after.status}.`, + }; +} + +export async function repointStorageLocationAction( + id: string, + newRoot: string, +): Promise<StreamActionResult> { + const root = newRoot.trim(); + if (!root) return { ok: false, error: "No new root given." }; + return enqueueRepointJob({ locationId: id, newRoot: root }); +} diff --git a/editor/app/storage/buildStorage.ts b/editor/app/storage/buildStorage.ts @@ -0,0 +1,44 @@ +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; +import { udisksctlAvailable } from "yt-dlp-transcript-common/lib/storageVolumes"; +import { + channelsOnLocation, + probeAllLocations, +} from "yt-dlp-transcript-common/controller/storageLocations"; +import { + buildStorageRows, + type StorageRowsPayload, +} from "yt-dlp-transcript-common/views/storage"; + +// THE SHELL. `common/views/storage.ts` is pure and takes every live fact as an +// argument; this is the file that actually goes and gets them — settings off +// disk, one probe per location (memoised for 10 s, so a double render is one +// set of subprocesses), the per-channel roll-up, the registry, and the clock. +// +// SERVER ONLY. It reaches `storageVolumes.ts` and the controller, both of which +// import execa. Nothing `"use client"` may import this file; `next build` is +// what proves it. +export async function buildStorage(opts: { refresh?: boolean } = {}): Promise< + StorageRowsPayload +> { + const paths = getPaths(); + const settings = getSettings(); + const locations = settings.storage.locations; + const [probes, rollups, udisksctl] = await Promise.all([ + probeAllLocations(locations, paths, { refresh: opts.refresh }), + channelsOnLocation({ paths, locations }), + // Memoised per binary path inside storageVolumes, so this is one + // `--version` for the life of the process. + locations.length > 0 ? udisksctlAvailable(paths) : Promise.resolve(false), + ]); + return buildStorageRows({ + locations, + defaultLocationId: settings.storage.defaultLocationId, + probes, + rollups, + registry: getRegistry(), + udisksctlAvailable: udisksctl, + now: Date.now(), + }); +} diff --git a/editor/app/storage/components/LocationForm.tsx b/editor/app/storage/components/LocationForm.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useState } from "react"; +import type { LocationDraft, LocationResult } from "../actions"; + +// ADD AND EDIT ARE ONE FORM, because they are one set of rules: the id shape, +// the absolute root, the label that falls back to the id, and the opt-in. The +// only difference is that an id is CHOSEN once and never changed — it is what +// `defaultLocationId` and every action refer to — so in edit mode the field is +// shown and disabled rather than hidden, which keeps the row readable. +// +// TYPES ONLY, FROM THE ACTION FILE. This is a "use client" file: it may not +// reach `lib/storageVolumes.ts` or the storage controller (both import execa, +// and `next build` fails on `node:child_process` in a client bundle). The +// server actions it calls are the wire, and `LocationDraft` is a plain object. + +type Props = { + mode: "add" | "edit"; + initial?: { + id: string; + label: string; + root: string; + autoRepoint: boolean; + isDefault: boolean; + }; + submit: (draft: LocationDraft) => Promise<LocationResult>; + onDone?: () => void; + onCancel?: () => void; +}; + +export function LocationForm({ mode, initial, submit, onDone, onCancel }: Props) { + const [id, setId] = useState(initial?.id ?? ""); + const [label, setLabel] = useState(initial?.label ?? ""); + const [root, setRoot] = useState(initial?.root ?? ""); + const [autoRepoint, setAutoRepoint] = useState(initial?.autoRepoint ?? false); + const [makeDefault, setMakeDefault] = useState(initial?.isDefault ?? false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + + async function run() { + setBusy(true); + setError(null); + const result = await submit({ id, label, root, autoRepoint, makeDefault }); + setBusy(false); + if (!result.ok) { + setError(result.error); + return; + } + if (mode === "add") { + setId(""); + setLabel(""); + setRoot(""); + setAutoRepoint(false); + setMakeDefault(false); + } + onDone?.(); + } + + return ( + <div className="flex flex-col gap-3 rounded border border-border bg-muted/30 px-3 py-3"> + <div className="grid gap-3 sm:grid-cols-3"> + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Id</span> + <input + type="text" + aria-label="location id" + value={id} + disabled={mode === "edit"} + onChange={(e) => setId(e.target.value)} + placeholder="platter" + className="rounded border border-border bg-card px-2 py-1 text-sm font-mono disabled:opacity-60" + /> + <span className="text-xs text-muted-foreground"> + Lower-case, stable. It never changes. + </span> + </label> + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Label</span> + <input + type="text" + aria-label="location label" + value={label} + onChange={(e) => setLabel(e.target.value)} + placeholder="Platter" + className="rounded border border-border bg-card px-2 py-1 text-sm" + /> + <span className="text-xs text-muted-foreground"> + Blank uses the id. + </span> + </label> + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Root</span> + <input + type="text" + aria-label="location root" + value={root} + onChange={(e) => setRoot(e.target.value)} + placeholder="/mnt/platter/archilyzer-media" + 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. One root holds many channels, each at + <code>&lt;slug&gt;/data</code>. + </span> + </label> + </div> + <div className="flex flex-wrap items-center gap-4 text-sm"> + <label className="flex items-center gap-2"> + <input + type="checkbox" + aria-label="auto re-point" + checked={autoRepoint} + onChange={(e) => setAutoRepoint(e.target.checked)} + /> + <span> + Auto re-point + <span className="text-muted-foreground"> + {" "} + — when the volume turns up at a different mountpoint, move the + links without asking (only if the preflight passes). + </span> + </span> + </label> + <label className="flex items-center gap-2"> + <input + type="checkbox" + aria-label="default location" + checked={makeDefault} + onChange={(e) => setMakeDefault(e.target.checked)} + /> + <span>Default destination for a move</span> + </label> + </div> + {error && ( + <p role="alert" className="text-sm text-destructive"> + {error} + </p> + )} + <div className="flex items-center gap-2"> + <button + type="button" + onClick={run} + disabled={busy} + aria-label={mode === "add" ? "add storage location" : `save storage location: ${id}`} + className="px-3 py-1.5 rounded-md border border-border text-sm font-medium disabled:opacity-50" + > + {busy ? "Saving…" : mode === "add" ? "Add location" : "Save"} + </button> + {onCancel && ( + <button + type="button" + onClick={onCancel} + className="px-3 py-1.5 rounded-md text-sm text-muted-foreground" + > + Cancel + </button> + )} + </div> + </div> + ); +} diff --git a/editor/app/storage/components/StorageLocationsTable.tsx b/editor/app/storage/components/StorageLocationsTable.tsx @@ -0,0 +1,326 @@ +"use client"; + +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"; + +// 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 && <Log/>}` 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 ( + <div className="flex flex-col gap-4"> + <section + aria-label="storage locations" + className="flex flex-col gap-3" + > + {payload.rows.length === 0 && ( + <p className="text-sm text-muted-foreground"> + No storage locations yet. A location is a named place a channel&rsquo;s + media may live — usually a second drive. Add one below, then move a + channel onto it from its Storage panel. + </p> + )} + {payload.rows.map((row) => ( + <LocationCard key={row.id} row={row} onChanged={() => router.refresh()} /> + ))} + </section> + + <section className="flex flex-col gap-2"> + <h2 className="text-base font-semibold">Add a location</h2> + {adding ? ( + <LocationForm + mode="add" + submit={addStorageLocationAction} + onDone={() => { + setAdding(false); + router.refresh(); + }} + onCancel={() => setAdding(false)} + /> + ) : ( + <div> + <button + type="button" + aria-label="add storage location" + onClick={() => setAdding(true)} + className="px-3 py-1.5 rounded-md border border-border text-sm font-medium" + > + Add location + </button> + </div> + )} + </section> + </div> + ); +} + +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<string | null>(null); + const [error, setError] = useState<string | null>(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 ( + <article + aria-label={`storage location: ${row.id}`} + className="flex flex-col gap-3 rounded-xl border border-border bg-card px-4 py-3" + > + <div className="flex flex-wrap items-center gap-3"> + <h2 className="text-base font-semibold">{row.label}</h2> + <span + aria-label="location status" + className={`rounded-full border px-2 py-0.5 text-xs font-medium ${ + row.status === "available" + ? "border-border bg-muted" + : "border-destructive/50 bg-destructive/5 text-destructive" + }`} + > + {row.statusLabel} + </span> + {row.isDefault && ( + <span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground"> + default + </span> + )} + {row.autoRepoint && ( + <span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground"> + auto re-point + </span> + )} + </div> + + <dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm"> + <dt className="text-muted-foreground">Root</dt> + <dd className="font-mono text-xs break-all" aria-label="location root path"> + {row.root} + </dd> + <dt className="text-muted-foreground">Volume</dt> + <dd className="text-xs" aria-label="location identity"> + {row.identity ?? "unknown — nothing here can ask (no findmnt, or a container)"} + </dd> + <dt className="text-muted-foreground">Channels</dt> + <dd aria-label="location channels">{row.channelsText}</dd> + <dt className="text-muted-foreground">Free</dt> + <dd aria-label="location free space"> + {row.freeBytes === undefined ? "—" : formatBytes(row.freeBytes)} + </dd> + <dt className="text-muted-foreground">Last probe</dt> + <dd aria-label="location last probe">{formatAge(row.lastProbeAgeMs)}</dd> + </dl> + + {row.warning && ( + <p + role="status" + aria-label="location warning" + className="text-xs rounded border border-border bg-muted px-3 py-2" + > + {row.warning} + </p> + )} + {row.busy && ( + <p role="status" className="text-sm rounded border border-border bg-muted px-3 py-2"> + {row.busy} + </p> + )} + {note && ( + <p role="status" aria-label="location result" className="text-sm"> + {note} + </p> + )} + {error && ( + <p role="alert" aria-label="location error" className="text-sm text-destructive"> + {error} + </p> + )} + + <div className="flex flex-wrap items-center gap-2"> + <button + type="button" + aria-label={`refresh ${row.id}`} + disabled={busy || !refresh?.offered} + title={refresh?.withheld} + onClick={() => run(() => refreshStorageLocationAction(row.id))} + className="px-3 py-1.5 rounded-md border border-border text-sm font-medium disabled:opacity-50" + > + {busy ? "Working…" : "Refresh"} + </button> + {mount?.offered && ( + <button + type="button" + aria-label={`mount ${row.id}`} + disabled={busy} + onClick={() => run(() => mountStorageLocationAction(row.id))} + className="px-3 py-1.5 rounded-md border border-border text-sm font-medium disabled:opacity-50" + > + Mount + </button> + )} + <button + type="button" + aria-label={`edit ${row.id}`} + disabled={!edit?.offered} + title={edit?.withheld} + onClick={() => setEditing((v) => !v)} + className="px-3 py-1.5 rounded-md border border-border text-sm font-medium disabled:opacity-50" + > + {editing ? "Close" : "Edit"} + </button> + <button + type="button" + aria-label={`delete ${row.id}`} + disabled={busy || !del?.offered} + // The withheld sentence IS the tooltip. A greyed button with no + // reason is the thing this page exists not to be. + title={del?.withheld} + onClick={() => { + if (!confirm(`Delete the storage location "${row.label}"? No files are moved or deleted.`)) { + return; + } + void run(() => deleteStorageLocationAction(row.id)); + }} + className="px-3 py-1.5 rounded-md border border-destructive text-destructive text-sm font-medium disabled:opacity-50" + > + Delete + </button> + </div> + {del && !del.offered && ( + <p className="text-xs text-muted-foreground" aria-label="delete withheld"> + {del.withheld} + </p> + )} + + {editing && ( + <LocationForm + mode="edit" + initial={{ + id: row.id, + label: row.label, + root: row.root, + autoRepoint: row.autoRepoint, + isDefault: row.isDefault, + }} + submit={(draft) => editStorageLocationAction(row.id, draft)} + onDone={() => { + setEditing(false); + onChanged(); + }} + onCancel={() => setEditing(false)} + /> + )} + + {(repoint?.offered || ranRepointHere) && ( + <section className="flex flex-col gap-2 rounded border border-border bg-muted/30 px-3 py-3"> + <h3 className="text-sm font-semibold">Re-point</h3> + <p className="text-xs text-muted-foreground"> + The volume is mounted at a different place than this location&rsquo;s + root. Re-pointing rewrites each channel&rsquo;s <code>data/</code>{" "} + symlink and its <code>dataDir</code>, then the root.{" "} + <strong>No bytes move</strong> — the media is already there. + </p> + <StreamActionLog + key="repoint-log" + trigger={() => { + setRanRepointHere(true); + return repointStorageLocationAction(row.id, repoint?.newRoot ?? ""); + }} + cancelAction={cancelJobAction} + buttonLabel={repoint?.label ?? "Re-point"} + runningLabel="Re-pointing…" + label="Re-point" + disabled={!repoint?.offered} + /> + </section> + )} + {repoint && !repoint.offered && !ranRepointHere && row.status !== "available" && ( + <p className="text-xs text-muted-foreground" aria-label="repoint withheld"> + {repoint.withheld} + </p> + )} + </article> + ); +} + +// "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`; +} diff --git a/editor/app/storage/lib/repointJob.ts b/editor/app/storage/lib/repointJob.ts @@ -0,0 +1,43 @@ +import { revalidatePath } from "next/cache"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; +import { enqueueRepoint } from "yt-dlp-transcript-common/controller/storageLocations"; +import { channelMediaBusyReason } from "../../channels/lib/mediaBusy"; + +// ONE ENQUEUE OF THE RE-POINT JOB, for the two callers that have one: the +// Re-point button on /storage and the auto path a Refresh can take. +// +// Deliberately NOT in `actions.ts`: that file carries "use server", which means +// every non-type export in it is a server action — a shared helper cannot live +// there, and exporting this one from a "use server" file would put an unguarded +// enqueue on the wire under its own endpoint. Same reason +// `channels/lib/relocationJob.ts` exists. +// +// WHAT THIS FILE ADDS over the controller's `enqueueRepoint` is exactly two +// things the controller may not have: the editor's busy check (S1's +// `channelMediaBusyReason`, which reads the job registry AND the auto-queue +// lanes' in-flight units — a digest unit writing a sidecar makes no job record +// and is the failure that cost the omnimirror move 131 GB), and the page +// revalidations. The job record's shape stays in the controller so the boot +// pass and this cannot drift. +export async function enqueueRepointJob(opts: { + locationId: string; + newRoot: string; +}): Promise<StreamActionResult> { + const paths = getPaths(); + return enqueueRepoint({ + paths, + locationId: opts.locationId, + newRoot: opts.newRoot, + bins: paths, + isBusy: channelMediaBusyReason, + afterDone: () => { + // Every channel on the location now reads from a different path, so the + // pages that draw a media location are stale. No snapshot regen — the + // kind is in NO_REGEN_KINDS, and a re-point changes no count. + revalidatePath("/storage"); + revalidatePath("/channels"); + revalidatePath("/"); + }, + }); +} diff --git a/editor/app/storage/page.tsx b/editor/app/storage/page.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { buildStorage } from "./buildStorage"; +import { StorageLocationsTable } from "./components/StorageLocationsTable"; + +// FORCE-DYNAMIC, and not as a formality. Every number on this page comes from a +// probe of the machine taken when the page was asked for — whether a disk is +// mounted, where, and how much room is on it. A cached render of that is a +// screenshot of a drive's past, and the whole reason the page exists is that +// the drive's past is not where the operator's media is. +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { title: "Storage" }; + +export default async function StoragePage() { + const payload = await buildStorage(); + return ( + <div className="flex flex-col gap-6"> + <div className="flex items-center justify-between flex-wrap gap-2"> + <h1 className="text-2xl font-semibold">Storage</h1> + </div> + + <p className="text-sm text-muted-foreground max-w-3xl"> + A storage location is a named place a channel&rsquo;s media may live — + usually a second drive. A channel is on a location when its{" "} + <code>dataDir</code> is under that location&rsquo;s root; nothing is + tagged, so moving a channel on or off one is a move, not a setting. When + a drive comes back at a different mountpoint, <strong>re-point</strong>{" "} + the location: it rewrites every channel&rsquo;s symlink and{" "} + <code>dataDir</code> and moves no bytes. Move media onto a location from + a channel&rsquo;s <Link href="/channels" className="underline">Storage + panel</Link>. + </p> + + <StorageLocationsTable payload={payload} /> + </div> + ); +}