#!/usr/bin/env node // E2E fake yt-dlp. Three modes are recognised by argument shape: // 1. --flat-playlist --skip-download --print url // -> emit 5 deterministic URLs to stdout // 2. -a playlist.tofetch ... // -> for each URL, write data//{metadata.info.json,transcript.en.vtt}, // append to ./archive // 3. --lazy-playlist ... // -> fetch a single deterministic "newest" entry // Per-handling args (--write-auto-subs vs -f bestaudio / -x) are accepted // but ignored — both produce a transcript.en.vtt + metadata.info.json so the // rest of the editor's controllers stay happy. // // Cwd is always the channel root (matches the editor's runYtdlp). import { mkdir, writeFile, readFile, appendFile, stat, rename } from "node:fs/promises"; import { existsSync, openSync, writeSync, closeSync } from "node:fs"; import path from "node:path"; import { installFixtureWatchdog } from "./_watchdog.mjs"; // Never outlive the run that spawned us — see _watchdog.mjs. installFixtureWatchdog(); // Used in audio-check scenarios. The fake-ffmpeg companion treats files // containing this string as malformed when probing. const CORRUPT_MARKER = "__CORRUPT__"; // When the `slowProbe` sidecar option is set, chunk 0 carries this marker so // the fake-ffmpeg probe sleeps. Combined with the per-chunk timestamp log // below, the pause test can prove the download is frozen while the probe runs. const SLOW_PROBE_MARKER = "__SLOWPROBE__"; // Tests use small chunks so the size gate (defaults to 1 MiB; tests override // via E2E_AUDIO_CHECK_SIZE_GATE) can be exceeded each chunk. ~16 KiB. const CHUNK_BYTES = 16 * 1024; const argv = process.argv.slice(2); function has(flag) { return argv.includes(flag); } function arg(flag) { const i = argv.indexOf(flag); if (i < 0) return undefined; return argv[i + 1]; } function lastNonFlag() { for (let i = argv.length - 1; i >= 0; i--) { if (!argv[i].startsWith("-")) return argv[i]; } return undefined; } // The `cookiegated` sentinel simulates an age-gated video: any metadata/ // download invocation for it fails with yt-dlp's age-gate error UNLESS // --cookies-from-browser was passed. (Deliberately distinct from the // `needsauth` sentinel, which only affects --dump-json probes and still // downloads fine — retry-bucket.spec depends on that.) function cookieArg() { return arg("--cookies-from-browser") ?? ""; } function cookieGateBlocked(url) { const lower = (url ?? "").toLowerCase(); return lower.includes("cookiegated") && !cookieArg(); } function failCookieGate(url) { process.stderr.write( `ERROR: [youtube] ${url}: Sign in to confirm your age. This video may be inappropriate for some users.\n`, ); process.exit(1); } function urlIdYouTube(u) { try { const parsed = new URL(u); const v = parsed.searchParams.get("v"); if (v) return v; return parsed.pathname.split("/").filter(Boolean).pop(); } catch { return u; } } async function ensureDir(p) { await mkdir(p, { recursive: true }); } async function writeMetadata(videoDir, id, opts = {}) { const meta = { id, title: `Synthetic ${id}`, channel: "Fake Channel", channel_id: "UCfake", channel_url: "https://www.youtube.com/channel/UCfake", uploader: "Fake Channel", upload_date: "20240101", // Default a short clip; the `longvideo` sentinel makes it long enough // (>=600s) to exercise the download-time duration guard. duration: opts.longDuration ? 6000 : 60, description: `Synthetic video ${id}`, is_live: false, was_live: false, live_status: "not_live", age_limit: 0, // The `odyseevid` sentinel makes the metadata advertise the lbry extractor // so platformFromMetadata() resolves "odysee" (and the "auto" download // format picks `original`). Otherwise it's a normal YouTube video. extractor_key: opts.odysee ? "lbry" : "Youtube", webpage_url: opts.odysee ? `https://odysee.com/${id}` : `https://www.youtube.com/watch?v=${id}`, }; // A bitchute.com URL: what yt-dlp's BitChute extractor writes — its key, the // video page, and the one mp4 on a media host. if (opts.bitchute) { const file = `https://seed901.bitchute.com/FakeHash0001/${id}.mp4`; meta.extractor = "BitChute"; meta.extractor_key = "BitChute"; meta.webpage_url = `https://www.bitchute.com/video/${id}/`; meta.channel_url = "https://www.bitchute.com/channel/fakechannel/"; meta.formats = [{ format_id: "0", ext: "mp4", url: file }]; meta.url = file; } // For the no-subs-fallback tests we need yt-dlp's metadata to reflect // whether the video advertises caption tracks. The default (no opts) // omits both keys so the rest of the existing suite keeps its behavior. if (opts.livechatOnly) { meta.subtitles = { live_chat: [{ ext: "json", url: "fake://chat" }] }; meta.automatic_captions = {}; } else if (opts.noCaptions) { meta.subtitles = {}; meta.automatic_captions = {}; } // Live-status sentinels for the skip-live filter tests. is_live = streaming // now, is_upcoming = scheduled, was_live = finished VOD (downloads normally). if (opts.isLive) { meta.is_live = true; meta.live_status = "is_live"; } else if (opts.isUpcoming) { meta.live_status = "is_upcoming"; } else if (opts.wasLive) { meta.was_live = true; meta.live_status = "was_live"; } Object.assign(meta, await readMetadataOverrides()); await writeFile( path.join(videoDir, "metadata.info.json"), JSON.stringify(meta), ); } // THE SOURCE CHANGED ITS METADATA. A spec that needs the second fetch of a // video to differ from the first (the metadata history, release 10 slice N) // writes `.fake-ytdlp-metadata.json` in the channel root (the fake's cwd): a // JSON object whose keys are laid over every info json this fake writes from // then on — `{ "title": "Renamed", "view_count": 150 }`. Absent (every other // spec), nothing changes and the output stays byte-stable. async function readMetadataOverrides() { try { const parsed = JSON.parse(await readFile(".fake-ytdlp-metadata.json", "utf8")); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; } catch { return {}; } } // Sentinels encoded in a video id/URL select metadata variants so a fixture // can deterministically exercise each filter/fallback branch. function urlSentinels(url) { const lower = (url ?? "").toLowerCase(); return { livechatOnly: lower.includes("livechatonly"), noCaptions: lower.includes("nocaptions"), isLive: lower.includes("islive"), isUpcoming: lower.includes("isupcoming"), // `livevid` is the download-filter fixture's livestream: a FINISHED VOD, so // it downloads normally and skip-live leaves it alone — which is what makes // it a clean test of `includeLivestreams`. wasLive: lower.includes("waslive") || lower.includes("livevid"), longDuration: lower.includes("longvideo"), odysee: lower.includes("odyseevid"), bitchute: /^https?:\/\/([a-z0-9-]+\.)?bitchute\.com\//.test(lower), }; } async function writeTranscript(videoDir) { const vtt = `WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nFake transcript line one.\n\n00:00:05.000 --> 00:00:10.000\nFake transcript line two.\n`; await writeFile(path.join(videoDir, "transcript.en.vtt"), vtt); } async function downloadOne(url, opts = {}) { const id = urlIdYouTube(url); if (!id) return false; const videoDir = path.join("data", id); const audioFmt = opts.audioFormat; // For audio-extraction invocations a single-URL re-download to fill in a // missing audio. is legitimate even if metadata is already on disk. const alreadyHasMetadata = existsSync( path.join(videoDir, "metadata.info.json"), ); if (alreadyHasMetadata && !audioFmt) { process.stdout.write(`[download] ${id} already in archive — skipping\n`); return false; } await ensureDir(videoDir); process.stdout.write(`[download] Fetching ${id}\n`); // Videos whose id contains the SLOWOP marker emit yt-dlp-style progress // lines with real delays so the per-operation progress bar (and drain) can // be observed on the Active Jobs screen. Unmarked ids stay instant. if (id.toLowerCase().includes("slowop")) { const fragTotal = 6; for (let f = 1; f <= fragTotal; f++) { const pct = ((f / fragTotal) * 100).toFixed(1); process.stdout.write( `[download] ${pct}% of ~ 10.00MiB at 1.50MiB/s ETA 00:00:0${Math.max(0, fragTotal - f)} (frag ${f}/${fragTotal})\n`, ); await sleep(500); } } if (!alreadyHasMetadata) await writeMetadata(videoDir, id, urlSentinels(url)); if (audioFmt) { // The `truncaud` sentinel simulates a source-truncated download: embed a // short-duration marker the fake-ffprobe reports, so the duration guard // trips even though the "download" completed. Unmarked audio reports a long // duration (no marker), so it passes the guard. const durMarker = id.toLowerCase().includes("truncaud") ? " __DUR=120__" : ""; await writeFile( path.join(videoDir, `audio.${audioFmt}`), `fake-ytdlp synthesised audio for ${id}${durMarker}\n`, ); } else { await writeTranscript(videoDir); } await appendFile("archive", `youtube ${id}\n`); process.stdout.write(`[download] ${id} done\n`); return true; } async function modeStorePlaylist() { // The emitted id set is configurable via a cwd sidecar so the quick // availability check (flat-playlist diff) can omit specific known videos to // exercise the maybe-missing flow. Sidecar shape: { ids: ["..."] } — each id // becomes a watch?v= URL. Defaults to the historical 5 fake ids. let ids = ["fake00000001", "fake00000002", "fake00000003", "fake00000004", "fake00000005"]; try { const raw = await readFile(".fake-ytdlp-flat-playlist.json", "utf8"); const parsed = JSON.parse(raw); if (Array.isArray(parsed?.ids)) { ids = parsed.ids.filter((x) => typeof x === "string"); } } catch { // no sidecar — use the default set } for (const id of ids) { process.stdout.write(`https://www.youtube.com/watch?v=${id}\n`); } } async function modeDownloadFromFile(file, opts = {}) { const text = await readFile(file, "utf8"); const urls = text.split("\n").map((s) => s.trim()).filter(Boolean); process.stdout.write(`[fake-ytdlp] -a ${file} (${urls.length} urls)\n`); // Mark that yt-dlp was actually invoked so a test can assert prefilter // skipped this step when the playlist was empty. await appendFile("fake-ytdlp.invocations", `download:${urls.length}\n`); for (const url of urls) await downloadOne(url, opts); process.stdout.write(`[fake-ytdlp] download complete\n`); } async function modeDownloadOneUrl(url, opts) { process.stdout.write(`[fake-ytdlp] single-url download ${url}\n`); await appendFile( "fake-ytdlp.invocations", `download-one:${url} cookies=${cookieArg()}\n`, ); if (cookieGateBlocked(url)) failCookieGate(url); // Record the resolved -f selector so format-selection tests can assert it // (e.g. Odysee "auto" -> original/bestaudio/worst). await appendFile("fake-ytdlp.invocations", `format:${arg("-f") ?? ""}\n`); await downloadOne(url, opts); process.stdout.write(`[fake-ytdlp] download complete\n`); } // Audio-check mode: yt-dlp args drop -x but keep -f bestaudio/worst. We // simulate a chunked HTTP download by writing audio.mp4.part in pieces with // short sleeps between, optionally inserting CORRUPT_MARKER. Supports // SIGSTOP/SIGCONT (the kernel handles it for us; we just need to ensure // signal handlers don't block) and -c resume by appending to an existing // .part. Re-invocations log to fake-ytdlp.invocations so tests can count // restarts/rollbacks. async function readAudioCheckSidecar() { // Tests configure the fake's per-run behaviour via this file in cwd // (the channel root). Falls back to env vars for ad-hoc use. try { const raw = await readFile(".fake-ytdlp-audio-check.json", "utf8"); return JSON.parse(raw); } catch { return {}; } } async function modeAudioCheckOneUrl(url) { const sidecar = await readAudioCheckSidecar(); const mode = sidecar.mode ?? process.env.E2E_FAKE_YTDLP_AUDIO_CHECK_MODE ?? "happy"; const recoverOnResume = sidecar.recoverOnResume === true || process.env.E2E_FAKE_YTDLP_RECOVER_ON_RESUME === "1"; // Generalizes recoverOnResume (= corruptRuns:1): serve corruption on the // first N audio-check invocations, then behave. Lets a test drive several // successive mid-stream rollbacks (e.g. to observe the adaptive probe // interval shrinking) before the source recovers. null = unset. const corruptRuns = sidecar.corruptRuns != null ? Number.parseInt(String(sidecar.corruptRuns), 10) : process.env.E2E_FAKE_YTDLP_CORRUPT_RUNS != null ? Number.parseInt(process.env.E2E_FAKE_YTDLP_CORRUPT_RUNS, 10) : null; const deterministicCorrupt = sidecar.deterministicCorrupt === true || process.env.E2E_FAKE_YTDLP_DETERMINISTIC_CORRUPT === "1"; const totalChunks = Number.parseInt( String( sidecar.totalChunks ?? process.env.E2E_FAKE_YTDLP_TOTAL_CHUNKS ?? "6", ), 10, ); const chunkDelayMs = Number.parseInt( String( sidecar.chunkDelayMs ?? process.env.E2E_FAKE_YTDLP_CHUNK_DELAY_MS ?? "200", ), 10, ); // Extension yt-dlp would pick for the downloaded stream. Defaults to mp4 // (Odysee "original" video). Tests can set this to "mp3" to simulate the // bestaudio-already-in-target-format case. const partExtension = String(sidecar.partExtension ?? "mp4"); // Pause test: embed the slow-probe marker in chunk 0 and record each chunk // write's wall-clock time so the test can detect the probe-induced freeze. const slowProbe = sidecar.slowProbe === true; const id = urlIdYouTube(url) ?? "ackid"; const videoDir = path.join("data", id); await ensureDir(videoDir); // Minimal metadata so downstream code doesn't choke if it inspects it. if (!existsSync(path.join(videoDir, "metadata.info.json"))) { await writeMetadata(videoDir, id); } const partFile = path.join(videoDir, `audio.${partExtension}.part`); const finalFile = path.join(videoDir, `audio.${partExtension}`); const isResume = existsSync(partFile); // Count prior audio-check invocations so the fake can simulate "transient // CDN hiccups" — first run emits the marker, subsequent runs don't even // if .part was deleted on rollback (no-baseline-restart scenario). let priorAudioCheckRuns = 0; try { const invs = await readFile("fake-ytdlp.invocations", "utf8"); priorAudioCheckRuns = invs.split("\n").filter((l) => l.startsWith("audio-check:")).length; } catch {} await appendFile( "fake-ytdlp.invocations", `audio-check:${mode}:resume=${isResume ? 1 : 0}:url=${url}\n`, ); // Resume semantics: when `recoverOnResume` is set, ANY re-invocation // (whether resuming via .part or restarting from 0) skips the marker. // `deterministicCorrupt` forces the marker every run regardless. const shouldEmitMarker = (() => { if (mode === "happy") return false; if (deterministicCorrupt) return true; if (corruptRuns != null) return priorAudioCheckRuns < corruptRuns; if (recoverOnResume && priorAudioCheckRuns > 0) return false; return true; })(); // Open .part for append (resume) or create. const fd = openSync(partFile, isResume ? "a" : "w"); // Append-only log of chunk write times (pause test only). const chunksLogFd = slowProbe ? openSync("fake-ytdlp.chunks", "a") : null; // Resume support for SIGCONT logging — install a no-op handler so that // SIGCONT delivery flips a flag that we log on each chunk. process.on("SIGCONT", () => { try { // Log to a sidecar so tests can prove SIGCONT landed. const f = openSync("fake-ytdlp.signals", "a"); writeSync(f, `resumed ${new Date().toISOString()}\n`); closeSync(f); } catch {} }); const corruptAfter = mode === "corrupt-after" ? Number.parseInt( String( sidecar.corruptAfterChunk ?? process.env.E2E_FAKE_YTDLP_CORRUPT_AFTER_CHUNK ?? "2", ), 10, ) : -1; // Determine starting chunk by current size. let writtenBytes = isResume ? (await stat(partFile)).size : 0; // Each chunk is CHUNK_BYTES of filler. Compute already-written chunks. const alreadyChunks = Math.floor(writtenBytes / CHUNK_BYTES); for (let i = alreadyChunks; i < totalChunks; i++) { if (chunkDelayMs > 0) { await sleep(chunkDelayMs); } let chunk = `chunk-${i} `.padEnd(CHUNK_BYTES, "x"); // Stream-corrupt: marker landing at chunk corruptAfter (so checkpoints // before that pass clean, the one after fails). Only for non-"final" // modes. if ( shouldEmitMarker && (mode === "corrupt-from-start" || (mode === "corrupt-after" && i === corruptAfter)) ) { chunk = `${CORRUPT_MARKER} ${chunk}`.padEnd(CHUNK_BYTES, "x"); } if (slowProbe && i === 0) { chunk = `${SLOW_PROBE_MARKER} ${chunk}`.padEnd(CHUNK_BYTES, "x"); } writeSync(fd, chunk); if (chunksLogFd != null) { // Record wall-clock at each chunk write so the pause test can measure // the gap that opens while yt-dlp is held SIGSTOPped during a probe. writeSync(chunksLogFd, `${i} ${Date.now()}\n`); } } closeSync(fd); if (chunksLogFd != null) closeSync(chunksLogFd); // Final-corrupt: appended marker only into the renamed file. if (mode === "corrupt-final" && shouldEmitMarker) { await rename(partFile, finalFile); await appendFile(finalFile, CORRUPT_MARKER); } else { await rename(partFile, finalFile); } // Print archive line in the shape downloadOneManaged expects. process.stdout.write(`DLOM_ARCHIVE youtube ${id}\n`); process.stdout.write(`[fake-ytdlp] audio-check download complete\n`); } async function modeSync(url) { process.stdout.write(`[fake-ytdlp] --lazy-playlist sync against ${url}\n`); await appendFile("fake-ytdlp.invocations", "sync:1\n"); // Always emit one "newest" entry the first time it's called per channel. await downloadOne("https://www.youtube.com/watch?v=fakeSync0001"); } // Managed YouTube-handling single-URL download (one per video, as // downloadOneManaged invokes per URL): write metadata + transcript for the // URL's id and print the DLOM_ARCHIVE line. The archive itself is appended // by downloadOneManaged based on the printed line, so the fake must NOT // write to ./archive. async function modeYoutubeSingleUrlManaged(url) { const id = urlIdYouTube(url); if (!id) { process.stderr.write(`[fake-ytdlp] could not derive id from ${url}\n`); process.exit(2); } const videoDir = path.join("data", id); await ensureDir(videoDir); await appendFile( "fake-ytdlp.invocations", `youtube-single:${url} cookies=${cookieArg()}\n`, ); if (cookieGateBlocked(url)) failCookieGate(url); // `dl429`: the 2026-09-24 / 2026-10-01 YouTube shape — the metadata // prefetch succeeded (the prefetch branch has no such sentinel), then the // real download's SUBTITLE fetch answers 429. Per video, not IP-wide. Like // the real yt-dlp: under --ignore-errors (which the managed primary passes // since release 17, slice RL) the failure is a WARNING, nothing is written // and the exit is clean; without it, an ERROR and exit 1. availability.ts // classifies either as `subs_rate_limit`: the downloader fetches the media // and the runner defers only the subtitles (rate-limit.spec.ts). if (url.toLowerCase().includes("dl429")) { const line = `[youtube] ${url}: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests`; if (has("--ignore-errors")) { process.stderr.write(`WARNING: ${line}\n`); const id429 = urlIdYouTube(url); if (!existsSync(path.join(videoDir, "metadata.info.json"))) { await writeMetadata(videoDir, id429, urlSentinels(url)); } process.stdout.write(`DLOM_ARCHIVE youtube ${id429}\n`); return; } process.stderr.write(`ERROR: ${line}\n`); process.exit(1); } process.stdout.write(`[fake-ytdlp] managed single-URL ${id}\n`); // URL sentinels for no-subs-fallback tests. The sentinels live in the // video id itself so they round-trip through urlIdYouTube and the // managed downloader's URL re-extraction. const lower = url.toLowerCase(); const livechatOnly = lower.includes("livechatonly"); const noCaptions = lower.includes("nocaptions"); if (!existsSync(path.join(videoDir, "metadata.info.json"))) { await writeMetadata(videoDir, id, { livechatOnly, noCaptions }); } if (livechatOnly) { // yt-dlp writes live_chat as JSON-lines (one continuation per line). // Content shape doesn't matter for these tests; just produce a file. await writeFile( path.join(videoDir, "transcript.live_chat.json"), '{"clientId":"fake","action":{"addChatItemAction":{}}}\n', ); } else if (noCaptions) { // Simulate "metadata-only" success — no transcript file at all. } else { await writeTranscript(videoDir); } process.stdout.write(`DLOM_ARCHIVE youtube ${id}\n`); process.stdout.write(`[fake-ytdlp] single-url fetched ${id}\n`); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function main() { // Test hook for cancellation tests — sleep before doing any work so a // Cancel click during the sleep can cleanly SIGTERM us. if (has("--test-slow")) { // Stderr because store-playlist mode buffers stdout for the URL list. process.stderr.write(`[fake-ytdlp] sleeping (test-slow)\n`); await sleep(30_000); } // Emit a friendly message and exit 101 so the editor's exit-code handling // can be tested against the "nothing new to download" case. if (has("--test-break-on-existing")) { process.stderr.write(`[fake-ytdlp] all entries already in archive\n`); process.exit(101); } // CLIP WINDOW (fetchWindowManaged): --download-sections *- with an // absolute -o. Branched FIRST because nothing else passes --download-sections // and because its -o is a dotfile .part under data//clips/, which no // other mode's shape test would recognise. // // It writes ONLY that file: the whole point of the feature is that a window // fetch leaves no metadata.info.json behind (that would put an undownloaded // video into the index), and a fake that wrote one would make the spec that // pins the rule pass for the wrong reason. if (has("--download-sections") && arg("-o")) { const dest = arg("-o"); const url = lastNonFlag() ?? ""; await appendFile( "fake-ytdlp.invocations", `download-sections:${arg("--download-sections")} dest=${dest} ` + `fmt=${arg("-f") ?? ""} cookies=${cookieArg()} ` + // THE WHOLE ARGV, in order. A spec has to be able to assert that the // sidecar refusals come AFTER a channel's own ytdlpExtraArgs, because // yt-dlp takes the last occurrence of an option and that ordering is // the entire guard. `argv=${argv.join(" ")}\n`, ); if (cookieGateBlocked(url)) failCookieGate(url); if (url.toLowerCase().includes("ratelimit")) { process.stderr.write( `ERROR: [youtube] ${url}: HTTP Error 429: Too Many Requests\n`, ); process.exit(1); } // `win403`: Rumble's Cloudflare / googlevideo refusing the media request — // a `network` failure, which the fetch-windows batch counts toward its // two-in-a-row backoff. if (url.toLowerCase().includes("win403")) { process.stderr.write(`ERROR: [download] Got error: HTTP Error 403: Forbidden\n`); process.exit(1); } await ensureDir(path.dirname(dest)); // Deterministic bytes, one chunk's worth, so a size assertion is stable. await writeFile(dest, Buffer.alloc(CHUNK_BYTES, "w")); process.stdout.write(`[download] section ${arg("--download-sections")}\n`); process.stdout.write(`[fake-ytdlp] window complete\n`); return; } if ( has("--flat-playlist") && has("--skip-download") && has("--print") && arg("--print") === "url" ) { // Every enumeration records its WHOLE argv, in order, so a spec can assert // the platform args (a rumble.com URL gets `--impersonate chrome // --sleep-requests 1`) and their position before the channel's own. await appendFile( "fake-ytdlp.invocations", `flat-playlist:${has("-I") ? "paged" : "full"} argv=${argv.join(" ")}\n`, ); // `sweep429`: a FULL enumeration (no -I range) prints three pages of five // urls, logging each page the way yt-dlp's RumbleChannel extractor does, // then hits HTTP 429 and exits 1 — the 2026-09-24 the-quartering-rumble // sweep in miniature. A paged walk (-I) of the same channel lists normally. if ((lastNonFlag() ?? "").toLowerCase().includes("sweep429") && !has("-I")) { for (let page = 1; page <= 3; page++) { process.stderr.write(`[RumbleChannel] sweep429: Downloading page ${page}\n`); for (let i = 1; i <= 5; i++) { process.stdout.write( `https://rumble.com/vsweep${page}${i}-listed.html\n`, ); } } process.stderr.write( "ERROR: sweep429: Unable to download webpage: HTTP Error 429: Too Many Requests (caused by )\n", ); process.exit(1); } await modeStorePlaylist(); return; } // Metadata probe for the URL-first new-channel flow: // --flat-playlist --skip-download --playlist-items 1 --print