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:
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's media, which is on “{stall.label}
+ ” — 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"