Archilyzer · Source

archilyzer

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

commit 03ee88077e31c70dfff08d7d1b7b6378b6141658
parent 92f4f51e85de625d7bd9911dc7353d3d5e62e52f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 17 Sep 2026 20:51:08 -0400

storage: a destination is a name, and an interrupted move can be finished

TWO CHANGES TO THE CHANNEL'S STORAGE PANEL.

The destination is a <select> over the configured locations, opening on the default one, with "Another root…" keeping the free-text box for the one-off and for a corpus that has configured none. Each option carries its drive's current state from the probe (taken on the server — this panel is "use client" and can never shell out), and an unavailable one says so under the select instead of letting the operator find out from a job log. The client sends the location ID AND NOTHING ELSE: the root is looked up in the same settings.json /storage writes, so the form cannot post a root a re-point has since moved.

And RESUME MOVE, next to Clear marker. relocateChannelMedia has always continued a same-direction marker — it logs "(resumed an interrupted move)" — and nothing in the editor could reach it, so the only offered way out of a killed copy was to throw the marker away and re-copy from scratch: for the incident that motivated this, 131 GB already correctly on the far side. The root the marker does not carry is its target's grandparent, and that inversion is CHECKED rather than assumed: a target that is not <root>/<slug>/data refuses instead of resuming towards a directory nobody named.

The marker section is now rendered unconditionally with its condition passed down, like the two moves: Resume's own success clears the marker that offered it, and a conditional mount would delete the log at the instant it is worth reading.

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

Diffstat:
Mcommon/views/storage.ts | 25+++++++++++++++----------
Meditor/app/channels/[slug]/components/stages/StorageStage.tsx | 353+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Meditor/app/channels/[slug]/page.tsx | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Meditor/app/channels/[slug]/storageActions.ts | 123++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
4 files changed, 456 insertions(+), 145 deletions(-)

diff --git a/common/views/storage.ts b/common/views/storage.ts @@ -30,11 +30,7 @@ import type { RegistryReader } from "./inputs"; // `offered` flag and, when it is false, the sentence saying what to fix. export type StorageActionKind = - | "refresh" - | "repoint" - | "mount" - | "edit" - | "delete"; + "refresh" | "repoint" | "mount" | "edit" | "delete"; export type StorageActionView = { kind: StorageActionKind; @@ -110,7 +106,11 @@ export type StorageRowsInputs = { now: number; }; -const STATUS_LABEL: Record<StorageLocationStatus, string> = { +// THE ONE WORDING of each probe status. Exported because the channel page's +// Storage panel names the same five states beside its destination select, and +// two tables of five strings is how "Not mounted" becomes "Unmounted" on one +// page and not the other. +export const STORAGE_STATUS_LABEL: Record<StorageLocationStatus, string> = { available: "Available", "mounted-elsewhere": "Mounted elsewhere", unmounted: "Not mounted", @@ -192,7 +192,7 @@ export function buildStorageRows(i: StorageRowsInputs): StorageRowsPayload { isDefault: loc.id === i.defaultLocationId, autoRepoint: loc.autoRepoint, status, - statusLabel: STATUS_LABEL[status], + statusLabel: STORAGE_STATUS_LABEL[status], identity: probe ? identityLine(probe.identity) : null, channels: counts, channelsText: `${counts.ok} ok / ${counts.unreachable} unreachable / ${counts.moving} moving`, @@ -219,7 +219,12 @@ function repointAction( busy: string | null, ): StorageActionView { if (busy) { - return { kind: "repoint", label: "Re-point", offered: false, withheld: busy }; + return { + kind: "repoint", + label: "Re-point", + offered: false, + withheld: busy, + }; } if (status !== "mounted-elsewhere") { return { @@ -229,7 +234,7 @@ function repointAction( withheld: status === "available" ? "The root is there — nothing to re-point." - : `Re-point needs the volume mounted somewhere else; this location reads ${STATUS_LABEL[status].toLowerCase()}.`, + : `Re-point needs the volume mounted somewhere else; this location reads ${STORAGE_STATUS_LABEL[status].toLowerCase()}.`, }; } if (!candidateRoot) { @@ -273,7 +278,7 @@ function mountAction( withheld: status === "absent" ? "The volume is not attached to this machine." - : `Mount is only offered for an attached, unmounted volume; this location reads ${STATUS_LABEL[status].toLowerCase()}.`, + : `Mount is only offered for an attached, unmounted volume; this location reads ${STORAGE_STATUS_LABEL[status].toLowerCase()}.`, }; } if (!udisksctlAvailable) { diff --git a/editor/app/channels/[slug]/components/stages/StorageStage.tsx b/editor/app/channels/[slug]/components/stages/StorageStage.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useState } from "react"; import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; @@ -12,33 +13,68 @@ import { moveChannelMediaBackAction, previewRelocationAction, relocateChannelMediaAction, + resumeRelocationAction, + type MoveDestination, } from "../../storageActions"; -// WHERE THIS CHANNEL'S MEDIA LIVES, and the two buttons that change it. +// WHERE THIS CHANNEL'S MEDIA LIVES, and the 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. +// there is to move, how much room is free where it is now, and — once a +// destination 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. +// THE DESTINATION IS A NAMED LOCATION FIRST and a typed root second. /storage +// owns the list; this panel picks from it by id and never posts a root the +// server could look up for itself. "Another root…" keeps the old free-text box +// for the one-off, and it is the whole control on a corpus with no locations +// configured. +// +// ⚠️ NO 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: +// operator wants to read it — and the same is true of Resume move, whose own +// success clears the marker that offered it. All three 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. +// ONE CONFIGURED DESTINATION, projected by the page. `statusLabel` is the +// location's own probe ("Available", "Not mounted", …) taken on the SERVER: +// this file is `"use client"` and may never reach `storageVolumes.ts`, which +// shells out. It is a fact about the drive, refreshed whenever the page +// re-renders (the probe memo makes that cheap), and it is what stops the +// operator picking a destination whose disk is not there and finding out from a +// job log twenty seconds later. +export type StorageDestination = { + id: string; + label: string; + root: string; + statusLabel: string; + // The probe said the root is a directory right now. + available: boolean; +}; + +// The select's escape hatch, and the value of the option that reveals the old +// free-text box. Not a legal location id (`/^[a-z0-9][a-z0-9-]{0,63}$/`), so it +// can never collide with one. +const CUSTOM = "__custom"; + type Props = { slug: string; location: ChannelMediaLocation; + // The name of the storage location this channel's media is on right now, for + // the badge. Undefined when it is on none — in place, or on a root nobody + // named. See common/lib/storageLocations.ts. + locationLabel?: string; // 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. @@ -56,28 +92,33 @@ type Props = { // each other, and reading the hatch off `blockedReason === null` would hide // it in exactly the state it exists for. canClearMarker: boolean; - // settings.storage.mediaRoot — the cold root the operator configured once, or - // "" when there is none. READ ONLY: this panel prefills its destination box - // with it and never writes it back. The settings page is the one writer. - defaultRoot: string; + // The configured storage locations, each with its current probe. READ ONLY: + // this panel picks one and never writes the list back — /storage is its one + // writer. Empty when none are configured, which leaves the destination as the + // free-text root it has always been. + destinations: StorageDestination[]; + // `settings.storage.defaultLocationId` — which one the select opens on. + defaultLocationId: string; }; export function StorageStage({ slug, location, + locationLabel, mediaBytes, freeBytes, volumeDir, blockedReason, canClearMarker, - defaultRoot, + destinations, + defaultLocationId, }: 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} /> + <MediaLocationBadge media={location} locationLabel={locationLabel} /> </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> @@ -124,14 +165,24 @@ export function StorageStage({ </p> )} - {canClearMarker && <StaleMarker key="stale-marker" slug={slug} />} + {/* UNCONDITIONAL, with the condition passed down — the same shape the two + moves use, and for the same reason: a successful Resume CLEARS the + marker, so a `{canClearMarker && …}` here would delete the log of the + run that just finished at the instant the operator wants to read it. */} + <StaleMarker + key="stale-marker" + slug={slug} + canAct={canClearMarker} + marker={location.marker ?? null} + /> <MoveOut key="move-out" slug={slug} canMoveOut={!location.relocated} blockedReason={blockedReason} - defaultRoot={defaultRoot} + destinations={destinations} + defaultLocationId={defaultLocationId} /> <MoveBack key="move-back" @@ -164,46 +215,67 @@ function MoveOut({ slug, canMoveOut, blockedReason, - defaultRoot, + destinations, + defaultLocationId, }: { slug: string; canMoveOut: boolean; blockedReason: string | null; - defaultRoot: string; + destinations: StorageDestination[]; + defaultLocationId: string; }) { - // Seeded from settings.storage.mediaRoot, then owned by the operator. It is - // an initial value and NOT a controlled default: a router.refresh() (which - // every finished run triggers) must not throw away a root being typed. It - // also does not enable the move — the preview gate is unchanged, so a - // prefilled root still has to be previewed before Move lights up. - const [root, setRoot] = useState(defaultRoot); + // WHICH CONFIGURED LOCATION, or `__custom` for a one-off root. Opens on the + // default location; with none configured there is nothing to pick, so the + // free-text box is the whole control and this opens on it — which is exactly + // the panel as it was before locations existed. + const [destId, setDestId] = useState(() => + destinations.some((d) => d.id === defaultLocationId) + ? defaultLocationId + : (destinations[0]?.id ?? CUSTOM), + ); + // The one-off root, owned by the operator. An initial value and NOT a + // controlled default: a router.refresh() (which every finished run triggers) + // must not throw away a root being typed. + const [customRoot, setCustomRoot] = 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(""); + // WHICH DESTINATION the preview above describes. Change the select or edit + // the box 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. Keyed by destination rather + // than by root so switching between two locations that happen to share a root + // is still one confirmed preview, and switching away from one is not. + const [previewedKey, setPreviewedKey] = 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 chosen = destinations.find((d) => d.id === destId) ?? null; + const custom = destId === CUSTOM || chosen === null; + const trimmed = customRoot.trim(); + // THE ID ALONE GOES TO THE SERVER for a configured location: the root is + // looked up there, from the settings the location list lives in, so the form + // can never send a root a re-point has since moved. + const destination: MoveDestination = custom + ? { kind: "custom", root: trimmed } + : { kind: "location", locationId: chosen.id }; + const key = custom ? `custom:${trimmed}` : `location:${chosen.id}`; + const named = custom ? trimmed !== "" : true; + const confirmed = named && key === previewedKey; const disabled = !canMoveOut || !confirmed || blockedReason !== null; async function runPreview() { setPreviewing(true); setPreviewError(null); - const result = await previewRelocationAction(slug, root); + const result = await previewRelocationAction(slug, destination); setPreviewing(false); if (result.ok) { setPreview(result.preview); - setPreviewedRoot(root); + setPreviewedKey(key); } else { setPreview(null); - setPreviewedRoot(""); + setPreviewedKey(""); setPreviewError(result.error); } } @@ -219,28 +291,81 @@ function MoveOut({ 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> + {destinations.length > 0 && ( + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Destination</span> + <select + aria-label="destination location" + value={destId} + disabled={!canMoveOut} + onChange={(e) => { + setDestId(e.target.value); + // A new destination is a new question. Dropping the preview with + // the key is belt and braces — `confirmed` already fails — but it + // stops a stale free-space figure sitting under a root it was not + // measured against. + setPreview(null); + setPreviewedKey(""); + setPreviewError(null); + }} + className="rounded border border-border bg-card px-2 py-1 text-sm" + > + {destinations.map((d) => ( + <option key={d.id} value={d.id}> + {d.label} — {d.statusLabel} + </option> + ))} + <option value={CUSTOM}>Another root…</option> + </select> + {chosen && ( + <span className="text-xs text-muted-foreground font-mono break-all"> + {chosen.root} + </span> + )} + {/* The probe's answer, said out loud rather than left in the option's + text alone: a move to a root that is not mounted refuses in the + job's preflight, and the operator should read that here first. */} + {chosen && !chosen.available && ( + <span role="status" className="text-xs text-destructive"> + {chosen.label} reads {chosen.statusLabel.toLowerCase()} — the move + will refuse until the drive is there. See{" "} + <Link href="/storage" className="underline"> + Storage + </Link> + . + </span> + )} + </label> + )} + {custom && ( + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Destination root</span> + <input + type="text" + name="destinationRoot" + aria-label="destination root" + value={customRoot} + onChange={(e) => setCustomRoot(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. + A root you expect to use again belongs on{" "} + <Link href="/storage" className="underline"> + Storage + </Link>{" "} + as a named location, where its drive can be checked and re-pointed. + </span> + </label> + )} <div> <button type="button" onClick={runPreview} - disabled={!canMoveOut || trimmed === "" || previewing} + disabled={!canMoveOut || !named || previewing} className="px-3 py-1.5 rounded-md border border-border text-sm font-medium disabled:opacity-50" > {previewing ? "Checking…" : "Preview"} @@ -289,7 +414,7 @@ function MoveOut({ run resumes it rather than starting over. </p> )} - {!confirmed && trimmed !== "" && ( + {!confirmed && named && ( <p className="text-xs text-muted-foreground"> Preview this root to enable the move. </p> @@ -298,7 +423,7 @@ function MoveOut({ key="move-out-log" trigger={() => { setRanHere(true); - return relocateChannelMediaAction(slug, root); + return relocateChannelMediaAction(slug, destination); }} cancelAction={cancelJobAction} buttonLabel="Move media" @@ -362,52 +487,104 @@ function MoveBack({ ); } -// The way out of a marker whose run is gone. +// THE TWO WAYS OUT OF A MARKER WHOSE RUN IS GONE — finish it, or throw it away. // -// It is offered ONLY when the channel is in-transition AND no job is running — +// Both are offered ONLY when the channel is in-transition AND no job is running: // with a live job the marker is not stale, it belongs to that run. Nothing else -// on this panel is available in that state (both moves are disabled while a -// marker stands), so without this a killed copy leaves the channel skipped by -// every lane, refused by every media job and unable to regenerate its snapshot, -// with the only fix being to delete a dotfile over SSH. +// on this panel is available in that state (both moves are hidden or disabled +// while a marker stands), so without this section a killed copy leaves the +// channel skipped by every lane, refused by every media job and unable to +// regenerate its snapshot, with the only fix being to delete a dotfile over SSH. +// +// RESUME IS THE FIRST ONE, and it is the answer the omnimirror incident wanted: +// `relocateChannelMedia` has always continued a same-direction marker (it logs +// "(resumed an interrupted move)"), and rsync skips what is already correctly on +// the far side — so a copy killed at 131 GB costs a verify pass, not 131 GB +// again. Nothing in the editor could reach it until now. // -// It clears the marker and nothing else, which is why the copy says to look -// first: whatever is on disk stays on disk, and the status underneath may well -// be `inconsistent`. That is the honest answer. -function StaleMarker({ slug }: { slug: string }) { +// CLEAR IS THE SECOND, and it stays exactly what it was: it removes the marker +// file and NOTHING else — no files moved, copied or deleted — which is why the +// copy says to look at the drive first. The status underneath may well be +// `inconsistent`, and that is the honest answer rather than a repair nobody +// asked for. +function StaleMarker({ + slug, + canAct, + marker, +}: { + slug: string; + // A marker is present AND nothing is running. Passed rather than inferred so + // this section survives its own success — see the parent's comment. + canAct: boolean; + marker: ChannelMediaLocation["marker"] | null; +}) { const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null); + const [ranHere, setRanHere] = useState(false); + if (!canAct && !ranHere) return null; return ( - <section className="flex flex-col gap-2 rounded border border-destructive/50 bg-destructive/5 px-3 py-2"> - <h3 className="text-base font-semibold">Clear the relocation marker</h3> - <p className="text-sm text-muted-foreground"> - A move is in flight, or one was interrupted. While the marker stands this - channel is skipped by every lane and its media jobs are refused. If no - move is actually running, clear the marker — it removes the marker file - and nothing else: no files are moved, copied or deleted. Check what is on - the drive first. - </p> + <section className="flex flex-col gap-3 rounded border border-destructive/50 bg-destructive/5 px-3 py-2"> + <div className="flex flex-col gap-2"> + <h3 className="text-base font-semibold"> + Finish the interrupted move, or clear its marker + </h3> + <p className="text-sm text-muted-foreground"> + A move is in flight, or one was interrupted + {marker + ? ` at phase "${marker.phase}" (${marker.direction === "out" ? "to" : "from"} ${marker.target})` + : ""} + . While the marker stands this channel is skipped by every lane and + its media jobs are refused. + </p> + <p className="text-sm text-muted-foreground"> + <strong>Resume move</strong> runs the same move again from where it + stopped — whatever already copied correctly is not copied twice, and + the source is not touched until the copy verifies. That is the usual + answer. + </p> + <p className="text-sm text-muted-foreground"> + <strong>Clear marker</strong> removes the marker file and nothing + else: no files are moved, copied or deleted. Check what is on the + drive first, because whatever this channel&rsquo;s location reads + afterwards is what the disk was already saying underneath. + </p> + </div> {error && ( <p role="alert" className="text-sm text-destructive"> {error} </p> )} - <div> - <button - type="button" - disabled={busy} - onClick={async () => { - setBusy(true); - setError(null); - const result = await clearRelocationMarkerAction(slug); - setBusy(false); - if (!result.ok) setError(result.error); - }} - className="px-3 py-1.5 rounded-md border border-destructive text-destructive text-sm font-medium disabled:opacity-50" - > - {busy ? "Clearing…" : "Clear marker"} - </button> - </div> + {/* ONE CONTROL ROW AND THE LOG LAST. Clear rides along as `extraControls` + so the two escapes sit together — and so the log below belongs + unambiguously to Resume, the only one of the two that runs a job. */} + <StreamActionLog + key="resume-move-log" + trigger={() => { + setRanHere(true); + return resumeRelocationAction(slug); + }} + cancelAction={cancelJobAction} + buttonLabel="Resume move" + runningLabel="Resuming…" + label="Resume move" + disabled={!canAct || busy} + extraControls={ + <button + type="button" + disabled={!canAct || busy} + onClick={async () => { + setBusy(true); + setError(null); + const result = await clearRelocationMarkerAction(slug); + setBusy(false); + if (!result.ok) setError(result.error); + }} + className="px-3 py-1.5 rounded-md border border-destructive text-destructive text-sm font-medium disabled:opacity-50" + > + {busy ? "Clearing…" : "Clear marker"} + </button> + } + /> </section> ); } diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -31,7 +31,9 @@ import { countPlaylist } from "yt-dlp-transcript-common/controller/channels"; import { loadFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions"; import { savedVideoTotals } from "yt-dlp-transcript-common/controller/savedVideoInventory"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; -import { defaultLocationRoot } from "yt-dlp-transcript-common/lib/storageLocations"; +import { locationLabelOfDataDir } from "yt-dlp-transcript-common/lib/storageLocations"; +import { probeAllLocations } from "yt-dlp-transcript-common/controller/storageLocations"; +import { STORAGE_STATUS_LABEL } from "yt-dlp-transcript-common/views/storage"; import { loadShardConfig, type ShardConfig, @@ -131,12 +133,14 @@ export default async function ChannelDetailPage({ // good follow-up, not this change. if (isSocialChannel(config)) { const channelRoot = path.join(paths.channelsDir, slug); - const [postCount, shards, fetchState, postAvailability] = await Promise.all([ - countPosts(channelRoot), - listPostShards(channelRoot), - readPostFetchState(channelRoot), - readPostAvailability(channelRoot), - ]); + const [postCount, shards, fetchState, postAvailability] = await Promise.all( + [ + countPosts(channelRoot), + listPostShards(channelRoot), + readPostFetchState(channelRoot), + readPostAvailability(channelRoot), + ], + ); const availabilityRecords = Object.values(postAvailability); const fetcher = resolveSocialFetcher(config.postFetcher, config.url); // Through the one builder, so this list has the same progress bars /jobs @@ -158,10 +162,12 @@ export default async function ChannelDetailPage({ fetcherLabel: fetcher?.label ?? "no fetcher configured", fetcherId: fetcher?.id ?? "", availableFetchers: await listPostFetchersFor(config.platform ?? ""), - deletedCount: availabilityRecords.filter((r) => isPostGone(r.availability)) - .length, + deletedCount: availabilityRecords.filter((r) => + isPostGone(r.availability), + ).length, checkedCount: availabilityRecords.length, - canCheckAvailability: typeof fetcher?.checkAvailability === "function", + canCheckAvailability: + typeof fetcher?.checkAvailability === "function", handle: config.socialHandle ?? slug, accountUrl: config.url, }} @@ -230,9 +236,8 @@ export default async function ChannelDetailPage({ const actionableNoTranscriptIds = buckets.noTranscript.filter( (id) => !excludedDownloadIds.has(id), ); - const actionableDownloadedNoTranscriptIds = buckets.downloadedNoTranscript.filter( - (id) => !excludedDownloadIds.has(id), - ); + const actionableDownloadedNoTranscriptIds = + buckets.downloadedNoTranscript.filter((id) => !excludedDownloadIds.has(id)); // Enabled lane backfills. A settings read, no I/O. // // backfillLaneOperations, still: this is the BACKFILL lane's list, and the snapshot @@ -284,9 +289,12 @@ export default async function ChannelDetailPage({ // the overview with no hint that the panel it named is still there under a new // id. Resolve the alias first and the old link keeps working. const STAGE_ALIASES: Record<string, StageId> = { backfill: "speakers" }; - const resolvedStage = rawStage ? (STAGE_ALIASES[rawStage] ?? rawStage) : undefined; + const resolvedStage = rawStage + ? (STAGE_ALIASES[rawStage] ?? rawStage) + : undefined; const selectedStage: StageId | null = - (stageOrder.find((id) => id === resolvedStage) as StageId | undefined) ?? null; + (stageOrder.find((id) => id === resolvedStage) as StageId | undefined) ?? + null; const flow = computeChannelFlow({ snapshot, @@ -304,9 +312,7 @@ export default async function ChannelDetailPage({ // site list. Rendering all ten panels on every request is what put four file // reads and a directory walk in front of a page whose default view needs // neither. `panel` is null on the overview, which does no I/O at all. - const panel = selectedStage - ? await buildPanel(selectedStage) - : null; + const panel = selectedStage ? await buildPanel(selectedStage) : null; async function buildPanel(id: StageId): Promise<ReactNode> { const summarize = (c: ShardConfig | null) => @@ -332,15 +338,17 @@ export default async function ChannelDetailPage({ defaultGroupId: s.defaultGroupId, groups: sortGroups(s.groups).map((g) => ({ id: g.id, name: g.name })), })); - const initialMemberships: InitialMembership[] = allSites.flatMap((s) => { - const m = s.channels.find((c) => c.slug === slug); - if (!m) return []; - return [ - m.groupId - ? { siteId: s.siteId, groupId: m.groupId } - : { siteId: s.siteId }, - ]; - }); + const initialMemberships: InitialMembership[] = allSites.flatMap( + (s) => { + const m = s.channels.find((c) => c.slug === slug); + if (!m) return []; + return [ + m.groupId + ? { siteId: s.siteId, groupId: m.groupId } + : { siteId: s.siteId }, + ]; + }, + ); return ( <ChannelFormClient action={ @@ -461,7 +469,10 @@ export default async function ChannelDetailPage({ case "cleanup": { // Saved-video store summary for this channel + whether backups are // configured, for the Retention & persistence section. - const savedTotals = await savedVideoTotals({ paths, channelSlug: slug }); + const savedTotals = await savedVideoTotals({ + paths, + channelSlug: slug, + }); return ( <CleanupStage slug={slug} @@ -517,7 +528,9 @@ export default async function ChannelDetailPage({ // 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; + media.status === "ok" && media.target + ? media.target + : paths.channelsDir; // `media` is inspectChannelMedia's answer and it already CARRIES the // marker — reading the file again here was a second read of the same // bytes that could disagree with the status rendered beside it. @@ -532,12 +545,22 @@ export default async function ChannelDetailPage({ const blockedReason = busy ?? (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 rerunning the move.` + ? `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 finished by "Resume move" below.` : null); + // THE DESTINATIONS, EACH WITH ITS DRIVE'S CURRENT STATE. One probe per + // configured location, memoised for 10 s inside the controller — so a + // re-render costs nothing and a stage the operator is not looking at + // costs nothing either, because this branch only runs for the Storage + // stage. The panel is `"use client"` and can never ask this itself: + // `storageVolumes.ts` shells out. + const locations = settings.storage.locations; + const probes = await probeAllLocations(locations, paths); return ( <StorageStage slug={slug} location={media} + // Which named location the media is on NOW, for the badge. + locationLabel={locationLabelOfDataDir(media.target, locations)} // 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. @@ -548,9 +571,20 @@ export default async function ChannelDetailPage({ // A marker with no job behind it is a stale marker: the run that // wrote it is gone, and nothing else will ever clear it. canClearMarker={marker !== null && busy === null} - // The configured cold root, prefilled into the destination box so - // it is typed once in Settings instead of once per channel. - defaultRoot={defaultLocationRoot(settings.storage)} + // The named destinations, so a move is a choice from a list the + // operator maintains once on /storage instead of a path retyped per + // channel — and so the panel can say whether that drive is there. + destinations={locations.map((loc) => { + const probe = probes[loc.id]; + return { + id: loc.id, + label: loc.label || loc.id, + root: loc.root, + statusLabel: STORAGE_STATUS_LABEL[probe?.status ?? "missing"], + available: probe?.status === "available", + }; + })} + defaultLocationId={settings.storage.defaultLocationId} /> ); } diff --git a/editor/app/channels/[slug]/storageActions.ts b/editor/app/channels/[slug]/storageActions.ts @@ -1,6 +1,6 @@ "use server"; -// WHERE A CHANNEL'S MEDIA LIVES — the three actions the Storage panel drives. +// WHERE A CHANNEL'S MEDIA LIVES — the actions the Storage panel drives. // // All of the mechanism is in common/controller/relocateChannelMedia.ts. What is // here is the editor's half: the preview (a plain async call, no job — it is @@ -24,10 +24,16 @@ // also serialized by the queue — but the queue would make it WAIT, and waiting // is the wrong answer for a move the operator can see is already in flight. +import path from "node:path"; import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; -import { clearRelocationMarker } from "yt-dlp-transcript-common/lib/channelMedia"; +import { + clearRelocationMarker, + readRelocationMarker, + relocatedDataDir, +} from "yt-dlp-transcript-common/lib/channelMedia"; import { previewRelocation, relocationRootProblem, @@ -36,9 +42,39 @@ import { import { enqueueRelocation } from "../lib/relocationJob"; import { channelMediaBusyReason } from "../lib/mediaBusy"; +// WHERE THE MOVE IS GOING, as the panel can say it. +// +// A destination is EITHER a configured location, named by its id, OR a root the +// operator typed. The difference is not cosmetic: for a location the client +// sends the ID AND NOTHING ELSE, and the root is looked up here, from the same +// settings.json /storage writes. A form that posted the root alongside the id +// would be a second copy of a fact that already has one home, and the copy the +// server trusted would be the one the browser had when the page was rendered — +// stale the moment a re-point moved the location. +export type MoveDestination = + { kind: "location"; locationId: string } | { kind: "custom"; root: string }; + +type ResolvedDestination = { root: string } | { error: string }; + +function resolveDestination(dest: MoveDestination): ResolvedDestination { + if (dest.kind === "custom") { + const root = dest.root.trim(); + return root ? { root } : { error: "Enter a destination root." }; + } + const id = dest.locationId.trim(); + const found = getSettings().storage.locations.find((l) => l.id === id); + if (!found) { + return { + error: + `No storage location "${id}" is configured. ` + + `Add it on /storage, or choose a different destination.`, + }; + } + return { root: found.root }; +} + export type PreviewRelocationResult = - | { ok: true; preview: RelocationPreview } - | { ok: false; error: string }; + { ok: true; preview: RelocationPreview } | { ok: false; error: string }; // Read-only: one tree walk of the channel's data dir plus two statfs calls. It // runs INLINE rather than as a job because its whole purpose is to answer a @@ -46,15 +82,15 @@ export type PreviewRelocationResult = // would be a worse way to show two numbers. export async function previewRelocationAction( slug: string, - root: string, + dest: MoveDestination, ): Promise<PreviewRelocationResult> { - const trimmed = root.trim(); - if (!trimmed) return { ok: false, error: "Enter a destination root." }; + const resolved = resolveDestination(dest); + if ("error" in resolved) return { ok: false, error: resolved.error }; try { const preview = await previewRelocation({ paths: getPaths(), slug, - root: trimmed, + root: resolved.root, }); return { ok: true, preview }; } catch (e) { @@ -64,22 +100,78 @@ export async function previewRelocationAction( export async function relocateChannelMediaAction( slug: string, - root: string, + dest: MoveDestination, ): Promise<StreamActionResult> { - const trimmed = root.trim(); - if (!trimmed) return { ok: false, error: "Enter a destination root." }; + const resolved = resolveDestination(dest); + if ("error" in resolved) return { ok: false, error: resolved.error }; + const root = resolved.root; // Relative, or inside the corpus. The job refuses both too — it is the guard — // but a root that would copy the channel onto itself should not become a job // record and a log the operator has to open to read the reason. const rootProblem = await relocationRootProblem({ paths: getPaths(), slug, - root: trimmed, + root, }); if (rootProblem) return { ok: false, error: rootProblem }; const refusal = channelMediaBusyReason(slug, "moving its media"); if (refusal) return { ok: false, error: refusal }; - return enqueueRelocation({ slug, direction: "out", root: trimmed }); + return enqueueRelocation({ slug, direction: "out", root }); +} + +// FINISH THE MOVE THAT WAS INTERRUPTED — the other half of "Clear marker", and +// the one the operator wanted first. +// +// `relocateChannelMedia` has ALWAYS resumed a same-direction marker +// (relocateChannelMedia.ts: `resumed` relaxes the "must be in place" +// precondition, and the job logs "(resumed an interrupted move)"). Nothing in +// the editor could reach it: the panel's Move button is hidden once the config +// records a target and disabled while a marker stands, so a killed copy left +// exactly one affordance — throw the marker away and re-copy from scratch, +// which for the incident that motivated this was 131 GB already correctly on +// the far side. +// +// So this enqueues the SAME relocation job with the SAME direction, and the +// controller does the resuming. What it adds is the root, which the marker does +// not carry: a marker records `<root>/<slug>/data`, so the root is its +// grandparent. That inversion is checked rather than assumed — if rebuilding +// the target from the derived root does not give back the marker's own target +// (a hand-edited marker, a slug with a separator in it, a future layout), this +// refuses instead of resuming a move towards a directory nobody named. The move +// BACK needs no root at all: its target is the config's, and the controller +// reads it. +export async function resumeRelocationAction( + slug: string, +): Promise<StreamActionResult> { + const paths = getPaths(); + const marker = await readRelocationMarker(paths, slug); + if (!marker) { + return { + ok: false, + error: + `Channel "${slug}" has no relocation marker — there is no ` + + `interrupted move to resume.`, + }; + } + // The same guard the two moves use, and for the same reason: a marker with a + // LIVE run behind it is not interrupted, it is in progress, and a second job + // would copy into the directory the first one is writing. + const refusal = channelMediaBusyReason(slug, "resuming its move"); + if (refusal) return { ok: false, error: refusal }; + if (marker.direction === "back") { + return enqueueRelocation({ slug, direction: "back" }); + } + const root = path.dirname(path.dirname(marker.target)); + if (marker.target !== relocatedDataDir(root, slug)) { + return { + ok: false, + error: + `The relocation marker points at ${marker.target}, which is not ` + + `<root>/${slug}/data — the destination root cannot be recovered from ` + + `it. Clear the marker and start the move again.`, + }; + } + return enqueueRelocation({ slug, direction: "out", root }); } export async function moveChannelMediaBackAction( @@ -108,7 +200,10 @@ export async function moveChannelMediaBackAction( export async function clearRelocationMarkerAction( slug: string, ): Promise<{ ok: true } | { ok: false; error: string }> { - const refusal = channelMediaBusyReason(slug, "clearing its relocation marker"); + const refusal = channelMediaBusyReason( + slug, + "clearing its relocation marker", + ); if (refusal) return { ok: false, error: refusal }; try { await clearRelocationMarker(getPaths(), slug);