"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"; // One spelling of the param, shared with the /storage row that writes the link. // A value import from views/, which is client-safe: every import in that module // is a type import. import { LOCATION_FILTER_PARAM } from "yt-dlp-transcript-common/views/storage"; // 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 because // 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 mediaDir 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; // "not answering since 11:35", while the health probe finds the drive not // answering (its free space is then not asked either). notAnswering?: string; // With `notAnswering`: how many clean checks clear it, in words ("twice in a // row"; settings.storage.health.clearAfterCleanPasses). clears?: string; }; 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"). The stored scope is a cookie // and not in the URL; a `?site=` link's scope is, and a chip that silently // dropped it would answer one question by discarding the other. const href = (id: string | null): string => { const next = new URLSearchParams(params.toString()); if (id === null) next.delete(LOCATION_FILTER_PARAM); else next.set(LOCATION_FILTER_PARAM, 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 (
Volumes {volumes.map((v) => ( 0 ? ` +${v.unmeasured}?` : "") + (v.notAnswering ? ` · ${v.notAnswering}` : ` · ${v.freeBytes === undefined ? "free —" : `${formatBytes(v.freeBytes)} free`}`) } title={ v.notAnswering ? `${v.label}: the drive is ${v.notAnswering}. Pages and polls do not touch it until it answers ${v.clears ?? "again"}; its channels read "not answering".` : 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 && }
); } // 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 ( Free up setTarget(e.target.value)} className="w-20 rounded-md border border-border bg-card px-2 py-0.5 text-xs tabular-nums" /> GB ); } function Chip({ href, active, label, detail, ariaLabel, title, }: { href: string; active: boolean; label: string; detail?: string; ariaLabel?: string; title?: string; }) { return ( {label} {detail && {detail}} ); }