// WHAT CHANGED EACH TIME yt-dlp REWROTE A VIDEO'S metadata.info.json. // // Release 10 slice N. Every managed download rewrites `data//metadata.info.json` // (the metadata prefetch has no existence check, by design: it is what the // filters decide on), so the file only ever says what the source says TODAY. A // title that changed upstream, a description that was edited, a caption track // that appeared — all of it was overwritten without a trace. The history keeps // one entry per rewrite, beside the video, in `metadata.history.json`. // // PURE: no fs. The server half (metadataHistory-server.ts) snapshots the file // around a yt-dlp spawn and hands both texts here, so every rule below is // testable without a directory. // // ── WHAT IS STORED, AND WHAT IS ONLY FINGERPRINTED ─────────────────────────── // // A real info json is ~50 KB and ~83 % of it is `formats`: signed URLs that // differ on EVERY fetch. Storing old versions whole would be 50 KB of noise per // rewrite. So the keys fall into three sets: // // VOLATILE compared by fingerprint (sha256 of their canonical JSON) and never // stored — an entry lists which of them moved, nothing more. The // two caption maps ALSO contribute their language-key lists as // content keys (`subtitles_langs`, `automatic_captions_langs`), so a // caption track appearing IS a meaningful change. // COUNTERS view/like/comment counts: they drift every fetch, and an entry // records only the ones that moved, as [from, to]. // CONTENT everything else (title, description, duration, availability, // chapters, uploader, …): deep-compared on the raw value and stored // WHOLE on both sides, because a description's from/to is exactly // what the operator wants to read. A single value over 16 KB is // replaced by its { sha256, bytes }. // // AN ENTRY IS APPENDED ON EVERY REWRITE whose bytes differ — even one where // only the volatile keys moved. The operator wants to know it was rewritten; // such an entry is ~250 bytes. Byte-identical → no entry. No old file → no // entry (a first write is not a rewrite). import { createHash } from "node:crypto"; export const METADATA_HISTORY_FILENAME = "metadata.history.json"; // Newest last; the oldest are dropped past this. Bounded so the file lives with // the video with no eviction pass of its own. export const METADATA_HISTORY_CAP = 200; // A stored value whose JSON is larger than this is replaced by its digest. export const METADATA_HISTORY_MAX_VALUE_BYTES = 16 * 1024; // Who rewrote the file: the three yt-dlp spawns that write it, and the one // writer that is not yt-dlp (`patchMetadataInfo`, metadataHistory-server.ts). export const METADATA_HISTORY_WRITERS = [ // downloadOneManaged's attempt 0, on every managed download. "prefetch", // The audio-checked primary, which re-extracts on purpose (its restarts // would outlive the prefetch's pinned format URLs). "audio-check", // runYtdlp's legacy single-video `download-one-audio` job. "download-one", // The podcast feed backfill (controller/feedMetadataBackfill.ts): title, // date, description and duration from the channel's RSS feed, for a record // imported by its enclosure URL, which carries none of them. "feed-backfill", // An archive.org record corrected from its provenance (lib/archiveOrg.ts // archiveOrgMetadataPatch): the file's page and title instead of the item's, // and a mirror's original title, date and uploader. "archiveorg-provenance", // An archive.org record's metadata.info.json written whole from the item's // metadata API (controller/archiveOrgDownload.ts) — archive.org records are // not fetched by yt-dlp. "archiveorg-import", // A Wayback Machine copy's title and date, set from what the operator found // for it (`archilyzer wayback refresh --titles`, controller/waybackRefresh.ts) // — a raw media file captured by the Wayback Machine carries no title. "wayback-provenance", // One video's metadata re-read on request — the video page's "Refresh // metadata" and `pnpm ops refresh-metadata` (controller/refreshVideoMetadata.ts): // a metadata-only yt-dlp pass, no subtitles, no media. For a source that // changes after the first fetch — a livestream VOD that gains its processed // formats and auto-captions hours after it ends. "refresh", // A record created from a local archive's folder (release 21 D1, // controller/attachMedia.ts `createRecords`): the folder's own yt-dlp // .info.json, or one built from its description.txt / source.txt and name. "local-archive", ] as const; export type MetadataHistoryWriter = (typeof METADATA_HISTORY_WRITERS)[number]; export const VOLATILE_KEYS: ReadonlySet = new Set([ "formats", "requested_formats", "requested_downloads", "thumbnails", "thumbnail", "heatmap", "automatic_captions", "subtitles", "epoch", "_version", "_format_sort_fields", "url", "http_headers", "format", "format_id", "format_note", "filesize", "filesize_approx", "tbr", "abr", "vbr", "asr", "acodec", "vcodec", "fps", "width", "height", "resolution", "aspect_ratio", "dynamic_range", "protocol", "ext", "audio_channels", "container", "downloader_options", "quality", "source_preference", "has_drm", "language_preference", ]); export const COUNTER_KEYS: ReadonlySet = new Set([ "view_count", "like_count", "dislike_count", "comment_count", "channel_follower_count", "average_rating", "repost_count", ]); // The volatile caption maps whose LANGUAGE LIST is content: the URLs inside // them are signed and move every fetch, but a language appearing or vanishing // is a real change to what the video offers. const LANG_LIST_KEYS: Readonly> = { subtitles: "subtitles_langs", automatic_captions: "automatic_captions_langs", }; // A file, identified. export type FileFingerprint = { sha256: string; bytes: number }; // What stands in for a stored value over METADATA_HISTORY_MAX_VALUE_BYTES. export type ValueDigest = { sha256: string; bytes: number }; export type MetadataHistoryEntry = { // When the rewrite was recorded (after the spawn returned). at: string; by: MetadataHistoryWriter; // Who asked for the download, when the caller knew: the saved-video origin's // requester on a whole-recording fetch ("umtool", "mcp", …). requestedBy?: string; // The file before; `at` is its mtime, i.e. when THAT version was written. from: FileFingerprint & { at?: string }; to: FileFingerprint; // Content keys present on both sides whose values differ. changed: Record; // Content keys only the new file has / only the old one had. added: Record; removed: Record; // Counters that moved, [from, to]; null for a side that lacked the key. counters: Record; // Volatile keys whose fingerprint differs (present on one side only counts). volatile: string[]; // A side that was not a JSON object (a torn or foreign file). The diff is // then left empty: comparing against `{}` would list every key as added or // removed, which is a lie about the source. The fingerprints still say the // file changed. unparseable?: Array<"from" | "to">; }; export type MetadataHistory = { entries: MetadataHistoryEntry[] }; type Json = unknown; type JsonObject = Record; function isPlainObject(v: unknown): v is JsonObject { return typeof v === "object" && v !== null && !Array.isArray(v); } function sha256(text: string | Buffer): string { return createHash("sha256").update(text).digest("hex"); } // JSON with object keys sorted at every depth, so two files that hold the same // value in a different key order compare equal. `undefined` (an absent key) // has no JSON and stays undefined. export function canonicalJson(v: Json): string | undefined { if (v === undefined) return undefined; return JSON.stringify(sortKeys(v)); } function sortKeys(v: Json): Json { if (Array.isArray(v)) return v.map(sortKeys); if (isPlainObject(v)) { const out: JsonObject = {}; for (const k of Object.keys(v).sort()) out[k] = sortKeys(v[k]); return out; } return v; } function jsonEqual(a: Json, b: Json): boolean { return canonicalJson(a) === canonicalJson(b); } function fingerprint(v: Json): string { return sha256(canonicalJson(v) ?? ""); } // A value as it is STORED in an entry: itself, or its digest when its JSON is // over the cap. Measured in UTF-8 bytes, the unit the file is. export function guardStoredValue(v: Json): Json { const text = JSON.stringify(v); if (text === undefined) return null; const bytes = Buffer.byteLength(text, "utf8"); if (bytes <= METADATA_HISTORY_MAX_VALUE_BYTES) return v; const digest: ValueDigest = { sha256: sha256(text), bytes }; return digest; } // Is this stored value a digest standing in for an oversized one? (A real // metadata value with exactly these two keys and a 64-hex sha is not a // plausible yt-dlp field.) export function isValueDigest(v: unknown): v is ValueDigest { if (!isPlainObject(v)) return false; const keys = Object.keys(v); return ( keys.length === 2 && typeof v.sha256 === "string" && /^[0-9a-f]{64}$/.test(v.sha256) && typeof v.bytes === "number" ); } export type NormalizedMetadata = { content: JsonObject; counters: JsonObject; // Volatile key -> fingerprint of its value. volatile: Record; }; // Split one parsed info json into the three sets above. export function normalizeForDiff(meta: JsonObject): NormalizedMetadata { const content: JsonObject = {}; const counters: JsonObject = {}; const volatile: Record = {}; for (const [key, value] of Object.entries(meta)) { if (VOLATILE_KEYS.has(key)) { volatile[key] = fingerprint(value); const langKey = LANG_LIST_KEYS[key]; if (langKey && isPlainObject(value)) { content[langKey] = Object.keys(value).sort(); } } else if (COUNTER_KEYS.has(key)) { counters[key] = value; } else { content[key] = value; } } return { content, counters, volatile }; } export type MetadataDiff = Pick< MetadataHistoryEntry, "changed" | "added" | "removed" | "counters" | "volatile" >; // Union of both sides' keys: the new file's order first, then keys only the // old one had — so an entry reads in the order the source writes its fields. function unionKeys(a: object, b: object): string[] { const out = Object.keys(b); const seen = new Set(out); for (const k of Object.keys(a)) if (!seen.has(k)) out.push(k); return out; } export function diffMetadata(oldMeta: JsonObject, newMeta: JsonObject): MetadataDiff { const a = normalizeForDiff(oldMeta); const b = normalizeForDiff(newMeta); const changed: MetadataDiff["changed"] = {}; const added: MetadataDiff["added"] = {}; const removed: MetadataDiff["removed"] = {}; for (const key of unionKeys(a.content, b.content)) { const inOld = Object.hasOwn(a.content, key); const inNew = Object.hasOwn(b.content, key); if (inOld && inNew) { if (!jsonEqual(a.content[key], b.content[key])) { changed[key] = { from: guardStoredValue(a.content[key]), to: guardStoredValue(b.content[key]), }; } } else if (inNew) { added[key] = guardStoredValue(b.content[key]); } else { removed[key] = guardStoredValue(a.content[key]); } } const counters: MetadataDiff["counters"] = {}; for (const key of unionKeys(a.counters, b.counters)) { const from = Object.hasOwn(a.counters, key) ? a.counters[key] : null; const to = Object.hasOwn(b.counters, key) ? b.counters[key] : null; if (!jsonEqual(from, to)) counters[key] = [from, to]; } const volatile = unionKeys(a.volatile, b.volatile) .filter((k) => a.volatile[k] !== b.volatile[k]) .sort(); return { changed, added, removed, counters, volatile }; } function parseObject(text: string | Buffer): JsonObject | null { try { const v: unknown = JSON.parse(typeof text === "string" ? text : text.toString("utf8")); return isPlainObject(v) ? v : null; } catch { return null; } } // One rewrite, as an entry — or null when there is nothing to record (the new // bytes are the old bytes). export function buildMetadataHistoryEntry(input: { before: { text: string | Buffer; mtime?: Date | string }; after: { text: string | Buffer }; at: string; by: MetadataHistoryWriter; requestedBy?: string; }): MetadataHistoryEntry | null { const fromSha = sha256(input.before.text); const toSha = sha256(input.after.text); if (fromSha === toSha) return null; const mtime = input.before.mtime; const from: MetadataHistoryEntry["from"] = { sha256: fromSha, bytes: Buffer.byteLength(input.before.text), ...(mtime !== undefined ? { at: typeof mtime === "string" ? mtime : mtime.toISOString() } : {}), }; const to = { sha256: toSha, bytes: Buffer.byteLength(input.after.text) }; const oldMeta = parseObject(input.before.text); const newMeta = parseObject(input.after.text); const unparseable: Array<"from" | "to"> = []; if (!oldMeta) unparseable.push("from"); if (!newMeta) unparseable.push("to"); const diff: MetadataDiff = oldMeta && newMeta ? diffMetadata(oldMeta, newMeta) : { changed: {}, added: {}, removed: {}, counters: {}, volatile: [] }; return { at: input.at, by: input.by, ...(input.requestedBy ? { requestedBy: input.requestedBy } : {}), from, to, ...diff, ...(unparseable.length > 0 ? { unparseable } : {}), }; } // Append, newest last, keeping at most `cap` (the oldest go first). export function appendMetadataHistoryEntry( history: MetadataHistory | null, entry: MetadataHistoryEntry, cap: number = METADATA_HISTORY_CAP, ): MetadataHistory { const entries = [...(history?.entries ?? []), entry]; return { entries: entries.slice(-Math.max(1, cap)) }; } // The sidecar's shape check. An entry that is not recognisably one is dropped // rather than failing the whole file: one torn entry must not hide the other // 199 from the page. export function coerceMetadataHistory(value: unknown): MetadataHistory | null { if (!isPlainObject(value) || !Array.isArray(value.entries)) return null; const entries = value.entries.filter( (e): e is MetadataHistoryEntry => isPlainObject(e) && typeof e.at === "string" && typeof e.by === "string" && isPlainObject(e.from) && isPlainObject(e.to) && isPlainObject(e.changed) && isPlainObject(e.added) && isPlainObject(e.removed) && isPlainObject(e.counters) && Array.isArray(e.volatile), ); return { entries }; }