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:
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'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'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 />