ytdwn 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Batikan Kutluer
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,136 @@
1
+ # YTDWN
2
+
3
+ A fast and simple CLI tool to download audio from YouTube videos. Powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp).
4
+
5
+ ## ✨ Features
6
+
7
+ - 🎵 Download audio from YouTube in MP3 or other formats
8
+ - ✂️ Clip specific sections of videos (e.g., `1:30-2:45`)
9
+ - 📁 Set a default download folder
10
+ - 🚀 Fast parallel downloads with concurrent fragments
11
+ - 🎨 Beautiful progress bar and colored output
12
+ - 📦 Auto-downloads yt-dlp binary if not present
13
+
14
+ ## 📋 Requirements
15
+
16
+ - [Node.js](https://nodejs.org) >= 18.0.0 or [Bun](https://bun.sh) >= 1.0.0
17
+
18
+ ## 🚀 Installation
19
+
20
+ ### Using npm/npx
21
+
22
+ ```bash
23
+ # Run directly without installing
24
+ npx ytdwn prepare
25
+ npx ytdwn https://www.youtube.com/watch?v=VIDEO_ID
26
+
27
+ # Or install globally
28
+ npm install -g ytdwn
29
+ ytdwn prepare
30
+ ytdwn https://www.youtube.com/watch?v=VIDEO_ID
31
+ ```
32
+
33
+ ### Using Bun
34
+
35
+ ```bash
36
+ # Run directly without installing
37
+ bunx ytdwn prepare
38
+ bunx ytdwn https://www.youtube.com/watch?v=VIDEO_ID
39
+
40
+ # Or install globally
41
+ bun add -g ytdwn
42
+ ytdwn prepare
43
+ ytdwn https://www.youtube.com/watch?v=VIDEO_ID
44
+ ```
45
+
46
+ ### From Source
47
+
48
+ ```bash
49
+ # Clone the repository
50
+ git clone https://github.com/batikankutluer/ytdwn.git
51
+ cd ytdwn
52
+
53
+ # Install dependencies
54
+ bun install
55
+
56
+ # Run directly from source (no build needed)
57
+ bun run index.ts prepare
58
+ bun run index.ts https://www.youtube.com/watch?v=VIDEO_ID
59
+
60
+ # Or build and use the compiled version
61
+ bun run build
62
+ node dist/index.js prepare
63
+ ```
64
+
65
+ ## 📖 Usage
66
+
67
+ ### Basic Download
68
+
69
+ ```bash
70
+ # Download audio from a YouTube URL
71
+ ytdwn https://www.youtube.com/watch?v=VIDEO_ID
72
+
73
+ # Or use the short form
74
+ ytdwn https://youtu.be/VIDEO_ID
75
+ ```
76
+
77
+ ### Options
78
+
79
+ | Flag | Description | Example |
80
+ | ----------------------- | ------------------------------ | ---------------- |
81
+ | `-f, --format <format>` | Audio format (default: mp3) | `-f opus` |
82
+ | `-c, --clip <range>` | Clip a specific time range | `-c 01:30-02:45` |
83
+ | `-q, --quiet` | Minimal output (only filename) | `-q` |
84
+ | `-v, --version` | Show version | `-v` |
85
+ | `-h, --help` | Show help | `-h` |
86
+
87
+ ### Examples
88
+
89
+ ```bash
90
+ # Download as MP3 (default)
91
+ ytdwn https://www.youtube.com/watch?v=VIDEO_ID
92
+
93
+ # Download as OPUS format
94
+ ytdwn https://www.youtube.com/watch?v=VIDEO_ID -f opus
95
+
96
+ # Download only a portion (from 1:30 to 2:45)
97
+ ytdwn https://www.youtube.com/watch?v=VIDEO_ID -c 1:30-2:45
98
+
99
+ # Quiet mode - outputs only the filename
100
+ ytdwn https://www.youtube.com/watch?v=VIDEO_ID -q
101
+ ```
102
+
103
+ ### Commands
104
+
105
+ ```bash
106
+ # Download and prepare yt-dlp binary
107
+ ytdwn prepare
108
+
109
+ # Set default download folder
110
+ ytdwn setDefaultFolder ~/Music/YouTube
111
+
112
+ # View current download folder
113
+ ytdwn setDefaultFolder
114
+
115
+ # Reset to current directory
116
+ ytdwn setDefaultFolder --reset
117
+ ```
118
+
119
+ ## ⚙️ Configuration
120
+
121
+ Settings are stored in `~/.ytdwn.json`:
122
+
123
+ - `downloadDir`: Default folder for downloaded files
124
+ - `binaryPath`: Cached path to yt-dlp binary
125
+
126
+ ## 🛠️ Tech Stack
127
+
128
+ - **Runtime**: [Node.js](https://nodejs.org) or [Bun](https://bun.sh)
129
+ - **CLI Framework**: [Commander.js](https://github.com/tj/commander.js)
130
+ - **Downloader**: [yt-dlp](https://github.com/yt-dlp/yt-dlp)
131
+ - **Audio Processing**: [FFmpeg](https://ffmpeg.org)
132
+ - **Styling**: [cfonts](https://github.com/dominikwilkowski/cfonts), [gradient-string](https://github.com/bokub/gradient-string), [picocolors](https://github.com/alexeyraspopov/picocolors)
133
+
134
+ ## 📄 License
135
+
136
+ MIT - see [LICENSE](LICENSE) file for details
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,562 @@
1
+ #!/usr/bin/env node
2
+
3
+ // index.ts
4
+ import { program } from "commander";
5
+
6
+ // src/config.ts
7
+ import { join } from "path";
8
+ var APP_NAME = "ytdwn";
9
+ var APP_TAGLINE = "YouTube to MP3 • Fast & Simple";
10
+ var APP_VERSION = "1.0.0";
11
+ var DEFAULT_FORMAT = "mp3";
12
+ var DEFAULT_AUDIO_QUALITY = "0";
13
+ var CONCURRENT_FRAGMENTS = "8";
14
+ var BIN_DIR = join(process.cwd(), "bin");
15
+ var getOutputTemplate = (downloadDir) => join(downloadDir, "%(title)s.%(ext)s");
16
+
17
+ // src/binary.ts
18
+ import { constants as fsConstants } from "fs";
19
+ import { access, chmod, mkdir, writeFile as writeFile2 } from "fs/promises";
20
+ import { platform } from "os";
21
+ import { join as join3, dirname } from "path";
22
+ import YTDlpWrap from "yt-dlp-wrap";
23
+
24
+ // src/settings.ts
25
+ import { readFile, writeFile } from "fs/promises";
26
+ import { homedir } from "os";
27
+ import { join as join2, resolve } from "path";
28
+ var SETTINGS_PATH = join2(homedir(), ".ytdwn.json");
29
+ async function load() {
30
+ try {
31
+ return JSON.parse(await readFile(SETTINGS_PATH, "utf-8"));
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+ var save = (settings) => writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2));
37
+ async function getDownloadDir() {
38
+ const { downloadDir } = await load();
39
+ return downloadDir ?? process.cwd();
40
+ }
41
+ async function setDownloadDir(dir) {
42
+ const settings = await load();
43
+ await save({ ...settings, downloadDir: resolve(dir) });
44
+ }
45
+ async function resetDownloadDir() {
46
+ const { downloadDir, ...rest } = await load();
47
+ await save(rest);
48
+ }
49
+ async function getCachedBinaryPath() {
50
+ const { binaryPath } = await load();
51
+ return binaryPath ?? null;
52
+ }
53
+ async function setCachedBinaryPath(path) {
54
+ const settings = await load();
55
+ await save({ ...settings, binaryPath: path });
56
+ }
57
+ async function clearCachedBinaryPath() {
58
+ const { binaryPath, ...rest } = await load();
59
+ await save(rest);
60
+ }
61
+
62
+ // src/binary.ts
63
+ var BINARY_NAMES = {
64
+ win32: ["yt-dlp.exe"],
65
+ darwin: {
66
+ arm64: ["yt-dlp_macos_arm64", "yt-dlp_macos_aarch64", "yt-dlp_macos"],
67
+ x64: ["yt-dlp_macos"]
68
+ },
69
+ linux: {
70
+ arm64: ["yt-dlp_linux_arm64", "yt-dlp_linux_aarch64", "yt-dlp"],
71
+ x64: ["yt-dlp_linux", "yt-dlp"]
72
+ }
73
+ };
74
+ var FALLBACK_BINARY = "yt-dlp";
75
+ function getCandidateNames() {
76
+ const platformNames = BINARY_NAMES[process.platform];
77
+ if (!platformNames)
78
+ return [FALLBACK_BINARY];
79
+ if (Array.isArray(platformNames))
80
+ return platformNames;
81
+ const archNames = platformNames[process.arch];
82
+ return archNames ?? [FALLBACK_BINARY];
83
+ }
84
+ function getCandidatePaths() {
85
+ return getCandidateNames().map((name) => join3(BIN_DIR, name));
86
+ }
87
+ async function isExecutable(filePath) {
88
+ try {
89
+ await access(filePath, fsConstants.X_OK);
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+ async function downloadFile(url, targetPath) {
96
+ await mkdir(dirname(targetPath), { recursive: true });
97
+ const response = await fetch(url);
98
+ if (!response.ok) {
99
+ throw new Error(`Download failed: ${response.status} ${response.statusText}`);
100
+ }
101
+ await writeFile2(targetPath, Buffer.from(await response.arrayBuffer()));
102
+ if (platform() !== "win32") {
103
+ await chmod(targetPath, 493);
104
+ }
105
+ }
106
+ function pickAsset(release) {
107
+ const assets = release.assets ?? [];
108
+ const candidates = getCandidateNames();
109
+ const match = assets.find((a) => candidates.includes(a.name)) ?? assets.find((a) => a.name === FALLBACK_BINARY);
110
+ if (!match) {
111
+ throw new Error("Suitable yt-dlp binary not found for this platform.");
112
+ }
113
+ return match;
114
+ }
115
+ async function findBinary() {
116
+ const cached = await getCachedBinaryPath();
117
+ if (cached && await isExecutable(cached)) {
118
+ return cached;
119
+ }
120
+ for (const candidate of getCandidatePaths()) {
121
+ if (await isExecutable(candidate)) {
122
+ await setCachedBinaryPath(candidate);
123
+ return candidate;
124
+ }
125
+ }
126
+ if (cached)
127
+ await clearCachedBinaryPath();
128
+ return null;
129
+ }
130
+ async function requireBinary() {
131
+ const existing = await findBinary();
132
+ if (existing)
133
+ return existing;
134
+ throw new Error("yt-dlp binary not found. Run 'ytdwn prepare' first.");
135
+ }
136
+ async function downloadLatestBinary() {
137
+ const [release] = await YTDlpWrap.getGithubReleases(1, 1);
138
+ if (!release) {
139
+ throw new Error("Failed to fetch yt-dlp release from GitHub.");
140
+ }
141
+ const asset = pickAsset(release);
142
+ const binaryPath = join3(BIN_DIR, asset.name);
143
+ if (await isExecutable(binaryPath)) {
144
+ await setCachedBinaryPath(binaryPath);
145
+ return binaryPath;
146
+ }
147
+ await downloadFile(asset.browser_download_url, binaryPath);
148
+ await setCachedBinaryPath(binaryPath);
149
+ return binaryPath;
150
+ }
151
+
152
+ // src/download.ts
153
+ import { mkdir as mkdir2 } from "fs/promises";
154
+ import { spawn } from "child_process";
155
+ import ffmpegInstaller from "@ffmpeg-installer/ffmpeg";
156
+
157
+ // src/timestamp.ts
158
+ var TIME_FORMAT_ERROR = "Invalid time format. Use MM:SS or HH:MM:SS (e.g. 0:02 or 01:23:45).";
159
+ var RANGE_FORMAT_ERROR = "Range must be in 'start-end' format (e.g. 0:02-23:10).";
160
+ var pad = (value) => value.padStart(2, "0");
161
+ function normalizeTimeParts(parts) {
162
+ if (parts.length === 2) {
163
+ return ["0", parts[0] ?? "0", parts[1] ?? "0"];
164
+ }
165
+ return [parts[0] ?? "0", parts[1] ?? "0", parts[2] ?? "0"];
166
+ }
167
+ function parseTimestamp(raw) {
168
+ const parts = raw.split(":").map((p) => p.trim());
169
+ if (parts.length < 2 || parts.length > 3) {
170
+ throw new Error(TIME_FORMAT_ERROR);
171
+ }
172
+ const [h, m, s] = normalizeTimeParts(parts);
173
+ return `${pad(h)}:${pad(m)}:${pad(s)}`;
174
+ }
175
+ function parseClipRange(range) {
176
+ const parts = range.split("-").map((p) => p.trim());
177
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
178
+ throw new Error(RANGE_FORMAT_ERROR);
179
+ }
180
+ return `${parseTimestamp(parts[0])}-${parseTimestamp(parts[1])}`;
181
+ }
182
+
183
+ // src/colors.ts
184
+ import pc from "picocolors";
185
+ var c = {
186
+ success: (text) => pc.green(text),
187
+ error: (text) => pc.red(text),
188
+ warn: (text) => pc.yellow(text),
189
+ info: (text) => pc.cyan(text),
190
+ dim: (text) => pc.dim(text),
191
+ bold: (text) => pc.bold(text),
192
+ file: (text) => pc.bold(pc.white(text)),
193
+ size: (text) => pc.dim(text),
194
+ speed: (text) => pc.cyan(text),
195
+ bar: {
196
+ filled: (text) => pc.green(text),
197
+ empty: (text) => pc.dim(text),
198
+ percent: (text) => pc.bold(text)
199
+ },
200
+ sym: {
201
+ success: pc.green("✓"),
202
+ error: pc.red("✗"),
203
+ warn: pc.yellow("⚠"),
204
+ info: pc.cyan("ℹ"),
205
+ arrow: pc.dim("→")
206
+ }
207
+ };
208
+
209
+ // src/download.ts
210
+ var SPINNER_FRAMES = [
211
+ "⠋",
212
+ "⠙",
213
+ "⠹",
214
+ "⠸",
215
+ "⠼",
216
+ "⠴",
217
+ "⠦",
218
+ "⠧",
219
+ "⠇",
220
+ "⠏"
221
+ ];
222
+ var SPINNER_INTERVAL_MS = 80;
223
+ var PROGRESS_BAR_WIDTH = 12;
224
+ var LINE_CLEAR_WIDTH = 60;
225
+ var REGEX = {
226
+ percent: /(\d+\.?\d*)%/,
227
+ total: /of\s+~?([\d.]+\s*\w+)/i,
228
+ speed: /at\s+([\d.]+\s*\w+\/s)/i,
229
+ size: /([\d.]+)\s*(\w+)/,
230
+ binaryUnits: /([KMG])iB/g,
231
+ destination: /\[(?:ExtractAudio|Merger)\].*?Destination:\s*(.+)$/m,
232
+ downloadDest: /\[download\]\s+Destination:\s*(.+)$/m,
233
+ fileSize: /~?([\d.]+\s*[KMG]i?B)/i
234
+ };
235
+ var PHASE_MESSAGES = new Map([
236
+ ["Extracting URL", "Extracting..."],
237
+ ["Downloading webpage", "Fetching info..."]
238
+ ]);
239
+ var write = (text) => process.stdout.write(text);
240
+ var clearLine = () => write(`\r${" ".repeat(LINE_CLEAR_WIDTH)}\r`);
241
+ function createSpinner(initialMessage = "Getting ready...", quiet = false) {
242
+ let frame = 0;
243
+ let message = initialMessage;
244
+ let stopped = false;
245
+ if (quiet) {
246
+ return {
247
+ update: () => {},
248
+ stop: () => {}
249
+ };
250
+ }
251
+ const interval = setInterval(() => {
252
+ if (!stopped) {
253
+ const frameChar = SPINNER_FRAMES[frame++ % SPINNER_FRAMES.length] ?? "⠋";
254
+ write(`\r${c.info(frameChar)} ${c.dim(message)}`);
255
+ }
256
+ }, SPINNER_INTERVAL_MS);
257
+ return {
258
+ update: (msg) => {
259
+ message = msg;
260
+ },
261
+ stop: () => {
262
+ if (stopped)
263
+ return;
264
+ stopped = true;
265
+ clearInterval(interval);
266
+ clearLine();
267
+ }
268
+ };
269
+ }
270
+ function renderProgress({ percent, speed }, quiet = false) {
271
+ if (quiet)
272
+ return;
273
+ const filled = Math.round(percent / 100 * PROGRESS_BAR_WIDTH);
274
+ const bar = c.bar.filled("█".repeat(filled)) + c.bar.empty("░".repeat(PROGRESS_BAR_WIDTH - filled));
275
+ const speedText = speed ? ` ${c.speed(speed)}` : "";
276
+ write(`\r${c.bold("Downloading:")} ${bar} ${c.bar.percent(`${percent.toFixed(0)}%`)}${speedText} `);
277
+ }
278
+ var normalizeUnit = (value) => value.replace(REGEX.binaryUnits, "$1B");
279
+ function parseProgress(text) {
280
+ const percentMatch = text.match(REGEX.percent);
281
+ if (!percentMatch?.[1])
282
+ return null;
283
+ const percent = parseFloat(percentMatch[1]);
284
+ const total = text.match(REGEX.total)?.[1]?.trim();
285
+ const speed = text.match(REGEX.speed)?.[1];
286
+ let downloaded;
287
+ if (total) {
288
+ const [, size, unit] = total.match(REGEX.size) ?? [];
289
+ if (size && unit) {
290
+ downloaded = normalizeUnit(`${(percent / 100 * parseFloat(size)).toFixed(1)}${unit}`);
291
+ }
292
+ }
293
+ return {
294
+ percent,
295
+ downloaded,
296
+ total: total ? normalizeUnit(total) : undefined,
297
+ speed: speed ? normalizeUnit(speed) : undefined
298
+ };
299
+ }
300
+ function detectPhase(text) {
301
+ for (const [pattern, message] of PHASE_MESSAGES) {
302
+ if (text.includes(pattern))
303
+ return message;
304
+ }
305
+ return text.includes("Downloading") && !text.includes("%") ? "Preparing..." : null;
306
+ }
307
+ var isConvertingPhase = (text) => text.includes("ExtractAudio") || text.includes("Converting");
308
+ function extractFileName(text) {
309
+ const match = text.match(REGEX.destination) || text.match(REGEX.downloadDest);
310
+ if (match?.[1]) {
311
+ const fullPath = match[1].trim();
312
+ return fullPath.split("/").pop() || null;
313
+ }
314
+ return null;
315
+ }
316
+ function extractFileSize(text) {
317
+ const match = text.match(REGEX.fileSize);
318
+ return match?.[1] || null;
319
+ }
320
+ function buildArgs(url, options, downloadDir) {
321
+ const baseArgs = [
322
+ url,
323
+ "-f",
324
+ "bestaudio/best",
325
+ "-x",
326
+ "--audio-format",
327
+ options.format,
328
+ "--audio-quality",
329
+ DEFAULT_AUDIO_QUALITY,
330
+ "-o",
331
+ getOutputTemplate(downloadDir),
332
+ "--no-playlist",
333
+ "--newline",
334
+ "--progress",
335
+ "--concurrent-fragments",
336
+ CONCURRENT_FRAGMENTS,
337
+ "--no-check-certificates",
338
+ "--prefer-free-formats"
339
+ ];
340
+ const conditionalArgs = [];
341
+ if (options.clip) {
342
+ conditionalArgs.push("--download-sections", `*${parseClipRange(options.clip)}`);
343
+ }
344
+ if (ffmpegInstaller?.path) {
345
+ conditionalArgs.push("--ffmpeg-location", ffmpegInstaller.path);
346
+ }
347
+ return [...baseArgs, ...conditionalArgs];
348
+ }
349
+ function createOutputHandler(spinner, state) {
350
+ return (data) => {
351
+ const text = data.toString();
352
+ const fileName = extractFileName(text);
353
+ if (fileName)
354
+ state.fileName = fileName;
355
+ const fileSize = extractFileSize(text);
356
+ if (fileSize)
357
+ state.fileSize = fileSize;
358
+ if (state.phase !== "converting" && isConvertingPhase(text)) {
359
+ state.phase = "converting";
360
+ spinner.stop();
361
+ clearLine();
362
+ if (!state.quiet) {
363
+ let frame = 0;
364
+ state.convertingInterval = setInterval(() => {
365
+ const frameChar = SPINNER_FRAMES[frame++ % SPINNER_FRAMES.length] ?? "⠋";
366
+ write(`\r${c.info(frameChar)} ${c.dim("Converting...")}`);
367
+ }, SPINNER_INTERVAL_MS);
368
+ }
369
+ return;
370
+ }
371
+ const progress = parseProgress(text);
372
+ if (progress && progress.percent > state.lastPercent) {
373
+ if (state.phase === "init") {
374
+ state.phase = "downloading";
375
+ spinner.stop();
376
+ }
377
+ state.lastPercent = progress.percent;
378
+ renderProgress(progress, state.quiet);
379
+ return;
380
+ }
381
+ if (state.phase === "init") {
382
+ const message = detectPhase(text);
383
+ if (message)
384
+ spinner.update(message);
385
+ }
386
+ };
387
+ }
388
+ function attachProcessHandlers(child, spinner, downloadDir, quiet) {
389
+ return new Promise((resolve2, reject) => {
390
+ const state = {
391
+ phase: "init",
392
+ lastPercent: 0,
393
+ fileName: null,
394
+ fileSize: null,
395
+ quiet,
396
+ convertingInterval: null
397
+ };
398
+ const handleOutput = createOutputHandler(spinner, state);
399
+ const cleanup = () => {
400
+ spinner.stop();
401
+ if (state.convertingInterval) {
402
+ clearInterval(state.convertingInterval);
403
+ }
404
+ if (!quiet)
405
+ clearLine();
406
+ };
407
+ child.stdout?.on("data", handleOutput);
408
+ child.stderr?.on("data", handleOutput);
409
+ child.on("close", (code) => {
410
+ cleanup();
411
+ if (code === 0) {
412
+ resolve2({
413
+ filePath: downloadDir,
414
+ fileName: state.fileName || "audio",
415
+ fileSize: state.fileSize || undefined
416
+ });
417
+ } else {
418
+ reject(new Error(`Download failed (code ${code})`));
419
+ }
420
+ });
421
+ child.on("error", (err) => {
422
+ cleanup();
423
+ reject(err);
424
+ });
425
+ });
426
+ }
427
+ async function downloadAudio(url, options) {
428
+ const [downloadDir, binaryPath] = await Promise.all([
429
+ getDownloadDir().then(async (dir) => {
430
+ await mkdir2(dir, { recursive: true });
431
+ return dir;
432
+ }),
433
+ requireBinary()
434
+ ]);
435
+ const quiet = options.quiet ?? false;
436
+ const spinner = createSpinner("Getting ready...", quiet);
437
+ const child = spawn(binaryPath, buildArgs(url, options, downloadDir));
438
+ return attachProcessHandlers(child, spinner, downloadDir, quiet);
439
+ }
440
+
441
+ // src/banner.ts
442
+ import cfonts from "cfonts";
443
+ import gradient from "gradient-string";
444
+ var GRADIENT_COLORS = ["#ff6b6b", "#feca57", "#48dbfb", "#ff9ff3", "#54a0ff"];
445
+ function showBanner() {
446
+ const result = cfonts.render(APP_NAME, {
447
+ font: "block",
448
+ align: "left",
449
+ colors: ["system"],
450
+ background: "transparent",
451
+ letterSpacing: 1,
452
+ lineHeight: 0,
453
+ space: false,
454
+ maxLength: 0,
455
+ rawMode: true
456
+ });
457
+ if (result && typeof result !== "boolean" && result.string) {
458
+ const gradientBanner = gradient(GRADIENT_COLORS)(result.string);
459
+ console.log(gradientBanner);
460
+ }
461
+ console.log(gradient(GRADIENT_COLORS)(` ${APP_TAGLINE}
462
+
463
+ `));
464
+ }
465
+
466
+ // index.ts
467
+ var formatError = (err) => {
468
+ const msg = err instanceof Error ? err.message : String(err);
469
+ if (msg.includes("code 1") || msg.includes("Video unavailable")) {
470
+ return "Video not found or private";
471
+ }
472
+ if (msg.includes("code 2")) {
473
+ return "Invalid URL format";
474
+ }
475
+ if (msg.includes("age")) {
476
+ return "Age-restricted video (login required)";
477
+ }
478
+ if (msg.includes("network") || msg.includes("connect")) {
479
+ return "Connection error, try again";
480
+ }
481
+ return msg;
482
+ };
483
+ var exitWithError = (err) => {
484
+ console.error(`${c.sym.error} ${c.error("Error:")} ${formatError(err)}`);
485
+ process.exit(1);
486
+ };
487
+ var isUrl = (arg) => arg.startsWith("http://") || arg.startsWith("https://");
488
+ async function handlePrepare() {
489
+ try {
490
+ if (await findBinary()) {
491
+ console.log(`${c.sym.success} ${c.success("Ready")}`);
492
+ return;
493
+ }
494
+ console.log(`${c.dim("Downloading yt-dlp...")}`);
495
+ await downloadLatestBinary();
496
+ console.log(`${c.sym.success} ${c.success("Ready")}`);
497
+ } catch (err) {
498
+ exitWithError(err);
499
+ }
500
+ }
501
+ async function handleSetFolder(folderPath, opts) {
502
+ try {
503
+ if (opts?.reset) {
504
+ await resetDownloadDir();
505
+ console.log(`${c.sym.success} ${c.success("Reset to current directory")}`);
506
+ return;
507
+ }
508
+ if (folderPath) {
509
+ await setDownloadDir(folderPath);
510
+ console.log(`${c.sym.success} ${c.info(folderPath)}`);
511
+ } else {
512
+ console.log(c.info(await getDownloadDir()));
513
+ }
514
+ } catch (err) {
515
+ exitWithError(err);
516
+ }
517
+ }
518
+ async function handleDownload(url, opts) {
519
+ try {
520
+ if (!opts.quiet) {
521
+ showBanner();
522
+ console.log(`${c.dim("URL:")} ${c.info(url)}
523
+ `);
524
+ }
525
+ const result = await downloadAudio(url, {
526
+ format: opts.format,
527
+ clip: opts.clip,
528
+ quiet: opts.quiet
529
+ });
530
+ if (opts.quiet) {
531
+ console.log(result.fileName);
532
+ } else {
533
+ console.log();
534
+ console.log(`${c.sym.success} ${c.success("Process done!")}
535
+ `);
536
+ const sizeInfo = result.fileSize ? ` ${c.size(`(${result.fileSize})`)}` : "";
537
+ console.log(`${c.file(result.fileName)}${sizeInfo}`);
538
+ }
539
+ } catch (err) {
540
+ exitWithError(err);
541
+ }
542
+ }
543
+ program.name(APP_NAME).usage("<url> [options]").description("Download audio from YouTube").version(APP_VERSION, "-v, --version").option("-f, --format <format>", "Audio format", DEFAULT_FORMAT).option("-c, --clip <range>", "Clip range (e.g. 1:30-2:45)").option("-q, --quiet", "Minimal output (only file name)").addHelpText("beforeAll", "").hook("preAction", (thisCommand) => {
544
+ if (thisCommand.args.includes("help") || process.argv.includes("--help") || process.argv.includes("-h")) {
545
+ showBanner();
546
+ }
547
+ });
548
+ program.command("prepare").description("Download yt-dlp binary").action(handlePrepare);
549
+ program.command("setDefaultFolder [path]").description("Set or view default download folder").option("-r, --reset", "Reset to current directory").action(handleSetFolder);
550
+ program.command("download <url>", { hidden: true }).description("Download audio from YouTube URL").option("-f, --format <format>", "Audio format", DEFAULT_FORMAT).option("-c, --clip <range>", "Clip range (e.g. 1:30-2:45)").option("-q, --quiet", "Minimal output").action(handleDownload);
551
+ var firstArg = process.argv[2];
552
+ if (firstArg && isUrl(firstArg)) {
553
+ process.argv.splice(2, 0, "download");
554
+ }
555
+ if (process.argv.length <= 2) {
556
+ showBanner();
557
+ program.help();
558
+ }
559
+ if (process.argv.includes("-v") || process.argv.includes("--version")) {
560
+ showBanner();
561
+ }
562
+ program.parse();
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Renders a stylish ASCII banner with gradient colors
3
+ */
4
+ export declare function showBanner(): void;
5
+ /**
6
+ * Renders a compact one-line banner
7
+ */
8
+ export declare function showCompactBanner(): void;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Finds an existing prepared binary. Uses cache for speed.
3
+ */
4
+ export declare function findBinary(): Promise<string | null>;
5
+ /**
6
+ * Ensures a prepared binary exists, throws if not.
7
+ */
8
+ export declare function requireBinary(): Promise<string>;
9
+ /**
10
+ * Downloads the latest yt-dlp binary from GitHub.
11
+ */
12
+ export declare function downloadLatestBinary(): Promise<string>;
@@ -0,0 +1,23 @@
1
+ export declare const c: {
2
+ success: (text: string) => string;
3
+ error: (text: string) => string;
4
+ warn: (text: string) => string;
5
+ info: (text: string) => string;
6
+ dim: (text: string) => string;
7
+ bold: (text: string) => string;
8
+ file: (text: string) => string;
9
+ size: (text: string) => string;
10
+ speed: (text: string) => string;
11
+ bar: {
12
+ filled: (text: string) => string;
13
+ empty: (text: string) => string;
14
+ percent: (text: string) => string;
15
+ };
16
+ sym: {
17
+ success: string;
18
+ error: string;
19
+ warn: string;
20
+ info: string;
21
+ arrow: string;
22
+ };
23
+ };
@@ -0,0 +1,8 @@
1
+ export declare const APP_NAME = "ytdwn";
2
+ export declare const APP_TAGLINE = "YouTube to MP3 \u2022 Fast & Simple";
3
+ export declare const APP_VERSION = "1.0.0";
4
+ export declare const DEFAULT_FORMAT = "mp3";
5
+ export declare const DEFAULT_AUDIO_QUALITY = "0";
6
+ export declare const CONCURRENT_FRAGMENTS = "8";
7
+ export declare const BIN_DIR: string;
8
+ export declare const getOutputTemplate: (downloadDir: string) => string;
@@ -0,0 +1,16 @@
1
+ interface DownloadOptions {
2
+ format: string;
3
+ clip?: string;
4
+ quiet?: boolean;
5
+ }
6
+ interface DownloadResult {
7
+ filePath: string;
8
+ fileName: string;
9
+ fileSize?: string;
10
+ }
11
+ /**
12
+ * Downloads audio from a URL using yt-dlp.
13
+ * @returns Download result with file info
14
+ */
15
+ export declare function downloadAudio(url: string, options: DownloadOptions): Promise<DownloadResult>;
16
+ export {};
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Gets the download directory. Falls back to current directory if not set.
3
+ */
4
+ export declare function getDownloadDir(): Promise<string>;
5
+ /**
6
+ * Sets the download directory.
7
+ */
8
+ export declare function setDownloadDir(dir: string): Promise<void>;
9
+ /**
10
+ * Resets the download directory to default (current directory).
11
+ */
12
+ export declare function resetDownloadDir(): Promise<void>;
13
+ /**
14
+ * Gets the cached binary path.
15
+ */
16
+ export declare function getCachedBinaryPath(): Promise<string | null>;
17
+ /**
18
+ * Sets the cached binary path.
19
+ */
20
+ export declare function setCachedBinaryPath(path: string): Promise<void>;
21
+ /**
22
+ * Clears the cached binary path.
23
+ */
24
+ export declare function clearCachedBinaryPath(): Promise<void>;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Parses a raw timestamp string into HH:MM:SS format.
3
+ * Accepts MM:SS or HH:MM:SS formats.
4
+ */
5
+ export declare function parseTimestamp(raw: string): string;
6
+ /**
7
+ * Parses a clip range string (start-end) into normalized format.
8
+ */
9
+ export declare function parseClipRange(range: string): string;
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "ytdwn",
3
+ "version": "1.0.0",
4
+ "description": "A fast and simple CLI tool to download audio from YouTube videos",
5
+ "author": {
6
+ "name": "Batikan Kutluer",
7
+ "url": "https://github.com/batikankutluer"
8
+ },
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/batikankutluer/ytdwn.git"
13
+ },
14
+ "homepage": "https://github.com/batikankutluer/ytdwn#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/batikankutluer/ytdwn/issues"
17
+ },
18
+ "keywords": [
19
+ "youtube",
20
+ "download",
21
+ "mp3",
22
+ "audio",
23
+ "yt-dlp",
24
+ "cli",
25
+ "music",
26
+ "converter",
27
+ "extractor",
28
+ "bun"
29
+ ],
30
+ "engines": {
31
+ "node": ">=18.0.0",
32
+ "bun": ">=1.0.0"
33
+ },
34
+ "type": "module",
35
+ "main": "dist/index.js",
36
+ "types": "dist/index.d.ts",
37
+ "bin": {
38
+ "ytdwn": "dist/index.js"
39
+ },
40
+ "files": [
41
+ "dist"
42
+ ],
43
+ "scripts": {
44
+ "dev": "bun run index.ts",
45
+ "build": "bun run build:clean && bun run build:bundle && bun run build:types && bun run build:chmod",
46
+ "build:clean": "rm -rf dist",
47
+ "build:bundle": "bun build ./index.ts --outfile ./dist/index.js --target node --packages external",
48
+ "build:types": "bun x tsc --project tsconfig.build.json --emitDeclarationOnly",
49
+ "build:chmod": "chmod +x dist/index.js",
50
+ "typecheck": "bun x tsc --noEmit",
51
+ "prepublishOnly": "bun run build"
52
+ },
53
+ "dependencies": {
54
+ "@ffmpeg-installer/ffmpeg": "^1.1.0",
55
+ "cfonts": "^3.3.1",
56
+ "commander": "^14.0.2",
57
+ "gradient-string": "^3.0.0",
58
+ "picocolors": "^1.1.1",
59
+ "yt-dlp-wrap": "^2.3.12"
60
+ },
61
+ "devDependencies": {
62
+ "@types/bun": "^1.1.14",
63
+ "@types/node": "^22.0.0",
64
+ "typescript": "^5.7.0"
65
+ }
66
+ }