Archilyzer · Source

archilyzer

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

commit 5f47a7f138eb7b9cdeccd6c5b8fc67d10dd347fe
parent 718c6fd115aa54938e051bf6a15548b4578422bf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 20 Sep 2026 17:48:59 -0400

storage: how many bytes, and the disk they are already on

The snapshot's per-video walk statted the audio files; it now stats every entry
in the dir it has already read and reports `totalMediaBytes` — audio plus
transcripts, cues, metadata and sidecars, which is what a volume actually holds
and what a move actually carries. No second walk, and the audio stats it
replaces were being paid anyway.

`channelsOnLocation` sums it per location and counts the channels that could
NOT contribute one, because a total that silently drops a channel whose report
predates the field ranks a 400 GB channel as empty. That count travels with the
figure everywhere: `storageBytesText` says "size unknown until Refresh report"
rather than "0 B".

And the corpus volume is a row. `internal` is synthetic — assembled where it is
rendered, never written to settings, because there is nothing to configure and
`locationOfDataDir` matching every in-place channel would break the rule the
design rests on. It sorts first: 523 GB on a disk with 67 left is not a
footnote under the locations added to fix it. Every row links to
/channels?location=<id>&sort=size.

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

Diffstat:
Mcommon/controller/channelSnapshot.ts | 45+++++++++++++++++++++++++++++++++++++++++----
Mcommon/controller/storageLocations.ts | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcommon/views/storage.test.ts | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/views/storage.ts | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Meditor/app/storage/buildStorage.ts | 29+++++++++++++++++++++++++++--
Meditor/app/storage/components/StorageLocationsTable.tsx | 43++++++++++++++++++++++++++++++++++++++-----
6 files changed, 421 insertions(+), 20 deletions(-)

diff --git a/common/controller/channelSnapshot.ts b/common/controller/channelSnapshot.ts @@ -318,6 +318,17 @@ export type ChannelSnapshot = { // must default to 0 — and MUST render that as "—", not "0", because a zero // here would claim a measurement nobody took. totalAudioBytes?: number; + // EVERY byte under `data/<id>/` for every video — audio, transcripts, cues, + // metadata, thumbnails, a persisted container. The figure `/storage` and the + // `/channels` Size column are priced in, and the one a relocation carries; + // `totalAudioBytes` is a fraction of it and is about what a CLEANUP could + // reclaim, which is a different question. + // + // Optional, and the distinction is load-bearing: a snapshot written before + // this field existed lacks it, and a reader MUST render that as "size unknown + // until Refresh report", never as 0 — a zero would rank a 400 GB channel + // bottom of a "free up N GB" list. + totalMediaBytes?: 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: @@ -741,13 +752,31 @@ export async function generateChannelSnapshot( limit(async () => { const dir = path.join(dataDir, id); const files = await readVideoFiles(dir, { checkUntranscribable: true }); - // Sizes of the real audio files, used to estimate how much disk a - // cleanup would reclaim. Best-effort: skip any file we can't stat. + // EVERY FILE IN THE DIR, STATTED ONCE, feeding two numbers. + // + // `audioSizes` is what it always was: the real audio files, keyed by + // name, for the cleanup reclaim estimate. `mediaBytes` is new and is + // every byte this video dir holds — audio, transcripts, sidecars, + // thumbnails, a persisted container — because THAT is the number a + // relocation moves and a volume holds, and the audio total is only a + // fraction of it (a channel's transcripts, cues and metadata are not + // free). + // + // NOT A SECOND WALK: the readdir is `files.entries`, already in hand, + // 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. + const audioSet = new Set(files.audioFiles); const audioSizes: Record<string, number> = {}; - for (const name of files.audioFiles) { + let mediaBytes = 0; + for (const name of files.entries) { try { const st = await stat(path.join(dir, name)); - audioSizes[name] = st.size; + if (!st.isFile()) continue; + mediaBytes += st.size; + if (audioSet.has(name)) audioSizes[name] = st.size; } catch { // ignore — file vanished or is unreadable } @@ -820,6 +849,7 @@ export async function generateChannelSnapshot( files, backfill, audioSizes, + mediaBytes, nativeId, availability, effectiveAvailability, @@ -931,6 +961,10 @@ export async function generateChannelSnapshot( // hand on the perVideo entry, so this adds ZERO I/O to a pass that runs over // ~79,000 videos. let totalAudioBytes = 0; + // Every byte under `data/`, not only the audio. The figure the storage + // surfaces are priced in: how much a volume is holding for this channel, and + // how much a move would carry. + let totalMediaBytes = 0; const heldAudioBytes = emptyHeldAudio(); const heldAudioCounts = emptyHeldAudio(); let reclaimableAtRiskBytes = 0; @@ -946,6 +980,7 @@ export async function generateChannelSnapshot( id, files, audioSizes, + mediaBytes, backfill, effectiveAvailability, outcome, @@ -955,6 +990,7 @@ export async function generateChannelSnapshot( } of perVideo) { if (isVideoTranscribed(files)) transcribed++; if (isVideoDownloaded(files)) downloaded++; + totalMediaBytes += mediaBytes; // --- Hold attribution --------------------------------------------------- // FIRST, before the short-circuits below: they `continue` past videos that @@ -1410,6 +1446,7 @@ export async function generateChannelSnapshot( foreignAudio: foreignAudioBytes, }, totalAudioBytes, + totalMediaBytes, heldAudioBytes, heldAudioCounts, reclaimableAtRiskBytes, diff --git a/common/controller/storageLocations.ts b/common/controller/storageLocations.ts @@ -82,6 +82,14 @@ export type LocationRollup = { // Every channel whose `config.dataDir` is under this location's root, sorted. slugs: string[]; total: number; + // Sum of `snapshot.totalMediaBytes` over the channels on this location, and + // how many of them could not contribute one (no snapshot, or one written + // before the field existed). THE SECOND NUMBER IS WHY THE FIRST IS HONEST: a + // location whose channels have never had a report reads `0 bytes` otherwise, + // which on a storage page is a claim that a 500 GB drive is empty. Every + // surface renders "+ n unknown" beside the total. + bytes: number; + unknownBytes: number; // `inspectChannelMedia` status, bucketed into the three numbers the page // shows. `unreachable` DELIBERATELY ABSORBS `inconsistent`: both mean "this // channel's media is not readable through its link right now", which is the @@ -95,9 +103,37 @@ export type LocationRollup = { }; function emptyRollup(locationId: string): LocationRollup { - return { locationId, slugs: [], total: 0, ok: 0, unreachable: 0, moving: 0 }; + return { + locationId, + slugs: [], + total: 0, + bytes: 0, + unknownBytes: 0, + ok: 0, + unreachable: 0, + moving: 0, + }; } +// THE SYNTHETIC LOCATION: the corpus volume itself. +// +// `paths.channelsDir` is where a channel's media lives when it has not been +// moved anywhere, and that is the row the operator actually acts on — "what is +// still on the internal disk, largest first" is the whole question a storage +// page is asked. It is NOT stored in settings and never will be: there is +// nothing to configure (the root is wherever the corpus is), nothing to +// re-point (re-pointing the corpus is moving the corpus), and writing it into +// `settings.storage.locations` would make it deletable and would make +// `locationOfDataDir` match every unrelocated channel — which would break the +// one rule the whole design rests on, that a channel is on a location iff its +// `dataDir` is under that location's root, and an in-place channel HAS no +// `dataDir`. +// +// So it is assembled where it is rendered, out of the same three facts every +// other row carries, and it is the one row with no actions. +export const INTERNAL_LOCATION_ID = "internal"; +export const INTERNAL_LOCATION_LABEL = "Internal (in place)"; + // One pass over the corpus for EVERY location, not one pass per location: the // page draws a row per location and the channel list is the same list for all // of them. Cost is `listChannelConfigs` (one readdir + one config read per @@ -110,22 +146,48 @@ export async function channelsOnLocation(opts: { // Already-read configs, when the caller has them (the /storage shell does // not, the channels page does). configs?: ReadonlyArray<{ slug: string; config: ChannelConfig }>; + // `snapshot.totalMediaBytes` by slug, INJECTED rather than read here. A + // snapshot is up to a megabyte of JSON per channel and the two callers + // already hold theirs (`listChannelBriefs`); a controller that went and read + // 71 of them to add one integer each would be the third read of the same + // file in one render. A slug that is absent (or maps to undefined) counts + // towards `unknownBytes`, never towards `bytes`. + mediaBytes?: Readonly<Record<string, number | undefined>>; + // The in-place row (see INTERNAL_LOCATION_ID). When true, every channel with + // NO `dataDir` is rolled up under that id alongside the configured ones. + includeInternal?: boolean; }): Promise<Record<string, LocationRollup>> { const out: Record<string, LocationRollup> = {}; for (const loc of opts.locations) out[loc.id] = emptyRollup(loc.id); - if (opts.locations.length === 0) return out; + if (opts.includeInternal) { + out[INTERNAL_LOCATION_ID] = emptyRollup(INTERNAL_LOCATION_ID); + } + if (opts.locations.length === 0 && !opts.includeInternal) return out; const configs = opts.configs ?? (await listChannelConfigs(opts.paths)); for (const { slug, config } of configs) { const dataDir = config.dataDir?.trim(); - if (!dataDir) continue; - const loc = locationOfDataDir(dataDir, opts.locations as StorageLocation[]); - if (!loc) continue; - const roll = out[loc.id]; + const loc = dataDir + ? locationOfDataDir(dataDir, opts.locations as StorageLocation[]) + : null; + // In place: no recorded dataDir at all. A dataDir under a root NOBODY named + // is neither in place nor on a location, and it is deliberately counted in + // neither — /storage says so by the totals not adding up to the corpus, and + // the remedy is to name that root as a location. + const id = loc + ? loc.id + : !dataDir && opts.includeInternal + ? INTERNAL_LOCATION_ID + : null; + if (!id) continue; + const roll = out[id]; roll.slugs.push(slug); roll.total += 1; + const bytes = opts.mediaBytes?.[slug]; + if (typeof bytes === "number") roll.bytes += bytes; + else roll.unknownBytes += 1; const media = await inspectChannelMedia(opts.paths, slug, config); - if (media.status === "ok") roll.ok += 1; + if (media.status === "ok" || media.status === "in-place") roll.ok += 1; else if (media.status === "in-transition") roll.moving += 1; else roll.unreachable += 1; } diff --git a/common/views/storage.test.ts b/common/views/storage.test.ts @@ -23,6 +23,8 @@ function rollup(partial: Partial<LocationRollup>): LocationRollup { locationId: partial.locationId ?? "x", slugs: partial.slugs ?? [], total: partial.total ?? 0, + bytes: partial.bytes ?? 0, + unknownBytes: partial.unknownBytes ?? 0, ok: partial.ok ?? 0, unreachable: partial.unreachable ?? 0, moving: partial.moving ?? 0, @@ -256,3 +258,104 @@ test("a location that has never been probed reads as missing, not as a gap", () assert.equal(rows[0].lastProbeAgeMs, 0); assert.equal(rows[0].channelsText, "0 ok / 0 unreachable / 0 moving"); }); + +const GB = 1024 ** 3; + +test("bytes travel with the count of channels that could not be measured", () => { + const { rows, bytesOnLocation, bytesInPlace } = buildStorageRows({ + locations: [loc("cold", "/mnt/cold")], + internal: { root: "/corpus/channels", freeBytes: 67 * GB }, + defaultLocationId: "cold", + probes: {}, + rollups: { + cold: rollup({ locationId: "cold", total: 3, ok: 3, bytes: 1500 * GB }), + internal: rollup({ + locationId: "internal", + total: 5, + ok: 5, + bytes: 523 * GB, + unknownBytes: 2, + }), + }, + registry: NO_JOBS, + now: NOW, + }); + // The internal row is FIRST — it is the row with the problem. + assert.equal(rows[0].id, "internal"); + assert.equal(rows[0].kind, "internal"); + assert.equal(rows[0].root, "/corpus/channels"); + assert.equal(rows[0].freeBytes, 67 * GB); + assert.equal(rows[0].bytes, 523 * GB); + assert.equal(rows[0].bytesText, "523.00 GB + 2 unmeasured"); + assert.equal(rows[1].bytesText, "1.46 TB"); + assert.equal(bytesInPlace, 523 * GB); + assert.deepEqual(bytesOnLocation, { + internal: 523 * GB, + cold: 1500 * GB, + }); +}); + +// A ZERO THAT NOBODY MEASURED IS NOT A ZERO. A fresh corpus whose channels have +// never had a report would otherwise say a 2 TB drive holds 0 B. +test("nothing measured says so instead of claiming an empty drive", () => { + const { rows } = buildStorageRows({ + locations: [loc("cold", "/mnt/cold")], + defaultLocationId: "", + probes: {}, + rollups: { + cold: rollup({ locationId: "cold", total: 4, ok: 4, unknownBytes: 4 }), + }, + registry: NO_JOBS, + now: NOW, + }); + assert.equal(rows[0].bytesText, "size unknown until Refresh report"); +}); + +test("the internal row offers nothing, and says why for each", () => { + const { rows } = buildStorageRows({ + locations: [], + internal: { root: "/corpus/channels" }, + defaultLocationId: "", + probes: {}, + rollups: {}, + registry: NO_JOBS, + now: NOW, + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].status, "available"); + assert.equal(rows[0].freeBytes, undefined); + for (const a of rows[0].actions) { + assert.equal(a.offered, false, a.kind); + assert.match(a.withheld ?? "", /not a configured location/); + } +}); + +test("every row links to its own channel list, biggest first", () => { + const { rows } = buildStorageRows({ + locations: [loc("cold", "/mnt/cold")], + internal: { root: "/corpus/channels" }, + defaultLocationId: "", + probes: {}, + rollups: {}, + registry: NO_JOBS, + now: NOW, + }); + assert.equal(rows[0].channelsHref, "/channels?location=internal&sort=size"); + assert.equal(rows[1].channelsHref, "/channels?location=cold&sort=size"); +}); + +test("no internal input means no internal row (a caller that wants only the locations)", () => { + const { rows, bytesInPlace } = buildStorageRows({ + locations: [loc("cold", "/mnt/cold")], + defaultLocationId: "", + probes: {}, + rollups: {}, + registry: NO_JOBS, + now: NOW, + }); + assert.deepEqual( + rows.map((r) => r.id), + ["cold"], + ); + assert.equal(bytesInPlace, 0); +}); diff --git a/common/views/storage.ts b/common/views/storage.ts @@ -55,6 +55,12 @@ export type StorageRow = { id: string; label: string; root: string; + // "internal" is the SYNTHETIC row — the corpus volume, where an unrelocated + // channel's media is. It is not in settings (see INTERNAL_LOCATION_ID) and it + // is the one row with nothing to refresh, re-point, mount, edit or delete: + // every action on it is present and withheld, because a greyed button with no + // reason is what this page exists not to be. + kind: "location" | "internal"; isDefault: boolean; autoRepoint: boolean; status: StorageLocationStatus; @@ -69,6 +75,18 @@ export type StorageRow = { // `n ok / n unreachable / n moving` — the cell's text, built here so the page // and any future poll cannot word it differently. channelsText: string; + // `/channels?location=<id>` — the list this row summarises, largest first. + // Built here rather than in the page so the param name has ONE spelling + // across the two surfaces that use it. + channelsHref: string; + // Media bytes on this location, summed from each channel's last report, and + // how many channels on it could not contribute a figure. + bytes: number; + unknownBytes: number; + // "1.42 TB (2 channels unmeasured)" / "size unknown until Refresh report". + // ONE wording, and never a bare "0 B" for a location whose channels have + // simply never had a report — that reads as an empty drive. + bytesText: string; // Absent when the location is not available, and ALSO when it is available // but unmeasurable (getFreeBytes fails open to Infinity, which the probe // drops rather than carry). Render "—" for both. @@ -86,6 +104,14 @@ export type StorageRow = { export type StorageRowsPayload = { rows: StorageRow[]; + // Media bytes per row id, INCLUDING "internal". The same numbers the rows + // carry, lifted out so a caller that wants the totals (the /channels meter + // bridge) does not have to re-fold the rows. + bytesOnLocation: Record<string, number>; + // The corpus volume's share — `bytesOnLocation.internal`, named because it is + // the number the whole exercise is about: what is still on the disk that is + // 96 % full. + bytesInPlace: number; defaultLocationId: string; // Whether `udisksctl` resolved in this process. False in a container, and the // reason the Mount button is withheld there. @@ -94,6 +120,10 @@ export type StorageRowsPayload = { export type StorageRowsInputs = { locations: readonly StorageLocation[]; + // The corpus volume as a row. Absent → no internal row (a caller that only + // wants the configured locations). `freeBytes` is a statfs of the root, taken + // by the shell, because nothing in views/ may touch a disk. + internal?: { root: string; freeBytes?: number }; defaultLocationId: string; // By location id. A location with no entry has never been probed in this // process — treated as `missing` with an unknown identity rather than @@ -139,6 +169,34 @@ function countsOf(rollup: LocationRollup | undefined): StorageChannelCounts { }; } +// Bytes → GB with two decimals, or TB past a terabyte. Local rather than +// `lib/format`'s formatBytes because this module is reachable from the client +// bundle and is deliberately import-free. +function bytesLabel(n: number): string { + const GB = 1024 ** 3; + if (n >= 1024 * GB) return `${(n / (1024 * GB)).toFixed(2)} TB`; + if (n >= GB) return `${(n / GB).toFixed(2)} GB`; + if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; + return `${n} B`; +} + +// THE ONE WORDING OF A SIZE THAT MAY BE PARTLY UNKNOWN. +// +// A channel whose snapshot predates `totalMediaBytes` contributes nothing to +// the sum, and a total that silently omits it is worse than no total: it ranks +// a 400 GB channel as empty. So the count of unmeasured channels travels with +// the figure everywhere it goes, and a row where NOTHING could be measured says +// so instead of printing "0 B". +export function storageBytesText(bytes: number, unknown: number): string { + if (unknown > 0 && bytes === 0) { + return "size unknown until Refresh report"; + } + if (unknown > 0) { + return `${bytesLabel(bytes)} + ${unknown} unmeasured`; + } + return bytesLabel(bytes); +} + // The one running re-point, or null. Reported for the whole page rather than // per row because the job record carries no location id: it is enqueued on the // shared `relocate` queue key, which the registry caps at concurrency 1, so @@ -158,10 +216,22 @@ function runningRepoint(registry: RegistryReader): string | null { ); } +// The one spelling of the /channels filter param, so the two surfaces that +// write it cannot disagree about whether it is `location` or `loc`. +export const LOCATION_FILTER_PARAM = "location"; +export const INTERNAL_ROW_ID = "internal"; + +export function channelsHrefForLocation(id: string): string { + // `size` descending, because the only reason to open this list is to find the + // channels worth moving, and that is the biggest ones. + return `/channels?${LOCATION_FILTER_PARAM}=${encodeURIComponent(id)}&sort=size`; +} + export function buildStorageRows(i: StorageRowsInputs): StorageRowsPayload { const busy = runningRepoint(i.registry); const udisksctlAvailable = i.udisksctlAvailable ?? false; - const rows = i.locations.map((loc): StorageRow => { + const bytesOnLocation: Record<string, number> = {}; + const configured = i.locations.map((loc): StorageRow => { const probe = i.probes[loc.id]; const status: StorageLocationStatus = probe?.status ?? "missing"; const counts = countsOf(i.rollups[loc.id]); @@ -185,10 +255,15 @@ export function buildStorageRows(i: StorageRowsInputs): StorageRowsPayload { deleteAction(loc, counts, busy), ]; + const roll = i.rollups[loc.id]; + const bytes = roll?.bytes ?? 0; + const unknownBytes = roll?.unknownBytes ?? 0; + bytesOnLocation[loc.id] = bytes; return { id: loc.id, label: loc.label || loc.id, root: loc.root, + kind: "location", isDefault: loc.id === i.defaultLocationId, autoRepoint: loc.autoRepoint, status, @@ -196,6 +271,10 @@ export function buildStorageRows(i: StorageRowsInputs): StorageRowsPayload { identity: probe ? identityLine(probe.identity) : null, channels: counts, channelsText: `${counts.ok} ok / ${counts.unreachable} unreachable / ${counts.moving} moving`, + channelsHref: channelsHrefForLocation(loc.id), + bytes, + unknownBytes, + bytesText: storageBytesText(bytes, unknownBytes), ...(probe?.freeBytes !== undefined ? { freeBytes: probe.freeBytes } : {}), lastProbeAgeMs: probe ? Math.max(0, i.now - probe.probedAt) : 0, ...(probe?.warning ? { warning: probe.warning } : {}), @@ -204,7 +283,69 @@ export function buildStorageRows(i: StorageRowsInputs): StorageRowsPayload { actions, }; }); - return { rows, defaultLocationId: i.defaultLocationId, udisksctlAvailable }; + const rows = i.internal + ? [internalRow(i, bytesOnLocation), ...configured] + : configured; + return { + rows, + bytesOnLocation, + bytesInPlace: bytesOnLocation[INTERNAL_ROW_ID] ?? 0, + defaultLocationId: i.defaultLocationId, + udisksctlAvailable, + }; +} + +// THE CORPUS VOLUME AS A ROW, AND IT IS FIRST. +// +// It is first because it is the row with the problem: 523 GB on a disk with 67 +// left is not a footnote under the locations that were added to fix it. It is +// `available` by construction — the process is reading the corpus out of it — +// and every action is withheld with the reason, which for all five is the same +// fact said five ways: there is nothing here to configure, because this is not +// a configured place. Moving media ONTO it is "move back in place", and that +// lives on the channel's own Storage panel where the media is. +function internalRow( + i: StorageRowsInputs, + bytesOnLocation: Record<string, number>, +): StorageRow { + const internal = i.internal as NonNullable<StorageRowsInputs["internal"]>; + const roll = i.rollups[INTERNAL_ROW_ID]; + const counts = countsOf(roll); + const bytes = roll?.bytes ?? 0; + const unknownBytes = roll?.unknownBytes ?? 0; + bytesOnLocation[INTERNAL_ROW_ID] = bytes; + const withheld = + "The corpus volume is where media lives when it has not been moved " + + "anywhere. It is not a configured location: there is nothing to re-point, " + + "and moving media back onto it is the channel's own Storage panel."; + return { + id: INTERNAL_ROW_ID, + label: "Internal (in place)", + root: internal.root, + kind: "internal", + isDefault: false, + autoRepoint: false, + status: "available", + statusLabel: STORAGE_STATUS_LABEL.available, + identity: null, + channels: counts, + channelsText: `${counts.ok} ok / ${counts.unreachable} unreachable / ${counts.moving} moving`, + channelsHref: channelsHrefForLocation(INTERNAL_ROW_ID), + bytes, + unknownBytes, + bytesText: storageBytesText(bytes, unknownBytes), + ...(internal.freeBytes !== undefined ? { freeBytes: internal.freeBytes } : {}), + lastProbeAgeMs: 0, + busy: null, + actions: ( + ["refresh", "repoint", "mount", "edit", "delete"] as StorageActionKind[] + ).map((kind) => ({ + kind, + label: kind[0].toUpperCase() + kind.slice(1), + offered: false, + withheld, + })), + }; } // RE-POINT IS OFFERED FOR EXACTLY ONE STATUS. `mounted-elsewhere` means the diff --git a/editor/app/storage/buildStorage.ts b/editor/app/storage/buildStorage.ts @@ -1,7 +1,9 @@ import { getPaths } from "yt-dlp-transcript-common/lib/paths"; 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 { listChannelBriefs } from "yt-dlp-transcript-common/controller/channels"; import { channelsOnLocation, probeAllLocations, @@ -27,15 +29,38 @@ export async function buildStorage(): Promise<StorageRowsPayload> { const paths = getPaths(); const settings = getSettings(); const locations = settings.storage.locations; - const [probes, rollups, udisksctl] = await Promise.all([ + // THE SIZES COME OFF THE REPORTS, not off a walk. `listChannelBriefs` is one + // config + one snapshot per channel (6.5 MB of JSON across 71 channels on the + // production corpus) and it is what /channels already pays; walking 523 GB of + // media to size this page would be the opposite trade. A channel whose + // snapshot predates `totalMediaBytes` contributes to `unknownBytes` and the + // row says so rather than under-reporting. + const briefs = await listChannelBriefs(paths); + const mediaBytes: Record<string, number | undefined> = {}; + for (const b of briefs) mediaBytes[b.slug] = b.snapshot?.totalMediaBytes; + const configs = briefs.map((b) => ({ slug: b.slug, config: b.config })); + const [probes, rollups, udisksctl, freeOnCorpus] = await Promise.all([ probeAllLocations(locations, paths), - channelsOnLocation({ paths, locations }), + channelsOnLocation({ + paths, + locations, + configs, + mediaBytes, + includeInternal: true, + }), // Memoised per binary path inside storageVolumes, so this is one // `--version` for the life of the process. locations.length > 0 ? udisksctlAvailable(paths) : Promise.resolve(false), + getFreeBytes(paths.channelsDir), ]); return buildStorageRows({ locations, + // The corpus volume, always: it is where every unrelocated channel's media + // is, and it is the row the operator is actually trying to empty. + internal: { + root: paths.channelsDir, + ...(Number.isFinite(freeOnCorpus) ? { freeBytes: freeOnCorpus } : {}), + }, defaultLocationId: settings.storage.defaultLocationId, probes, rollups, diff --git a/editor/app/storage/components/StorageLocationsTable.tsx b/editor/app/storage/components/StorageLocationsTable.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; @@ -181,10 +182,32 @@ function LocationCard({ </dd> <dt className="text-muted-foreground">Volume</dt> <dd className="text-xs" aria-label="volume identity"> - {row.identity ?? "unknown — nothing here can ask (no findmnt, or a container)"} + {row.kind === "internal" + ? "the corpus volume" + : (row.identity ?? + "unknown — nothing here can ask (no findmnt, or a container)")} </dd> <dt className="text-muted-foreground">Channels</dt> - <dd aria-label="location channels">{row.channelsText}</dd> + <dd aria-label="location channels"> + {row.channelsText} + {row.channels.total > 0 && ( + <> + {" — "} + {/* THE ROW'S OWN LIST, largest first. A summary that cannot be + opened is a number the operator has to go and re-derive by + hand, which on this page is opening 71 channel pages. */} + <Link + href={row.channelsHref} + aria-label={`list channels on ${row.id}`} + className="underline hover:text-foreground" + > + list them + </Link> + </> + )} + </dd> + <dt className="text-muted-foreground">Media</dt> + <dd aria-label="location media bytes">{row.bytesText}</dd> <dt className="text-muted-foreground">Free</dt> <dd aria-label="location free space"> {row.freeBytes === undefined ? "—" : formatBytes(row.freeBytes)} @@ -218,6 +241,11 @@ function LocationCard({ </p> )} + {row.kind === "internal" ? ( + <p className="text-xs text-muted-foreground" aria-label="internal note"> + {actionOf(row, "edit")?.withheld} + </p> + ) : ( <div className="flex flex-wrap items-center gap-2"> <button type="button" @@ -268,13 +296,14 @@ function LocationCard({ Delete </button> </div> - {del && !del.offered && ( + )} + {row.kind !== "internal" && del && !del.offered && ( <p className="text-xs text-muted-foreground" aria-label="delete withheld"> {del.withheld} </p> )} - {editing && ( + {row.kind !== "internal" && editing && ( <LocationForm mode="edit" initial={{ @@ -316,7 +345,11 @@ function LocationCard({ /> </section> )} - {repoint && !repoint.offered && !ranRepointHere && row.status !== "available" && ( + {row.kind !== "internal" && + repoint && + !repoint.offered && + !ranRepointHere && + row.status !== "available" && ( <p className="text-xs text-muted-foreground" aria-label="repoint withheld"> {repoint.withheld} </p>