Archilyzer · Source

archilyzer

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

commit 2e81d8b218c4c7f0403a39e1b3e516ba93271279
parent c661c087f9a5b5ee832c873653fad74952ea9fc2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 11:27:49 -0400

editor: a relocation is a job on the channel's own queue

The job kind is one entry, and `needsMedia: false` is written out rather than
omitted because this is the entry where the explicit false is load-bearing: the
relocate job is what FIXES an unreachable channel, so guard 1 refusing it would
block the only action that can clear the condition it refuses for. Not drainable
(the work is one rsync child; stopping it is a cancel, which the signal already
does and which leaves the source untouched) and not replayable (a replay carries
neither direction nor root).

storageActions.ts is the editor's half. The preview runs INLINE — it is
read-only and the operator is holding a form open for its two numbers, so a
queued job with a log would be a worse way to show them. Both directions of the
move go through runManagedFunction on channelQueueKey(slug), so a relocation
serializes with the channel's own bookkeeping jobs instead of running while one
of them writes into the dir being copied.

The active-jobs guard is the rename's, copied rather than shared, and it has a
sharper edge here: a download running against data/ WHILE its bytes are copied
out either fails the verify (safe) or loses the write (not). The queue alone
would make a second move WAIT, and waiting is the wrong answer for a move the
operator can already see in flight.

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

Diffstat:
Mcommon/jobs/jobKinds.ts | 28++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/storageActions.ts | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 167 insertions(+), 0 deletions(-)

diff --git a/common/jobs/jobKinds.ts b/common/jobs/jobKinds.ts @@ -343,6 +343,34 @@ const JOB_KINDS: Record<string, JobKindMeta> = { replayable: false, queueKeyStrategy: "custom", }, + // MOVE A CHANNEL'S MEDIA TO ANOTHER DRIVE, AND BACK + // (plans/relocate-channel-media.md). + // + // `needsMedia: false` is written out rather than omitted, and this is the one + // entry where the explicit `false` earns its line: this kind is what FIXES an + // unreachable channel. Guard 1 in runManagedFunction refuses a media kind for + // a channel whose media it cannot reach — so a kind that declared `true` here + // would be refused precisely when the operator needs it (a `back` run after + // remounting, or a retry of an interrupted move), and the only action that can + // clear the condition would be the one the condition blocks. + // + // Not drainable: the work is one rsync child, and stopping it is a cancel — + // which the AbortSignal already does, leaving the source untouched and the + // partial copy resumable. There is no "stop starting new sub-operations" to + // honor. Not replayable: a replay carries no direction and no root, and + // re-running a move against a channel that has since moved is not a retry. + // + // Queue key is channelQueueKey(slug) (set by the action), so a relocation + // serializes with the channel's own bookkeeping jobs instead of running while + // one of them writes into the dir being copied. + "relocate-channel-media": { + kind: "relocate-channel-media", + label: "Relocate channel media", + drainable: false, + replayable: false, + queueKeyStrategy: "custom", + needsMedia: false, + }, // Social-post ingest for a `sourceKind: "social"` channel. Drainable (the // fetcher stops paging on the drain signal and keeps what it already has) and // replayable. queueKeyForUrl() routes x.com / bsky.app to diff --git a/editor/app/channels/[slug]/storageActions.ts b/editor/app/channels/[slug]/storageActions.ts @@ -0,0 +1,139 @@ +"use server"; + +// WHERE A CHANNEL'S MEDIA LIVES — the three 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 +// read-only and the operator is waiting on its numbers before committing), and +// the two directions of the move, each wrapped in runManagedFunction so it gets +// a job record, a streamed log, a queue slot and a cancel button like every +// other long action in the editor. +// +// THE ACTIVE-JOBS GUARD IS THE RENAME'S, deliberately copied rather than shared: +// renameChannelAction refuses while the channel has running or queued jobs +// because the in-memory registry keys by slug and those jobs would be orphaned +// by the move. A relocation has the same hazard with a sharper edge — a download +// or a transcribe running against `data/` WHILE its bytes are being copied out +// would write into the directory the swap is about to replace, and the verify +// would then fail (which is the safe outcome) or the write would be lost (which +// is not). The check is cheap and refuses early, before any bytes move. +// +// The relocate job's own queue key is channelQueueKey(slug), so a second one is +// 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 { revalidatePath } from "next/cache"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { channelQueueKey } from "yt-dlp-transcript-common/lib/queueKeys"; +import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; +import { + runManagedFunction, + type StreamActionResult, +} from "yt-dlp-transcript-common/jobs/streamCommand"; +import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotScheduler"; +import { formatBytes } from "yt-dlp-transcript-common/lib/format"; +import { + previewRelocation, + relocateChannelMedia, + type RelocationPreview, +} from "yt-dlp-transcript-common/controller/relocateChannelMedia"; + +export type PreviewRelocationResult = + | { 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 +// question the operator is holding a form open for; a queued job with a log +// would be a worse way to show two numbers. +export async function previewRelocationAction( + slug: string, + root: string, +): Promise<PreviewRelocationResult> { + const trimmed = root.trim(); + if (!trimmed) return { ok: false, error: "Enter a destination root." }; + try { + const preview = await previewRelocation({ + paths: getPaths(), + slug, + root: trimmed, + }); + return { ok: true, preview }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } +} + +// The rename's guard (editor/app/channels/actions.ts), returning the shape +// StreamActionLog already renders rather than an ActionResult. +function activeJobsRefusal(slug: string, what: string): string | null { + const active = getRegistry() + .list() + .filter( + (j) => + j.channelSlug === slug && + (j.status === "running" || j.status === "queued"), + ); + if (active.length === 0) return null; + return ( + `Finish or cancel ${active.length} running/queued job(s) for this channel ` + + `before ${what} its media.` + ); +} + +export async function relocateChannelMediaAction( + slug: string, + root: string, +): Promise<StreamActionResult> { + const trimmed = root.trim(); + if (!trimmed) return { ok: false, error: "Enter a destination root." }; + const refusal = activeJobsRefusal(slug, "moving"); + if (refusal) return { ok: false, error: refusal }; + return runMove(slug, "out", trimmed); +} + +export async function moveChannelMediaBackAction( + slug: string, +): Promise<StreamActionResult> { + const refusal = activeJobsRefusal(slug, "moving back"); + if (refusal) return { ok: false, error: refusal }; + return runMove(slug, "back"); +} + +async function runMove( + slug: string, + direction: "out" | "back", + root?: string, +): Promise<StreamActionResult> { + const paths = getPaths(); + return runManagedFunction({ + kind: "relocate-channel-media", + queueKey: channelQueueKey(slug), + paths, + channelSlug: slug, + fn: async (onLog, signal) => { + const result = await relocateChannelMedia({ + paths, + slug, + direction, + root, + onLog, + signal, + }); + onLog( + `${direction === "out" ? "Moved" : "Moved back"} ${result.files} file(s) / ` + + `${formatBytes(result.bytes)} — ${result.target}` + + (result.resumed ? " (resumed an interrupted move)" : ""), + ); + // The snapshot records what is in `data/`, and a move does not change + // that — but the badge, the free-space line and the media location on + // every page that draws them are read per render, so the pages that show + // them have to be told. The snapshot request is for the report's own + // freshness clock, not for a count this changed. + requestChannelSnapshot(paths, slug); + revalidatePath(`/channels/${slug}`); + revalidatePath("/channels"); + revalidatePath("/"); + }, + }); +}