Archilyzer · Source

archilyzer

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

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:
Mcommon/controller/savedVideoInventory.ts | 20+++++++++++++++++++-
Mcommon/controller/storageStall.test.ts | 19+++++++++++++++++++
Meditor/app/channels/[slug]/page.tsx | 15++++++++++++++-
Meditor/app/saved-videos/page.tsx | 21++++++++++++++++++++-
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&apos;s keep-latest window, then run