commit bb156c29beaa3bd7e0c048dc82447bd6d047e788
parent 4458168e0e07afa34db8fa9e4bd4d722e6978cc2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 21:10:17 -0400
common, editor: the export build tiers nothing; a shard save sweeps nothing; a video delete is refused while its media is unreachable (review L4, L6, N9, N10)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
5 files changed, 45 insertions(+), 7 deletions(-)
diff --git a/common/controller/archiveLiveChat.ts b/common/controller/archiveLiveChat.ts
@@ -161,6 +161,8 @@ async function stageChannel(
videoDir,
channelSlug: ch.slug,
configName: ch.config.name,
+ // A build moves no media (release 17): the jobs tier, this reads.
+ tier: false,
});
} catch (err) {
normalizeFailed++;
diff --git a/common/controller/channelSnapshot.ts b/common/controller/channelSnapshot.ts
@@ -366,9 +366,12 @@ export type ChannelSnapshot = {
// snapshot is still written — its text is readable — and the bytes are
// unknown, never 0.
totalMediaBytes?: number;
- // THE TEXT TIER's bytes: everything else under `data/<id>/` — transcripts,
- // cues, metadata, sidecars, thumbnails, a persisted source container not yet
- // moved to the store, scratch — but not `clips/`. On the corpus disk always.
+ // EVERYTHING ELSE UNDER `data/<id>/` BUT `clips/` — the bytes this channel
+ // holds on the corpus disk outside the tier and the clip cache: the text
+ // (transcripts, cues, metadata, sidecars, thumbnails) AND whatever is not
+ // tierable — a persisted source container not yet moved to the store, a
+ // partial download, scratch. Named for its bulk; on a channel with a few
+ // containers or partials it is more than the text alone.
// Absent on a snapshot written before release 17: "unknown", never 0.
totalTextBytes?: number;
// The bytes held by `data/<id>/clips/` — the clip windows umtool and the
diff --git a/common/controller/normalizeLiveChat.ts b/common/controller/normalizeLiveChat.ts
@@ -27,6 +27,9 @@ export type NormalizeLiveChatOptions = {
configName?: string;
log?: (msg: string) => void;
force?: boolean;
+ // Tier the raw replay after writing the cues (default true). The export
+ // build's archive pass passes false: a build reads, it does not move media.
+ tier?: boolean;
};
export type NormalizeLiveChatOutcome =
@@ -119,7 +122,7 @@ export async function normalizeLiveChat(
// THE MEDIA TIER'S HOOK (release 17): the raw replay is read once, here, and
// every other reader uses the cues just written — so it moves into
// channels/<slug>/media now (a relative link stays). Never throws.
- await tierMediaFile(opts.videoDir, LIVE_CHAT_FILENAME, {
+ if (opts.tier !== false) await tierMediaFile(opts.videoDir, LIVE_CHAT_FILENAME, {
onLog: opts.log ? (line) => opts.log?.(line.trimEnd()) : undefined,
});
return { status: "wrote", cuesPath };
diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts
@@ -183,7 +183,10 @@ export async function runYtdlp(opts: RunYtdlpOpts): Promise<void> {
if (!opts.channelConfig.url) {
throw new Error("Channel has no `url` configured");
}
- if (!MEDIA_WRITING_MODES.has(opts.mode)) return runYtdlpMode(opts);
+ // A shard save computes a slice and writes it; it downloads nothing.
+ if (!MEDIA_WRITING_MODES.has(opts.mode) || opts.saveShardOnly) {
+ return runYtdlpMode(opts);
+ }
// THE MEDIA TIER'S HOOK FOR A BATCH (release 17): after the child returns —
// done, failed or cancelled, whatever it finished — every video dir this run
// touched has its media moved into channels/<slug>/media when the channel
diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts
@@ -36,7 +36,11 @@ import {
type KeepVideosField,
type KeepVideosResult,
} from "yt-dlp-transcript-common/controller/keepVideosMatching";
-import { ChannelMediaUnreachableError } from "yt-dlp-transcript-common/lib/channelMedia";
+import {
+ ChannelMediaUnreachableError,
+ inspectChannelMedia,
+} from "yt-dlp-transcript-common/lib/channelMedia";
+import { onDrive } from "yt-dlp-transcript-common/lib/storageHealth";
import { setExcludedFromTruncatedCheck } from "yt-dlp-transcript-common/lib/excludeTruncatedCheck-server";
import { pruneFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions";
import { transcodeAudio } from "yt-dlp-transcript-common/controller/transcode";
@@ -573,7 +577,30 @@ export async function deleteOneVideoDir(
}
// The video's media tier first (release 17): every tiered link's bytes and
// its `media/<id>/`, which the recursive rm of the text dir cannot reach.
- await removeVideoDirMedia(resolved);
+ // Only while the channel's media is reachable, and through the watchdog: on
+ // an unmounted drive the bytes would be orphaned, on a stalled one the rm
+ // would block. Refused before anything is touched.
+ const config = await readChannelConfig(getPaths(), slug);
+ const media = await inspectChannelMedia(getPaths(), slug, config, { fresh: true });
+ if (media.status !== "ok" && media.status !== "in-place") {
+ return {
+ ok: false,
+ error:
+ `Refusing to delete ${videoId}: its media is not reachable — ` +
+ `${media.detail ?? media.status}. Nothing has been touched.`,
+ };
+ }
+ const drive = config?.mediaDir?.trim();
+ try {
+ await (drive
+ ? onDrive(drive, () => removeVideoDirMedia(resolved))
+ : removeVideoDirMedia(resolved));
+ } catch (err) {
+ return {
+ ok: false,
+ error: `Refusing to delete ${videoId}: ${(err as Error).message}. The text was not touched.`,
+ };
+ }
await rm(resolved, { recursive: true, force: true });
return { ok: true };
}