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:
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’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`;