Archilyzer · Source

archilyzer

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

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:
Mcommon/controller/buildStats.test.ts | 35+++++++++++++++++++++++++++++++----
Mcommon/controller/buildStats.ts | 42+++++++++++++++++++++++++++++++++---------
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;