ytdwn 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,60 +6,158 @@ import { program } from "commander";
6
6
  // src/config.ts
7
7
  import { join } from "path";
8
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";
9
+ var APP_TAGLINE = "YouTube to MP3/MP4 • Fast & Simple";
10
+ var APP_VERSION = "1.1.0";
11
+ var DEFAULT_AUDIO_FORMAT = "mp3";
12
12
  var DEFAULT_AUDIO_QUALITY = "0";
13
13
  var CONCURRENT_FRAGMENTS = "8";
14
+ var DEFAULT_FORMAT = DEFAULT_AUDIO_FORMAT;
14
15
  var BIN_DIR = join(process.cwd(), "bin");
15
16
  var getOutputTemplate = (downloadDir) => join(downloadDir, "%(title)s.%(ext)s");
16
17
 
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";
18
+ // src/layers/AppLive.ts
19
+ import { Layer as Layer4 } from "effect";
23
20
 
24
- // src/settings.ts
25
- import { readFile, writeFile } from "fs/promises";
21
+ // src/services/SettingsService.ts
22
+ import { Effect as Effect2, Context, Layer } from "effect";
26
23
  import { homedir } from "os";
27
24
  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
- }
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") {
35
47
  }
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();
48
+
49
+ class DownloadError extends Data.TaggedError("DownloadError") {
40
50
  }
41
- async function setDownloadDir(dir) {
42
- const settings = await load();
43
- await save({ ...settings, downloadDir: resolve(dir) });
51
+
52
+ class BinaryNotFoundError extends Data.TaggedError("BinaryNotFoundError") {
44
53
  }
45
- async function resetDownloadDir() {
46
- const { downloadDir, ...rest } = await load();
47
- await save(rest);
54
+
55
+ class BinaryDownloadError extends Data.TaggedError("BinaryDownloadError") {
48
56
  }
49
- async function getCachedBinaryPath() {
50
- const { binaryPath } = await load();
51
- return binaryPath ?? null;
57
+
58
+ class BinaryExecutionError extends Data.TaggedError("BinaryExecutionError") {
52
59
  }
53
- async function setCachedBinaryPath(path) {
54
- const settings = await load();
55
- await save({ ...settings, binaryPath: path });
60
+
61
+ class TimestampParseError extends Data.TaggedError("TimestampParseError") {
56
62
  }
57
- async function clearCachedBinaryPath() {
58
- const { binaryPath, ...rest } = await load();
59
- await save(rest);
63
+
64
+ class VideoNotFoundError extends Data.TaggedError("VideoNotFoundError") {
60
65
  }
61
66
 
62
- // src/binary.ts
67
+ class InvalidUrlError extends Data.TaggedError("InvalidUrlError") {
68
+ }
69
+
70
+ class AgeRestrictedError extends Data.TaggedError("AgeRestrictedError") {
71
+ }
72
+
73
+ class ConnectionError extends Data.TaggedError("ConnectionError") {
74
+ }
75
+
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 YTDlpWrap 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
63
161
  var BINARY_NAMES = {
64
162
  win32: ["yt-dlp.exe"],
65
163
  darwin: {
@@ -72,7 +170,7 @@ var BINARY_NAMES = {
72
170
  }
73
171
  };
74
172
  var FALLBACK_BINARY = "yt-dlp";
75
- function getCandidateNames() {
173
+ var getCandidateNames = () => {
76
174
  const platformNames = BINARY_NAMES[process.platform];
77
175
  if (!platformNames)
78
176
  return [FALLBACK_BINARY];
@@ -80,81 +178,106 @@ function getCandidateNames() {
80
178
  return platformNames;
81
179
  const archNames = platformNames[process.arch];
82
180
  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) {
181
+ };
182
+ var getCandidatePaths = () => getCandidateNames().map((name) => join3(BIN_DIR, name));
183
+ var pickAsset = (release) => {
107
184
  const assets = release.assets ?? [];
108
185
  const candidates = getCandidateNames();
109
186
  const match = assets.find((a) => candidates.includes(a.name)) ?? assets.find((a) => a.name === FALLBACK_BINARY);
110
187
  if (!match) {
111
- throw new Error("Suitable yt-dlp binary not found for this platform.");
188
+ return Effect4.fail(new BinaryDownloadError({
189
+ platform: `${process.platform}-${process.arch}`,
190
+ cause: "No suitable binary found for this platform"
191
+ }));
112
192
  }
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;
193
+ return Effect4.succeed(match);
194
+ };
195
+
196
+ class BinaryService extends Context2.Tag("BinaryService")() {
197
+ }
198
+ var BinaryServiceLive = Layer2.effect(BinaryService, Effect4.gen(function* () {
199
+ const settings = yield* SettingsService;
200
+ const searchForBinary = Effect4.gen(function* () {
201
+ for (const candidate of getCandidatePaths()) {
202
+ const executable = yield* isExecutable(candidate);
203
+ if (executable) {
204
+ yield* settings.setCachedBinaryPath(candidate).pipe(Effect4.ignore);
205
+ return candidate;
206
+ }
124
207
  }
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);
208
+ return null;
209
+ });
210
+ const findBinary = Effect4.gen(function* () {
211
+ const cached = yield* settings.getCachedBinaryPath;
212
+ if (cached) {
213
+ const executable = yield* isExecutable(cached);
214
+ if (executable)
215
+ return cached;
216
+ }
217
+ const found = yield* searchForBinary;
218
+ if (found)
219
+ return found;
220
+ if (cached) {
221
+ yield* settings.clearCachedBinaryPath.pipe(Effect4.ignore);
222
+ }
223
+ return null;
224
+ });
225
+ const requireBinary = Effect4.gen(function* () {
226
+ const existing = yield* findBinary;
227
+ if (existing)
228
+ return existing;
229
+ return yield* Effect4.fail(new BinaryNotFoundError({
230
+ message: "yt-dlp binary not found. Run 'ytdwn prepare' first."
231
+ }));
232
+ });
233
+ const getYtDlpWrap = Effect4.gen(function* () {
234
+ const binaryPath = yield* requireBinary;
235
+ return new YTDlpWrap(binaryPath);
236
+ });
237
+ const downloadLatestBinary = Effect4.gen(function* () {
238
+ const releases = yield* Effect4.tryPromise({
239
+ try: () => YTDlpWrap.getGithubReleases(1, 1),
240
+ catch: (cause) => new BinaryDownloadError({
241
+ platform: `${process.platform}-${process.arch}`,
242
+ cause
243
+ })
244
+ });
245
+ const release = Array.isArray(releases) ? releases[0] : releases;
246
+ if (!release) {
247
+ return yield* Effect4.fail(new BinaryDownloadError({
248
+ platform: `${process.platform}-${process.arch}`,
249
+ cause: "No releases found"
250
+ }));
251
+ }
252
+ const asset = yield* pickAsset(release);
253
+ const binaryPath = join3(BIN_DIR, asset.name);
254
+ const exists = yield* isExecutable(binaryPath);
255
+ if (exists) {
256
+ yield* settings.setCachedBinaryPath(binaryPath).pipe(Effect4.ignore);
257
+ return binaryPath;
258
+ }
259
+ yield* ensureDirectory(BIN_DIR);
260
+ const data = yield* fetchBinaryWithRetry(asset.browser_download_url);
261
+ yield* writeFileBinary(binaryPath, data);
262
+ if (platform() !== "win32") {
263
+ yield* makeExecutable(binaryPath);
264
+ }
265
+ yield* settings.setCachedBinaryPath(binaryPath).pipe(Effect4.ignore);
145
266
  return binaryPath;
146
- }
147
- await downloadFile(asset.browser_download_url, binaryPath);
148
- await setCachedBinaryPath(binaryPath);
149
- return binaryPath;
150
- }
267
+ });
268
+ return {
269
+ findBinary,
270
+ requireBinary,
271
+ getYtDlpWrap,
272
+ downloadLatestBinary
273
+ };
274
+ }));
151
275
 
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";
276
+ // src/services/DownloadService.ts
277
+ import { Effect as Effect6, Context as Context3, Layer as Layer3 } from "effect";
156
278
 
157
279
  // src/timestamp.ts
280
+ import { Effect as Effect5 } from "effect";
158
281
  var TIME_FORMAT_ERROR = "Invalid time format. Use MM:SS or HH:MM:SS (e.g. 0:02 or 01:23:45).";
159
282
  var RANGE_FORMAT_ERROR = "Range must be in 'start-end' format (e.g. 0:02-23:10).";
160
283
  var pad = (value) => value.padStart(2, "0");
@@ -206,7 +329,7 @@ var c = {
206
329
  }
207
330
  };
208
331
 
209
- // src/download.ts
332
+ // src/services/DownloadService.ts
210
333
  var SPINNER_FRAMES = [
211
334
  "⠋",
212
335
  "⠙",
@@ -222,20 +345,6 @@ var SPINNER_FRAMES = [
222
345
  var SPINNER_INTERVAL_MS = 80;
223
346
  var PROGRESS_BAR_WIDTH = 12;
224
347
  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
348
  var write = (text) => process.stdout.write(text);
240
349
  var clearLine = () => write(`\r${" ".repeat(LINE_CLEAR_WIDTH)}\r`);
241
350
  function createSpinner(initialMessage = "Getting ready...", quiet = false) {
@@ -243,10 +352,7 @@ function createSpinner(initialMessage = "Getting ready...", quiet = false) {
243
352
  let message = initialMessage;
244
353
  let stopped = false;
245
354
  if (quiet) {
246
- return {
247
- update: () => {},
248
- stop: () => {}
249
- };
355
+ return { update: () => {}, stop: () => {} };
250
356
  }
251
357
  const interval = setInterval(() => {
252
358
  if (!stopped) {
@@ -267,7 +373,7 @@ function createSpinner(initialMessage = "Getting ready...", quiet = false) {
267
373
  }
268
374
  };
269
375
  }
270
- function renderProgress({ percent, speed }, quiet = false) {
376
+ function renderProgress(percent, speed, quiet = false) {
271
377
  if (quiet)
272
378
  return;
273
379
  const filled = Math.round(percent / 100 * PROGRESS_BAR_WIDTH);
@@ -275,58 +381,45 @@ function renderProgress({ percent, speed }, quiet = false) {
275
381
  const speedText = speed ? ` ${c.speed(speed)}` : "";
276
382
  write(`\r${c.bold("Downloading:")} ${bar} ${c.bar.percent(`${percent.toFixed(0)}%`)}${speedText} `);
277
383
  }
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
- }
384
+ function mapOutputToError(output, url) {
385
+ const ageRestrictedPatterns = [
386
+ /age[- ]?restrict/i,
387
+ /age[- ]?gate/i,
388
+ /sign in to confirm your age/i,
389
+ /this video is age-restricted/i,
390
+ /confirm your age/i
391
+ ];
392
+ if (ageRestrictedPatterns.some((p) => p.test(output))) {
393
+ return new AgeRestrictedError({ url });
292
394
  }
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;
395
+ if (/\b(network|connection)\s+(error|failed|refused)/i.test(output)) {
396
+ return new ConnectionError({ message: "Connection error, try again" });
304
397
  }
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;
398
+ if (/video unavailable/i.test(output) || /private video/i.test(output)) {
399
+ return new VideoNotFoundError({ url });
313
400
  }
314
401
  return null;
315
402
  }
316
- function extractFileSize(text) {
317
- const match = text.match(REGEX.fileSize);
318
- return match?.[1] || null;
403
+ function mapExitCodeToError(code, url) {
404
+ switch (code) {
405
+ case 1:
406
+ return new VideoNotFoundError({ url });
407
+ case 2:
408
+ return new InvalidUrlError({ url });
409
+ default:
410
+ return new BinaryExecutionError({
411
+ exitCode: code,
412
+ message: `Download failed with exit code ${code}`
413
+ });
414
+ }
319
415
  }
416
+ var VIDEO_FORMATS = ["mp4", "mkv", "webm", "avi", "mov"];
417
+ var isVideoFormat = (format) => VIDEO_FORMATS.includes(format.toLowerCase());
320
418
  function buildArgs(url, options, downloadDir) {
419
+ const format = options.format.toLowerCase();
420
+ const isVideo = isVideoFormat(format);
321
421
  const baseArgs = [
322
422
  url,
323
- "-f",
324
- "bestaudio/best",
325
- "-x",
326
- "--audio-format",
327
- options.format,
328
- "--audio-quality",
329
- DEFAULT_AUDIO_QUALITY,
330
423
  "-o",
331
424
  getOutputTemplate(downloadDir),
332
425
  "--no-playlist",
@@ -334,68 +427,39 @@ function buildArgs(url, options, downloadDir) {
334
427
  "--progress",
335
428
  "--concurrent-fragments",
336
429
  CONCURRENT_FRAGMENTS,
337
- "--no-check-certificates",
430
+ "--no-check-certificates"
431
+ ];
432
+ const formatArgs = isVideo ? [
433
+ "-f",
434
+ `bestvideo[ext=${format}]+bestaudio/best[ext=${format}]/best`,
435
+ "--merge-output-format",
436
+ format
437
+ ] : [
438
+ "-f",
439
+ "bestaudio/best",
440
+ "-x",
441
+ "--audio-format",
442
+ format,
443
+ "--audio-quality",
444
+ DEFAULT_AUDIO_QUALITY,
338
445
  "--prefer-free-formats"
339
446
  ];
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];
447
+ const clipArgs = options.clip ? ["--download-sections", `*${parseClipRange(options.clip)}`] : [];
448
+ return [...baseArgs, ...formatArgs, ...clipArgs];
348
449
  }
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
- };
450
+
451
+ class DownloadService extends Context3.Tag("DownloadService")() {
387
452
  }
388
- function attachProcessHandlers(child, spinner, downloadDir, quiet) {
389
- return new Promise((resolve2, reject) => {
453
+ function executeDownload(ytDlpWrap, args, downloadDir, url, quiet) {
454
+ return Effect6.async((resume) => {
455
+ const spinner = createSpinner("Getting ready...", quiet);
390
456
  const state = {
391
457
  phase: "init",
392
- lastPercent: 0,
393
458
  fileName: null,
394
459
  fileSize: null,
395
- quiet,
460
+ outputBuffer: "",
396
461
  convertingInterval: null
397
462
  };
398
- const handleOutput = createOutputHandler(spinner, state);
399
463
  const cleanup = () => {
400
464
  spinner.stop();
401
465
  if (state.convertingInterval) {
@@ -404,40 +468,118 @@ function attachProcessHandlers(child, spinner, downloadDir, quiet) {
404
468
  if (!quiet)
405
469
  clearLine();
406
470
  };
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})`));
471
+ const emitter = ytDlpWrap.exec(args);
472
+ emitter.on("progress", (progress) => {
473
+ if (state.phase === "init") {
474
+ state.phase = "downloading";
475
+ spinner.stop();
476
+ }
477
+ if (progress.percent !== undefined) {
478
+ renderProgress(progress.percent, progress.currentSpeed, quiet);
419
479
  }
420
480
  });
421
- child.on("error", (err) => {
481
+ emitter.on("ytDlpEvent", (eventType, eventData) => {
482
+ state.outputBuffer += `[${eventType}] ${eventData}
483
+ `;
484
+ if (eventType === "ExtractAudio" || eventType === "Merger") {
485
+ const match = eventData.match(/Destination:\s*(.+)/);
486
+ if (match?.[1]) {
487
+ state.fileName = match[1].split("/").pop() || null;
488
+ }
489
+ }
490
+ if (eventType === "download") {
491
+ const destMatch = eventData.match(/Destination:\s*(.+)/);
492
+ if (destMatch?.[1]) {
493
+ state.fileName = destMatch[1].split("/").pop() || null;
494
+ }
495
+ const sizeMatch = eventData.match(/~?([\d.]+\s*[KMG]i?B)/i);
496
+ if (sizeMatch?.[1]) {
497
+ state.fileSize = sizeMatch[1];
498
+ }
499
+ }
500
+ if ((eventType === "ExtractAudio" || eventType === "ffmpeg") && state.phase !== "converting") {
501
+ state.phase = "converting";
502
+ spinner.stop();
503
+ clearLine();
504
+ if (!quiet) {
505
+ let frame = 0;
506
+ state.convertingInterval = setInterval(() => {
507
+ const frameChar = SPINNER_FRAMES[frame++ % SPINNER_FRAMES.length] ?? "⠋";
508
+ write(`\r${c.info(frameChar)} ${c.dim("Converting...")}`);
509
+ }, SPINNER_INTERVAL_MS);
510
+ }
511
+ }
512
+ if (state.phase === "init") {
513
+ if (eventType === "youtube" && eventData.includes("Extracting")) {
514
+ spinner.update("Extracting...");
515
+ } else if (eventType === "youtube" && eventData.includes("Downloading webpage")) {
516
+ spinner.update("Fetching info...");
517
+ }
518
+ }
519
+ });
520
+ emitter.on("error", (error) => {
422
521
  cleanup();
423
- reject(err);
522
+ const outputError = mapOutputToError(String(error), url);
523
+ resume(Effect6.fail(outputError ?? new BinaryExecutionError({
524
+ exitCode: -1,
525
+ message: String(error)
526
+ })));
527
+ });
528
+ emitter.on("close", () => {
529
+ cleanup();
530
+ const outputError = mapOutputToError(state.outputBuffer, url);
531
+ if (outputError) {
532
+ resume(Effect6.fail(outputError));
533
+ return;
534
+ }
535
+ const exitCode = emitter.ytDlpProcess?.exitCode;
536
+ if (exitCode !== null && exitCode !== undefined && exitCode !== 0) {
537
+ resume(Effect6.fail(mapExitCodeToError(exitCode, url)));
538
+ return;
539
+ }
540
+ resume(Effect6.succeed({
541
+ filePath: downloadDir,
542
+ fileName: state.fileName || "audio",
543
+ fileSize: state.fileSize || undefined
544
+ }));
424
545
  });
425
546
  });
426
547
  }
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
- }
548
+ var DownloadServiceLive = Layer3.effect(DownloadService, Effect6.gen(function* () {
549
+ const binary = yield* BinaryService;
550
+ const settings = yield* SettingsService;
551
+ return {
552
+ download: (url, options) => Effect6.gen(function* () {
553
+ const [downloadDir, ytDlpWrap] = yield* Effect6.all([
554
+ settings.getDownloadDir.pipe(Effect6.flatMap((dir) => ensureDirectory(dir))),
555
+ binary.getYtDlpWrap
556
+ ]);
557
+ const quiet = options.quiet ?? false;
558
+ const args = buildArgs(url, options, downloadDir);
559
+ return yield* executeDownload(ytDlpWrap, args, downloadDir, url, quiet);
560
+ })
561
+ };
562
+ }));
440
563
 
564
+ // src/layers/AppLive.ts
565
+ var AppLive = DownloadServiceLive.pipe(Layer4.provide(BinaryServiceLive), Layer4.provide(SettingsServiceLive));
566
+ var SettingsLive = SettingsServiceLive;
567
+ var BinaryLive = BinaryServiceLive.pipe(Layer4.provide(SettingsServiceLive));
568
+ // src/cli/errors.ts
569
+ import { Effect as Effect7, Match, Console, pipe } from "effect";
570
+ 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);
571
+ var isAppError = (error) => error !== null && typeof error === "object" && ("_tag" in error) && typeof error._tag === "string";
572
+ var formatError = (error) => {
573
+ if (isAppError(error))
574
+ return formatAppError(error);
575
+ if (error instanceof Error)
576
+ return error.message;
577
+ return String(error);
578
+ };
579
+ 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))))));
580
+ var runCommand = (effect, layer) => Effect7.runPromise(pipe(effect, Effect7.provide(layer), withErrorHandler));
581
+ // src/cli/commands.ts
582
+ import { Effect as Effect8 } from "effect";
441
583
  // src/banner.ts
442
584
  import cfonts from "cfonts";
443
585
  import gradient from "gradient-string";
@@ -463,100 +605,86 @@ function showBanner() {
463
605
  `));
464
606
  }
465
607
 
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";
608
+ // src/cli/commands.ts
609
+ var log = (message) => Effect8.sync(() => console.log(message));
610
+ var logSuccess = (message) => log(`${c.sym.success} ${c.success(message)}`);
611
+ var logInfo = (message) => log(c.info(message));
612
+ var logDim = (message) => log(c.dim(message));
613
+ var prepareCommand = Effect8.gen(function* () {
614
+ const binary = yield* BinaryService;
615
+ const existing = yield* binary.findBinary;
616
+ if (existing) {
617
+ yield* logSuccess("Ready");
618
+ return;
474
619
  }
475
- if (msg.includes("age")) {
476
- return "Age-restricted video (login required)";
620
+ yield* logDim("Downloading yt-dlp...");
621
+ yield* binary.downloadLatestBinary;
622
+ yield* logSuccess("Ready");
623
+ });
624
+ var setFolderCommand = (path, options) => Effect8.gen(function* () {
625
+ const settings = yield* SettingsService;
626
+ if (options?.reset) {
627
+ yield* settings.resetDownloadDir;
628
+ yield* logSuccess("Reset to current directory");
629
+ return;
477
630
  }
478
- if (msg.includes("network") || msg.includes("connect")) {
479
- return "Connection error, try again";
631
+ if (path) {
632
+ yield* settings.setDownloadDir(path);
633
+ yield* log(`${c.sym.success} ${c.info(path)}`);
634
+ } else {
635
+ const dir = yield* settings.getDownloadDir;
636
+ yield* logInfo(dir);
480
637
  }
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);
638
+ });
639
+ var downloadCommand = (url, options) => Effect8.gen(function* () {
640
+ const quiet = options.quiet ?? false;
641
+ if (!quiet) {
642
+ yield* Effect8.sync(() => showBanner());
643
+ yield* log(`${c.dim("URL:")} ${c.info(url)}
644
+ `);
499
645
  }
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);
646
+ const download = yield* DownloadService;
647
+ const result = yield* download.download(url, options);
648
+ if (quiet) {
649
+ yield* log(result.fileName);
650
+ } else {
651
+ yield* log("");
652
+ yield* logSuccess("Process done!");
653
+ yield* log("");
654
+ const sizeInfo = result.fileSize ? ` ${c.size(`(${result.fileSize})`)}` : "";
655
+ yield* log(`${c.file(result.fileName)}${sizeInfo}`);
516
656
  }
517
- }
518
- async function handleDownload(url, opts) {
519
- try {
520
- if (!opts.quiet) {
657
+ });
658
+ // index.ts
659
+ var isUrl = (arg) => arg.startsWith("http://") || arg.startsWith("https://");
660
+ var configureCli = () => {
661
+ 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) => {
662
+ const isHelp = cmd.args.includes("help") || process.argv.includes("--help") || process.argv.includes("-h");
663
+ if (isHelp)
521
664
  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}`);
665
+ }).action((url, opts) => {
666
+ if (url && isUrl(url)) {
667
+ const options = {
668
+ format: opts?.format ?? DEFAULT_FORMAT,
669
+ clip: opts?.clip,
670
+ quiet: opts?.quiet
671
+ };
672
+ return runCommand(downloadCommand(url, options), AppLive);
538
673
  }
539
- } catch (err) {
540
- exitWithError(err);
674
+ });
675
+ program.command("prepare").description("Download yt-dlp binary").action(() => runCommand(prepareCommand, BinaryLive));
676
+ 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));
677
+ return program;
678
+ };
679
+ var main = () => {
680
+ if (process.argv.length <= 2) {
681
+ showBanner();
682
+ configureCli().help();
683
+ return;
541
684
  }
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")) {
685
+ if (process.argv.includes("-v") || process.argv.includes("--version")) {
545
686
  showBanner();
546
687
  }
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();
688
+ configureCli().parse();
689
+ };
690
+ main();