commit f6a1108d4804a421618645629ab93b55b095e78a
parent 8f95dd0d12d06280fff461b296a995898f604786
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 00:26:25 -0400
umtool: report-to-video's yt-dlp spawns carry the platform args
build-video.mjs (clip fetch) and check-availability.mjs (--simulate)
built their own argv with no `--impersonate`, so every Rumble clip fetch
and availability check 403'd at Cloudflare (yt-dlp #17496) — the gap
release 5 slice R left outside its scope.
They are `.mjs` run by bare `node` (20 in the runtime image, no type
stripping), so they cannot import common's TS. The table moves instead:
`PLATFORM_ARGS`/`platformArgs` into `common/ytdlp/platformArgs.mjs` and
`detectPlatform` into `common/lib/detectPlatform.mjs`, plain JS with
JSDoc types, re-exported from `channelArgs.ts` / `platform.ts` so no TS
caller changes. One copy of each. common's package.json exports the two
files by exact path; report-to-video depends on common (lockfile: that
importer entry only).
The argv builders are now exported (`clipFetchArgs`, `simulateArgs`) and
tested in platform-args.test.mjs: Rumble gets the table's args, before a
retry's `extra`; YouTube gets none.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
10 files changed, 184 insertions(+), 56 deletions(-)
diff --git a/common/lib/detectPlatform.mjs b/common/lib/detectPlatform.mjs
@@ -0,0 +1,31 @@
+// detectPlatform — which platform a URL is on, by host.
+//
+// Plain JS (with JSDoc types) rather than TS for ONE reason: umtool's
+// report-to-video scripts are `.mjs` run by bare `node` (20 in the runtime
+// image — no type stripping), and they spawn yt-dlp too, so they need the same
+// answer the editor gets. `lib/platform.ts` re-exports this; every TS caller
+// keeps importing it from there. Do not grow a second host table anywhere.
+
+/** @typedef {import("./platform").Platform} Platform */
+
+/**
+ * @param {string | undefined | null} url
+ * @returns {Platform | null}
+ */
+export function detectPlatform(url) {
+ if (!url) return null;
+ try {
+ const host = new URL(url).hostname.toLowerCase();
+ if (host.endsWith("youtube.com") || host === "youtu.be") return "youtube";
+ if (host.endsWith("rumble.com")) return "rumble";
+ if (host.endsWith("odysee.com")) return "odysee";
+ if (host.endsWith("twitch.tv")) return "twitch";
+ if (host.endsWith("kick.com")) return "kick";
+ if (host === "x.com" || host.endsWith(".x.com")) return "twitter";
+ if (host.endsWith("twitter.com")) return "twitter";
+ if (host === "bsky.app" || host.endsWith(".bsky.app")) return "bluesky";
+ } catch {
+ /* fall through */
+ }
+ return null;
+}
diff --git a/common/lib/platform.ts b/common/lib/platform.ts
@@ -33,25 +33,10 @@ export function isSocialPlatform(platform: Platform | null | undefined): boolean
return platform === "twitter" || platform === "bluesky";
}
-export function detectPlatform(
- url: string | undefined | null,
-): Platform | null {
- if (!url) return null;
- try {
- const host = new URL(url).hostname.toLowerCase();
- if (host.endsWith("youtube.com") || host === "youtu.be") return "youtube";
- if (host.endsWith("rumble.com")) return "rumble";
- if (host.endsWith("odysee.com")) return "odysee";
- if (host.endsWith("twitch.tv")) return "twitch";
- if (host.endsWith("kick.com")) return "kick";
- if (host === "x.com" || host.endsWith(".x.com")) return "twitter";
- if (host.endsWith("twitter.com")) return "twitter";
- if (host === "bsky.app" || host.endsWith(".bsky.app")) return "bluesky";
- } catch {
- /* fall through */
- }
- return null;
-}
+// Host → platform. Lives in `detectPlatform.mjs` (plain JS so umtool's `.mjs`
+// scripts can import the same copy); every TS caller imports it from here.
+import { detectPlatform } from "./detectPlatform.mjs";
+export { detectPlatform };
// Best-effort canonical webpage URL for a video given its platform and
// canonical id. Used both as a metadata fallback in summarize() and to
diff --git a/common/package.json b/common/package.json
@@ -31,6 +31,8 @@
"./components/urlState": "./components/urlState.ts",
"./components/virtualizer": "./components/virtualizer.ts",
"./components/*": "./components/*.tsx",
+ "./lib/detectPlatform.mjs": "./lib/detectPlatform.mjs",
+ "./ytdlp/platformArgs.mjs": "./ytdlp/platformArgs.mjs",
"./lib/*": "./lib/*.ts",
"./controller/*": "./controller/*.ts",
"./jobs/*": "./jobs/*.ts",
diff --git a/common/ytdlp/channelArgs.ts b/common/ytdlp/channelArgs.ts
@@ -18,23 +18,10 @@ import type { ChannelConfig } from "../lib/channelConfig";
import { cookieArgs } from "../lib/cookiePolicy";
import { detectPlatform, type Platform } from "../lib/platform";
-// Args a platform needs on EVERY yt-dlp spawn, whatever the job. Code, not a
-// setting: per-channel `ytdlpExtraArgs` is the tweak surface, and it comes
-// AFTER these, so a channel override wins (yt-dlp is last-flag-wins for
-// `--sleep-requests` and `--impersonate`).
-//
-// rumble: every request 403s at Cloudflare without a browser TLS fingerprint
-// (yt-dlp #17496); a probe on 2026-09-24 got 200 with `--impersonate chrome`
-// and 403 without. `--sleep-requests 1` paces the listing walk — the
-// the-quartering-rumble full sweep 429'd at page 155 unpaced the same day.
-export const PLATFORM_ARGS: Partial<Record<Platform, readonly string[]>> = {
- rumble: ["--impersonate", "chrome", "--sleep-requests", "1"],
-};
-
-export function platformArgs(platform: Platform | null | undefined): string[] {
- if (!platform) return [];
- return [...(PLATFORM_ARGS[platform] ?? [])];
-}
+// The platform args table lives in `platformArgs.mjs` (plain JS so umtool's
+// `.mjs` scripts can import the same copy); re-exported here for TS callers.
+import { platformArgs } from "./platformArgs.mjs";
+export { PLATFORM_ARGS, platformArgs, platformArgsForUrl } from "./platformArgs.mjs";
export function channelPlatform(
config: Pick<ChannelConfig, "platform" | "url">,
diff --git a/common/ytdlp/platformArgs.mjs b/common/ytdlp/platformArgs.mjs
@@ -0,0 +1,44 @@
+// The per-platform yt-dlp args table — THE one copy.
+//
+// Plain JS (with JSDoc types) so umtool's report-to-video scripts, which are
+// `.mjs` run by bare `node` (20 in the runtime image — no type stripping), can
+// import it: they spawn yt-dlp for clip fetches and availability checks and hit
+// the same Cloudflare wall. `ytdlp/channelArgs.ts` re-exports it for every TS
+// caller. Never copy these args into another file.
+
+import { detectPlatform } from "../lib/detectPlatform.mjs";
+
+/** @typedef {import("../lib/platform").Platform} Platform */
+
+// Args a platform needs on EVERY yt-dlp spawn, whatever the job. Code, not a
+// setting: per-channel `ytdlpExtraArgs` is the tweak surface, and it comes
+// AFTER these, so a channel override wins (yt-dlp is last-flag-wins for
+// `--sleep-requests` and `--impersonate`).
+//
+// rumble: every request 403s at Cloudflare without a browser TLS fingerprint
+// (yt-dlp #17496); a probe on 2026-09-24 got 200 with `--impersonate chrome`
+// and 403 without. `--sleep-requests 1` paces the listing walk — the
+// the-quartering-rumble full sweep 429'd at page 155 unpaced the same day.
+/** @type {Readonly<Partial<Record<Platform, readonly string[]>>>} */
+export const PLATFORM_ARGS = Object.freeze({
+ rumble: Object.freeze(["--impersonate", "chrome", "--sleep-requests", "1"]),
+});
+
+/**
+ * @param {Platform | null | undefined} platform
+ * @returns {string[]}
+ */
+export function platformArgs(platform) {
+ if (!platform) return [];
+ return [...(PLATFORM_ARGS[platform] ?? [])];
+}
+
+/**
+ * The args for whatever platform `url` is on — for a spawn that has a URL and
+ * no channel config (umtool's clip fetch and availability check).
+ * @param {string | undefined | null} url
+ * @returns {string[]}
+ */
+export function platformArgsForUrl(url) {
+ return platformArgs(detectPlatform(url));
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -360,7 +360,11 @@ importers:
specifier: ^5.9.3
version: 5.9.3
- umtool/report-to-video: {}
+ umtool/report-to-video:
+ dependencies:
+ yt-dlp-transcript-common:
+ specifier: workspace:*
+ version: link:../../common
packages:
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -63,6 +63,9 @@ import {
cardWidth, contentWidth, reservedFooterHeight,
} from "./render-cards.mjs";
import { createCueSource, siteOriginFromManifest } from "./cues.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";
// The header line, the clock in it and the title cleaner live in one module
// the clip bench imports too -- a preview that shows a line this renderer
// would never draw is worse than no preview.
@@ -351,6 +354,32 @@ export async function findContainingWindow(rawDir, video, from, to) {
return tightestContaining(await cachedWindowsFor(rawDir, video), from, to);
}
+// The yt-dlp argv for one clip window. The platform's fixed args come from
+// common's table (`platformArgsForUrl`) -- the same copy every editor spawn
+// uses, so a Rumble fetch carries `--impersonate chrome` here too instead of
+// 403ing at Cloudflare. They go BEFORE `extra`, so a retry's own flags win.
+export function clipFetchArgs({ url, from, to, fmt, dest, extra = [] }) {
+ return [
+ // The operator's own yt-dlp config redirects output and attaches thumbnail
+ // and metadata post-processors; without this the clips land elsewhere.
+ "--ignore-config",
+ "--no-playlist",
+ "--download-sections", `*${from.toFixed(2)}-${to.toFixed(2)}`,
+ // Without this the cut snaps to the nearest preceding keyframe, which can be
+ // seconds early — fine for scrubbing, not fine when the clip IS the citation.
+ "--force-keyframes-at-cuts",
+ ...platformArgsForUrl(url),
+ ...extra,
+ // Pin H.264/AAC in mp4. Left alone yt-dlp picks VP9+Opus at these heights,
+ // and since --force-keyframes-at-cuts re-encodes, that means libvpx-vp9 —
+ // 27s to cut a 5s clip. It also writes .webm and appends that to -o.
+ "-f", fmt,
+ "--merge-output-format", "mp4",
+ "-o", dest,
+ "--", url,
+ ];
+}
+
/**
* WHERE A CLIP IS CUT OUT OF A CACHED WINDOW, as ffmpeg input arguments.
*
@@ -415,24 +444,8 @@ async function fetchClip(entry, meta, render, rawDir, opts) {
`b[height<=${maxH}]`,
].join("/");
- const argsWith = (extra) => [
- // The operator's own yt-dlp config redirects output and attaches thumbnail
- // and metadata post-processors; without this the clips land elsewhere.
- "--ignore-config",
- "--no-playlist",
- "--download-sections", `*${from.toFixed(2)}-${to.toFixed(2)}`,
- // Without this the cut snaps to the nearest preceding keyframe, which can be
- // seconds early — fine for scrubbing, not fine when the clip IS the citation.
- "--force-keyframes-at-cuts",
- ...extra,
- // Pin H.264/AAC in mp4. Left alone yt-dlp picks VP9+Opus at these heights,
- // and since --force-keyframes-at-cuts re-encodes, that means libvpx-vp9 —
- // 27s to cut a 5s clip. It also writes .webm and appends that to -o.
- "-f", fmt,
- "--merge-output-format", "mp4",
- "-o", dest,
- "--", meta.webpageUrl,
- ];
+ const argsWith = (extra) =>
+ clipFetchArgs({ url: meta.webpageUrl, from, to, fmt, dest, extra });
const attempt = async (extra) => {
try {
diff --git a/umtool/report-to-video/check-availability.mjs b/umtool/report-to-video/check-availability.mjs
@@ -26,6 +26,9 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { DEFAULT_CHANNELS_DIR } from "./cues.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);
@@ -50,6 +53,17 @@ function classify(stderr) {
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"));
@@ -118,7 +132,7 @@ export async function checkAvailability(manifestPath, { outDir, maxAgeDays = 0 }
try {
await execFileP(
YTDLP,
- ["--ignore-config", "--no-playlist", "--simulate", "--quiet", "--no-warnings", "--", meta.webpageUrl],
+ simulateArgs(meta.webpageUrl),
{ maxBuffer: 1 << 24 },
);
sources.push({
diff --git a/umtool/report-to-video/package.json b/umtool/report-to-video/package.json
@@ -4,6 +4,9 @@
"private": true,
"type": "module",
"description": "Turn a cited sweep report into a narrated-by-text video.",
+ "dependencies": {
+ "yt-dlp-transcript-common": "workspace:*"
+ },
"bin": {
"report-build-video": "./build-video.mjs",
"report-resolve-windows": "./resolve-windows.mjs",
diff --git a/umtool/report-to-video/platform-args.test.mjs b/umtool/report-to-video/platform-args.test.mjs
@@ -0,0 +1,45 @@
+// Tests that umtool's two yt-dlp spawns carry the platform's fixed args from
+// common's ONE table — a Rumble clip fetch or availability check without
+// `--impersonate chrome` 403s at Cloudflare (yt-dlp #17496).
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { PLATFORM_ARGS } from "yt-dlp-transcript-common/ytdlp/platformArgs.mjs";
+import { clipFetchArgs } from "./build-video.mjs";
+import { simulateArgs } from "./check-availability.mjs";
+
+const RUMBLE = "https://rumble.com/v7e07us-some-title.html";
+const YOUTUBE = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
+
+/** The args between two markers, so the assertion names the position too. */
+const slice = (argv, from, to) => argv.slice(argv.indexOf(from) + 1, argv.indexOf(to));
+
+test("a Rumble clip fetch carries the rumble platform args, before the retry's extra", () => {
+ const argv = clipFetchArgs({
+ url: RUMBLE, from: 12, to: 20.5, fmt: "b", dest: "/x/out.mp4",
+ extra: ["--downloader-args", "ffmpeg_i:-extension_picky 0"],
+ });
+ assert.deepEqual(
+ slice(argv, "--force-keyframes-at-cuts", "-f"),
+ [...PLATFORM_ARGS.rumble, "--downloader-args", "ffmpeg_i:-extension_picky 0"],
+ );
+ assert.ok(argv.includes("--impersonate"));
+ assert.deepEqual(argv.slice(-2), ["--", RUMBLE]);
+ assert.equal(argv[argv.indexOf("--download-sections") + 1], "*12.00-20.50");
+});
+
+test("a YouTube clip fetch gets no platform args", () => {
+ const argv = clipFetchArgs({ url: YOUTUBE, from: 1, to: 2, fmt: "b", dest: "/x/out.mp4" });
+ assert.deepEqual(slice(argv, "--force-keyframes-at-cuts", "-f"), []);
+ assert.ok(!argv.includes("--impersonate"));
+});
+
+test("the availability simulate carries the platform args for Rumble only", () => {
+ const rumble = simulateArgs(RUMBLE);
+ assert.deepEqual(slice(rumble, "--no-warnings", "--"), [...PLATFORM_ARGS.rumble]);
+ assert.deepEqual(rumble.slice(-2), ["--", RUMBLE]);
+ assert.ok(rumble.includes("--simulate"));
+ assert.ok(!simulateArgs(YOUTUBE).includes("--impersonate"));
+});