// `archilyzer storage report […] [--json]` — WHERE EACH CHANNEL'S BYTES // ARE, by tier, off its report (release 19, A4). // // The tally an operator used to do by hand before freeing the corpus disk: per // channel, the MEDIA tier's bytes (what `lib/mediaTier.ts`'s `isTierable` // names — the audio and the raw live-chat replay; the bytes a media move // carries), the text, and the clip-window cache, and WHERE the media is: on the // corpus disk (in place: what a move would free), on a storage location // (`config.mediaDir` under its root — a channel is never tagged, it is on L iff // that is true), at a path no location names, or `legacy` (the retired // whole-`data/` move, held by every guard until `storage migrate-tier`). // // OFF THE REPORTS, NEVER A WALK — the /storage page's rule. One config and one // snapshot per channel; no drive is touched, so an unmounted platter cannot // stall it. A channel whose report predates a figure, or whose media drive was // not answering when the report was written, has that figure UNKNOWN, never 0 // (`—` in the table, `null` in --json), and "Refresh report" is the fix. // Social channels hold no media and are left out. // // Needs no editor. Writes nothing. import { getPaths, type Paths } from "../lib/paths"; import { getSettings } from "../lib/settings"; import { formatBytes } from "../lib/format"; import { isSocialChannel } from "../lib/channelConfig"; import { locationOfDataDir, type StorageLocation } from "../lib/storageLocations"; import { listChannelBriefs, type ChannelBrief } from "../controller/channels"; export type StoragePlace = "corpus" | "location" | "custom" | "legacy"; export type StorageReportRow = { slug: string; place: StoragePlace; // The location's id when `place` is "location" (or a legacy channel's, when // its retired dataDir sits under one). location: string | null; // config.mediaDir (or a legacy channel's dataDir): where its media is. mediaDir: string | null; mediaBytes: number | null; textBytes: number | null; clipsBytes: number | null; reportedAt: string | null; }; export type StorageReport = { rows: StorageReportRow[]; // Per place ("corpus", "location:", "custom", "legacy"): channels, the // media bytes known, and how many channels' media bytes are unknown. totals: Record; }; export function storageReportRow( brief: ChannelBrief, locations: StorageLocation[], ): StorageReportRow { const { config, snapshot } = brief; const legacyDir = config.dataDir?.trim() || ""; const mediaDir = config.mediaDir?.trim() || ""; const dir = legacyDir || mediaDir; const loc = dir ? locationOfDataDir(dir, locations) : null; const place: StoragePlace = legacyDir ? "legacy" : !mediaDir ? "corpus" : loc ? "location" : "custom"; const n = (v: number | undefined) => (typeof v === "number" ? v : null); return { slug: brief.slug, place, location: loc?.id ?? null, mediaDir: dir || null, mediaBytes: n(snapshot?.totalMediaBytes), textBytes: n(snapshot?.totalTextBytes), clipsBytes: n(snapshot?.totalClipsBytes), reportedAt: snapshot?.generatedAt ?? null, }; } export function placeKey(row: StorageReportRow): string { return row.place === "location" ? `location:${row.location}` : row.place; } export function buildStorageReport( briefs: ChannelBrief[], locations: StorageLocation[], only: readonly string[] = [], ): StorageReport { const wanted = new Set(only); const rows = briefs .filter((b) => !isSocialChannel(b.config)) .filter((b) => wanted.size === 0 || wanted.has(b.slug)) .map((b) => storageReportRow(b, locations)) // Biggest media first; unknowns last, by slug. .sort( (a, b) => (b.mediaBytes ?? -1) - (a.mediaBytes ?? -1) || a.slug.localeCompare(b.slug), ); const totals: StorageReport["totals"] = {}; for (const r of rows) { const t = (totals[placeKey(r)] ??= { channels: 0, mediaBytes: 0, unknown: 0 }); t.channels++; if (r.mediaBytes === null) t.unknown++; else t.mediaBytes += r.mediaBytes; } return { rows, totals }; } const size = (v: number | null) => (v === null ? "—" : formatBytes(v)); export function renderStorageReport(report: StorageReport): string { const head = ["channel", "media (tierable)", "text", "clips", "where"]; const body = report.rows.map((r) => [ r.slug, size(r.mediaBytes), size(r.textBytes), size(r.clipsBytes), r.place === "location" ? `location ${r.location}` : r.place === "corpus" ? "corpus disk" : `${r.place} ${r.mediaDir ?? ""}`.trim(), ]); const widths = head.map((h, i) => Math.max(h.length, ...body.map((row) => row[i].length))); const line = (cells: string[]) => cells .map((c, i) => (i >= 1 && i <= 3 ? c.padStart(widths[i]) : c.padEnd(widths[i]))) .join(" ") .trimEnd(); const totals = Object.entries(report.totals).map( ([place, t]) => ` ${place === "corpus" ? "on the corpus disk (a move would free)" : place}: ` + `${t.channels} channel(s), ${formatBytes(t.mediaBytes)} media` + (t.unknown ? `, ${t.unknown} unknown (refresh their reports)` : ""), ); return [line(head), ...body.map(line), "", "Media by place:", ...totals].join("\n"); } export async function main(opts: { slugs: string[]; json: boolean; paths?: Paths; out?: (s: string) => void; }): Promise { const paths = opts.paths ?? getPaths(); const out = opts.out ?? ((s: string) => console.log(s)); const briefs = await listChannelBriefs(paths); const known = new Set(briefs.map((b) => b.slug)); const missing = opts.slugs.filter((s) => !known.has(s)); if (missing.length) { console.error(`storage report: no channel ${missing.join(", ")}`); return 2; } const report = buildStorageReport(briefs, getSettings().storage.locations, opts.slugs); out(opts.json ? JSON.stringify(report, null, 2) : renderStorageReport(report)); return 0; }