commit d3b83b0c04098d7c1702f4a33c0c766c883c6606
parent b365aef422eeced8dc3947f80ef087691877b135
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 19:09:17 -0400
common: buildStats' refusal names every way out, and "not indexable" names an unreachable drive
Re-review R3, R4 and a nit:
- The "not indexable" line now also says the channel's media may have been
unreachable during the last index build (run one with every drive mounted),
instead of implying only a missing upload_date or a failure.
- A held channel is described without paths (its media unreachable, a move in
progress, or its link and config disagreeing), with its storage location's
label when it has one. The schema-clear refusal lists the ways out for each,
mounting first: mount its media; repair or re-point its location on
/storage; finish or clear its move; or, if it is gone for good, delete the
channel or set excludeFromBuild.
- Test (z) asserts outright that the LMDB file is under the temp root.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 64 insertions(+), 13 deletions(-)
diff --git a/common/controller/buildStats.test.ts b/common/controller/buildStats.test.ts
@@ -439,7 +439,14 @@ test("(h) a video the index skipped is not announced as pending on every run", a
assert.equal(res.notIndexedYet, 1, `run ${run}`);
assert.equal(res.notIndexable, 1, `run ${run}`);
assert.ok(log.some((l) => l.startsWith("1 video(s) were downloaded after the last index build")), log.join("\n"));
- assert.ok(log.some((l) => l.startsWith("1 video(s) are not in the index although its last build saw them")), log.join("\n"));
+ assert.ok(
+ log.some(
+ (l) =>
+ l.startsWith("1 video(s) are not in the index although they are older than its last build") &&
+ l.includes("or its channel's media was unreachable during that build"),
+ ),
+ log.join("\n"),
+ );
}
await runIndex();
const { res } = await runStats();
@@ -475,8 +482,14 @@ test("(i) an unmounted media drive keeps its channel's stats; a cache clear refu
const mounted = await runStats();
assert.equal(statOf(mounted.byId, "d1").hasTranscript, true);
- // Unmount: the link now dangles, exactly as an absent USB drive leaves it.
+ // The drive is a storage location, as /storage records it; the refusal names
+ // it by its label, never by a path.
const media = path.join(ROOT, "media");
+ writeFileSync(
+ paths.settingsFile,
+ JSON.stringify({ storage: { locations: [{ id: "usb", label: "USB drive", root: media, autoRepoint: false }] } }),
+ );
+ // Unmount: the link now dangles, exactly as an absent USB drive leaves it.
renameSync(media, `${media}-away`);
const log: string[] = [];
const away = await runStats(log);
@@ -485,14 +498,26 @@ test("(i) an unmounted media drive keeps its channel's stats; a cache clear refu
assert.deepEqual(away.res.heldChannels, [DRIVE_CHANNEL]);
assert.equal(statOf(away.byId, "d1").hasTranscript, true);
assert.ok(
- log.some((l) => l.startsWith(`Channel ${DRIVE_CHANNEL}: media not reachable`) && l.includes("its 2 cached stat(s) are kept")),
+ log.some((l) => l.startsWith(`Channel ${DRIVE_CHANNEL}: its media is not reachable`) && l.includes("its 2 cached stat(s) are kept")),
log.join("\n"),
);
// A schema change needs the whole cache rebuilt, which cannot include a
// channel it cannot read: refuse, and leave the cache as it is.
setStoredSchema(STATS_SCHEMA_VERSION - 1);
- await assert.rejects(runStats(), /must be rebuilt .* cannot be read: drive-channel/);
+ await assert.rejects(runStats(), (err: Error) => {
+ assert.match(
+ err.message,
+ /must be rebuilt .* cannot be read: drive-channel \(its media is not reachable \(drive not mounted\?\), on location "USB drive"\)/,
+ );
+ // The ways out, mounting first, and no path in the message.
+ assert.match(
+ err.message,
+ /For each: mount its media and run this again; or repair or re-point its location on \/storage; or finish or clear its move .*; or, if it is gone for good, delete the channel or set excludeFromBuild/,
+ );
+ assert.ok(!err.message.includes(ROOT), err.message);
+ return true;
+ });
assert.equal(readStoredSchema(), STATS_SCHEMA_VERSION - 1);
assert.equal(countStats(), 3);
@@ -570,6 +595,8 @@ test("(j) the schema guard: an older cache is cleared, a newer one is refused un
});
test("(z) no write this file caused landed outside its temp root", () => {
+ // LMDB writes natively, past the spy: its file must be under the root too.
+ assert.ok(paths.lmdbPath.startsWith(ROOT + path.sep), paths.lmdbPath);
const outside = fsCalls.filter(
(c) => c.write && c.p !== ROOT && !c.p.startsWith(ROOT + path.sep),
);
diff --git a/common/controller/buildStats.ts b/common/controller/buildStats.ts
@@ -63,7 +63,12 @@ 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";
+import { getSettings } from "../lib/settings";
+import { locationLabelOfDataDir } from "../lib/storageLocations";
import type { Paths } from "../lib/paths";
import { listSites, siteStatsDir } from "../lib/site";
import {
@@ -148,8 +153,9 @@ export type BuildStatsResult = {
// Videos on disk with no index record, in two kinds. `notIndexedYet`: their
// metadata is newer than the last completed index build's scan — downloaded
// since — and the first stats run after the next index build redoes them.
- // `notIndexable`: the last index build saw them and did not index them (no
- // upload_date, or it failed on them); nothing here will change that.
+ // `notIndexable`: older than the last index build, which did not index them
+ // (no upload_date, a failure, or their channel's media unreachable during
+ // that build); a stats run alone will not change that.
notIndexedYet: number;
notIndexable: number;
// Channels whose media was not reachable, so their cached stats were kept.
@@ -242,8 +248,19 @@ async function resolveAcquisitionDates(
return { downloadedDate, transcribedDate };
}
-// `held` maps each channel whose media is not reachable to the reason: it is
-// not scanned, and the caller keeps its cached stats (see the file header).
+// 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).
async function scanSource(
channelsDir: string,
log: (msg: string) => void,
@@ -252,6 +269,7 @@ async function scanSource(
channels: Map<string, ChannelConfig>;
held: Map<string, string>;
}> {
+ const locations = getSettings().storage.locations;
const channels = new Map<string, ChannelConfig>();
const entries: ScanEntry[] = [];
const held = new Map<string, string>();
@@ -275,7 +293,11 @@ async function scanSource(
// 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") {
- held.set(ch.name, media.detail ?? media.status);
+ const label = locationLabelOfDataDir(cfg.dataDir ?? media.target, locations);
+ held.set(
+ ch.name,
+ `${HELD_REASON[media.status]}${label ? `, on location "${label}"` : ""}`,
+ );
continue;
}
const dataDir = path.join(channelDir, "data");
@@ -424,7 +446,9 @@ export async function buildStats({
`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("; ") +
- `. Mount their media (see /storage) and run it again.`,
+ `. 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.`,
);
}
log(
@@ -475,7 +499,7 @@ export async function buildStats({
}
if (notIndexable > 0) {
log(
- `${notIndexable} video(s) are not in the index although its last build saw them (no upload_date, or it failed on them: see that build's log). Their stats show no transcript until that is fixed.`,
+ `${notIndexable} video(s) are not in the index although they are older than its last build: it skipped them (no upload_date, or it failed on them: see that build's log), or its channel's media was unreachable during that build (run an index build with every drive mounted). Their stats show no transcript until then.`,
);
}
const removedKeys: PathKey[] = [];
@@ -490,7 +514,7 @@ export async function buildStats({
}
for (const [slug, why] of held) {
log(
- `Channel ${slug}: media not reachable (${why}); its ${keptHeld.get(slug) ?? 0} cached stat(s) are kept as they are, not rescanned.`,
+ `Channel ${slug}: ${why}; its ${keptHeld.get(slug) ?? 0} cached stat(s) are kept as they are, not rescanned.`,
);
}
const removed = removedKeys.length;