Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 3b95a7bb8ef0efa2197766a949d1b1e56dc5fdc0
parent 19c214306feda8c4c433ca33bdebfc085393adfd
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 26 Sep 2026 16:46:10 -0400

editor: the video page says "Metadata rewritten N× · last … by …: <what moved>" with the entries (from → to) under it, from the pure views/metadataHistoryView; the Source video card's "Persist source video" is live on youtube-handling channels too

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Acommon/views/metadataHistoryView.test.ts | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/views/metadataHistoryView.ts | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/MetadataHistoryDetails.tsx | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/videos/[id]/components/cards/SourceVideoSection.tsx | 19+++++++++++--------
Meditor/app/channels/[slug]/videos/[id]/lib/videoChoreCards.ts | 2+-
Meditor/app/channels/[slug]/videos/[id]/page.tsx | 10++++++++++
6 files changed, 387 insertions(+), 9 deletions(-)

diff --git a/common/views/metadataHistoryView.test.ts b/common/views/metadataHistoryView.test.ts @@ -0,0 +1,129 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + METADATA_VALUE_DISPLAY_CHARS, + NOTHING_MEANINGFUL, + metadataHistoryView, + relativeAgo, + valueView, +} from "./metadataHistoryView"; +import type { MetadataHistoryEntry } from "../lib/metadataHistory"; + +function e(over: Partial<MetadataHistoryEntry>): MetadataHistoryEntry { + return { + at: "2026-09-26T12:00:00.000Z", + by: "prefetch", + from: { sha256: "a".repeat(64), bytes: 10 }, + to: { sha256: "b".repeat(64), bytes: 11 }, + changed: {}, + added: {}, + removed: {}, + counters: {}, + volatile: ["epoch", "formats"], + ...over, + }; +} + +const NOW = Date.parse("2026-09-26T12:05:00.000Z"); + +test("no history, or an empty one, draws nothing", () => { + assert.equal(metadataHistoryView(null, NOW), null); + assert.equal(metadataHistoryView({ entries: [] }, NOW), null); +}); + +test("the line: count, how lately, by whom, and what moved", () => { + const v = metadataHistoryView( + { + entries: [ + e({ at: "2026-09-25T12:00:00.000Z" }), + e({ + at: "2026-09-26T12:00:00.000Z", + changed: { title: { from: "Old", to: "New" } }, + counters: { view_count: [10, 12] }, + }), + ], + }, + NOW, + ); + assert.ok(v); + assert.equal(v.count, 2); + assert.equal(v.line, "Metadata rewritten 2× · last 5m ago by prefetch: title"); + // Newest first. + assert.deepEqual( + v.entries.map((x) => x.at), + ["2026-09-26T12:00:00.000Z", "2026-09-25T12:00:00.000Z"], + ); + assert.equal(v.entries[0].atLabel, "2026-09-26 12:00 UTC"); + assert.deepEqual( + v.entries[0].changes.map((c) => [c.key, c.kind, c.from?.text, c.to?.text]), + [ + ["title", "changed", "Old", "New"], + ["view_count", "counter", "10", "12"], + ], + ); + assert.equal(v.entries[1].summary, NOTHING_MEANINGFUL); +}); + +test("counters alone are named; an all-volatile rewrite says nothing meaningful", () => { + const counters = metadataHistoryView( + { entries: [e({ counters: { view_count: [1, 2], like_count: [null, 3] } })] }, + NOW, + )!; + assert.equal( + counters.line, + "Metadata rewritten 1× · last 5m ago by prefetch: only counters (view_count, like_count)", + ); + assert.equal(counters.entries[0].changes[1].from, null); + const none = metadataHistoryView({ entries: [e({})] }, NOW)!; + assert.match(none.line, /: nothing meaningful \(formats only\)$/); + assert.deepEqual(none.entries[0].volatile, ["epoch", "formats"]); +}); + +test("added and removed keys, requestedBy, and the unreadable case", () => { + const v = metadataHistoryView( + { + entries: [ + e({ + by: "audio-check", + requestedBy: "umtool", + added: { chapters: [{ title: "Intro" }] }, + removed: { availability: "public" }, + }), + e({ unparseable: ["from"], volatile: [] }), + ], + }, + NOW, + )!; + assert.equal(v.entries[1].summary, "chapters, availability"); + assert.equal(v.entries[1].requestedBy, "umtool"); + assert.deepEqual( + v.entries[1].changes.map((c) => [c.kind, c.from?.text ?? null, c.to?.text ?? null]), + [ + ["added", null, '[{"title":"Intro"}]'], + ["removed", "public", null], + ], + ); + assert.equal(v.entries[0].summary, "unreadable metadata (no diff)"); + assert.equal(v.entries[0].unparseable, true); +}); + +test("long values are cut at 300 with the whole text kept; digests read as such", () => { + const long = "d".repeat(METADATA_VALUE_DISPLAY_CHARS + 50); + const cut = valueView(long); + assert.equal(cut.truncated, true); + assert.equal(cut.text.length, METADATA_VALUE_DISPLAY_CHARS + 1); + assert.ok(cut.text.endsWith("…")); + assert.equal(cut.full, long); + const short = valueView("short"); + assert.deepEqual(short, { text: "short", full: "short", truncated: false }); + const digest = valueView({ sha256: "c".repeat(64), bytes: 20000 }); + assert.match(digest.text, /^\(20000 bytes, too long to keep; sha256 cccccccccccc…\)$/); +}); + +test("relativeAgo steps s → m → h → d, and a future stamp is just now", () => { + assert.equal(relativeAgo(4_000), "4s ago"); + assert.equal(relativeAgo(5 * 60_000), "5m ago"); + assert.equal(relativeAgo(3 * 3_600_000), "3h ago"); + assert.equal(relativeAgo(2 * 86_400_000), "2d ago"); + assert.equal(relativeAgo(-10), "just now"); +}); diff --git a/common/views/metadataHistoryView.ts b/common/views/metadataHistoryView.ts @@ -0,0 +1,155 @@ +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 <relative time> by <by>: <summary>`. + 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 }; +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/MetadataHistoryDetails.tsx b/editor/app/channels/[slug]/videos/[id]/components/MetadataHistoryDetails.tsx @@ -0,0 +1,81 @@ +import { Fragment } from "react"; +import type { + MetadataHistoryView, + MetadataKeyChangeView, + MetadataValueView, +} from "yt-dlp-transcript-common/views/metadataHistoryView"; + +// WHAT CHANGED EACH TIME THE METADATA WAS REWRITTEN (release 10 slice N). +// +// Drawn in the page header, beside the Description, because it is about the +// same thing: the video's metadata as the source reports it. One line — how +// often, how lately, by what, and what moved — and the entries behind it, +// newest first. Read-only and server-rendered: the view +// (views/metadataHistoryView.ts) is already folded, so there is no client +// state and no control. + +const KIND_NOTE: Record<MetadataKeyChangeView["kind"], string> = { + changed: "", + added: " (new)", + removed: " (gone)", + counter: "", +}; + +function Value({ v }: { v: MetadataValueView | null }) { + if (!v) return <span className="text-muted-foreground">—</span>; + return ( + <span + className="whitespace-pre-wrap break-words" + title={v.truncated ? v.full : undefined} + > + {v.text} + </span> + ); +} + +export function MetadataHistoryDetails({ view }: { view: MetadataHistoryView }) { + return ( + <details className="text-sm" aria-label="metadata history"> + <summary className="cursor-pointer text-muted-foreground hover:text-foreground"> + {view.line} + </summary> + <ol className="mt-2 flex flex-col gap-3" aria-label="metadata history entries"> + {view.entries.map((e, i) => ( + <li + key={`${e.at}-${i}`} + className="flex flex-col gap-1 rounded border border-border px-3 py-2" + > + <div className="text-muted-foreground"> + <span className="tabular-nums">{e.atLabel}</span> + {` · by ${e.by}`} + {e.requestedBy ? ` for ${e.requestedBy}` : ""} + {` · ${e.summary}`} + </div> + {e.changes.length > 0 && ( + <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1"> + {e.changes.map((c) => ( + <Fragment key={`${c.kind}-${c.key}`}> + <dt className="font-mono text-xs text-muted-foreground pt-0.5"> + {c.key} + {KIND_NOTE[c.kind]} + </dt> + <dd className="min-w-0"> + <Value v={c.from} /> + <span className="text-muted-foreground"> → </span> + <Value v={c.to} /> + </dd> + </Fragment> + ))} + </dl> + )} + {e.volatile.length > 0 && ( + <div className="text-xs text-muted-foreground"> + Also moved (compared by fingerprint only): {e.volatile.join(", ")} + </div> + )} + </li> + ))} + </ol> + </details> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/SourceVideoSection.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/SourceVideoSection.tsx @@ -37,14 +37,11 @@ export function SourceVideoSection({ // video is already persisted, so the DOM is unchanged. const [ranHere, setRanHere] = useState(false); - if (handling !== "transcribe") { - return ( - <p className="text-sm text-muted-foreground"> - Source-video persistence applies to transcribe-handling channels only. - </p> - ); - } - + // EVERY CHANNEL GETS THE CONTROLS (release 10 slice N). A youtube-handling + // channel used to be told persistence was for transcribe handling only — + // which was true in effect, because its one media pass was skipped whenever a + // transcript or captions existed. The run now forces that pass (forceMedia), + // so the button does what it says on every channel. return ( <div className="flex flex-col gap-3"> {savedVideo ? ( @@ -53,6 +50,12 @@ export function SourceVideoSection({ videoId={videoId} savedVideo={savedVideo} /> + ) : handling === "youtube" ? ( + <p className="text-sm text-muted-foreground"> + Download this video&apos;s full source container and move it into + the saved-video store. A transcript already on disk is kept, and + no audio is extracted beside it. + </p> ) : ( <p className="text-sm text-muted-foreground"> Re-fetch this video&apos;s full source container and move it into the diff --git a/editor/app/channels/[slug]/videos/[id]/lib/videoChoreCards.ts b/editor/app/channels/[slug]/videos/[id]/lib/videoChoreCards.ts @@ -113,7 +113,7 @@ export const VIDEO_CHORE_CARDS: readonly VideoChoreCard[] = [ { id: "source-video-persistence", component: SourceVideoSection, - shown: "always (transcribe-handling channels get the controls)", + shown: "always, with its controls on every channel (youtube handling too since release 10 slice N)", why: "a storage move into the saved-video store; not derived from the transcript", }, { diff --git a/editor/app/channels/[slug]/videos/[id]/page.tsx b/editor/app/channels/[slug]/videos/[id]/page.tsx @@ -7,6 +7,8 @@ import type { Dirent } from "node:fs"; import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; import { loadDownloadOutcome } from "yt-dlp-transcript-common/lib/downloadOutcome-server"; import { loadAvailability } from "yt-dlp-transcript-common/lib/availability-server"; +import { loadMetadataHistory } from "yt-dlp-transcript-common/lib/metadataHistory-server"; +import { metadataHistoryView } from "yt-dlp-transcript-common/views/metadataHistoryView"; import { listClipWindows } from "yt-dlp-transcript-common/lib/clipWindow-server"; import { isDoNotClean } from "yt-dlp-transcript-common/lib/doNotClean-server"; import { isExcludedFromTruncatedCheck } from "yt-dlp-transcript-common/lib/excludeTruncatedCheck-server"; @@ -37,6 +39,7 @@ import { RunningJobsList } from "../../../../jobs/components/RunningJobsList"; import { VideoPanel, type VideoFile } from "./components/VideoPanel"; import { OperationPanel } from "./components/OperationPanel"; import { TagsPanel } from "./components/TagsPanel"; +import { MetadataHistoryDetails } from "./components/MetadataHistoryDetails"; import { loadVideoTags } from "./lib/videoTags"; import { loadVideoOperationPanels } from "./lib/videoOperationPanels"; @@ -96,6 +99,12 @@ export default async function VideoDetailPage({ const downloadOutcome = await loadDownloadOutcome(videoDir); const availabilityRecord = await loadAvailability(videoDir); const availabilityHistory = availabilityRecord?.history ?? []; + // Every rewrite of metadata.info.json that changed its bytes (release 10 + // slice N). One small bounded file, read once; null → nothing drawn. + const metadataHistory = metadataHistoryView( + await loadMetadataHistory(videoDir), + Date.now(), + ); const doNotClean = await isDoNotClean(videoDir); const excludedFromTruncatedCheck = await isExcludedFromTruncatedCheck(videoDir); @@ -210,6 +219,7 @@ export default async function VideoDetailPage({ </p> </details> )} + {metadataHistory && <MetadataHistoryDetails view={metadataHistory} />} </header> <RunningJobsList jobs={activeJobs} hideChannelSlug hideVideoId />