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