ytdwn 1.1.2 → 1.1.3
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/README.md +2 -2
- package/dist/index.js +255 -72
- package/dist/src/cli/errors.d.ts +2 -2
- package/dist/src/config.d.ts +1 -1
- package/dist/src/services/BinaryService.d.ts +2 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -24,12 +24,12 @@
|
|
|
24
24
|
- 📁 **Custom Folders** - Set a default download directory
|
|
25
25
|
- 🚀 **Fast Downloads** - Parallel fragment downloading
|
|
26
26
|
- 🎨 **Beautiful UI** - Gradient banner, spinners, progress bars
|
|
27
|
-
- 📦 **Auto-Setup** - Downloads yt-dlp binary automatically
|
|
27
|
+
- 📦 **Auto-Setup** - Downloads yt-dlp binary and uses static FFmpeg automatically
|
|
28
28
|
|
|
29
29
|
## 📋 Requirements
|
|
30
30
|
|
|
31
31
|
- [Node.js](https://nodejs.org) >= 18.0.0 or [Bun](https://bun.sh) >= 1.0.0
|
|
32
|
-
-
|
|
32
|
+
- *FFmpeg is handled automatically via static binaries*
|
|
33
33
|
|
|
34
34
|
## 🚀 Quick Start
|
|
35
35
|
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
5
|
+
var __defProp = Object.defineProperty;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __toESM = (mod, isNodeMode, target) => {
|
|
9
|
+
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
10
|
+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
11
|
+
for (let key of __getOwnPropNames(mod))
|
|
12
|
+
if (!__hasOwnProp.call(to, key))
|
|
13
|
+
__defProp(to, key, {
|
|
14
|
+
get: () => mod[key],
|
|
15
|
+
enumerable: true
|
|
16
|
+
});
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
2
20
|
|
|
3
21
|
// index.ts
|
|
4
22
|
import { program } from "commander";
|
|
@@ -7,7 +25,7 @@ import { program } from "commander";
|
|
|
7
25
|
import { join } from "path";
|
|
8
26
|
var APP_NAME = "ytdwn";
|
|
9
27
|
var APP_TAGLINE = "YouTube to MP3/MP4 • Fast & Simple";
|
|
10
|
-
var APP_VERSION = "1.1.
|
|
28
|
+
var APP_VERSION = "1.1.3";
|
|
11
29
|
var DEFAULT_AUDIO_FORMAT = "mp3";
|
|
12
30
|
var DEFAULT_AUDIO_QUALITY = "0";
|
|
13
31
|
var CONCURRENT_FRAGMENTS = "8";
|
|
@@ -141,6 +159,7 @@ var SettingsServiceLive = Layer.succeed(SettingsService, {
|
|
|
141
159
|
// src/services/BinaryService.ts
|
|
142
160
|
import { Effect as Effect4, Context as Context2, Layer as Layer2 } from "effect";
|
|
143
161
|
import YTDlpWrapModule from "yt-dlp-wrap";
|
|
162
|
+
import ffmpegInstaller from "@ffmpeg-installer/ffmpeg";
|
|
144
163
|
import { platform } from "os";
|
|
145
164
|
import { join as join3 } from "path";
|
|
146
165
|
|
|
@@ -158,6 +177,7 @@ var fetchBinaryWithRetry = (url, maxRetries = 2) => Effect3.tryPromise({
|
|
|
158
177
|
}).pipe(Effect3.retry(Schedule.recurs(maxRetries).pipe(Schedule.addDelay(() => "1 second"))));
|
|
159
178
|
|
|
160
179
|
// src/services/BinaryService.ts
|
|
180
|
+
var ffmpegPath = ffmpegInstaller.path;
|
|
161
181
|
var YTDlpWrap = YTDlpWrapModule.default ?? YTDlpWrapModule;
|
|
162
182
|
var BINARY_NAMES = {
|
|
163
183
|
win32: ["yt-dlp.exe"],
|
|
@@ -231,6 +251,25 @@ var BinaryServiceLive = Layer2.effect(BinaryService, Effect4.gen(function* () {
|
|
|
231
251
|
message: "yt-dlp binary not found. Run 'ytdwn prepare' first."
|
|
232
252
|
}));
|
|
233
253
|
});
|
|
254
|
+
const requireFFmpeg = Effect4.gen(function* () {
|
|
255
|
+
const systemPath = yield* Effect4.tryPromise({
|
|
256
|
+
try: async () => {
|
|
257
|
+
const { exec } = await import("child_process");
|
|
258
|
+
const { promisify } = await import("util");
|
|
259
|
+
const execAsync = promisify(exec);
|
|
260
|
+
const cmd = process.platform === "win32" ? "where ffmpeg" : "which ffmpeg";
|
|
261
|
+
const { stdout } = await execAsync(cmd);
|
|
262
|
+
const outStr = String(stdout);
|
|
263
|
+
const firstLine = outStr.trim().split(`
|
|
264
|
+
`)[0];
|
|
265
|
+
return firstLine ? firstLine.trim() : null;
|
|
266
|
+
},
|
|
267
|
+
catch: () => new Error("FFmpeg lookup failed")
|
|
268
|
+
}).pipe(Effect4.orElseSucceed(() => null));
|
|
269
|
+
if (systemPath)
|
|
270
|
+
return systemPath;
|
|
271
|
+
return ffmpegPath;
|
|
272
|
+
});
|
|
234
273
|
const getYtDlpWrap = Effect4.gen(function* () {
|
|
235
274
|
const binaryPath = yield* requireBinary;
|
|
236
275
|
return new YTDlpWrap(binaryPath);
|
|
@@ -253,15 +292,13 @@ var BinaryServiceLive = Layer2.effect(BinaryService, Effect4.gen(function* () {
|
|
|
253
292
|
const asset = yield* pickAsset(release);
|
|
254
293
|
const binaryPath = join3(BIN_DIR, asset.name);
|
|
255
294
|
const exists = yield* isExecutable(binaryPath);
|
|
256
|
-
if (exists) {
|
|
257
|
-
yield*
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (platform() !== "win32") {
|
|
264
|
-
yield* makeExecutable(binaryPath);
|
|
295
|
+
if (!exists) {
|
|
296
|
+
yield* ensureDirectory(BIN_DIR);
|
|
297
|
+
const data = yield* fetchBinaryWithRetry(asset.browser_download_url);
|
|
298
|
+
yield* writeFileBinary(binaryPath, data);
|
|
299
|
+
if (platform() !== "win32") {
|
|
300
|
+
yield* makeExecutable(binaryPath);
|
|
301
|
+
}
|
|
265
302
|
}
|
|
266
303
|
yield* settings.setCachedBinaryPath(binaryPath).pipe(Effect4.ignore);
|
|
267
304
|
return binaryPath;
|
|
@@ -270,39 +307,13 @@ var BinaryServiceLive = Layer2.effect(BinaryService, Effect4.gen(function* () {
|
|
|
270
307
|
findBinary,
|
|
271
308
|
requireBinary,
|
|
272
309
|
getYtDlpWrap,
|
|
273
|
-
downloadLatestBinary
|
|
310
|
+
downloadLatestBinary,
|
|
311
|
+
requireFFmpeg
|
|
274
312
|
};
|
|
275
313
|
}));
|
|
276
314
|
|
|
277
315
|
// src/services/DownloadService.ts
|
|
278
|
-
import { Effect as
|
|
279
|
-
|
|
280
|
-
// src/timestamp.ts
|
|
281
|
-
import { Effect as Effect5 } from "effect";
|
|
282
|
-
var TIME_FORMAT_ERROR = "Invalid time format. Use MM:SS or HH:MM:SS (e.g. 0:02 or 01:23:45).";
|
|
283
|
-
var RANGE_FORMAT_ERROR = "Range must be in 'start-end' format (e.g. 0:02-23:10).";
|
|
284
|
-
var pad = (value) => value.padStart(2, "0");
|
|
285
|
-
function normalizeTimeParts(parts) {
|
|
286
|
-
if (parts.length === 2) {
|
|
287
|
-
return ["0", parts[0] ?? "0", parts[1] ?? "0"];
|
|
288
|
-
}
|
|
289
|
-
return [parts[0] ?? "0", parts[1] ?? "0", parts[2] ?? "0"];
|
|
290
|
-
}
|
|
291
|
-
function parseTimestamp(raw) {
|
|
292
|
-
const parts = raw.split(":").map((p) => p.trim());
|
|
293
|
-
if (parts.length < 2 || parts.length > 3) {
|
|
294
|
-
throw new Error(TIME_FORMAT_ERROR);
|
|
295
|
-
}
|
|
296
|
-
const [h, m, s] = normalizeTimeParts(parts);
|
|
297
|
-
return `${pad(h)}:${pad(m)}:${pad(s)}`;
|
|
298
|
-
}
|
|
299
|
-
function parseClipRange(range) {
|
|
300
|
-
const parts = range.split("-").map((p) => p.trim());
|
|
301
|
-
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
302
|
-
throw new Error(RANGE_FORMAT_ERROR);
|
|
303
|
-
}
|
|
304
|
-
return `${parseTimestamp(parts[0])}-${parseTimestamp(parts[1])}`;
|
|
305
|
-
}
|
|
316
|
+
import { Effect as Effect5, Context as Context3, Layer as Layer3 } from "effect";
|
|
306
317
|
|
|
307
318
|
// src/colors.ts
|
|
308
319
|
import pc from "picocolors";
|
|
@@ -331,6 +342,9 @@ var c = {
|
|
|
331
342
|
};
|
|
332
343
|
|
|
333
344
|
// src/services/DownloadService.ts
|
|
345
|
+
import * as fs from "fs";
|
|
346
|
+
import * as path from "path";
|
|
347
|
+
import { spawn } from "child_process";
|
|
334
348
|
var SPINNER_FRAMES = [
|
|
335
349
|
"⠋",
|
|
336
350
|
"⠙",
|
|
@@ -416,7 +430,7 @@ function mapExitCodeToError(code, url) {
|
|
|
416
430
|
}
|
|
417
431
|
var VIDEO_FORMATS = ["mp4", "mkv", "webm", "avi", "mov"];
|
|
418
432
|
var isVideoFormat = (format) => VIDEO_FORMATS.includes(format.toLowerCase());
|
|
419
|
-
function buildArgs(url, options, downloadDir) {
|
|
433
|
+
function buildArgs(url, options, downloadDir, ffmpegPath2) {
|
|
420
434
|
const format = options.format.toLowerCase();
|
|
421
435
|
const isVideo = isVideoFormat(format);
|
|
422
436
|
const baseArgs = [
|
|
@@ -428,8 +442,12 @@ function buildArgs(url, options, downloadDir) {
|
|
|
428
442
|
"--progress",
|
|
429
443
|
"--concurrent-fragments",
|
|
430
444
|
CONCURRENT_FRAGMENTS,
|
|
431
|
-
"--no-check-certificates"
|
|
445
|
+
"--no-check-certificates",
|
|
446
|
+
"--restrict-filenames"
|
|
432
447
|
];
|
|
448
|
+
if (ffmpegPath2) {
|
|
449
|
+
baseArgs.push("--ffmpeg-location", ffmpegPath2);
|
|
450
|
+
}
|
|
433
451
|
const formatArgs = isVideo ? [
|
|
434
452
|
"-f",
|
|
435
453
|
"bestvideo+bestaudio/best",
|
|
@@ -445,14 +463,15 @@ function buildArgs(url, options, downloadDir) {
|
|
|
445
463
|
DEFAULT_AUDIO_QUALITY,
|
|
446
464
|
"--prefer-free-formats"
|
|
447
465
|
];
|
|
448
|
-
const clipArgs =
|
|
466
|
+
const clipArgs = [];
|
|
449
467
|
return [...baseArgs, ...formatArgs, ...clipArgs];
|
|
450
468
|
}
|
|
451
469
|
|
|
452
470
|
class DownloadService extends Context3.Tag("DownloadService")() {
|
|
453
471
|
}
|
|
454
|
-
function executeDownload(ytDlpWrap, args, downloadDir, url,
|
|
455
|
-
|
|
472
|
+
function executeDownload(ytDlpWrap, args, downloadDir, url, options) {
|
|
473
|
+
const quiet = options.quiet ?? false;
|
|
474
|
+
return Effect5.async((resume) => {
|
|
456
475
|
const spinner = createSpinner("Getting ready...", quiet);
|
|
457
476
|
const state = {
|
|
458
477
|
phase: "init",
|
|
@@ -469,7 +488,87 @@ function executeDownload(ytDlpWrap, args, downloadDir, url, quiet) {
|
|
|
469
488
|
if (!quiet)
|
|
470
489
|
clearLine();
|
|
471
490
|
};
|
|
491
|
+
const parseDurationSeconds = (timeStr) => {
|
|
492
|
+
const parts = timeStr.split(":").map(Number);
|
|
493
|
+
if (parts.length === 3)
|
|
494
|
+
return (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0);
|
|
495
|
+
if (parts.length === 2)
|
|
496
|
+
return (parts[0] || 0) * 60 + (parts[1] || 0);
|
|
497
|
+
return parts[0] || 0;
|
|
498
|
+
};
|
|
499
|
+
const parseSizeToBytes = (sizeStr) => {
|
|
500
|
+
const match = sizeStr.match(/([\d.]+)\s*([KMG]i?B)/i);
|
|
501
|
+
if (!match || !match[1] || !match[2])
|
|
502
|
+
return 0;
|
|
503
|
+
const val = parseFloat(match[1]);
|
|
504
|
+
const unit = match[2].toUpperCase();
|
|
505
|
+
const multipliers = {
|
|
506
|
+
KB: 1024,
|
|
507
|
+
KIB: 1024,
|
|
508
|
+
MB: 1024 * 1024,
|
|
509
|
+
MIB: 1024 * 1024,
|
|
510
|
+
GB: 1024 * 1024 * 1024,
|
|
511
|
+
GIB: 1024 * 1024 * 1024
|
|
512
|
+
};
|
|
513
|
+
return val * (multipliers[unit] || 1);
|
|
514
|
+
};
|
|
515
|
+
const formatSpeed = (bytesPerSec) => {
|
|
516
|
+
if (bytesPerSec > 1024 * 1024)
|
|
517
|
+
return `${(bytesPerSec / (1024 * 1024)).toFixed(2)}MiB/s`;
|
|
518
|
+
if (bytesPerSec > 1024)
|
|
519
|
+
return `${(bytesPerSec / 1024).toFixed(2)}KiB/s`;
|
|
520
|
+
return `${bytesPerSec.toFixed(0)}B/s`;
|
|
521
|
+
};
|
|
522
|
+
let lastBytes = 0;
|
|
523
|
+
let lastTime = Date.now();
|
|
524
|
+
let currentSpeedStr;
|
|
525
|
+
let totalClipDuration = 0;
|
|
526
|
+
if (options.clip) {
|
|
527
|
+
const parts = options.clip.split("-");
|
|
528
|
+
if (parts.length === 2 && parts[0] && parts[1]) {
|
|
529
|
+
const start = parseDurationSeconds(parts[0]);
|
|
530
|
+
const end = parseDurationSeconds(parts[1]);
|
|
531
|
+
totalClipDuration = end - start;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
472
534
|
const emitter = ytDlpWrap.exec(args);
|
|
535
|
+
if (emitter.ytDlpProcess?.stderr) {
|
|
536
|
+
emitter.ytDlpProcess.stderr.on("data", (data) => {
|
|
537
|
+
const text = data.toString();
|
|
538
|
+
if (text.includes("frame=") && text.includes("time=")) {
|
|
539
|
+
if (state.phase === "init") {
|
|
540
|
+
state.phase = "downloading";
|
|
541
|
+
spinner.stop();
|
|
542
|
+
}
|
|
543
|
+
const timeMatch = text.match(/time=([\d:.]+)/);
|
|
544
|
+
const sizeMatch = text.match(/(?:Lsize|size)=\s*([\d.]+\s*[KMG]i?B)/i);
|
|
545
|
+
if (timeMatch?.[1]) {
|
|
546
|
+
const currentTime = parseDurationSeconds(timeMatch[1]);
|
|
547
|
+
let percent = 0;
|
|
548
|
+
if (totalClipDuration > 0) {
|
|
549
|
+
percent = currentTime / totalClipDuration * 100;
|
|
550
|
+
if (percent > 100)
|
|
551
|
+
percent = 100;
|
|
552
|
+
}
|
|
553
|
+
if (sizeMatch?.[1]) {
|
|
554
|
+
const currentBytes = parseSizeToBytes(sizeMatch[1]);
|
|
555
|
+
const now = Date.now();
|
|
556
|
+
const deltaMs = now - lastTime;
|
|
557
|
+
if (deltaMs > 500) {
|
|
558
|
+
const deltaBytes = currentBytes - lastBytes;
|
|
559
|
+
if (deltaBytes > 0) {
|
|
560
|
+
const bytesPerSec = deltaBytes / deltaMs * 1000;
|
|
561
|
+
currentSpeedStr = formatSpeed(bytesPerSec);
|
|
562
|
+
}
|
|
563
|
+
lastBytes = currentBytes;
|
|
564
|
+
lastTime = now;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
renderProgress(percent, currentSpeedStr, quiet);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
}
|
|
473
572
|
emitter.on("progress", (progress) => {
|
|
474
573
|
if (state.phase === "init") {
|
|
475
574
|
state.phase = "downloading";
|
|
@@ -483,12 +582,25 @@ function executeDownload(ytDlpWrap, args, downloadDir, url, quiet) {
|
|
|
483
582
|
state.outputBuffer += `[${eventType}] ${eventData}
|
|
484
583
|
`;
|
|
485
584
|
if (eventType === "ExtractAudio" || eventType === "Merger") {
|
|
486
|
-
|
|
585
|
+
let match = eventData.match(/Destination:\s*(.+)/);
|
|
586
|
+
if (!match) {
|
|
587
|
+
match = eventData.match(/Merging formats into "(.+)"/);
|
|
588
|
+
}
|
|
487
589
|
if (match?.[1]) {
|
|
488
590
|
state.fileName = match[1].split("/").pop() || null;
|
|
489
591
|
}
|
|
490
592
|
}
|
|
593
|
+
if (eventType === "info" && eventData.includes("Downloading 1 time ranges")) {
|
|
594
|
+
if (state.phase === "init") {
|
|
595
|
+
state.phase = "downloading";
|
|
596
|
+
spinner.stop();
|
|
597
|
+
}
|
|
598
|
+
}
|
|
491
599
|
if (eventType === "download") {
|
|
600
|
+
if (state.phase === "init") {
|
|
601
|
+
state.phase = "downloading";
|
|
602
|
+
spinner.stop();
|
|
603
|
+
}
|
|
492
604
|
const destMatch = eventData.match(/Destination:\s*(.+)/);
|
|
493
605
|
if (destMatch?.[1]) {
|
|
494
606
|
state.fileName = destMatch[1].split("/").pop() || null;
|
|
@@ -497,6 +609,18 @@ function executeDownload(ytDlpWrap, args, downloadDir, url, quiet) {
|
|
|
497
609
|
if (sizeMatch?.[1]) {
|
|
498
610
|
state.fileSize = sizeMatch[1];
|
|
499
611
|
}
|
|
612
|
+
const cleanData = eventData.replace(/\u001b\[.*?m/g, "");
|
|
613
|
+
const percentMatch = cleanData.match(/(\d+(?:\.\d+)?)%/);
|
|
614
|
+
if (percentMatch?.[1]) {
|
|
615
|
+
if (state.phase !== "downloading") {
|
|
616
|
+
state.phase = "downloading";
|
|
617
|
+
spinner.stop();
|
|
618
|
+
}
|
|
619
|
+
const percent = parseFloat(percentMatch[1]);
|
|
620
|
+
const speedMatch = cleanData.match(/at\s+([~\d.]+\s*[KMG]i?B\/s)/);
|
|
621
|
+
const speed = speedMatch?.[1];
|
|
622
|
+
renderProgress(percent, speed || undefined, quiet);
|
|
623
|
+
}
|
|
500
624
|
}
|
|
501
625
|
if ((eventType === "ExtractAudio" || eventType === "ffmpeg") && state.phase !== "converting") {
|
|
502
626
|
state.phase = "converting";
|
|
@@ -521,7 +645,7 @@ function executeDownload(ytDlpWrap, args, downloadDir, url, quiet) {
|
|
|
521
645
|
emitter.on("error", (error) => {
|
|
522
646
|
cleanup();
|
|
523
647
|
const outputError = mapOutputToError(String(error), url);
|
|
524
|
-
resume(
|
|
648
|
+
resume(Effect5.fail(outputError ?? new BinaryExecutionError({
|
|
525
649
|
exitCode: -1,
|
|
526
650
|
message: String(error)
|
|
527
651
|
})));
|
|
@@ -530,15 +654,15 @@ function executeDownload(ytDlpWrap, args, downloadDir, url, quiet) {
|
|
|
530
654
|
cleanup();
|
|
531
655
|
const outputError = mapOutputToError(state.outputBuffer, url);
|
|
532
656
|
if (outputError) {
|
|
533
|
-
resume(
|
|
657
|
+
resume(Effect5.fail(outputError));
|
|
534
658
|
return;
|
|
535
659
|
}
|
|
536
660
|
const exitCode = emitter.ytDlpProcess?.exitCode;
|
|
537
661
|
if (exitCode !== null && exitCode !== undefined && exitCode !== 0) {
|
|
538
|
-
resume(
|
|
662
|
+
resume(Effect5.fail(mapExitCodeToError(exitCode, url)));
|
|
539
663
|
return;
|
|
540
664
|
}
|
|
541
|
-
resume(
|
|
665
|
+
resume(Effect5.succeed({
|
|
542
666
|
filePath: downloadDir,
|
|
543
667
|
fileName: state.fileName || "audio",
|
|
544
668
|
fileSize: state.fileSize || undefined
|
|
@@ -546,18 +670,76 @@ function executeDownload(ytDlpWrap, args, downloadDir, url, quiet) {
|
|
|
546
670
|
});
|
|
547
671
|
});
|
|
548
672
|
}
|
|
549
|
-
var DownloadServiceLive = Layer3.effect(DownloadService,
|
|
673
|
+
var DownloadServiceLive = Layer3.effect(DownloadService, Effect5.gen(function* () {
|
|
550
674
|
const binary = yield* BinaryService;
|
|
551
675
|
const settings = yield* SettingsService;
|
|
552
676
|
return {
|
|
553
|
-
download: (url, options) =>
|
|
554
|
-
const [downloadDir, ytDlpWrap] = yield*
|
|
555
|
-
settings.getDownloadDir.pipe(
|
|
556
|
-
binary.getYtDlpWrap
|
|
677
|
+
download: (url, options) => Effect5.gen(function* () {
|
|
678
|
+
const [downloadDir, ytDlpWrap, ffmpegPath2] = yield* Effect5.all([
|
|
679
|
+
settings.getDownloadDir.pipe(Effect5.flatMap((dir) => ensureDirectory(dir))),
|
|
680
|
+
binary.getYtDlpWrap,
|
|
681
|
+
binary.requireFFmpeg
|
|
557
682
|
]);
|
|
558
683
|
const quiet = options.quiet ?? false;
|
|
559
|
-
const args = buildArgs(url, options, downloadDir);
|
|
560
|
-
|
|
684
|
+
const args = buildArgs(url, options, downloadDir, ffmpegPath2);
|
|
685
|
+
const result = yield* executeDownload(ytDlpWrap, args, downloadDir, url, options);
|
|
686
|
+
if (options.clip && ffmpegPath2 && result.fileName && result.filePath) {
|
|
687
|
+
const fullFilePath = path.join(result.filePath, result.fileName);
|
|
688
|
+
if (!fs.existsSync(fullFilePath)) {
|
|
689
|
+
if (!quiet)
|
|
690
|
+
console.error(c.error(`
|
|
691
|
+
Could not find full file for cutting: ${fullFilePath}`));
|
|
692
|
+
return result;
|
|
693
|
+
}
|
|
694
|
+
const parts = options.clip.split("-");
|
|
695
|
+
if (parts.length === 2 && parts[0] && parts[1]) {
|
|
696
|
+
const startTime = parts[0];
|
|
697
|
+
const endTime = parts[1];
|
|
698
|
+
const ext = path.extname(result.fileName);
|
|
699
|
+
const baseName = path.basename(result.fileName, ext);
|
|
700
|
+
const clipFileName = `${baseName}_clip${ext}`;
|
|
701
|
+
const clipFilePath = path.join(result.filePath, clipFileName);
|
|
702
|
+
if (!quiet)
|
|
703
|
+
console.log(c.dim(`
|
|
704
|
+
Cutting clip: ${startTime} to ${endTime}...`));
|
|
705
|
+
yield* Effect5.async((resume) => {
|
|
706
|
+
const ffmpeg = spawn(ffmpegPath2, [
|
|
707
|
+
"-i",
|
|
708
|
+
fullFilePath,
|
|
709
|
+
"-ss",
|
|
710
|
+
startTime,
|
|
711
|
+
"-to",
|
|
712
|
+
endTime,
|
|
713
|
+
"-c",
|
|
714
|
+
"copy",
|
|
715
|
+
"-y",
|
|
716
|
+
clipFilePath
|
|
717
|
+
]);
|
|
718
|
+
ffmpeg.on("close", (code) => {
|
|
719
|
+
if (code === 0) {
|
|
720
|
+
try {
|
|
721
|
+
fs.unlinkSync(fullFilePath);
|
|
722
|
+
} catch (e) {}
|
|
723
|
+
result.fileName = clipFileName;
|
|
724
|
+
result.fileSize = undefined;
|
|
725
|
+
resume(Effect5.void);
|
|
726
|
+
} else {
|
|
727
|
+
resume(Effect5.fail(new BinaryExecutionError({
|
|
728
|
+
exitCode: code || -1,
|
|
729
|
+
message: "Failed to cut clip with ffmpeg"
|
|
730
|
+
})));
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
ffmpeg.on("error", (err) => {
|
|
734
|
+
resume(Effect5.fail(new BinaryExecutionError({
|
|
735
|
+
exitCode: -1,
|
|
736
|
+
message: `FFmpeg spawn error: ${err.message}`
|
|
737
|
+
})));
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return result;
|
|
561
743
|
})
|
|
562
744
|
};
|
|
563
745
|
}));
|
|
@@ -567,8 +749,8 @@ var AppLive = DownloadServiceLive.pipe(Layer4.provide(BinaryServiceLive), Layer4
|
|
|
567
749
|
var SettingsLive = SettingsServiceLive;
|
|
568
750
|
var BinaryLive = BinaryServiceLive.pipe(Layer4.provide(SettingsServiceLive));
|
|
569
751
|
// src/cli/errors.ts
|
|
570
|
-
import { Effect as
|
|
571
|
-
var formatAppError = (error) => Match.value(error).pipe(Match.tag("BinaryNotFoundError", (e) => e.message), Match.tag("VideoNotFoundError", () => "Video not found or private"), Match.tag("InvalidUrlError", () => "Invalid URL format"), Match.tag("AgeRestrictedError", () => "Age-restricted video (login required)"), Match.tag("ConnectionError", (e) => e.message), Match.tag("BinaryExecutionError", (e) => e.message), Match.tag("BinaryDownloadError", () => "Failed to download yt-dlp binary"), Match.tag("NetworkError", (e) => e.message), Match.tag("FileWriteError", () => "Failed to write file"), Match.tag("DirectoryCreateError", () => "Failed to create directory"), Match.exhaustive);
|
|
752
|
+
import { Effect as Effect6, Match, Console, pipe } from "effect";
|
|
753
|
+
var formatAppError = (error) => Match.value(error).pipe(Match.tag("BinaryNotFoundError", (e) => e.message), Match.tag("VideoNotFoundError", () => "Video not found or private"), Match.tag("InvalidUrlError", () => "Invalid URL format"), Match.tag("AgeRestrictedError", () => "Age-restricted video (login required)"), Match.tag("ConnectionError", (e) => e.message), Match.tag("BinaryExecutionError", (e) => e.message), Match.tag("BinaryDownloadError", () => "Failed to download yt-dlp binary"), Match.tag("DownloadError", (e) => `Download failed: ${e.url}`), Match.tag("NetworkError", (e) => e.message), Match.tag("FileWriteError", () => "Failed to write file"), Match.tag("DirectoryCreateError", () => "Failed to create directory"), Match.exhaustive);
|
|
572
754
|
var isAppError = (error) => error !== null && typeof error === "object" && ("_tag" in error) && typeof error._tag === "string";
|
|
573
755
|
var formatError = (error) => {
|
|
574
756
|
if (isAppError(error))
|
|
@@ -577,10 +759,10 @@ var formatError = (error) => {
|
|
|
577
759
|
return error.message;
|
|
578
760
|
return String(error);
|
|
579
761
|
};
|
|
580
|
-
var withErrorHandler = (effect) => pipe(effect,
|
|
581
|
-
var runCommand = (effect, layer) =>
|
|
762
|
+
var withErrorHandler = (effect) => pipe(effect, Effect6.catchAll((error) => pipe(Console.error(`${c.sym.error} ${c.error("Error:")} ${formatError(error)}`), Effect6.flatMap(() => Effect6.sync(() => process.exit(1))))));
|
|
763
|
+
var runCommand = (effect, layer) => Effect6.runPromise(pipe(effect, Effect6.provide(layer), withErrorHandler));
|
|
582
764
|
// src/cli/commands.ts
|
|
583
|
-
import { Effect as
|
|
765
|
+
import { Effect as Effect7 } from "effect";
|
|
584
766
|
// src/banner.ts
|
|
585
767
|
import cfonts from "cfonts";
|
|
586
768
|
import gradient from "gradient-string";
|
|
@@ -607,40 +789,41 @@ function showBanner() {
|
|
|
607
789
|
}
|
|
608
790
|
|
|
609
791
|
// src/cli/commands.ts
|
|
610
|
-
var log = (message) =>
|
|
792
|
+
var log = (message) => Effect7.sync(() => console.log(message));
|
|
611
793
|
var logSuccess = (message) => log(`${c.sym.success} ${c.success(message)}`);
|
|
612
794
|
var logInfo = (message) => log(c.info(message));
|
|
613
795
|
var logDim = (message) => log(c.dim(message));
|
|
614
|
-
var prepareCommand =
|
|
796
|
+
var prepareCommand = Effect7.gen(function* () {
|
|
615
797
|
const binary = yield* BinaryService;
|
|
616
798
|
const existing = yield* binary.findBinary;
|
|
617
|
-
|
|
799
|
+
const ffmpeg = yield* binary.requireFFmpeg;
|
|
800
|
+
if (existing && ffmpeg) {
|
|
618
801
|
yield* logSuccess("Ready");
|
|
619
802
|
return;
|
|
620
803
|
}
|
|
621
|
-
yield* logDim("Downloading
|
|
804
|
+
yield* logDim("Downloading dependencies...");
|
|
622
805
|
yield* binary.downloadLatestBinary;
|
|
623
806
|
yield* logSuccess("Ready");
|
|
624
807
|
});
|
|
625
|
-
var setFolderCommand = (
|
|
808
|
+
var setFolderCommand = (path2, options) => Effect7.gen(function* () {
|
|
626
809
|
const settings = yield* SettingsService;
|
|
627
810
|
if (options?.reset) {
|
|
628
811
|
yield* settings.resetDownloadDir;
|
|
629
812
|
yield* logSuccess("Reset to current directory");
|
|
630
813
|
return;
|
|
631
814
|
}
|
|
632
|
-
if (
|
|
633
|
-
yield* settings.setDownloadDir(
|
|
634
|
-
yield* log(`${c.sym.success} ${c.info(
|
|
815
|
+
if (path2) {
|
|
816
|
+
yield* settings.setDownloadDir(path2);
|
|
817
|
+
yield* log(`${c.sym.success} ${c.info(path2)}`);
|
|
635
818
|
} else {
|
|
636
819
|
const dir = yield* settings.getDownloadDir;
|
|
637
820
|
yield* logInfo(dir);
|
|
638
821
|
}
|
|
639
822
|
});
|
|
640
|
-
var downloadCommand = (url, options) =>
|
|
823
|
+
var downloadCommand = (url, options) => Effect7.gen(function* () {
|
|
641
824
|
const quiet = options.quiet ?? false;
|
|
642
825
|
if (!quiet) {
|
|
643
|
-
yield*
|
|
826
|
+
yield* Effect7.sync(() => showBanner());
|
|
644
827
|
yield* log(`${c.dim("URL:")} ${c.info(url)}
|
|
645
828
|
`);
|
|
646
829
|
}
|
|
@@ -674,7 +857,7 @@ var configureCli = () => {
|
|
|
674
857
|
}
|
|
675
858
|
});
|
|
676
859
|
program.command("prepare").description("Download yt-dlp binary").action(() => runCommand(prepareCommand, BinaryLive));
|
|
677
|
-
program.command("setDefaultFolder [path]").description("Set or view default download folder").option("-r, --reset", "Reset to current directory").action((
|
|
860
|
+
program.command("setDefaultFolder [path]").description("Set or view default download folder").option("-r, --reset", "Reset to current directory").action((path2, opts) => runCommand(setFolderCommand(path2, opts), SettingsLive));
|
|
678
861
|
return program;
|
|
679
862
|
};
|
|
680
863
|
var main = () => {
|
package/dist/src/cli/errors.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect, Layer } from "effect";
|
|
2
|
-
import { BinaryNotFoundError, VideoNotFoundError, InvalidUrlError, AgeRestrictedError, ConnectionError, BinaryExecutionError, BinaryDownloadError, NetworkError, FileWriteError, DirectoryCreateError } from "../lib/errors";
|
|
3
|
-
export type AppError = BinaryNotFoundError | VideoNotFoundError | InvalidUrlError | AgeRestrictedError | ConnectionError | BinaryExecutionError | BinaryDownloadError | NetworkError | FileWriteError | DirectoryCreateError;
|
|
2
|
+
import { BinaryNotFoundError, VideoNotFoundError, InvalidUrlError, AgeRestrictedError, ConnectionError, BinaryExecutionError, BinaryDownloadError, DownloadError, NetworkError, FileWriteError, DirectoryCreateError } from "../lib/errors";
|
|
3
|
+
export type AppError = BinaryNotFoundError | VideoNotFoundError | InvalidUrlError | AgeRestrictedError | ConnectionError | BinaryExecutionError | BinaryDownloadError | DownloadError | NetworkError | FileWriteError | DirectoryCreateError;
|
|
4
4
|
export declare const formatError: (error: unknown) => string;
|
|
5
5
|
/**
|
|
6
6
|
* Runs an effect with error handling. Main entry point for CLI commands.
|
package/dist/src/config.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare const APP_NAME = "ytdwn";
|
|
2
2
|
export declare const APP_TAGLINE = "YouTube to MP3/MP4 \u2022 Fast & Simple";
|
|
3
|
-
export declare const APP_VERSION = "1.1.
|
|
3
|
+
export declare const APP_VERSION = "1.1.3";
|
|
4
4
|
export declare const DEFAULT_AUDIO_FORMAT = "mp3";
|
|
5
5
|
export declare const DEFAULT_AUDIO_QUALITY = "0";
|
|
6
6
|
export declare const DEFAULT_VIDEO_FORMAT = "mp4";
|
|
@@ -5,9 +5,10 @@ import { BinaryNotFoundError, BinaryDownloadError, type DownloadError, type File
|
|
|
5
5
|
import { SettingsService } from "./SettingsService";
|
|
6
6
|
declare const BinaryService_base: Context.TagClass<BinaryService, "BinaryService", {
|
|
7
7
|
readonly findBinary: Effect.Effect<string | null>;
|
|
8
|
-
readonly
|
|
8
|
+
readonly requireFFmpeg: Effect.Effect<string>;
|
|
9
9
|
readonly getYtDlpWrap: Effect.Effect<InstanceType<typeof YTDlpWrap>, BinaryNotFoundError>;
|
|
10
10
|
readonly downloadLatestBinary: Effect.Effect<string, BinaryDownloadError | DownloadError | FileWriteError | DirectoryCreateError>;
|
|
11
|
+
readonly requireBinary: Effect.Effect<string, BinaryNotFoundError>;
|
|
11
12
|
}>;
|
|
12
13
|
export declare class BinaryService extends BinaryService_base {
|
|
13
14
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ytdwn",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "A fast and simple CLI tool to download audio and video from YouTube",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Batikan Kutluer",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"prepublishOnly": "bun run build"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
+
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
57
58
|
"cfonts": "^3.3.1",
|
|
58
59
|
"commander": "^14.0.2",
|
|
59
60
|
"effect": "^3.19.14",
|
|
@@ -66,4 +67,4 @@
|
|
|
66
67
|
"@types/node": "^22.0.0",
|
|
67
68
|
"typescript": "^5.7.0"
|
|
68
69
|
}
|
|
69
|
-
}
|
|
70
|
+
}
|