@jnzlab/easy-ytdlp 1.0.3 → 1.1.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 (4) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +28 -0
  3. package/dist/cli.js +505 -198
  4. package/package.json +10 -1
package/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ ## 1.1.0 - 2026-07-27
4
+
5
+ ### Added
6
+
7
+ - Added a shorter default wizard that keeps common downloads focused on mode, quality, and output folder.
8
+ - Added an advanced-options step for subtitles, playlist handling, filename presets, container preferences, embedded thumbnail/metadata, and SponsorBlock.
9
+ - Added a grouped final summary with readable labels for download, source, output, and extras.
10
+ - Added a final action menu with start, show command, change settings, and cancel actions.
11
+ - Added persisted output-folder preferences so the last destination is reused on future runs.
12
+ - Added support for multiple URLs in one command and `--batch-file` input with blank-line and comment handling.
13
+ - Added compact numbered metadata previews for multi-URL jobs.
14
+ - Added per-URL download progress labels for multi-URL runs.
15
+ - Added non-interactive `--yes` mode with flags for mode, quality, output folder, audio format, subtitles, playlist range, filename preset, embedded metadata/thumbnail, and SponsorBlock.
16
+
17
+ ### Changed
18
+
19
+ - Downloads for multiple URLs now run one URL at a time, which makes progress and failures easier to understand.
20
+ - CLI version output now matches the package version.
21
+ - Plain video-only downloads no longer warn about missing ffmpeg unless remuxing or post-processing is requested.
22
+ - Saved-file output now uses consistent Clack log formatting.
23
+ - README usage examples now cover multi-URL, batch-file, advanced wizard, and non-interactive workflows.
24
+
25
+ ### Fixed
26
+
27
+ - Batch-file read failures now show a friendly CLI error instead of a raw Node exception.
28
+ - Multi-URL command preview now explains that the displayed command is for the first URL and will be repeated for each URL.
package/README.md CHANGED
@@ -22,10 +22,38 @@ easy-ytdlp <url>
22
22
  ```bash
23
23
  easy-ytdlp # prompts for URL
24
24
  easy-ytdlp https://youtu.be/dQw4w9WgXcQ # start with a URL
25
+ easy-ytdlp <url1> <url2> # download multiple URLs with shared settings
26
+ easy-ytdlp --batch-file urls.txt # read URLs from a file, one per line
25
27
  easy-ytdlp <url> --show-command # preview the yt-dlp flags first
28
+ easy-ytdlp <url> --yes --mode audio --audio-format mp3
26
29
  easy-ytdlp update-binary # force-refresh the cached yt-dlp binary
27
30
  ```
28
31
 
32
+ Batch files may include blank lines and comments that start with `#`.
33
+ When multiple URLs are provided, easy-ytdlp fetches metadata for each URL, then asks one set of questions using the first URL with valid metadata as the prompt context.
34
+ The default wizard keeps common downloads short. Choose advanced options to customize subtitles, playlists, filename presets, containers, and embeds.
35
+
36
+ ### Non-interactive options
37
+
38
+ Use `--yes` to skip prompts and run with defaults plus any flags you provide:
39
+
40
+ ```bash
41
+ easy-ytdlp <url> --yes --mode video --quality 1080 --output ~/Videos
42
+ easy-ytdlp <url> --yes --mode audio --audio-format mp3 --audio-quality good
43
+ easy-ytdlp --batch-file urls.txt --yes --mode video --quality best
44
+ ```
45
+
46
+ Useful flags:
47
+
48
+ - `--mode video|audio|video-only|subs-only|thumbnail-only`
49
+ - `--quality best|1080|720|480|<height>`
50
+ - `--container best|mp4|mkv|webm`
51
+ - `--audio-format best|mp3|m4a|opus|flac|wav`
52
+ - `--subs none|embed|write|both --sub-langs en,es`
53
+ - `--playlist single|all|range --playlist-range 2:5`
54
+ - `--filename title|title-channel|title-date`
55
+ - `--embed-thumbnail --embed-metadata --sponsorblock`
56
+
29
57
  ### Example session
30
58
 
31
59
  ![easy-ytdlp demo](https://pub-453eda74623641f7967529680d3689bb.r2.dev/easy-ytdlp-demo.gif)
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/cli.ts
2
2
  import { Command } from "commander";
3
3
  import * as p3 from "@clack/prompts";
4
+ import { readFileSync } from "fs";
4
5
 
5
6
  // src/binary.ts
6
7
  import { createWriteStream } from "fs";
@@ -274,7 +275,10 @@ function buildFlags(answers) {
274
275
  flags.push("--sponsorblock-remove", "default");
275
276
  }
276
277
  flags.push("--print", "after_move:filepath");
277
- flags.push(answers.url);
278
+ flags.push(...answers.urls);
279
+ if (answers.urls.length > 1) {
280
+ flags.unshift("--ignore-errors");
281
+ }
278
282
  return flags;
279
283
  }
280
284
  function formatCommand(flags) {
@@ -363,7 +367,8 @@ function ffmpegInstallHint() {
363
367
  return lines.join("\n");
364
368
  }
365
369
  function needsFfmpeg(mode, extras) {
366
- if (mode === "video" || mode === "video-only") return true;
370
+ if (mode === "video") return true;
371
+ if (mode === "video-only" && extras.remuxVideo) return true;
367
372
  if (mode === "audio" || extras.extractAudio) return true;
368
373
  if (extras.embedSubs || extras.embedThumbnail) return true;
369
374
  return false;
@@ -538,7 +543,7 @@ function parsePostprocessLine(line) {
538
543
  function progressFlags() {
539
544
  return ["--newline", "--progress"];
540
545
  }
541
- async function runDownload(ytDlp, flags) {
546
+ async function runDownload(ytDlp, flags, options = {}) {
542
547
  const filepaths = [];
543
548
  let lastError = "";
544
549
  let part = 1;
@@ -561,7 +566,7 @@ async function runDownload(ytDlp, flags) {
561
566
  );
562
567
  let barStarted = false;
563
568
  let lastPercent = 0;
564
- let phase = "Downloading";
569
+ let phase = options.label ? `Downloading ${options.label}` : "Downloading";
565
570
  const ensureBar = () => {
566
571
  if (!barStarted) {
567
572
  bar.start(100, 0, {
@@ -577,7 +582,7 @@ async function runDownload(ytDlp, flags) {
577
582
  if (!Number.isFinite(percent)) return;
578
583
  if (percent + 5 < lastPercent) {
579
584
  part += 1;
580
- phase = `Downloading (${part})`;
585
+ phase = options.label ? `Downloading ${options.label} (${part})` : `Downloading (${part})`;
581
586
  }
582
587
  lastPercent = percent;
583
588
  ensureBar();
@@ -613,7 +618,7 @@ async function runDownload(ytDlp, flags) {
613
618
  if (lastPercent >= 99) {
614
619
  part += 1;
615
620
  lastPercent = 0;
616
- phase = `Downloading (${part})`;
621
+ phase = options.label ? `Downloading ${options.label} (${part})` : `Downloading (${part})`;
617
622
  ensureBar();
618
623
  bar.update(0, {
619
624
  phase,
@@ -703,7 +708,28 @@ async function runDownload(ytDlp, flags) {
703
708
  // src/questions.ts
704
709
  import * as p2 from "@clack/prompts";
705
710
  import { homedir } from "os";
706
- import { join as join4 } from "path";
711
+ import { join as join5 } from "path";
712
+
713
+ // src/preferences.ts
714
+ import { mkdir as mkdir2, readFile, writeFile } from "fs/promises";
715
+ import { dirname, join as join4 } from "path";
716
+ import envPaths2 from "env-paths";
717
+ var paths2 = envPaths2("easy-ytdlp");
718
+ var PREFS_PATH = join4(paths2.config, "preferences.json");
719
+ async function loadPreferences() {
720
+ try {
721
+ const raw = await readFile(PREFS_PATH, "utf-8");
722
+ const parsed = JSON.parse(raw);
723
+ return parsed && typeof parsed === "object" ? parsed : {};
724
+ } catch {
725
+ return {};
726
+ }
727
+ }
728
+ async function savePreferences(prefs) {
729
+ await mkdir2(dirname(PREFS_PATH), { recursive: true });
730
+ await writeFile(PREFS_PATH, `${JSON.stringify(prefs, null, 2)}
731
+ `, "utf-8");
732
+ }
707
733
 
708
734
  // src/ui.ts
709
735
  import * as p from "@clack/prompts";
@@ -730,13 +756,15 @@ function wrapLine(line, width) {
730
756
  function showNote(body, title) {
731
757
  p.note(wrapText(body, contentWidth(14)), title);
732
758
  }
733
- function showSaved(paths2) {
734
- p.log.step("Saved");
735
- const width = contentWidth(4);
736
- for (const filePath of paths2) {
737
- for (const line of wrapText(filePath, width).split("\n")) {
738
- console.log(` ${line}`);
739
- }
759
+ function showSaved(paths3) {
760
+ if (paths3.length === 0) return;
761
+ if (paths3.length === 1) {
762
+ p.log.success(paths3[0]);
763
+ return;
764
+ }
765
+ p.log.success(`Saved ${paths3.length} files`);
766
+ for (const filePath of paths3) {
767
+ p.log.info(filePath);
740
768
  }
741
769
  }
742
770
  function showCommand(command) {
@@ -764,6 +792,9 @@ function looksLikeUrl2(input) {
764
792
  return false;
765
793
  }
766
794
  }
795
+ function splitUrlList(text2) {
796
+ return text2.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
797
+ }
767
798
  function formatDuration(seconds) {
768
799
  if (seconds == null || Number.isNaN(seconds)) return "unknown duration";
769
800
  const h = Math.floor(seconds / 3600);
@@ -803,26 +834,85 @@ function isPlaylistUrl(url, meta) {
803
834
  return false;
804
835
  }
805
836
  }
806
- async function promptUrl(initial) {
807
- if (initial && looksLikeUrl2(initial)) return initial;
808
- const url = await p2.text({
809
- message: "Paste the video URL",
810
- placeholder: "https://www.youtube.com/watch?v=\u2026",
811
- initialValue: initial ?? "",
837
+ function modeLabel(mode) {
838
+ const labels = {
839
+ video: "Video with audio",
840
+ audio: "Audio only",
841
+ "video-only": "Video only",
842
+ "subs-only": "Subtitles only",
843
+ "thumbnail-only": "Thumbnail only"
844
+ };
845
+ return labels[mode];
846
+ }
847
+ function filenameLabel(preset) {
848
+ const labels = {
849
+ title: "Title only",
850
+ "title-channel": "Title + Channel",
851
+ "title-date": "Title + Upload Date"
852
+ };
853
+ return labels[preset];
854
+ }
855
+ function playlistLabel(playlist) {
856
+ if (playlist.kind === "single") return "Just this video";
857
+ if (playlist.kind === "all") return "Whole playlist";
858
+ return `Playlist items ${playlist.start}:${playlist.stop}`;
859
+ }
860
+ function videoQualityLabel(quality) {
861
+ if (!quality || quality === "best") return "Best available";
862
+ if (typeof quality === "object") return `${quality.height}p`;
863
+ return `Up to ${quality}p`;
864
+ }
865
+ function summarizeAnswers(answers, sourceCount = answers.urls.length) {
866
+ const lines = [
867
+ "Download",
868
+ ` Mode ${modeLabel(answers.mode)}`,
869
+ answers.mode === "video" || answers.mode === "video-only" ? ` Quality ${videoQualityLabel(answers.videoQuality)}` : null,
870
+ answers.container ? ` Container ${answers.container}` : null,
871
+ answers.mode === "audio" ? ` Audio ${answers.audioFormat ?? "best"} (${answers.audioQuality ?? "best"})` : null,
872
+ "",
873
+ "Sources",
874
+ ` URLs ${sourceCount}`,
875
+ ` Playlist ${playlistLabel(answers.playlist)}`,
876
+ "",
877
+ "Output",
878
+ ` Folder ${answers.outputDir}`,
879
+ ` Filename ${filenameLabel(answers.filenamePreset)}`,
880
+ "",
881
+ "Extras",
882
+ answers.subtitles.mode !== "none" ? ` Subtitles ${answers.subtitles.mode} [${answers.subtitles.languages.join(", ")}]` : " Subtitles none",
883
+ answers.embedThumbnail ? " Thumbnail embed" : null,
884
+ answers.embedMetadata ? " Metadata embed" : null,
885
+ answers.sponsorBlock ? " SponsorBlock remove segments" : null
886
+ ];
887
+ return lines.filter((line) => line != null).join("\n");
888
+ }
889
+ async function promptUrls(initial) {
890
+ if (initial && initial.length > 0) {
891
+ for (const u of initial) {
892
+ if (!looksLikeUrl2(u)) {
893
+ p2.log.error(`Invalid URL: ${u}`);
894
+ process.exit(1);
895
+ }
896
+ }
897
+ return initial;
898
+ }
899
+ const input = await p2.text({
900
+ message: "Paste video URL(s)",
901
+ placeholder: "One or more URLs, separated by spaces or commas",
902
+ initialValue: "",
812
903
  validate: (v) => {
813
- if (!v?.trim()) return "URL is required";
814
- if (!looksLikeUrl2(v.trim())) return "That does not look like a valid http(s) URL";
904
+ if (!v?.trim()) return "At least one URL is required";
905
+ const urls = splitUrlList(v.trim());
906
+ if (urls.length === 0) return "At least one valid URL is required";
907
+ for (const u of urls) {
908
+ if (!looksLikeUrl2(u)) return `Invalid URL: ${u}`;
909
+ }
815
910
  }
816
911
  });
817
- exitOnCancel(url);
818
- return String(url).trim();
912
+ exitOnCancel(input);
913
+ return splitUrlList(String(input).trim());
819
914
  }
820
915
  async function askQuestions(url, meta) {
821
- const title = meta.title ?? "Unknown title";
822
- const uploader = meta.uploader ?? "Unknown uploader";
823
- const duration = formatDuration(meta.duration);
824
- showNote(`${title}
825
- by ${uploader} \xB7 ${duration}`, "Found");
826
916
  const mode = await p2.select({
827
917
  message: "What do you want to download?",
828
918
  options: [
@@ -834,11 +924,17 @@ by ${uploader} \xB7 ${duration}`, "Found");
834
924
  ]
835
925
  });
836
926
  exitOnCancel(mode);
927
+ const selectedMode = mode;
837
928
  let videoQuality;
838
- let container;
929
+ let container = selectedMode === "video" || selectedMode === "video-only" ? "best" : void 0;
839
930
  let audioFormat;
840
931
  let audioQuality;
841
- if (mode === "video" || mode === "video-only") {
932
+ let subMode = "none";
933
+ let subLangs = [];
934
+ let playlist = { kind: "single" };
935
+ let filenamePreset = "title";
936
+ let extras = [];
937
+ if (selectedMode === "video" || selectedMode === "video-only") {
842
938
  const resolutions = availableResolutions(meta);
843
939
  const qualityOpts = [
844
940
  { value: "best", label: "Best available" },
@@ -872,19 +968,8 @@ by ${uploader} \xB7 ${duration}`, "Found");
872
968
  } else {
873
969
  videoQuality = q;
874
970
  }
875
- const c = await p2.select({
876
- message: "Container preference?",
877
- options: [
878
- { value: "best", label: "Best available" },
879
- { value: "mp4", label: "mp4" },
880
- { value: "mkv", label: "mkv" },
881
- { value: "webm", label: "webm" }
882
- ]
883
- });
884
- exitOnCancel(c);
885
- container = c;
886
971
  }
887
- if (mode === "audio") {
972
+ if (selectedMode === "audio") {
888
973
  const fmt = await p2.select({
889
974
  message: "Audio format?",
890
975
  options: [
@@ -908,9 +993,7 @@ by ${uploader} \xB7 ${duration}`, "Found");
908
993
  exitOnCancel(aq);
909
994
  audioQuality = aq;
910
995
  }
911
- let subMode = "none";
912
- let subLangs = [];
913
- if (mode === "subs-only") {
996
+ if (selectedMode === "subs-only") {
914
997
  subMode = "write";
915
998
  const langs = availableSubtitleLangs(meta);
916
999
  if (langs.length === 0) {
@@ -929,7 +1012,36 @@ by ${uploader} \xB7 ${duration}`, "Found");
929
1012
  const sel = picked;
930
1013
  subLangs = sel.includes("all") ? ["all"] : sel;
931
1014
  }
932
- } else if (mode !== "thumbnail-only") {
1015
+ }
1016
+ const prefs = await loadPreferences();
1017
+ const defaultDir = prefs.outputDir ?? join5(homedir(), "Downloads");
1018
+ const outDir = await p2.text({
1019
+ message: "Destination folder",
1020
+ initialValue: defaultDir,
1021
+ validate: (v) => !v?.trim() ? "Folder is required" : void 0
1022
+ });
1023
+ exitOnCancel(outDir);
1024
+ const outputDir = String(outDir).trim();
1025
+ await savePreferences({ ...prefs, outputDir });
1026
+ const customize = selectedMode === "subs-only" || selectedMode === "thumbnail-only" ? false : await p2.confirm({
1027
+ message: "Customize advanced options?",
1028
+ initialValue: false
1029
+ });
1030
+ exitOnCancel(customize);
1031
+ if (customize && (selectedMode === "video" || selectedMode === "video-only")) {
1032
+ const c = await p2.select({
1033
+ message: "Container preference?",
1034
+ options: [
1035
+ { value: "best", label: "Best available" },
1036
+ { value: "mp4", label: "mp4" },
1037
+ { value: "mkv", label: "mkv" },
1038
+ { value: "webm", label: "webm" }
1039
+ ]
1040
+ });
1041
+ exitOnCancel(c);
1042
+ container = c;
1043
+ }
1044
+ if (customize && selectedMode !== "thumbnail-only") {
933
1045
  const wantSubs = await p2.confirm({
934
1046
  message: "Download subtitles?",
935
1047
  initialValue: false
@@ -964,71 +1076,62 @@ by ${uploader} \xB7 ${duration}`, "Found");
964
1076
  exitOnCancel(how);
965
1077
  subMode = how;
966
1078
  }
967
- }
968
- let playlist = { kind: "single" };
969
- if (isPlaylistUrl(url, meta)) {
970
- const pl = await p2.select({
971
- message: "This URL is part of a playlist. What should we download?",
1079
+ if (isPlaylistUrl(url, meta)) {
1080
+ const pl = await p2.select({
1081
+ message: "This URL is part of a playlist. What should we download?",
1082
+ options: [
1083
+ { value: "single", label: "Just this video" },
1084
+ { value: "all", label: "Whole playlist" },
1085
+ { value: "range", label: "A specific range" }
1086
+ ]
1087
+ });
1088
+ exitOnCancel(pl);
1089
+ if (pl === "single") {
1090
+ playlist = { kind: "single" };
1091
+ } else if (pl === "all") {
1092
+ playlist = { kind: "all" };
1093
+ } else {
1094
+ const start = await p2.text({
1095
+ message: "Playlist start index (1-based)",
1096
+ initialValue: "1",
1097
+ validate: (v) => {
1098
+ const n = Number(v);
1099
+ if (!Number.isInteger(n) || n < 1) return "Enter a positive integer";
1100
+ }
1101
+ });
1102
+ exitOnCancel(start);
1103
+ const stop = await p2.text({
1104
+ message: "Playlist stop index (inclusive)",
1105
+ initialValue: String(meta.playlist_count ?? 10),
1106
+ validate: (v) => {
1107
+ const n = Number(v);
1108
+ if (!Number.isInteger(n) || n < 1) return "Enter a positive integer";
1109
+ }
1110
+ });
1111
+ exitOnCancel(stop);
1112
+ playlist = {
1113
+ kind: "range",
1114
+ start: Number(start),
1115
+ stop: Number(stop)
1116
+ };
1117
+ }
1118
+ }
1119
+ const pickedFilename = await p2.select({
1120
+ message: "Filename style?",
972
1121
  options: [
973
- { value: "single", label: "Just this video" },
974
- { value: "all", label: "Whole playlist" },
975
- { value: "range", label: "A specific range" }
1122
+ { value: "title", label: "Title only" },
1123
+ {
1124
+ value: "title-channel",
1125
+ label: "Title + Channel"
1126
+ },
1127
+ {
1128
+ value: "title-date",
1129
+ label: "Title + Upload Date"
1130
+ }
976
1131
  ]
977
1132
  });
978
- exitOnCancel(pl);
979
- if (pl === "single") {
980
- playlist = { kind: "single" };
981
- } else if (pl === "all") {
982
- playlist = { kind: "all" };
983
- } else {
984
- const start = await p2.text({
985
- message: "Playlist start index (1-based)",
986
- initialValue: "1",
987
- validate: (v) => {
988
- const n = Number(v);
989
- if (!Number.isInteger(n) || n < 1) return "Enter a positive integer";
990
- }
991
- });
992
- exitOnCancel(start);
993
- const stop = await p2.text({
994
- message: "Playlist stop index (inclusive)",
995
- initialValue: String(meta.playlist_count ?? 10),
996
- validate: (v) => {
997
- const n = Number(v);
998
- if (!Number.isInteger(n) || n < 1) return "Enter a positive integer";
999
- }
1000
- });
1001
- exitOnCancel(stop);
1002
- playlist = {
1003
- kind: "range",
1004
- start: Number(start),
1005
- stop: Number(stop)
1006
- };
1007
- }
1008
- }
1009
- const defaultDir = join4(homedir(), "Downloads");
1010
- const outDir = await p2.text({
1011
- message: "Destination folder",
1012
- initialValue: defaultDir,
1013
- validate: (v) => !v?.trim() ? "Folder is required" : void 0
1014
- });
1015
- exitOnCancel(outDir);
1016
- const filenamePreset = await p2.select({
1017
- message: "Filename style?",
1018
- options: [
1019
- { value: "title", label: "Title only" },
1020
- {
1021
- value: "title-channel",
1022
- label: "Title + Channel"
1023
- },
1024
- {
1025
- value: "title-date",
1026
- label: "Title + Upload Date"
1027
- }
1028
- ]
1029
- });
1030
- exitOnCancel(filenamePreset);
1031
- const extras = mode === "subs-only" || mode === "thumbnail-only" ? [] : await (async () => {
1133
+ exitOnCancel(pickedFilename);
1134
+ filenamePreset = pickedFilename;
1032
1135
  const e = await p2.multiselect({
1033
1136
  message: "Extras (optional)",
1034
1137
  options: [
@@ -1045,29 +1148,10 @@ by ${uploader} \xB7 ${duration}`, "Found");
1045
1148
  required: false
1046
1149
  });
1047
1150
  exitOnCancel(e);
1048
- return e;
1049
- })();
1050
- const selectedMode = mode;
1051
- const selectedFilename = filenamePreset;
1052
- const summaryLines = [
1053
- `Mode: ${selectedMode}`,
1054
- videoQuality ? `Quality: ${typeof videoQuality === "object" ? `${videoQuality.height}p` : videoQuality}` : null,
1055
- container ? `Container: ${container}` : null,
1056
- audioFormat ? `Audio: ${audioFormat} (${audioQuality ?? "best"})` : null,
1057
- subMode !== "none" ? `Subtitles: ${subMode} [${subLangs.join(", ")}]` : "Subtitles: none",
1058
- `Playlist: ${playlist.kind}${playlist.kind === "range" ? ` ${playlist.start}:${playlist.stop}` : ""}`,
1059
- `Output: ${String(outDir).trim()}`,
1060
- `Filename: ${selectedFilename}`,
1061
- extras.length ? `Extras: ${extras.join(", ")}` : "Extras: none"
1062
- ].filter(Boolean).join("\n");
1063
- showNote(summaryLines, "Summary");
1064
- const showCmd = await p2.confirm({
1065
- message: "Show the yt-dlp command before downloading?",
1066
- initialValue: false
1067
- });
1068
- exitOnCancel(showCmd);
1069
- return {
1070
- url,
1151
+ extras = e;
1152
+ }
1153
+ const answers = {
1154
+ urls: [url],
1071
1155
  mode: selectedMode,
1072
1156
  videoQuality,
1073
1157
  container,
@@ -1078,17 +1162,193 @@ by ${uploader} \xB7 ${duration}`, "Found");
1078
1162
  languages: subLangs
1079
1163
  },
1080
1164
  playlist,
1081
- outputDir: String(outDir).trim(),
1082
- filenamePreset: selectedFilename,
1165
+ outputDir,
1166
+ filenamePreset,
1083
1167
  embedThumbnail: extras.includes("thumbnail"),
1084
1168
  embedMetadata: extras.includes("metadata"),
1085
1169
  sponsorBlock: extras.includes("sponsorblock"),
1086
- showCommand: Boolean(showCmd)
1170
+ showCommand: false
1087
1171
  };
1172
+ showNote(summarizeAnswers(answers), "Summary");
1173
+ return answers;
1088
1174
  }
1089
1175
 
1090
1176
  // src/cli.ts
1091
- async function runWizard(urlArg, opts = {}) {
1177
+ var CLI_VERSION = "1.1.0";
1178
+ function fail(message) {
1179
+ p3.log.error(message);
1180
+ process.exit(1);
1181
+ }
1182
+ function pick(value, allowed, name, fallback) {
1183
+ if (value == null) return fallback;
1184
+ if (allowed.includes(value)) return value;
1185
+ fail(`Invalid ${name}: ${value}. Expected one of: ${allowed.join(", ")}`);
1186
+ }
1187
+ function parseQuality(value) {
1188
+ if (!value || value === "best") return "best";
1189
+ if (value === "1080" || value === "720" || value === "480") return value;
1190
+ const height = Number(value.replace(/p$/i, ""));
1191
+ if (Number.isInteger(height) && height > 0) return { height };
1192
+ fail(`Invalid quality: ${value}. Use best, 1080, 720, 480, or a height like 1440.`);
1193
+ }
1194
+ function parsePlaylist(options) {
1195
+ const kind = pick(
1196
+ options.playlist,
1197
+ ["single", "all", "range"],
1198
+ "playlist",
1199
+ "single"
1200
+ );
1201
+ if (kind !== "range") return { kind };
1202
+ if (!options.playlistRange) {
1203
+ fail("--playlist range requires --playlist-range <start:stop>");
1204
+ }
1205
+ const [startRaw, stopRaw] = options.playlistRange.split(":");
1206
+ const start = Number(startRaw);
1207
+ const stop = Number(stopRaw);
1208
+ if (!Number.isInteger(start) || !Number.isInteger(stop) || start < 1 || stop < start) {
1209
+ fail("Invalid --playlist-range. Use a 1-based inclusive range like 2:5.");
1210
+ }
1211
+ return { kind: "range", start, stop };
1212
+ }
1213
+ function parseLanguages(value) {
1214
+ return value ? value.split(",").map((lang) => lang.trim()).filter(Boolean) : [];
1215
+ }
1216
+ function compactTitle(title, max = 72) {
1217
+ return title.length > max ? `${title.slice(0, max - 1)}\u2026` : title;
1218
+ }
1219
+ function answersFromOptions(urls, options) {
1220
+ const mode = pick(
1221
+ options.mode,
1222
+ ["video", "audio", "video-only", "subs-only", "thumbnail-only"],
1223
+ "mode",
1224
+ "video"
1225
+ );
1226
+ const subtitlesMode = pick(
1227
+ options.subs,
1228
+ ["none", "embed", "write", "both"],
1229
+ "subs",
1230
+ mode === "subs-only" ? "write" : "none"
1231
+ );
1232
+ return {
1233
+ urls,
1234
+ mode,
1235
+ videoQuality: mode === "video" || mode === "video-only" ? parseQuality(options.quality) : void 0,
1236
+ container: mode === "video" || mode === "video-only" ? pick(
1237
+ options.container,
1238
+ ["best", "mp4", "mkv", "webm"],
1239
+ "container",
1240
+ "best"
1241
+ ) : void 0,
1242
+ audioFormat: mode === "audio" ? pick(
1243
+ options.audioFormat,
1244
+ ["best", "mp3", "m4a", "opus", "flac", "wav"],
1245
+ "audio-format",
1246
+ "best"
1247
+ ) : void 0,
1248
+ audioQuality: mode === "audio" ? pick(
1249
+ options.audioQuality,
1250
+ ["best", "good"],
1251
+ "audio-quality",
1252
+ "best"
1253
+ ) : void 0,
1254
+ subtitles: {
1255
+ mode: subtitlesMode,
1256
+ languages: parseLanguages(options.subLangs)
1257
+ },
1258
+ playlist: parsePlaylist(options),
1259
+ outputDir: options.output ?? process.cwd(),
1260
+ filenamePreset: pick(
1261
+ options.filename,
1262
+ ["title", "title-channel", "title-date"],
1263
+ "filename",
1264
+ "title"
1265
+ ),
1266
+ embedThumbnail: Boolean(options.embedThumbnail),
1267
+ embedMetadata: Boolean(options.embedMetadata),
1268
+ sponsorBlock: Boolean(options.sponsorblock),
1269
+ showCommand: Boolean(options.showCommand)
1270
+ };
1271
+ }
1272
+ function displayFlagsForAnswers(answers) {
1273
+ const urls = answers.urls.length > 1 ? [answers.urls[0]] : answers.urls;
1274
+ return [
1275
+ ...youtubeCompatFlags(),
1276
+ ...progressFlags(),
1277
+ ...buildFlags({ ...answers, urls })
1278
+ ];
1279
+ }
1280
+ function showCommandForAnswers(answers) {
1281
+ if (answers.urls.length > 1) {
1282
+ p3.log.info("Showing the command for the first URL. It will be repeated for each URL.");
1283
+ }
1284
+ showCommand(formatCommand(displayFlagsForAnswers(answers)));
1285
+ }
1286
+ async function confirmStart(answers) {
1287
+ while (true) {
1288
+ const action = await p3.select({
1289
+ message: "Ready?",
1290
+ options: [
1291
+ { value: "start", label: "Start download" },
1292
+ { value: "show-command", label: "Show yt-dlp command" },
1293
+ { value: "change", label: "Change settings" },
1294
+ { value: "cancel", label: "Cancel" }
1295
+ ]
1296
+ });
1297
+ if (p3.isCancel(action) || action === "cancel") {
1298
+ p3.cancel("Cancelled.");
1299
+ process.exit(0);
1300
+ }
1301
+ if (action === "show-command") {
1302
+ showCommandForAnswers(answers);
1303
+ continue;
1304
+ }
1305
+ return action;
1306
+ }
1307
+ }
1308
+ async function ensureFfmpegIfNeeded(answers) {
1309
+ if (!needsFfmpeg(answers.mode, {
1310
+ embedSubs: answers.subtitles.mode === "embed" || answers.subtitles.mode === "both",
1311
+ embedThumbnail: answers.embedThumbnail,
1312
+ extractAudio: answers.mode === "audio",
1313
+ remuxVideo: answers.container != null && answers.container !== "best"
1314
+ })) {
1315
+ return;
1316
+ }
1317
+ const status = await checkFfmpeg();
1318
+ if (status.ok) return;
1319
+ p3.log.warn(
1320
+ [
1321
+ "ffmpeg/ffprobe not found on PATH.",
1322
+ ffmpegInstallHint()
1323
+ ].join("\n\n")
1324
+ );
1325
+ const cont = await p3.confirm({
1326
+ message: "Continue anyway? (download may fail at merge/extract)",
1327
+ initialValue: false
1328
+ });
1329
+ if (p3.isCancel(cont) || !cont) {
1330
+ p3.cancel("Cancelled.");
1331
+ process.exit(0);
1332
+ }
1333
+ }
1334
+ async function runDownloads(ytDlp, answers) {
1335
+ const allPaths = [];
1336
+ for (let i = 0; i < answers.urls.length; i++) {
1337
+ const url = answers.urls[i];
1338
+ const label = answers.urls.length > 1 ? `${i + 1} of ${answers.urls.length}` : void 0;
1339
+ const flags = buildFlags({ ...answers, urls: [url] });
1340
+ if (label) p3.log.info(`Download ${label}`);
1341
+ try {
1342
+ const result = await runDownload(ytDlp, flags, { label });
1343
+ allPaths.push(...result.filepaths);
1344
+ } catch (err) {
1345
+ if (answers.urls.length === 1) throw err;
1346
+ p3.log.warn(err instanceof Error ? err.message : String(err));
1347
+ }
1348
+ }
1349
+ return allPaths;
1350
+ }
1351
+ async function runWizard(urlsArg, opts = {}) {
1092
1352
  p3.intro("easy-ytdlp");
1093
1353
  const spinner2 = p3.spinner();
1094
1354
  spinner2.start("Preparing yt-dlp binary\u2026");
@@ -1105,75 +1365,98 @@ async function runWizard(urlArg, opts = {}) {
1105
1365
  p3.log.error(err instanceof Error ? err.message : String(err));
1106
1366
  process.exit(1);
1107
1367
  }
1108
- const url = await promptUrl(urlArg);
1368
+ const urls = await promptUrls(urlsArg);
1369
+ if (opts.yes) {
1370
+ const answers2 = answersFromOptions(urls, opts);
1371
+ showNote(summarizeAnswers(answers2), "Summary");
1372
+ if (answers2.showCommand) {
1373
+ showCommandForAnswers(answers2);
1374
+ }
1375
+ await ensureFfmpegIfNeeded(answers2);
1376
+ p3.log.info("Starting download\u2026");
1377
+ try {
1378
+ const filepaths = await runDownloads(ytDlp, answers2);
1379
+ if (filepaths.length > 0) {
1380
+ showSaved(filepaths);
1381
+ } else {
1382
+ p3.log.success("Done. (No filepath printed \u2014 check your output folder.)");
1383
+ p3.log.info(`Output folder: ${answers2.outputDir}`);
1384
+ }
1385
+ p3.outro("Finished");
1386
+ } catch (err) {
1387
+ p3.log.error(err instanceof Error ? err.message : String(err));
1388
+ p3.outro("Failed");
1389
+ process.exit(1);
1390
+ }
1391
+ return;
1392
+ }
1109
1393
  spinner2.start("Fetching video info\u2026");
1110
- let meta;
1111
- try {
1112
- meta = await fetchMetadata(ytDlp, url);
1113
- spinner2.stop("Metadata loaded");
1114
- } catch (err) {
1394
+ const metaResults = await Promise.allSettled(
1395
+ urls.map((u) => fetchMetadata(ytDlp, u))
1396
+ );
1397
+ const videoInfos = [];
1398
+ const failedUrls = [];
1399
+ for (let i = 0; i < metaResults.length; i++) {
1400
+ const r = metaResults[i];
1401
+ const u = urls[i];
1402
+ if (r.status === "fulfilled") {
1403
+ const m = r.value;
1404
+ videoInfos.push({
1405
+ url: u,
1406
+ meta: m,
1407
+ title: m.title ?? "Unknown title",
1408
+ uploader: m.uploader ?? "Unknown uploader",
1409
+ duration: formatDuration(m.duration)
1410
+ });
1411
+ } else {
1412
+ failedUrls.push(u);
1413
+ }
1414
+ }
1415
+ if (videoInfos.length === 0) {
1115
1416
  spinner2.stop("Could not fetch metadata");
1116
- p3.log.error(err instanceof Error ? err.message : String(err));
1417
+ p3.log.error("Failed to fetch metadata for any of the provided URLs.");
1117
1418
  process.exit(1);
1118
1419
  }
1119
- const answers = await askQuestions(url, meta);
1120
- if (opts.showCommand) {
1121
- answers.showCommand = true;
1122
- }
1123
- const flags = buildFlags(answers);
1124
- const displayFlags = [
1125
- ...youtubeCompatFlags(),
1126
- ...progressFlags(),
1127
- ...flags
1128
- ];
1129
- if (answers.showCommand) {
1130
- showCommand(formatCommand(displayFlags));
1131
- const proceed = await p3.confirm({
1132
- message: "Run this command?",
1133
- initialValue: true
1134
- });
1135
- if (p3.isCancel(proceed) || !proceed) {
1136
- p3.cancel("Cancelled.");
1137
- process.exit(0);
1138
- }
1420
+ spinner2.stop("Metadata loaded");
1421
+ if (videoInfos.length === 1) {
1422
+ const v = videoInfos[0];
1423
+ showNote(`${v.title}
1424
+ by ${v.uploader} \xB7 ${v.duration}`, "Found");
1139
1425
  } else {
1140
- const proceed = await p3.confirm({
1141
- message: "Start download?",
1142
- initialValue: true
1143
- });
1144
- if (p3.isCancel(proceed) || !proceed) {
1145
- p3.cancel("Cancelled.");
1146
- process.exit(0);
1147
- }
1426
+ const lines = videoInfos.map(
1427
+ (v, index) => `${index + 1}. ${compactTitle(v.title)}
1428
+ by ${v.uploader} \xB7 ${v.duration}`
1429
+ );
1430
+ showNote(lines.join("\n\n"), `Found (${videoInfos.length} videos)`);
1148
1431
  }
1149
- if (needsFfmpeg(answers.mode, {
1150
- embedSubs: answers.subtitles.mode === "embed" || answers.subtitles.mode === "both",
1151
- embedThumbnail: answers.embedThumbnail,
1152
- extractAudio: answers.mode === "audio"
1153
- })) {
1154
- const status = await checkFfmpeg();
1155
- if (!status.ok) {
1156
- p3.log.warn(
1157
- [
1158
- "ffmpeg/ffprobe not found on PATH.",
1159
- ffmpegInstallHint()
1160
- ].join("\n\n")
1161
- );
1162
- const cont = await p3.confirm({
1163
- message: "Continue anyway? (download may fail at merge/extract)",
1164
- initialValue: false
1165
- });
1166
- if (p3.isCancel(cont) || !cont) {
1167
- p3.cancel("Cancelled.");
1168
- process.exit(0);
1169
- }
1432
+ if (failedUrls.length > 0) {
1433
+ p3.log.warn(
1434
+ `Could not fetch metadata for ${failedUrls.length} URL(s). They will still be downloaded with shared settings.`
1435
+ );
1436
+ }
1437
+ const primary = videoInfos[0];
1438
+ if (urls.length > 1) {
1439
+ p3.log.info(`Using shared settings based on: ${primary.title}`);
1440
+ }
1441
+ let answers;
1442
+ while (true) {
1443
+ answers = await askQuestions(primary.url, primary.meta);
1444
+ answers.urls = urls;
1445
+ if (opts.showCommand) {
1446
+ answers.showCommand = true;
1170
1447
  }
1448
+ if (answers.showCommand) {
1449
+ showCommandForAnswers(answers);
1450
+ }
1451
+ const action = await confirmStart(answers);
1452
+ if (action === "start") break;
1171
1453
  }
1454
+ await ensureFfmpegIfNeeded(answers);
1172
1455
  p3.log.info("Starting download\u2026");
1173
1456
  try {
1174
- const result = await runDownload(ytDlp, flags);
1175
- if (result.filepaths.length > 0) {
1176
- showSaved(result.filepaths);
1457
+ const filepaths = await runDownloads(ytDlp, answers);
1458
+ if (filepaths.length > 0) {
1459
+ showSaved(filepaths);
1177
1460
  } else {
1178
1461
  p3.log.success("Done. (No filepath printed \u2014 check your output folder.)");
1179
1462
  p3.log.info(`Output folder: ${answers.outputDir}`);
@@ -1202,11 +1485,35 @@ async function runUpdateBinary() {
1202
1485
  var program = new Command();
1203
1486
  program.name("easy-ytdlp").description(
1204
1487
  "Interactive, user-friendly wrapper around yt-dlp \u2014 no flag memorization required"
1205
- ).version("1.0.0").argument("[url]", "Video URL (prompted if omitted)").option(
1488
+ ).version(CLI_VERSION).argument("[urls...]", "One or more video URLs (prompted if omitted)").option(
1206
1489
  "--show-command",
1207
1490
  "Always show the generated yt-dlp command before running"
1208
- ).action(async (url, options) => {
1209
- await runWizard(url, { showCommand: options.showCommand });
1491
+ ).option(
1492
+ "-a, --batch-file <path>",
1493
+ "File containing URLs to download, one per line"
1494
+ ).option("-y, --yes", "Run without interactive prompts using defaults/options").option(
1495
+ "--mode <mode>",
1496
+ "Download mode: video, audio, video-only, subs-only, thumbnail-only"
1497
+ ).option("--quality <quality>", "Video quality: best, 1080, 720, 480, or height").option("--container <container>", "Container: best, mp4, mkv, webm").option("--audio-format <format>", "Audio format: best, mp3, m4a, opus, flac, wav").option("--audio-quality <quality>", "Audio quality: best, good").option("-o, --output <dir>", "Destination folder").option("--filename <preset>", "Filename preset: title, title-channel, title-date").option("--subs <mode>", "Subtitle mode: none, embed, write, both").option("--sub-langs <langs>", "Comma-separated subtitle languages, for example en,es").option("--playlist <mode>", "Playlist mode: single, all, range").option("--playlist-range <range>", "Playlist range for --playlist range, for example 2:5").option("--embed-thumbnail", "Embed thumbnail as cover art").option("--embed-metadata", "Embed metadata").option("--sponsorblock", "Remove SponsorBlock default segments").action(async (urls, options) => {
1498
+ let allUrls = urls ?? [];
1499
+ if (options.batchFile) {
1500
+ let content;
1501
+ try {
1502
+ content = readFileSync(options.batchFile, "utf-8");
1503
+ } catch (err) {
1504
+ const detail = err instanceof Error ? err.message : String(err);
1505
+ p3.log.error(`Could not read batch file "${options.batchFile}": ${detail}`);
1506
+ process.exit(1);
1507
+ }
1508
+ const fileUrls = content.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
1509
+ allUrls = [...allUrls, ...fileUrls];
1510
+ }
1511
+ if (options.yes && allUrls.length === 0) {
1512
+ fail("--yes requires at least one URL or --batch-file.");
1513
+ }
1514
+ await runWizard(allUrls.length > 0 ? allUrls : void 0, {
1515
+ ...options
1516
+ });
1210
1517
  });
1211
1518
  program.command("update-binary").description("Force-refresh the cached yt-dlp binary").action(async () => {
1212
1519
  await runUpdateBinary();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jnzlab/easy-ytdlp",
3
- "version": "1.0.3",
3
+ "version": "1.1.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",
@@ -15,6 +15,7 @@
15
15
  "bin",
16
16
  "dist",
17
17
  "README.md",
18
+ "CHANGELOG.md",
18
19
  "LICENSE"
19
20
  ],
20
21
  "engines": {
@@ -36,6 +37,14 @@
36
37
  "video"
37
38
  ],
38
39
  "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/jnzlab/easy-ytdlp.git"
43
+ },
44
+ "homepage": "https://github.com/jnzlab/easy-ytdlp#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/jnzlab/easy-ytdlp/issues"
47
+ },
39
48
  "dependencies": {
40
49
  "@clack/prompts": "^0.10.0",
41
50
  "cli-progress": "^3.12.0",