ytdwn 1.0.1 → 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
@@ -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,158 @@ 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.0";
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 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
80
161
  var BINARY_NAMES = {
81
162
  win32: ["yt-dlp.exe"],
82
163
  darwin: {
@@ -89,7 +170,7 @@ var BINARY_NAMES = {
89
170
  }
90
171
  };
91
172
  var FALLBACK_BINARY = "yt-dlp";
92
- function getCandidateNames() {
173
+ var getCandidateNames = () => {
93
174
  const platformNames = BINARY_NAMES[process.platform];
94
175
  if (!platformNames)
95
176
  return [FALLBACK_BINARY];
@@ -97,96 +178,106 @@ function getCandidateNames() {
97
178
  return platformNames;
98
179
  const archNames = platformNames[process.arch];
99
180
  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) {
181
+ };
182
+ var getCandidatePaths = () => getCandidateNames().map((name) => join3(BIN_DIR, name));
183
+ var pickAsset = (release) => {
124
184
  const assets = release.assets ?? [];
125
185
  const candidates = getCandidateNames();
126
186
  const match = assets.find((a) => candidates.includes(a.name)) ?? assets.find((a) => a.name === FALLBACK_BINARY);
127
187
  if (!match) {
128
- 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
+ }));
129
192
  }
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;
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
+ }
141
207
  }
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;
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;
161
216
  }
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}`);
217
+ const found = yield* searchForBinary;
218
+ if (found)
219
+ return found;
220
+ if (cached) {
221
+ yield* settings.clearCachedBinaryPath.pipe(Effect4.ignore);
167
222
  }
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);
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);
177
266
  return binaryPath;
178
- }
179
- await downloadFile(asset.browser_download_url, binaryPath);
180
- await setCachedBinaryPath(binaryPath);
181
- return binaryPath;
182
- }
267
+ });
268
+ return {
269
+ findBinary,
270
+ requireBinary,
271
+ getYtDlpWrap,
272
+ downloadLatestBinary
273
+ };
274
+ }));
183
275
 
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";
276
+ // src/services/DownloadService.ts
277
+ import { Effect as Effect6, Context as Context3, Layer as Layer3 } from "effect";
188
278
 
189
279
  // src/timestamp.ts
280
+ import { Effect as Effect5 } from "effect";
190
281
  var TIME_FORMAT_ERROR = "Invalid time format. Use MM:SS or HH:MM:SS (e.g. 0:02 or 01:23:45).";
191
282
  var RANGE_FORMAT_ERROR = "Range must be in 'start-end' format (e.g. 0:02-23:10).";
192
283
  var pad = (value) => value.padStart(2, "0");
@@ -238,7 +329,7 @@ var c = {
238
329
  }
239
330
  };
240
331
 
241
- // src/download.ts
332
+ // src/services/DownloadService.ts
242
333
  var SPINNER_FRAMES = [
243
334
  "⠋",
244
335
  "⠙",
@@ -254,20 +345,6 @@ var SPINNER_FRAMES = [
254
345
  var SPINNER_INTERVAL_MS = 80;
255
346
  var PROGRESS_BAR_WIDTH = 12;
256
347
  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
348
  var write = (text) => process.stdout.write(text);
272
349
  var clearLine = () => write(`\r${" ".repeat(LINE_CLEAR_WIDTH)}\r`);
273
350
  function createSpinner(initialMessage = "Getting ready...", quiet = false) {
@@ -275,10 +352,7 @@ function createSpinner(initialMessage = "Getting ready...", quiet = false) {
275
352
  let message = initialMessage;
276
353
  let stopped = false;
277
354
  if (quiet) {
278
- return {
279
- update: () => {},
280
- stop: () => {}
281
- };
355
+ return { update: () => {}, stop: () => {} };
282
356
  }
283
357
  const interval = setInterval(() => {
284
358
  if (!stopped) {
@@ -299,7 +373,7 @@ function createSpinner(initialMessage = "Getting ready...", quiet = false) {
299
373
  }
300
374
  };
301
375
  }
302
- function renderProgress({ percent, speed }, quiet = false) {
376
+ function renderProgress(percent, speed, quiet = false) {
303
377
  if (quiet)
304
378
  return;
305
379
  const filled = Math.round(percent / 100 * PROGRESS_BAR_WIDTH);
@@ -307,58 +381,45 @@ function renderProgress({ percent, speed }, quiet = false) {
307
381
  const speedText = speed ? ` ${c.speed(speed)}` : "";
308
382
  write(`\r${c.bold("Downloading:")} ${bar} ${c.bar.percent(`${percent.toFixed(0)}%`)}${speedText} `);
309
383
  }
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
- }
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 });
324
394
  }
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;
395
+ if (/\b(network|connection)\s+(error|failed|refused)/i.test(output)) {
396
+ return new ConnectionError({ message: "Connection error, try again" });
336
397
  }
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;
398
+ if (/video unavailable/i.test(output) || /private video/i.test(output)) {
399
+ return new VideoNotFoundError({ url });
345
400
  }
346
401
  return null;
347
402
  }
348
- function extractFileSize(text) {
349
- const match = text.match(REGEX.fileSize);
350
- 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
+ }
351
415
  }
416
+ var VIDEO_FORMATS = ["mp4", "mkv", "webm", "avi", "mov"];
417
+ var isVideoFormat = (format) => VIDEO_FORMATS.includes(format.toLowerCase());
352
418
  function buildArgs(url, options, downloadDir) {
419
+ const format = options.format.toLowerCase();
420
+ const isVideo = isVideoFormat(format);
353
421
  const baseArgs = [
354
422
  url,
355
- "-f",
356
- "bestaudio/best",
357
- "-x",
358
- "--audio-format",
359
- options.format,
360
- "--audio-quality",
361
- DEFAULT_AUDIO_QUALITY,
362
423
  "-o",
363
424
  getOutputTemplate(downloadDir),
364
425
  "--no-playlist",
@@ -366,68 +427,39 @@ function buildArgs(url, options, downloadDir) {
366
427
  "--progress",
367
428
  "--concurrent-fragments",
368
429
  CONCURRENT_FRAGMENTS,
369
- "--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,
370
445
  "--prefer-free-formats"
371
446
  ];
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];
447
+ const clipArgs = options.clip ? ["--download-sections", `*${parseClipRange(options.clip)}`] : [];
448
+ return [...baseArgs, ...formatArgs, ...clipArgs];
380
449
  }
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
- };
450
+
451
+ class DownloadService extends Context3.Tag("DownloadService")() {
419
452
  }
420
- function attachProcessHandlers(child, spinner, downloadDir, quiet) {
421
- 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);
422
456
  const state = {
423
457
  phase: "init",
424
- lastPercent: 0,
425
458
  fileName: null,
426
459
  fileSize: null,
427
- quiet,
460
+ outputBuffer: "",
428
461
  convertingInterval: null
429
462
  };
430
- const handleOutput = createOutputHandler(spinner, state);
431
463
  const cleanup = () => {
432
464
  spinner.stop();
433
465
  if (state.convertingInterval) {
@@ -436,40 +468,118 @@ function attachProcessHandlers(child, spinner, downloadDir, quiet) {
436
468
  if (!quiet)
437
469
  clearLine();
438
470
  };
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})`));
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);
451
479
  }
452
480
  });
453
- 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) => {
521
+ cleanup();
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", () => {
454
529
  cleanup();
455
- reject(err);
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
+ }));
456
545
  });
457
546
  });
458
547
  }
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
- }
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
+ }));
472
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";
473
583
  // src/banner.ts
474
584
  import cfonts from "cfonts";
475
585
  import gradient from "gradient-string";
@@ -495,100 +605,86 @@ function showBanner() {
495
605
  `));
496
606
  }
497
607
 
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";
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;
506
619
  }
507
- if (msg.includes("age")) {
508
- 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;
509
630
  }
510
- if (msg.includes("network") || msg.includes("connect")) {
511
- 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);
512
637
  }
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);
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
+ `);
531
645
  }
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);
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}`);
548
656
  }
549
- }
550
- async function handleDownload(url, opts) {
551
- try {
552
- 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)
553
664
  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}`);
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);
570
673
  }
571
- } catch (err) {
572
- 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;
573
684
  }
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")) {
685
+ if (process.argv.includes("-v") || process.argv.includes("--version")) {
577
686
  showBanner();
578
687
  }
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();
688
+ configureCli().parse();
689
+ };
690
+ main();