commit 34eb213fc52676cccf4734cb18c0e13841b46204
parent ea672ea2cb7ffe32b1e047508356e4925c8806f7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 29 Sep 2026 22:43:12 -0400
editor: the saved-video inventory and the Cleanup stage do not read a stalled drive
listSavedVideos reads a pointer in every video dir. A page that asks it now
passes `notAnswering`: a channel whose data/ links onto a location whose drive
is not answering is skipped and named there (the link is read, not followed;
it is on the corpus disk). /saved-videos says which channels it did not read
(aria-label "saved videos not read"); the channel page's Cleanup stage shows
the not-answering notice instead of its saved-video summary and its actions.
The backup job passes nothing and is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 72 insertions(+), 3 deletions(-)
diff --git a/common/controller/savedVideoInventory.ts b/common/controller/savedVideoInventory.ts
@@ -4,8 +4,9 @@ import type { Paths } from "../lib/paths";
import { mapConcurrent } from "../lib/concurrency";
import { loadSavedVideo } from "../lib/savedVideo-server";
import { savedVideoPath, type SavedVideoPointer } from "../lib/savedVideo";
+import { stalledLocationForPath } from "../lib/storageHealth";
-const { pathExists, readdir } = fs;
+const { pathExists, readdir, readlink } = fs;
// Channels are read a few at a time so the per-video fan-out inside each one
// still dominates; the product is the real ceiling on open descriptors.
@@ -37,12 +38,21 @@ async function listChannelSlugs(paths: Paths): Promise<string[]> {
// channel when channelSlug is given), by following the saved-video.json pointers
// in each data dir. The pointer's `dir` is absolute, so per-channel store
// overrides resolve correctly without consulting channel config here.
+//
+// `notAnswering`, for a PAGE: pass an array and every channel whose `data/`
+// links onto a storage location whose drive is not answering
+// (lib/storageHealth.ts) is skipped — its pointers are one read per video dir,
+// each of which would wait on the drive — and its slug is pushed there, so the
+// page can say which channels it did not read. The link is read, not followed:
+// it is on the corpus disk. Omitted (the backup job), nothing is skipped.
export async function listSavedVideos({
paths,
channelSlug,
+ notAnswering,
}: {
paths: Paths;
channelSlug?: string;
+ notAnswering?: string[];
}): Promise<SavedVideoEntry[]> {
const slugs = channelSlug ? [channelSlug] : await listChannelSlugs(paths);
// One pointer read per video dir — 78,350 of them across the corpus. Done one
@@ -53,6 +63,13 @@ export async function listSavedVideos({
CHANNEL_CONCURRENCY,
async (slug): Promise<SavedVideoEntry[]> => {
const dataDir = path.join(paths.channelsDir, slug, "data");
+ if (notAnswering) {
+ const target = await readlink(dataDir).catch(() => "");
+ if (target && stalledLocationForPath(target)) {
+ notAnswering.push(slug);
+ return [];
+ }
+ }
if (!(await pathExists(dataDir))) return [];
const ids = await readdir(dataDir).catch(() => [] as string[]);
const entries = await mapConcurrent(
@@ -91,6 +108,7 @@ export type SavedVideoTotals = {
export async function savedVideoTotals(opts: {
paths: Paths;
channelSlug?: string;
+ notAnswering?: string[];
}): Promise<SavedVideoTotals> {
const entries = await listSavedVideos(opts);
let bytes = 0;
diff --git a/common/controller/storageStall.test.ts b/common/controller/storageStall.test.ts
@@ -52,6 +52,7 @@ import { buildRecencyKeys, clearRecencyCache } from "./recencyIndex";
import { relocationRootPresenceProblem } from "./relocateChannelMedia";
import { generateChannelSnapshot } from "./channelSnapshot";
import { inspectSavedVideosStore } from "./relocateSavedVideos";
+import { listSavedVideos } from "./savedVideoInventory";
import type { SiteSettings } from "../lib/settings";
// ── the spy ────────────────────────────────────────────────────────────────
@@ -358,3 +359,21 @@ test("the saved-video store on a stalled drive reads unreachable, without a stat
);
assert.deepEqual(followed, []);
});
+
+test("the saved-video inventory, for a page: a stalled channel is named, not read", async () => {
+ // A saved video on the drive, so a channel that WAS read has an entry.
+ // (savedVideoInventory reads through fs-extra, whose functions graceful-fs
+ // captured before this file's spy was installed, so this case proves the skip
+ // by what comes back, not by the spy.)
+ writeFileSync(
+ path.join(TARGET, "vid1", "saved-video.json"),
+ JSON.stringify({ storedAt: "", dir: "/store", file: "v.mp4", bytes: 7 }),
+ );
+ assert.equal((await listSavedVideos({ paths, notAnswering: [] })).length, 1);
+ stall();
+ const notAnswering: string[] = [];
+ assert.deepEqual(await listSavedVideos({ paths, notAnswering }), []);
+ assert.deepEqual(notAnswering, [SLUG]);
+ // Without the array (the backup job) nothing is skipped: it reads the drive.
+ assert.equal((await listSavedVideos({ paths })).length, 1);
+});
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -40,7 +40,11 @@ import {
type ShardOp,
} from "yt-dlp-transcript-common/controller/shard";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia";
+import {
+ channelMediaStall,
+ inspectChannelMedia,
+} from "yt-dlp-transcript-common/lib/channelMedia";
+import { MediaNotAnswering } from "./components/MediaNotAnswering";
import { getFreeBytes } from "yt-dlp-transcript-common/lib/diskSpace";
import {
platformQueueKey,
@@ -515,6 +519,15 @@ export default async function ChannelDetailPage({
/>
);
case "cleanup": {
+ // The saved-video summary below reads a pointer in every video dir, and
+ // this stage's actions all act on the media: on a drive that is not
+ // answering the stage says so instead (see MediaNotAnswering).
+ const stall = channelMediaStall(config);
+ if (stall) {
+ return (
+ <MediaNotAnswering slug={slug} stall={stall} what="The Cleanup stage" />
+ );
+ }
// Saved-video store summary for this channel + whether backups are
// configured, for the Retention & persistence section.
const savedTotals = await savedVideoTotals({
diff --git a/editor/app/saved-videos/page.tsx b/editor/app/saved-videos/page.tsx
@@ -22,7 +22,11 @@ export default async function SavedVideosPage() {
const paths = getPaths();
const settings = getSettings();
const backup = settings.savedVideoBackup;
- const entries = await listSavedVideos({ paths });
+ // Channels whose drive is not answering are not read (one pointer read per
+ // video dir, each of which would wait on it); the page names them.
+ const notAnswering: string[] = [];
+ const entries = await listSavedVideos({ paths, notAnswering });
+ notAnswering.sort();
const scheduler = await readSchedulerState(paths);
const byChannel = new Map<string, ChannelSummary>();
@@ -96,6 +100,21 @@ export default async function SavedVideosPage() {
<section aria-label="per-channel saved videos" className="flex flex-col gap-2">
<h2 className="text-base font-semibold">By channel</h2>
+ {notAnswering.length > 0 && (
+ <p
+ role="status"
+ aria-label="saved videos not read"
+ className="rounded border border-destructive/50 bg-destructive/5 px-3 py-2 text-sm text-destructive"
+ >
+ Not read, because the drive their media is on is not answering:{" "}
+ {notAnswering.join(", ")}. Their saved videos are not in the counts
+ above until it answers again (see{" "}
+ <Link href="/storage" className="underline">
+ Storage
+ </Link>
+ ).
+ </p>
+ )}
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground">
No saved videos yet. Set a channel's keep-latest window, then run