@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.
- package/CHANGELOG.md +12 -0
- package/dist/cli.js +339 -180
- 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
|
|
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
|
|
256
|
-
import { join as
|
|
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
|
|
425
|
+
function isExecutable3(filePath) {
|
|
263
426
|
try {
|
|
264
|
-
|
|
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 =
|
|
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 =
|
|
292
|
-
if (
|
|
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 (
|
|
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
|
|
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
|
|
764
|
-
import { homedir } from "os";
|
|
765
|
-
import { join as
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
833
|
-
return
|
|
955
|
+
function isCancel3(value) {
|
|
956
|
+
return p3.isCancel(value);
|
|
834
957
|
}
|
|
835
958
|
function exitOnCancel(value) {
|
|
836
|
-
if (
|
|
837
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
1072
|
-
const outDir = await
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1230
|
+
p3.log.warn('No subtitle languages found \u2014 will request "all".');
|
|
1108
1231
|
subLangs = ["all"];
|
|
1109
1232
|
} else {
|
|
1110
|
-
const picked = await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
1362
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
1394
|
-
|
|
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
|
|
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)
|
|
1528
|
+
if (label) p4.log.info(`Download ${label}`);
|
|
1405
1529
|
try {
|
|
1406
1530
|
const result = await runDownload(ytDlp, flags, { label });
|
|
1407
|
-
|
|
1531
|
+
filepaths.push(...result.filepaths);
|
|
1408
1532
|
} catch (err) {
|
|
1409
|
-
|
|
1410
|
-
|
|
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
|
-
|
|
1417
|
-
const spinner2 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1548
|
-
const spinner2 =
|
|
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
|
-
|
|
1712
|
+
p4.outro("Binary update complete");
|
|
1554
1713
|
} catch (err) {
|
|
1555
1714
|
spinner2.stop("Update failed");
|
|
1556
|
-
|
|
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
|
-
|
|
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("#"));
|