#!/usr/bin/env node // Fetch one clip's window BY ASKING THE EDITOR, not by running yt-dlp. // // A drop-in for `build-video.mjs --fetch-only`: same argv shape, same NDJSON // events, same exit convention — so the driver can swap one step for the other // and nothing downstream knows which ran. // // WHY IT EXISTS. The operator's rule is that no fetch happens by hand. Running // yt-dlp from here means no cookie policy, no per-platform sleeps, no 429 // cooldown, and bytes that land in one project's out/clips-raw where the next // report cannot see them. Asking the editor means all four, and the window ends // up in the corpus beside the video with a note saying who wanted it and why. // // It never names a file: the editor decides where the bytes go and tells us. // What comes back is a path inside the corpus, which lib/paths.mjs admits as a // READ root (and deliberately not a write one). // // The local path is still there behind UMTOOL_LOCAL_FETCH=1 — for a machine // with no editor to ask. import path from "node:path"; import { readFile } from "node:fs/promises"; const DEFAULT_EDITOR = "http://localhost:3001"; const POLL_MS = 1000; // A window is seconds of media; a queue behind a channel sync is not. Generous, // and the job keeps running on the editor if we give up — nothing is lost, the // next ask finds it cached. const POLL_TIMEOUT_MS = 10 * 60_000; const argv = process.argv.slice(2); const flag = (name) => { const i = argv.indexOf(name); return i < 0 ? undefined : argv[i + 1]; }; let EMIT = (ev, fields = {}) => { if (ev === "fetch" && !fields.cached) { console.log(` fetch ${fields.id}: ${fields.video} ${fields.from}–${fields.to}`); } else if (ev === "note") { console.log(fields.message); } else if (ev === "done" && fields.out) { console.log(`fetched ${fields.out}`); } }; if (flag("--progress") === "ndjson") { EMIT = (ev, fields = {}) => process.stdout.write(JSON.stringify({ ev, ...fields }) + "\n"); } function die(message) { EMIT("note", { message }); process.stderr.write(`${message}\n`); process.exit(1); } const manifestPath = argv.find((a) => !a.startsWith("-") && a.endsWith(".json")); const clipId = flag("--fetch-only") ?? flag("--clip"); // THE WHOLE TIMELINE AS ONE ASK: every clip entry's window, posted as one // `fetch-windows` call. The editor answers the windows already on disk at once // and fetches the rest as one paced job per platform — the pacing, the 403 // streak and the 429 stop live there, not in a shell loop here. const wantAll = argv.includes("--all"); if (!manifestPath || (!clipId && !wantAll) || (clipId && wantAll)) { die("usage: fetch-via-editor.mjs (--fetch-only [--full] | --all) [--max-height N] [--pad N] [--pad-before N] [--pad-after N] [--progress ndjson]"); } // THE WHOLE RECORDING INSTEAD OF A WINDOW. For a clip whose windows would tile // the entire runtime, or a bench session that wants to re-cut freely without a // fetch per attempt. The editor puts it in the SAVED-VIDEO STORE (not clips/), // where the retention rule leaves an explicitly-requested container alone, and // the pointer it writes beside the video is what `clipWindowDirs` reads back. const wantFull = argv.includes("--full"); // THE TALLEST SOURCE TO FETCH, in pixels, when the caller names one. A window // is fetched at or under it; a whole recording at 720 or less is saved as the // editor's 720p H.264 preset, above it at the original quality. Absent, the // editor's own default applies (720 for a window, the channel's source-video // quality for a whole recording). Checked here with the editor's bounds, so a // typo is refused before the request rather than as a 400 after it. const MIN_MAX_HEIGHT = 144; const MAX_MAX_HEIGHT = 2160; const maxHeightArg = flag("--max-height"); const maxHeight = maxHeightArg === undefined ? undefined : Number(maxHeightArg); if ( maxHeight !== undefined && !(Number.isInteger(maxHeight) && maxHeight >= MIN_MAX_HEIGHT && maxHeight <= MAX_MAX_HEIGHT) ) { die( `--max-height ${maxHeightArg} must be a whole number of pixels from ` + `${MIN_MAX_HEIGHT} to ${MAX_MAX_HEIGHT}`, ); } const editorUrl = (process.env.ARCHILYZER_EDITOR_URL ?? DEFAULT_EDITOR).replace(/\/+$/, ""); const token = process.env.WORKER_TOKEN ?? ""; if (!token) { // NAME THE VARIABLE. "401 Unauthorized" from an endpoint the operator has // never heard of is a twenty-minute detour; this is a ten-second one. die( "WORKER_TOKEN is not set, so the editor cannot be asked for this window. " + "Set WORKER_TOKEN to the same value the editor runs with (or set " + "UMTOOL_LOCAL_FETCH=1 to fetch locally with yt-dlp instead).", ); } const whole = JSON.parse(await readFile(manifestPath, "utf8")); const provenance = whole.provenance ?? {}; const pad = Number(flag("--pad") ?? 3); const padBefore = Number(flag("--pad-before") ?? pad); const padAfter = Number(flag("--pad-after") ?? pad); // TWO DECIMALS, matching the editor's own naming (common/lib/clipWindow.ts) and // the build's. The name IS the window, so a request that rounds differently // addresses a different file and the cache misses forever. const windowOf = (e) => ({ from: Number(Math.max(0, Number(e.start) - padBefore).toFixed(2)), to: Number((Number(e.end) + padAfter).toFixed(2)), }); const manifestId = provenance.manifestId ?? path.basename(path.dirname(path.resolve(manifestPath))); const headers = { authorization: `Bearer ${token}`, "content-type": "application/json", }; async function ask(url, init) { try { return await fetch(url, init); } catch (err) { die(`could not reach the editor at ${editorUrl}: ${err.message}`); } } if (wantAll) { if (argv.includes("--full")) die("--all fetches windows; --full is one clip's whole recording"); const clips = (whole.timeline ?? []).filter((e) => e.type === "clip"); const items = []; for (const e of clips) { const slug = e.channel ?? provenance.channelSlug; if (!slug || !e.video) die(`${e.id} has no channel or video to fetch`); items.push({ slug, id: e.video, ...windowOf(e), clipId: e.id, pad: Math.max(padBefore, padAfter), ...(e.webpageUrl ? { webpageUrl: e.webpageUrl } : {}), reason: String(e.note ?? e.quote ?? `clip window with ${padBefore}s before / ${padAfter}s after`).slice(0, 400), }); } if (items.length === 0) { EMIT("note", { message: "no clip entries in the timeline — nothing to fetch" }); EMIT("done", { out: null, nothingToFetch: true }); process.exit(0); } const res = await ask(`${editorUrl}/api/ops/fetch-windows`, { method: "POST", headers, body: JSON.stringify({ items, requestedBy: "umtool", manifest: manifestId, ...(maxHeight !== undefined ? { maxHeight } : {}), }), }); const body = await res.json().catch(() => ({})); if (!res.ok || !body.ok) { die(`the editor refused (HTTP ${res.status}): ${body.error ?? "no reason given"}`); } EMIT("note", { message: `${items.length} clip(s): ${body.cached.length} already on disk, ` + `${body.jobs.reduce((n, j) => n + j.items, 0)} queued in ${body.jobs.length} job(s)`, }); for (const u of body.unresolved ?? []) { EMIT("note", { message: ` ${u.item.clipId ?? u.item.id}: ${u.error}` }); } for (const r of body.refused ?? []) { EMIT("note", { message: ` ${r.platform}: ${r.items} window(s) not started — ${r.error}` }); } // Every job, polled to its end. A job that stopped short (a rate limit, two // 403s) fails with the reason; asking again later resumes it, the fetched // windows answering from the cache. const pending = new Map(body.jobs.map((j) => [j.jobId, j])); const failed = []; const deadline = Date.now() + 6 * 60 * 60_000; const last = new Map(); while (pending.size > 0) { if (Date.now() > deadline) die(`gave up waiting for editor job(s) ${[...pending.keys()].join(", ")}`); await new Promise((r) => setTimeout(r, POLL_MS * 5)); for (const [jobId, j] of pending) { const poll = await ask(`${editorUrl}/api/media/fetch-window/${jobId}`, { headers }); const p = await poll.json().catch(() => ({})); if (!poll.ok) die(`polling editor job ${jobId} failed (HTTP ${poll.status}): ${p.error ?? ""}`); if (p.status !== last.get(jobId)) { last.set(jobId, p.status); EMIT("note", { message: ` editor job ${jobId} (${j.platform}, ${j.items} window(s)): ${p.status}` }); } if (p.status === "done") pending.delete(jobId); else if (p.status === "failed" || p.status === "cancelled") { pending.delete(jobId); failed.push(`${jobId} ${p.status}: ${String(p.error ?? "").split("\n").slice(-3).join(" / ")}`); } } } const unfinished = failed.length + (body.refused?.length ?? 0) + (body.unresolved?.length ?? 0); if (unfinished > 0) { die(`not every window was fetched:\n ${[...failed, ...(body.refused ?? []).map((r) => `${r.platform}: ${r.error}`)].join("\n ") || "see the notes above"}`); } EMIT("done", { out: null, all: true, clips: items.length }); process.exit(0); } // Same resolution build-video.mjs's --fetch-only does, and for the same // reasons: a still has nothing to fetch, a non-clip entry is an error, and a // LEDGER CLAIM is a moment rather than a window (most of a ledger is cited by // no clip at all, and adjudicating one means listening around it). let entry = (whole.timeline ?? []).find((e) => e.id === clipId); if (entry?.type === "image") { EMIT("note", { message: `${clipId} is an image entry — nothing to fetch` }); EMIT("done", { out: null, nothingToFetch: true }); process.exit(0); } if (entry && entry.type !== "clip") { die(`${clipId} is a ${entry.type ?? "non-clip"} entry, not a clip`); } if (!entry) { const claim = (whole.ledger ?? []).find((e) => e.id === clipId); if (!claim) die(`no timeline entry or ledger claim with id ${clipId}`); if (!claim.video) die(`ledger claim ${clipId} has no \`video\` to fetch`); const at = Number(claim.cite); if (!Number.isFinite(at)) die(`ledger claim ${clipId} has no \`cite\` second`); entry = { id: claim.id, video: claim.video, channel: claim.channel ?? null, start: Math.max(0, at - 1), end: at + 1, note: claim.text ?? claim.claim ?? null, }; } const channelSlug = entry.channel ?? provenance.channelSlug; if (!channelSlug) { die(`${clipId} has no channel, and the manifest's provenance names none`); } const { from, to } = windowOf(entry); // WHY THESE SECONDS. Stored beside the file so a directory of windows can be // read back months later. The clip's own note is the closest thing the manifest // has to a reason; the manifest id and the clip id say the rest. const reason = entry.note ?? entry.quote ?? `clip window with ${padBefore}s before / ${padAfter}s after`; EMIT("fetch", { id: entry.id, video: entry.video, ...(wantFull ? { full: true } : { from, to }), cached: false, }); const res = await ask(`${editorUrl}/api/media/fetch-window`, { method: "POST", headers, // `full` REPLACES the span rather than joining it: the route reads // `full === true` before it validates from/to, and sending both would // describe a request the editor does not have. body: JSON.stringify({ channelSlug, videoId: entry.video, webpageUrl: entry.webpageUrl ?? undefined, ...(wantFull ? { full: true } : { from, to, pad: Math.max(padBefore, padAfter) }), ...(maxHeight !== undefined ? { maxHeight } : {}), requestedBy: "umtool", manifest: manifestId, clipId: entry.id, reason: String(reason).slice(0, 400), }), }); const body = await res.json().catch(() => ({})); // The file's height, when the editor reports it: carried on `done`, and said // out loud when it is taller than --max-height (a file already on disk is // served as it is, never re-fetched for a different cap). function heightOf(answer) { const h = Number(answer.height); if (!Number.isInteger(h) || h <= 0) return {}; if (maxHeight !== undefined && h > maxHeight) { EMIT("note", { message: ` ${path.basename(String(answer.file ?? ""))} is ${h}p, taller than --max-height ${maxHeight}`, }); } return { height: h }; } if (res.status === 200) { // Already on disk — possibly WIDER than asked for, which is the point of // containing-window reuse. `fetchStart` is the file's own start, because // every cut downstream is expressed relative to it. EMIT("fetch", { id: entry.id, video: entry.video, from: body.from, to: body.to, cached: true, reuse: path.basename(String(body.file ?? "")), }); EMIT("done", { out: body.file, fetchStart: body.from, cached: true, ...heightOf(body) }); process.exit(0); } if (res.status === 409) { // The per-platform cooldown. SAY THE SECONDS: "rate limited" with no number // is advice to keep pressing the button. const secs = Math.ceil(Number(body.cooldownMs ?? 0) / 1000); die( `the editor is in a ${body.platform ?? "platform"} rate-limit cooldown — ` + `${secs}s remaining. ${body.error ?? ""}`.trim(), ); } if (res.status !== 202) { // Everything else — an unreachable drive, a low-disk refusal, a bad token — // is passed through VERBATIM. The editor's operator is the person who can // act on it, and a paraphrase here is a sentence they cannot search for. die(`the editor refused (HTTP ${res.status}): ${body.error ?? "no reason given"}`); } const jobId = body.jobId; EMIT("note", { message: ` queued on the editor as job ${jobId}` }); const deadline = Date.now() + POLL_TIMEOUT_MS; let last = ""; for (;;) { if (Date.now() > deadline) { die( `gave up waiting for editor job ${jobId} after ${POLL_TIMEOUT_MS / 60_000} ` + `minutes (it is still running there; ask again and it will be cached)`, ); } await new Promise((r) => setTimeout(r, POLL_MS)); const poll = await ask(`${editorUrl}/api/media/fetch-window/${jobId}`, { headers }); const j = await poll.json().catch(() => ({})); if (!poll.ok) die(`polling editor job ${jobId} failed (HTTP ${poll.status}): ${j.error ?? ""}`); if (j.status !== last) { last = j.status; EMIT("note", { message: ` editor job ${jobId}: ${j.status}` }); } if (j.status === "done") { EMIT("fetch", { id: entry.id, video: entry.video, from, to, cached: true }); EMIT("done", { out: j.file ?? null, fetchStart: j.from ?? from, cached: false, ...heightOf(j) }); process.exit(0); } if (j.status === "failed" || j.status === "cancelled") { die(`editor job ${jobId} ${j.status}: ${j.error ?? "see the editor's /jobs log"}`); } }