Archilyzer · Source

archilyzer

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

commit fe16bc67fdcf6b346f57a7ccba513848d0753ec2
parent 0f375893eda5ae97fd457cd616b0bd2a220f3621
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 29 Sep 2026 22:01:48 -0400

editor: a drive that is not answering is said so, and its pages read nothing from it

- /storage: a stalled location's row reads "Not answering" (the probe status)
  with a line "not answering since HH:MM — <what did not answer>"
  (aria-label "location not answering"). Refresh asks the location's health
  first (one child `stat`, counted like any other answer) and forgets the
  channels' remembered media answers; on a stalled drive it says so instead of
  probing.
- /channels: the volume chip of a stalled location reads "not answering since
  HH:MM" in place of its free space; the rows' badges read "on <label> — not
  answering" (MediaLocationBadge, previous commit).
- The channel's Storage panel names the destination "Not answering" (the probe
  label) and refuses Move back for a stalled channel with the inspector's
  sentence, as it does for an unreachable one.
- The videos list and the video page (and its title) do not read a stalled
  drive: they show a notice (aria-label "media not answering") with links to
  the channel and to /storage. The media file route answers 503 with
  Retry-After.
- The e2e invalidate-cache route also forgets the media memo and the health
  state.

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

Diffstat:
Mcommon/controller/storageWatch.test.ts | 19+++++++++++++++++++
Mcommon/controller/storageWatch.ts | 15+++++++++++++++
Mcommon/views/storage.test.ts | 33+++++++++++++++++++++++++++++++++
Mcommon/views/storage.ts | 10++++++++++
Meditor/app/api/channels/[slug]/videos/[id]/files/[name]/route.ts | 12++++++++++++
Meditor/app/api/test/invalidate-cache/route.ts | 8++++++++
Aeditor/app/channels/[slug]/components/MediaNotAnswering.tsx | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/components/stages/StorageStage.tsx | 3++-
Meditor/app/channels/[slug]/videos/[id]/page.tsx | 12++++++++++++
Meditor/app/channels/[slug]/videos/page.tsx | 9+++++++++
Meditor/app/channels/components/ChannelVolumeBar.tsx | 11+++++++++--
Meditor/app/channels/page.tsx | 14++++++++++++++
Meditor/app/storage/actions.ts | 22++++++++++++++++++++++
Meditor/app/storage/buildStorage.ts | 19++++++++++++++++++-
Meditor/app/storage/components/StorageLocationsTable.tsx | 9+++++++++
15 files changed, 246 insertions(+), 4 deletions(-)

diff --git a/common/controller/storageWatch.test.ts b/common/controller/storageWatch.test.ts @@ -11,6 +11,7 @@ import { } from "../lib/channelPriority"; import { LANES } from "../lib/autoQueueTypes"; import { + refreshLocationHealth, resetStorageWatchSuspicion, runStorageHealthPass, runStorageWatchPass, @@ -498,3 +499,21 @@ test("arming the watch runs a health pass at once, and stopping it stops both ti stopStorageWatch(); }); }); + +test("a Refresh asks one location now: it counts as one answer, and prunes nothing", async () => { + await withTmp(async (h) => { + const other = { id: "other", label: "Other", root: "/elsewhere", autoRepoint: false }; + await runStorageHealthPass({ + locations: [h.io.read().storage.locations[0], other], + probe: scripted(["stalled"]), + }); + const cold = h.io.read().storage.locations[0]; + assert.equal(await refreshLocationHealth(cold, async () => "ok"), "ok"); + // One clean answer is not two. + assert.equal(locationHealth("cold")?.state, "stalled"); + assert.equal(locationHealth("other")?.state, "stalled"); + await refreshLocationHealth(cold, async () => "ok"); + assert.equal(locationHealth("cold")?.state, "ok"); + assert.equal(locationHealth("other")?.state, "stalled"); + }); +}); diff --git a/common/controller/storageWatch.ts b/common/controller/storageWatch.ts @@ -419,6 +419,21 @@ export async function runStorageHealthPass( return out; } +// ONE LOCATION, NOW: what /storage's Refresh asks before its own probe, so the +// operator pressing it after doing something about the drive gets an answer +// taken afterwards. It counts as one answer like any other — a stalled location +// still needs two clean ones in a row. Nothing is pruned. +export async function refreshLocationHealth( + loc: StorageLocation, + probe: LocationHealthProbe = (l) => probeLocationHealth(l), +): Promise<LocationHealthState> { + const answer = await probe(loc).catch((): LocationHealthState => "ok"); + recordLocationHealth(loc, answer, { + cause: `a stat of its root did not answer within ${HEALTH_PROBE_TIMEOUT_MS / 1000} s`, + }); + return answer; +} + // --------------------------------------------------------------------------- // The cadence // --------------------------------------------------------------------------- diff --git a/common/views/storage.test.ts b/common/views/storage.test.ts @@ -399,3 +399,36 @@ test("a location row carries the clips share of its bytes", () => { // A SUBSET, not a sibling: the clips are already inside `bytes`. assert.equal(row?.bytes, 10_000_000_000); }); + +test("a location whose drive is not answering reads so, and says since when", () => { + const { rows } = buildStorageRows({ + locations: [loc("cold", "/mnt/cold")], + defaultLocationId: "", + probes: { + cold: { status: "stalled", identity: { known: false }, probedAt: NOW }, + }, + notAnswering: { cold: "not answering since 11:35 — a stat of its root did not answer within 3 s." }, + rollups: { cold: rollup({ locationId: "cold", total: 2, unreachable: 2 }) }, + registry: NO_JOBS, + now: NOW, + }); + const row = rows[0]; + assert.equal(row.status, "stalled"); + assert.equal(row.statusLabel, "Not answering"); + assert.match(String(row.notAnswering), /^not answering since 11:35/); + assert.equal(row.freeBytes, undefined); + // Neither re-point nor mount is offered for a drive that is here and stalled. + assert.equal(action(row, "repoint").offered, false); + assert.match(String(action(row, "repoint").withheld), /not answering/); + assert.equal(action(row, "mount").offered, false); + // A row with no entry says nothing. + const quiet = buildStorageRows({ + locations: [loc("cold", "/mnt/cold")], + defaultLocationId: "", + probes: {}, + rollups: {}, + registry: NO_JOBS, + now: NOW, + }).rows[0]; + assert.equal(quiet.notAnswering, undefined); +}); diff --git a/common/views/storage.ts b/common/views/storage.ts @@ -103,6 +103,9 @@ export type StorageRow = { freeBytes?: number; // How long ago the probe behind this row was taken, in ms. lastProbeAgeMs: number; + // "not answering since 11:35 — …", while the health probe finds this + // location's drive not answering (lib/storageHealth.ts). Absent otherwise. + notAnswering?: string; warning?: string; candidateRoot?: string; // Why every action on this row is withheld, or null. One re-point runs at a @@ -201,6 +204,10 @@ export type StorageRowsInputs = { // omitted, because a row that vanishes when a probe fails is worse than one // that says it does not know. probes: Record<string, MemoizedProbe | undefined>; + // By location id: the sentence for a location whose drive is not answering. + // Worded by the shell (lib/storageHealth.ts's `notAnsweringText`), because + // this module takes types only from lib/. + notAnswering?: Record<string, string | undefined>; rollups: Record<string, LocationRollup | undefined>; registry: RegistryReader; udisksctlAvailable?: boolean; @@ -387,6 +394,9 @@ export function buildStorageRows(i: StorageRowsInputs): StorageRowsPayload { clipsText: storageClipsText(clipsBytes), ...(probe?.freeBytes !== undefined ? { freeBytes: probe.freeBytes } : {}), lastProbeAgeMs: probe ? Math.max(0, i.now - probe.probedAt) : 0, + ...(i.notAnswering?.[loc.id] + ? { notAnswering: i.notAnswering[loc.id] } + : {}), ...(probe?.warning ? { warning: probe.warning } : {}), ...(candidateRoot ? { candidateRoot } : {}), busy, diff --git a/editor/app/api/channels/[slug]/videos/[id]/files/[name]/route.ts b/editor/app/api/channels/[slug]/videos/[id]/files/[name]/route.ts @@ -4,6 +4,9 @@ import { stat } from "node:fs/promises"; import { NextResponse } from "next/server"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { makeSafeController } from "yt-dlp-transcript-common/lib/safeStreamController"; +import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; +import { channelMediaStall } from "yt-dlp-transcript-common/lib/channelMedia"; +import { NOT_ANSWERING } from "yt-dlp-transcript-common/lib/storageHealth"; export const dynamic = "force-dynamic"; @@ -123,6 +126,15 @@ export async function GET( return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } + // A drive that is not answering is not asked: the stat and the stream would + // each wait on it. 503, because it is a state that passes. + if (channelMediaStall(await readChannelConfig(paths, slug))) { + return NextResponse.json( + { error: `Media not read: ${NOT_ANSWERING}.` }, + { status: 503, headers: { "retry-after": "15" } }, + ); + } + let stats; try { stats = await stat(fullPath); diff --git a/editor/app/api/test/invalidate-cache/route.ts b/editor/app/api/test/invalidate-cache/route.ts @@ -4,6 +4,8 @@ import { resetSnapshotScheduler } from "yt-dlp-transcript-common/jobs/snapshotSc import { resetChannelSnapshotMemo } from "yt-dlp-transcript-common/controller/channels"; import { resetStorageProbeMemo } from "yt-dlp-transcript-common/controller/storageLocations"; import { resetVideoTitleMemo } from "yt-dlp-transcript-common/controller/videoTitles"; +import { forgetChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia"; +import { resetStorageHealth } from "yt-dlp-transcript-common/lib/storageHealth"; import { testRouteDenied } from "../_guard"; export const dynamic = "force-dynamic"; @@ -112,6 +114,12 @@ function invalidate() { // testInfo.outputPath varies by accident rather than on purpose; cleared here // so it is on purpose. resetStorageProbeMemo(); + // And the two memories a stalled drive lives in: inspectChannelMedia's + // five-second answers (keyed by channels dir, slug and the configured target, + // which a reset fixture reproduces exactly) and each location's health, which + // a previous spec's location id would otherwise carry into this one. + forgetChannelMedia(); + resetStorageHealth(); // And the video list's metadata.info.json title memo. It is keyed by the // channel's data/ mtime, which resetData() changes by recreating the dir — but // a spec that rewrites a title in place inside one mtime tick would otherwise diff --git a/editor/app/channels/[slug]/components/MediaNotAnswering.tsx b/editor/app/channels/[slug]/components/MediaNotAnswering.tsx @@ -0,0 +1,54 @@ +import Link from "next/link"; +import { + notAnsweringText, + type LocationHealth, +} from "yt-dlp-transcript-common/lib/storageHealth"; + +// WHAT A PAGE THAT READS A CHANNEL'S `data/` SHOWS WHILE ITS DRIVE IS NOT +// ANSWERING, instead of reading it. +// +// The videos list and the video page read the channel's media directory on +// every render — a readdir, a stat per file, a metadata head read per title. On +// a drive that is mounted and not answering (lib/storageHealth.ts) each of those +// calls waits for the drive, on one of the few threads every page and poll in +// this process shares. So the page asks the health state first, and on a +// stalled drive it says so and reads nothing. The rest of the channel (its +// overview, its report, its settings) comes off the corpus disk and still +// renders. +// +// Server-only: it takes the health entry from the page, which read it from +// memory. +export function MediaNotAnswering({ + slug, + stall, + what, +}: { + slug: string; + stall: LocationHealth; + // What would have been shown: "The video list", "This video". + what: string; +}) { + return ( + <div className="flex flex-col gap-3"> + <p + role="status" + aria-label="media not answering" + className="rounded border border-destructive/50 bg-destructive/5 px-3 py-2 text-sm text-destructive" + > + {what} reads this channel&apos;s media, which is on &ldquo;{stall.label} + &rdquo; — a drive that is {notAnsweringText(stall)}. Nothing is read + from it until it answers again (it is checked every 15 s). + </p> + <p className="text-sm text-muted-foreground"> + <Link href={`/channels/${slug}`} className="underline hover:text-foreground"> + The channel + </Link>{" "} + still shows its report, and{" "} + <Link href="/storage" className="underline hover:text-foreground"> + Storage + </Link>{" "} + shows the drive. + </p> + </div> + ); +} diff --git a/editor/app/channels/[slug]/components/stages/StorageStage.tsx b/editor/app/channels/[slug]/components/stages/StorageStage.tsx @@ -214,7 +214,8 @@ export function StorageStage({ // a reason, one click earlier. unvouched={ location.status === "inconsistent" || - location.status === "unreachable" + location.status === "unreachable" || + location.status === "stalled" ? `This channel's media location is ${location.status}: ${ location.detail ?? "disk and config do not agree" } Moving back would delete the relocated copy, so it is refused until the location reads "relocated · reachable".` diff --git a/editor/app/channels/[slug]/videos/[id]/page.tsx b/editor/app/channels/[slug]/videos/[id]/page.tsx @@ -42,6 +42,8 @@ import { TagsPanel } from "./components/TagsPanel"; import { MetadataHistoryDetails } from "./components/MetadataHistoryDetails"; import { loadVideoTags } from "./lib/videoTags"; import { loadVideoOperationPanels } from "./lib/videoOperationPanels"; +import { channelMediaStall } from "yt-dlp-transcript-common/lib/channelMedia"; +import { MediaNotAnswering } from "../../components/MediaNotAnswering"; export const dynamic = "force-dynamic"; @@ -80,6 +82,10 @@ export async function generateMetadata({ params: Promise<{ slug: string; id: string }>; }): Promise<Metadata> { const { slug, id } = await params; + // The title is read off the drive; a drive that is not answering is not asked. + if (channelMediaStall(await readChannelConfig(getPaths(), slug))) { + return { title: `${id} — Video — ${slug}` }; + } const meta = await loadMeta(slug, id); const subject = meta.title ?? id; return { title: `${subject} — Video — ${slug}` }; @@ -93,6 +99,12 @@ export default async function VideoDetailPage({ const { slug, id } = await params; const config = await readChannelConfig(getPaths(), slug); if (!config) notFound(); + // Everything below reads the video's directory on the channel's drive; a + // drive that is not answering is not read. See MediaNotAnswering. + const stall = channelMediaStall(config); + if (stall) { + return <MediaNotAnswering slug={slug} stall={stall} what="This video's page" />; + } const dirData = await loadVideoDir(slug, id); const meta = await loadMeta(slug, id); const videoDir = path.join(getPaths().channelsDir, slug, "data", id); diff --git a/editor/app/channels/[slug]/videos/page.tsx b/editor/app/channels/[slug]/videos/page.tsx @@ -36,6 +36,8 @@ import { computeVideoRows, readDataDirVideoIds } from "../lib/videoRowsServer"; import { normalizeBuckets } from "yt-dlp-transcript-common/views/pipeline/stageStatus"; import { attachCuratedTags } from "../lib/videoTagRows"; import { readChannelVideoTitles } from "yt-dlp-transcript-common/controller/videoTitles"; +import { channelMediaStall } from "yt-dlp-transcript-common/lib/channelMedia"; +import { MediaNotAnswering } from "../components/MediaNotAnswering"; export const dynamic = "force-dynamic"; @@ -100,6 +102,13 @@ export default async function ChannelVideosPage({ // A social channel has posts, not videos — there is no data directory to list // and nothing here would render. 404 rather than an empty workspace. if (isSocialChannel(config)) notFound(); + // THE LIST IS READ OFF THE DRIVE (a readdir of data/, a head read per title, + // the selected video's files), so a drive that is not answering is not read: + // the page says so instead. See MediaNotAnswering. + const stall = channelMediaStall(config); + if (stall) { + return <MediaNotAnswering slug={slug} stall={stall} what="The video list" />; + } const registry = getRegistry(); const existingQueues = registry.activeQueueNames(); diff --git a/editor/app/channels/components/ChannelVolumeBar.tsx b/editor/app/channels/components/ChannelVolumeBar.tsx @@ -42,6 +42,9 @@ export type ChannelVolume = { // statfs of the root, or undefined when the root is not there (an unmounted // drive). Never inferred from a parent — see volumeFreeBytes. freeBytes?: number; + // "not answering since 11:35", while the health probe finds the drive not + // answering (its free space is then not asked either). + notAnswering?: string; }; export function ChannelVolumeBar({ @@ -96,10 +99,14 @@ export function ChannelVolumeBar({ detail={ `${v.channels} ch · ${formatBytes(v.bytes)}` + (v.unmeasured > 0 ? ` +${v.unmeasured}?` : "") + - ` · ${v.freeBytes === undefined ? "free —" : `${formatBytes(v.freeBytes)} free`}` + (v.notAnswering + ? ` · ${v.notAnswering}` + : ` · ${v.freeBytes === undefined ? "free —" : `${formatBytes(v.freeBytes)} free`}`) } title={ - v.freeBytes === undefined + v.notAnswering + ? `${v.label}: the drive is ${v.notAnswering}. Pages and polls do not touch it until it answers twice in a row; its channels read "not answering".` + : v.freeBytes === undefined ? `${v.label}: the root is not there — an unmounted drive reports no free space rather than its parent's.` : `${v.label}: ${v.channels} channel(s) hold ${formatBytes(v.bytes)}${ v.unmeasured > 0 diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -8,6 +8,10 @@ import { import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia"; import { + notAnsweringText, + stalledLocation, +} from "yt-dlp-transcript-common/lib/storageHealth"; +import { getSite, listSiteIds, listSites, @@ -220,6 +224,13 @@ export default async function ChannelsPage({ // TWO SYSCALLS PER VOLUME, NOT A PROBE. See volumeFreeBytes: this table draws // 71 rows on every auto-refresh and the rule is that tables never shell out. const freeByVolume = await volumeFreeBytes({ paths, locations }); + // A DRIVE THAT IS NOT ANSWERING, said on its chip. From memory — the health + // probe (a child `stat` every 15 s) is what asked; the table asks nothing. + const notAnsweringByVolume: Record<string, string | undefined> = {}; + for (const loc of locations) { + const stall = stalledLocation(loc); + if (stall) notAnsweringByVolume[loc.id] = notAnsweringText(stall); + } // ONE ROW PER CHANNEL, off the shared builder (common/views/channelRow.ts): // the dashboard and the operation pages build theirs the same way. The view // carries no `config` — this table is a client component, and a channel @@ -292,6 +303,9 @@ export default async function ChannelsPage({ bytes: measured.reduce((sum, c) => sum + (c.mediaBytes ?? 0), 0), unmeasured: rows.length - measured.length, freeBytes: freeByVolume[id], + ...(notAnsweringByVolume[id] + ? { notAnswering: notAnsweringByVolume[id] } + : {}), }; }) // The unnamed-root chip only exists when something is actually on one. diff --git a/editor/app/storage/actions.ts b/editor/app/storage/actions.ts @@ -33,6 +33,12 @@ import { enqueueRepointJob } from "./lib/repointJob"; import { enqueueSavedVideosRelocation } from "./lib/savedVideosJob"; import { enqueueEvictClipWindows } from "./lib/evictClipsJob"; import { savedVideosStoreBusyReason } from "./lib/storeBusy"; +import { refreshLocationHealth } from "yt-dlp-transcript-common/controller/storageWatch"; +import { forgetChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia"; +import { + locationHealth, + notAnsweringText, +} from "yt-dlp-transcript-common/lib/storageHealth"; // THE SIX THINGS AN OPERATOR MAY DO TO A STORAGE LOCATION. // @@ -244,6 +250,22 @@ export async function refreshStorageLocationAction( const settings = getSettings(); const location = settings.storage.locations.find((l) => l.id === id); if (!location) return { ok: false, error: `There is no storage location "${id}".` }; + // IS IT ANSWERING, asked first and out of process (a child `stat` against a + // 3 s timer): the probe below runs in-process, and on a stalled drive it is + // not asked at all. The channels' remembered answers go too — the operator + // has just done something about the drive. + await refreshLocationHealth(location); + forgetChannelMedia(); + const health = locationHealth(location.id); + if (health?.state === "stalled") { + revalidateStorage(); + return { + ok: true, + note: + `${location.label}: ${notAnsweringText(health)} — ${health.cause ?? "its root did not answer"}. ` + + `Pages skip this drive until it answers twice in a row (checked every 15 s).`, + }; + } const probe = await probeLocationMemo(location, paths, { refresh: true }); const wrote = await recordProbedIdentity({ locationId: id, probe }); diff --git a/editor/app/storage/buildStorage.ts b/editor/app/storage/buildStorage.ts @@ -3,6 +3,10 @@ import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { getFreeBytes } from "yt-dlp-transcript-common/lib/diskSpace"; import { udisksctlAvailable } from "yt-dlp-transcript-common/lib/storageVolumes"; +import { + notAnsweringText, + stalledLocation, +} from "yt-dlp-transcript-common/lib/storageHealth"; import { listChannelBriefs } from "yt-dlp-transcript-common/controller/channels"; import { channelsOnLocation, @@ -81,6 +85,18 @@ export async function buildStorage(): Promise<StorageRowsPayload> { // situation the operator opened the page to understand. The store's SIZE is // the only thing cached; its location, status and marker are read fresh every // render, because those are the safety facts. + // THE DRIVES THAT ARE NOT ANSWERING, in words. From memory: the health probe + // (a child `stat`, every 15 s) is what asked. + const now = Date.now(); + const notAnswering: Record<string, string> = {}; + for (const loc of locations) { + const stall = stalledLocation(loc); + if (stall) { + notAnswering[loc.id] = + `${notAnsweringText(stall, now)} — ${stall.cause ?? "its root did not answer"}. ` + + `Pages and polls skip this drive until it answers twice in a row.`; + } + } const store = await inspectSavedVideosStore(paths, settings); const storeMeasured = store.status === "unreachable" || store.status === "in-transition" @@ -106,9 +122,10 @@ export async function buildStorage(): Promise<StorageRowsPayload> { }, defaultLocationId: settings.storage.defaultLocationId, probes, + notAnswering, rollups, registry: getRegistry(), udisksctlAvailable: udisksctl, - now: Date.now(), + now, }); } diff --git a/editor/app/storage/components/StorageLocationsTable.tsx b/editor/app/storage/components/StorageLocationsTable.tsx @@ -254,6 +254,15 @@ function LocationCard({ <dd aria-label="location last probe">{formatAge(row.lastProbeAgeMs)}</dd> </dl> + {row.notAnswering && ( + <p + role="status" + aria-label="location not answering" + className="text-xs rounded border border-destructive/50 bg-destructive/5 px-3 py-2 text-destructive" + > + {row.notAnswering} + </p> + )} {row.warning && ( <p role="status"