import { isValueDigest, type MetadataHistory, type MetadataHistoryEntry, } from "../lib/metadataHistory"; // THE VIDEO PAGE'S METADATA HISTORY: one line, and the entries behind it. // // Release 10 slice N. `metadata.history.json` records every rewrite of the // video's metadata.info.json (lib/metadataHistory.ts); this turns the loaded // sidecar into what the page draws — a line saying how often and how lately it // was rewritten and what moved, and the entries newest first, each key with its // from → to. Read-only: nothing here writes, and there is no control. // // PURE, like everything in this directory: the history and the clock arrive // as arguments, so "5m ago" is testable. // // ONE SENTENCE FOR "NOTHING MOVED". Almost every rewrite changes only the // signed format URLs and the counters; the line must say so plainly rather // than list `formats`, which means nothing to an operator. Content keys are // the news; counters are named only when nothing else moved. // A value longer than this is cut on the page, with the whole text in `full` // (the renderer's `title=`). export const METADATA_VALUE_DISPLAY_CHARS = 300; export type MetadataValueView = { text: string; full: string; truncated: boolean; }; export type MetadataKeyChangeView = { key: string; kind: "changed" | "added" | "removed" | "counter"; // null for the side that lacked the key (an added key has no `from`). from: MetadataValueView | null; to: MetadataValueView | null; }; export type MetadataHistoryEntryView = { // ISO, as stored. at: string; // "2026-09-26 19:40 UTC" — fixed, so a server render never depends on the // host's locale. atLabel: string; by: string; requestedBy: string | null; // The entry's changed keys, or the sentence for none. summary: string; changes: MetadataKeyChangeView[]; // Volatile keys that moved (by fingerprint only; never shown as values). volatile: string[]; // A side was not JSON, so the entry carries no diff. unparseable: boolean; }; export type MetadataHistoryView = { count: number; // `Metadata rewritten N× · last by : `. line: string; // Newest first. entries: MetadataHistoryEntryView[]; }; export const NOTHING_MEANINGFUL = "nothing meaningful (formats only)"; // Compact relative time, the steps the editor's widget uses (s → m → h → d). export function relativeAgo(deltaMs: number): string { if (!Number.isFinite(deltaMs) || deltaMs < 0) return "just now"; const s = Math.round(deltaMs / 1000); if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.round(s / 60)}m ago`; if (s < 86400) return `${Math.round(s / 3600)}h ago`; return `${Math.round(s / 86400)}d ago`; } function atLabel(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; return `${d.toISOString().slice(0, 16).replace("T", " ")} UTC`; } export function valueView(v: unknown): MetadataValueView { const full = isValueDigest(v) ? `(${v.bytes} bytes, too long to keep; sha256 ${v.sha256.slice(0, 12)}…)` : typeof v === "string" ? v : (JSON.stringify(v) ?? "null"); const truncated = full.length > METADATA_VALUE_DISPLAY_CHARS; return { text: truncated ? `${full.slice(0, METADATA_VALUE_DISPLAY_CHARS)}…` : full, full, truncated, }; } function changesOf(e: MetadataHistoryEntry): MetadataKeyChangeView[] { const out: MetadataKeyChangeView[] = []; for (const [key, c] of Object.entries(e.changed)) { out.push({ key, kind: "changed", from: valueView(c.from), to: valueView(c.to) }); } for (const [key, v] of Object.entries(e.added)) { out.push({ key, kind: "added", from: null, to: valueView(v) }); } for (const [key, v] of Object.entries(e.removed)) { out.push({ key, kind: "removed", from: valueView(v), to: null }); } for (const [key, pair] of Object.entries(e.counters)) { const [from, to] = Array.isArray(pair) ? pair : [null, null]; out.push({ key, kind: "counter", from: from === null ? null : valueView(from), to: to === null ? null : valueView(to), }); } return out; } export function entrySummary(e: MetadataHistoryEntry): string { const content = [ ...Object.keys(e.changed), ...Object.keys(e.added), ...Object.keys(e.removed), ]; if (content.length > 0) return content.join(", "); const counters = Object.keys(e.counters); if (counters.length > 0) return `only counters (${counters.join(", ")})`; if (e.unparseable?.length) return "unreadable metadata (no diff)"; return NOTHING_MEANINGFUL; } export function metadataHistoryView( history: MetadataHistory | null, nowMs: number, ): MetadataHistoryView | null { const stored = history?.entries ?? []; if (stored.length === 0) return null; const entries: MetadataHistoryEntryView[] = [...stored].reverse().map((e) => ({ at: e.at, atLabel: atLabel(e.at), by: e.by, requestedBy: e.requestedBy ?? null, summary: entrySummary(e), changes: changesOf(e), volatile: [...e.volatile], unparseable: Boolean(e.unparseable?.length), })); const last = entries[0]; const line = `Metadata rewritten ${stored.length}× · last ` + `${relativeAgo(nowMs - Date.parse(last.at))} by ${last.by}: ${last.summary}`; return { count: stored.length, line, entries }; }