@jnzlab/easy-ytdlp 1.1.3 → 1.2.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/dist/cli.js +359 -173
  3. package/package.json +1 -2
package/CHANGELOG.md CHANGED
@@ -1,9 +1,30 @@
1
1
  # Changelog
2
2
 
3
- ## 1.1.3 - 2026-08-05
3
+ ## 1.2.0 - 2026-09-18
4
+
5
+ ### Added
6
+
7
+ - When a download or metadata fetch fails because YouTube's JS challenge could
8
+ not be solved, easy-ytdlp now offers to install Deno for you
9
+ (`curl -fsSL https://deno.land/install.sh | sh`, or the PowerShell one-liner
10
+ on Windows) and retries automatically once it is installed. Declining prints
11
+ the command so you can install it yourself and come back.
12
+ - yt-dlp is now pointed at a Deno binary in `~/.deno/bin` even when that
13
+ directory is not yet on `PATH` (which is the case right after installing it).
14
+
15
+ ## 1.1.4 - 2026-09-08
4
16
 
5
17
  ### Fixed
6
18
 
19
+ - Interactive mode no longer fails with "Failed to fetch metadata for any of
20
+ the provided URLs" on videos that have no pre-merged format. `yt-dlp-wrap`
21
+ silently added `-f best` to the metadata call, which YouTube now rejects for
22
+ many videos; the metadata fetch passes `--ignore-no-formats-error` and its
23
+ own format flag instead. When every URL fails, the underlying yt-dlp error is
24
+ now shown instead of a generic message.
25
+ - `--version` now reads the version from `package.json` instead of a
26
+ hardcoded constant that had drifted from the published version.
27
+ - Removed an accidental dependency of the package on itself.
7
28
  - Pasting a bare YouTube playlist link no longer fails/hangs at startup. The
8
29
  metadata fetch now uses `--flat-playlist` for playlist URLs (fast, one small
9
30
  line per video) and shows the playlist title + video count instead of dumping
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/cli.ts
2
2
  import { Command } from "commander";
3
- import * as p3 from "@clack/prompts";
3
+ import * as p4 from "@clack/prompts";
4
4
  import { readFileSync } from "fs";
5
5
 
6
6
  // src/binary.ts
@@ -252,16 +252,179 @@ function needsFfmpeg(mode, extras) {
252
252
  }
253
253
 
254
254
  // src/youtube-compat.ts
255
- import { accessSync, constants as constants3, readdirSync } from "fs";
256
- import { join as join3 } from "path";
255
+ import { accessSync as accessSync2, constants as constants4, readdirSync } from "fs";
256
+ import { join as join4 } from "path";
257
+
258
+ // src/deno.ts
259
+ import { spawn } from "child_process";
260
+ import { accessSync, constants as constants3 } from "fs";
261
+ import { homedir, platform as platform3 } from "os";
262
+ import { delimiter as delimiter2, dirname, join as join3 } from "path";
263
+ import * as p2 from "@clack/prompts";
264
+
265
+ // src/ui.ts
266
+ import * as p from "@clack/prompts";
267
+ function contentWidth(pad = 8) {
268
+ const cols = process.stdout.columns ?? 80;
269
+ return Math.max(40, cols - pad);
270
+ }
271
+ function wrapText(text2, width = contentWidth()) {
272
+ return text2.split(/\r?\n/).flatMap((line) => wrapLine(line, width)).join("\n");
273
+ }
274
+ function wrapLine(line, width) {
275
+ if (line.length <= width) return [line || " "];
276
+ const out = [];
277
+ let rest = line;
278
+ while (rest.length > width) {
279
+ let breakAt = rest.lastIndexOf(" ", width);
280
+ if (breakAt < Math.floor(width * 0.5)) breakAt = width;
281
+ out.push(rest.slice(0, breakAt).trimEnd());
282
+ rest = rest.slice(breakAt).trimStart();
283
+ }
284
+ if (rest.length) out.push(rest);
285
+ return out;
286
+ }
287
+ function showNote(body, title) {
288
+ p.note(wrapText(body, contentWidth(14)), title);
289
+ }
290
+ function showSaved(paths3) {
291
+ if (paths3.length === 0) return;
292
+ if (paths3.length === 1) {
293
+ p.log.success(paths3[0]);
294
+ return;
295
+ }
296
+ p.log.success(`Saved ${paths3.length} files`);
297
+ for (const filePath of paths3) {
298
+ p.log.info(filePath);
299
+ }
300
+ }
301
+ function showCommand(command) {
302
+ p.log.step("yt-dlp command");
303
+ for (const line of wrapText(command, contentWidth(4)).split("\n")) {
304
+ console.log(` ${line}`);
305
+ }
306
+ }
307
+
308
+ // src/deno.ts
309
+ var INSTALL_SH = "curl -fsSL https://deno.land/install.sh | sh";
310
+ var INSTALL_PS = "irm https://deno.land/install.ps1 | iex";
311
+ function isJsRuntimeError(message) {
312
+ const lower = message.toLowerCase();
313
+ return lower.includes("http error 403") || lower.includes("403: forbidden") || lower.includes("javascript runtime") || lower.includes("js challenge") || lower.includes("n challenge");
314
+ }
315
+ function denoInstallCommand() {
316
+ return platform3() === "win32" ? INSTALL_PS : INSTALL_SH;
317
+ }
318
+ function isExecutable2(filePath) {
319
+ try {
320
+ accessSync(filePath, constants3.X_OK);
321
+ return true;
322
+ } catch {
323
+ return false;
324
+ }
325
+ }
326
+ function denoFilename() {
327
+ return platform3() === "win32" ? "deno.exe" : "deno";
328
+ }
329
+ function knownDenoDirs() {
330
+ const dirs = [];
331
+ if (process.env.DENO_INSTALL) dirs.push(join3(process.env.DENO_INSTALL, "bin"));
332
+ if (process.env.DENO_INSTALL_ROOT) dirs.push(process.env.DENO_INSTALL_ROOT);
333
+ const home = homedir();
334
+ if (home) dirs.push(join3(home, ".deno", "bin"));
335
+ return dirs;
336
+ }
337
+ function findDeno() {
338
+ const name = denoFilename();
339
+ const dirs = [
340
+ ...(process.env.PATH ?? "").split(delimiter2).filter(Boolean),
341
+ ...knownDenoDirs()
342
+ ];
343
+ for (const dir of dirs) {
344
+ const candidate = join3(dir, name);
345
+ if (isExecutable2(candidate)) return candidate;
346
+ }
347
+ return null;
348
+ }
349
+ function ensureDenoOnPath(denoPath) {
350
+ const dir = dirname(denoPath);
351
+ const entries = (process.env.PATH ?? "").split(delimiter2);
352
+ if (entries.includes(dir)) return;
353
+ process.env.PATH = [dir, ...entries].filter(Boolean).join(delimiter2);
354
+ }
355
+ function showManualInstructions() {
356
+ showNote(
357
+ [
358
+ "Install Deno yourself with:",
359
+ "",
360
+ ` ${denoInstallCommand()}`,
361
+ "",
362
+ "Then re-run easy-ytdlp \u2014 it will pick Deno up automatically."
363
+ ].join("\n"),
364
+ "Install Deno"
365
+ );
366
+ }
367
+ function runInstallCommand() {
368
+ const isWindows = platform3() === "win32";
369
+ const command = isWindows ? "powershell.exe" : "sh";
370
+ const args = isWindows ? ["-NoProfile", "-Command", INSTALL_PS] : ["-c", INSTALL_SH];
371
+ return new Promise((resolve) => {
372
+ const child = spawn(command, args, { stdio: "inherit" });
373
+ child.on("error", (err) => {
374
+ p2.log.error(`Could not run the installer: ${err.message}`);
375
+ resolve(false);
376
+ });
377
+ child.on("close", (code) => resolve(code === 0));
378
+ });
379
+ }
380
+ async function offerDenoInstall(options = {}) {
381
+ const existing = findDeno();
382
+ if (existing) {
383
+ ensureDenoOnPath(existing);
384
+ p2.log.info(`Found Deno at ${existing} \u2014 using it for this run.`);
385
+ return true;
386
+ }
387
+ if (options.canPrompt === false || !process.stdin.isTTY) {
388
+ showManualInstructions();
389
+ return false;
390
+ }
391
+ p2.log.info(`Install command: ${denoInstallCommand()}`);
392
+ const answer = await p2.confirm({
393
+ message: "Install Deno now?",
394
+ initialValue: true
395
+ });
396
+ if (p2.isCancel(answer) || !answer) {
397
+ showManualInstructions();
398
+ return false;
399
+ }
400
+ p2.log.step("Installing Deno\u2026");
401
+ const ok = await runInstallCommand();
402
+ if (!ok) {
403
+ p2.log.error("Deno installation failed.");
404
+ showManualInstructions();
405
+ return false;
406
+ }
407
+ const installed = findDeno();
408
+ if (!installed) {
409
+ p2.log.warn(
410
+ "The installer finished but no deno binary was found. Open a new terminal and re-run easy-ytdlp."
411
+ );
412
+ return false;
413
+ }
414
+ ensureDenoOnPath(installed);
415
+ p2.log.success(`Deno installed: ${installed}`);
416
+ return true;
417
+ }
418
+
419
+ // src/youtube-compat.ts
257
420
  var MIN_RECOMMENDED_MAJOR = 22;
258
421
  function nodeMajor(version) {
259
422
  const m = version.replace(/^v/, "").split(".")[0];
260
423
  return Number(m) || 0;
261
424
  }
262
- function isExecutable2(filePath) {
425
+ function isExecutable3(filePath) {
263
426
  try {
264
- accessSync(filePath, constants3.X_OK);
427
+ accessSync2(filePath, constants4.X_OK);
265
428
  return true;
266
429
  } catch {
267
430
  return false;
@@ -274,7 +437,7 @@ function resolveNodeForYtDlp() {
274
437
  }
275
438
  const nvmDir = process.env.NVM_DIR;
276
439
  if (nvmDir) {
277
- const versionsRoot = join3(nvmDir, "versions", "node");
440
+ const versionsRoot = join4(nvmDir, "versions", "node");
278
441
  try {
279
442
  const dirs = readdirSync(versionsRoot).filter((name) => /^v\d+\.\d+\.\d+$/.test(name)).sort((a, b) => {
280
443
  const pa = a.slice(1).split(".").map(Number);
@@ -288,8 +451,8 @@ function resolveNodeForYtDlp() {
288
451
  for (const dir of dirs) {
289
452
  const major = nodeMajor(dir);
290
453
  if (major < MIN_RECOMMENDED_MAJOR) continue;
291
- const candidate = join3(versionsRoot, dir, "bin", "node");
292
- if (isExecutable2(candidate)) {
454
+ const candidate = join4(versionsRoot, dir, "bin", "node");
455
+ if (isExecutable3(candidate)) {
293
456
  return { path: candidate, major };
294
457
  }
295
458
  }
@@ -300,7 +463,9 @@ function resolveNodeForYtDlp() {
300
463
  }
301
464
  function youtubeCompatFlags() {
302
465
  const { path } = resolveNodeForYtDlp();
466
+ const deno = findDeno();
303
467
  return [
468
+ ...deno ? ["--js-runtimes", `deno:${deno}`] : [],
304
469
  "--js-runtimes",
305
470
  `node:${path}`,
306
471
  // Fallback if the cached binary's bundled EJS scripts are missing/outdated
@@ -326,14 +491,15 @@ function humanizeError(raw) {
326
491
  `yt-dlp said: ${raw.trim()}`
327
492
  ].join("\n");
328
493
  }
329
- if (lower.includes("http error 403") || lower.includes("403: forbidden") || lower.includes("javascript runtime") || lower.includes("js challenge") || lower.includes("n challenge")) {
494
+ if (isJsRuntimeError(lower)) {
330
495
  return [
331
496
  "YouTube blocked the download (often HTTP 403) \u2014 usually because a JavaScript runtime is needed to solve YouTube challenges.",
332
497
  "",
333
- "easy-ytdlp now enables Node automatically. If this still fails:",
498
+ "easy-ytdlp enables Node automatically, but yt-dlp solves these challenges most reliably with Deno.",
499
+ "",
500
+ "If Deno does not help, also try:",
334
501
  " 1. Update the yt-dlp binary: easy-ytdlp update-binary",
335
502
  " 2. Use Node 22+ (recommended by yt-dlp for the JS solver)",
336
- " 3. Or install Deno: https://deno.land (yt-dlp\u2019s preferred runtime)",
337
503
  "",
338
504
  "More detail: https://github.com/yt-dlp/yt-dlp/wiki/EJS",
339
505
  "",
@@ -391,8 +557,15 @@ function isPlaylistOnlyUrl(url) {
391
557
  return true;
392
558
  }
393
559
  function metadataArgs(url) {
394
- const scopeFlags = isPlaylistOnlyUrl(url) ? ["--flat-playlist", "-f", "best"] : ["--no-playlist"];
395
- return [...youtubeCompatFlags(), ...scopeFlags, url];
560
+ const scopeFlags = isPlaylistOnlyUrl(url) ? ["--flat-playlist"] : ["--no-playlist"];
561
+ return [
562
+ ...youtubeCompatFlags(),
563
+ ...scopeFlags,
564
+ "--ignore-no-formats-error",
565
+ "-f",
566
+ "b",
567
+ url
568
+ ];
396
569
  }
397
570
  function normalizePlaylistMeta(raw, url) {
398
571
  const entries = Array.isArray(raw) ? raw : raw && typeof raw === "object" ? [raw] : [];
@@ -753,16 +926,16 @@ function looksLikeUrl(s) {
753
926
  }
754
927
 
755
928
  // src/questions.ts
756
- import * as p2 from "@clack/prompts";
757
- import { homedir } from "os";
758
- import { join as join5 } from "path";
929
+ import * as p3 from "@clack/prompts";
930
+ import { homedir as homedir2 } from "os";
931
+ import { join as join6 } from "path";
759
932
 
760
933
  // src/preferences.ts
761
934
  import { mkdir as mkdir2, readFile, writeFile } from "fs/promises";
762
- import { dirname, join as join4 } from "path";
935
+ import { dirname as dirname2, join as join5 } from "path";
763
936
  import envPaths2 from "env-paths";
764
937
  var paths2 = envPaths2("easy-ytdlp");
765
- var PREFS_PATH = join4(paths2.config, "preferences.json");
938
+ var PREFS_PATH = join5(paths2.config, "preferences.json");
766
939
  async function loadPreferences() {
767
940
  try {
768
941
  const raw = await readFile(PREFS_PATH, "utf-8");
@@ -773,61 +946,18 @@ async function loadPreferences() {
773
946
  }
774
947
  }
775
948
  async function savePreferences(prefs) {
776
- await mkdir2(dirname(PREFS_PATH), { recursive: true });
949
+ await mkdir2(dirname2(PREFS_PATH), { recursive: true });
777
950
  await writeFile(PREFS_PATH, `${JSON.stringify(prefs, null, 2)}
778
951
  `, "utf-8");
779
952
  }
780
953
 
781
- // src/ui.ts
782
- import * as p from "@clack/prompts";
783
- function contentWidth(pad = 8) {
784
- const cols = process.stdout.columns ?? 80;
785
- return Math.max(40, cols - pad);
786
- }
787
- function wrapText(text2, width = contentWidth()) {
788
- return text2.split(/\r?\n/).flatMap((line) => wrapLine(line, width)).join("\n");
789
- }
790
- function wrapLine(line, width) {
791
- if (line.length <= width) return [line || " "];
792
- const out = [];
793
- let rest = line;
794
- while (rest.length > width) {
795
- let breakAt = rest.lastIndexOf(" ", width);
796
- if (breakAt < Math.floor(width * 0.5)) breakAt = width;
797
- out.push(rest.slice(0, breakAt).trimEnd());
798
- rest = rest.slice(breakAt).trimStart();
799
- }
800
- if (rest.length) out.push(rest);
801
- return out;
802
- }
803
- function showNote(body, title) {
804
- p.note(wrapText(body, contentWidth(14)), title);
805
- }
806
- function showSaved(paths3) {
807
- if (paths3.length === 0) return;
808
- if (paths3.length === 1) {
809
- p.log.success(paths3[0]);
810
- return;
811
- }
812
- p.log.success(`Saved ${paths3.length} files`);
813
- for (const filePath of paths3) {
814
- p.log.info(filePath);
815
- }
816
- }
817
- function showCommand(command) {
818
- p.log.step("yt-dlp command");
819
- for (const line of wrapText(command, contentWidth(4)).split("\n")) {
820
- console.log(` ${line}`);
821
- }
822
- }
823
-
824
954
  // src/questions.ts
825
- function isCancel2(value) {
826
- return p2.isCancel(value);
955
+ function isCancel3(value) {
956
+ return p3.isCancel(value);
827
957
  }
828
958
  function exitOnCancel(value) {
829
- if (isCancel2(value)) {
830
- p2.cancel("Cancelled.");
959
+ if (isCancel3(value)) {
960
+ p3.cancel("Cancelled.");
831
961
  process.exit(0);
832
962
  }
833
963
  }
@@ -937,13 +1067,13 @@ async function promptUrls(initial) {
937
1067
  if (initial && initial.length > 0) {
938
1068
  for (const u of initial) {
939
1069
  if (!looksLikeUrl2(u)) {
940
- p2.log.error(`Invalid URL: ${u}`);
1070
+ p3.log.error(`Invalid URL: ${u}`);
941
1071
  process.exit(1);
942
1072
  }
943
1073
  }
944
1074
  return initial;
945
1075
  }
946
- const input = await p2.text({
1076
+ const input = await p3.text({
947
1077
  message: "Paste video URL(s)",
948
1078
  placeholder: "One or more URLs, separated by spaces or commas",
949
1079
  initialValue: "",
@@ -960,7 +1090,7 @@ async function promptUrls(initial) {
960
1090
  return splitUrlList(String(input).trim());
961
1091
  }
962
1092
  async function askQuestions(url, meta) {
963
- const mode = await p2.select({
1093
+ const mode = await p3.select({
964
1094
  message: "What do you want to download?",
965
1095
  options: [
966
1096
  { value: "video", label: "Video (with audio)" },
@@ -995,13 +1125,13 @@ async function askQuestions(url, meta) {
995
1125
  label: "Choose from available resolutions\u2026"
996
1126
  });
997
1127
  }
998
- const q = await p2.select({
1128
+ const q = await p3.select({
999
1129
  message: "Video quality?",
1000
1130
  options: qualityOpts
1001
1131
  });
1002
1132
  exitOnCancel(q);
1003
1133
  if (q === "pick") {
1004
- const picked = await p2.select({
1134
+ const picked = await p3.select({
1005
1135
  message: "Available resolutions",
1006
1136
  options: resolutions.map((h) => ({
1007
1137
  value: String(h),
@@ -1017,7 +1147,7 @@ async function askQuestions(url, meta) {
1017
1147
  }
1018
1148
  }
1019
1149
  if (selectedMode === "audio") {
1020
- const fmt = await p2.select({
1150
+ const fmt = await p3.select({
1021
1151
  message: "Audio format?",
1022
1152
  options: [
1023
1153
  { value: "mp3", label: "mp3" },
@@ -1030,7 +1160,7 @@ async function askQuestions(url, meta) {
1030
1160
  });
1031
1161
  exitOnCancel(fmt);
1032
1162
  audioFormat = fmt;
1033
- const aq = await p2.select({
1163
+ const aq = await p3.select({
1034
1164
  message: "Audio quality?",
1035
1165
  options: [
1036
1166
  { value: "best", label: "Best" },
@@ -1044,10 +1174,10 @@ async function askQuestions(url, meta) {
1044
1174
  subMode = "write";
1045
1175
  const langs = availableSubtitleLangs(meta);
1046
1176
  if (langs.length === 0) {
1047
- p2.log.warn('No subtitle languages found in metadata \u2014 will request "all".');
1177
+ p3.log.warn('No subtitle languages found in metadata \u2014 will request "all".');
1048
1178
  subLangs = ["all"];
1049
1179
  } else {
1050
- const picked = await p2.multiselect({
1180
+ const picked = await p3.multiselect({
1051
1181
  message: "Which subtitle languages?",
1052
1182
  options: [
1053
1183
  { value: "all", label: "All languages" },
@@ -1061,8 +1191,8 @@ async function askQuestions(url, meta) {
1061
1191
  }
1062
1192
  }
1063
1193
  const prefs = await loadPreferences();
1064
- const defaultDir = prefs.outputDir ?? join5(homedir(), "Downloads");
1065
- const outDir = await p2.text({
1194
+ const defaultDir = prefs.outputDir ?? join6(homedir2(), "Downloads");
1195
+ const outDir = await p3.text({
1066
1196
  message: "Destination folder",
1067
1197
  initialValue: defaultDir,
1068
1198
  validate: (v) => !v?.trim() ? "Folder is required" : void 0
@@ -1070,13 +1200,13 @@ async function askQuestions(url, meta) {
1070
1200
  exitOnCancel(outDir);
1071
1201
  const outputDir = String(outDir).trim();
1072
1202
  await savePreferences({ ...prefs, outputDir });
1073
- const customize = selectedMode === "subs-only" || selectedMode === "thumbnail-only" ? false : await p2.confirm({
1203
+ const customize = selectedMode === "subs-only" || selectedMode === "thumbnail-only" ? false : await p3.confirm({
1074
1204
  message: "Customize advanced options?",
1075
1205
  initialValue: false
1076
1206
  });
1077
1207
  exitOnCancel(customize);
1078
1208
  if (customize && (selectedMode === "video" || selectedMode === "video-only")) {
1079
- const c = await p2.select({
1209
+ const c = await p3.select({
1080
1210
  message: "Container preference?",
1081
1211
  options: [
1082
1212
  { value: "best", label: "Best available" },
@@ -1089,7 +1219,7 @@ async function askQuestions(url, meta) {
1089
1219
  container = c;
1090
1220
  }
1091
1221
  if (customize && selectedMode !== "thumbnail-only") {
1092
- const wantSubs = await p2.confirm({
1222
+ const wantSubs = await p3.confirm({
1093
1223
  message: "Download subtitles?",
1094
1224
  initialValue: false
1095
1225
  });
@@ -1097,10 +1227,10 @@ async function askQuestions(url, meta) {
1097
1227
  if (wantSubs) {
1098
1228
  const langs = availableSubtitleLangs(meta);
1099
1229
  if (langs.length === 0) {
1100
- p2.log.warn('No subtitle languages found \u2014 will request "all".');
1230
+ p3.log.warn('No subtitle languages found \u2014 will request "all".');
1101
1231
  subLangs = ["all"];
1102
1232
  } else {
1103
- const picked = await p2.multiselect({
1233
+ const picked = await p3.multiselect({
1104
1234
  message: "Which subtitle languages?",
1105
1235
  options: [
1106
1236
  { value: "all", label: "All languages" },
@@ -1112,7 +1242,7 @@ async function askQuestions(url, meta) {
1112
1242
  const sel = picked;
1113
1243
  subLangs = sel.includes("all") ? ["all"] : sel;
1114
1244
  }
1115
- const how = await p2.select({
1245
+ const how = await p3.select({
1116
1246
  message: "How should subtitles be saved?",
1117
1247
  options: [
1118
1248
  { value: "embed", label: "Embed in the video" },
@@ -1124,7 +1254,7 @@ async function askQuestions(url, meta) {
1124
1254
  subMode = how;
1125
1255
  }
1126
1256
  if (isPlaylistUrl(url, meta)) {
1127
- const pl = await p2.select({
1257
+ const pl = await p3.select({
1128
1258
  message: "This URL is part of a playlist. What should we download?",
1129
1259
  options: [
1130
1260
  { value: "single", label: "Just this video" },
@@ -1138,7 +1268,7 @@ async function askQuestions(url, meta) {
1138
1268
  } else if (pl === "all") {
1139
1269
  playlist = { kind: "all" };
1140
1270
  } else {
1141
- const start = await p2.text({
1271
+ const start = await p3.text({
1142
1272
  message: "Playlist start index (1-based)",
1143
1273
  initialValue: "1",
1144
1274
  validate: (v) => {
@@ -1147,7 +1277,7 @@ async function askQuestions(url, meta) {
1147
1277
  }
1148
1278
  });
1149
1279
  exitOnCancel(start);
1150
- const stop = await p2.text({
1280
+ const stop = await p3.text({
1151
1281
  message: "Playlist stop index (inclusive)",
1152
1282
  initialValue: String(meta.playlist_count ?? 10),
1153
1283
  validate: (v) => {
@@ -1163,7 +1293,7 @@ async function askQuestions(url, meta) {
1163
1293
  };
1164
1294
  }
1165
1295
  }
1166
- const pickedFilename = await p2.select({
1296
+ const pickedFilename = await p3.select({
1167
1297
  message: "Filename style?",
1168
1298
  options: [
1169
1299
  { value: "title", label: "Title only" },
@@ -1179,7 +1309,7 @@ async function askQuestions(url, meta) {
1179
1309
  });
1180
1310
  exitOnCancel(pickedFilename);
1181
1311
  filenamePreset = pickedFilename;
1182
- const e = await p2.multiselect({
1312
+ const e = await p3.multiselect({
1183
1313
  message: "Extras (optional)",
1184
1314
  options: [
1185
1315
  {
@@ -1221,9 +1351,19 @@ async function askQuestions(url, meta) {
1221
1351
  }
1222
1352
 
1223
1353
  // src/cli.ts
1224
- var CLI_VERSION = "1.1.3";
1354
+ function readCliVersion() {
1355
+ try {
1356
+ const pkg = JSON.parse(
1357
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
1358
+ );
1359
+ return pkg.version ?? "0.0.0";
1360
+ } catch {
1361
+ return "0.0.0";
1362
+ }
1363
+ }
1364
+ var CLI_VERSION = readCliVersion();
1225
1365
  function fail(message) {
1226
- p3.log.error(message);
1366
+ p4.log.error(message);
1227
1367
  process.exit(1);
1228
1368
  }
1229
1369
  function pick(value, allowed, name, fallback) {
@@ -1326,13 +1466,13 @@ function displayFlagsForAnswers(answers) {
1326
1466
  }
1327
1467
  function showCommandForAnswers(answers) {
1328
1468
  if (answers.urls.length > 1) {
1329
- p3.log.info("Showing the command for the first URL. It will be repeated for each URL.");
1469
+ p4.log.info("Showing the command for the first URL. It will be repeated for each URL.");
1330
1470
  }
1331
1471
  showCommand(formatCommand(displayFlagsForAnswers(answers)));
1332
1472
  }
1333
1473
  async function confirmStart(answers) {
1334
1474
  while (true) {
1335
- const action = await p3.select({
1475
+ const action = await p4.select({
1336
1476
  message: "Ready?",
1337
1477
  options: [
1338
1478
  { value: "start", label: "Start download" },
@@ -1341,8 +1481,8 @@ async function confirmStart(answers) {
1341
1481
  { value: "cancel", label: "Cancel" }
1342
1482
  ]
1343
1483
  });
1344
- if (p3.isCancel(action) || action === "cancel") {
1345
- p3.cancel("Cancelled.");
1484
+ if (p4.isCancel(action) || action === "cancel") {
1485
+ p4.cancel("Cancelled.");
1346
1486
  process.exit(0);
1347
1487
  }
1348
1488
  if (action === "show-command") {
@@ -1363,41 +1503,143 @@ async function ensureFfmpegIfNeeded(answers) {
1363
1503
  }
1364
1504
  const status = await checkFfmpeg();
1365
1505
  if (status.ok) return;
1366
- p3.log.warn(
1506
+ p4.log.warn(
1367
1507
  [
1368
1508
  "ffmpeg/ffprobe not found on PATH.",
1369
1509
  ffmpegInstallHint()
1370
1510
  ].join("\n\n")
1371
1511
  );
1372
- const cont = await p3.confirm({
1512
+ const cont = await p4.confirm({
1373
1513
  message: "Continue anyway? (download may fail at merge/extract)",
1374
1514
  initialValue: false
1375
1515
  });
1376
- if (p3.isCancel(cont) || !cont) {
1377
- p3.cancel("Cancelled.");
1516
+ if (p4.isCancel(cont) || !cont) {
1517
+ p4.cancel("Cancelled.");
1378
1518
  process.exit(0);
1379
1519
  }
1380
1520
  }
1381
1521
  async function runDownloads(ytDlp, answers) {
1382
- const allPaths = [];
1522
+ const filepaths = [];
1523
+ const failures = [];
1383
1524
  for (let i = 0; i < answers.urls.length; i++) {
1384
1525
  const url = answers.urls[i];
1385
1526
  const label = answers.urls.length > 1 ? `${i + 1} of ${answers.urls.length}` : void 0;
1386
1527
  const flags = buildFlags({ ...answers, urls: [url] });
1387
- if (label) p3.log.info(`Download ${label}`);
1528
+ if (label) p4.log.info(`Download ${label}`);
1388
1529
  try {
1389
1530
  const result = await runDownload(ytDlp, flags, { label });
1390
- allPaths.push(...result.filepaths);
1531
+ filepaths.push(...result.filepaths);
1391
1532
  } catch (err) {
1392
- if (answers.urls.length === 1) throw err;
1393
- p3.log.warn(err instanceof Error ? err.message : String(err));
1533
+ failures.push({
1534
+ url,
1535
+ message: err instanceof Error ? err.message : String(err)
1536
+ });
1537
+ }
1538
+ }
1539
+ return { filepaths, failures };
1540
+ }
1541
+ async function gatherMetadata(ytDlp, urls, spinner2) {
1542
+ for (let attempt = 0; ; attempt++) {
1543
+ spinner2.start(
1544
+ attempt === 0 ? "Fetching video info\u2026" : "Fetching video info with Deno\u2026"
1545
+ );
1546
+ const metaResults = await Promise.allSettled(
1547
+ urls.map((u) => fetchMetadata(ytDlp, u))
1548
+ );
1549
+ const videoInfos = [];
1550
+ const failedUrls = [];
1551
+ for (let i = 0; i < metaResults.length; i++) {
1552
+ const r = metaResults[i];
1553
+ const u = urls[i];
1554
+ if (r.status === "fulfilled") {
1555
+ const m = r.value;
1556
+ const isPlaylist = m._type === "playlist";
1557
+ videoInfos.push({
1558
+ url: u,
1559
+ meta: m,
1560
+ title: m.title ?? "Unknown title",
1561
+ uploader: m.uploader ?? "Unknown uploader",
1562
+ duration: isPlaylist ? `${m.playlist_count ?? "?"} videos` : formatDuration(m.duration)
1563
+ });
1564
+ } else {
1565
+ failedUrls.push(u);
1566
+ }
1567
+ }
1568
+ if (videoInfos.length > 0) {
1569
+ spinner2.stop("Metadata loaded");
1570
+ return { videoInfos, failedUrls };
1571
+ }
1572
+ spinner2.stop("Could not fetch metadata");
1573
+ const reasons = [
1574
+ ...new Set(
1575
+ metaResults.filter((r) => r.status === "rejected").map(
1576
+ (r) => r.reason instanceof Error ? r.reason.message : String(r.reason)
1577
+ )
1578
+ )
1579
+ ];
1580
+ p4.log.error(
1581
+ [
1582
+ "Failed to fetch metadata for any of the provided URLs.",
1583
+ ...reasons
1584
+ ].join("\n\n")
1585
+ );
1586
+ if (attempt === 0 && reasons.some(isJsRuntimeError) && await offerDenoInstall()) {
1587
+ continue;
1588
+ }
1589
+ process.exit(1);
1590
+ }
1591
+ }
1592
+ function reportSaved(filepaths, answers) {
1593
+ if (filepaths.length > 0) {
1594
+ showSaved(filepaths);
1595
+ return;
1596
+ }
1597
+ p4.log.success("Done. (No filepath printed \u2014 check your output folder.)");
1598
+ p4.log.info(`Output folder: ${answers.outputDir}`);
1599
+ }
1600
+ async function downloadAndReport(ytDlp, answers, options = {}) {
1601
+ const allPaths = [];
1602
+ let pending = answers.urls;
1603
+ for (let attempt = 0; ; attempt++) {
1604
+ p4.log.info(
1605
+ attempt === 0 ? "Starting download\u2026" : "Retrying the failed downloads with Deno\u2026"
1606
+ );
1607
+ const { filepaths, failures } = await runDownloads(ytDlp, {
1608
+ ...answers,
1609
+ urls: pending
1610
+ });
1611
+ allPaths.push(...filepaths);
1612
+ if (failures.length === 0) {
1613
+ reportSaved(allPaths, answers);
1614
+ p4.outro("Finished");
1615
+ return;
1616
+ }
1617
+ for (const failure of failures) {
1618
+ p4.log.error(
1619
+ answers.urls.length > 1 ? `${failure.url}
1620
+ ${failure.message}` : failure.message
1621
+ );
1622
+ }
1623
+ const jsRuntimeFailures = failures.filter(
1624
+ (f) => isJsRuntimeError(f.message)
1625
+ );
1626
+ if (attempt === 0 && jsRuntimeFailures.length > 0 && await offerDenoInstall({ canPrompt: options.canPrompt })) {
1627
+ pending = jsRuntimeFailures.map((f) => f.url);
1628
+ continue;
1629
+ }
1630
+ if (allPaths.length > 0) {
1631
+ reportSaved(allPaths, answers);
1632
+ p4.log.warn(`${failures.length} download(s) failed.`);
1633
+ p4.outro("Finished with errors");
1634
+ return;
1394
1635
  }
1636
+ p4.outro("Failed");
1637
+ process.exit(1);
1395
1638
  }
1396
- return allPaths;
1397
1639
  }
1398
1640
  async function runWizard(urlsArg, opts = {}) {
1399
- p3.intro("easy-ytdlp");
1400
- const spinner2 = p3.spinner();
1641
+ p4.intro("easy-ytdlp");
1642
+ const spinner2 = p4.spinner();
1401
1643
  spinner2.start("Preparing yt-dlp binary\u2026");
1402
1644
  let ytDlp;
1403
1645
  try {
@@ -1409,7 +1651,7 @@ async function runWizard(urlsArg, opts = {}) {
1409
1651
  spinner2.stop("yt-dlp ready");
1410
1652
  } catch (err) {
1411
1653
  spinner2.stop("Binary setup failed");
1412
- p3.log.error(err instanceof Error ? err.message : String(err));
1654
+ p4.log.error(err instanceof Error ? err.message : String(err));
1413
1655
  process.exit(1);
1414
1656
  }
1415
1657
  const urls = await promptUrls(urlsArg);
@@ -1420,52 +1662,10 @@ async function runWizard(urlsArg, opts = {}) {
1420
1662
  showCommandForAnswers(answers2);
1421
1663
  }
1422
1664
  await ensureFfmpegIfNeeded(answers2);
1423
- p3.log.info("Starting download\u2026");
1424
- try {
1425
- const filepaths = await runDownloads(ytDlp, answers2);
1426
- if (filepaths.length > 0) {
1427
- showSaved(filepaths);
1428
- } else {
1429
- p3.log.success("Done. (No filepath printed \u2014 check your output folder.)");
1430
- p3.log.info(`Output folder: ${answers2.outputDir}`);
1431
- }
1432
- p3.outro("Finished");
1433
- } catch (err) {
1434
- p3.log.error(err instanceof Error ? err.message : String(err));
1435
- p3.outro("Failed");
1436
- process.exit(1);
1437
- }
1665
+ await downloadAndReport(ytDlp, answers2, { canPrompt: false });
1438
1666
  return;
1439
1667
  }
1440
- spinner2.start("Fetching video info\u2026");
1441
- const metaResults = await Promise.allSettled(
1442
- urls.map((u) => fetchMetadata(ytDlp, u))
1443
- );
1444
- const videoInfos = [];
1445
- const failedUrls = [];
1446
- for (let i = 0; i < metaResults.length; i++) {
1447
- const r = metaResults[i];
1448
- const u = urls[i];
1449
- if (r.status === "fulfilled") {
1450
- const m = r.value;
1451
- const isPlaylist = m._type === "playlist";
1452
- videoInfos.push({
1453
- url: u,
1454
- meta: m,
1455
- title: m.title ?? "Unknown title",
1456
- uploader: m.uploader ?? "Unknown uploader",
1457
- duration: isPlaylist ? `${m.playlist_count ?? "?"} videos` : formatDuration(m.duration)
1458
- });
1459
- } else {
1460
- failedUrls.push(u);
1461
- }
1462
- }
1463
- if (videoInfos.length === 0) {
1464
- spinner2.stop("Could not fetch metadata");
1465
- p3.log.error("Failed to fetch metadata for any of the provided URLs.");
1466
- process.exit(1);
1467
- }
1468
- spinner2.stop("Metadata loaded");
1668
+ const { videoInfos, failedUrls } = await gatherMetadata(ytDlp, urls, spinner2);
1469
1669
  if (videoInfos.length === 1) {
1470
1670
  const v = videoInfos[0];
1471
1671
  showNote(`${v.title}
@@ -1478,13 +1678,13 @@ by ${v.uploader} \xB7 ${v.duration}`, "Found");
1478
1678
  showNote(lines.join("\n\n"), `Found (${videoInfos.length} videos)`);
1479
1679
  }
1480
1680
  if (failedUrls.length > 0) {
1481
- p3.log.warn(
1681
+ p4.log.warn(
1482
1682
  `Could not fetch metadata for ${failedUrls.length} URL(s). They will still be downloaded with shared settings.`
1483
1683
  );
1484
1684
  }
1485
1685
  const primary = videoInfos[0];
1486
1686
  if (urls.length > 1) {
1487
- p3.log.info(`Using shared settings based on: ${primary.title}`);
1687
+ p4.log.info(`Using shared settings based on: ${primary.title}`);
1488
1688
  }
1489
1689
  let answers;
1490
1690
  while (true) {
@@ -1500,33 +1700,19 @@ by ${v.uploader} \xB7 ${v.duration}`, "Found");
1500
1700
  if (action === "start") break;
1501
1701
  }
1502
1702
  await ensureFfmpegIfNeeded(answers);
1503
- p3.log.info("Starting download\u2026");
1504
- try {
1505
- const filepaths = await runDownloads(ytDlp, answers);
1506
- if (filepaths.length > 0) {
1507
- showSaved(filepaths);
1508
- } else {
1509
- p3.log.success("Done. (No filepath printed \u2014 check your output folder.)");
1510
- p3.log.info(`Output folder: ${answers.outputDir}`);
1511
- }
1512
- p3.outro("Finished");
1513
- } catch (err) {
1514
- p3.log.error(err instanceof Error ? err.message : String(err));
1515
- p3.outro("Failed");
1516
- process.exit(1);
1517
- }
1703
+ await downloadAndReport(ytDlp, answers);
1518
1704
  }
1519
1705
  async function runUpdateBinary() {
1520
- p3.intro("easy-ytdlp update-binary");
1521
- const spinner2 = p3.spinner();
1706
+ p4.intro("easy-ytdlp update-binary");
1707
+ const spinner2 = p4.spinner();
1522
1708
  spinner2.start("Refreshing yt-dlp binary\u2026");
1523
1709
  try {
1524
1710
  const path = await updateBinary((msg) => spinner2.message(msg));
1525
1711
  spinner2.stop(`Updated: ${path}`);
1526
- p3.outro("Binary update complete");
1712
+ p4.outro("Binary update complete");
1527
1713
  } catch (err) {
1528
1714
  spinner2.stop("Update failed");
1529
- p3.log.error(err instanceof Error ? err.message : String(err));
1715
+ p4.log.error(err instanceof Error ? err.message : String(err));
1530
1716
  process.exit(1);
1531
1717
  }
1532
1718
  }
@@ -1550,7 +1736,7 @@ program.name("easy-ytdlp").description(
1550
1736
  content = readFileSync(options.batchFile, "utf-8");
1551
1737
  } catch (err) {
1552
1738
  const detail = err instanceof Error ? err.message : String(err);
1553
- p3.log.error(`Could not read batch file "${options.batchFile}": ${detail}`);
1739
+ p4.log.error(`Could not read batch file "${options.batchFile}": ${detail}`);
1554
1740
  process.exit(1);
1555
1741
  }
1556
1742
  const fileUrls = content.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jnzlab/easy-ytdlp",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "A user-friendly interactive CLI wrapper for yt-dlp — no Python or flag memorization required",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",
@@ -47,7 +47,6 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@clack/prompts": "^0.10.0",
50
- "@jnzlab/easy-ytdlp": "^1.1.0",
51
50
  "cli-progress": "^3.12.0",
52
51
  "commander": "^13.1.0",
53
52
  "env-paths": "^3.0.0",