@jnzlab/easy-ytdlp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 easy-ytdlp contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # easy-ytdlp
2
+
3
+ Interactive, user-friendly CLI wrapper around [yt-dlp](https://github.com/yt-dlp/yt-dlp). Answer a few plain-English questions — quality, format, destination — and the tool builds and runs the correct `yt-dlp` command for you.
4
+
5
+ No Python install. No flag memorization. The `yt-dlp` binary is downloaded and cached automatically on first run.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ # one-shot (no install)
11
+ npx @jnzlab/easy-ytdlp <url>
12
+
13
+ # or install globally
14
+ npm install -g @jnzlab/easy-ytdlp
15
+ easy-ytdlp <url>
16
+ ```
17
+
18
+ **Requirements:** Node.js 18+. For video merging, audio extraction, and embedding, install [ffmpeg](https://ffmpeg.org/) on your system.
19
+
20
+ ## Usage
21
+
22
+ ```bash
23
+ easy-ytdlp # prompts for URL
24
+ easy-ytdlp https://youtu.be/dQw4w9WgXcQ # start with a URL
25
+ easy-ytdlp <url> --show-command # preview the yt-dlp flags first
26
+ easy-ytdlp update-binary # force-refresh the cached yt-dlp binary
27
+ ```
28
+
29
+ ### Example session
30
+
31
+ ```text
32
+ ┌ easy-ytdlp
33
+
34
+ ◇ yt-dlp ready
35
+
36
+ ◇ Fetching video info…
37
+
38
+ ◇ Found
39
+ │ Never Gonna Give You Up
40
+ │ by Rick Astley · 3m 33s
41
+
42
+ ◇ What do you want to download?
43
+ │ Video (with audio)
44
+
45
+ ◇ Video quality?
46
+ │ Up to 1080p
47
+
48
+ ◇ Container preference?
49
+ │ mp4
50
+
51
+ ◇ Download subtitles?
52
+ │ No
53
+
54
+ ◇ Destination folder
55
+ │ /home/you/Downloads
56
+
57
+ ◇ Filename style?
58
+ │ Title only
59
+
60
+ ◇ Extras (optional)
61
+ │ Embed metadata
62
+
63
+ ◇ Summary
64
+ │ Mode: video
65
+ │ Quality: 1080
66
+ │ Container: mp4
67
+ │ …
68
+
69
+ ◇ Start download?
70
+ │ Yes
71
+
72
+ │ Downloading |████████████████| 100% | 4.2MiB/s | ETA 0s
73
+
74
+ ◇ Saved
75
+ │ /home/you/Downloads/Never Gonna Give You Up.mp4
76
+
77
+ └ Finished
78
+ ```
79
+
80
+ ### Audio-only example
81
+
82
+ ```bash
83
+ npx @jnzlab/easy-ytdlp "https://www.youtube.com/watch?v=…"
84
+ # → choose "Audio only" → mp3 → Best → confirm
85
+ ```
86
+
87
+ ## What it covers
88
+
89
+ Guided choices for:
90
+
91
+ - Video (+ audio), audio-only, video-only, subtitles-only, thumbnail-only
92
+ - Quality caps (`-S res:…`) and available resolutions from metadata
93
+ - Containers, audio formats, subtitle languages / embed vs file
94
+ - Playlist: this video, whole playlist, or index range
95
+ - Output folder + plain-English filename templates
96
+ - Embed thumbnail / metadata, SponsorBlock remove
97
+
98
+ ## Out of scope (v1)
99
+
100
+ These need the raw `yt-dlp` CLI directly:
101
+
102
+ - Login / cookies-based extraction
103
+ - DRM content
104
+ - Live streams from the start
105
+
106
+ See the [yt-dlp authentication](https://github.com/yt-dlp/yt-dlp#authentication-options) and related docs, or the local reference copy in [`docs/yt-dlp-README.md`](docs/yt-dlp-README.md).
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/cli.js';
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/cli.js ADDED
@@ -0,0 +1,1217 @@
1
+ // src/cli.ts
2
+ import { Command } from "commander";
3
+ import * as p3 from "@clack/prompts";
4
+
5
+ // src/binary.ts
6
+ import { createWriteStream } from "fs";
7
+ import { access, chmod, mkdir, unlink } from "fs/promises";
8
+ import { constants } from "fs";
9
+ import { arch, platform } from "os";
10
+ import { join } from "path";
11
+ import { pipeline } from "stream/promises";
12
+ import { request as httpsRequest } from "https";
13
+ import { request as httpRequest } from "http";
14
+ import envPaths from "env-paths";
15
+
16
+ // src/yt-dlp-wrap.ts
17
+ import YTDlpWrapImport from "yt-dlp-wrap";
18
+ function resolveConstructor() {
19
+ const mod = YTDlpWrapImport;
20
+ if (typeof mod === "function") {
21
+ return mod;
22
+ }
23
+ if (mod && typeof mod.default === "function") {
24
+ return mod.default;
25
+ }
26
+ throw new Error(
27
+ "Failed to load yt-dlp-wrap: unexpected module shape. Try reinstalling dependencies."
28
+ );
29
+ }
30
+ var YTDlpWrap = resolveConstructor();
31
+
32
+ // src/binary.ts
33
+ var paths = envPaths("easy-ytdlp");
34
+ var RELEASE_BASE = "https://github.com/yt-dlp/yt-dlp/releases/latest/download";
35
+ function binaryName() {
36
+ return platform() === "win32" ? "yt-dlp.exe" : "yt-dlp";
37
+ }
38
+ function releaseAssetName(osPlatform = platform(), cpuArch = arch()) {
39
+ if (osPlatform === "win32") return "yt-dlp.exe";
40
+ if (osPlatform === "darwin") return "yt-dlp_macos";
41
+ if (osPlatform === "linux") {
42
+ if (cpuArch === "arm64" || cpuArch === "aarch64") {
43
+ return "yt-dlp_linux_aarch64";
44
+ }
45
+ return "yt-dlp_linux";
46
+ }
47
+ return "yt-dlp";
48
+ }
49
+ function getBinaryPath() {
50
+ return join(paths.cache, binaryName());
51
+ }
52
+ async function exists(filePath) {
53
+ try {
54
+ await access(filePath, constants.F_OK);
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+ function formatUnknownError(err) {
61
+ if (err instanceof Error) return err.message || err.name;
62
+ if (typeof err === "string") return err;
63
+ if (err && typeof err === "object") {
64
+ const r = err;
65
+ if (typeof r.statusCode === "number") {
66
+ return `HTTP ${r.statusCode}${r.statusMessage ? ` ${r.statusMessage}` : ""}`;
67
+ }
68
+ if (typeof r.message === "string") return r.message;
69
+ if (typeof r.code === "string") return r.code;
70
+ try {
71
+ return JSON.stringify(err);
72
+ } catch {
73
+ return Object.prototype.toString.call(err);
74
+ }
75
+ }
76
+ return String(err);
77
+ }
78
+ function friendlyBinaryError(err) {
79
+ const msg = formatUnknownError(err);
80
+ return new Error(
81
+ [
82
+ "Failed to download or set up the yt-dlp binary.",
83
+ "",
84
+ `Details: ${msg}`,
85
+ "",
86
+ "Common fixes:",
87
+ " \u2022 Check your network / proxy settings",
88
+ " \u2022 Ensure the cache directory is writable:",
89
+ ` ${paths.cache}`,
90
+ " \u2022 Retry with: easy-ytdlp update-binary"
91
+ ].join("\n")
92
+ );
93
+ }
94
+ function getOnce(url) {
95
+ return new Promise((resolve, reject) => {
96
+ const lib = url.startsWith("http:") ? httpRequest : httpsRequest;
97
+ const req = lib(
98
+ url,
99
+ {
100
+ headers: {
101
+ "User-Agent": "easy-ytdlp",
102
+ Accept: "*/*"
103
+ }
104
+ },
105
+ (res) => resolve(res)
106
+ );
107
+ req.on("error", reject);
108
+ req.end();
109
+ });
110
+ }
111
+ async function downloadReleaseAsset(asset, destPath) {
112
+ const startUrl = `${RELEASE_BASE}/${asset}`;
113
+ let url = startUrl;
114
+ let redirects = 0;
115
+ while (url) {
116
+ const res = await getOnce(url);
117
+ const status = res.statusCode ?? 0;
118
+ if (status >= 300 && status < 400 && res.headers.location) {
119
+ res.resume();
120
+ const next = res.headers.location;
121
+ url = next.startsWith("http") ? next : new URL(next, url).href;
122
+ redirects += 1;
123
+ if (redirects > 10) {
124
+ throw new Error(`Too many redirects downloading ${asset}`);
125
+ }
126
+ continue;
127
+ }
128
+ if (status !== 200) {
129
+ res.resume();
130
+ throw new Error(
131
+ `Download failed: HTTP ${status} ${res.statusMessage ?? ""} (${startUrl})`.trim()
132
+ );
133
+ }
134
+ await pipeline(res, createWriteStream(destPath));
135
+ return;
136
+ }
137
+ throw new Error(`Download failed: no URL for ${asset}`);
138
+ }
139
+ async function ensureBinary(options = {}) {
140
+ const { force = false, onStatus } = options;
141
+ const binPath = getBinaryPath();
142
+ await mkdir(paths.cache, { recursive: true });
143
+ if (!force && await exists(binPath)) {
144
+ return binPath;
145
+ }
146
+ if (force && await exists(binPath)) {
147
+ try {
148
+ await unlink(binPath);
149
+ } catch {
150
+ }
151
+ }
152
+ const asset = releaseAssetName();
153
+ onStatus?.(`Downloading ${asset} (one-time setup)\u2026`);
154
+ const tmpPath = `${binPath}.tmp`;
155
+ try {
156
+ if (await exists(tmpPath)) {
157
+ await unlink(tmpPath);
158
+ }
159
+ await downloadReleaseAsset(asset, tmpPath);
160
+ await chmod(tmpPath, 493);
161
+ const { rename } = await import("fs/promises");
162
+ await rename(tmpPath, binPath);
163
+ } catch (err) {
164
+ try {
165
+ await unlink(tmpPath);
166
+ } catch {
167
+ }
168
+ throw friendlyBinaryError(err);
169
+ }
170
+ onStatus?.(`yt-dlp binary ready at ${binPath}`);
171
+ return binPath;
172
+ }
173
+ async function updateBinary(onStatus) {
174
+ return ensureBinary({ force: true, onStatus });
175
+ }
176
+ async function createYtDlp(options = {}) {
177
+ const binPath = await ensureBinary(options);
178
+ return new YTDlpWrap(binPath);
179
+ }
180
+
181
+ // src/builder.ts
182
+ var FILENAME_TEMPLATES = {
183
+ title: "%(title)s.%(ext)s",
184
+ "title-channel": "%(title)s [%(uploader)s].%(ext)s",
185
+ "title-date": "%(upload_date)s - %(title)s.%(ext)s"
186
+ };
187
+ function audioQualityValue(q) {
188
+ return q === "good" ? "5" : "0";
189
+ }
190
+ function videoQualityFlags(quality) {
191
+ if (!quality || quality === "best") {
192
+ return ["-f", "bv*+ba/b"];
193
+ }
194
+ if (typeof quality === "object" && "height" in quality) {
195
+ const h = quality.height;
196
+ return ["-f", `bv*[height=${h}]+ba/b`];
197
+ }
198
+ return ["-S", `res:${quality}`, "-f", "bv*+ba/b"];
199
+ }
200
+ function containerFlags(container) {
201
+ if (!container || container === "best") return [];
202
+ return ["--merge-output-format", container];
203
+ }
204
+ function buildFlags(answers) {
205
+ const flags = [];
206
+ switch (answers.mode) {
207
+ case "video":
208
+ flags.push(...videoQualityFlags(answers.videoQuality));
209
+ flags.push(...containerFlags(answers.container));
210
+ break;
211
+ case "video-only":
212
+ if (answers.videoQuality && answers.videoQuality !== "best" && typeof answers.videoQuality !== "object") {
213
+ flags.push("-S", `res:${answers.videoQuality}`, "-f", "bv");
214
+ } else if (typeof answers.videoQuality === "object" && answers.videoQuality?.height) {
215
+ flags.push("-f", `bv[height=${answers.videoQuality.height}]`);
216
+ } else {
217
+ flags.push("-f", "bv");
218
+ }
219
+ flags.push(...containerFlags(answers.container));
220
+ break;
221
+ case "audio":
222
+ flags.push(
223
+ "-x",
224
+ "--audio-format",
225
+ answers.audioFormat ?? "best",
226
+ "--audio-quality",
227
+ audioQualityValue(answers.audioQuality)
228
+ );
229
+ break;
230
+ case "subs-only":
231
+ flags.push("--skip-download", "--write-subs");
232
+ break;
233
+ case "thumbnail-only":
234
+ flags.push("--skip-download", "--write-thumbnail");
235
+ break;
236
+ }
237
+ if (answers.mode === "subs-only") {
238
+ const langs = answers.subtitles.languages.length > 0 ? answers.subtitles.languages.join(",") : "all";
239
+ flags.push("--sub-langs", langs);
240
+ } else if (answers.subtitles.mode !== "none" && answers.mode !== "thumbnail-only") {
241
+ const langs = answers.subtitles.languages.length > 0 ? answers.subtitles.languages.join(",") : "all";
242
+ flags.push("--sub-langs", langs);
243
+ if (answers.subtitles.mode === "write" || answers.subtitles.mode === "both") {
244
+ flags.push("--write-subs");
245
+ }
246
+ if (answers.subtitles.mode === "embed" || answers.subtitles.mode === "both") {
247
+ flags.push("--embed-subs");
248
+ }
249
+ }
250
+ switch (answers.playlist.kind) {
251
+ case "single":
252
+ flags.push("--no-playlist");
253
+ break;
254
+ case "all":
255
+ flags.push("--yes-playlist");
256
+ break;
257
+ case "range":
258
+ flags.push(
259
+ "--yes-playlist",
260
+ "-I",
261
+ `${answers.playlist.start}:${answers.playlist.stop}`
262
+ );
263
+ break;
264
+ }
265
+ flags.push("-P", answers.outputDir);
266
+ flags.push("-o", FILENAME_TEMPLATES[answers.filenamePreset]);
267
+ if (answers.embedThumbnail && answers.mode !== "thumbnail-only") {
268
+ flags.push("--embed-thumbnail");
269
+ }
270
+ if (answers.embedMetadata) {
271
+ flags.push("--embed-metadata");
272
+ }
273
+ if (answers.sponsorBlock) {
274
+ flags.push("--sponsorblock-remove", "default");
275
+ }
276
+ flags.push("--print", "after_move:filepath");
277
+ flags.push(answers.url);
278
+ return flags;
279
+ }
280
+ function formatCommand(flags) {
281
+ const quoted = flags.map(
282
+ (f) => /[\s"'\\]/.test(f) ? JSON.stringify(f) : f
283
+ );
284
+ const parts = ["yt-dlp"];
285
+ for (let i = 0; i < quoted.length; i++) {
286
+ const cur = quoted[i];
287
+ const next = quoted[i + 1];
288
+ if (cur.startsWith("-") && next && !next.startsWith("-") && !looksLikeUrl(next)) {
289
+ parts.push(`${cur} ${next}`);
290
+ i++;
291
+ } else {
292
+ parts.push(cur);
293
+ }
294
+ }
295
+ return parts.join("\n ");
296
+ }
297
+ function looksLikeUrl(s) {
298
+ return /^https?:\/\//i.test(s) || s.startsWith('"http');
299
+ }
300
+
301
+ // src/downloader.ts
302
+ import cliProgress from "cli-progress";
303
+
304
+ // src/ffmpeg.ts
305
+ import { access as access2 } from "fs/promises";
306
+ import { constants as constants2 } from "fs";
307
+ import { platform as platform2 } from "os";
308
+ import { delimiter, join as join2 } from "path";
309
+ async function isExecutable(filePath) {
310
+ try {
311
+ await access2(filePath, constants2.X_OK);
312
+ return true;
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
317
+ async function commandExists(name) {
318
+ const pathEnv = process.env.PATH ?? "";
319
+ const exts = platform2() === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
320
+ for (const dir of pathEnv.split(delimiter)) {
321
+ for (const ext of exts) {
322
+ const candidate = join2(dir, name + ext.toLowerCase());
323
+ if (await isExecutable(candidate)) return true;
324
+ const candidate2 = join2(dir, name + ext);
325
+ if (candidate2 !== candidate && await isExecutable(candidate2)) {
326
+ return true;
327
+ }
328
+ }
329
+ }
330
+ return false;
331
+ }
332
+ async function checkFfmpeg() {
333
+ const ffmpeg = await commandExists("ffmpeg");
334
+ const ffprobe = await commandExists("ffprobe");
335
+ return { ffmpeg, ffprobe, ok: ffmpeg && ffprobe };
336
+ }
337
+ function ffmpegInstallHint() {
338
+ const os = platform2();
339
+ const lines = [
340
+ "ffmpeg (and ffprobe) are required for merging video+audio, extracting audio, and embedding subtitles/thumbnails.",
341
+ "",
342
+ "Install ffmpeg:"
343
+ ];
344
+ if (os === "darwin") {
345
+ lines.push(" brew install ffmpeg");
346
+ } else if (os === "win32") {
347
+ lines.push(
348
+ " winget install ffmpeg",
349
+ " # or: choco install ffmpeg",
350
+ " # or download from https://ffmpeg.org/download.html"
351
+ );
352
+ } else {
353
+ lines.push(
354
+ " # Fedora / RHEL:",
355
+ " sudo dnf install ffmpeg",
356
+ " # Debian / Ubuntu:",
357
+ " sudo apt install ffmpeg",
358
+ " # Arch:",
359
+ " sudo pacman -S ffmpeg"
360
+ );
361
+ }
362
+ lines.push("", "Then re-run easy-ytdlp.");
363
+ return lines.join("\n");
364
+ }
365
+ function needsFfmpeg(mode, extras) {
366
+ if (mode === "video" || mode === "video-only") return true;
367
+ if (mode === "audio" || extras.extractAudio) return true;
368
+ if (extras.embedSubs || extras.embedThumbnail) return true;
369
+ return false;
370
+ }
371
+
372
+ // src/youtube-compat.ts
373
+ import { accessSync, constants as constants3, readdirSync } from "fs";
374
+ import { join as join3 } from "path";
375
+ var MIN_RECOMMENDED_MAJOR = 22;
376
+ function nodeMajor(version) {
377
+ const m = version.replace(/^v/, "").split(".")[0];
378
+ return Number(m) || 0;
379
+ }
380
+ function isExecutable2(filePath) {
381
+ try {
382
+ accessSync(filePath, constants3.X_OK);
383
+ return true;
384
+ } catch {
385
+ return false;
386
+ }
387
+ }
388
+ function resolveNodeForYtDlp() {
389
+ const currentMajor = nodeMajor(process.versions.node);
390
+ if (currentMajor >= MIN_RECOMMENDED_MAJOR) {
391
+ return { path: process.execPath, major: currentMajor };
392
+ }
393
+ const nvmDir = process.env.NVM_DIR;
394
+ if (nvmDir) {
395
+ const versionsRoot = join3(nvmDir, "versions", "node");
396
+ try {
397
+ const dirs = readdirSync(versionsRoot).filter((name) => /^v\d+\.\d+\.\d+$/.test(name)).sort((a, b) => {
398
+ const pa = a.slice(1).split(".").map(Number);
399
+ const pb = b.slice(1).split(".").map(Number);
400
+ for (let i = 0; i < 3; i++) {
401
+ const diff = (pb[i] ?? 0) - (pa[i] ?? 0);
402
+ if (diff !== 0) return diff;
403
+ }
404
+ return 0;
405
+ });
406
+ for (const dir of dirs) {
407
+ const major = nodeMajor(dir);
408
+ if (major < MIN_RECOMMENDED_MAJOR) continue;
409
+ const candidate = join3(versionsRoot, dir, "bin", "node");
410
+ if (isExecutable2(candidate)) {
411
+ return { path: candidate, major };
412
+ }
413
+ }
414
+ } catch {
415
+ }
416
+ }
417
+ return { path: process.execPath, major: currentMajor };
418
+ }
419
+ function youtubeCompatFlags() {
420
+ const { path } = resolveNodeForYtDlp();
421
+ return [
422
+ "--js-runtimes",
423
+ `node:${path}`,
424
+ // Fallback if the cached binary's bundled EJS scripts are missing/outdated
425
+ "--remote-components",
426
+ "ejs:github"
427
+ ];
428
+ }
429
+
430
+ // src/downloader.ts
431
+ var LIVE_PROGRESS_RE = /\[download\]\s+([\d.]+)%(?:\s+of\s+(\S+))?(?:\s+at\s+(\S+))?\s+ETA\s+(\S+)/i;
432
+ var POSTPROCESS_RE = /^\[(?<name>Merger|ExtractAudio|EmbedSubtitle|EmbedThumbnail|Metadata|ModifyChapters|SponsorBlock|VideoRemuxer|VideoConvertor|MoveFiles|ThumbnailsConvertor|SubtitlesConvertor)\]/i;
433
+ function stripAnsi(text2) {
434
+ return text2.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "");
435
+ }
436
+ function humanizeError(raw) {
437
+ const lower = raw.toLowerCase();
438
+ if (lower.includes("ffmpeg") || lower.includes("ffprobe") || lower.includes("postprocessing")) {
439
+ return [
440
+ "Download or post-processing failed because ffmpeg/ffprobe is missing or broken.",
441
+ "",
442
+ ffmpegInstallHint(),
443
+ "",
444
+ `yt-dlp said: ${raw.trim()}`
445
+ ].join("\n");
446
+ }
447
+ if (lower.includes("http error 403") || lower.includes("403: forbidden") || lower.includes("javascript runtime") || lower.includes("js challenge") || lower.includes("n challenge")) {
448
+ return [
449
+ "YouTube blocked the download (often HTTP 403) \u2014 usually because a JavaScript runtime is needed to solve YouTube challenges.",
450
+ "",
451
+ "easy-ytdlp now enables Node automatically. If this still fails:",
452
+ " 1. Update the yt-dlp binary: easy-ytdlp update-binary",
453
+ " 2. Use Node 22+ (recommended by yt-dlp for the JS solver)",
454
+ " 3. Or install Deno: https://deno.land (yt-dlp\u2019s preferred runtime)",
455
+ "",
456
+ "More detail: https://github.com/yt-dlp/yt-dlp/wiki/EJS",
457
+ "",
458
+ `yt-dlp said: ${raw.trim()}`
459
+ ].join("\n");
460
+ }
461
+ if (lower.includes("impersonat")) {
462
+ return [
463
+ "This site asked for browser impersonation, but the required library is not available in the standalone yt-dlp binary.",
464
+ "Try: easy-ytdlp update-binary",
465
+ "Or see: https://github.com/yt-dlp/yt-dlp#impersonation",
466
+ "",
467
+ `yt-dlp said: ${raw.trim()}`
468
+ ].join("\n");
469
+ }
470
+ if (lower.includes("private video") || lower.includes("this video is private")) {
471
+ return "This video is private. Login/cookies are out of scope for easy-ytdlp \u2014 see yt-dlp authentication docs if you need them.";
472
+ }
473
+ if (lower.includes("geo") || lower.includes("not available in your country") || lower.includes("blocked in your country")) {
474
+ return `This video appears geo-restricted or blocked in your region.
475
+
476
+ yt-dlp said: ${raw.trim()}`;
477
+ }
478
+ if (lower.includes("unsupported url") || lower.includes("no suitable extractor") || lower.includes("unable to extract")) {
479
+ return `That URL is not supported or could not be parsed.
480
+
481
+ yt-dlp said: ${raw.trim()}`;
482
+ }
483
+ if (lower.includes("video unavailable") || lower.includes("has been removed")) {
484
+ return `This video is unavailable.
485
+
486
+ yt-dlp said: ${raw.trim()}`;
487
+ }
488
+ return raw.trim() || "Download failed for an unknown reason.";
489
+ }
490
+ async function fetchMetadata(ytDlp, url) {
491
+ try {
492
+ return await ytDlp.getVideoInfo([
493
+ ...youtubeCompatFlags(),
494
+ "--no-playlist",
495
+ url
496
+ ]);
497
+ } catch (err) {
498
+ const msg = err instanceof Error ? err.message : String(err);
499
+ throw new Error(humanizeError(msg));
500
+ }
501
+ }
502
+ function parseProgressLine(line) {
503
+ const cleaned = stripAnsi(line).trim();
504
+ if (/\bin\b/i.test(cleaned) && !/\bETA\b/i.test(cleaned)) {
505
+ return null;
506
+ }
507
+ const m = cleaned.match(LIVE_PROGRESS_RE);
508
+ if (!m) return null;
509
+ const percent = parseFloat(m[1]);
510
+ if (!Number.isFinite(percent)) return null;
511
+ return {
512
+ percent,
513
+ totalSize: m[2],
514
+ currentSpeed: m[3],
515
+ eta: m[4]
516
+ };
517
+ }
518
+ function parsePostprocessLine(line) {
519
+ const cleaned = stripAnsi(line).trim();
520
+ const m = cleaned.match(POSTPROCESS_RE);
521
+ if (!m?.groups?.name) return null;
522
+ const labels = {
523
+ Merger: "Merging video + audio\u2026",
524
+ ExtractAudio: "Extracting audio\u2026",
525
+ EmbedSubtitle: "Embedding subtitles\u2026",
526
+ EmbedThumbnail: "Embedding thumbnail\u2026",
527
+ Metadata: "Embedding metadata\u2026",
528
+ ModifyChapters: "Removing sponsor segments\u2026",
529
+ SponsorBlock: "SponsorBlock\u2026",
530
+ VideoRemuxer: "Remuxing\u2026",
531
+ VideoConvertor: "Converting\u2026",
532
+ MoveFiles: "Moving file\u2026",
533
+ ThumbnailsConvertor: "Processing thumbnail\u2026",
534
+ SubtitlesConvertor: "Processing subtitles\u2026"
535
+ };
536
+ return labels[m.groups.name] ?? `${m.groups.name}\u2026`;
537
+ }
538
+ function progressFlags() {
539
+ return ["--newline", "--progress"];
540
+ }
541
+ async function runDownload(ytDlp, flags) {
542
+ const filepaths = [];
543
+ let lastError = "";
544
+ let part = 1;
545
+ console.log();
546
+ const bar = new cliProgress.SingleBar(
547
+ {
548
+ // Use custom tokens — built-in `{eta}` is seconds-remaining and hits 0 at 100%
549
+ format: " {phase} |{bar}| {percentage}% | {dl_speed} | ETA {dl_eta}",
550
+ barCompleteChar: "\u2588",
551
+ barIncompleteChar: "\u2591",
552
+ hideCursor: true,
553
+ clearOnComplete: false,
554
+ // CRITICAL: must be false — YouTube DASH hits 100% per stream; stopping
555
+ // here froze the bar while audio/merge still ran for minutes.
556
+ stopOnComplete: false,
557
+ forceRedraw: true,
558
+ stream: process.stderr
559
+ },
560
+ cliProgress.Presets.shades_classic
561
+ );
562
+ let barStarted = false;
563
+ let lastPercent = 0;
564
+ let phase = "Downloading";
565
+ const ensureBar = () => {
566
+ if (!barStarted) {
567
+ bar.start(100, 0, {
568
+ phase,
569
+ dl_speed: "starting\u2026",
570
+ dl_eta: "\u2014"
571
+ });
572
+ barStarted = true;
573
+ }
574
+ };
575
+ const updateBar = (progress) => {
576
+ let percent = Math.min(100, Math.max(0, progress.percent));
577
+ if (!Number.isFinite(percent)) return;
578
+ if (percent + 5 < lastPercent) {
579
+ part += 1;
580
+ phase = `Downloading (${part})`;
581
+ }
582
+ lastPercent = percent;
583
+ ensureBar();
584
+ bar.update(percent, {
585
+ phase,
586
+ dl_speed: progress.currentSpeed ?? "N/A",
587
+ dl_eta: progress.eta && progress.eta !== "Unknown" ? progress.eta : "\u2014"
588
+ });
589
+ };
590
+ const setPhase = (label) => {
591
+ phase = label;
592
+ ensureBar();
593
+ bar.update(Math.min(99, Math.max(lastPercent, 1)), {
594
+ phase,
595
+ dl_speed: "\u2026",
596
+ dl_eta: "\u2014"
597
+ });
598
+ };
599
+ const handleLine = (raw) => {
600
+ const trimmed = stripAnsi(raw).trim();
601
+ if (!trimmed) return;
602
+ const progress = parseProgressLine(trimmed);
603
+ if (progress) {
604
+ updateBar(progress);
605
+ return;
606
+ }
607
+ const post = parsePostprocessLine(trimmed);
608
+ if (post) {
609
+ setPhase(post);
610
+ return;
611
+ }
612
+ if (/^\[download\]\s+Destination:/i.test(trimmed)) {
613
+ if (lastPercent >= 99) {
614
+ part += 1;
615
+ lastPercent = 0;
616
+ phase = `Downloading (${part})`;
617
+ ensureBar();
618
+ bar.update(0, {
619
+ phase,
620
+ dl_speed: "starting\u2026",
621
+ dl_eta: "\u2014"
622
+ });
623
+ }
624
+ }
625
+ };
626
+ const execFlags = [...youtubeCompatFlags(), ...progressFlags(), ...flags];
627
+ return new Promise((resolve, reject) => {
628
+ const emitter = ytDlp.exec(execFlags);
629
+ emitter.on("progress", (progress) => {
630
+ if (typeof progress.percent === "number" && Number.isFinite(progress.percent) && // Ignore wrap's parse of completion lines (often lack a real ETA string)
631
+ progress.eta) {
632
+ updateBar({
633
+ percent: progress.percent,
634
+ currentSpeed: progress.currentSpeed,
635
+ eta: progress.eta
636
+ });
637
+ }
638
+ });
639
+ ensureBar();
640
+ const proc = emitter.ytDlpProcess;
641
+ if (proc?.stderr) {
642
+ let errBuf = "";
643
+ proc.stderr.on("data", (chunk) => {
644
+ const text2 = chunk.toString();
645
+ if (/error|errno|traceback|ffmpeg/i.test(text2)) {
646
+ lastError += text2;
647
+ }
648
+ errBuf += text2;
649
+ const lines = errBuf.split(/\r|\n/);
650
+ errBuf = lines.pop() ?? "";
651
+ for (const line of lines) handleLine(line);
652
+ });
653
+ }
654
+ if (proc?.stdout) {
655
+ let buffer = "";
656
+ proc.stdout.on("data", (chunk) => {
657
+ buffer += chunk.toString();
658
+ const lines = buffer.split(/\r?\n/);
659
+ buffer = lines.pop() ?? "";
660
+ for (const line of lines) {
661
+ const trimmed = stripAnsi(line).trim();
662
+ if (!trimmed) continue;
663
+ handleLine(trimmed);
664
+ if (!trimmed.startsWith("[") && (trimmed.includes("/") || trimmed.includes("\\")) && !trimmed.toLowerCase().includes("error")) {
665
+ filepaths.push(trimmed);
666
+ }
667
+ }
668
+ });
669
+ }
670
+ emitter.on("error", (err) => {
671
+ if (barStarted) {
672
+ bar.stop();
673
+ console.error();
674
+ }
675
+ const combined = [err.message, lastError].filter(Boolean).join("\n");
676
+ reject(new Error(humanizeError(combined)));
677
+ });
678
+ emitter.on("close", (code) => {
679
+ if (barStarted) {
680
+ if (code === 0 || code === null) {
681
+ bar.update(100, {
682
+ phase: "Done",
683
+ dl_speed: "done",
684
+ dl_eta: "0s"
685
+ });
686
+ }
687
+ bar.stop();
688
+ console.error();
689
+ }
690
+ if (code !== 0 && code !== null) {
691
+ reject(
692
+ new Error(
693
+ humanizeError(lastError || `yt-dlp exited with code ${code}`)
694
+ )
695
+ );
696
+ return;
697
+ }
698
+ resolve({ filepaths });
699
+ });
700
+ });
701
+ }
702
+
703
+ // src/questions.ts
704
+ import * as p2 from "@clack/prompts";
705
+ import { homedir } from "os";
706
+ import { join as join4 } from "path";
707
+
708
+ // src/ui.ts
709
+ import * as p from "@clack/prompts";
710
+ function contentWidth(pad = 8) {
711
+ const cols = process.stdout.columns ?? 80;
712
+ return Math.max(40, cols - pad);
713
+ }
714
+ function wrapText(text2, width = contentWidth()) {
715
+ return text2.split(/\r?\n/).flatMap((line) => wrapLine(line, width)).join("\n");
716
+ }
717
+ function wrapLine(line, width) {
718
+ if (line.length <= width) return [line || " "];
719
+ const out = [];
720
+ let rest = line;
721
+ while (rest.length > width) {
722
+ let breakAt = rest.lastIndexOf(" ", width);
723
+ if (breakAt < Math.floor(width * 0.5)) breakAt = width;
724
+ out.push(rest.slice(0, breakAt).trimEnd());
725
+ rest = rest.slice(breakAt).trimStart();
726
+ }
727
+ if (rest.length) out.push(rest);
728
+ return out;
729
+ }
730
+ function showNote(body, title) {
731
+ p.note(wrapText(body, contentWidth(14)), title);
732
+ }
733
+ function showSaved(paths2) {
734
+ p.log.step("Saved");
735
+ const width = contentWidth(4);
736
+ for (const filePath of paths2) {
737
+ for (const line of wrapText(filePath, width).split("\n")) {
738
+ console.log(` ${line}`);
739
+ }
740
+ }
741
+ }
742
+ function showCommand(command) {
743
+ p.log.step("yt-dlp command");
744
+ for (const line of wrapText(command, contentWidth(4)).split("\n")) {
745
+ console.log(` ${line}`);
746
+ }
747
+ }
748
+
749
+ // src/questions.ts
750
+ function isCancel2(value) {
751
+ return p2.isCancel(value);
752
+ }
753
+ function exitOnCancel(value) {
754
+ if (isCancel2(value)) {
755
+ p2.cancel("Cancelled.");
756
+ process.exit(0);
757
+ }
758
+ }
759
+ function looksLikeUrl2(input) {
760
+ try {
761
+ const u = new URL(input);
762
+ return u.protocol === "http:" || u.protocol === "https:";
763
+ } catch {
764
+ return false;
765
+ }
766
+ }
767
+ function formatDuration(seconds) {
768
+ if (seconds == null || Number.isNaN(seconds)) return "unknown duration";
769
+ const h = Math.floor(seconds / 3600);
770
+ const m = Math.floor(seconds % 3600 / 60);
771
+ const s = Math.floor(seconds % 60);
772
+ if (h > 0) return `${h}h ${m}m ${s}s`;
773
+ if (m > 0) return `${m}m ${s}s`;
774
+ return `${s}s`;
775
+ }
776
+ function availableResolutions(meta) {
777
+ const heights = /* @__PURE__ */ new Set();
778
+ for (const f of meta.formats ?? []) {
779
+ if (f.height && f.vcodec && f.vcodec !== "none") {
780
+ heights.add(f.height);
781
+ }
782
+ }
783
+ return [...heights].sort((a, b) => b - a);
784
+ }
785
+ function availableSubtitleLangs(meta) {
786
+ const langs = /* @__PURE__ */ new Set();
787
+ for (const key of Object.keys(meta.subtitles ?? {})) {
788
+ if (key && key !== "live_chat") langs.add(key);
789
+ }
790
+ if (langs.size === 0) {
791
+ for (const key of Object.keys(meta.automatic_captions ?? {})) {
792
+ if (key && key !== "live_chat") langs.add(key);
793
+ }
794
+ }
795
+ return [...langs].sort();
796
+ }
797
+ function isPlaylistUrl(url, meta) {
798
+ if (meta.playlist || meta._type === "playlist") return true;
799
+ try {
800
+ const u = new URL(url);
801
+ return u.searchParams.has("list");
802
+ } catch {
803
+ return false;
804
+ }
805
+ }
806
+ async function promptUrl(initial) {
807
+ if (initial && looksLikeUrl2(initial)) return initial;
808
+ const url = await p2.text({
809
+ message: "Paste the video URL",
810
+ placeholder: "https://www.youtube.com/watch?v=\u2026",
811
+ initialValue: initial ?? "",
812
+ validate: (v) => {
813
+ if (!v?.trim()) return "URL is required";
814
+ if (!looksLikeUrl2(v.trim())) return "That does not look like a valid http(s) URL";
815
+ }
816
+ });
817
+ exitOnCancel(url);
818
+ return String(url).trim();
819
+ }
820
+ async function askQuestions(url, meta) {
821
+ const title = meta.title ?? "Unknown title";
822
+ const uploader = meta.uploader ?? "Unknown uploader";
823
+ const duration = formatDuration(meta.duration);
824
+ showNote(`${title}
825
+ by ${uploader} \xB7 ${duration}`, "Found");
826
+ const mode = await p2.select({
827
+ message: "What do you want to download?",
828
+ options: [
829
+ { value: "video", label: "Video (with audio)" },
830
+ { value: "audio", label: "Audio only" },
831
+ { value: "video-only", label: "Video only (no audio)" },
832
+ { value: "subs-only", label: "Subtitles only" },
833
+ { value: "thumbnail-only", label: "Thumbnail only" }
834
+ ]
835
+ });
836
+ exitOnCancel(mode);
837
+ let videoQuality;
838
+ let container;
839
+ let audioFormat;
840
+ let audioQuality;
841
+ if (mode === "video" || mode === "video-only") {
842
+ const resolutions = availableResolutions(meta);
843
+ const qualityOpts = [
844
+ { value: "best", label: "Best available" },
845
+ { value: "1080", label: "Up to 1080p" },
846
+ { value: "720", label: "Up to 720p" },
847
+ { value: "480", label: "Up to 480p" }
848
+ ];
849
+ if (resolutions.length > 0) {
850
+ qualityOpts.push({
851
+ value: "pick",
852
+ label: "Choose from available resolutions\u2026"
853
+ });
854
+ }
855
+ const q = await p2.select({
856
+ message: "Video quality?",
857
+ options: qualityOpts
858
+ });
859
+ exitOnCancel(q);
860
+ if (q === "pick") {
861
+ const picked = await p2.select({
862
+ message: "Available resolutions",
863
+ options: resolutions.map((h) => ({
864
+ value: String(h),
865
+ label: `${h}p`
866
+ }))
867
+ });
868
+ exitOnCancel(picked);
869
+ videoQuality = { height: Number(picked) };
870
+ } else if (q === "best") {
871
+ videoQuality = "best";
872
+ } else {
873
+ videoQuality = q;
874
+ }
875
+ const c = await p2.select({
876
+ message: "Container preference?",
877
+ options: [
878
+ { value: "best", label: "Best available" },
879
+ { value: "mp4", label: "mp4" },
880
+ { value: "mkv", label: "mkv" },
881
+ { value: "webm", label: "webm" }
882
+ ]
883
+ });
884
+ exitOnCancel(c);
885
+ container = c;
886
+ }
887
+ if (mode === "audio") {
888
+ const fmt = await p2.select({
889
+ message: "Audio format?",
890
+ options: [
891
+ { value: "mp3", label: "mp3" },
892
+ { value: "m4a", label: "m4a" },
893
+ { value: "opus", label: "opus" },
894
+ { value: "flac", label: "flac" },
895
+ { value: "wav", label: "wav" },
896
+ { value: "best", label: "Best (no convert)" }
897
+ ]
898
+ });
899
+ exitOnCancel(fmt);
900
+ audioFormat = fmt;
901
+ const aq = await p2.select({
902
+ message: "Audio quality?",
903
+ options: [
904
+ { value: "best", label: "Best" },
905
+ { value: "good", label: "Good (smaller file)" }
906
+ ]
907
+ });
908
+ exitOnCancel(aq);
909
+ audioQuality = aq;
910
+ }
911
+ let subMode = "none";
912
+ let subLangs = [];
913
+ if (mode === "subs-only") {
914
+ subMode = "write";
915
+ const langs = availableSubtitleLangs(meta);
916
+ if (langs.length === 0) {
917
+ p2.log.warn('No subtitle languages found in metadata \u2014 will request "all".');
918
+ subLangs = ["all"];
919
+ } else {
920
+ const picked = await p2.multiselect({
921
+ message: "Which subtitle languages?",
922
+ options: [
923
+ { value: "all", label: "All languages" },
924
+ ...langs.map((l) => ({ value: l, label: l }))
925
+ ],
926
+ required: true
927
+ });
928
+ exitOnCancel(picked);
929
+ const sel = picked;
930
+ subLangs = sel.includes("all") ? ["all"] : sel;
931
+ }
932
+ } else if (mode !== "thumbnail-only") {
933
+ const wantSubs = await p2.confirm({
934
+ message: "Download subtitles?",
935
+ initialValue: false
936
+ });
937
+ exitOnCancel(wantSubs);
938
+ if (wantSubs) {
939
+ const langs = availableSubtitleLangs(meta);
940
+ if (langs.length === 0) {
941
+ p2.log.warn('No subtitle languages found \u2014 will request "all".');
942
+ subLangs = ["all"];
943
+ } else {
944
+ const picked = await p2.multiselect({
945
+ message: "Which subtitle languages?",
946
+ options: [
947
+ { value: "all", label: "All languages" },
948
+ ...langs.map((l) => ({ value: l, label: l }))
949
+ ],
950
+ required: true
951
+ });
952
+ exitOnCancel(picked);
953
+ const sel = picked;
954
+ subLangs = sel.includes("all") ? ["all"] : sel;
955
+ }
956
+ const how = await p2.select({
957
+ message: "How should subtitles be saved?",
958
+ options: [
959
+ { value: "embed", label: "Embed in the video" },
960
+ { value: "write", label: "Separate .srt / subtitle file" },
961
+ { value: "both", label: "Both embed and separate file" }
962
+ ]
963
+ });
964
+ exitOnCancel(how);
965
+ subMode = how;
966
+ }
967
+ }
968
+ let playlist = { kind: "single" };
969
+ if (isPlaylistUrl(url, meta)) {
970
+ const pl = await p2.select({
971
+ message: "This URL is part of a playlist. What should we download?",
972
+ options: [
973
+ { value: "single", label: "Just this video" },
974
+ { value: "all", label: "Whole playlist" },
975
+ { value: "range", label: "A specific range" }
976
+ ]
977
+ });
978
+ exitOnCancel(pl);
979
+ if (pl === "single") {
980
+ playlist = { kind: "single" };
981
+ } else if (pl === "all") {
982
+ playlist = { kind: "all" };
983
+ } else {
984
+ const start = await p2.text({
985
+ message: "Playlist start index (1-based)",
986
+ initialValue: "1",
987
+ validate: (v) => {
988
+ const n = Number(v);
989
+ if (!Number.isInteger(n) || n < 1) return "Enter a positive integer";
990
+ }
991
+ });
992
+ exitOnCancel(start);
993
+ const stop = await p2.text({
994
+ message: "Playlist stop index (inclusive)",
995
+ initialValue: String(meta.playlist_count ?? 10),
996
+ validate: (v) => {
997
+ const n = Number(v);
998
+ if (!Number.isInteger(n) || n < 1) return "Enter a positive integer";
999
+ }
1000
+ });
1001
+ exitOnCancel(stop);
1002
+ playlist = {
1003
+ kind: "range",
1004
+ start: Number(start),
1005
+ stop: Number(stop)
1006
+ };
1007
+ }
1008
+ }
1009
+ const defaultDir = join4(homedir(), "Downloads");
1010
+ const outDir = await p2.text({
1011
+ message: "Destination folder",
1012
+ initialValue: defaultDir,
1013
+ validate: (v) => !v?.trim() ? "Folder is required" : void 0
1014
+ });
1015
+ exitOnCancel(outDir);
1016
+ const filenamePreset = await p2.select({
1017
+ message: "Filename style?",
1018
+ options: [
1019
+ { value: "title", label: "Title only" },
1020
+ {
1021
+ value: "title-channel",
1022
+ label: "Title + Channel"
1023
+ },
1024
+ {
1025
+ value: "title-date",
1026
+ label: "Title + Upload Date"
1027
+ }
1028
+ ]
1029
+ });
1030
+ exitOnCancel(filenamePreset);
1031
+ const extras = mode === "subs-only" || mode === "thumbnail-only" ? [] : await (async () => {
1032
+ const e = await p2.multiselect({
1033
+ message: "Extras (optional)",
1034
+ options: [
1035
+ {
1036
+ value: "thumbnail",
1037
+ label: "Embed thumbnail as cover art"
1038
+ },
1039
+ { value: "metadata", label: "Embed metadata" },
1040
+ {
1041
+ value: "sponsorblock",
1042
+ label: "SponsorBlock: remove sponsor segments"
1043
+ }
1044
+ ],
1045
+ required: false
1046
+ });
1047
+ exitOnCancel(e);
1048
+ return e;
1049
+ })();
1050
+ const selectedMode = mode;
1051
+ const selectedFilename = filenamePreset;
1052
+ const summaryLines = [
1053
+ `Mode: ${selectedMode}`,
1054
+ videoQuality ? `Quality: ${typeof videoQuality === "object" ? `${videoQuality.height}p` : videoQuality}` : null,
1055
+ container ? `Container: ${container}` : null,
1056
+ audioFormat ? `Audio: ${audioFormat} (${audioQuality ?? "best"})` : null,
1057
+ subMode !== "none" ? `Subtitles: ${subMode} [${subLangs.join(", ")}]` : "Subtitles: none",
1058
+ `Playlist: ${playlist.kind}${playlist.kind === "range" ? ` ${playlist.start}:${playlist.stop}` : ""}`,
1059
+ `Output: ${String(outDir).trim()}`,
1060
+ `Filename: ${selectedFilename}`,
1061
+ extras.length ? `Extras: ${extras.join(", ")}` : "Extras: none"
1062
+ ].filter(Boolean).join("\n");
1063
+ showNote(summaryLines, "Summary");
1064
+ const showCmd = await p2.confirm({
1065
+ message: "Show the yt-dlp command before downloading?",
1066
+ initialValue: false
1067
+ });
1068
+ exitOnCancel(showCmd);
1069
+ return {
1070
+ url,
1071
+ mode: selectedMode,
1072
+ videoQuality,
1073
+ container,
1074
+ audioFormat,
1075
+ audioQuality,
1076
+ subtitles: {
1077
+ mode: subMode,
1078
+ languages: subLangs
1079
+ },
1080
+ playlist,
1081
+ outputDir: String(outDir).trim(),
1082
+ filenamePreset: selectedFilename,
1083
+ embedThumbnail: extras.includes("thumbnail"),
1084
+ embedMetadata: extras.includes("metadata"),
1085
+ sponsorBlock: extras.includes("sponsorblock"),
1086
+ showCommand: Boolean(showCmd)
1087
+ };
1088
+ }
1089
+
1090
+ // src/cli.ts
1091
+ async function runWizard(urlArg, opts = {}) {
1092
+ p3.intro("easy-ytdlp");
1093
+ const spinner2 = p3.spinner();
1094
+ spinner2.start("Preparing yt-dlp binary\u2026");
1095
+ let ytDlp;
1096
+ try {
1097
+ ytDlp = await createYtDlp({
1098
+ onStatus: (msg) => {
1099
+ spinner2.message(msg);
1100
+ }
1101
+ });
1102
+ spinner2.stop("yt-dlp ready");
1103
+ } catch (err) {
1104
+ spinner2.stop("Binary setup failed");
1105
+ p3.log.error(err instanceof Error ? err.message : String(err));
1106
+ process.exit(1);
1107
+ }
1108
+ const url = await promptUrl(urlArg);
1109
+ spinner2.start("Fetching video info\u2026");
1110
+ let meta;
1111
+ try {
1112
+ meta = await fetchMetadata(ytDlp, url);
1113
+ spinner2.stop("Metadata loaded");
1114
+ } catch (err) {
1115
+ spinner2.stop("Could not fetch metadata");
1116
+ p3.log.error(err instanceof Error ? err.message : String(err));
1117
+ process.exit(1);
1118
+ }
1119
+ const answers = await askQuestions(url, meta);
1120
+ if (opts.showCommand) {
1121
+ answers.showCommand = true;
1122
+ }
1123
+ const flags = buildFlags(answers);
1124
+ const displayFlags = [
1125
+ ...youtubeCompatFlags(),
1126
+ ...progressFlags(),
1127
+ ...flags
1128
+ ];
1129
+ if (answers.showCommand) {
1130
+ showCommand(formatCommand(displayFlags));
1131
+ const proceed = await p3.confirm({
1132
+ message: "Run this command?",
1133
+ initialValue: true
1134
+ });
1135
+ if (p3.isCancel(proceed) || !proceed) {
1136
+ p3.cancel("Cancelled.");
1137
+ process.exit(0);
1138
+ }
1139
+ } else {
1140
+ const proceed = await p3.confirm({
1141
+ message: "Start download?",
1142
+ initialValue: true
1143
+ });
1144
+ if (p3.isCancel(proceed) || !proceed) {
1145
+ p3.cancel("Cancelled.");
1146
+ process.exit(0);
1147
+ }
1148
+ }
1149
+ if (needsFfmpeg(answers.mode, {
1150
+ embedSubs: answers.subtitles.mode === "embed" || answers.subtitles.mode === "both",
1151
+ embedThumbnail: answers.embedThumbnail,
1152
+ extractAudio: answers.mode === "audio"
1153
+ })) {
1154
+ const status = await checkFfmpeg();
1155
+ if (!status.ok) {
1156
+ p3.log.warn(
1157
+ [
1158
+ "ffmpeg/ffprobe not found on PATH.",
1159
+ ffmpegInstallHint()
1160
+ ].join("\n\n")
1161
+ );
1162
+ const cont = await p3.confirm({
1163
+ message: "Continue anyway? (download may fail at merge/extract)",
1164
+ initialValue: false
1165
+ });
1166
+ if (p3.isCancel(cont) || !cont) {
1167
+ p3.cancel("Cancelled.");
1168
+ process.exit(0);
1169
+ }
1170
+ }
1171
+ }
1172
+ p3.log.info("Starting download\u2026");
1173
+ try {
1174
+ const result = await runDownload(ytDlp, flags);
1175
+ if (result.filepaths.length > 0) {
1176
+ showSaved(result.filepaths);
1177
+ } else {
1178
+ p3.log.success("Done. (No filepath printed \u2014 check your output folder.)");
1179
+ p3.log.info(`Output folder: ${answers.outputDir}`);
1180
+ }
1181
+ p3.outro("Finished");
1182
+ } catch (err) {
1183
+ p3.log.error(err instanceof Error ? err.message : String(err));
1184
+ p3.outro("Failed");
1185
+ process.exit(1);
1186
+ }
1187
+ }
1188
+ async function runUpdateBinary() {
1189
+ p3.intro("easy-ytdlp update-binary");
1190
+ const spinner2 = p3.spinner();
1191
+ spinner2.start("Refreshing yt-dlp binary\u2026");
1192
+ try {
1193
+ const path = await updateBinary((msg) => spinner2.message(msg));
1194
+ spinner2.stop(`Updated: ${path}`);
1195
+ p3.outro("Binary update complete");
1196
+ } catch (err) {
1197
+ spinner2.stop("Update failed");
1198
+ p3.log.error(err instanceof Error ? err.message : String(err));
1199
+ process.exit(1);
1200
+ }
1201
+ }
1202
+ var program = new Command();
1203
+ program.name("easy-ytdlp").description(
1204
+ "Interactive, user-friendly wrapper around yt-dlp \u2014 no flag memorization required"
1205
+ ).version("1.0.0").argument("[url]", "Video URL (prompted if omitted)").option(
1206
+ "--show-command",
1207
+ "Always show the generated yt-dlp command before running"
1208
+ ).action(async (url, options) => {
1209
+ await runWizard(url, { showCommand: options.showCommand });
1210
+ });
1211
+ program.command("update-binary").description("Force-refresh the cached yt-dlp binary").action(async () => {
1212
+ await runUpdateBinary();
1213
+ });
1214
+ program.parseAsync(process.argv).catch((err) => {
1215
+ console.error(err instanceof Error ? err.message : err);
1216
+ process.exit(1);
1217
+ });
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@jnzlab/easy-ytdlp",
3
+ "version": "1.0.0",
4
+ "description": "A user-friendly interactive CLI wrapper for yt-dlp — no Python or flag memorization required",
5
+ "type": "module",
6
+ "main": "./dist/cli.js",
7
+ "types": "./dist/cli.d.ts",
8
+ "bin": {
9
+ "easy-ytdlp": "bin/easy-ytdlp.js"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "scripts": {
24
+ "build": "tsup src/cli.ts --format esm --dts --clean --outDir dist",
25
+ "dev": "tsx src/cli.ts",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest",
28
+ "prepublishOnly": "npm run build",
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "keywords": [
32
+ "yt-dlp",
33
+ "youtube",
34
+ "download",
35
+ "cli",
36
+ "video"
37
+ ],
38
+ "license": "MIT",
39
+ "dependencies": {
40
+ "@clack/prompts": "^0.10.0",
41
+ "cli-progress": "^3.12.0",
42
+ "commander": "^13.1.0",
43
+ "env-paths": "^3.0.0",
44
+ "yt-dlp-wrap": "^2.3.12"
45
+ },
46
+ "devDependencies": {
47
+ "@types/cli-progress": "^3.11.6",
48
+ "@types/node": "^22.13.10",
49
+ "tsup": "^8.4.0",
50
+ "tsx": "^4.19.3",
51
+ "typescript": "^5.8.2",
52
+ "vitest": "^3.0.8"
53
+ }
54
+ }