Archilyzer · Source

archilyzer

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

commit 93d0eefb55df91b60f68c9d01f0593bfd882b801
parent 45a37f16b987efe990b0ab460a84ff0ab1039931
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 21 Sep 2026 02:00:28 -0400

snapshot: clips/ is bytes on the platter, so count them

`data/<id>/clips/` — the clip windows umtool and the video page fetch a few
seconds at a time — was counted by nothing. The `mediaBytes` loop did
`if (!st.isFile()) continue` under a comment asserting "a video dir is flat",
which the clip-window feature made untrue the day it landed (FACTS: "clips/ HAS
NO GARBAGE COLLECTION, and its bytes are counted by nothing"). Meanwhile
`rsync -a` of the data dir carries the windows, so a relocation moved bytes the
estimate never priced.

THE DECISION: `totalMediaBytes` GROWS to include clips. It is defined as every
byte under `data/<id>/` — what the volume is holding and what a move carries —
and a window is exactly that. `totalClipsBytes` is a new optional "of which",
not a sibling: the two overlap on purpose. Split out because clips are the one
part of a channel's bytes that is a CACHE (nothing prunes a window), so an
operator looking at a row wants to know which of the two the number is.

The risk this carries, stated: `totalMediaBytes` is read by the /storage rows,
the /channels Size column, the free-up selection and the relocation preflight.
Every one of them now sees a slightly larger, and correct, number for a channel
that has been walked by a report. Nothing partitions on it, so nothing breaks;
the numbers move.

`totalClipsBytes` is optional on the same terms as `totalMediaBytes`: absent
means "unknown until Refresh report", never 0 (a fresh snapshot always writes
it, including 0).

The recursion is ONE LEVEL and ONLY into `clips/`: `clipWindow-server.ts`
writes `<from>-<to>.<ext>` and `<from>-<to>.json` flat and nothing else does, so
anything deeper would be machinery for a case that cannot happen. Every other
directory still counts nothing — a blanket recursion here would be the second
walk this loop exists to avoid.

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

Diffstat:
Mcommon/controller/channelSnapshot.test.ts | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/channelSnapshot.ts | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 128 insertions(+), 3 deletions(-)

diff --git a/common/controller/channelSnapshot.test.ts b/common/controller/channelSnapshot.test.ts @@ -358,3 +358,65 @@ test("generateChannelSnapshot refuses an unreachable channel rather than writing await rm(dir, { recursive: true, force: true }); } }); + +test("a video dir's clips/ counts into totalMediaBytes AND into totalClipsBytes", async () => { + // `clips/` is the one subdirectory a video dir has, and until this it was + // counted by nothing: the loop did `if (!st.isFile()) continue` under a + // comment saying a video dir is flat, which the clip-window feature made + // untrue. The bytes were on the platter and `rsync -a` carried them, so + // /storage and every relocation estimate under-reported a channel that had + // been walked by a report. + // + // The two numbers OVERLAP on purpose: totalMediaBytes is every byte under + // data/<id>/, totalClipsBytes is the "of which". + const dir = await mkdtemp(path.join(tmpdir(), "ttb-snap-clips-")); + try { + const paths = { channelsDir: path.join(dir, "channels") } as Paths; + const channelDir = path.join(paths.channelsDir, "alpha"); + const videoDir = path.join(channelDir, "data", "v1"); + await mkdir(path.join(videoDir, "clips"), { recursive: true }); + // A directory that is NOT clips/ still counts nothing — no blanket recursion. + await mkdir(path.join(videoDir, "scratch"), { recursive: true }); + await writeFile( + path.join(channelDir, "config.json"), + JSON.stringify({ url: "https://example.com/c" }), + ); + await writeFile(path.join(channelDir, "playlist"), ""); + await writeFile(path.join(videoDir, "audio.m4a"), Buffer.alloc(100)); + await writeFile(path.join(videoDir, "scratch", "junk.bin"), Buffer.alloc(999)); + const meta = JSON.stringify({ id: "v1" }); + await writeFile(path.join(videoDir, "metadata.info.json"), meta); + await writeFile(path.join(videoDir, "clips", "1.00-3.00.mp4"), Buffer.alloc(50)); + await writeFile(path.join(videoDir, "clips", "1.00-3.00.json"), Buffer.alloc(7)); + + const snap = await generateChannelSnapshot(paths, "alpha"); + assert.equal(snap.totalClipsBytes, 57); + assert.equal(snap.totalMediaBytes, 100 + meta.length + 57); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("a channel with no clips/ reports 0 clip bytes, not undefined", async () => { + // A fresh snapshot always carries the field; only a snapshot written BEFORE + // the field existed lacks it, which is the case readers render as "unknown". + const dir = await mkdtemp(path.join(tmpdir(), "ttb-snap-noclips-")); + try { + const paths = { channelsDir: path.join(dir, "channels") } as Paths; + const channelDir = path.join(paths.channelsDir, "alpha"); + const videoDir = path.join(channelDir, "data", "v1"); + await mkdir(videoDir, { recursive: true }); + await writeFile( + path.join(channelDir, "config.json"), + JSON.stringify({ url: "https://example.com/c" }), + ); + await writeFile(path.join(channelDir, "playlist"), ""); + await writeFile(path.join(videoDir, "audio.m4a"), Buffer.alloc(100)); + + const snap = await generateChannelSnapshot(paths, "alpha"); + assert.equal(snap.totalClipsBytes, 0); + assert.equal(snap.totalMediaBytes, 100); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/common/controller/channelSnapshot.ts b/common/controller/channelSnapshot.ts @@ -20,6 +20,7 @@ import { } from "../lib/availability"; import { resolveCookiePolicy } from "../lib/cookiePolicy"; import { assertChannelMediaReachable } from "../lib/channelMedia"; +import { CLIPS_DIR_NAME } from "../lib/clipWindow"; import { getSettings } from "../lib/settings"; import { loadAvailability, @@ -329,6 +330,19 @@ export type ChannelSnapshot = { // until Refresh report", never as 0 — a zero would rank a 400 GB channel // bottom of a "free up N GB" list. totalMediaBytes?: number; + // OF WHICH: the bytes held by `data/<id>/clips/` — the clip windows umtool + // and the video page fetch a few seconds at a time. A SUBSET of + // `totalMediaBytes`, not a sibling of it: a window lives under the video dir, + // `rsync -a` carries it with everything else, and a volume is holding it. + // + // Split out because it is the one part of a channel's bytes that is a CACHE: + // nothing prunes a window, so a channel walked by many reports accumulates + // them, and an operator looking at a row wants to know which of those two + // things the number is. `evictClipWindows` is what acts on it. + // + // Optional on the same terms as `totalMediaBytes`: absent means "unknown + // until Refresh report", never 0. + totalClipsBytes?: number; // WHY the audio that isn't reclaimable isn't reclaimable, in the sweep's own // order (cleanAudioFromTranscribed's discover loop). A video leaves at the // FIRST gate it hits, so these are an attribution and never overlapping sets: @@ -629,6 +643,29 @@ export { SNAPSHOT_FILENAME, snapshotPath, readChannelSnapshot }; const SNAPSHOT_VIDEO_CONCURRENCY = 16; +// ONE LEVEL of a video dir's `clips/` — the files in it, and nothing deeper. +// Deliberately not recursive: `clipWindow-server.ts` writes `<from>-<to>.<ext>` +// and `<from>-<to>.json` flat into it and nothing else does, so a recursion +// would be machinery for a case that cannot happen. A missing dir is 0. +async function dirFileBytes(dir: string): Promise<number> { + let total = 0; + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return 0; + } + for (const name of entries) { + try { + const st = await stat(path.join(dir, name)); + if (st.isFile()) total += st.size; + } catch { + // ignore — vanished mid-walk + } + } + return total; +} + async function readPlaylistUrls(file: string): Promise<string[]> { try { const raw = await readFile(file, "utf8"); @@ -766,15 +803,34 @@ export async function generateChannelSnapshot( // and the audio stats this loop replaces were being paid anyway. What // it adds is a stat per NON-audio entry — six to ten per video, warm // inode cache, on a pass that already reads several sidecars per video. - // Sub-directories are counted as nothing rather than recursed: a video - // dir is flat, and a walk here would be the second walk this avoids. + // + // A VIDEO DIR IS NO LONGER FLAT, and exactly one subdirectory is the + // reason: `clips/` (CLIPS_DIR_NAME), the fetched clip windows. It is + // recursed ONE LEVEL — the windows and their `.json` sidecars are files, + // and nothing writes a directory under it — and its bytes are counted + // BOTH into `clipsBytes` and into `mediaBytes`. Every OTHER directory + // still counts nothing: there is no other one today, and a blanket + // recursion here would be the second walk this loop exists to avoid. + // + // Why clips count toward `mediaBytes` at all: `mediaBytes` is every + // byte under `data/<id>/`, which is what the volume is holding and what + // `rsync -a` carries in a relocation. A window was being left out of + // both numbers while sitting on the platter. const audioSet = new Set(files.audioFiles); const audioSizes: Record<string, number> = {}; let mediaBytes = 0; + let clipsBytes = 0; for (const name of files.entries) { try { const st = await stat(path.join(dir, name)); - if (!st.isFile()) continue; + if (!st.isFile()) { + if (st.isDirectory() && name === CLIPS_DIR_NAME) { + const bytes = await dirFileBytes(path.join(dir, name)); + clipsBytes += bytes; + mediaBytes += bytes; + } + continue; + } mediaBytes += st.size; if (audioSet.has(name)) audioSizes[name] = st.size; } catch { @@ -850,6 +906,7 @@ export async function generateChannelSnapshot( backfill, audioSizes, mediaBytes, + clipsBytes, nativeId, availability, effectiveAvailability, @@ -965,6 +1022,9 @@ export async function generateChannelSnapshot( // surfaces are priced in: how much a volume is holding for this channel, and // how much a move would carry. let totalMediaBytes = 0; + // The `clips/` share of the above. Counted in BOTH, deliberately: see the + // field comment on `totalClipsBytes`. + let totalClipsBytes = 0; const heldAudioBytes = emptyHeldAudio(); const heldAudioCounts = emptyHeldAudio(); let reclaimableAtRiskBytes = 0; @@ -981,6 +1041,7 @@ export async function generateChannelSnapshot( files, audioSizes, mediaBytes, + clipsBytes, backfill, effectiveAvailability, outcome, @@ -991,6 +1052,7 @@ export async function generateChannelSnapshot( if (isVideoTranscribed(files)) transcribed++; if (isVideoDownloaded(files)) downloaded++; totalMediaBytes += mediaBytes; + totalClipsBytes += clipsBytes; // --- Hold attribution --------------------------------------------------- // FIRST, before the short-circuits below: they `continue` past videos that @@ -1447,6 +1509,7 @@ export async function generateChannelSnapshot( }, totalAudioBytes, totalMediaBytes, + totalClipsBytes, heldAudioBytes, heldAudioCounts, reclaimableAtRiskBytes,