#!/usr/bin/env node // check-availability.mjs — is every source this manifest cites still fetchable? // // This is the one fact about a report video that goes stale in BOTH directions // and that nothing on disk records. A source can be deleted between writing the // manifest and building it (so a 40-minute build dies at clip 14 having paid for // thirteen fetches), and a source can come back (so a manifest annotated "gone" // stays wrong). Neither is visible until a build runs. // // So it runs first, it runs cheap, and it writes down when it ran. `--simulate` // resolves formats without downloading a byte: a few seconds for a whole // manifest against twenty-odd minutes for the build it protects. // // On the CLI: // node umtool/report-to-video/check-availability.mjs [--json] // // Options: // --json Print the report as JSON instead of a table // --out Output root (default: manifest dir + /out) // --allow-missing Exit 0 even when a source is gone (report only) // --max-age Reuse a recorded verdict younger than this (default: 0) import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { DEFAULT_CHANNELS_DIR } from "./cues.mjs"; import { ensureWriteDir } from "../lib/report/storage.mjs"; // The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table, // in common, plain JS so bare `node` can load it. import { platformArgsForUrl } from "yt-dlp-transcript-common/ytdlp/platformArgs.mjs"; const execFileP = promisify(execFile); const YTDLP = process.env.YTDLP_BIN ?? "yt-dlp"; // Resolved relative to the repo (see cues.mjs) rather than an absolute path in // one machine's home directory, which every other clone would miss. const CHANNELS_DIR = DEFAULT_CHANNELS_DIR; // yt-dlp says why in prose, and the distinction matters editorially: a private // or removed video needs the clip converting to a quote card, while a network // blip needs a retry. Anything unrecognised stays `maybe_missing` rather than // being called deleted -- claiming a source is gone when it is not is the more // expensive mistake, because it invites deleting a citation. function classify(stderr) { const t = String(stderr ?? ""); if (/Private video|private/i.test(t)) return "private"; if (/removed by the uploader|has been removed|no longer available|Video unavailable|does not exist|410/i.test(t)) return "deleted"; if (/age.?restrict|Sign in to confirm|confirm your age/i.test(t)) return "restricted"; if (/members-only|join this channel/i.test(t)) return "members-only"; if (/geo|not available in your country/i.test(t)) return "geo-blocked"; return "maybe_missing"; } // `--simulate` resolves formats without downloading. It carries the platform's // fixed args like every other spawn: without `--impersonate` every Rumble source // 403s here and reads as missing when it is not. export function simulateArgs(url) { return [ "--ignore-config", "--no-playlist", "--simulate", "--quiet", "--no-warnings", ...platformArgsForUrl(url), "--", url, ]; } async function cueMeta(videoId, channelSlug) { const p = path.join(CHANNELS_DIR, channelSlug, "data", videoId, "transcript.cues.json"); const d = JSON.parse(await readFile(p, "utf8")); return { title: d.title, webpageUrl: d.webpageUrl, duration: d.duration }; } export async function checkAvailability(manifestPath, { outDir, maxAgeDays = 0 } = {}) { const manifest = JSON.parse(await readFile(manifestPath, "utf8")); const slug = manifest.provenance?.channelSlug; const dir = outDir ?? path.join(path.dirname(path.resolve(manifestPath)), "out"); const file = path.join(dir, "availability.json"); // A clip may name its own channel: the same streamer's VODs are mirrored // across more than one archive, and the same id under a different slug is a // different file. So the unit of work is (channel, video), never video alone. const wanted = new Map(); const want = (channel, video) => { const key = `${channel}/${video}`; if (!wanted.has(key)) wanted.set(key, { key, channel, video, clips: [], claims: [] }); return wanted.get(key); }; for (const e of manifest.timeline ?? []) { // A clip with its own `src` is a file beside the manifest, on no platform: // the build checks it is there. if (e.type !== "clip" || e.src != null) continue; want(e.channel ?? slug, e.video).clips.push(e.id); } // LEDGER SOURCES TOO, not just the clipped ones. A cut that stacks its // unclipped claims onto cards has to say WHY each one is a line of text // rather than footage, and "the upload is gone" and "we did not cut it" are // different sentences. Guessing which is which is how a live source ends up // labelled deleted on screen. for (const c of manifest.ledger ?? []) { if (!c.video) continue; want(c.channel ?? slug, c.video).claims.push(c.id); } const prev = await readFile(file, "utf8").then( (s) => JSON.parse(s), () => ({ sources: [] }), ); const prevBy = new Map((prev.sources ?? []).map((s) => [s.key, s])); const freshMs = maxAgeDays * 86400_000; const sources = []; for (const w of wanted.values()) { const was = prevBy.get(w.key); if (freshMs > 0 && was?.checkedAt && Date.now() - Date.parse(was.checkedAt) < freshMs) { sources.push({ ...was, clips: w.clips, claims: w.claims, reused: true }); continue; } let meta; try { meta = await cueMeta(w.video, w.channel); } catch { // No cue file is a DIFFERENT failure from a dead source, and it is the one // the Rumble two-ids trap produces: the manifest names the MCP video id // while the cues live under the URL slug. Build would die here too, so it // is reported here rather than discovered twenty minutes in. sources.push({ ...w, ok: false, state: "no-cues", checkedAt: new Date().toISOString(), error: `no transcript.cues.json under ${w.channel}/data/${w.video}`, }); continue; } try { await execFileP( YTDLP, simulateArgs(meta.webpageUrl), { maxBuffer: 1 << 24 }, ); sources.push({ ...w, ok: true, state: "ok", title: meta.title, url: meta.webpageUrl, checkedAt: new Date().toISOString(), error: null, }); } catch (err) { const stderr = err?.stderr ?? err?.message ?? ""; sources.push({ ...w, ok: false, state: classify(stderr), title: meta.title, url: meta.webpageUrl, checkedAt: new Date().toISOString(), error: String(stderr).trim().split("\n").slice(-3).join(" "), }); } } const report = { manifest: path.resolve(manifestPath), checkedAt: new Date().toISOString(), sources }; // ensureWriteDir, not mkdir: a project's out/ may belong on the media root. await ensureWriteDir(dir); await writeFile(file, JSON.stringify(report, null, 2) + "\n", "utf8"); return { ...report, file }; } async function main() { const argv = process.argv.slice(2); const manifestPath = argv.find((a) => !a.startsWith("--")); if (!manifestPath) { console.error("usage: check-availability.mjs [--json] [--out ] [--allow-missing]"); process.exit(2); } const flag = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; }; const report = await checkAvailability(manifestPath, { outDir: flag("--out"), maxAgeDays: Number(flag("--max-age") ?? 0), }); if (argv.includes("--json")) { console.log(JSON.stringify(report, null, 2)); } else { for (const s of report.sources) { const mark = s.ok ? "ok " : "GONE"; console.log( `${mark} ${s.key.padEnd(40)} ${String(s.state).padEnd(14)} ` + `${s.clips.length} clip(s)${s.reused ? " (cached)" : ""}`, ); if (!s.ok && s.error) console.log(` ${s.error}`); } const bad = report.sources.filter((s) => !s.ok).length; console.log(`\n${report.sources.length} source(s), ${bad} unavailable -> ${report.file}`); } if (report.sources.some((s) => !s.ok) && !argv.includes("--allow-missing")) process.exit(1); } if (import.meta.url === `file://${process.argv[1]}`) { main().catch((err) => { console.error(err.message ?? err); process.exit(1); }); }