Archilyzer · Source

archilyzer

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

commit 2c1c750bcea44dc0fa2b828f58cc1f3c9f1ed9ed
parent 3ffd166de250210552ea57c454b7064a65f90770
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 10:49:44 -0400

editor: the Storage panel and the rack show a channel's media hold; Reconcile and resume; the busy sentence names the writer

- StorageStage: "Held: its media is moving (…)" under the location while a
  marker stands, and Reconcile and resume beside Resume move — the same
  relocation job with `reconcile`, its own log ("Reconcile and resume
  output"). storageActions: reconcileRelocationAction, sharing Resume's guards.
- The rack's tier cell carries a "media held" chip ("media hold for <slug>")
  with the hold's sentence on title and in screen-reader text.
- mediaBusy: the counts as before, then "Now: <the first writer, named>." from
  common/controller/channelWriters.ts — the list the move's own job asks.
- The store move's job asks the store check (running only) as its first step.

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

Diffstat:
Meditor/app/channels/[slug]/components/stages/StorageStage.tsx | 52++++++++++++++++++++++++++++++++++++++++++++++++++--
Meditor/app/channels/[slug]/page.tsx | 6+++++-
Meditor/app/channels/[slug]/storageActions.ts | 33+++++++++++++++++++++++++++++----
Meditor/app/channels/components/ChannelTierSelect.tsx | 21+++++++++++++++++++++
Meditor/app/channels/components/channelColumns.tsx | 1+
Meditor/app/channels/lib/mediaBusy.ts | 34++++++++++++++++++++--------------
Meditor/app/channels/lib/relocationJob.ts | 14+++++++++++---
Meditor/app/storage/lib/savedVideosJob.ts | 9++++++++-
Meditor/app/storage/lib/storeBusy.ts | 19++++++++++++++++---
9 files changed, 161 insertions(+), 28 deletions(-)

diff --git a/editor/app/channels/[slug]/components/stages/StorageStage.tsx b/editor/app/channels/[slug]/components/stages/StorageStage.tsx @@ -13,6 +13,7 @@ import { clearRelocationMarkerAction, moveChannelMediaBackAction, previewRelocationAction, + reconcileRelocationAction, relocateChannelMediaAction, resumeRelocationAction, } from "../../storageActions"; @@ -112,6 +113,10 @@ type Props = { destinations: StorageDestination[]; // `settings.storage.defaultLocationId` — which one the select opens on. defaultLocationId: string; + // The channel's media hold in its one wording ("held: its media is moving + // (…)", lib/channelMediaHold.ts), or null. Computed on the server: the hold + // module is not imported into a client file. + mediaHold: string | null; }; export function StorageStage({ @@ -126,6 +131,7 @@ export function StorageStage({ canClearMarker, destinations, defaultLocationId, + mediaHold, }: Props) { return ( <div className="flex flex-col gap-6"> @@ -161,6 +167,24 @@ export function StorageStage({ {location.detail && ( <p className="text-sm text-muted-foreground">{location.detail}</p> )} + {/* THE HOLD, said where the operator looks for it (release 16 slice + RM): the rack carries the same sentence on its "media held" chip. + It lifts with the status — the move completing, its marker cleared, + the drive back. */} + {mediaHold && ( + <p + role="status" + aria-label="media hold" + className="text-sm rounded border border-warning/40 bg-warning-soft px-3 py-2" + > + {mediaHold.charAt(0).toUpperCase() + mediaHold.slice(1)}. The lanes + skip this channel and its media jobs refuse to start until the hold + lifts + {location.status === "in-transition" + ? " — when the move completes, or its marker is cleared below." + : "."} + </p> + )} {mediaBytes === null && ( <p className="text-xs text-muted-foreground"> No audio total in this channel&rsquo;s report yet — refresh the @@ -605,8 +629,17 @@ function StaleMarker({ <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. + twice, the copy is mirrored to the source, and the source is not + touched until the copy verifies. That is the usual answer. + </p> + <p className="text-sm text-muted-foreground"> + <strong>Reconcile and resume</strong> is the answer to a move + whose verification failed: it first lists how the copy on the + destination differs from the source — extra there, missing there, + changed — then makes the copy match the source (removing from the + copy what the source no longer has; the source itself is never + changed), verifies, and finishes the move. No file needs deleting + by hand. </p> <p className="text-sm text-muted-foreground"> <strong>Clear marker</strong> removes the marker file and nothing @@ -657,6 +690,21 @@ function StaleMarker({ </button> } /> + {/* THE REMEDIATION, with its own log so each run reads as itself. Same + gate as Resume: a marker stands and nothing is running on the + channel. */} + <StreamActionLog + key="reconcile-move-log" + trigger={() => { + setRanHere(true); + return reconcileRelocationAction(slug); + }} + cancelAction={cancelJobAction} + buttonLabel="Reconcile and resume" + runningLabel="Reconciling…" + label="Reconcile and resume" + disabled={!canAct || busy} + /> </section> ); } diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -44,6 +44,7 @@ import { channelMediaStall, inspectChannelMedia, } from "yt-dlp-transcript-common/lib/channelMedia"; +import { mediaHoldText } from "yt-dlp-transcript-common/lib/channelMediaHold"; import { MediaNotAnswering } from "./components/MediaNotAnswering"; import { isDriveNotAnswering, @@ -638,7 +639,7 @@ 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 finished by "Resume move" below.` + ? `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" or "Reconcile and resume" 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 @@ -682,6 +683,9 @@ export default async function ChannelDetailPage({ }; })} defaultLocationId={settings.storage.defaultLocationId} + // THE HOLD, in the rack's words: "held: its media is moving (…)" + // while the marker stands (release 16 slice RM). + mediaHold={mediaHoldText(media.status)} /> ); } diff --git a/editor/app/channels/[slug]/storageActions.ts b/editor/app/channels/[slug]/storageActions.ts @@ -122,6 +122,28 @@ export async function relocateChannelMediaAction( export async function resumeRelocationAction( slug: string, ): Promise<StreamActionResult> { + return resumeRelocation(slug, false); +} + +// RECONCILE AND RESUME — the remediation for a move whose verification failed +// (release 16 slice RM). The same job as Resume move, asked to make the +// destination copy MATCH the source before it verifies: one mirror pass +// (`rsync -a --delete` toward the destination copy, never the source), the +// verify, the swap, the reclaim. What it found is logged by kind first — extra +// on the destination, missing there, changed — so the operator reads what was +// settled; a difference that will not settle (something still writing) is +// refused again and named. The operator never deletes a file by hand. +export async function reconcileRelocationAction( + slug: string, +): Promise<StreamActionResult> { + return resumeRelocation(slug, true); +} + +// Not exported: in a "use server" file every export is an endpoint. +async function resumeRelocation( + slug: string, + reconcile: boolean, +): Promise<StreamActionResult> { const paths = getPaths(); const marker = await readRelocationMarker(paths, slug); if (!marker) { @@ -129,16 +151,19 @@ export async function resumeRelocationAction( ok: false, error: `Channel "${slug}" has no relocation marker — there is no ` + - `interrupted move to resume.`, + `interrupted move to ${reconcile ? "reconcile" : "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"); + const refusal = channelMediaBusyReason( + slug, + reconcile ? "reconciling its move" : "resuming its move", + ); if (refusal) return { ok: false, error: refusal }; if (marker.direction === "back") { - return enqueueRelocation({ slug, direction: "back" }); + return enqueueRelocation({ slug, direction: "back", reconcile }); } const root = path.dirname(path.dirname(marker.target)); if (marker.target !== relocatedDataDir(root, slug)) { @@ -150,7 +175,7 @@ export async function resumeRelocationAction( `it. Clear the marker and start the move again.`, }; } - return enqueueRelocation({ slug, direction: "out", root }); + return enqueueRelocation({ slug, direction: "out", root, reconcile }); } export async function moveChannelMediaBackAction( diff --git a/editor/app/channels/components/ChannelTierSelect.tsx b/editor/app/channels/components/ChannelTierSelect.tsx @@ -57,6 +57,12 @@ export type ChannelTierSelectProps = { // says the tier ITSELF was set by something other than the operator, and // will be set back. autoPausedReason?: string | null; + // THE CHANNEL'S MEDIA HOLD — "held: its media is moving (…)" while a + // relocation marker stands — or null (common/views/channelRow.ts' + // `mediaHold`). Distinct from both chips above: the lanes skip this channel + // whatever its tier and whatever the focus, until the move completes or its + // marker is cleared (release 16 slice RM). + mediaHold?: string | null; disabled?: boolean; }; @@ -86,6 +92,7 @@ export default function ChannelTierSelect({ focused = false, heldReason = null, autoPausedReason = null, + mediaHold = null, disabled = false, }: ChannelTierSelectProps): React.ReactNode { const [pending, startTransition] = useTransition(); @@ -187,6 +194,20 @@ export default function ChannelTierSelect({ <span className="sr-only"> — {autoPausedReason}</span> </span> )} + {/* THE MEDIA HOLD. The chip says the fact in the rack's width; the + sentence ("held: its media is moving …") is on `title` and in the + screen-reader text, as for the two chips above. */} + {mediaHold && ( + <span + role="note" + aria-label={`media hold for ${slug}`} + title={mediaHold} + className="rounded-full border border-warning/30 bg-warning-soft px-1.5 text-[10px] uppercase tracking-wide text-warning" + > + media held + <span className="sr-only"> — {mediaHold}</span> + </span> + )} {error && ( <span role="alert" diff --git a/editor/app/channels/components/channelColumns.tsx b/editor/app/channels/components/channelColumns.tsx @@ -178,6 +178,7 @@ export const CHANNEL_COLUMNS: Record< focused={c.priority.focused} heldReason={c.priority.heldReason} autoPausedReason={c.priority.autoPausedReason} + mediaHold={c.mediaHold} /> ), }), diff --git a/editor/app/channels/lib/mediaBusy.ts b/editor/app/channels/lib/mediaBusy.ts @@ -23,31 +23,37 @@ // transitively), so nothing reachable from a `"use client"` file may import it; // `next build` is what proves that. -import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; -import { getAutoRunnerStatus } from "yt-dlp-transcript-common/controller/autoRunner"; -import { LANES } from "yt-dlp-transcript-common/lib/autoQueueTypes"; +import { + channelWriters, + describeChannelWriter, +} from "yt-dlp-transcript-common/controller/channelWriters"; // A sentence naming what is holding the channel, or null when nothing is. The // caller supplies the verb (`"moving its media"`) so the same reason reads as // an instruction in every panel it appears in. +// +// THE COUNTS, THEN THE FIRST WRITER BY NAME (release 16 slice RM): "Finish or +// cancel 1 running/queued job(s) for this channel before moving its media. +// Now: a transcription of v50t5yt is running (Transcribe all, job 01M…)." The +// list is common/controller/channelWriters.ts's — the one the move's preview +// and its job's first step ask — with queued jobs counted here, because this is +// the courtesy before anything is enqueued. A lane's download unit is also a +// registry job; it is counted once, as the unit. export function channelMediaBusyReason( slug: string, what?: string, ): string | null { - const jobs = getRegistry() - .list() - .filter( - (j) => - j.channelSlug === slug && - (j.status === "running" || j.status === "queued"), - ).length; - const units = LANES.flatMap((kind) => getAutoRunnerStatus(kind).inFlight) - .filter((u) => u.channelSlug === slug).length; - if (jobs === 0 && units === 0) return null; + const writers = channelWriters(slug, { includeQueued: true }); + if (writers.length === 0) return null; + const jobs = writers.filter((w) => w.source === "job").length; + const units = writers.length - jobs; const parts: string[] = []; if (jobs > 0) parts.push(`${jobs} running/queued job(s)`); if (units > 0) parts.push(`${units} auto-queue unit(s) in flight`); const subject = `${parts.join(" and ")} for this channel`; - return what ? `Finish or cancel ${subject} before ${what}.` : subject; + const now = `Now: ${describeChannelWriter(writers[0])}.`; + return what + ? `Finish or cancel ${subject} before ${what}. ${now}` + : `${subject}. ${now}`; } diff --git a/editor/app/channels/lib/relocationJob.ts b/editor/app/channels/lib/relocationJob.ts @@ -36,8 +36,11 @@ export async function enqueueRelocation(opts: { slug: string; direction: RelocationDirection; root?: string; + // Reconcile and resume (release 16 slice RM): finish an interrupted move by + // mirroring the destination copy to the source, verifying, and completing. + reconcile?: boolean; }): Promise<StreamActionResult> { - const { slug, direction, root } = opts; + const { slug, direction, root, reconcile } = opts; const paths = getPaths(); return runManagedFunction({ kind: "relocate-channel-media", @@ -64,6 +67,7 @@ export async function enqueueRelocation(opts: { slug, direction, root, + reconcile, onLog, onProgress: (p) => { if (!started) { @@ -93,12 +97,16 @@ export async function enqueueRelocation(opts: { onLog( `${direction === "out" ? "Moved" : "Moved back"} ${result.files} file(s) / ` + `${formatBytes(result.bytes)} — ${result.target}` + - (result.resumed ? " (resumed an interrupted move)" : "") + + (result.resumed + ? reconcile + ? " (reconciled and resumed an interrupted move)" + : " (resumed an interrupted move)" + : "") + // Said out loud because the operator has watched this refuse a move: // a directory timestamp left by a sidecar written mid-copy used to // fail the verify with 131 GB correctly on the far side. (result.retried - ? " (one directory timestamp settled by a second pass)" + ? " (a change made during the copy was settled by a second mirror pass)" : ""), ); // No snapshot regen — deliberately, and `relocate-channel-media` is in diff --git a/editor/app/storage/lib/savedVideosJob.ts b/editor/app/storage/lib/savedVideosJob.ts @@ -7,6 +7,7 @@ import { } from "yt-dlp-transcript-common/jobs/streamCommand"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import { relocateSavedVideos } from "yt-dlp-transcript-common/controller/relocateSavedVideos"; +import { savedVideosStoreBusyReason } from "./storeBusy"; // ONE ENQUEUE OF THE SAVED-VIDEO STORE MOVE, for the two callers that have one: // the Move control on /storage and its Resume. @@ -39,6 +40,12 @@ export async function enqueueSavedVideosRelocation(opts: { const result = await relocateSavedVideos({ paths, locationId: opts.locationId, + // Asked again as the job's first step: the action asked at enqueue, + // and the move may have waited behind others on the relocation queue. + busy: () => + savedVideosStoreBusyReason("moving the saved-video store", { + runningOnly: true, + }), onLog, // Added on the FIRST frame, not at start: the preflight and a resumed // run's verify pass transfer nothing, and a bar sitting at 0 % through @@ -66,7 +73,7 @@ export async function enqueueSavedVideosRelocation(opts: { `${formatBytes(result.bytes)} — ${result.target}` + (result.resumed ? " (resumed an interrupted move)" : "") + (result.retried - ? " (one directory timestamp settled by a second pass)" + ? " (a change made during the copy was settled by a second mirror pass)" : ""), ); // No snapshot regen — the kind is in NO_REGEN_KINDS. The move changes diff --git a/editor/app/storage/lib/storeBusy.ts b/editor/app/storage/lib/storeBusy.ts @@ -54,13 +54,23 @@ const STORE_TOUCHING_KINDS = new Set([ // A sentence naming what is holding the store, or null. The caller supplies the // verb, so the same reason reads as an instruction wherever it appears. -export function savedVideosStoreBusyReason(what?: string): string | null { +// +// `runningOnly` is the store move's own first step (relocateSavedVideos' +// `busy`): by then the move has waited its turn on the relocation queue, and a +// download merely QUEUED behind other work is not writing — when it persists +// later, the marker refuses the persist and leaves the container in its data +// dir. The enqueue-time courtesy counts queued jobs too. +export function savedVideosStoreBusyReason( + what?: string, + opts: { runningOnly?: boolean } = {}, +): string | null { const jobs = getRegistry() .list() .filter( (j) => STORE_TOUCHING_KINDS.has(j.kind) && - (j.status === "running" || j.status === "queued"), + (j.status === "running" || + (!opts.runningOnly && j.status === "queued")), ); // THE DOWNLOAD LANE'S UNITS MAKE NO JOB RECORD — the omnimirror lesson, and // the reason `channelMediaBusyReason` exists in the shape it does. A unit @@ -72,7 +82,10 @@ export function savedVideosStoreBusyReason(what?: string): string | null { const parts: string[] = []; if (jobs.length > 0) { const kinds = [...new Set(jobs.map((j) => j.kind))].slice(0, 3).join(", "); - parts.push(`${jobs.length} running/queued download job(s) (${kinds})`); + parts.push( + `${jobs.length} ${opts.runningOnly ? "running" : "running/queued"} ` + + `download job(s) (${kinds})`, + ); } if (units > 0) parts.push(`${units} auto-download unit(s) in flight`); const subject = `${parts.join(" and ")} — any of them can persist a source video into the store`;