commit 473e7f908bf1db9ec1571295c9cf4aec95da505b
parent 767298b66444b5db93b8f3d7edadfa49f8e3607d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 20 Sep 2026 17:57:28 -0400
/channels: which disk, how big, and filter by volume
Operator ask, verbatim: "I'd like to see disk space and current storage volume
as columns on the channels menu, and let me filter by volume for easy checking
of things that may need moving or other processing."
Two columns — Location (the volume label, "Internal" for in place, with the
existing reachability badge beside it) and Size (`totalMediaBytes`, sortable on
`size`, biggest first on the first click, "—" and never "0 B" for a report that
predates the field). Free space is NOT a column: it is a fact about a disk and
71 copies of one number is what the focus bar already taught us not to do. It
goes in a volume bar above the rack, one chip per volume, and the chip is the
filter — `?location=<id|internal>` as a URL param beside `?site=`, so /storage
can link straight to "everything still on Internal, largest first"
(`&sort=size` seeds the sort once; sorting stays client state, because a
router.replace races the global AutoRefresh).
"Free up N GB" ticks the largest in-place channels until the target is met.
The rule is pure and unit-tested (common/views/freeUpSelection.ts): a channel
already on a location frees nothing on the disk being emptied and is never
picked, and a channel with no measurement is excluded AND COUNTED rather than
ranked as empty — which would leave the biggest thing on the disk at the bottom
of the list. It selects; the deck's existing Move button is what acts.
Volume free space is two syscalls per root, never a probe: tables do not shell
out, and the stat is what stops getFreeBytes reporting an unmounted root's
PARENT volume — which here is the disk being emptied.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
7 files changed, 660 insertions(+), 15 deletions(-)
diff --git a/common/controller/storageLocations.ts b/common/controller/storageLocations.ts
@@ -1,6 +1,7 @@
import path from "node:path";
import { readlink, stat, symlink, unlink } from "node:fs/promises";
import { getPaths, type Paths } from "../lib/paths";
+import { getFreeBytes } from "../lib/diskSpace";
import {
getSettings,
writeSettings,
@@ -195,6 +196,45 @@ export async function channelsOnLocation(opts: {
return out;
}
+// HOW MUCH ROOM EACH VOLUME HAS, WITHOUT PROBING.
+//
+// `probeLocation` is up to three subprocesses and is right for /storage, which
+// renders once per navigation. It is wrong for /channels, which renders on
+// every auto-refresh with 71 rows on it — the rule the badge already follows is
+// that TABLES NEVER PROBE.
+//
+// So this is two syscalls per location: one `stat` to find out whether the root
+// is there at all, and `getFreeBytes` only when it is. The stat is what makes
+// the answer honest — `getFreeBytes` walks up to the nearest existing ancestor
+// on ENOENT, so an unmounted root would otherwise report the free space of
+// whatever is mounted over its parent, which on this machine is the disk the
+// operator is trying to empty. Absent → undefined, rendered as "—".
+//
+// `internal` is always present: it is the corpus volume, and the process is
+// reading the corpus out of it.
+export async function volumeFreeBytes(opts: {
+ paths: Paths;
+ locations: readonly StorageLocation[];
+}): Promise<Record<string, number | undefined>> {
+ const out: Record<string, number | undefined> = {};
+ const corpus = await getFreeBytes(opts.paths.channelsDir);
+ out[INTERNAL_LOCATION_ID] = Number.isFinite(corpus) ? corpus : undefined;
+ await Promise.all(
+ opts.locations.map(async (loc) => {
+ const there = await stat(loc.root)
+ .then((st) => st.isDirectory())
+ .catch(() => false);
+ if (!there) {
+ out[loc.id] = undefined;
+ return;
+ }
+ const free = await getFreeBytes(loc.root);
+ out[loc.id] = Number.isFinite(free) ? free : undefined;
+ }),
+ );
+ return out;
+}
+
// ---------------------------------------------------------------------------
// The probe memo
// ---------------------------------------------------------------------------
diff --git a/common/views/freeUpSelection.test.ts b/common/views/freeUpSelection.test.ts
@@ -0,0 +1,79 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { selectToFreeBytes, type FreeUpCandidate } from "./freeUpSelection";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common exec tsx --test views/freeUpSelection.test.ts
+
+const GB = 1024 ** 3;
+
+function c(
+ slug: string,
+ gb: number | null,
+ inPlace = true,
+): FreeUpCandidate {
+ return { slug, bytes: gb === null ? null : gb * GB, inPlace };
+}
+
+test("largest first, stopping at the first pick that clears the target", () => {
+ const r = selectToFreeBytes(
+ [c("small", 10), c("huge", 300), c("mid", 120), c("tiny", 1)],
+ 200 * GB,
+ );
+ assert.deepEqual(r.slugs, ["huge"]);
+ assert.equal(r.bytes, 300 * GB);
+ assert.equal(r.shortfall, 0);
+});
+
+test("it keeps taking until the target is met", () => {
+ const r = selectToFreeBytes([c("a", 80), c("b", 70), c("c", 60)], 200 * GB);
+ assert.deepEqual(r.slugs, ["a", "b", "c"]);
+ assert.equal(r.bytes, 210 * GB);
+ assert.equal(r.shortfall, 0);
+});
+
+// MOVING A CHANNEL THAT IS ALREADY ON THE PLATTER FREES NOTHING on the disk
+// being emptied — counting it would report progress the operator would not get.
+test("channels already on a location are never picked", () => {
+ const r = selectToFreeBytes(
+ [c("moved", 500, false), c("here", 50)],
+ 200 * GB,
+ );
+ assert.deepEqual(r.slugs, ["here"]);
+ assert.equal(r.shortfall, 150 * GB);
+ assert.match(r.note, /150\.0 GB short/);
+});
+
+// A ZERO NOBODY MEASURED IS NOT A ZERO: ranking an unmeasured channel as empty
+// would leave the biggest thing on the disk at the bottom of the list.
+test("an unmeasured channel is excluded and counted, never ranked as empty", () => {
+ const r = selectToFreeBytes([c("unknown", null), c("known", 30)], 200 * GB);
+ assert.deepEqual(r.slugs, ["known"]);
+ assert.equal(r.unmeasured, 1);
+ assert.match(r.note, /1 channel\(s\) have no size/);
+ // An unmeasured channel that is not even in place is not our problem.
+ assert.equal(
+ selectToFreeBytes([c("elsewhere", null, false)], 10 * GB).unmeasured,
+ 0,
+ );
+});
+
+test("a zero or negative target selects nothing", () => {
+ for (const target of [0, -1]) {
+ assert.deepEqual(selectToFreeBytes([c("a", 80)], target).slugs, []);
+ }
+});
+
+test("ties break by slug so the same target always proposes the same list", () => {
+ const rows = [c("beta", 50), c("alpha", 50), c("gamma", 50)];
+ const first = selectToFreeBytes(rows, 100 * GB).slugs;
+ const again = selectToFreeBytes([...rows].reverse(), 100 * GB).slugs;
+ assert.deepEqual(first, ["alpha", "beta"]);
+ assert.deepEqual(again, first);
+});
+
+test("nothing measurable says so rather than proposing an empty selection", () => {
+ const r = selectToFreeBytes([c("x", null), c("y", null)], 100 * GB);
+ assert.deepEqual(r.slugs, []);
+ assert.match(r.note, /Nothing in place has a measured size/);
+});
diff --git a/common/views/freeUpSelection.ts b/common/views/freeUpSelection.ts
@@ -0,0 +1,104 @@
+// "FREE UP N GB" — which channels to move, given a number of bytes to reclaim.
+//
+// The operator's actual question on a disk that is 96 % full is not "which
+// channel is biggest" but "what is the shortest list I can move to get 200 GB
+// back". Answering it by hand means sorting the table by size, adding figures
+// in your head and ticking rows until the sum clears the target — which is
+// exactly the kind of arithmetic a computer should not be making a person do at
+// 3am on a nearly-full disk.
+//
+// PURE, like everything in views/: it takes rows and a target and returns
+// slugs. The page holds the rows, the deck ticks the boxes, and the existing
+// bulk Move action is what actually moves anything — this selects, it does not
+// act.
+//
+// THREE RULES, each of which is a refusal to guess:
+//
+// 1. ONLY CHANNELS THAT ARE IN PLACE. Moving a channel that is already on the
+// platter frees nothing on the disk the operator is trying to empty; it
+// would be counted as progress and deliver none.
+// 2. A CHANNEL WITH NO MEASUREMENT IS NOT A CHANNEL WITH ZERO BYTES. A
+// snapshot written before `totalMediaBytes` existed carries no figure, and
+// ranking it as empty would leave the largest channel on the disk at the
+// bottom of the list. They are excluded and COUNTED, so the deck can say
+// "3 channels have no size yet — refresh their reports" instead of
+// silently proposing a worse answer.
+// 3. LARGEST FIRST, AND THE LAST ONE OVERSHOOTS. The point is the shortest
+// list, so the greedy order is the right one; the final pick will usually
+// carry the total past the target, and that is the honest outcome rather
+// than a subset-sum search for an exact fit nobody asked for.
+
+export type FreeUpCandidate = {
+ slug: string;
+ // `snapshot.totalMediaBytes`, or null when the report predates the field.
+ bytes: number | null;
+ // Its media is on the corpus volume — the disk being freed.
+ inPlace: boolean;
+};
+
+export type FreeUpSelection = {
+ // Largest first, which is also the order they were picked in.
+ slugs: string[];
+ // What the selection would move.
+ bytes: number;
+ // Bytes still wanted after every eligible channel was taken. 0 when the
+ // target was met.
+ shortfall: number;
+ // Channels skipped for want of a measurement (rule 2).
+ unmeasured: number;
+ // A sentence for the deck, built here so the two surfaces that could show it
+ // cannot word it differently.
+ note: string;
+};
+
+export function selectToFreeBytes(
+ candidates: ReadonlyArray<FreeUpCandidate>,
+ targetBytes: number,
+): FreeUpSelection {
+ const unmeasured = candidates.filter(
+ (c) => c.inPlace && c.bytes === null,
+ ).length;
+ const eligible = candidates
+ .filter(
+ (c): c is FreeUpCandidate & { bytes: number } =>
+ c.inPlace && typeof c.bytes === "number" && c.bytes > 0,
+ )
+ .sort((a, b) => b.bytes - a.bytes || a.slug.localeCompare(b.slug));
+
+ const slugs: string[] = [];
+ let bytes = 0;
+ if (targetBytes > 0) {
+ for (const c of eligible) {
+ if (bytes >= targetBytes) break;
+ slugs.push(c.slug);
+ bytes += c.bytes;
+ }
+ }
+ const shortfall = Math.max(0, targetBytes - bytes);
+ return { slugs, bytes, shortfall, unmeasured, note: noteFor(slugs.length, shortfall, unmeasured) };
+}
+
+function gb(n: number): string {
+ return `${(n / 1024 ** 3).toFixed(1)} GB`;
+}
+
+function noteFor(
+ picked: number,
+ shortfall: number,
+ unmeasured: number,
+): string {
+ const tail =
+ unmeasured > 0
+ ? ` ${unmeasured} channel(s) have no size in their report yet and were not considered — refresh them for a better answer.`
+ : "";
+ if (picked === 0 && shortfall > 0) {
+ return `Nothing in place has a measured size to move.${tail}`;
+ }
+ if (shortfall > 0) {
+ return (
+ `Selected all ${picked} measured in-place channel(s) — still ` +
+ `${gb(shortfall)} short of the target.${tail}`
+ );
+ }
+ return `Selected ${picked} channel(s), largest first.${tail}`;
+}
diff --git a/editor/app/channels/components/ChannelSelectionDeck.tsx b/editor/app/channels/components/ChannelSelectionDeck.tsx
@@ -75,6 +75,7 @@ export function ChannelSelectionDeck({
onClear,
destinations,
defaultLocationId,
+ freeUpNote = null,
}: {
slugs: string[];
onClear: () => void;
@@ -84,6 +85,11 @@ export function ChannelSelectionDeck({
destinations: BulkDestination[];
// Which one the select opens on — `settings.storage.defaultLocationId`.
defaultLocationId: string;
+ // What "Free up N GB" came to, when that is how this selection was made.
+ // The CONTROL is in the volume bar (it has to work with nothing ticked, and
+ // the deck does not exist then); what belongs here is the answer, beside the
+ // Move button that will act on it.
+ freeUpNote?: string | null;
}) {
// Two runners, because they are two writers: the priority actions clear the
// selection on success, the relocate queues jobs and keeps it.
@@ -251,6 +257,14 @@ export function ChannelSelectionDeck({
</button>
</div>
+ {freeUpNote && (
+ <p
+ aria-label="free up selection note"
+ className="pt-1.5 text-xs text-muted-foreground"
+ >
+ {freeUpNote}
+ </p>
+ )}
{(result || moveError || priorityError) && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 pt-1.5 text-xs">
{result && (
diff --git a/editor/app/channels/components/ChannelVolumeBar.tsx b/editor/app/channels/components/ChannelVolumeBar.tsx
@@ -0,0 +1,182 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import { usePathname, useSearchParams } from "next/navigation";
+import { formatBytes } from "yt-dlp-transcript-common/lib/format";
+
+// THE VOLUME BRIDGE — how much room each disk has, said ONCE, and the filter.
+//
+// The operator's ask, verbatim (2026-09-20): "I'd like to see disk space and
+// current storage volume as columns on the channels menu, and let me filter by
+// volume for easy checking of things that may need moving or other processing."
+//
+// The volume of a ROW is a column (ChannelsTable's Location cell). Free space
+// is NOT: it is a fact about a disk, not about a channel, and repeating "67 GB
+// free" down 71 rows would be 71 copies of one number — the same mistake the
+// per-row focus sentence made before the focus bar took it. So it lives here,
+// one chip per volume, above the rack.
+//
+// THE CHIP IS THE FILTER, and it is a LINK. `?location=` is a URL param beside
+// `?site=` for the reason that one is: the page is a server component, the
+// filter changes what the server sends, and a link is shareable — /storage
+// links straight to a filtered list. Client state would also lose the race with
+// the global AutoRefresh's router.refresh(), which is why the sort is the one
+// thing here that stays local.
+
+export type ChannelVolume = {
+ // A location id, "internal" for the corpus volume, or "" for a dataDir under
+ // a root nobody named.
+ id: string;
+ label: string;
+ channels: number;
+ // Media bytes on this volume across the channels on screen, and how many of
+ // them had no figure in their report. The second number is why the first is
+ // honest — see storageBytesText.
+ bytes: number;
+ unmeasured: number;
+ // statfs of the root, or undefined when the root is not there (an unmounted
+ // drive). Never inferred from a parent — see volumeFreeBytes.
+ freeBytes?: number;
+};
+
+export function ChannelVolumeBar({
+ volumes,
+ active,
+ onFreeUp,
+}: {
+ volumes: ChannelVolume[];
+ // The id currently filtered to, or null for "every volume".
+ active: string | null;
+ // "Free up N GB": tick the largest in-place channels until the target is met.
+ // IT LIVES HERE AND NOT ON THE SELECTION DECK because the deck only exists
+ // once something is ticked, and this is the control whose whole job is to do
+ // the ticking. The deck shows what it came to, beside the Move button that
+ // acts on it.
+ onFreeUp?: (targetGB: number) => void;
+}) {
+ const pathname = usePathname();
+ const params = useSearchParams();
+ // THE SITE SCOPE SURVIVES THE VOLUME FILTER. They are two independent
+ // questions ("whose channels" and "which disk") and a chip that silently
+ // dropped ?site= would answer one by discarding the other.
+ const href = (id: string | null): string => {
+ const next = new URLSearchParams(params.toString());
+ if (id === null) next.delete("location");
+ else next.set("location", id);
+ // A new filter is a new list; the sort /storage asked for belongs to the
+ // link that carried it.
+ next.delete("sort");
+ const q = next.toString();
+ return q ? `${pathname}?${q}` : pathname;
+ };
+
+ if (volumes.length <= 1 && active === null && !onFreeUp) return null;
+
+ return (
+ <div
+ aria-label="storage volumes"
+ className="flex flex-wrap items-center gap-2 px-1 text-xs"
+ >
+ <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
+ Volumes
+ </span>
+ <Chip href={href(null)} active={active === null} label="All" />
+ {volumes.map((v) => (
+ <Chip
+ key={v.id || "elsewhere"}
+ href={href(v.id)}
+ active={active === v.id}
+ label={v.label}
+ ariaLabel={`volume ${v.id || "elsewhere"}`}
+ detail={
+ `${v.channels} ch · ${formatBytes(v.bytes)}` +
+ (v.unmeasured > 0 ? ` +${v.unmeasured}?` : "") +
+ ` · ${v.freeBytes === undefined ? "free —" : `${formatBytes(v.freeBytes)} free`}`
+ }
+ title={
+ v.freeBytes === undefined
+ ? `${v.label}: the root is not there — an unmounted drive reports no free space rather than its parent's.`
+ : `${v.label}: ${v.channels} channel(s) hold ${formatBytes(v.bytes)}${
+ v.unmeasured > 0
+ ? `, plus ${v.unmeasured} with no size in their report yet`
+ : ""
+ }. ${formatBytes(v.freeBytes)} free.`
+ }
+ />
+ ))}
+ {onFreeUp && <FreeUpControl onFreeUp={onFreeUp} />}
+ </div>
+ );
+}
+
+// THE ARITHMETIC NOBODY SHOULD BE DOING BY HAND. Sort by size, add the figures
+// up, tick rows until the sum clears the target — on a disk that is 96 % full,
+// at whatever hour it got that way. The rule is in
+// common/views/freeUpSelection.ts; this is a number box and a button.
+function FreeUpControl({ onFreeUp }: { onFreeUp: (targetGB: number) => void }) {
+ const [target, setTarget] = useState("100");
+ const n = Number.parseFloat(target);
+ const valid = Number.isFinite(n) && n > 0;
+ return (
+ <span className="ml-auto inline-flex items-center gap-1.5">
+ <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
+ Free up
+ </span>
+ <input
+ type="number"
+ min={1}
+ step={10}
+ aria-label="free up target GB"
+ value={target}
+ onChange={(e) => setTarget(e.target.value)}
+ className="w-20 rounded-md border border-border bg-card px-2 py-0.5 text-xs tabular-nums"
+ />
+ <span className="text-muted-foreground">GB</span>
+ <button
+ type="button"
+ disabled={!valid}
+ aria-label="select largest channels to free up"
+ title="Ticks the largest channels still on the corpus volume until the target is met, largest first. It selects only — moving them is the deck's Move button. Channels already on another volume free nothing here and are never picked; ones with no size in their report are skipped and counted."
+ onClick={() => onFreeUp(n)}
+ className="rounded-md border border-border px-2 py-0.5 text-xs hover:bg-muted disabled:opacity-50"
+ >
+ Select largest
+ </button>
+ </span>
+ );
+}
+
+function Chip({
+ href,
+ active,
+ label,
+ detail,
+ ariaLabel,
+ title,
+}: {
+ href: string;
+ active: boolean;
+ label: string;
+ detail?: string;
+ ariaLabel?: string;
+ title?: string;
+}) {
+ return (
+ <Link
+ href={href}
+ aria-label={ariaLabel}
+ aria-current={active ? "true" : undefined}
+ title={title}
+ className={
+ "inline-flex items-baseline gap-1.5 rounded-full border px-2.5 py-0.5 " +
+ (active
+ ? "border-primary bg-primary/10 text-foreground"
+ : "border-border text-muted-foreground hover:text-foreground")
+ }
+ >
+ <span className="font-medium">{label}</span>
+ {detail && <span className="tabular-nums text-[11px]">{detail}</span>}
+ </Link>
+ );
+}
diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx
@@ -24,6 +24,9 @@ import {
ChannelSelectionDeck,
type BulkDestination,
} from "./ChannelSelectionDeck";
+import { ChannelVolumeBar, type ChannelVolume } from "./ChannelVolumeBar";
+import { formatBytes } from "yt-dlp-transcript-common/lib/format";
+import { selectToFreeBytes } from "yt-dlp-transcript-common/views/freeUpSelection";
import {
tierOrder,
type PriorityOperation,
@@ -61,6 +64,16 @@ export type ChannelRow = ChannelStat & {
// badge column is empty for them and the two that matter stand out. See
// components/MediaLocationBadge.tsx.
media: MediaBadgeInput | null;
+ // WHICH VOLUME, as an id the filter can name: a location id, "internal" for
+ // the corpus volume, or "" for a dataDir under a root nobody named. Derived
+ // on the server from `config.dataDir` — a pure prefix match, never a probe.
+ volumeId: string;
+ volumeLabel: string;
+ // `snapshot.totalMediaBytes`. NULL, not 0, for a report written before the
+ // field existed: the Size cell renders that as "—" and the sort puts it
+ // first on ascending, because a 400 GB channel that has not been measured
+ // must not sort as the smallest thing on the disk.
+ mediaBytes: number | null;
};
// A column heading for one pipeline. Comes off the operation registry on the
@@ -93,6 +106,8 @@ type SortKey =
| "playlist"
| "lastSync"
| "report"
+ | "location"
+ | "size"
| `op:${string}`;
type SortDir = "asc" | "desc";
@@ -110,6 +125,12 @@ const DEFAULT_DIR: Record<string, SortDir> = {
// Ascending, and missing dates sort as oldest: the first click puts the
// channels whose numbers cannot be trusted at the top.
report: "asc",
+ location: "asc",
+ // THE ONLY REASON TO SORT BY SIZE is to find what is worth moving, so the
+ // first click is biggest-first. (Descending is the record's default anyway;
+ // it is stated here because this column is the page's whole storage story and
+ // an implicit default is a thing to get wrong later.)
+ size: "desc",
};
// A pipeline column defaults to `reachable` descending: the first click puts the
@@ -205,6 +226,19 @@ function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number {
a.report.generatedAt ?? undefined,
b.report.generatedAt ?? undefined,
);
+ case "location":
+ // By LABEL, then slug: the operator reads names, and a stable tiebreak
+ // keeps the two halves of a volume from shuffling between renders.
+ return (
+ a.volumeLabel.localeCompare(b.volumeLabel) ||
+ a.slug.localeCompare(b.slug)
+ );
+ case "size":
+ // compareNumbers sorts a missing value FIRST, which on descending (the
+ // default here) puts the unmeasured channels last — behind everything
+ // whose size is known, which is where a row that cannot be ranked
+ // belongs.
+ return compareNumbers(a.mediaBytes, b.mediaBytes);
default:
return 0;
}
@@ -230,6 +264,9 @@ export function ChannelsTable({
defaultLocationId = "",
sites = [],
focusLabel = null,
+ volumes = [],
+ locationFilter = null,
+ initialSort = null,
}: {
channels: ChannelRow[];
// Which pipelines to draw, in group order, resolved on the server from the
@@ -254,8 +291,19 @@ export function ChannelsTable({
sites?: FocusSite[];
// What the focus selector currently names, resolved on the server, or null.
focusLabel?: string | null;
+ // One entry per storage volume the channels on screen live on, with its free
+ // space. Rendered ONCE above the rack, never per row — see ChannelVolumeBar.
+ volumes?: ChannelVolume[];
+ // `?location=`, already validated by the server against what is on screen.
+ locationFilter?: string | null;
+ // The sort the URL asked for (`?sort=size`, which is how /storage links to a
+ // "largest first" list). Only the initial value: sorting is client state
+ // from then on, because a router.replace races the global AutoRefresh.
+ initialSort?: "size" | null;
}) {
- const [sort, setSort] = useState<SortState>(null);
+ const [sort, setSort] = useState<SortState>(
+ initialSort ? { key: initialSort, dir: defaultDirFor(initialSort) } : null,
+ );
// Slugs ticked for a bulk edit. A Set of SLUGS, not indices, so a
// re-render that reorders or drops a row cannot retarget the selection —
// the same reason SyncConsole keys its selection this way.
@@ -274,8 +322,9 @@ export function ChannelsTable({
[channels],
);
const showSections = grouped && !!sections && sections.length > 0 && !!siteId;
- // The select column, eight fixed columns, one per pipeline, then Actions.
- const colSpan = 10 + columns.length;
+ // The select column, ten fixed columns (Location and Size joined the eight),
+ // one per pipeline, then Actions.
+ const colSpan = 12 + columns.length;
// Always the intersection with what is on screen: a slug can leave the table
// between renders (a scope change, a deletion), and a bulk edit must not act
// on a row nobody can see.
@@ -285,6 +334,29 @@ export function ChannelsTable({
const allSelected =
channels.length > 0 && selectedSlugs.length === channels.length;
+ // "FREE UP N GB" — the arithmetic the operator was doing by hand.
+ //
+ // The rule lives in common/views/freeUpSelection.ts (pure, unit-tested); this
+ // is the wire between it and the tick boxes. It SELECTS and does not act:
+ // what moves anything is the deck's existing Move button, with its existing
+ // destination and its existing per-channel skips.
+ //
+ // Always over `channels` — what is on screen — so a volume filter or a site
+ // scope narrows the proposal exactly as the operator expects.
+ const [freeUpNote, setFreeUpNote] = useState<string | null>(null);
+ function freeUp(targetGB: number) {
+ const result = selectToFreeBytes(
+ channels.map((c) => ({
+ slug: c.slug,
+ bytes: c.mediaBytes,
+ inPlace: c.volumeId === "internal",
+ })),
+ targetGB * 1024 ** 3,
+ );
+ setSelected(new Set(result.slugs));
+ setFreeUpNote(`${formatBytes(result.bytes)} selected. ${result.note}`);
+ }
+
function toggleOne(slug: string) {
setSelected((prev) => {
const next = new Set(prev);
@@ -338,6 +410,11 @@ export function ChannelsTable({
focusLabel={focusLabel}
heldCount={heldCount}
/>
+ <ChannelVolumeBar
+ volumes={volumes}
+ active={locationFilter}
+ onFreeUp={freeUp}
+ />
{/* THE INSTRUMENT BAR: what the rack is showing (grouping) on the left,
how to read it (the band legend, and the two-route explainer behind a
disclosure) on the right. Both used to live in an 11px stack UNDER 67
@@ -453,6 +530,28 @@ export function ChannelsTable({
className="whitespace-nowrap"
title="When this channel's report was last generated. Every count and band on the row is read from it — stale or missing means those numbers may be wrong."
/>
+ {/* THE TWO STORAGE COLUMNS. Which disk the media is on, and how
+ much of it there is — the pair that makes "what should I move"
+ answerable without opening 71 channel pages. Free space is
+ deliberately NOT here: it is a fact about a volume, not about
+ a row, and it is stated once in the volume bar above. */}
+ <SortableTh
+ label="Location"
+ sortKey="location"
+ sort={sort}
+ onClick={onHeaderClick}
+ className="whitespace-nowrap"
+ title="Which storage volume this channel's media is on. Internal is the corpus disk; anything else is a location configured on /storage."
+ />
+ <SortableTh
+ label="Size"
+ sortKey="size"
+ sort={sort}
+ onClick={onHeaderClick}
+ align="right"
+ className="whitespace-nowrap"
+ title="Every byte under this channel's data/ — audio, transcripts, cues, metadata — from its last report. Sorts biggest first."
+ />
{/* THE METER BRIDGE. Six loose grey dashes become one block: the
band columns share an eyebrow naming them, and the rules that
open and close the block run the full height of the rack. */}
@@ -524,9 +623,18 @@ export function ChannelsTable({
the old bar off the bottom of a phone. */}
<ChannelSelectionDeck
slugs={selectedSlugs}
- onClear={() => setSelected(new Set())}
+ onClear={() => {
+ setSelected(new Set());
+ setFreeUpNote(null);
+ }}
destinations={mediaDestinations}
defaultLocationId={defaultLocationId}
+ // THE NOTE, NOT THE CONTROL. The helper itself lives in the volume bar
+ // (it is about volumes and free space, and it has to be reachable with
+ // nothing ticked — the deck does not exist then). What belongs down
+ // here is what the proposal actually came to, beside the Move button
+ // that will act on it.
+ freeUpNote={freeUpNote}
/>
</div>
);
@@ -688,6 +796,31 @@ function ChannelTableRow({
? formatStamp(c.report.generatedAt)
: c.report.state}
</Td>
+ <Td
+ ariaLabel={`media location for ${c.slug}`}
+ className={`whitespace-nowrap text-xs${dim}`}
+ title={c.media?.target ?? undefined}
+ >
+ <span className="inline-flex items-center gap-1.5">
+ {c.volumeLabel}
+ {/* The unreachable / in-transition marking the badge already owns.
+ The COLUMN says where the media is; the badge says whether it can
+ be reached, which is a different fact and the one that makes every
+ other number on the row suspect. */}
+ <MediaLocationBadge media={c.media} compact />
+ </span>
+ </Td>
+ <Td
+ ariaLabel={`media size for ${c.slug}`}
+ className={`whitespace-nowrap text-right tabular-nums${dim}`}
+ title={
+ c.mediaBytes === null
+ ? "No size in this channel's report yet — refresh it for a figure."
+ : undefined
+ }
+ >
+ {c.mediaBytes === null ? "—" : formatBytes(c.mediaBytes)}
+ </Td>
{columns.map((col, i) => (
<PipelineCell
key={col.id}
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -30,7 +30,15 @@ import {
getSettings,
type SiteSettings,
} from "yt-dlp-transcript-common/lib/settings";
-import { locationLabelOfDataDir } from "yt-dlp-transcript-common/lib/storageLocations";
+import {
+ locationOfDataDir,
+ type StorageLocation,
+} from "yt-dlp-transcript-common/lib/storageLocations";
+import {
+ INTERNAL_LOCATION_ID,
+ INTERNAL_LOCATION_LABEL,
+ volumeFreeBytes,
+} from "yt-dlp-transcript-common/controller/storageLocations";
import { buildChannelBands } from "yt-dlp-transcript-common/views/pipeline/buildBands";
import { EXTERNAL_BAND_IDS } from "yt-dlp-transcript-common/views/pipeline/buildBands";
import {
@@ -38,6 +46,7 @@ import {
type ChannelRow,
type PipelineColumn,
} from "./components/ChannelsTable";
+import type { ChannelVolume } from "./components/ChannelVolumeBar";
import { buildChannelGroupSections } from "yt-dlp-transcript-common/views/channelGroupSections";
import { SyncAllChannelsButton } from "./components/SyncAllChannelsButton";
import { RefreshAllReportsButton } from "./components/RefreshAllReportsButton";
@@ -145,14 +154,36 @@ function focusLabelOf(
: `${focusSlugs.length} channels`;
}
+// WHICH VOLUME A CHANNEL'S MEDIA IS ON, as a filterable id.
+//
+// Same derivation the badge uses (`config.dataDir` under a location's root,
+// longest match wins) with one addition: no `dataDir` at all means the corpus
+// volume, which is the row the operator is trying to empty and therefore the
+// one they most need to filter to. A `dataDir` under a root NOBODY named is
+// neither — it gets "" and falls out of every volume filter, which is the
+// honest answer and the nudge to name that root on /storage.
+function volumeOf(
+ dataDir: string | undefined,
+ locations: StorageLocation[],
+): { id: string; label: string } {
+ const trimmed = dataDir?.trim();
+ if (!trimmed) {
+ return { id: INTERNAL_LOCATION_ID, label: "Internal" };
+ }
+ const found = locationOfDataDir(trimmed, locations);
+ return found
+ ? { id: found.id, label: found.label || found.id }
+ : { id: "", label: "Elsewhere" };
+}
+
export default async function ChannelsPage({
searchParams,
}: {
- searchParams: Promise<{ site?: string }>;
+ searchParams: Promise<{ site?: string; location?: string; sort?: string }>;
}) {
const paths = getPaths();
const settings = getSettings();
- const { site } = await searchParams;
+ const { site, location: locationParam, sort: sortParam } = await searchParams;
const active = resolveActiveSite(site, listSiteIds(paths));
// Counts come from each channel's last snapshot, not a corpus walk. One read
// serves both the table and the freshness footer below.
@@ -206,11 +237,24 @@ export default async function ChannelsPage({
),
),
);
+ const locations = settings.storage.locations;
+ // TWO SYSCALLS PER VOLUME, NOT A PROBE. See volumeFreeBytes: this table draws
+ // 71 rows on every auto-refresh and the rule is that tables never shell out.
+ const freeByVolume = await volumeFreeBytes({ paths, locations });
const all: ChannelRow[] = stats.map((stat) => {
const brief = briefBySlug.get(stat.slug);
const media = mediaBySlug.get(stat.slug);
+ const volume = volumeOf(brief?.config.dataDir, locations);
return {
...stat,
+ // WHERE THE BYTES ARE, AND HOW MANY. Both off the snapshot already in
+ // hand — no walk, no probe. `mediaBytes` is null for a report written
+ // before the field existed, and the cell renders that as "—" rather than
+ // "0 B": a zero would sort a 400 GB channel to the bottom of the very
+ // list the operator opened to find it.
+ volumeId: volume.id,
+ volumeLabel: volume.label,
+ mediaBytes: brief?.snapshot?.totalMediaBytes ?? null,
pipelines: buildChannelBands(snapshots.get(stat.slug) ?? null, ids),
report: {
generatedAt: brief?.snapshot?.generatedAt ?? null,
@@ -227,10 +271,12 @@ export default async function ChannelsPage({
// settings are here and the table is a client component. NEVER a
// probe: this table draws one badge per row. Undefined for a root
// nobody named, which renders exactly what it rendered before.
- locationLabel: locationLabelOfDataDir(
- media.target,
- settings.storage.locations,
- ),
+ // The row already derived it once for the Location column;
+ // re-deriving it here would be a second answer to the same
+ // question. "" is the root nobody named — the badge takes
+ // undefined for that, which renders exactly what it rendered
+ // before locations existed.
+ locationLabel: volume.id === "" ? undefined : volume.label,
}
: null,
priority: {
@@ -259,12 +305,52 @@ export default async function ChannelsPage({
// single site is the active scope — there is no one grouping across the pool.
const activeSite =
active.isAll || !active.siteId ? null : getSite(active.siteId, paths);
- const channels = activeSite
+ const inSite = activeSite
? all.filter((c) => siteChannelSlugs(activeSite).has(c.slug))
: all;
- const sections = activeSite
- ? buildChannelGroupSections(activeSite, channels, briefs, settings)
- : null;
+ // THE VOLUME BAR IS BUILT FROM THE SITE SCOPE, NOT FROM THE FILTERED LIST.
+ // A filter that erased every chip but the one you are standing on would be a
+ // one-way door, and the numbers on the other chips are exactly what makes
+ // "which of these should I move" answerable.
+ const volumeIds = [INTERNAL_LOCATION_ID, ...locations.map((l) => l.id), ""];
+ const volumes: ChannelVolume[] = volumeIds
+ .map((id) => {
+ const rows = inSite.filter((c) => c.volumeId === id);
+ const measured = rows.filter((c) => c.mediaBytes !== null);
+ return {
+ id,
+ label:
+ id === INTERNAL_LOCATION_ID
+ ? INTERNAL_LOCATION_LABEL
+ : id === ""
+ ? "Elsewhere (unnamed root)"
+ : (locations.find((l) => l.id === id)?.label ?? id),
+ channels: rows.length,
+ bytes: measured.reduce((sum, c) => sum + (c.mediaBytes ?? 0), 0),
+ unmeasured: rows.length - measured.length,
+ freeBytes: freeByVolume[id],
+ };
+ })
+ // The unnamed-root chip only exists when something is actually on one.
+ .filter((v) => v.id !== "" || v.channels > 0);
+ // ?location=<id|internal>, alongside ?site=. Ignored when it names nothing on
+ // screen — a stale link from /storage after the last channel moved off a
+ // location must show the page, not an empty table with no way back.
+ const locationFilter =
+ locationParam !== undefined &&
+ volumes.some((v) => v.id === locationParam && v.channels > 0)
+ ? locationParam
+ : null;
+ const channels =
+ locationFilter === null
+ ? inSite
+ : inSite.filter((c) => c.volumeId === locationFilter);
+ // GROUPS ARE A PARTITION OF A SITE, so a volume filter and the grouped render
+ // cannot both be true: half a group is not a group. Filtering flattens.
+ const sections =
+ activeSite && locationFilter === null
+ ? buildChannelGroupSections(activeSite, channels, briefs, settings)
+ : null;
const shown = new Set(channels.map((c) => c.slug));
const freshness = summariseFreshness(briefs.filter((b) => shown.has(b.slug)));
return (
@@ -361,6 +447,13 @@ export default async function ChannelsPage({
columns={columns}
sections={sections}
siteId={activeSite?.siteId}
+ volumes={volumes}
+ locationFilter={locationFilter}
+ // /storage links here with `sort=size`, so the list it promised
+ // ("largest first") is the list that renders. Any other value is
+ // ignored; sorting is otherwise client state, deliberately (a
+ // router.replace races the global AutoRefresh and gets dropped).
+ initialSort={sortParam === "size" ? "size" : null}
// The configured storage locations, for the selection deck's
// destination select. Read here, not in the client component —
// /storage is their one writer. No probe: the deck names a place and