@jnzlab/easy-ytdlp 1.1.4 → 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 +12 -0
  2. package/dist/cli.js +339 -180
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
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
+
3
15
  ## 1.1.4 - 2026-09-08
4
16
 
5
17
  ### Fixed
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
  "",
@@ -760,16 +926,16 @@ function looksLikeUrl(s) {
760
926
  }
761
927
 
762
928
  // src/questions.ts
763
- import * as p2 from "@clack/prompts";
764
- import { homedir } from "os";
765
- 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";
766
932
 
767
933
  // src/preferences.ts
768
934
  import { mkdir as mkdir2, readFile, writeFile } from "fs/promises";
769
- import { dirname, join as join4 } from "path";
935
+ import { dirname as dirname2, join as join5 } from "path";
770
936
  import envPaths2 from "env-paths";
771
937
  var paths2 = envPaths2("easy-ytdlp");
772
- var PREFS_PATH = join4(paths2.config, "preferences.json");
938
+ var PREFS_PATH = join5(paths2.config, "preferences.json");
773
939
  async function loadPreferences() {
774
940
  try {
775
941
  const raw = await readFile(PREFS_PATH, "utf-8");
@@ -780,61 +946,18 @@ async function loadPreferences() {
780
946
  }
781
947
  }
782
948
  async function savePreferences(prefs) {
783
- await mkdir2(dirname(PREFS_PATH), { recursive: true });
949
+ await mkdir2(dirname2(PREFS_PATH), { recursive: true });
784
950
  await writeFile(PREFS_PATH, `${JSON.stringify(prefs, null, 2)}
785
951
  `, "utf-8");
786
952
  }
787
953
 
788
- // src/ui.ts
789
- import * as p from "@clack/prompts";
790
- function contentWidth(pad = 8) {
791
- const cols = process.stdout.columns ?? 80;
792
- return Math.max(40, cols - pad);
793
- }
794
- function wrapText(text2, width = contentWidth()) {
795
- return text2.split(/\r?\n/).flatMap((line) => wrapLine(line, width)).join("\n");
796
- }
797
- function wrapLine(line, width) {
798
- if (line.length <= width) return [line || " "];
799
- const out = [];
800
- let rest = line;
801
- while (rest.length > width) {
802
- let breakAt = rest.lastIndexOf(" ", width);
803
- if (breakAt < Math.floor(width * 0.5)) breakAt = width;
804
- out.push(rest.slice(0, breakAt).trimEnd());
805
- rest = rest.slice(breakAt).trimStart();
806
- }
807
- if (rest.length) out.push(rest);
808
- return out;
809
- }
810
- function showNote(body, title) {
811
- p.note(wrapText(body, contentWidth(14)), title);
812
- }
813
- function showSaved(paths3) {
814
- if (paths3.length === 0) return;
815
- if (paths3.length === 1) {
816
- p.log.success(paths3[0]);
817
- return;
818
- }
819
- p.log.success(`Saved ${paths3.length} files`);
820
- for (const filePath of paths3) {
821
- p.log.info(filePath);
822
- }
823
- }
824
- function showCommand(command) {
825
- p.log.step("yt-dlp command");
826
- for (const line of wrapText(command, contentWidth(4)).split("\n")) {
827
- console.log(` ${line}`);
828
- }
829
- }
830
-
831
954
  // src/questions.ts
832
- function isCancel2(value) {
833
- return p2.isCancel(value);
955
+ function isCancel3(value) {
956
+ return p3.isCancel(value);
834
957
  }
835
958
  function exitOnCancel(value) {
836
- if (isCancel2(value)) {
837
- p2.cancel("Cancelled.");
959
+ if (isCancel3(value)) {
960
+ p3.cancel("Cancelled.");
838
961
  process.exit(0);
839
962
  }
840
963
  }
@@ -944,13 +1067,13 @@ async function promptUrls(initial) {
944
1067
  if (initial && initial.length > 0) {
945
1068
  for (const u of initial) {
946
1069
  if (!looksLikeUrl2(u)) {
947
- p2.log.error(`Invalid URL: ${u}`);
1070
+ p3.log.error(`Invalid URL: ${u}`);
948
1071
  process.exit(1);
949
1072
  }
950
1073
  }
951
1074
  return initial;
952
1075
  }
953
- const input = await p2.text({
1076
+ const input = await p3.text({
954
1077
  message: "Paste video URL(s)",
955
1078
  placeholder: "One or more URLs, separated by spaces or commas",
956
1079
  initialValue: "",
@@ -967,7 +1090,7 @@ async function promptUrls(initial) {
967
1090
  return splitUrlList(String(input).trim());
968
1091
  }
969
1092
  async function askQuestions(url, meta) {
970
- const mode = await p2.select({
1093
+ const mode = await p3.select({
971
1094
  message: "What do you want to download?",
972
1095
  options: [
973
1096
  { value: "video", label: "Video (with audio)" },
@@ -1002,13 +1125,13 @@ async function askQuestions(url, meta) {
1002
1125
  label: "Choose from available resolutions\u2026"
1003
1126
  });
1004
1127
  }
1005
- const q = await p2.select({
1128
+ const q = await p3.select({
1006
1129
  message: "Video quality?",
1007
1130
  options: qualityOpts
1008
1131
  });
1009
1132
  exitOnCancel(q);
1010
1133
  if (q === "pick") {
1011
- const picked = await p2.select({
1134
+ const picked = await p3.select({
1012
1135
  message: "Available resolutions",
1013
1136
  options: resolutions.map((h) => ({
1014
1137
  value: String(h),
@@ -1024,7 +1147,7 @@ async function askQuestions(url, meta) {
1024
1147
  }
1025
1148
  }
1026
1149
  if (selectedMode === "audio") {
1027
- const fmt = await p2.select({
1150
+ const fmt = await p3.select({
1028
1151
  message: "Audio format?",
1029
1152
  options: [
1030
1153
  { value: "mp3", label: "mp3" },
@@ -1037,7 +1160,7 @@ async function askQuestions(url, meta) {
1037
1160
  });
1038
1161
  exitOnCancel(fmt);
1039
1162
  audioFormat = fmt;
1040
- const aq = await p2.select({
1163
+ const aq = await p3.select({
1041
1164
  message: "Audio quality?",
1042
1165
  options: [
1043
1166
  { value: "best", label: "Best" },
@@ -1051,10 +1174,10 @@ async function askQuestions(url, meta) {
1051
1174
  subMode = "write";
1052
1175
  const langs = availableSubtitleLangs(meta);
1053
1176
  if (langs.length === 0) {
1054
- 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".');
1055
1178
  subLangs = ["all"];
1056
1179
  } else {
1057
- const picked = await p2.multiselect({
1180
+ const picked = await p3.multiselect({
1058
1181
  message: "Which subtitle languages?",
1059
1182
  options: [
1060
1183
  { value: "all", label: "All languages" },
@@ -1068,8 +1191,8 @@ async function askQuestions(url, meta) {
1068
1191
  }
1069
1192
  }
1070
1193
  const prefs = await loadPreferences();
1071
- const defaultDir = prefs.outputDir ?? join5(homedir(), "Downloads");
1072
- const outDir = await p2.text({
1194
+ const defaultDir = prefs.outputDir ?? join6(homedir2(), "Downloads");
1195
+ const outDir = await p3.text({
1073
1196
  message: "Destination folder",
1074
1197
  initialValue: defaultDir,
1075
1198
  validate: (v) => !v?.trim() ? "Folder is required" : void 0
@@ -1077,13 +1200,13 @@ async function askQuestions(url, meta) {
1077
1200
  exitOnCancel(outDir);
1078
1201
  const outputDir = String(outDir).trim();
1079
1202
  await savePreferences({ ...prefs, outputDir });
1080
- 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({
1081
1204
  message: "Customize advanced options?",
1082
1205
  initialValue: false
1083
1206
  });
1084
1207
  exitOnCancel(customize);
1085
1208
  if (customize && (selectedMode === "video" || selectedMode === "video-only")) {
1086
- const c = await p2.select({
1209
+ const c = await p3.select({
1087
1210
  message: "Container preference?",
1088
1211
  options: [
1089
1212
  { value: "best", label: "Best available" },
@@ -1096,7 +1219,7 @@ async function askQuestions(url, meta) {
1096
1219
  container = c;
1097
1220
  }
1098
1221
  if (customize && selectedMode !== "thumbnail-only") {
1099
- const wantSubs = await p2.confirm({
1222
+ const wantSubs = await p3.confirm({
1100
1223
  message: "Download subtitles?",
1101
1224
  initialValue: false
1102
1225
  });
@@ -1104,10 +1227,10 @@ async function askQuestions(url, meta) {
1104
1227
  if (wantSubs) {
1105
1228
  const langs = availableSubtitleLangs(meta);
1106
1229
  if (langs.length === 0) {
1107
- p2.log.warn('No subtitle languages found \u2014 will request "all".');
1230
+ p3.log.warn('No subtitle languages found \u2014 will request "all".');
1108
1231
  subLangs = ["all"];
1109
1232
  } else {
1110
- const picked = await p2.multiselect({
1233
+ const picked = await p3.multiselect({
1111
1234
  message: "Which subtitle languages?",
1112
1235
  options: [
1113
1236
  { value: "all", label: "All languages" },
@@ -1119,7 +1242,7 @@ async function askQuestions(url, meta) {
1119
1242
  const sel = picked;
1120
1243
  subLangs = sel.includes("all") ? ["all"] : sel;
1121
1244
  }
1122
- const how = await p2.select({
1245
+ const how = await p3.select({
1123
1246
  message: "How should subtitles be saved?",
1124
1247
  options: [
1125
1248
  { value: "embed", label: "Embed in the video" },
@@ -1131,7 +1254,7 @@ async function askQuestions(url, meta) {
1131
1254
  subMode = how;
1132
1255
  }
1133
1256
  if (isPlaylistUrl(url, meta)) {
1134
- const pl = await p2.select({
1257
+ const pl = await p3.select({
1135
1258
  message: "This URL is part of a playlist. What should we download?",
1136
1259
  options: [
1137
1260
  { value: "single", label: "Just this video" },
@@ -1145,7 +1268,7 @@ async function askQuestions(url, meta) {
1145
1268
  } else if (pl === "all") {
1146
1269
  playlist = { kind: "all" };
1147
1270
  } else {
1148
- const start = await p2.text({
1271
+ const start = await p3.text({
1149
1272
  message: "Playlist start index (1-based)",
1150
1273
  initialValue: "1",
1151
1274
  validate: (v) => {
@@ -1154,7 +1277,7 @@ async function askQuestions(url, meta) {
1154
1277
  }
1155
1278
  });
1156
1279
  exitOnCancel(start);
1157
- const stop = await p2.text({
1280
+ const stop = await p3.text({
1158
1281
  message: "Playlist stop index (inclusive)",
1159
1282
  initialValue: String(meta.playlist_count ?? 10),
1160
1283
  validate: (v) => {
@@ -1170,7 +1293,7 @@ async function askQuestions(url, meta) {
1170
1293
  };
1171
1294
  }
1172
1295
  }
1173
- const pickedFilename = await p2.select({
1296
+ const pickedFilename = await p3.select({
1174
1297
  message: "Filename style?",
1175
1298
  options: [
1176
1299
  { value: "title", label: "Title only" },
@@ -1186,7 +1309,7 @@ async function askQuestions(url, meta) {
1186
1309
  });
1187
1310
  exitOnCancel(pickedFilename);
1188
1311
  filenamePreset = pickedFilename;
1189
- const e = await p2.multiselect({
1312
+ const e = await p3.multiselect({
1190
1313
  message: "Extras (optional)",
1191
1314
  options: [
1192
1315
  {
@@ -1240,7 +1363,7 @@ function readCliVersion() {
1240
1363
  }
1241
1364
  var CLI_VERSION = readCliVersion();
1242
1365
  function fail(message) {
1243
- p3.log.error(message);
1366
+ p4.log.error(message);
1244
1367
  process.exit(1);
1245
1368
  }
1246
1369
  function pick(value, allowed, name, fallback) {
@@ -1343,13 +1466,13 @@ function displayFlagsForAnswers(answers) {
1343
1466
  }
1344
1467
  function showCommandForAnswers(answers) {
1345
1468
  if (answers.urls.length > 1) {
1346
- 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.");
1347
1470
  }
1348
1471
  showCommand(formatCommand(displayFlagsForAnswers(answers)));
1349
1472
  }
1350
1473
  async function confirmStart(answers) {
1351
1474
  while (true) {
1352
- const action = await p3.select({
1475
+ const action = await p4.select({
1353
1476
  message: "Ready?",
1354
1477
  options: [
1355
1478
  { value: "start", label: "Start download" },
@@ -1358,8 +1481,8 @@ async function confirmStart(answers) {
1358
1481
  { value: "cancel", label: "Cancel" }
1359
1482
  ]
1360
1483
  });
1361
- if (p3.isCancel(action) || action === "cancel") {
1362
- p3.cancel("Cancelled.");
1484
+ if (p4.isCancel(action) || action === "cancel") {
1485
+ p4.cancel("Cancelled.");
1363
1486
  process.exit(0);
1364
1487
  }
1365
1488
  if (action === "show-command") {
@@ -1380,41 +1503,143 @@ async function ensureFfmpegIfNeeded(answers) {
1380
1503
  }
1381
1504
  const status = await checkFfmpeg();
1382
1505
  if (status.ok) return;
1383
- p3.log.warn(
1506
+ p4.log.warn(
1384
1507
  [
1385
1508
  "ffmpeg/ffprobe not found on PATH.",
1386
1509
  ffmpegInstallHint()
1387
1510
  ].join("\n\n")
1388
1511
  );
1389
- const cont = await p3.confirm({
1512
+ const cont = await p4.confirm({
1390
1513
  message: "Continue anyway? (download may fail at merge/extract)",
1391
1514
  initialValue: false
1392
1515
  });
1393
- if (p3.isCancel(cont) || !cont) {
1394
- p3.cancel("Cancelled.");
1516
+ if (p4.isCancel(cont) || !cont) {
1517
+ p4.cancel("Cancelled.");
1395
1518
  process.exit(0);
1396
1519
  }
1397
1520
  }
1398
1521
  async function runDownloads(ytDlp, answers) {
1399
- const allPaths = [];
1522
+ const filepaths = [];
1523
+ const failures = [];
1400
1524
  for (let i = 0; i < answers.urls.length; i++) {
1401
1525
  const url = answers.urls[i];
1402
1526
  const label = answers.urls.length > 1 ? `${i + 1} of ${answers.urls.length}` : void 0;
1403
1527
  const flags = buildFlags({ ...answers, urls: [url] });
1404
- if (label) p3.log.info(`Download ${label}`);
1528
+ if (label) p4.log.info(`Download ${label}`);
1405
1529
  try {
1406
1530
  const result = await runDownload(ytDlp, flags, { label });
1407
- allPaths.push(...result.filepaths);
1531
+ filepaths.push(...result.filepaths);
1408
1532
  } catch (err) {
1409
- if (answers.urls.length === 1) throw err;
1410
- 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;
1411
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;
1635
+ }
1636
+ p4.outro("Failed");
1637
+ process.exit(1);
1412
1638
  }
1413
- return allPaths;
1414
1639
  }
1415
1640
  async function runWizard(urlsArg, opts = {}) {
1416
- p3.intro("easy-ytdlp");
1417
- const spinner2 = p3.spinner();
1641
+ p4.intro("easy-ytdlp");
1642
+ const spinner2 = p4.spinner();
1418
1643
  spinner2.start("Preparing yt-dlp binary\u2026");
1419
1644
  let ytDlp;
1420
1645
  try {
@@ -1426,7 +1651,7 @@ async function runWizard(urlsArg, opts = {}) {
1426
1651
  spinner2.stop("yt-dlp ready");
1427
1652
  } catch (err) {
1428
1653
  spinner2.stop("Binary setup failed");
1429
- p3.log.error(err instanceof Error ? err.message : String(err));
1654
+ p4.log.error(err instanceof Error ? err.message : String(err));
1430
1655
  process.exit(1);
1431
1656
  }
1432
1657
  const urls = await promptUrls(urlsArg);
@@ -1437,62 +1662,10 @@ async function runWizard(urlsArg, opts = {}) {
1437
1662
  showCommandForAnswers(answers2);
1438
1663
  }
1439
1664
  await ensureFfmpegIfNeeded(answers2);
1440
- p3.log.info("Starting download\u2026");
1441
- try {
1442
- const filepaths = await runDownloads(ytDlp, answers2);
1443
- if (filepaths.length > 0) {
1444
- showSaved(filepaths);
1445
- } else {
1446
- p3.log.success("Done. (No filepath printed \u2014 check your output folder.)");
1447
- p3.log.info(`Output folder: ${answers2.outputDir}`);
1448
- }
1449
- p3.outro("Finished");
1450
- } catch (err) {
1451
- p3.log.error(err instanceof Error ? err.message : String(err));
1452
- p3.outro("Failed");
1453
- process.exit(1);
1454
- }
1665
+ await downloadAndReport(ytDlp, answers2, { canPrompt: false });
1455
1666
  return;
1456
1667
  }
1457
- spinner2.start("Fetching video info\u2026");
1458
- const metaResults = await Promise.allSettled(
1459
- urls.map((u) => fetchMetadata(ytDlp, u))
1460
- );
1461
- const videoInfos = [];
1462
- const failedUrls = [];
1463
- for (let i = 0; i < metaResults.length; i++) {
1464
- const r = metaResults[i];
1465
- const u = urls[i];
1466
- if (r.status === "fulfilled") {
1467
- const m = r.value;
1468
- const isPlaylist = m._type === "playlist";
1469
- videoInfos.push({
1470
- url: u,
1471
- meta: m,
1472
- title: m.title ?? "Unknown title",
1473
- uploader: m.uploader ?? "Unknown uploader",
1474
- duration: isPlaylist ? `${m.playlist_count ?? "?"} videos` : formatDuration(m.duration)
1475
- });
1476
- } else {
1477
- failedUrls.push(u);
1478
- }
1479
- }
1480
- if (videoInfos.length === 0) {
1481
- spinner2.stop("Could not fetch metadata");
1482
- const reasons = metaResults.filter(
1483
- (r) => r.status === "rejected"
1484
- ).map(
1485
- (r) => r.reason instanceof Error ? r.reason.message : String(r.reason)
1486
- );
1487
- p3.log.error(
1488
- [
1489
- "Failed to fetch metadata for any of the provided URLs.",
1490
- ...new Set(reasons)
1491
- ].join("\n\n")
1492
- );
1493
- process.exit(1);
1494
- }
1495
- spinner2.stop("Metadata loaded");
1668
+ const { videoInfos, failedUrls } = await gatherMetadata(ytDlp, urls, spinner2);
1496
1669
  if (videoInfos.length === 1) {
1497
1670
  const v = videoInfos[0];
1498
1671
  showNote(`${v.title}
@@ -1505,13 +1678,13 @@ by ${v.uploader} \xB7 ${v.duration}`, "Found");
1505
1678
  showNote(lines.join("\n\n"), `Found (${videoInfos.length} videos)`);
1506
1679
  }
1507
1680
  if (failedUrls.length > 0) {
1508
- p3.log.warn(
1681
+ p4.log.warn(
1509
1682
  `Could not fetch metadata for ${failedUrls.length} URL(s). They will still be downloaded with shared settings.`
1510
1683
  );
1511
1684
  }
1512
1685
  const primary = videoInfos[0];
1513
1686
  if (urls.length > 1) {
1514
- p3.log.info(`Using shared settings based on: ${primary.title}`);
1687
+ p4.log.info(`Using shared settings based on: ${primary.title}`);
1515
1688
  }
1516
1689
  let answers;
1517
1690
  while (true) {
@@ -1527,33 +1700,19 @@ by ${v.uploader} \xB7 ${v.duration}`, "Found");
1527
1700
  if (action === "start") break;
1528
1701
  }
1529
1702
  await ensureFfmpegIfNeeded(answers);
1530
- p3.log.info("Starting download\u2026");
1531
- try {
1532
- const filepaths = await runDownloads(ytDlp, answers);
1533
- if (filepaths.length > 0) {
1534
- showSaved(filepaths);
1535
- } else {
1536
- p3.log.success("Done. (No filepath printed \u2014 check your output folder.)");
1537
- p3.log.info(`Output folder: ${answers.outputDir}`);
1538
- }
1539
- p3.outro("Finished");
1540
- } catch (err) {
1541
- p3.log.error(err instanceof Error ? err.message : String(err));
1542
- p3.outro("Failed");
1543
- process.exit(1);
1544
- }
1703
+ await downloadAndReport(ytDlp, answers);
1545
1704
  }
1546
1705
  async function runUpdateBinary() {
1547
- p3.intro("easy-ytdlp update-binary");
1548
- const spinner2 = p3.spinner();
1706
+ p4.intro("easy-ytdlp update-binary");
1707
+ const spinner2 = p4.spinner();
1549
1708
  spinner2.start("Refreshing yt-dlp binary\u2026");
1550
1709
  try {
1551
1710
  const path = await updateBinary((msg) => spinner2.message(msg));
1552
1711
  spinner2.stop(`Updated: ${path}`);
1553
- p3.outro("Binary update complete");
1712
+ p4.outro("Binary update complete");
1554
1713
  } catch (err) {
1555
1714
  spinner2.stop("Update failed");
1556
- p3.log.error(err instanceof Error ? err.message : String(err));
1715
+ p4.log.error(err instanceof Error ? err.message : String(err));
1557
1716
  process.exit(1);
1558
1717
  }
1559
1718
  }
@@ -1577,7 +1736,7 @@ program.name("easy-ytdlp").description(
1577
1736
  content = readFileSync(options.batchFile, "utf-8");
1578
1737
  } catch (err) {
1579
1738
  const detail = err instanceof Error ? err.message : String(err);
1580
- p3.log.error(`Could not read batch file "${options.batchFile}": ${detail}`);
1739
+ p4.log.error(`Could not read batch file "${options.batchFile}": ${detail}`);
1581
1740
  process.exit(1);
1582
1741
  }
1583
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.4",
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",