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:
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>