From 15d52de9131c835ded9ca05cb0bda0d8734e965a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 16:08:11 +0000 Subject: [PATCH] =?UTF-8?q?0.13.0:=20dl=20=E2=80=94=20download=20a=20video?= =?UTF-8?q?,=20or=20just=20its=20audio,=20through=20yt-dlp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thin front for yt-dlp in the same shape `vid` is a thin front for ffmpeg: the handful of things worth not remembering the flags for, out of the way for everything else. Argv construction and output parsing live in src/download.ts so they can be reasoned about without a network; bin/dl.ts spawns, inheriting stdio because yt-dlp's progress line is the whole user interface of a long transfer and a captured one arrives all at once, after the wait it explained. Two defaults are decisions rather than plumbing: `--no-playlist`. A YouTube link copied from the browser while a mix is playing carries `list=`, and yt-dlp reads that as "download all of it" — the difference between one file and two hundred, on a command whose entire input is a pasted URL. The whole list is something you ask for. ffmpeg-awareness in the format selector. Above about 720p the picture and the sound arrive separately and have to be muxed, so on a box without ffmpeg the `bv*+ba` alternatives are not a lower-quality option but a wasted download: yt-dlp fetches both halves and only then finds it cannot merge them. Without ffmpeg `dl` restricts itself to single-stream formats and warns that it did. `dl audio` is `-x`, which *is* ffmpeg, so there it is a hard requirement with nothing to downgrade to. findBinary grew a probe flag. It ran every candidate with `-version`, which ImageMagick and ffmpeg both answer 0 — but yt-dlp's parser reads that single dash as seven combined short options and exits non-zero, so probing it the same way would have reported an installed binary missing. youtube-dl is accepted as a fallback name, second, because it still exists on plenty of boxes and is years behind on everything but a plain YouTube URL. The verb is optional (`dl ` is the common case), which is only unambiguous because a URL never collides with `audio`/`info`/`formats`. A first argument that is neither is a typo worth naming rather than a hostname to hand to yt-dlp. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LkEHoAhsoDhqVeJ1MJH9yD --- README.md | 38 +++++++- bin/dl.ts | 168 ++++++++++++++++++++++++++++++++++ package.json | 2 +- src/download.ts | 205 ++++++++++++++++++++++++++++++++++++++++++ src/media.ts | 18 ++-- src/registry.ts | 1 + test/download.test.ts | 201 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 625 insertions(+), 8 deletions(-) create mode 100755 bin/dl.ts create mode 100644 src/download.ts create mode 100644 test/download.test.ts diff --git a/README.md b/README.md index 45d5ded..05c087f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ TypeScript, installed as executables on `PATH`. | [`img`](#img) | Resize, convert and inspect images, with sharp or ImageMagick | | [`favicon`](#favicon) | Every icon a site links, rendered from one SVG | | [`vid`](#vid) | Inspect, thumbnail, clip and shrink video, through ffmpeg | +| [`dl`](#dl) | Download a video, or just its audio, through yt-dlp | | [`codeburn`](#codeburn) | See where your AI spend goes, by task, tool, model and project | | [`shorten`](#shorten) | Mint a short link on the pit, and follow it from `/f/` | @@ -37,8 +38,12 @@ One thing here is not a `PATH` command and does not need Node: - **`dig`** at `/usr/bin/dig` — `domainjson` only - **[OpenRDAP](https://github.com/openrdap/rdap)** (`rdap` on `PATH`, or `~/go/bin/rdap`) — `domainjson` only, and it degrades to DNS-only without it -- **`ffmpeg`** — `vid` only, and it is a hard requirement rather than a - degradation: nothing on npm decodes video the way sharp handles images +- **`ffmpeg`** — `vid` and `dl`, and it is a hard requirement rather than a + degradation: nothing on npm decodes video the way sharp handles images. + `dl audio` cannot run without it at all; `dl` on its own falls back to the + best single stream and says so +- **[`yt-dlp`](https://github.com/yt-dlp/yt-dlp)** — `dl` only + (`moshcode install yt-dlp`, `pipx install yt-dlp`, `brew install yt-dlp`) - **ImageMagick** (`magick`) — `img` only, and only for what sharp cannot do (PDF, PSD, animated GIF); sharp ships with this repo as an optional dependency - **Network on first use** — `favicon` only: the generation is @@ -773,6 +778,35 @@ That is the trade: re-encoding to hit an exact frame takes as long as the clip. Needs `ffmpeg` on `PATH`. There is no bundled fallback — nothing on npm decodes video the way sharp handles images. +### `dl` + +A thin front for [yt-dlp](https://github.com/yt-dlp/yt-dlp), in the same shape +`vid` is a thin front for ffmpeg: + +```sh +dl https://example.com/watch?v=abc # the video +dl --height 720 https://example.com/v/abc # capped +dl audio https://example.com/watch?v=abc # → .m4a, --format mp3 for anything else +dl info https://example.com/watch?v=abc # title, uploader, duration; nothing downloaded +dl formats https://example.com/watch?v=abc # everything yt-dlp will give you +dl --to ~/Downloads https://a/1 https://b/2 # several at once +``` + +**One entry, not the list.** A YouTube link copied from the browser while a mix +is playing carries `list=`, and yt-dlp reads that as "download all of it" — the +difference between one file and two hundred, on a command whose entire input is +a pasted URL. So `--no-playlist` is the default here and `--playlist` is how you +ask for the rest. + +**ffmpeg is the other half.** Above about 720p the picture and the sound arrive +as separate streams that have to be muxed, so without ffmpeg on `PATH` `dl` +restricts itself to the best single stream and warns that it did — rather than +picking a format it cannot finish, downloading both halves, and failing at the +merge. `dl audio` is `-x`, which *is* ffmpeg, so there it is a hard requirement. + +A URL that fails does not stop the ones after it; the exit code still reports +the failure. + ### `codeburn` Where your AI spend actually went — [codeburn](https://www.npmjs.com/package/codeburn), diff --git a/bin/dl.ts b/bin/dl.ts new file mode 100755 index 0000000..187c3bc --- /dev/null +++ b/bin/dl.ts @@ -0,0 +1,168 @@ +#!/usr/bin/env -S npx --yes tsx +/** + * dl -- pull a video, or just its audio, off a URL. + * + * A thin front for yt-dlp, in the same shape as `vid` is a thin front for + * ffmpeg: it covers the handful of things worth not remembering the flags for + * and gets out of the way for everything else. yt-dlp's own flags are the + * reference; nothing here renames one of them. + * + * The download itself inherits stdio rather than being captured, because + * yt-dlp's progress line is the entire user interface of a long transfer and a + * captured one appears all at once, after the wait it was meant to explain. + */ + +import { spawn } from 'node:child_process'; + +import { UsageError, integer, parseArgs } from '../src/args.ts'; +import { + downloadArgs, + formatsArgs, + infoArgs, + looksLikeUrl, + parseInfoStream, + splitCommand, +} from '../src/download.ts'; +import { isMain } from '../src/is-main.ts'; +import { run } from '../src/exec.ts'; +import { MissingEngineError, findFfmpeg, findYtDlp, humanDuration } from '../src/media.ts'; + +const USAGE = `Usage: + dl ... download the video + dl audio ... download the audio only + dl info ... title, uploader, duration -- nothing downloaded + dl formats every format yt-dlp will give you + +Options: + --height N cap the video height (720, 1080, ...) + --format EXT audio container for \`dl audio\` (default: m4a) + --to DIR write into this directory (default: here) + -o, --out TMPL yt-dlp output template + --playlist take the whole list, not just the entry the URL points at + --json info as JSON + --help show this help + +A YouTube link copied while a mix is playing carries \`list=\`, and yt-dlp reads +that as "download all of it". So one entry is the default and \`--playlist\` is +how you ask for the rest. + +Needs yt-dlp on PATH (\`moshcode install yt-dlp\`). ffmpeg too for \`dl audio\`, +and for any video good enough that its picture and sound arrive separately -- +without it, \`dl\` falls back to the best single stream and says that it did. +`; + +/** Run a child with our stdio, and resolve its exit code. */ +function passthrough(file: string, args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn(file, args, { stdio: 'inherit' }); + child.on('error', () => resolve(127)); + child.on('close', (code) => resolve(code ?? 1)); + }); +} + +if (isMain(import.meta.url)) { + try { + const { flags, values, positional } = parseArgs(process.argv.slice(2), { + boolean: ['--help', '--playlist', '--json'], + string: ['--height', '--format', '--to', '-o', '--out'], + }); + + if (flags.has('--help') || positional.length === 0) { + process.stdout.write(USAGE); + process.exit(positional.length === 0 ? 1 : 0); + } + + const { verb, urls } = splitCommand(positional); + if (urls.length === 0) throw new UsageError(`${verb} needs a URL`); + for (const url of urls) { + if (!looksLikeUrl(url)) throw new UsageError(`not a URL: ${url}`); + } + + const ytdlp = await findYtDlp(); + if (!ytdlp) { + throw new MissingEngineError( + 'yt-dlp is not on PATH. Install it (moshcode install yt-dlp / pipx install yt-dlp / brew install yt-dlp).', + ); + } + + const playlist = flags.has('--playlist'); + + if (verb === 'info') { + const rows: string[] = []; + const objects: unknown[] = []; + for (const url of urls) { + const res = await run(ytdlp, infoArgs(url, { playlist }), { timeoutMs: 120_000 }); + if (res.code !== 0) { + process.stderr.write(`${url}: ${res.stderr.trim().split('\n').pop() || 'unreadable'}\n`); + continue; + } + for (const info of parseInfoStream(res.stdout)) { + objects.push(info); + rows.push( + [info.title, info.uploader, humanDuration(info.duration), info.extractor].join(' '), + ); + } + } + process.stdout.write( + flags.has('--json') ? `${JSON.stringify(objects, null, 2)}\n` : `${rows.join('\n')}\n`, + ); + process.exit(rows.length === 0 ? 1 : 0); + } + + if (verb === 'formats') { + process.exit(await passthrough(ytdlp, formatsArgs(urls[0] as string))); + } + + const ffmpeg = await findFfmpeg(); + if (verb === 'audio' && !ffmpeg) { + // Hard, not a fallback: -x is ffmpeg doing the extraction. There is + // nothing to downgrade to. + throw new MissingEngineError( + 'dl audio needs ffmpeg to extract the audio track. Install it (moshcode install ffmpeg / apt install ffmpeg / brew install ffmpeg).', + ); + } + if (verb === 'video' && !ffmpeg) { + process.stderr.write( + 'dl: ffmpeg is not on PATH, so only single-stream formats are available -- the result may be lower quality than this URL offers.\n', + ); + } + + const height = values.has('--height') + ? integer(values, '--height', 0, { min: 1, max: 10_000 }) + : undefined; + + let worst = 0; + for (const url of urls) { + const code = await passthrough( + ytdlp, + downloadArgs({ + url, + kind: verb === 'audio' ? 'audio' : 'video', + ...(height === undefined ? {} : { height }), + ...(values.has('--format') ? { audioFormat: values.get('--format') as string } : {}), + ...(values.has('--to') ? { dir: values.get('--to') as string } : {}), + ...(values.get('-o') ?? values.get('--out') + ? { template: (values.get('-o') ?? values.get('--out')) as string } + : {}), + playlist, + canMerge: ffmpeg !== null, + }), + ); + // Keep going through the rest of the list: one dead URL in ten is not a + // reason to abandon the other nine, and the exit code still reports it. + if (code !== 0) worst = code; + } + process.exit(worst); + } catch (err) { + if (err instanceof MissingEngineError) { + process.stderr.write(`dl: ${err.message}\n`); + process.exit(2); + } + if (err instanceof UsageError) { + process.stderr.write(`dl: ${err.message}\n\n${USAGE}`); + process.exit(1); + } + process.stderr.write(`dl: ${(err as Error).message}\n`); + process.exit(1); + } +} diff --git a/package.json b/package.json index f4ece2b..4a940e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.12.0", + "version": "0.13.0", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", diff --git a/src/download.ts b/src/download.ts new file mode 100644 index 0000000..5d26123 --- /dev/null +++ b/src/download.ts @@ -0,0 +1,205 @@ +/** + * Pulling media off the web, through yt-dlp. + * + * yt-dlp is a system binary like ffmpeg and ImageMagick: it is on the box or it + * is not, and there is no npm fallback that speaks a thousand site extractors. + * So this module is argv construction and output parsing -- everything that can + * be reasoned about without a network or a download -- and bin/dl.ts is the + * part that spawns. + * + * Two things here are decisions rather than plumbing, and both are about not + * surprising the person who pasted a URL: + * + * playlists yt-dlp downloads the WHOLE list when the URL carries `list=`, + * and a YouTube link copied from the browser while a mix is + * playing carries one. That is the difference between one file + * and two hundred, so the default here is --no-playlist and the + * whole list is something you ask for. + * containers a single progressive stream tops out well below what YouTube + * actually has, so the good formats arrive as separate video and + * audio that ffmpeg has to mux. That makes ffmpeg a requirement + * for anything but the smallest download, which is worth saying + * up front rather than at 98%. + */ + +export type DownloadKind = 'video' | 'audio'; + +export interface DownloadRequest { + url: string; + kind: DownloadKind; + /** Cap the video height, e.g. 720. Undefined takes the best available. */ + height?: number; + /** Container for `dl audio`. */ + audioFormat?: string; + /** Directory to write into. */ + dir?: string; + /** yt-dlp output template, if the default naming is not what is wanted. */ + template?: string; + /** Take the whole playlist rather than the one entry the URL points at. */ + playlist?: boolean; + /** + * Is ffmpeg available to mux separate video and audio streams? + * + * False restricts the request to what can be written without it. Not a + * preference: yt-dlp downloads both halves and then fails at the merge, so a + * box without ffmpeg spends the whole transfer to produce nothing. + */ + canMerge?: boolean; +} + +/** The default filename template: yt-dlp's own, stated rather than assumed. */ +export const DEFAULT_TEMPLATE = '%(title)s [%(id)s].%(ext)s'; + +/** + * A yt-dlp format selector for a height cap. + * + * The three alternatives matter. `bv*+ba` is separate video and audio, which is + * where the good formats live; `b[height<=N]` is a single progressive stream, + * for sites that only serve one; and a bare `b` is the last resort, because a + * site with no height metadata at all matches neither of the first two and + * failing there would read as "this video does not exist". + */ +export function formatSelector(height?: number, canMerge = true): string { + const cap = height === undefined ? '' : `[height<=${height}]`; + // Without ffmpeg the `bv*+ba` alternatives are traps: yt-dlp picks them, + // downloads both halves, and only then discovers it cannot mux. + if (!canMerge) return cap ? `b${cap}/b` : 'b'; + if (!cap) return 'bv*+ba/b'; + return `bv*${cap}+ba/b${cap}/bv*+ba/b`; +} + +/** The argv for a download. */ +export function downloadArgs(request: DownloadRequest): string[] { + const args: string[] = ['--no-warnings']; + + // Ask for one thing unless told otherwise. See the playlist note above. + args.push(request.playlist ? '--yes-playlist' : '--no-playlist'); + + if (request.kind === 'audio') { + args.push('-x', '--audio-format', request.audioFormat ?? 'm4a'); + // Height is meaningless once the video track is thrown away, and passing a + // video selector alongside -x makes yt-dlp fetch a stream it then discards. + } else { + const canMerge = request.canMerge !== false; + args.push('-f', formatSelector(request.height, canMerge)); + // Nothing to ask for when there will be no merge, and passing it anyway + // makes yt-dlp remux a file that arrived whole. + if (canMerge) args.push('--merge-output-format', 'mp4'); + } + + args.push('-o', request.template ?? DEFAULT_TEMPLATE); + if (request.dir) args.push('-P', request.dir); + + // Four fragments at once is most of the speed available on a segmented + // stream, and enough of them to saturate a home line without looking like a + // scraper to the far end. + args.push('-N', '4'); + + args.push('--', request.url); + return args; +} + +/** The argv for `dl info` -- one JSON object per line, nothing downloaded. */ +export function infoArgs(url: string, { playlist = false } = {}): string[] { + return [ + '--no-warnings', + playlist ? '--yes-playlist' : '--no-playlist', + // -J on a playlist buffers the entire thing before printing; -j streams one + // object per entry, which is also the shape a single video comes back in. + '-j', + ...(playlist ? ['--flat-playlist'] : []), + '--', + url, + ]; +} + +/** The argv for `dl formats`. */ +export function formatsArgs(url: string): string[] { + return ['--no-warnings', '--no-playlist', '-F', '--', url]; +} + +export interface MediaInfo { + title: string; + uploader: string; + duration: number; + extractor: string; + url: string; +} + +/** + * What yt-dlp said about a URL, from its `-j` output. + * + * Written against the fields rather than a schema because extractors disagree + * about which of them exist: `uploader` is `channel` on some, absent on others, + * and `duration` is null for a livestream. Every field falls back to something + * printable, because a missing uploader is not a reason to fail a lookup. + */ +export function parseInfo(line: string): MediaInfo | null { + let raw: Record; + try { + raw = JSON.parse(line) as Record; + } catch { + return null; + } + if (typeof raw !== 'object' || raw === null) return null; + + const str = (...keys: string[]): string => { + for (const key of keys) { + const value = raw[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return ''; + }; + + const duration = raw.duration; + return { + title: str('title', 'fulltitle', 'id') || '(untitled)', + uploader: str('uploader', 'channel', 'creator', 'uploader_id') || '(unknown)', + // A livestream has no duration; 0 is what humanDuration renders as 0:00, + // which is honest -- there is no length to report yet. + duration: typeof duration === 'number' && Number.isFinite(duration) ? duration : 0, + extractor: str('extractor_key', 'extractor') || '?', + url: str('webpage_url', 'original_url', 'url'), + }; +} + +/** Every JSON object in a `-j` stream, skipping anything that is not one. */ +export function parseInfoStream(stdout: string): MediaInfo[] { + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map(parseInfo) + .filter((info): info is MediaInfo => info !== null); +} + + +/** The verbs `dl` takes before a URL. */ +export const VERBS = ['audio', 'info', 'formats'] as const; +export type Verb = (typeof VERBS)[number]; + +/** + * Which verb was asked for, and which URLs it was asked about. + * + * `dl ` is the common case and typing `dl video ` for it would be + * noise, so the verb is optional and the default is a video download. That is + * only unambiguous because a URL never collides with one of the three words -- + * every one of them has a scheme in front of it -- which is what this asserts + * rather than assumes: a first argument that is neither a verb nor URL-shaped + * is a typo worth naming, not a hostname to hand to yt-dlp. + */ +export function splitCommand(positional: readonly string[]): { + verb: Verb | 'video'; + urls: string[]; +} { + const [first, ...rest] = positional; + if (first !== undefined && (VERBS as readonly string[]).includes(first)) { + return { verb: first as Verb, urls: rest }; + } + return { verb: 'video', urls: [...positional] }; +} + +/** Does this read as something yt-dlp can be handed? */ +export function looksLikeUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} diff --git a/src/media.ts b/src/media.ts index fca0c49..76f84dd 100644 --- a/src/media.ts +++ b/src/media.ts @@ -40,15 +40,19 @@ const probed = new Map(); * `command -v` through a shell would be shorter and is exactly the thing * src/exec.ts exists to avoid; this runs the candidate with a harmless flag and * reads the exit code instead. + * + * The flag is a parameter because the harmless one is not the same everywhere. + * ImageMagick and ffmpeg both answer `-version` with 0; yt-dlp's parser reads + * that single dash as seven combined short options and exits non-zero, so + * probing it the same way would report an installed binary missing. */ -export async function findBinary(names: string[]): Promise { - const key = names.join(','); +export async function findBinary(names: string[], flag = '-version'): Promise { + const key = `${flag} ${names.join(',')}`; if (probed.has(key)) return probed.get(key) ?? null; for (const name of names) { - const res = await run(name, ['-version'], { timeoutMs: 5000 }).catch(() => null); - // ImageMagick and ffmpeg both answer -version with 0. A missing binary - // rejects at spawn, which the catch turns into null. + const res = await run(name, [flag], { timeoutMs: 5000 }).catch(() => null); + // A missing binary rejects at spawn, which src/exec.ts reports as 127. if (res && res.code === 0) { probed.set(key, name); return name; @@ -91,6 +95,10 @@ export async function loadSharp(): Promise { export const findMagick = () => findBinary(['magick', 'convert']); export const findFfmpeg = () => findBinary(['ffmpeg']); export const findFfprobe = () => findBinary(['ffprobe']); +/* youtube-dl is accepted as a fallback name, but only as one: it still exists on + * plenty of boxes and still resolves a plain YouTube URL, and it is years + * behind on everything else. Preferring yt-dlp keeps that from being silent. */ +export const findYtDlp = () => findBinary(['yt-dlp', 'youtube-dl'], '--version'); /** * Which image engine to use. diff --git a/src/registry.ts b/src/registry.ts index ed44aa3..a2c3fad 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -32,6 +32,7 @@ const SUMMARIES: Record = { 'blog-post': 'Publish to a plain-HTML blog without breaking the feed', 'cli-tools': 'This dispatcher: list, update and wire up the others', codeburn: 'See where your AI spend goes, by task, tool, model and project', + dl: 'Download a video, or just its audio, through yt-dlp', domainfree: 'Which of these domains can you actually register', domainjson: 'whois-style, JSON-first name lookup', favicon: 'Every icon a site links, rendered from one SVG', diff --git a/test/download.test.ts b/test/download.test.ts new file mode 100644 index 0000000..51791b6 --- /dev/null +++ b/test/download.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_TEMPLATE, + downloadArgs, + formatSelector, + formatsArgs, + infoArgs, + looksLikeUrl, + parseInfo, + parseInfoStream, + splitCommand, +} from '../src/download.ts'; + +const URL = 'https://example.com/watch?v=abc'; + +describe('formatSelector', () => { + it('prefers separate streams, then a progressive one', () => { + expect(formatSelector()).toBe('bv*+ba/b'); + expect(formatSelector(720)).toBe('bv*[height<=720]+ba/b[height<=720]/bv*+ba/b'); + }); + + /* + * The alternative that keeps a capped request from failing outright. + * + * A site that reports no height metadata matches neither capped branch, and + * without the uncapped tail yt-dlp answers "requested format not available" + * -- which reads as "this video is gone" rather than "we asked for something + * this extractor cannot describe". + */ + it('still has an answer for a site with no height metadata', () => { + expect(formatSelector(1080).endsWith('/bv*+ba/b')).toBe(true); + }); + + /* + * Without ffmpeg, `bv*+ba` is not a lower-quality option -- it is a wasted + * download. yt-dlp fetches both halves and only then finds it cannot mux, so + * the whole transfer is spent to produce nothing. + */ + it('offers nothing that needs muxing when ffmpeg is absent', () => { + expect(formatSelector(720, false)).toBe('b[height<=720]/b'); + expect(formatSelector(undefined, false)).toBe('b'); + expect(formatSelector(720, false)).not.toContain('+'); + }); +}); + +describe('downloadArgs', () => { + /* + * The default that matters most. + * + * A YouTube URL copied from the browser while a mix is playing carries + * `list=`, and yt-dlp reads that as the whole list. That is the difference + * between one file and two hundred, on a command whose whole input is a + * pasted URL. + */ + it('takes one entry unless the whole list was asked for', () => { + expect(downloadArgs({ url: URL, kind: 'video' })).toContain('--no-playlist'); + expect(downloadArgs({ url: URL, kind: 'video', playlist: true })).toContain('--yes-playlist'); + expect(downloadArgs({ url: URL, kind: 'video', playlist: true })).not.toContain('--no-playlist'); + }); + + it('passes the URL after -- so a leading dash is not read as a flag', () => { + const args = downloadArgs({ url: URL, kind: 'video' }); + expect(args.slice(-2)).toEqual(['--', URL]); + }); + + it('names the output template rather than leaving it implicit', () => { + expect(downloadArgs({ url: URL, kind: 'video' })).toContain(DEFAULT_TEMPLATE); + expect(downloadArgs({ url: URL, kind: 'video', template: '%(id)s.%(ext)s' })).toContain( + '%(id)s.%(ext)s', + ); + }); + + it('extracts audio into the container asked for', () => { + expect(downloadArgs({ url: URL, kind: 'audio' })).toEqual( + expect.arrayContaining(['-x', '--audio-format', 'm4a']), + ); + expect(downloadArgs({ url: URL, kind: 'audio', audioFormat: 'mp3' })).toContain('mp3'); + }); + + /* + * A height cap on an audio-only download is a stream fetched and discarded: + * -x throws the video track away, so selecting for it costs a download and + * buys nothing. + */ + it('does not select a video stream it is about to throw away', () => { + const args = downloadArgs({ url: URL, kind: 'audio', height: 720 }); + expect(args).not.toContain('-f'); + expect(args.join(' ')).not.toContain('height<='); + }); + + it('only asks for a remux when there is something to merge', () => { + expect(downloadArgs({ url: URL, kind: 'video' })).toContain('--merge-output-format'); + expect(downloadArgs({ url: URL, kind: 'video', canMerge: false })).not.toContain( + '--merge-output-format', + ); + }); + + it('writes where it was told to', () => { + expect(downloadArgs({ url: URL, kind: 'video', dir: '/tmp/out' })).toEqual( + expect.arrayContaining(['-P', '/tmp/out']), + ); + }); +}); + +describe('infoArgs and formatsArgs', () => { + /* + * -j rather than -J: the capital streams one object per entry, while -J + * buffers an entire playlist before printing anything. + */ + it('asks for one JSON object per line', () => { + expect(infoArgs(URL)).toContain('-j'); + expect(infoArgs(URL)).not.toContain('-J'); + }); + + it('only flattens when it was asked for a playlist', () => { + expect(infoArgs(URL)).not.toContain('--flat-playlist'); + expect(infoArgs(URL, { playlist: true })).toContain('--flat-playlist'); + }); + + it('ends both with a guarded URL', () => { + expect(infoArgs(URL).slice(-2)).toEqual(['--', URL]); + expect(formatsArgs(URL).slice(-2)).toEqual(['--', URL]); + }); +}); + +describe('parseInfo', () => { + it('reads the fields a person wants to see', () => { + const info = parseInfo( + JSON.stringify({ + title: 'A talk', + uploader: 'Someone', + duration: 3725, + extractor_key: 'Youtube', + webpage_url: URL, + }), + ); + expect(info).toEqual({ + title: 'A talk', + uploader: 'Someone', + duration: 3725, + extractor: 'Youtube', + url: URL, + }); + }); + + /* + * Extractors disagree about which fields exist -- `uploader` is `channel` on + * some and absent on others -- and a missing uploader is not a reason to fail + * a lookup that got the title right. + */ + it('falls back through the names different extractors use', () => { + const info = parseInfo(JSON.stringify({ title: 'X', channel: 'C', extractor: 'generic' })); + expect(info?.uploader).toBe('C'); + expect(info?.extractor).toBe('generic'); + }); + + /* A livestream has null duration; 0 renders as 0:00, which is honest. */ + it('does not invent a duration for a livestream', () => { + expect(parseInfo(JSON.stringify({ title: 'Live', duration: null }))?.duration).toBe(0); + }); + + it('returns null rather than throwing on a line that is not JSON', () => { + expect(parseInfo('WARNING: something')).toBeNull(); + expect(parseInfo('')).toBeNull(); + }); + + it('skips the noise in a stream rather than losing the stream', () => { + const stdout = ['not json', JSON.stringify({ title: 'One' }), '', 'also not'].join('\n'); + expect(parseInfoStream(stdout).map((i) => i.title)).toEqual(['One']); + }); +}); + +describe('splitCommand', () => { + it('treats a bare URL as a video download', () => { + expect(splitCommand([URL])).toEqual({ verb: 'video', urls: [URL] }); + }); + + it('reads the verbs it knows', () => { + expect(splitCommand(['audio', URL])).toEqual({ verb: 'audio', urls: [URL] }); + expect(splitCommand(['info', URL, URL])).toEqual({ verb: 'info', urls: [URL, URL] }); + }); + + /* + * The check that makes an optional verb safe: a first argument that is + * neither a verb nor URL-shaped is a typo worth naming, not a hostname to + * hand to yt-dlp -- which would go and look it up. + */ + it('leaves a mistyped verb visible instead of downloading it', () => { + const { verb, urls } = splitCommand(['audi0', URL]); + expect(verb).toBe('video'); + expect(urls[0]).toBe('audi0'); + expect(looksLikeUrl('audi0')).toBe(false); + }); + + it('knows a URL when it sees one', () => { + expect(looksLikeUrl('https://x.test/a')).toBe(true); + expect(looksLikeUrl('HTTP://x.test/a')).toBe(true); + expect(looksLikeUrl('x.test/a')).toBe(false); + expect(looksLikeUrl('file:///etc/passwd')).toBe(false); + }); +});