Archilyzer · Source

archilyzer

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

commit b7688f22158827ee9ebc2d1fe9af48eedd10385a
parent 32b81b9082dd5e1cee5695b54cd547679582f9d8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  2 Oct 2026 00:51:40 -0400

common, editor: a move is held by the channel's media writers only

channelMediaWriters (relocateChannelMedia.ts) keeps the jobs whose kind opens
or writes a big file, a running move of the channel, and every lane but the
digest one; the move's first step, its preview and the Storage panel's
actions (move, back, resume, reconcile, clear marker, the bulk move) ask it,
so a digest may run during a move. A rename and a delete still ask every
writer. The /storage and re-point prose names mediaDir.

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

Diffstat:
Mcommon/controller/relocateChannelMedia.test.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/relocateChannelMedia.ts | 32+++++++++++++++++++++++++-------
Mcommon/jobs/jobKinds.ts | 2+-
Meditor/app/channels/[slug]/page.tsx | 6+++++-
Meditor/app/channels/[slug]/storageActions.ts | 10++++++++--
Meditor/app/channels/bulkStorageActions.ts | 2+-
Meditor/app/channels/components/ChannelVolumeBar.tsx | 2+-
Meditor/app/channels/lib/mediaBusy.ts | 11++++++++++-
Meditor/app/storage/components/StorageLocationsTable.tsx | 4++--
Meditor/app/storage/page.tsx | 15++++++++-------
10 files changed, 111 insertions(+), 23 deletions(-)

diff --git a/common/controller/relocateChannelMedia.test.ts b/common/controller/relocateChannelMedia.test.ts @@ -30,8 +30,10 @@ import { tierLinkTarget, } from "../lib/mediaTier-server"; import type { ChannelWriter } from "./channelWriters"; +import type { JobRecord } from "../jobs/registry"; import { assertRelocationRootPresent, + channelMediaWriters, previewRelocation, relocateChannelMedia, relocationRootPresenceProblem, @@ -1464,6 +1466,54 @@ test("a writer seen once the marker is written refuses, and a fresh move's marke }); }); +// A MOVE HOLDS ONLY THE MEDIA WRITERS (release 17 ruling): a digest — a job or +// the digest lane's unit — reads and writes the text, which never moves, so it +// is not a writer for this question. A running move of the channel IS one, +// though its kind is not `needsMedia`: the preview and the panel must not +// offer a second move over it. +test("channelMediaWriters: media jobs, a move and the media lanes — never a digest", () => { + const job = (id: string, kind: string): JobRecord => + ({ + id, + kind, + queueKey: "q", + status: "running", + queuedAt: 1, + logPath: "/dev/null", + channelSlug: "alpha", + }) as JobRecord; + const jobs = [ + job("J1", "digest-channel-local"), + job("J2", "whisper-all"), + job("J3", "relocate-channel-media"), + ]; + const unit = (lane: "digest" | "transcription", videoId: string) => ({ + lane, + unit: { videoId, leafId: "leaf", channelSlug: "alpha", startedAt: 1 }, + }); + const source = { + jobs: () => jobs, + units: () => [unit("digest", "d1"), unit("transcription", "t1")], + }; + const names = (ws: ChannelWriter[]) => + ws.map((w) => (w.source === "job" ? w.jobId : `${w.lane}:${w.videoId}`)); + assert.deepEqual(names(channelMediaWriters("alpha", { source })), [ + "J2", + "J3", + "transcription:t1", + ]); + // The move's own first step leaves itself out. + assert.deepEqual( + names( + channelMediaWriters("alpha", { + source, + ignoreKinds: ["relocate-channel-media"], + }), + ), + ["J2", "transcription:t1"], + ); +}); + // THE 2026-10-01 CASE. A move killed mid-copy left a transcriber's scratch dir // on the destination that has since gone from the source. The resume used to // copy everything else and refuse on the counts (1755 against 1750), and no diff --git a/common/controller/relocateChannelMedia.ts b/common/controller/relocateChannelMedia.ts @@ -30,7 +30,9 @@ import { channelWriters, channelWritersRefusal, type ChannelWriter, + type ChannelWritersOptions, } from "./channelWriters"; +import { kindNeedsMedia } from "../jobs/jobKinds"; import { isSocialChannel } from "../lib/channelConfig"; import { locationOfDataDir, @@ -181,13 +183,29 @@ const RELOCATE_KIND = "relocate-channel-media"; // MEDIA WRITERS ONLY (release 17 ruling): a move carries `media/` and nothing // else, so a digest, a normalize or any other reader/writer of the TEXT may run -// while it does — `mediaOnly` keeps the jobs whose kind opens or writes a big -// file and the lane units of every lane but the digest one. +// while it does. Kept: the jobs whose kind opens or writes a big file +// (`kindNeedsMedia`), the lane units of every lane but the digest one — and a +// MOVE of this channel's media itself, whose kind is not `needsMedia` (it must +// not be refused by the media guard it is the reason for) but which is, of +// everything, the thing writing into `media/`. In the order `channelWriters` +// gives (running jobs, queued jobs, lane units), so the refusal names the same +// writer first. +// +// The Storage panel's actions, the preview and the job's own first step all +// ask this; a rename or a delete of the channel asks every writer. +export function channelMediaWriters( + slug: string, + opts: Omit<ChannelWritersOptions, "mediaOnly"> = {}, +): ChannelWriter[] { + return channelWriters(slug, opts).filter((w) => + w.source === "job" + ? kindNeedsMedia(w.kind) || w.kind === RELOCATE_KIND + : w.lane !== "digest", + ); +} + function liveWriters(slug: string): ChannelWriter[] { - return channelWriters(slug, { - ignoreKinds: [RELOCATE_KIND], - mediaOnly: true, - }); + return channelMediaWriters(slug, { ignoreKinds: [RELOCATE_KIND] }); } // THE RETIRED WHOLE-DIRECTORY LAYOUT, refused by every entry point of the mover @@ -554,7 +572,7 @@ export async function previewRelocation({ paths, slug, root, - writers = (s) => channelWriters(s, { mediaOnly: true }), + writers = (s) => channelMediaWriters(s), }: { paths: Paths; slug: string; diff --git a/common/jobs/jobKinds.ts b/common/jobs/jobKinds.ts @@ -463,7 +463,7 @@ const JOB_KINDS: Record<string, JobKindMeta> = { // THE SAME LINKS, WITHOUT THE BYTES. A re-point rewrites every channel // symlink on one storage location plus the location's root, for the case the // relocation above cannot help with: the media never moved, the DISK did, and - // every `config.dataDir` on it now names a mountpoint that is not there. + // every `config.mediaDir` on it now names a mountpoint that is not there. // // `needsMedia: false` for the relocation's reason, and more sharply: every // channel this job touches is BY DEFINITION unreachable when it starts — diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -635,7 +635,11 @@ export default async function ChannelDetailPage({ // and stated in the action too, because a disabled button is a courtesy // and the server is the guard. "Busy" is jobs AND in-flight auto-queue // units, which make no job record — see lib/mediaBusy.ts. - const busy = channelMediaBusyReason(slug, "moving its media"); + // The move holds the media writers only (release 17): a digest may + // run during it. + const busy = channelMediaBusyReason(slug, "moving its media", { + mediaOnly: true, + }); const blockedReason = busy ?? (marker diff --git a/editor/app/channels/[slug]/storageActions.ts b/editor/app/channels/[slug]/storageActions.ts @@ -95,7 +95,9 @@ export async function relocateChannelMediaAction( root, }); if (rootProblem) return { ok: false, error: rootProblem }; - const refusal = channelMediaBusyReason(slug, "moving its media"); + const refusal = channelMediaBusyReason(slug, "moving its media", { + mediaOnly: true, + }); if (refusal) return { ok: false, error: refusal }; return enqueueRelocation({ slug, direction: "out", root }); } @@ -162,6 +164,7 @@ async function resumeRelocation( const refusal = channelMediaBusyReason( slug, reconcile ? "reconciling its move" : "resuming its move", + { mediaOnly: true }, ); if (refusal) return { ok: false, error: refusal }; // A TIER MIGRATION'S MARKER (release 17) is not this panel's to resume: @@ -194,7 +197,9 @@ async function resumeRelocation( export async function moveChannelMediaBackAction( slug: string, ): Promise<StreamActionResult> { - const refusal = channelMediaBusyReason(slug, "moving its media back"); + const refusal = channelMediaBusyReason(slug, "moving its media back", { + mediaOnly: true, + }); if (refusal) return { ok: false, error: refusal }; return enqueueRelocation({ slug, direction: "back" }); } @@ -220,6 +225,7 @@ export async function clearRelocationMarkerAction( const refusal = channelMediaBusyReason( slug, "clearing its relocation marker", + { mediaOnly: true }, ); if (refusal) return { ok: false, error: refusal }; try { diff --git a/editor/app/channels/bulkStorageActions.ts b/editor/app/channels/bulkStorageActions.ts @@ -99,7 +99,7 @@ export async function bulkRelocateChannelMediaAction( } // Jobs AND in-flight auto-queue units — the lanes write into `data/` // and make no job record. See lib/mediaBusy.ts. - const busy = channelMediaBusyReason(slug); + const busy = channelMediaBusyReason(slug, undefined, { mediaOnly: true }); if (busy) return busy; const media = await inspectChannelMedia(paths, slug, config); // NOTHING TO MOVE IS A SKIP, NOT A JOB. A channel that has downloaded diff --git a/editor/app/channels/components/ChannelVolumeBar.tsx b/editor/app/channels/components/ChannelVolumeBar.tsx @@ -28,7 +28,7 @@ import { LOCATION_FILTER_PARAM } from "yt-dlp-transcript-common/views/storage"; // thing here that stays local. export type ChannelVolume = { - // A location id, "internal" for the corpus volume, or "" for a dataDir under + // A location id, "internal" for the corpus volume, or "" for a mediaDir under // a root nobody named. id: string; label: string; diff --git a/editor/app/channels/lib/mediaBusy.ts b/editor/app/channels/lib/mediaBusy.ts @@ -27,6 +27,7 @@ import { channelWriters, describeChannelWriter, } from "yt-dlp-transcript-common/controller/channelWriters"; +import { channelMediaWriters } from "yt-dlp-transcript-common/controller/relocateChannelMedia"; // 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 @@ -39,11 +40,19 @@ import { // 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. +// +// `mediaOnly` (release 17): a MOVE of the media tier is held only by the +// channel's media writers — a digest may run during it (`channelMediaWriters`, +// the same list the move's preview and its first step ask). A rename or a +// delete moves or removes the whole channel and asks every writer. export function channelMediaBusyReason( slug: string, what?: string, + opts: { mediaOnly?: boolean } = {}, ): string | null { - const writers = channelWriters(slug, { includeQueued: true }); + const writers = opts.mediaOnly + ? channelMediaWriters(slug, { includeQueued: true }) + : channelWriters(slug, { includeQueued: true }); if (writers.length === 0) return null; const jobs = writers.filter((w) => w.source === "job").length; const units = writers.length - jobs; diff --git a/editor/app/storage/components/StorageLocationsTable.tsx b/editor/app/storage/components/StorageLocationsTable.tsx @@ -386,8 +386,8 @@ function LocationCard({ <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.{" "} + root. Re-pointing rewrites each channel&rsquo;s <code>media</code>{" "} + symlink and its <code>mediaDir</code>, then the root.{" "} <strong>No bytes move</strong> — the media is already there. </p> <StreamActionLog diff --git a/editor/app/storage/page.tsx b/editor/app/storage/page.tsx @@ -23,13 +23,14 @@ export default async function StoragePage() { </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 storage location is a named place a channel&rsquo;s media — its big + files, the audio and the raw live chat — may live, usually a second + drive; a channel&rsquo;s text never leaves the corpus volume. A channel + is on a location when its <code>mediaDir</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>mediaDir</code> and moves no bytes. Move media onto a location from a channel&rsquo;s <Link href="/channels" className="underline">Storage panel</Link>. </p>