ytdwn 1.0.1 → 1.1.1

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/dist/index.js CHANGED
@@ -1,22 +1,4 @@
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);
20
2
 
21
3
  // index.ts
22
4
  import { program } from "commander";
@@ -24,59 +6,159 @@ import { program } from "commander";
24
6
  // src/config.ts
25
7
  import { join } from "path";
26
8
  var APP_NAME = "ytdwn";
27
- var APP_TAGLINE = "YouTube to MP3 • Fast & Simple";
28
- var APP_VERSION = "1.0.0";
29
- var DEFAULT_FORMAT = "mp3";
9
+ var APP_TAGLINE = "YouTube to MP3/MP4 • Fast & Simple";
10
+ var APP_VERSION = "1.1.1";
11
+ var DEFAULT_AUDIO_FORMAT = "mp3";
30
12
  var DEFAULT_AUDIO_QUALITY = "0";
31
13
  var CONCURRENT_FRAGMENTS = "8";
14
+ var DEFAULT_FORMAT = DEFAULT_AUDIO_FORMAT;
32
15
  var BIN_DIR = join(process.cwd(), "bin");
33
16
  var getOutputTemplate = (downloadDir) => join(downloadDir, "%(title)s.%(ext)s");
34
17
 
35
- // src/binary.ts
36
- import { constants as fsConstants } from "fs";
37
- import { access, chmod, mkdir, writeFile as writeFile2 } from "fs/promises";
38
- import { platform } from "os";
39
- import { join as join3, dirname } from "path";
18
+ // src/layers/AppLive.ts
19
+ import { Layer as Layer4 } from "effect";
40
20
 
41
- // src/settings.ts
42
- import { readFile, writeFile } from "fs/promises";
21
+ // src/services/SettingsService.ts
22
+ import { Effect as Effect2, Context, Layer } from "effect";
43
23
  import { homedir } from "os";
44
24
  import { join as join2, resolve } from "path";
45
- var SETTINGS_PATH = join2(homedir(), ".ytdwn.json");
46
- async function load() {
47
- try {
48
- return JSON.parse(await readFile(SETTINGS_PATH, "utf-8"));
49
- } catch {
50
- return {};
51
- }
25
+
26
+ // src/lib/filesystem.ts
27
+ import { Effect } from "effect";
28
+ import {
29
+ access,
30
+ chmod,
31
+ mkdir,
32
+ readFile,
33
+ writeFile,
34
+ constants as fsConstants
35
+ } from "fs/promises";
36
+
37
+ // src/lib/errors.ts
38
+ import { Data } from "effect";
39
+
40
+ class FileWriteError extends Data.TaggedError("FileWriteError") {
41
+ }
42
+
43
+ class DirectoryCreateError extends Data.TaggedError("DirectoryCreateError") {
44
+ }
45
+
46
+ class NetworkError extends Data.TaggedError("NetworkError") {
47
+ }
48
+
49
+ class DownloadError extends Data.TaggedError("DownloadError") {
52
50
  }
53
- var save = (settings) => writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2));
54
- async function getDownloadDir() {
55
- const { downloadDir } = await load();
56
- return downloadDir ?? process.cwd();
51
+
52
+ class BinaryNotFoundError extends Data.TaggedError("BinaryNotFoundError") {
57
53
  }
58
- async function setDownloadDir(dir) {
59
- const settings = await load();
60
- await save({ ...settings, downloadDir: resolve(dir) });
54
+
55
+ class BinaryDownloadError extends Data.TaggedError("BinaryDownloadError") {
61
56
  }
62
- async function resetDownloadDir() {
63
- const { downloadDir, ...rest } = await load();
64
- await save(rest);
57
+
58
+ class BinaryExecutionError extends Data.TaggedError("BinaryExecutionError") {
65
59
  }
66
- async function getCachedBinaryPath() {
67
- const { binaryPath } = await load();
68
- return binaryPath ?? null;
60
+
61
+ class TimestampParseError extends Data.TaggedError("TimestampParseError") {
62
+ }
63
+
64
+ class VideoNotFoundError extends Data.TaggedError("VideoNotFoundError") {
65
+ }
66
+
67
+ class InvalidUrlError extends Data.TaggedError("InvalidUrlError") {
69
68
  }
70
- async function setCachedBinaryPath(path) {
71
- const settings = await load();
72
- await save({ ...settings, binaryPath: path });
69
+
70
+ class AgeRestrictedError extends Data.TaggedError("AgeRestrictedError") {
73
71
  }
74
- async function clearCachedBinaryPath() {
75
- const { binaryPath, ...rest } = await load();
76
- await save(rest);
72
+
73
+ class ConnectionError extends Data.TaggedError("ConnectionError") {
77
74
  }
78
75
 
79
- // src/binary.ts
76
+ // src/lib/filesystem.ts
77
+ var isExecutable = (path) => Effect.tryPromise({
78
+ try: async () => {
79
+ await access(path, fsConstants.X_OK);
80
+ return true;
81
+ },
82
+ catch: () => false
83
+ }).pipe(Effect.orElseSucceed(() => false));
84
+ var readJsonFileOrDefault = (path, defaultValue) => Effect.tryPromise({
85
+ try: async () => JSON.parse(await readFile(path, "utf-8")),
86
+ catch: () => defaultValue
87
+ }).pipe(Effect.orElseSucceed(() => defaultValue));
88
+ var writeJsonFile = (path, data) => Effect.tryPromise({
89
+ try: () => writeFile(path, JSON.stringify(data, null, 2), "utf-8"),
90
+ catch: (cause) => new FileWriteError({ path, cause })
91
+ });
92
+ var writeFileBinary = (path, data) => Effect.tryPromise({
93
+ try: () => writeFile(path, data),
94
+ catch: (cause) => new FileWriteError({ path, cause })
95
+ });
96
+ var ensureDirectory = (path) => Effect.tryPromise({
97
+ try: () => mkdir(path, { recursive: true }),
98
+ catch: (cause) => new DirectoryCreateError({ path, cause })
99
+ }).pipe(Effect.as(path));
100
+ var makeExecutable = (path) => Effect.tryPromise({
101
+ try: () => chmod(path, 493),
102
+ catch: (cause) => new FileWriteError({ path, cause })
103
+ });
104
+
105
+ // src/services/SettingsService.ts
106
+ var SETTINGS_PATH = join2(homedir(), ".ytdwn.json");
107
+
108
+ class SettingsService extends Context.Tag("SettingsService")() {
109
+ }
110
+ var loadSettings = () => readJsonFileOrDefault(SETTINGS_PATH, {});
111
+ var saveSettings = (settings) => writeJsonFile(SETTINGS_PATH, settings);
112
+ var SettingsServiceLive = Layer.succeed(SettingsService, {
113
+ getDownloadDir: Effect2.gen(function* () {
114
+ const { downloadDir } = yield* loadSettings();
115
+ return downloadDir ?? process.cwd();
116
+ }),
117
+ setDownloadDir: (dir) => Effect2.gen(function* () {
118
+ const settings = yield* loadSettings();
119
+ yield* saveSettings({ ...settings, downloadDir: resolve(dir) });
120
+ }),
121
+ resetDownloadDir: Effect2.gen(function* () {
122
+ const settings = yield* loadSettings();
123
+ const { downloadDir: _, ...rest } = settings;
124
+ yield* saveSettings(rest);
125
+ }),
126
+ getCachedBinaryPath: Effect2.gen(function* () {
127
+ const { binaryPath } = yield* loadSettings();
128
+ return binaryPath ?? null;
129
+ }),
130
+ setCachedBinaryPath: (path) => Effect2.gen(function* () {
131
+ const settings = yield* loadSettings();
132
+ yield* saveSettings({ ...settings, binaryPath: path });
133
+ }),
134
+ clearCachedBinaryPath: Effect2.gen(function* () {
135
+ const settings = yield* loadSettings();
136
+ const { binaryPath: _, ...rest } = settings;
137
+ yield* saveSettings(rest);
138
+ })
139
+ });
140
+
141
+ // src/services/BinaryService.ts
142
+ import { Effect as Effect4, Context as Context2, Layer as Layer2 } from "effect";
143
+ import YTDlpWrapModule from "yt-dlp-wrap";
144
+ import { platform } from "os";
145
+ import { join as join3 } from "path";
146
+
147
+ // src/lib/http.ts
148
+ import { Effect as Effect3, Schedule } from "effect";
149
+ var fetchBinaryWithRetry = (url, maxRetries = 2) => Effect3.tryPromise({
150
+ try: async () => {
151
+ const response = await fetch(url);
152
+ if (!response.ok) {
153
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
154
+ }
155
+ return Buffer.from(await response.arrayBuffer());
156
+ },
157
+ catch: (cause) => new DownloadError({ url, cause })
158
+ }).pipe(Effect3.retry(Schedule.recurs(maxRetries).pipe(Schedule.addDelay(() => "1 second"))));
159
+
160
+ // src/services/BinaryService.ts
161
+ var YTDlpWrap = YTDlpWrapModule.default ?? YTDlpWrapModule;
80
162
  var BINARY_NAMES = {
81
163
  win32: ["yt-dlp.exe"],
82
164
  darwin: {
@@ -89,7 +171,7 @@ var BINARY_NAMES = {
89
171
  }
90
172
  };
91
173
  var FALLBACK_BINARY = "yt-dlp";
92
- function getCandidateNames() {
174
+ var getCandidateNames = () => {
93
175
  const platformNames = BINARY_NAMES[process.platform];
94
176
  if (!platformNames)
95
177
  return [FALLBACK_BINARY];
@@ -97,96 +179,106 @@ function getCandidateNames() {
97
179
  return platformNames;
98
180
  const archNames = platformNames[process.arch];
99
181
  return archNames ?? [FALLBACK_BINARY];
100
- }
101
- function getCandidatePaths() {
102
- return getCandidateNames().map((name) => join3(BIN_DIR, name));
103
- }
104
- async function isExecutable(filePath) {
105
- try {
106
- await access(filePath, fsConstants.X_OK);
107
- return true;
108
- } catch {
109
- return false;
110
- }
111
- }
112
- async function downloadFile(url, targetPath) {
113
- await mkdir(dirname(targetPath), { recursive: true });
114
- const response = await fetch(url);
115
- if (!response.ok) {
116
- throw new Error(`Download failed: ${response.status} ${response.statusText}`);
117
- }
118
- await writeFile2(targetPath, Buffer.from(await response.arrayBuffer()));
119
- if (platform() !== "win32") {
120
- await chmod(targetPath, 493);
121
- }
122
- }
123
- function pickAsset(release) {
182
+ };
183
+ var getCandidatePaths = () => getCandidateNames().map((name) => join3(BIN_DIR, name));
184
+ var pickAsset = (release) => {
124
185
  const assets = release.assets ?? [];
125
186
  const candidates = getCandidateNames();
126
187
  const match = assets.find((a) => candidates.includes(a.name)) ?? assets.find((a) => a.name === FALLBACK_BINARY);
127
188
  if (!match) {
128
- throw new Error("Suitable yt-dlp binary not found for this platform.");
189
+ return Effect4.fail(new BinaryDownloadError({
190
+ platform: `${process.platform}-${process.arch}`,
191
+ cause: "No suitable binary found for this platform"
192
+ }));
129
193
  }
130
- return match;
131
- }
132
- async function findBinary() {
133
- const cached = await getCachedBinaryPath();
134
- if (cached && await isExecutable(cached)) {
135
- return cached;
136
- }
137
- for (const candidate of getCandidatePaths()) {
138
- if (await isExecutable(candidate)) {
139
- await setCachedBinaryPath(candidate);
140
- return candidate;
194
+ return Effect4.succeed(match);
195
+ };
196
+
197
+ class BinaryService extends Context2.Tag("BinaryService")() {
198
+ }
199
+ var BinaryServiceLive = Layer2.effect(BinaryService, Effect4.gen(function* () {
200
+ const settings = yield* SettingsService;
201
+ const searchForBinary = Effect4.gen(function* () {
202
+ for (const candidate of getCandidatePaths()) {
203
+ const executable = yield* isExecutable(candidate);
204
+ if (executable) {
205
+ yield* settings.setCachedBinaryPath(candidate).pipe(Effect4.ignore);
206
+ return candidate;
207
+ }
141
208
  }
142
- }
143
- if (cached)
144
- await clearCachedBinaryPath();
145
- return null;
146
- }
147
- async function requireBinary() {
148
- const existing = await findBinary();
149
- if (existing)
150
- return existing;
151
- throw new Error("yt-dlp binary not found. Run 'ytdwn prepare' first.");
152
- }
153
- async function downloadLatestBinary() {
154
- let release = null;
155
- try {
156
- const ytDlpWrapModule = await import("yt-dlp-wrap");
157
- const YTDlpWrap = ytDlpWrapModule.default;
158
- if (typeof YTDlpWrap?.getGithubReleases === "function") {
159
- const releases = await YTDlpWrap.getGithubReleases(1, 1);
160
- release = Array.isArray(releases) ? releases[0] : releases;
209
+ return null;
210
+ });
211
+ const findBinary = Effect4.gen(function* () {
212
+ const cached = yield* settings.getCachedBinaryPath;
213
+ if (cached) {
214
+ const executable = yield* isExecutable(cached);
215
+ if (executable)
216
+ return cached;
161
217
  }
162
- } catch (err) {}
163
- if (!release) {
164
- const response = await fetch("https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest");
165
- if (!response.ok) {
166
- throw new Error(`Failed to fetch yt-dlp release: ${response.status} ${response.statusText}`);
218
+ const found = yield* searchForBinary;
219
+ if (found)
220
+ return found;
221
+ if (cached) {
222
+ yield* settings.clearCachedBinaryPath.pipe(Effect4.ignore);
167
223
  }
168
- release = await response.json();
169
- }
170
- if (!release) {
171
- throw new Error("Failed to fetch yt-dlp release from GitHub.");
172
- }
173
- const asset = pickAsset(release);
174
- const binaryPath = join3(BIN_DIR, asset.name);
175
- if (await isExecutable(binaryPath)) {
176
- await setCachedBinaryPath(binaryPath);
224
+ return null;
225
+ });
226
+ const requireBinary = Effect4.gen(function* () {
227
+ const existing = yield* findBinary;
228
+ if (existing)
229
+ return existing;
230
+ return yield* Effect4.fail(new BinaryNotFoundError({
231
+ message: "yt-dlp binary not found. Run 'ytdwn prepare' first."
232
+ }));
233
+ });
234
+ const getYtDlpWrap = Effect4.gen(function* () {
235
+ const binaryPath = yield* requireBinary;
236
+ return new YTDlpWrap(binaryPath);
237
+ });
238
+ const downloadLatestBinary = Effect4.gen(function* () {
239
+ const releases = yield* Effect4.tryPromise({
240
+ try: () => YTDlpWrap.getGithubReleases(1, 1),
241
+ catch: (cause) => new BinaryDownloadError({
242
+ platform: `${process.platform}-${process.arch}`,
243
+ cause
244
+ })
245
+ });
246
+ const release = Array.isArray(releases) ? releases[0] : releases;
247
+ if (!release) {
248
+ return yield* Effect4.fail(new BinaryDownloadError({
249
+ platform: `${process.platform}-${process.arch}`,
250
+ cause: "No releases found"
251
+ }));
252
+ }
253
+ const asset = yield* pickAsset(release);
254
+ const binaryPath = join3(BIN_DIR, asset.name);
255
+ const exists = yield* isExecutable(binaryPath);
256
+ if (exists) {
257
+ yield* settings.setCachedBinaryPath(binaryPath).pipe(Effect4.ignore);
258
+ return binaryPath;
259
+ }
260
+ yield* ensureDirectory(BIN_DIR);
261
+ const data = yield* fetchBinaryWithRetry(asset.browser_download_url);
262
+ yield* writeFileBinary(binaryPath, data);
263
+ if (platform() !== "win32") {
264
+ yield* makeExecutable(binaryPath);
265
+ }
266
+ yield* settings.setCachedBinaryPath(binaryPath).pipe(Effect4.ignore);
177
267
  return binaryPath;
178
- }
179
- await downloadFile(asset.browser_download_url, binaryPath);
180
- await setCachedBinaryPath(binaryPath);
181
- return binaryPath;
182
- }
268
+ });
269
+ return {
270
+ findBinary,
271
+ requireBinary,
272
+ getYtDlpWrap,
273
+ downloadLatestBinary
274
+ };
275
+ }));
183
276
 
184
- // src/download.ts
185
- import { mkdir as mkdir2 } from "fs/promises";
186
- import { spawn } from "child_process";
187
- import ffmpegInstaller from "@ffmpeg-installer/ffmpeg";
277
+ // src/services/DownloadService.ts
278
+ import { Effect as Effect6, Context as Context3, Layer as Layer3 } from "effect";
188
279
 
189
280
  // src/timestamp.ts
281
+ import { Effect as Effect5 } from "effect";
190
282
  var TIME_FORMAT_ERROR = "Invalid time format. Use MM:SS or HH:MM:SS (e.g. 0:02 or 01:23:45).";
191
283
  var RANGE_FORMAT_ERROR = "Range must be in 'start-end' format (e.g. 0:02-23:10).";
192
284
  var pad = (value) => value.padStart(2, "0");
@@ -238,7 +330,7 @@ var c = {
238
330
  }
239
331
  };
240
332
 
241
- // src/download.ts
333
+ // src/services/DownloadService.ts
242
334
  var SPINNER_FRAMES = [
243
335
  "⠋",
244
336
  "⠙",
@@ -254,20 +346,6 @@ var SPINNER_FRAMES = [
254
346
  var SPINNER_INTERVAL_MS = 80;
255
347
  var PROGRESS_BAR_WIDTH = 12;
256
348
  var LINE_CLEAR_WIDTH = 60;
257
- var REGEX = {
258
- percent: /(\d+\.?\d*)%/,
259
- total: /of\s+~?([\d.]+\s*\w+)/i,
260
- speed: /at\s+([\d.]+\s*\w+\/s)/i,
261
- size: /([\d.]+)\s*(\w+)/,
262
- binaryUnits: /([KMG])iB/g,
263
- destination: /\[(?:ExtractAudio|Merger)\].*?Destination:\s*(.+)$/m,
264
- downloadDest: /\[download\]\s+Destination:\s*(.+)$/m,
265
- fileSize: /~?([\d.]+\s*[KMG]i?B)/i
266
- };
267
- var PHASE_MESSAGES = new Map([
268
- ["Extracting URL", "Extracting..."],
269
- ["Downloading webpage", "Fetching info..."]
270
- ]);
271
349
  var write = (text) => process.stdout.write(text);
272
350
  var clearLine = () => write(`\r${" ".repeat(LINE_CLEAR_WIDTH)}\r`);
273
351
  function createSpinner(initialMessage = "Getting ready...", quiet = false) {
@@ -275,10 +353,7 @@ function createSpinner(initialMessage = "Getting ready...", quiet = false) {
275
353
  let message = initialMessage;
276
354
  let stopped = false;
277
355
  if (quiet) {
278
- return {
279
- update: () => {},
280
- stop: () => {}
281
- };
356
+ return { update: () => {}, stop: () => {} };
282
357
  }
283
358
  const interval = setInterval(() => {
284
359
  if (!stopped) {
@@ -299,7 +374,7 @@ function createSpinner(initialMessage = "Getting ready...", quiet = false) {
299
374
  }
300
375
  };
301
376
  }
302
- function renderProgress({ percent, speed }, quiet = false) {
377
+ function renderProgress(percent, speed, quiet = false) {
303
378
  if (quiet)
304
379
  return;
305
380
  const filled = Math.round(percent / 100 * PROGRESS_BAR_WIDTH);
@@ -307,58 +382,45 @@ function renderProgress({ percent, speed }, quiet = false) {
307
382
  const speedText = speed ? ` ${c.speed(speed)}` : "";
308
383
  write(`\r${c.bold("Downloading:")} ${bar} ${c.bar.percent(`${percent.toFixed(0)}%`)}${speedText} `);
309
384
  }
310
- var normalizeUnit = (value) => value.replace(REGEX.binaryUnits, "$1B");
311
- function parseProgress(text) {
312
- const percentMatch = text.match(REGEX.percent);
313
- if (!percentMatch?.[1])
314
- return null;
315
- const percent = parseFloat(percentMatch[1]);
316
- const total = text.match(REGEX.total)?.[1]?.trim();
317
- const speed = text.match(REGEX.speed)?.[1];
318
- let downloaded;
319
- if (total) {
320
- const [, size, unit] = total.match(REGEX.size) ?? [];
321
- if (size && unit) {
322
- downloaded = normalizeUnit(`${(percent / 100 * parseFloat(size)).toFixed(1)}${unit}`);
323
- }
385
+ function mapOutputToError(output, url) {
386
+ const ageRestrictedPatterns = [
387
+ /age[- ]?restrict/i,
388
+ /age[- ]?gate/i,
389
+ /sign in to confirm your age/i,
390
+ /this video is age-restricted/i,
391
+ /confirm your age/i
392
+ ];
393
+ if (ageRestrictedPatterns.some((p) => p.test(output))) {
394
+ return new AgeRestrictedError({ url });
324
395
  }
325
- return {
326
- percent,
327
- downloaded,
328
- total: total ? normalizeUnit(total) : undefined,
329
- speed: speed ? normalizeUnit(speed) : undefined
330
- };
331
- }
332
- function detectPhase(text) {
333
- for (const [pattern, message] of PHASE_MESSAGES) {
334
- if (text.includes(pattern))
335
- return message;
396
+ if (/\b(network|connection)\s+(error|failed|refused)/i.test(output)) {
397
+ return new ConnectionError({ message: "Connection error, try again" });
336
398
  }
337
- return text.includes("Downloading") && !text.includes("%") ? "Preparing..." : null;
338
- }
339
- var isConvertingPhase = (text) => text.includes("ExtractAudio") || text.includes("Converting");
340
- function extractFileName(text) {
341
- const match = text.match(REGEX.destination) || text.match(REGEX.downloadDest);
342
- if (match?.[1]) {
343
- const fullPath = match[1].trim();
344
- return fullPath.split("/").pop() || null;
399
+ if (/video unavailable/i.test(output) || /private video/i.test(output)) {
400
+ return new VideoNotFoundError({ url });
345
401
  }
346
402
  return null;
347
403
  }
348
- function extractFileSize(text) {
349
- const match = text.match(REGEX.fileSize);
350
- return match?.[1] || null;
404
+ function mapExitCodeToError(code, url) {
405
+ switch (code) {
406
+ case 1:
407
+ return new VideoNotFoundError({ url });
408
+ case 2:
409
+ return new InvalidUrlError({ url });
410
+ default:
411
+ return new BinaryExecutionError({
412
+ exitCode: code,
413
+ message: `Download failed with exit code ${code}`
414
+ });
415
+ }
351
416
  }
417
+ var VIDEO_FORMATS = ["mp4", "mkv", "webm", "avi", "mov"];
418
+ var isVideoFormat = (format) => VIDEO_FORMATS.includes(format.toLowerCase());
352
419
  function buildArgs(url, options, downloadDir) {
420
+ const format = options.format.toLowerCase();
421
+ const isVideo = isVideoFormat(format);
353
422
  const baseArgs = [
354
423
  url,
355
- "-f",
356
- "bestaudio/best",
357
- "-x",
358
- "--audio-format",
359
- options.format,
360
- "--audio-quality",
361
- DEFAULT_AUDIO_QUALITY,
362
424
  "-o",
363
425
  getOutputTemplate(downloadDir),
364
426
  "--no-playlist",
@@ -366,68 +428,39 @@ function buildArgs(url, options, downloadDir) {
366
428
  "--progress",
367
429
  "--concurrent-fragments",
368
430
  CONCURRENT_FRAGMENTS,
369
- "--no-check-certificates",
431
+ "--no-check-certificates"
432
+ ];
433
+ const formatArgs = isVideo ? [
434
+ "-f",
435
+ `bestvideo[ext=${format}]+bestaudio/best[ext=${format}]/best`,
436
+ "--merge-output-format",
437
+ format
438
+ ] : [
439
+ "-f",
440
+ "bestaudio/best",
441
+ "-x",
442
+ "--audio-format",
443
+ format,
444
+ "--audio-quality",
445
+ DEFAULT_AUDIO_QUALITY,
370
446
  "--prefer-free-formats"
371
447
  ];
372
- const conditionalArgs = [];
373
- if (options.clip) {
374
- conditionalArgs.push("--download-sections", `*${parseClipRange(options.clip)}`);
375
- }
376
- if (ffmpegInstaller?.path) {
377
- conditionalArgs.push("--ffmpeg-location", ffmpegInstaller.path);
378
- }
379
- return [...baseArgs, ...conditionalArgs];
448
+ const clipArgs = options.clip ? ["--download-sections", `*${parseClipRange(options.clip)}`] : [];
449
+ return [...baseArgs, ...formatArgs, ...clipArgs];
380
450
  }
381
- function createOutputHandler(spinner, state) {
382
- return (data) => {
383
- const text = data.toString();
384
- const fileName = extractFileName(text);
385
- if (fileName)
386
- state.fileName = fileName;
387
- const fileSize = extractFileSize(text);
388
- if (fileSize)
389
- state.fileSize = fileSize;
390
- if (state.phase !== "converting" && isConvertingPhase(text)) {
391
- state.phase = "converting";
392
- spinner.stop();
393
- clearLine();
394
- if (!state.quiet) {
395
- let frame = 0;
396
- state.convertingInterval = setInterval(() => {
397
- const frameChar = SPINNER_FRAMES[frame++ % SPINNER_FRAMES.length] ?? "⠋";
398
- write(`\r${c.info(frameChar)} ${c.dim("Converting...")}`);
399
- }, SPINNER_INTERVAL_MS);
400
- }
401
- return;
402
- }
403
- const progress = parseProgress(text);
404
- if (progress && progress.percent > state.lastPercent) {
405
- if (state.phase === "init") {
406
- state.phase = "downloading";
407
- spinner.stop();
408
- }
409
- state.lastPercent = progress.percent;
410
- renderProgress(progress, state.quiet);
411
- return;
412
- }
413
- if (state.phase === "init") {
414
- const message = detectPhase(text);
415
- if (message)
416
- spinner.update(message);
417
- }
418
- };
451
+
452
+ class DownloadService extends Context3.Tag("DownloadService")() {
419
453
  }
420
- function attachProcessHandlers(child, spinner, downloadDir, quiet) {
421
- return new Promise((resolve2, reject) => {
454
+ function executeDownload(ytDlpWrap, args, downloadDir, url, quiet) {
455
+ return Effect6.async((resume) => {
456
+ const spinner = createSpinner("Getting ready...", quiet);
422
457
  const state = {
423
458
  phase: "init",
424
- lastPercent: 0,
425
459
  fileName: null,
426
460
  fileSize: null,
427
- quiet,
461
+ outputBuffer: "",
428
462
  convertingInterval: null
429
463
  };
430
- const handleOutput = createOutputHandler(spinner, state);
431
464
  const cleanup = () => {
432
465
  spinner.stop();
433
466
  if (state.convertingInterval) {
@@ -436,40 +469,118 @@ function attachProcessHandlers(child, spinner, downloadDir, quiet) {
436
469
  if (!quiet)
437
470
  clearLine();
438
471
  };
439
- child.stdout?.on("data", handleOutput);
440
- child.stderr?.on("data", handleOutput);
441
- child.on("close", (code) => {
442
- cleanup();
443
- if (code === 0) {
444
- resolve2({
445
- filePath: downloadDir,
446
- fileName: state.fileName || "audio",
447
- fileSize: state.fileSize || undefined
448
- });
449
- } else {
450
- reject(new Error(`Download failed (code ${code})`));
472
+ const emitter = ytDlpWrap.exec(args);
473
+ emitter.on("progress", (progress) => {
474
+ if (state.phase === "init") {
475
+ state.phase = "downloading";
476
+ spinner.stop();
477
+ }
478
+ if (progress.percent !== undefined) {
479
+ renderProgress(progress.percent, progress.currentSpeed, quiet);
451
480
  }
452
481
  });
453
- child.on("error", (err) => {
482
+ emitter.on("ytDlpEvent", (eventType, eventData) => {
483
+ state.outputBuffer += `[${eventType}] ${eventData}
484
+ `;
485
+ if (eventType === "ExtractAudio" || eventType === "Merger") {
486
+ const match = eventData.match(/Destination:\s*(.+)/);
487
+ if (match?.[1]) {
488
+ state.fileName = match[1].split("/").pop() || null;
489
+ }
490
+ }
491
+ if (eventType === "download") {
492
+ const destMatch = eventData.match(/Destination:\s*(.+)/);
493
+ if (destMatch?.[1]) {
494
+ state.fileName = destMatch[1].split("/").pop() || null;
495
+ }
496
+ const sizeMatch = eventData.match(/~?([\d.]+\s*[KMG]i?B)/i);
497
+ if (sizeMatch?.[1]) {
498
+ state.fileSize = sizeMatch[1];
499
+ }
500
+ }
501
+ if ((eventType === "ExtractAudio" || eventType === "ffmpeg") && state.phase !== "converting") {
502
+ state.phase = "converting";
503
+ spinner.stop();
504
+ clearLine();
505
+ if (!quiet) {
506
+ let frame = 0;
507
+ state.convertingInterval = setInterval(() => {
508
+ const frameChar = SPINNER_FRAMES[frame++ % SPINNER_FRAMES.length] ?? "⠋";
509
+ write(`\r${c.info(frameChar)} ${c.dim("Converting...")}`);
510
+ }, SPINNER_INTERVAL_MS);
511
+ }
512
+ }
513
+ if (state.phase === "init") {
514
+ if (eventType === "youtube" && eventData.includes("Extracting")) {
515
+ spinner.update("Extracting...");
516
+ } else if (eventType === "youtube" && eventData.includes("Downloading webpage")) {
517
+ spinner.update("Fetching info...");
518
+ }
519
+ }
520
+ });
521
+ emitter.on("error", (error) => {
522
+ cleanup();
523
+ const outputError = mapOutputToError(String(error), url);
524
+ resume(Effect6.fail(outputError ?? new BinaryExecutionError({
525
+ exitCode: -1,
526
+ message: String(error)
527
+ })));
528
+ });
529
+ emitter.on("close", () => {
454
530
  cleanup();
455
- reject(err);
531
+ const outputError = mapOutputToError(state.outputBuffer, url);
532
+ if (outputError) {
533
+ resume(Effect6.fail(outputError));
534
+ return;
535
+ }
536
+ const exitCode = emitter.ytDlpProcess?.exitCode;
537
+ if (exitCode !== null && exitCode !== undefined && exitCode !== 0) {
538
+ resume(Effect6.fail(mapExitCodeToError(exitCode, url)));
539
+ return;
540
+ }
541
+ resume(Effect6.succeed({
542
+ filePath: downloadDir,
543
+ fileName: state.fileName || "audio",
544
+ fileSize: state.fileSize || undefined
545
+ }));
456
546
  });
457
547
  });
458
548
  }
459
- async function downloadAudio(url, options) {
460
- const [downloadDir, binaryPath] = await Promise.all([
461
- getDownloadDir().then(async (dir) => {
462
- await mkdir2(dir, { recursive: true });
463
- return dir;
464
- }),
465
- requireBinary()
466
- ]);
467
- const quiet = options.quiet ?? false;
468
- const spinner = createSpinner("Getting ready...", quiet);
469
- const child = spawn(binaryPath, buildArgs(url, options, downloadDir));
470
- return attachProcessHandlers(child, spinner, downloadDir, quiet);
471
- }
549
+ var DownloadServiceLive = Layer3.effect(DownloadService, Effect6.gen(function* () {
550
+ const binary = yield* BinaryService;
551
+ const settings = yield* SettingsService;
552
+ return {
553
+ download: (url, options) => Effect6.gen(function* () {
554
+ const [downloadDir, ytDlpWrap] = yield* Effect6.all([
555
+ settings.getDownloadDir.pipe(Effect6.flatMap((dir) => ensureDirectory(dir))),
556
+ binary.getYtDlpWrap
557
+ ]);
558
+ const quiet = options.quiet ?? false;
559
+ const args = buildArgs(url, options, downloadDir);
560
+ return yield* executeDownload(ytDlpWrap, args, downloadDir, url, quiet);
561
+ })
562
+ };
563
+ }));
472
564
 
565
+ // src/layers/AppLive.ts
566
+ var AppLive = DownloadServiceLive.pipe(Layer4.provide(BinaryServiceLive), Layer4.provide(SettingsServiceLive));
567
+ var SettingsLive = SettingsServiceLive;
568
+ var BinaryLive = BinaryServiceLive.pipe(Layer4.provide(SettingsServiceLive));
569
+ // src/cli/errors.ts
570
+ import { Effect as Effect7, Match, Console, pipe } from "effect";
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);
572
+ var isAppError = (error) => error !== null && typeof error === "object" && ("_tag" in error) && typeof error._tag === "string";
573
+ var formatError = (error) => {
574
+ if (isAppError(error))
575
+ return formatAppError(error);
576
+ if (error instanceof Error)
577
+ return error.message;
578
+ return String(error);
579
+ };
580
+ var withErrorHandler = (effect) => pipe(effect, Effect7.catchAll((error) => pipe(Console.error(`${c.sym.error} ${c.error("Error:")} ${formatError(error)}`), Effect7.flatMap(() => Effect7.sync(() => process.exit(1))))));
581
+ var runCommand = (effect, layer) => Effect7.runPromise(pipe(effect, Effect7.provide(layer), withErrorHandler));
582
+ // src/cli/commands.ts
583
+ import { Effect as Effect8 } from "effect";
473
584
  // src/banner.ts
474
585
  import cfonts from "cfonts";
475
586
  import gradient from "gradient-string";
@@ -495,100 +606,86 @@ function showBanner() {
495
606
  `));
496
607
  }
497
608
 
498
- // index.ts
499
- var formatError = (err) => {
500
- const msg = err instanceof Error ? err.message : String(err);
501
- if (msg.includes("code 1") || msg.includes("Video unavailable")) {
502
- return "Video not found or private";
503
- }
504
- if (msg.includes("code 2")) {
505
- return "Invalid URL format";
609
+ // src/cli/commands.ts
610
+ var log = (message) => Effect8.sync(() => console.log(message));
611
+ var logSuccess = (message) => log(`${c.sym.success} ${c.success(message)}`);
612
+ var logInfo = (message) => log(c.info(message));
613
+ var logDim = (message) => log(c.dim(message));
614
+ var prepareCommand = Effect8.gen(function* () {
615
+ const binary = yield* BinaryService;
616
+ const existing = yield* binary.findBinary;
617
+ if (existing) {
618
+ yield* logSuccess("Ready");
619
+ return;
506
620
  }
507
- if (msg.includes("age")) {
508
- return "Age-restricted video (login required)";
621
+ yield* logDim("Downloading yt-dlp...");
622
+ yield* binary.downloadLatestBinary;
623
+ yield* logSuccess("Ready");
624
+ });
625
+ var setFolderCommand = (path, options) => Effect8.gen(function* () {
626
+ const settings = yield* SettingsService;
627
+ if (options?.reset) {
628
+ yield* settings.resetDownloadDir;
629
+ yield* logSuccess("Reset to current directory");
630
+ return;
509
631
  }
510
- if (msg.includes("network") || msg.includes("connect")) {
511
- return "Connection error, try again";
632
+ if (path) {
633
+ yield* settings.setDownloadDir(path);
634
+ yield* log(`${c.sym.success} ${c.info(path)}`);
635
+ } else {
636
+ const dir = yield* settings.getDownloadDir;
637
+ yield* logInfo(dir);
512
638
  }
513
- return msg;
514
- };
515
- var exitWithError = (err) => {
516
- console.error(`${c.sym.error} ${c.error("Error:")} ${formatError(err)}`);
517
- process.exit(1);
518
- };
519
- var isUrl = (arg) => arg.startsWith("http://") || arg.startsWith("https://");
520
- async function handlePrepare() {
521
- try {
522
- if (await findBinary()) {
523
- console.log(`${c.sym.success} ${c.success("Ready")}`);
524
- return;
525
- }
526
- console.log(`${c.dim("Downloading yt-dlp...")}`);
527
- await downloadLatestBinary();
528
- console.log(`${c.sym.success} ${c.success("Ready")}`);
529
- } catch (err) {
530
- exitWithError(err);
639
+ });
640
+ var downloadCommand = (url, options) => Effect8.gen(function* () {
641
+ const quiet = options.quiet ?? false;
642
+ if (!quiet) {
643
+ yield* Effect8.sync(() => showBanner());
644
+ yield* log(`${c.dim("URL:")} ${c.info(url)}
645
+ `);
531
646
  }
532
- }
533
- async function handleSetFolder(folderPath, opts) {
534
- try {
535
- if (opts?.reset) {
536
- await resetDownloadDir();
537
- console.log(`${c.sym.success} ${c.success("Reset to current directory")}`);
538
- return;
539
- }
540
- if (folderPath) {
541
- await setDownloadDir(folderPath);
542
- console.log(`${c.sym.success} ${c.info(folderPath)}`);
543
- } else {
544
- console.log(c.info(await getDownloadDir()));
545
- }
546
- } catch (err) {
547
- exitWithError(err);
647
+ const download = yield* DownloadService;
648
+ const result = yield* download.download(url, options);
649
+ if (quiet) {
650
+ yield* log(result.fileName);
651
+ } else {
652
+ yield* log("");
653
+ yield* logSuccess("Process done!");
654
+ yield* log("");
655
+ const sizeInfo = result.fileSize ? ` ${c.size(`(${result.fileSize})`)}` : "";
656
+ yield* log(`${c.file(result.fileName)}${sizeInfo}`);
548
657
  }
549
- }
550
- async function handleDownload(url, opts) {
551
- try {
552
- if (!opts.quiet) {
658
+ });
659
+ // index.ts
660
+ var isUrl = (arg) => arg.startsWith("http://") || arg.startsWith("https://");
661
+ var configureCli = () => {
662
+ program.name(APP_NAME).usage("<url> [options]").description("Download audio or video from YouTube").version(APP_VERSION, "-v, --version").argument("[url]", "YouTube URL to download").option("-f, --format <format>", "Output format (mp3, mp4, mkv, etc.)", 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", (cmd) => {
663
+ const isHelp = cmd.args.includes("help") || process.argv.includes("--help") || process.argv.includes("-h");
664
+ if (isHelp)
553
665
  showBanner();
554
- console.log(`${c.dim("URL:")} ${c.info(url)}
555
- `);
556
- }
557
- const result = await downloadAudio(url, {
558
- format: opts.format,
559
- clip: opts.clip,
560
- quiet: opts.quiet
561
- });
562
- if (opts.quiet) {
563
- console.log(result.fileName);
564
- } else {
565
- console.log();
566
- console.log(`${c.sym.success} ${c.success("Process done!")}
567
- `);
568
- const sizeInfo = result.fileSize ? ` ${c.size(`(${result.fileSize})`)}` : "";
569
- console.log(`${c.file(result.fileName)}${sizeInfo}`);
666
+ }).action((url, opts) => {
667
+ if (url && isUrl(url)) {
668
+ const options = {
669
+ format: opts?.format ?? DEFAULT_FORMAT,
670
+ clip: opts?.clip,
671
+ quiet: opts?.quiet
672
+ };
673
+ return runCommand(downloadCommand(url, options), AppLive);
570
674
  }
571
- } catch (err) {
572
- exitWithError(err);
675
+ });
676
+ 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((path, opts) => runCommand(setFolderCommand(path, opts), SettingsLive));
678
+ return program;
679
+ };
680
+ var main = () => {
681
+ if (process.argv.length <= 2) {
682
+ showBanner();
683
+ configureCli().help();
684
+ return;
573
685
  }
574
- }
575
- 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) => {
576
- if (thisCommand.args.includes("help") || process.argv.includes("--help") || process.argv.includes("-h")) {
686
+ if (process.argv.includes("-v") || process.argv.includes("--version")) {
577
687
  showBanner();
578
688
  }
579
- });
580
- program.command("prepare").description("Download yt-dlp binary").action(handlePrepare);
581
- program.command("setDefaultFolder [path]").description("Set or view default download folder").option("-r, --reset", "Reset to current directory").action(handleSetFolder);
582
- 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);
583
- var firstArg = process.argv[2];
584
- if (firstArg && isUrl(firstArg)) {
585
- process.argv.splice(2, 0, "download");
586
- }
587
- if (process.argv.length <= 2) {
588
- showBanner();
589
- program.help();
590
- }
591
- if (process.argv.includes("-v") || process.argv.includes("--version")) {
592
- showBanner();
593
- }
594
- program.parse();
689
+ configureCli().parse();
690
+ };
691
+ main();