commit 48343182098bc1eb3570f35680e698e5dc9060dc
parent f9fd2272b3840c5a0e7e7a4771c980ae09bf625d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 29 Sep 2026 20:51:35 -0400
common: the hold's words move to lib/channelMediaHold.ts — which statuses hold, why (no path), and the ways out a refusal names — so the index build can share them; the stats build's messages are unchanged
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 70 insertions(+), 25 deletions(-)
diff --git a/common/controller/buildStats.ts b/common/controller/buildStats.ts
@@ -63,12 +63,14 @@ import {
import type { VideoStatus } from "../lib/stats";
import type { ChannelConfig } from "../lib/channelConfig";
import { readChannelConfigFile } from "./channels";
+import { inspectChannelMedia } from "../lib/channelMedia";
import {
- inspectChannelMedia,
- type ChannelMediaStatus,
-} from "../lib/channelMedia";
+ HELD_WAYS_OUT,
+ describeHeld,
+ heldReason,
+ isMediaHeld,
+} from "../lib/channelMediaHold";
import { getSettings } from "../lib/settings";
-import { locationLabelOfDataDir } from "../lib/storageLocations";
import type { Paths } from "../lib/paths";
import { listSites, siteStatsDir } from "../lib/site";
import {
@@ -248,16 +250,6 @@ async function resolveAcquisitionDates(
return { downloadedDate, transcribedDate };
}
-// Why a channel is held, without the paths inspectChannelMedia's `detail`
-// carries (/storage shows those).
-const HELD_REASON: Record<ChannelMediaStatus, string> = {
- unreachable: "its media is not reachable (drive not mounted?)",
- "in-transition": "a move of its media is in progress or was interrupted",
- inconsistent: "its data link and its config disagree",
- ok: "reachable",
- "in-place": "reachable",
-};
-
// `held` maps each channel whose media is not reachable to why, in words with
// no path in them: it is not scanned, and the caller keeps its cached stats
// (see the file header).
@@ -292,12 +284,8 @@ async function scanSource(
// An unmounted drive is not an empty channel (lib/channelMedia.ts): the
// readdir below would fail and every one of its stats would be removed.
const media = await inspectChannelMedia({ channelsDir }, ch.name, cfg);
- if (media.status !== "ok" && media.status !== "in-place") {
- const label = locationLabelOfDataDir(cfg.dataDir ?? media.target, locations);
- held.set(
- ch.name,
- `${HELD_REASON[media.status]}${label ? `, on location "${label}"` : ""}`,
- );
+ if (isMediaHeld(media.status)) {
+ held.set(ch.name, heldReason(media, cfg.dataDir, locations));
continue;
}
const dataDir = path.join(channelDir, "data");
@@ -444,11 +432,7 @@ export async function buildStats({
await root.close();
throw new Error(
`The stats cache must be rebuilt (stats schema ${storedSchema ?? "<none>"} -> ${STATS_SCHEMA_VERSION}), ` +
- `but ${held.size} channel(s) cannot be read: ` +
- [...held].map(([slug, why]) => `${slug} (${why})`).join("; ") +
- `. For each: mount its media and run this again; or repair or re-point its location on /storage; ` +
- `or finish or clear its move (the channel's Storage panel); or, if it is gone for good, ` +
- `delete the channel or set excludeFromBuild in its config.`,
+ `but ${held.size} channel(s) cannot be read: ${describeHeld(held)}. ${HELD_WAYS_OUT}`,
);
}
log(
diff --git a/common/lib/channelMediaHold.ts b/common/lib/channelMediaHold.ts
@@ -0,0 +1,61 @@
+import type {
+ ChannelMediaLocation,
+ ChannelMediaStatus,
+} from "./channelMedia";
+import {
+ locationLabelOfDataDir,
+ type StorageLocation,
+} from "./storageLocations";
+
+// THE HOLD, in the words both pool-wide builds use.
+//
+// The index build (controller/buildIndex.ts) and the stats build
+// (controller/buildStats.ts) each walk every channel's `data/`. A channel whose
+// media cannot be read — a relocated `data/` on an unmounted drive, a move in
+// progress, a link and a config that disagree — is HELD by both: not rescanned,
+// and what the last build knew of it kept, rather than read as a channel with
+// no videos and removed (lib/channelMedia.ts says why that reading is the
+// dangerous one). This module is only the shared vocabulary: which statuses
+// hold, why, in words with no path in them (/storage shows the paths), and the
+// ways out a refusal names. Each build decides for itself what "kept" means.
+//
+// Pure: no I/O. The caller asks inspectChannelMedia and passes the answer in.
+
+// "ok" and "in-place" are read; every other status holds, including any a later
+// inspectChannelMedia adds.
+export function isMediaHeld(status: ChannelMediaStatus): boolean {
+ return status !== "ok" && status !== "in-place";
+}
+
+// Why a channel is held, without the paths inspectChannelMedia's `detail`
+// carries.
+export const HELD_REASON: Record<ChannelMediaStatus, string> = {
+ unreachable: "its media is not reachable (drive not mounted?)",
+ "in-transition": "a move of its media is in progress or was interrupted",
+ inconsistent: "its data link and its config disagree",
+ ok: "reachable",
+ "in-place": "reachable",
+};
+
+// The reason, and the storage location's label when the channel's media is on
+// one. `dataDir` is the channel config's; the inspector's target stands in when
+// the config names none (a move in flight).
+export function heldReason(
+ media: Pick<ChannelMediaLocation, "status" | "target">,
+ dataDir: string | undefined,
+ locations: StorageLocation[],
+): string {
+ const label = locationLabelOfDataDir(dataDir ?? media.target, locations);
+ return `${HELD_REASON[media.status]}${label ? `, on location "${label}"` : ""}`;
+}
+
+// A held channel named in a refusal, as `slug (why)`, joined.
+export function describeHeld(held: Map<string, string>): string {
+ return [...held].map(([slug, why]) => `${slug} (${why})`).join("; ");
+}
+
+// What a refusal tells the operator to do, mounting first.
+export const HELD_WAYS_OUT =
+ `For each: mount its media and run this again; or repair or re-point its location on /storage; ` +
+ `or finish or clear its move (the channel's Storage panel); or, if it is gone for good, ` +
+ `delete the channel or set excludeFromBuild in its config.`;