grab-url 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/grab-api.cjs.js +2 -0
  2. package/dist/grab-api.cjs.js.map +1 -0
  3. package/dist/grab-api.d.ts +321 -0
  4. package/dist/grab-api.es.js +495 -0
  5. package/dist/grab-api.es.js.map +1 -0
  6. package/dist/icons.cjs.js +2 -0
  7. package/dist/icons.cjs.js.map +1 -0
  8. package/dist/icons.d.ts +1 -0
  9. package/dist/icons.es.js +2 -0
  10. package/dist/icons.es.js.map +1 -0
  11. package/package.json +83 -0
  12. package/readme.md +156 -0
  13. package/src/grab-api.ts +783 -0
  14. package/src/grab-url.js +2084 -0
  15. package/src/icons/cli/spinners.json +1074 -0
  16. package/src/icons/index.ts +58 -0
  17. package/src/icons/svg/index.ts +313 -0
  18. package/src/icons/svg/loading-bouncy-ball.svg +3 -0
  19. package/src/icons/svg/loading-double-ring.svg +6 -0
  20. package/src/icons/svg/loading-eclipse.svg +3 -0
  21. package/src/icons/svg/loading-ellipsis.svg +16 -0
  22. package/src/icons/svg/loading-floating-search.svg +11 -0
  23. package/src/icons/svg/loading-gears.svg +3 -0
  24. package/src/icons/svg/loading-infinity.svg +3 -0
  25. package/src/icons/svg/loading-orbital.svg +8 -0
  26. package/src/icons/svg/loading-pacman.svg +22 -0
  27. package/src/icons/svg/loading-pulse-bars.svg +12 -0
  28. package/src/icons/svg/loading-red-blue-ball.svg +10 -0
  29. package/src/icons/svg/loading-reload-arrow.svg +5 -0
  30. package/src/icons/svg/loading-ring.svg +3 -0
  31. package/src/icons/svg/loading-ripple.svg +7 -0
  32. package/src/icons/svg/loading-spinner-oval.svg +49 -0
  33. package/src/icons/svg/loading-spinner.svg +57 -0
  34. package/src/icons/svg/loading-square-blocks.svg +17 -0
  35. package/src/log.ts +309 -0
@@ -0,0 +1,2084 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { pipeline } from 'stream/promises';
6
+ import { Readable } from 'stream';
7
+ import cliProgress from 'cli-progress';
8
+ import chalk from 'chalk';
9
+ import ora from 'ora';
10
+ import Table from 'cli-table3';
11
+ import grab, { log } from '../dist/grab-api.es.js';
12
+ import readline from 'readline';
13
+ import { GlobalKeyboardListener } from 'node-global-key-listener';
14
+ import { readFileSync } from 'fs';
15
+ import { fileURLToPath } from 'url';
16
+ import { dirname, join } from 'path';
17
+
18
+ // Try multiple possible paths for spinners.json
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = dirname(__filename);
21
+
22
+ let spinners;
23
+ try {
24
+ // Try the icons/cli path first (where it actually exists)
25
+ spinners = JSON.parse(readFileSync(join(__dirname, 'icons', 'cli', 'spinners.json'), 'utf8'));
26
+ } catch (error) {
27
+ try {
28
+ // Try the local path
29
+ spinners = JSON.parse(readFileSync(join(__dirname, 'spinners.json'), 'utf8'));
30
+ } catch (error2) {
31
+ try {
32
+ // Try the parent directory (src)
33
+ spinners = JSON.parse(readFileSync(join(__dirname, '..', 'spinners.json'), 'utf8'));
34
+ } catch (error3) {
35
+ try {
36
+ // Try the current working directory
37
+ spinners = JSON.parse(readFileSync(join(process.cwd(), 'src', 'spinners.json'), 'utf8'));
38
+ } catch (error4) {
39
+ // Fallback to default spinners if file not found
40
+ console.warn('Could not load spinners.json, using defaults');
41
+ spinners = {
42
+ dots: { frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] },
43
+ line: { frames: ['-', '\\', '|', '/'] },
44
+ arrow: { frames: ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'] }
45
+ };
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ // --- ArgParser from grab-cli.js ---
52
+ class ArgParser {
53
+ constructor() {
54
+ this.commands = {};
55
+ this.options = {};
56
+ this.examples = [];
57
+ this.helpText = '';
58
+ this.versionText = '1.0.0';
59
+ }
60
+ usage(text) { this.helpText = text; return this; }
61
+ command(pattern, desc, handler) {
62
+ const match = pattern.match(/\$0 <(\w+)>/);
63
+ if (match) this.commands[match[1]] = { desc, handler, required: true };
64
+ return this;
65
+ }
66
+ option(name, opts = {}) { this.options[name] = opts; return this; }
67
+ example(cmd, desc) { this.examples.push({ cmd, desc }); return this; }
68
+ help() { return this; }
69
+ alias(short, long) { if (this.options[long]) this.options[long].alias = short; return this; }
70
+ version(v) { if (v) this.versionText = v; return this; }
71
+ strict() { return this; }
72
+ parseSync() {
73
+ const args = process.argv.slice(2);
74
+ const result = {};
75
+ const positional = [];
76
+ if (args.includes('--help') || args.includes('-h')) { this.showHelp(); process.exit(0); }
77
+ if (args.includes('--version')) { console.log(this.versionText); process.exit(0); }
78
+ for (let i = 0; i < args.length; i++) {
79
+ const arg = args[i];
80
+ if (arg.startsWith('--')) {
81
+ const [key, value] = arg.split('=');
82
+ const optName = key.slice(2);
83
+ if (value !== undefined) {
84
+ result[optName] = this.coerceValue(optName, value);
85
+ } else if (this.options[optName]?.type === 'boolean') {
86
+ result[optName] = true;
87
+ } else {
88
+ const nextArg = args[i + 1];
89
+ if (nextArg && !nextArg.startsWith('-')) {
90
+ result[optName] = this.coerceValue(optName, nextArg);
91
+ i++;
92
+ } else {
93
+ result[optName] = true;
94
+ }
95
+ }
96
+ } else if (arg.startsWith('-') && arg.length === 2) {
97
+ const shortFlag = arg[1];
98
+ const longName = this.findLongName(shortFlag);
99
+ if (longName) {
100
+ if (this.options[longName]?.type === 'boolean') {
101
+ result[longName] = true;
102
+ } else {
103
+ const nextArg = args[i + 1];
104
+ if (nextArg && !nextArg.startsWith('-')) {
105
+ result[longName] = this.coerceValue(longName, nextArg);
106
+ i++;
107
+ }
108
+ }
109
+ }
110
+ } else {
111
+ positional.push(arg);
112
+ }
113
+ }
114
+ if (positional.length > 0) result.urls = positional;
115
+ Object.keys(this.options).forEach(key => {
116
+ if (result[key] === undefined && this.options[key].default !== undefined) {
117
+ result[key] = this.options[key].default;
118
+ }
119
+ });
120
+ if ((!result.urls || result.urls.length === 0) && this.commands.url?.required) {
121
+ console.error('Error: Missing required argument: url');
122
+ this.showHelp();
123
+ process.exit(1);
124
+ }
125
+ return result;
126
+ }
127
+ coerceValue(optName, value) {
128
+ const opt = this.options[optName];
129
+ if (!opt) return value;
130
+ if (opt.coerce) return opt.coerce(value);
131
+ switch (opt.type) {
132
+ case 'number': return Number(value);
133
+ case 'boolean': return value === 'true' || value === '1';
134
+ default: return value;
135
+ }
136
+ }
137
+ findLongName(shortFlag) {
138
+ return Object.keys(this.options).find(key => this.options[key].alias === shortFlag);
139
+ }
140
+ showHelp() {
141
+ console.log(this.helpText || 'Usage: grab <url> [options]');
142
+ console.log('\nPositional arguments:');
143
+ Object.keys(this.commands).forEach(cmd => {
144
+ console.log(` ${cmd.padEnd(20)} ${this.commands[cmd].desc}`);
145
+ });
146
+ console.log('\nOptions:');
147
+ Object.keys(this.options).forEach(key => {
148
+ const opt = this.options[key];
149
+ const flags = opt.alias ? `-${opt.alias}, --${key}` : `--${key}`;
150
+ console.log(` ${flags.padEnd(20)} ${opt.describe || ''}`);
151
+ });
152
+ if (this.examples.length > 0) {
153
+ console.log('\nExamples:');
154
+ this.examples.forEach(ex => {
155
+ console.log(` ${ex.cmd}`);
156
+ console.log(` ${ex.desc}`);
157
+ });
158
+ }
159
+ }
160
+ }
161
+
162
+ // --- Helper: Detect if a URL is a file download ---
163
+ function isFileUrl(url) {
164
+ // Heuristic: ends with a file extension (e.g., .zip, .mp4, .tar.gz, .pdf, etc)
165
+ return /\.[a-zA-Z0-9]{1,5}(?:\.[a-zA-Z0-9]{1,5})*$/.test(url.split('?')[0]);
166
+ }
167
+
168
+ export class ColorFileDownloader {
169
+ constructor() {
170
+ this.progressBar = null;
171
+ this.multiBar = null;
172
+ this.loadingSpinner = null;
173
+ this.abortController = null;
174
+
175
+ // Column width constants for alignment
176
+ this.COL_FILENAME = 25;
177
+ this.COL_SPINNER = 2;
178
+ this.COL_BAR = 15;
179
+ this.COL_PERCENT = 4;
180
+ this.COL_DOWNLOADED = 16;
181
+ this.COL_TOTAL = 10;
182
+ this.COL_SPEED = 10;
183
+ this.COL_ETA = 10;
184
+
185
+ this.colors = {
186
+ primary: chalk.cyan,
187
+ success: chalk.green,
188
+ warning: chalk.yellow,
189
+ error: chalk.red,
190
+ info: chalk.blue,
191
+ purple: chalk.magenta,
192
+ pink: chalk.magentaBright,
193
+ yellow: chalk.yellowBright,
194
+ cyan: chalk.cyanBright,
195
+ green: chalk.green,
196
+ gradient: [
197
+ chalk.blue,
198
+ chalk.magenta,
199
+ chalk.cyan,
200
+ chalk.green,
201
+ chalk.yellow,
202
+ chalk.red
203
+ ]
204
+ };
205
+
206
+ // ANSI color codes for progress bars
207
+ this.barColors = [
208
+ '\u001b[32m', // green
209
+ '\u001b[33m', // yellow
210
+ '\u001b[34m', // blue
211
+ '\u001b[35m', // magenta
212
+ '\u001b[36m', // cyan
213
+ '\u001b[91m', // bright red
214
+ '\u001b[92m', // bright green
215
+ '\u001b[93m', // bright yellow
216
+ '\u001b[94m', // bright blue
217
+ '\u001b[95m', // bright magenta
218
+ '\u001b[96m' // bright cyan
219
+ ];
220
+
221
+ this.barGlueColors = [
222
+ '\u001b[31m', // red
223
+ '\u001b[33m', // yellow
224
+ '\u001b[35m', // magenta
225
+ '\u001b[37m', // white
226
+ '\u001b[90m', // gray
227
+ '\u001b[93m', // bright yellow
228
+ '\u001b[97m' // bright white
229
+ ];
230
+
231
+ // Available spinner types for random selection (from spinners.json)
232
+ this.spinnerTypes = Object.keys(spinners.default || spinners);
233
+
234
+ // Initialize state directory
235
+ this.stateDir = this.getStateDirectory();
236
+ this.ensureStateDirectoryExists();
237
+ this.isPaused = false;
238
+ this.pauseCallback = null;
239
+ this.resumeCallback = null;
240
+ this.abortControllers = [];
241
+
242
+ // Initialize global keyboard listener
243
+ this.keyboardListener = null;
244
+ this.isAddingUrl = false;
245
+ }
246
+
247
+ /**
248
+ * Get state directory from environment variable or use default
249
+ * @returns {string} State directory path
250
+ */
251
+ getStateDirectory() {
252
+ return process.env.GRAB_DOWNLOAD_STATE_DIR || path.join(process.cwd(), '.grab-downloads');
253
+ }
254
+
255
+ /**
256
+ * Ensure state directory exists
257
+ */
258
+ ensureStateDirectoryExists() {
259
+ try {
260
+ if (!fs.existsSync(this.stateDir)) {
261
+ fs.mkdirSync(this.stateDir, { recursive: true });
262
+ }
263
+ } catch (error) {
264
+ console.log(this.colors.warning('⚠️ Could not create state directory, using current directory'));
265
+ this.stateDir = process.cwd();
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Get state file path for a given output path
271
+ * @param {string} outputPath - The output file path
272
+ * @returns {string} State file path
273
+ */
274
+ getStateFilePath(outputPath) {
275
+ const stateFileName = path.basename(outputPath) + '.download-state';
276
+ return path.join(this.stateDir, stateFileName);
277
+ }
278
+
279
+ /**
280
+ * Clean up state file
281
+ * @param {string} stateFilePath - Path to state file
282
+ */
283
+ cleanupStateFile(stateFilePath) {
284
+ try {
285
+ if (fs.existsSync(stateFilePath)) {
286
+ fs.unlinkSync(stateFilePath);
287
+ }
288
+ } catch (error) {
289
+ console.log(this.colors.warning('⚠️ Could not clean up state file'));
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Print aligned header row for progress bars
295
+ */
296
+ printHeaderRow() {
297
+ console.log(
298
+ this.colors.success('📈 %'.padEnd(this.COL_PERCENT)) +
299
+ this.colors.yellow('📁 Files'.padEnd(this.COL_FILENAME)) +
300
+ this.colors.cyan('🔄'.padEnd(this.COL_SPINNER)) +
301
+ ' ' +
302
+ this.colors.green('📊 Progress'.padEnd(this.COL_BAR + 1)) +
303
+ this.colors.info('📥 Downloaded'.padEnd(this.COL_DOWNLOADED)) +
304
+ this.colors.info('📦 Total'.padEnd(this.COL_TOTAL)) +
305
+ this.colors.purple('⚡ Speed'.padEnd(this.COL_SPEED)) +
306
+ this.colors.pink('⏱️ ETA'.padEnd(this.COL_ETA))
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Get random ora spinner type (for ora spinners)
312
+ * @returns {string} Random ora spinner name
313
+ */
314
+ getRandomOraSpinner() {
315
+ return this.spinnerTypes[Math.floor(Math.random() * this.spinnerTypes.length)];
316
+ }
317
+
318
+ /**
319
+ * Get random bar color
320
+ * @returns {string} ANSI color code
321
+ */
322
+ getRandomBarColor() {
323
+ return this.barColors[Math.floor(Math.random() * this.barColors.length)];
324
+ }
325
+
326
+ /**
327
+ * Get random bar glue color
328
+ * @returns {string} ANSI color code
329
+ */
330
+ getRandomBarGlueColor() {
331
+ return this.barGlueColors[Math.floor(Math.random() * this.barGlueColors.length)];
332
+ }
333
+
334
+ /**
335
+ * Get random spinner type
336
+ */
337
+ getRandomSpinner() {
338
+ return this.spinnerTypes[Math.floor(Math.random() * this.spinnerTypes.length)];
339
+ }
340
+
341
+ /**
342
+ * Get spinner frames for a given spinner type
343
+ * @param {string} spinnerType - The spinner type name
344
+ * @returns {array} Array of spinner frame characters
345
+ */
346
+ getSpinnerFrames(spinnerType) {
347
+ const spinnerData = spinners.default || spinners;
348
+ const spinner = spinnerData[spinnerType];
349
+
350
+ if (spinner && spinner.frames) {
351
+ return spinner.frames;
352
+ }
353
+
354
+ // Fallback to dots if spinner not found
355
+ return spinnerData.dots?.frames || ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
356
+ }
357
+
358
+ /**
359
+ * Get the visual width of a spinner frame (accounting for multi-char emojis)
360
+ * @param {string} frame - The spinner frame
361
+ * @returns {number} Visual width
362
+ */
363
+ getSpinnerWidth(frame) {
364
+ // Count visual width - emojis and some unicode chars take 2 spaces
365
+ let width = 0;
366
+ for (const char of frame) {
367
+ const code = char.codePointAt(0);
368
+ // Emoji range check and other wide characters
369
+ if ((code >= 0x1F000 && code <= 0x1F6FF) || // Miscellaneous Symbols and Pictographs
370
+ (code >= 0x1F300 && code <= 0x1F5FF) || // Miscellaneous Symbols
371
+ (code >= 0x1F600 && code <= 0x1F64F) || // Emoticons
372
+ (code >= 0x1F680 && code <= 0x1F6FF) || // Transport and Map
373
+ (code >= 0x1F700 && code <= 0x1F77F) || // Alchemical Symbols
374
+ (code >= 0x1F780 && code <= 0x1F7FF) || // Geometric Shapes Extended
375
+ (code >= 0x1F800 && code <= 0x1F8FF) || // Supplemental Arrows-C
376
+ (code >= 0x2600 && code <= 0x26FF) || // Miscellaneous Symbols
377
+ (code >= 0x2700 && code <= 0x27BF)) { // Dingbats
378
+ width += 2;
379
+ } else {
380
+ width += 1;
381
+ }
382
+ }
383
+ return width;
384
+ }
385
+
386
+ /**
387
+ * Calculate dynamic bar size based on spinner width and terminal width
388
+ * @param {string} spinnerFrame - Current spinner frame
389
+ * @param {number} baseBarSize - Base bar size
390
+ * @returns {number} Adjusted bar size
391
+ */
392
+ calculateBarSize(spinnerFrame, baseBarSize = 20) {
393
+ const terminalWidth = process.stdout.columns || 120;
394
+ const spinnerWidth = this.getSpinnerWidth(spinnerFrame);
395
+
396
+ // Account for other UI elements: percentage (4), progress (20), speed (10), ETA (15), spaces and colors (10)
397
+ const otherElementsWidth = 59;
398
+ const filenameWidth = 20; // Truncated filename width
399
+
400
+ const availableWidth = terminalWidth - otherElementsWidth - filenameWidth - spinnerWidth;
401
+
402
+ // Ensure minimum bar size
403
+ const adjustedBarSize = Math.max(10, Math.min(baseBarSize, availableWidth));
404
+
405
+ return adjustedBarSize;
406
+ }
407
+
408
+ /**
409
+ * Check if server supports resumable downloads
410
+ * @param {string} url - The URL to check
411
+ * @returns {Object} - Server support info and headers
412
+ */
413
+ async checkServerSupport(url) {
414
+ try {
415
+ const response = await fetch(url, {
416
+ method: 'HEAD',
417
+ signal: this.abortController?.signal
418
+ });
419
+
420
+ if (!response.ok) {
421
+ throw new Error(`HTTP error! status: ${response.status}`);
422
+ }
423
+
424
+ const acceptRanges = response.headers.get('accept-ranges');
425
+ const contentLength = response.headers.get('content-length');
426
+ const lastModified = response.headers.get('last-modified');
427
+ const etag = response.headers.get('etag');
428
+
429
+ return {
430
+ supportsResume: acceptRanges === 'bytes',
431
+ totalSize: contentLength ? parseInt(contentLength, 10) : 0,
432
+ lastModified,
433
+ etag,
434
+ headers: response.headers
435
+ };
436
+ } catch (error) {
437
+ console.log(this.colors.warning('⚠️ Could not check server resume support, proceeding with regular download'));
438
+ return {
439
+ supportsResume: false,
440
+ totalSize: 0,
441
+ lastModified: null,
442
+ etag: null,
443
+ headers: null
444
+ };
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Load download state from file
450
+ * @param {string} stateFilePath - Path to state file
451
+ * @returns {Object} - Download state
452
+ */
453
+ loadDownloadState(stateFilePath) {
454
+ try {
455
+ if (fs.existsSync(stateFilePath)) {
456
+ const stateData = fs.readFileSync(stateFilePath, 'utf8');
457
+ return JSON.parse(stateData);
458
+ }
459
+ } catch (error) {
460
+ console.log(this.colors.warning('⚠️ Could not load download state, starting fresh'));
461
+ }
462
+ return null;
463
+ }
464
+
465
+ /**
466
+ * Save download state to file
467
+ * @param {string} stateFilePath - Path to state file
468
+ * @param {Object} state - Download state
469
+ */
470
+ saveDownloadState(stateFilePath, state) {
471
+ try {
472
+ fs.writeFileSync(stateFilePath, JSON.stringify(state, null, 2));
473
+ } catch (error) {
474
+ console.log(this.colors.warning('⚠️ Could not save download state'));
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Get partial file size
480
+ * @param {string} filePath - Path to partial file
481
+ * @returns {number} - Size of partial file
482
+ */
483
+ getPartialFileSize(filePath) {
484
+ try {
485
+ if (fs.existsSync(filePath)) {
486
+ const stats = fs.statSync(filePath);
487
+ return stats.size;
488
+ }
489
+ } catch (error) {
490
+ console.log(this.colors.warning('⚠️ Could not read partial file size'));
491
+ }
492
+ return 0;
493
+ }
494
+
495
+ /**
496
+ * Get random gradient color
497
+ */
498
+ getRandomColor() {
499
+ return this.colors.gradient[Math.floor(Math.random() * this.colors.gradient.length)];
500
+ }
501
+
502
+ /**
503
+ * Format bytes into human readable format with proper MB/GB units using 1024 base
504
+ * @param {number} bytes - Number of bytes
505
+ * @param {number} decimals - Number of decimal places
506
+ * @returns {string} Formatted string
507
+ */
508
+ formatBytes(bytes, decimals = 2) {
509
+ if (bytes === 0) return this.colors.info('0 B');
510
+
511
+ const k = 1024; // Use 1024 for binary calculations
512
+ const dm = decimals < 0 ? 0 : decimals;
513
+ const sizes = [
514
+ { unit: 'B', color: this.colors.info },
515
+ { unit: 'KB', color: this.colors.cyan },
516
+ { unit: 'MB', color: this.colors.yellow },
517
+ { unit: 'GB', color: this.colors.purple },
518
+ { unit: 'TB', color: this.colors.pink },
519
+ { unit: 'PB', color: this.colors.primary }
520
+ ];
521
+
522
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
523
+ const value = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
524
+ const size = sizes[i] || sizes[sizes.length - 1];
525
+
526
+ return size.color.bold(`${value} ${size.unit}`);
527
+ }
528
+
529
+ /**
530
+ * Format bytes for progress display (without colors for progress bar)
531
+ * @param {number} bytes - Number of bytes
532
+ * @param {number} decimals - Number of decimal places
533
+ * @returns {string} Formatted string without colors
534
+ */
535
+ formatBytesPlain(bytes, decimals = 1) {
536
+ if (bytes === 0) return '0 B';
537
+
538
+ const k = 1024;
539
+ const dm = decimals < 0 ? 0 : decimals;
540
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
541
+
542
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
543
+ const value = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
544
+
545
+ return `${value} ${sizes[i] || sizes[sizes.length - 1]}`;
546
+ }
547
+
548
+ /**
549
+ * Format bytes for progress display (compact version for tight layouts)
550
+ * @param {number} bytes - Number of bytes
551
+ * @returns {string} Formatted string in compact format
552
+ */
553
+ formatBytesCompact(bytes) {
554
+ if (bytes === 0) return '0B';
555
+
556
+ const k = 1024;
557
+ const kb = bytes / k;
558
+
559
+ // If below 100KB, show in KB with whole numbers
560
+ if (kb < 100) {
561
+ const value = Math.round(kb);
562
+ return `${value}KB`;
563
+ }
564
+
565
+ // Otherwise show in MB with 1 decimal place (without "MB" text)
566
+ const mb = bytes / (k * k);
567
+ const value = mb.toFixed(1);
568
+ return `${value}`;
569
+ }
570
+
571
+ /**
572
+ * Truncate filename for display
573
+ * @param {string} filename - Original filename
574
+ * @param {number} maxLength - Maximum length
575
+ * @returns {string} Truncated filename
576
+ */
577
+ truncateFilename(filename, maxLength = 25) {
578
+ if (filename.length <= maxLength) return filename.padEnd(maxLength);
579
+
580
+ const extension = path.extname(filename);
581
+ const baseName = path.basename(filename, extension);
582
+
583
+ if (baseName.length <= 3) {
584
+ return filename.padEnd(maxLength);
585
+ }
586
+
587
+ // Show first few and last few characters with ellipsis in middle
588
+ const firstPart = Math.ceil((maxLength - extension.length - 3) / 2);
589
+ const lastPart = Math.floor((maxLength - extension.length - 3) / 2);
590
+
591
+ const truncatedBase = baseName.substring(0, firstPart) + '...' + baseName.substring(baseName.length - lastPart);
592
+ return `${truncatedBase}${extension}`.padEnd(maxLength);
593
+ }
594
+
595
+ /**
596
+ * Format ETA time in hours:minutes:seconds format
597
+ * @param {number} seconds - ETA in seconds
598
+ * @returns {string} Formatted ETA string (padded to consistent width)
599
+ */
600
+ formatETA(seconds) {
601
+ if (!seconds || seconds === Infinity || seconds < 0) return ' -- ';
602
+
603
+ const hours = Math.floor(seconds / 3600);
604
+ const mins = Math.floor((seconds % 3600) / 60);
605
+ const secs = Math.round(seconds % 60);
606
+
607
+ return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`.padEnd(this.COL_ETA);
608
+ }
609
+
610
+ /**
611
+ * Format progress for master bar showing sum of all downloads
612
+ * @param {number} totalDownloaded - Total downloaded bytes across all files
613
+ * @param {number} totalSize - Total size bytes across all files
614
+ * @returns {string} Formatted progress string showing sums in MB
615
+ */
616
+ formatMasterProgress(totalDownloaded, totalSize) {
617
+ const k = 1024;
618
+ const totalDownloadedMB = totalDownloaded / (k * k);
619
+ const totalSizeMB = totalSize / (k * k);
620
+
621
+ if (totalSizeMB >= 1024) {
622
+ const totalDownloadedGB = totalDownloadedMB / 1024;
623
+ const totalSizeGB = totalSizeMB / 1024;
624
+ return `${totalDownloadedGB.toFixed(1)}GB`.padEnd(this.COL_DOWNLOADED);
625
+ }
626
+
627
+ return `${totalDownloadedMB.toFixed(1)}MB`.padEnd(this.COL_DOWNLOADED);
628
+ }
629
+
630
+ /**
631
+ * Format progress display with consistent width
632
+ * @param {number} downloaded - Downloaded bytes
633
+ * @param {number} total - Total bytes
634
+ * @returns {string} Formatted progress string
635
+ */
636
+ formatProgress(downloaded, total) {
637
+ const downloadedStr = this.formatBytesCompact(downloaded);
638
+ return downloadedStr.padEnd(this.COL_DOWNLOADED);
639
+ }
640
+
641
+ /**
642
+ * Format downloaded bytes for display
643
+ * @param {number} downloaded - Downloaded bytes
644
+ * @returns {string} Formatted downloaded string
645
+ */
646
+ formatDownloaded(downloaded) {
647
+ return this.formatBytesCompact(downloaded).padEnd(this.COL_DOWNLOADED);
648
+ }
649
+
650
+ /**
651
+ * Format total bytes for display (separate column)
652
+ * @param {number} total - Total bytes
653
+ * @returns {string} Formatted total string
654
+ */
655
+ formatTotalDisplay(total) {
656
+ if (total === 0) return '0MB'.padEnd(this.COL_TOTAL);
657
+
658
+ const k = 1024;
659
+ const mb = total / (k * k);
660
+
661
+ if (mb >= 1024) {
662
+ const gb = mb / 1024;
663
+ return `${gb.toFixed(1)}GB`.padEnd(this.COL_TOTAL);
664
+ }
665
+
666
+ // For files smaller than 1MB, show in MB with decimal
667
+ if (mb < 1) {
668
+ return `${mb.toFixed(2)}MB`.padEnd(this.COL_TOTAL);
669
+ }
670
+
671
+ return `${mb.toFixed(1)}MB`.padEnd(this.COL_TOTAL);
672
+ }
673
+
674
+ /**
675
+ * Format total bytes for display (MB/GB format)
676
+ * @param {number} total - Total bytes
677
+ * @returns {string} Formatted total string
678
+ */
679
+ formatTotal(total) {
680
+ if (total === 0) return '0MB'.padEnd(this.COL_TOTAL);
681
+
682
+ const k = 1024;
683
+ const mb = total / (k * k);
684
+
685
+ if (mb >= 1024) {
686
+ const gb = mb / 1024;
687
+ return `${gb.toFixed(1)}GB`.padEnd(this.COL_TOTAL);
688
+ }
689
+
690
+ // For files smaller than 1MB, show in MB with decimal
691
+ if (mb < 1) {
692
+ return `${mb.toFixed(2)}MB`.padEnd(this.COL_TOTAL);
693
+ }
694
+
695
+ return `${mb.toFixed(1)}MB`.padEnd(this.COL_TOTAL);
696
+ }
697
+
698
+ /**
699
+ * Format speed display with consistent width
700
+ * @param {string} speed - Speed string
701
+ * @returns {string} Formatted speed string
702
+ */
703
+ formatSpeed(speed) {
704
+ return speed.padEnd(this.COL_SPEED);
705
+ }
706
+
707
+ /**
708
+ * Format speed for display (MB/s without "MB" text unless below 100KB/s)
709
+ * @param {number} bytesPerSecond - Speed in bytes per second
710
+ * @returns {string} Formatted speed string
711
+ */
712
+ formatSpeedDisplay(bytesPerSecond) {
713
+ if (bytesPerSecond === 0) return '0B';
714
+
715
+ const k = 1024;
716
+ const kbPerSecond = bytesPerSecond / k;
717
+
718
+ // If below 100KB/s, show in KB with whole numbers
719
+ if (kbPerSecond < 100) {
720
+ const formattedValue = Math.round(kbPerSecond);
721
+ return `${formattedValue}KB`;
722
+ }
723
+
724
+ // Otherwise show in MB with 1 decimal place (without "MB" text)
725
+ const mbPerSecond = bytesPerSecond / (k * k);
726
+ const formattedValue = mbPerSecond.toFixed(1);
727
+ return `${formattedValue}`;
728
+ }
729
+
730
+ /**
731
+ * Format speed for total display (MB/s without "MB" text unless below 100KB/s)
732
+ * @param {number} bytesPerSecond - Speed in bytes per second
733
+ * @returns {string} Formatted speed string
734
+ */
735
+ formatTotalSpeed(bytesPerSecond) {
736
+ return this.formatSpeedDisplay(bytesPerSecond).padEnd(this.COL_SPEED);
737
+ }
738
+
739
+ /**
740
+ * Download multiple files with multibar progress tracking
741
+ * @param {Array} downloads - Array of {url, outputPath, filename} objects
742
+ */
743
+ async downloadMultipleFiles(downloads) {
744
+ try {
745
+ console.log(this.colors.primary(`🚀 Starting download of ${downloads.length} files...\n`));
746
+
747
+ // Set up global keyboard listener for pause/resume and add URL BEFORE starting downloads
748
+ this.setupGlobalKeyboardListener();
749
+
750
+ // Print header row with emojis
751
+ this.printHeaderRow();
752
+
753
+ // Show keyboard shortcut info for pause/resume in multibar view
754
+ console.log(this.colors.info('💡 Press p to pause/resume downloads, a to add URL.'));
755
+
756
+ // Get random colors for the multibar
757
+ const masterBarColor = this.getRandomBarColor();
758
+ const masterBarGlue = this.getRandomBarGlueColor();
759
+
760
+ // Create multibar container with compact format and random colors
761
+ this.multiBar = new cliProgress.MultiBar({
762
+ format: this.colors.success('{percentage}%') + ' ' +
763
+ this.colors.yellow('{filename}') + ' ' +
764
+ this.colors.cyan('{spinner}') + ' ' +
765
+ masterBarColor + '{bar}\u001b[0m' + ' ' +
766
+ this.colors.info('{downloadedDisplay}') + ' ' +
767
+ this.colors.info('{totalDisplay}') + ' ' +
768
+ this.colors.purple('{speed}') + ' ' +
769
+ this.colors.pink('{etaFormatted}'),
770
+ hideCursor: true,
771
+ clearOnComplete: false,
772
+ stopOnComplete: true,
773
+ autopadding: false,
774
+ barCompleteChar: '█',
775
+ barIncompleteChar: '░',
776
+ barGlue: masterBarGlue,
777
+ barsize: this.COL_BAR
778
+ });
779
+
780
+ // Track overall progress for master bar
781
+ let totalDownloaded = 0;
782
+ let totalSize = 0;
783
+ let individualSpeeds = new Array(downloads.length).fill(0);
784
+ let individualSizes = new Array(downloads.length).fill(0);
785
+ let individualDownloaded = new Array(downloads.length).fill(0);
786
+ let individualStartTimes = new Array(downloads.length).fill(Date.now());
787
+ let lastSpeedUpdate = Date.now();
788
+ let lastIndividualDownloaded = new Array(downloads.length).fill(0);
789
+ let lastTotalUpdate = Date.now();
790
+ let lastTotalDownloaded = 0;
791
+
792
+ // Calculate total size from all downloads
793
+ const totalSizeFromDownloads = downloads.reduce((sum, download) => {
794
+ // Estimate size based on filename or use a default
795
+ const estimatedSize = download.estimatedSize || 1024 * 1024 * 100; // 100MB default
796
+ return sum + estimatedSize;
797
+ }, 0);
798
+ totalSize = totalSizeFromDownloads;
799
+
800
+ // Track actual total size as we discover file sizes
801
+ let actualTotalSize = 0;
802
+
803
+ // Set up interval to update speeds every second
804
+ const speedUpdateInterval = setInterval(() => {
805
+ const now = Date.now();
806
+ const timeSinceLastUpdate = (now - lastSpeedUpdate) / 1000; // seconds
807
+
808
+ // Update individual speeds based on incremental download since last update
809
+ for (let i = 0; i < downloads.length; i++) {
810
+ if (timeSinceLastUpdate > 0) {
811
+ const incrementalDownloaded = individualDownloaded[i] - lastIndividualDownloaded[i];
812
+ individualSpeeds[i] = incrementalDownloaded / timeSinceLastUpdate;
813
+
814
+ if (fileBars[i] && fileBars[i].bar) {
815
+ const speed = this.formatSpeed(this.formatSpeedDisplay(individualSpeeds[i]));
816
+ const eta = individualSizes[i] > 0 ?
817
+ this.formatETA((individualSizes[i] - individualDownloaded[i]) / individualSpeeds[i]) :
818
+ this.formatETA(0);
819
+
820
+ fileBars[i].bar.update(individualDownloaded[i], {
821
+ speed: speed,
822
+ progress: this.formatProgress(individualDownloaded[i], individualSizes[i]),
823
+ downloadedDisplay: this.formatBytesCompact(individualDownloaded[i]),
824
+ totalDisplay: this.formatTotalDisplay(individualSizes[i]),
825
+ etaFormatted: eta
826
+ });
827
+ }
828
+ }
829
+ }
830
+
831
+ // Update last values for next calculation
832
+ lastSpeedUpdate = now;
833
+ lastIndividualDownloaded = [...individualDownloaded];
834
+
835
+ // Calculate total speed
836
+ const totalSpeedBps = individualSpeeds.reduce((sum, speed) => sum + speed, 0);
837
+
838
+ // Calculate total downloaded from individual files
839
+ const totalDownloadedFromFiles = individualDownloaded.reduce((sum, downloaded) => sum + downloaded, 0);
840
+
841
+ // Calculate time elapsed since start
842
+ const timeElapsed = (now - individualStartTimes[0]) / 1000; // seconds since first download started
843
+
844
+ // Update master bar
845
+ const totalEta = totalSize > 0 && totalSpeedBps > 0 ?
846
+ this.formatETA((totalSize - totalDownloadedFromFiles) / totalSpeedBps) :
847
+ this.formatETA(0);
848
+
849
+ const totalPercentage = totalSize > 0 ?
850
+ Math.round((totalDownloadedFromFiles / totalSize) * 100) : 0;
851
+
852
+ // Calculate actual total size from discovered individual file sizes
853
+ const discoveredTotalSize = individualSizes.reduce((sum, size) => sum + size, 0);
854
+ const displayTotalSize = discoveredTotalSize > 0 ? discoveredTotalSize : totalSize;
855
+
856
+ masterBar.update(totalDownloadedFromFiles, {
857
+ speed: this.formatTotalSpeed(totalSpeedBps),
858
+ progress: this.formatMasterProgress(totalDownloadedFromFiles, displayTotalSize),
859
+ downloadedDisplay: this.formatBytesCompact(totalDownloadedFromFiles),
860
+ totalDisplay: this.formatTotalDisplay(displayTotalSize),
861
+ etaFormatted: this.formatETA(timeElapsed), // Show time elapsed instead of ETA
862
+ percentage: displayTotalSize > 0 ?
863
+ Math.round((totalDownloadedFromFiles / displayTotalSize) * 100) : 0
864
+ });
865
+ }, 1000);
866
+
867
+ // Create master progress bar with more compact format and special colors
868
+ const masterSpinnerWidth = this.getSpinnerWidth('⬇️');
869
+ const masterMaxFilenameLength = this.COL_FILENAME - masterSpinnerWidth;
870
+ const masterBarSize = this.calculateBarSize('⬇️', this.COL_BAR);
871
+ const masterBar = this.multiBar.create(totalSize, 0, {
872
+ filename: 'Total'.padEnd(masterMaxFilenameLength),
873
+ spinner: '⬇️',
874
+ speed: '0B'.padEnd(this.COL_SPEED),
875
+ progress: this.formatMasterProgress(0, totalSize),
876
+ downloadedDisplay: this.formatBytesCompact(0),
877
+ totalDisplay: this.formatTotalDisplay(totalSize),
878
+ etaFormatted: this.formatETA(0),
879
+ percentage: ' 0'.padStart(this.COL_PERCENT - 1)
880
+ }, {
881
+ format: this.colors.success('{percentage}%') + ' ' +
882
+ this.colors.yellow.bold('{filename}') + ' ' +
883
+ this.colors.success('{spinner}') + ' ' +
884
+ '\u001b[92m{bar}\u001b[0m' + ' ' +
885
+ this.colors.info('{downloadedDisplay}') + ' ' +
886
+ this.colors.info('{totalDisplay}') + ' ' +
887
+ this.colors.purple('{speed}') + ' ' +
888
+ this.colors.pink('{etaFormatted}'),
889
+ barCompleteChar: '▶',
890
+ barIncompleteChar: '▷',
891
+ barGlue: '\u001b[33m',
892
+ barsize: masterBarSize
893
+ });
894
+
895
+ // Create individual progress bars for each download
896
+ const fileBars = downloads.map((download, index) => {
897
+ const spinnerType = this.getRandomSpinner();
898
+ const spinnerFrames = this.getSpinnerFrames(spinnerType);
899
+
900
+ // Calculate spinner width to adjust filename padding
901
+ const spinnerWidth = this.getSpinnerWidth(spinnerFrames[0]);
902
+ const maxFilenameLength = this.COL_FILENAME - spinnerWidth; // Adjust filename length based on spinner width
903
+ const truncatedName = this.truncateFilename(download.filename, maxFilenameLength);
904
+
905
+ // Get random colors for this file's progress bar
906
+ const fileBarColor = this.getRandomBarColor();
907
+ const fileBarGlue = this.getRandomBarGlueColor();
908
+
909
+ // Calculate bar size based on spinner width
910
+ const barSize = this.calculateBarSize(spinnerFrames[0], this.COL_BAR);
911
+
912
+ return {
913
+ bar: this.multiBar.create(100, 0, {
914
+ filename: truncatedName,
915
+ spinner: spinnerFrames[0],
916
+ speed: this.formatSpeed('0B'),
917
+ progress: this.formatProgress(0, 0),
918
+ downloadedDisplay: this.formatBytesCompact(0),
919
+ totalDisplay: this.formatTotalDisplay(0),
920
+ etaFormatted: this.formatETA(0),
921
+ percentage: ' 0'.padStart(3)
922
+ }, {
923
+ format: this.colors.yellow('{filename}') + ' ' +
924
+ this.colors.cyan('{spinner}') + ' ' +
925
+ fileBarColor + '{bar}\u001b[0m' + ' ' +
926
+ this.colors.success('{percentage}%') + ' ' +
927
+ this.colors.info('{downloadedDisplay}') + ' ' +
928
+ this.colors.info('{totalDisplay}') + ' ' +
929
+ this.colors.purple('{speed}') + ' ' +
930
+ this.colors.pink('{etaFormatted}'),
931
+ barCompleteChar: '█',
932
+ barIncompleteChar: '░',
933
+ barGlue: fileBarGlue,
934
+ barsize: barSize
935
+ }),
936
+ spinnerFrames,
937
+ spinnerIndex: 0,
938
+ lastSpinnerUpdate: Date.now(),
939
+ lastFrameUpdate: Date.now(),
940
+ download: { ...download, index }
941
+ };
942
+ });
943
+
944
+ // Start all downloads concurrently
945
+ const downloadPromises = fileBars.map(async (fileBar, index) => {
946
+ try {
947
+ await this.downloadSingleFileWithBar(fileBar, masterBar, downloads.length, {
948
+ totalDownloaded,
949
+ totalSize,
950
+ individualSpeeds,
951
+ individualSizes,
952
+ individualDownloaded,
953
+ individualStartTimes,
954
+ lastTotalUpdate,
955
+ lastTotalDownloaded,
956
+ actualTotalSize
957
+ });
958
+ return { success: true, index, filename: fileBar.download.filename };
959
+ } catch (error) {
960
+ return { success: false, index, filename: fileBar.download.filename, error };
961
+ }
962
+ });
963
+
964
+ // Wait for all downloads to complete
965
+ const results = await Promise.allSettled(downloadPromises);
966
+
967
+ // Clear the speed update interval
968
+ clearInterval(speedUpdateInterval);
969
+
970
+ // Stop multibar
971
+ this.multiBar.stop();
972
+
973
+ // Display results
974
+ const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length;
975
+ const failed = results.length - successful;
976
+
977
+ console.log(this.colors.green(`✅ Successful: ${successful}/${downloads.length}`));
978
+ if (failed > 0) {
979
+ console.log(this.colors.error(`❌ Failed: ${failed}/${downloads.length}`));
980
+
981
+ results.forEach((result, index) => {
982
+ if (result.status === 'rejected' || !result.value.success) {
983
+ const filename = downloads[index].filename;
984
+ const error = result.reason || result.value?.error || 'Unknown error';
985
+ console.log(this.colors.error(` • ${filename}: ${error.message || error}`));
986
+ }
987
+ });
988
+ }
989
+
990
+ // Random celebration emoji
991
+ const celebrationEmojis = ['🥳', '🎊', '🎈', '🌟', '💯', '🚀', '✨', '🔥'];
992
+ const randomEmoji = celebrationEmojis[Math.floor(Math.random() * celebrationEmojis.length)];
993
+ console.log(this.colors.success(`${randomEmoji} Batch download completed! ${randomEmoji}`));
994
+
995
+ this.clearAbortControllers();
996
+
997
+ let pausedMessageShown = false;
998
+
999
+ this.setPauseCallback(() => {
1000
+ if (!pausedMessageShown) {
1001
+ this.multiBar.stop();
1002
+ console.log(this.colors.warning('⏸️ Paused. Press p to resume, a to add URL.'));
1003
+ pausedMessageShown = true;
1004
+ }
1005
+ });
1006
+
1007
+ this.setResumeCallback(() => {
1008
+ if (pausedMessageShown) {
1009
+ console.log(this.colors.success('▶️ Resumed. Press p to pause, a to add URL.'));
1010
+ pausedMessageShown = false;
1011
+ }
1012
+ });
1013
+
1014
+ } catch (error) {
1015
+ if (this.multiBar) {
1016
+ this.multiBar.stop();
1017
+ }
1018
+ console.error(this.colors.error.bold('💥 Batch download failed: ') + this.colors.warning(error.message));
1019
+ throw error;
1020
+ }
1021
+ }
1022
+
1023
+ /**
1024
+ * Download a single file with multibar integration and resume capability
1025
+ * @param {Object} fileBar - File bar object with progress bar and spinner info
1026
+ * @param {Object} masterBar - Master progress bar
1027
+ * @param {number} totalFiles - Total number of files being downloaded
1028
+ * @param {Object} totalTracking - Object to track total progress
1029
+ */
1030
+ async downloadSingleFileWithBar(fileBar, masterBar, totalFiles, totalTracking) {
1031
+ const { bar, spinnerFrames, download } = fileBar;
1032
+ const { url, outputPath, filename } = download;
1033
+ const stateFilePath = this.getStateFilePath(outputPath);
1034
+ const tempFilePath = outputPath + '.tmp';
1035
+
1036
+ try {
1037
+ // Create abort controller for this download
1038
+ const abortController = new AbortController();
1039
+ this.setAbortController(abortController);
1040
+
1041
+ // Check server support and get file info
1042
+ const serverInfo = await this.checkServerSupport(url);
1043
+
1044
+ // Load previous download state
1045
+ const previousState = this.loadDownloadState(stateFilePath);
1046
+
1047
+ // Check if we have a partial file
1048
+ const partialSize = this.getPartialFileSize(tempFilePath);
1049
+
1050
+ let startByte = 0;
1051
+ let resuming = false;
1052
+
1053
+ if (serverInfo.supportsResume && partialSize > 0 && previousState) {
1054
+ // Validate that the file hasn't changed on server
1055
+ const fileUnchanged =
1056
+ (!serverInfo.lastModified || serverInfo.lastModified === previousState.lastModified) &&
1057
+ (!serverInfo.etag || serverInfo.etag === previousState.etag) &&
1058
+ (serverInfo.totalSize === previousState.totalSize);
1059
+
1060
+ if (fileUnchanged && partialSize < serverInfo.totalSize) {
1061
+ startByte = partialSize;
1062
+ resuming = true;
1063
+ } else {
1064
+ // Clean up partial file and state
1065
+ if (fs.existsSync(tempFilePath)) {
1066
+ fs.unlinkSync(tempFilePath);
1067
+ }
1068
+ this.cleanupStateFile(stateFilePath);
1069
+ }
1070
+ } else if (partialSize > 0) {
1071
+ // Server doesn't support resume, clean up partial file
1072
+ if (fs.existsSync(tempFilePath)) {
1073
+ fs.unlinkSync(tempFilePath);
1074
+ }
1075
+ }
1076
+
1077
+ // Prepare request headers
1078
+ const headers = {};
1079
+ if (resuming && startByte > 0) {
1080
+ headers['Range'] = `bytes=${startByte}-`;
1081
+ }
1082
+
1083
+ // Make the fetch request
1084
+ const response = await fetch(url, {
1085
+ headers,
1086
+ signal: abortController.signal
1087
+ });
1088
+
1089
+ if (!response.ok) {
1090
+ throw new Error(`HTTP error! status: ${response.status}`);
1091
+ }
1092
+
1093
+ // Get the total file size
1094
+ const contentLength = response.headers.get('content-length');
1095
+ const totalSize = resuming ? serverInfo.totalSize : (contentLength ? parseInt(contentLength, 10) : 0);
1096
+
1097
+ // Save download state
1098
+ const downloadState = {
1099
+ url,
1100
+ outputPath,
1101
+ totalSize,
1102
+ startByte,
1103
+ lastModified: serverInfo.lastModified,
1104
+ etag: serverInfo.etag,
1105
+ timestamp: new Date().toISOString()
1106
+ };
1107
+ this.saveDownloadState(stateFilePath, downloadState);
1108
+
1109
+ // Update bar with file size info
1110
+ bar.setTotal(totalSize || 100);
1111
+ bar.update(startByte, {
1112
+ progress: this.formatProgress(startByte, totalSize),
1113
+ downloadedDisplay: this.formatBytesCompact(startByte),
1114
+ totalDisplay: this.formatTotalDisplay(totalSize)
1115
+ });
1116
+
1117
+ // Create write stream (append mode if resuming)
1118
+ const writeStream = fs.createWriteStream(tempFilePath, {
1119
+ flags: resuming ? 'a' : 'w'
1120
+ });
1121
+
1122
+ // Track progress
1123
+ let downloaded = startByte;
1124
+ let lastTime = Date.now();
1125
+ let lastDownloaded = downloaded;
1126
+
1127
+ // Create progress stream
1128
+ const progressStream = new Readable({
1129
+ read() {}
1130
+ });
1131
+
1132
+ const reader = response.body.getReader();
1133
+
1134
+ const processChunk = async () => {
1135
+ try {
1136
+ while (true) {
1137
+ // Check for pause state
1138
+ while (this.isPaused) {
1139
+ await new Promise(resolve => setTimeout(resolve, 100)); // Wait 100ms before checking again
1140
+ }
1141
+
1142
+ const { done, value } = await reader.read();
1143
+
1144
+ if (done) {
1145
+ progressStream.push(null);
1146
+ break;
1147
+ }
1148
+
1149
+ downloaded += value.length;
1150
+
1151
+ const now = Date.now();
1152
+ const timeDiff = (now - lastTime) / 1000;
1153
+
1154
+ // Update spinner frame every 150ms for smooth animation
1155
+ if (now - fileBar.lastFrameUpdate >= 150) {
1156
+ fileBar.spinnerIndex = (fileBar.spinnerIndex + 1) % spinnerFrames.length;
1157
+ fileBar.lastFrameUpdate = now;
1158
+
1159
+ // Recalculate bar size when spinner changes
1160
+ const currentSpinner = spinnerFrames[fileBar.spinnerIndex];
1161
+ const newBarSize = this.calculateBarSize(currentSpinner, this.COL_BAR);
1162
+ bar.options.barsize = newBarSize;
1163
+ }
1164
+
1165
+ // Change spinner type every 45 seconds
1166
+ if (now - fileBar.lastSpinnerUpdate >= 45000) {
1167
+ const newSpinnerType = this.getRandomSpinner();
1168
+ fileBar.spinnerFrames = this.getSpinnerFrames(newSpinnerType);
1169
+ fileBar.spinnerIndex = 0;
1170
+ fileBar.lastSpinnerUpdate = now;
1171
+ }
1172
+
1173
+ if (timeDiff >= 0.3) { // Update every 300ms for smoother animation
1174
+ bar.update(downloaded, {
1175
+ spinner: spinnerFrames[fileBar.spinnerIndex],
1176
+ progress: this.formatProgress(downloaded, totalSize),
1177
+ downloadedDisplay: this.formatBytesCompact(downloaded),
1178
+ totalDisplay: this.formatTotalDisplay(totalSize)
1179
+ });
1180
+
1181
+ // Update total tracking
1182
+ if (totalTracking) {
1183
+ const bytesDiff = downloaded - lastDownloaded;
1184
+ totalTracking.totalDownloaded += bytesDiff;
1185
+
1186
+ // Update individual downloaded amount and size for this file
1187
+ const fileIndex = fileBar.download.index || 0;
1188
+ totalTracking.individualDownloaded[fileIndex] = downloaded;
1189
+ totalTracking.individualSizes[fileIndex] = totalSize;
1190
+
1191
+ // Calculate total size from all individual sizes
1192
+ totalTracking.totalSize = totalTracking.individualSizes.reduce((sum, size) => sum + size, 0);
1193
+
1194
+ // Update actual total size for master bar display
1195
+ if (totalTracking.actualTotalSize !== undefined) {
1196
+ totalTracking.actualTotalSize = totalTracking.totalSize;
1197
+ }
1198
+
1199
+ // Update master bar total if this is the first time we're getting the actual size
1200
+ if (totalSize > 0 && totalTracking.individualSizes[fileIndex] === totalSize) {
1201
+ masterBar.setTotal(totalTracking.totalSize);
1202
+ }
1203
+ }
1204
+
1205
+ lastTime = now;
1206
+ lastDownloaded = downloaded;
1207
+ } else {
1208
+ bar.update(downloaded, {
1209
+ spinner: spinnerFrames[fileBar.spinnerIndex],
1210
+ progress: this.formatProgress(downloaded, totalSize),
1211
+ downloadedDisplay: this.formatBytesCompact(downloaded),
1212
+ totalDisplay: this.formatTotalDisplay(totalSize)
1213
+ });
1214
+ }
1215
+
1216
+ progressStream.push(Buffer.from(value));
1217
+ }
1218
+ } catch (error) {
1219
+ progressStream.destroy(error);
1220
+ }
1221
+ };
1222
+
1223
+ processChunk();
1224
+ await pipeline(progressStream, writeStream);
1225
+
1226
+ // Move temp file to final location
1227
+ if (fs.existsSync(outputPath)) {
1228
+ fs.unlinkSync(outputPath);
1229
+ }
1230
+ fs.renameSync(tempFilePath, outputPath);
1231
+
1232
+ // Clean up state file
1233
+ this.cleanupStateFile(stateFilePath);
1234
+
1235
+ // Update master progress
1236
+ const currentCompleted = masterBar.value + 1;
1237
+ const finalTotalSize = totalTracking.actualTotalSize || totalTracking.totalSize;
1238
+ const discoveredTotalSize = totalTracking.individualSizes.reduce((sum, size) => sum + size, 0);
1239
+ const displayTotalSize = discoveredTotalSize > 0 ? discoveredTotalSize : finalTotalSize;
1240
+
1241
+ masterBar.update(totalTracking.totalDownloaded, {
1242
+ progress: this.formatMasterProgress(totalTracking.totalDownloaded, displayTotalSize),
1243
+ downloadedDisplay: this.formatBytesCompact(totalTracking.totalDownloaded),
1244
+ totalDisplay: this.formatTotalDisplay(displayTotalSize),
1245
+ etaFormatted: this.formatETA((Date.now() - (totalTracking.individualStartTimes?.[0] || Date.now())) / 1000) // Show time elapsed
1246
+ });
1247
+
1248
+ } catch (error) {
1249
+ // Update bar to show error state
1250
+ bar.update(bar.total, {
1251
+ spinner: '❌',
1252
+ speed: this.formatSpeed('FAILED'),
1253
+ downloadedDisplay: this.formatBytesCompact(0),
1254
+ totalDisplay: this.formatTotalDisplay(0)
1255
+ });
1256
+
1257
+ // Don't clean up partial file on error - allow resume
1258
+ console.log(this.colors.info(`💾 Partial download saved for ${filename}. Restart to resume.`));
1259
+ throw error;
1260
+ }
1261
+ }
1262
+
1263
+ /**
1264
+ * Download a file with colorful progress tracking and resume capability
1265
+ * @param {string} url - The URL to download
1266
+ * @param {string} outputPath - The local path to save the file
1267
+ */
1268
+ async downloadFile(url, outputPath) {
1269
+ const stateFilePath = this.getStateFilePath(outputPath);
1270
+ const tempFilePath = outputPath + '.tmp';
1271
+
1272
+ try {
1273
+ // Create abort controller for cancellation
1274
+ this.abortController = new AbortController();
1275
+
1276
+ // Start with a random ora spinner animation
1277
+ const randomOraSpinner = this.getRandomOraSpinner();
1278
+ this.loadingSpinner = ora({
1279
+ text: this.colors.primary('🌐 Checking server capabilities...'),
1280
+ spinner: randomOraSpinner,
1281
+ color: 'cyan'
1282
+ }).start();
1283
+
1284
+ // Check server support and get file info
1285
+ const serverInfo = await this.checkServerSupport(url);
1286
+
1287
+ // Load previous download state
1288
+ const previousState = this.loadDownloadState(stateFilePath);
1289
+
1290
+ // Check if we have a partial file
1291
+ const partialSize = this.getPartialFileSize(tempFilePath);
1292
+
1293
+ let startByte = 0;
1294
+ let resuming = false;
1295
+
1296
+ if (serverInfo.supportsResume && partialSize > 0 && previousState) {
1297
+ // Validate that the file hasn't changed on server
1298
+ const fileUnchanged =
1299
+ (!serverInfo.lastModified || serverInfo.lastModified === previousState.lastModified) &&
1300
+ (!serverInfo.etag || serverInfo.etag === previousState.etag) &&
1301
+ (serverInfo.totalSize === previousState.totalSize);
1302
+
1303
+ if (fileUnchanged && partialSize < serverInfo.totalSize) {
1304
+ startByte = partialSize;
1305
+ resuming = true;
1306
+ this.loadingSpinner.succeed(this.colors.success(`✅ Found partial download: ${this.formatBytes(partialSize)} of ${this.formatTotal(serverInfo.totalSize)}`));
1307
+ console.log(this.colors.info(`🔄 Resuming download from ${this.formatBytes(startByte)}`));
1308
+ } else {
1309
+ this.loadingSpinner.warn(this.colors.warning('⚠️ File changed on server, starting fresh download'));
1310
+ // Clean up partial file and state
1311
+ if (fs.existsSync(tempFilePath)) {
1312
+ fs.unlinkSync(tempFilePath);
1313
+ }
1314
+ this.cleanupStateFile(stateFilePath);
1315
+ }
1316
+ } else {
1317
+ this.loadingSpinner.stop();
1318
+ if (partialSize > 0) {
1319
+ console.log(this.colors.warning('⚠️ Server does not support resumable downloads, starting fresh'));
1320
+ // Clean up partial file
1321
+ if (fs.existsSync(tempFilePath)) {
1322
+ fs.unlinkSync(tempFilePath);
1323
+ }
1324
+ }
1325
+ }
1326
+
1327
+ // Prepare request headers
1328
+ const headers = {};
1329
+ if (resuming && startByte > 0) {
1330
+ headers['Range'] = `bytes=${startByte}-`;
1331
+ }
1332
+
1333
+ // Make the fetch request
1334
+ const response = await fetch(url, {
1335
+ headers,
1336
+ signal: this.abortController.signal
1337
+ });
1338
+
1339
+ if (!response.ok) {
1340
+ throw new Error(`HTTP error! status: ${response.status}`);
1341
+ }
1342
+
1343
+ // Get the total file size
1344
+ const contentLength = response.headers.get('content-length');
1345
+ const totalSize = resuming ? serverInfo.totalSize : (contentLength ? parseInt(contentLength, 10) : 0);
1346
+ const remainingSize = contentLength ? parseInt(contentLength, 10) : 0;
1347
+
1348
+ if (!resuming) {
1349
+ if (totalSize === 0) {
1350
+ console.log(this.colors.warning('⚠️ Warning: Content-Length not provided, progress will be estimated'));
1351
+ } else {
1352
+ console.log(this.colors.info(`📦 File size: ${this.formatTotal(totalSize)}`));
1353
+ }
1354
+ }
1355
+
1356
+ // Save download state
1357
+ const downloadState = {
1358
+ url,
1359
+ outputPath,
1360
+ totalSize,
1361
+ startByte,
1362
+ lastModified: serverInfo.lastModified,
1363
+ etag: serverInfo.etag,
1364
+ timestamp: new Date().toISOString()
1365
+ };
1366
+ this.saveDownloadState(stateFilePath, downloadState);
1367
+
1368
+ // Get random colors for single file progress bar
1369
+ const singleBarColor = this.getRandomBarColor();
1370
+ const singleBarGlue = this.getRandomBarGlueColor();
1371
+
1372
+ // Get initial spinner frames
1373
+ let currentSpinnerType = this.getRandomSpinner();
1374
+ let spinnerFrames = this.getSpinnerFrames(currentSpinnerType);
1375
+ let spinnerFrameIndex = 0;
1376
+
1377
+ // Calculate initial bar size
1378
+ const initialBarSize = this.calculateBarSize(spinnerFrames[0], this.COL_BAR);
1379
+
1380
+ // Print header row with emojis for single file download
1381
+ console.log(
1382
+ this.colors.success('📈 %'.padEnd(this.COL_PERCENT)) +
1383
+ this.colors.cyan('🔄'.padEnd(this.COL_SPINNER)) +
1384
+ ' ' +
1385
+ this.colors.green('📊 Progress'.padEnd(this.COL_BAR + 1)) +
1386
+ this.colors.info('📥 Downloaded'.padEnd(this.COL_DOWNLOADED)) +
1387
+ this.colors.info('📦 Total'.padEnd(this.COL_TOTAL)) +
1388
+ this.colors.purple('⚡ Speed'.padEnd(this.COL_SPEED)) +
1389
+ this.colors.pink('⏱️ ETA'.padEnd(this.COL_ETA))
1390
+ );
1391
+
1392
+ // Set up keyboard listeners for single file download
1393
+ const keyboardRl = this.setupSingleFileKeyboardListeners(url, outputPath);
1394
+
1395
+ // Create compact colorful progress bar with random colors
1396
+ this.progressBar = new cliProgress.SingleBar({
1397
+ format: this.colors.success('{percentage}%') + ' ' +
1398
+ this.colors.cyan('{spinner}') + ' ' +
1399
+ singleBarColor + '{bar}\u001b[0m' + ' ' +
1400
+ this.colors.info('{downloadedDisplay}') + ' ' +
1401
+ this.colors.info('{totalDisplay}') + ' ' +
1402
+ this.colors.purple('{speed}') + ' ' +
1403
+ this.colors.pink('{etaFormatted}'),
1404
+ barCompleteChar: '█',
1405
+ barIncompleteChar: '░',
1406
+ barGlue: singleBarGlue,
1407
+ hideCursor: true,
1408
+ barsize: initialBarSize,
1409
+ stopOnComplete: true,
1410
+ clearOnComplete: false
1411
+ });
1412
+
1413
+ // Initialize progress bar with spinner
1414
+ this.progressBar.start(totalSize || 100, startByte, {
1415
+ speed: this.formatSpeed('0B/s'),
1416
+ etaFormatted: this.formatETA(0),
1417
+ spinner: spinnerFrames[0],
1418
+ progress: this.formatProgress(startByte, totalSize),
1419
+ downloadedDisplay: this.formatBytesCompact(startByte),
1420
+ totalDisplay: this.formatTotalDisplay(totalSize)
1421
+ });
1422
+
1423
+ // Create write stream (append mode if resuming)
1424
+ const writeStream = fs.createWriteStream(tempFilePath, {
1425
+ flags: resuming ? 'a' : 'w'
1426
+ });
1427
+
1428
+ // Track progress
1429
+ let downloaded = startByte;
1430
+ let sessionDownloaded = 0;
1431
+ let lastTime = Date.now();
1432
+ let lastDownloaded = downloaded;
1433
+ let lastSpinnerUpdate = Date.now();
1434
+ let lastSpinnerFrameUpdate = Date.now();
1435
+
1436
+ // Create a transform stream to track progress
1437
+ const progressStream = new Readable({
1438
+ read() {} // No-op, we'll push data manually
1439
+ });
1440
+
1441
+ // Process the response body stream
1442
+ const reader = response.body.getReader();
1443
+
1444
+ const processChunk = async () => {
1445
+ try {
1446
+ while (true) {
1447
+ // Check for pause state
1448
+ while (this.isPaused) {
1449
+ await new Promise(resolve => setTimeout(resolve, 100)); // Wait 100ms before checking again
1450
+ }
1451
+
1452
+ const { done, value } = await reader.read();
1453
+
1454
+ if (done) {
1455
+ progressStream.push(null); // Signal end of stream
1456
+ break;
1457
+ }
1458
+
1459
+ // Update progress tracking
1460
+ sessionDownloaded += value.length;
1461
+ downloaded += value.length;
1462
+
1463
+ // Calculate download speed and update display
1464
+ const now = Date.now();
1465
+ const timeDiff = (now - lastTime) / 1000;
1466
+
1467
+ // Update spinner type every 45 seconds for variety
1468
+ if (now - lastSpinnerUpdate >= 45000) {
1469
+ currentSpinnerType = this.getRandomSpinner();
1470
+ spinnerFrames = this.getSpinnerFrames(currentSpinnerType);
1471
+ spinnerFrameIndex = 0;
1472
+ lastSpinnerUpdate = now;
1473
+ }
1474
+
1475
+ // Update spinner frame every 120ms for smooth animation
1476
+ if (now - lastSpinnerFrameUpdate >= 120) {
1477
+ spinnerFrameIndex = (spinnerFrameIndex + 1) % spinnerFrames.length;
1478
+ lastSpinnerFrameUpdate = now;
1479
+
1480
+ // Recalculate bar size when spinner changes
1481
+ const currentSpinner = spinnerFrames[spinnerFrameIndex];
1482
+ const newBarSize = this.calculateBarSize(currentSpinner, this.COL_BAR);
1483
+ this.progressBar.options.barsize = newBarSize;
1484
+ }
1485
+
1486
+ if (timeDiff >= 0.3) { // Update every 300ms for smooth animation
1487
+ const bytesDiff = downloaded - lastDownloaded;
1488
+ const speedBps = bytesDiff / timeDiff;
1489
+ const speed = this.formatSpeed(this.formatSpeedDisplay(speedBps));
1490
+ const eta = totalSize > 0 ? this.formatETA((totalSize - downloaded) / speedBps) : this.formatETA(0);
1491
+
1492
+ this.progressBar.update(downloaded, {
1493
+ speed: speed,
1494
+ etaFormatted: eta,
1495
+ spinner: spinnerFrames[spinnerFrameIndex],
1496
+ progress: this.formatProgress(downloaded, totalSize),
1497
+ downloadedDisplay: this.formatBytesCompact(downloaded),
1498
+ totalDisplay: this.formatTotalDisplay(totalSize)
1499
+ });
1500
+
1501
+ lastTime = now;
1502
+ lastDownloaded = downloaded;
1503
+ } else {
1504
+ // Update progress and spinner without speed calculation
1505
+ this.progressBar.update(downloaded, {
1506
+ spinner: spinnerFrames[spinnerFrameIndex],
1507
+ progress: this.formatProgress(downloaded, totalSize),
1508
+ downloadedDisplay: this.formatBytesCompact(downloaded),
1509
+ totalDisplay: this.formatTotalDisplay(totalSize)
1510
+ });
1511
+ }
1512
+
1513
+ // Push the chunk to our readable stream
1514
+ progressStream.push(Buffer.from(value));
1515
+ }
1516
+ } catch (error) {
1517
+ progressStream.destroy(error);
1518
+ }
1519
+ };
1520
+
1521
+ // Start processing chunks
1522
+ processChunk();
1523
+
1524
+ // Use pipeline to handle the stream properly
1525
+ await pipeline(progressStream, writeStream);
1526
+
1527
+ // Complete the progress bar
1528
+ this.progressBar.stop();
1529
+
1530
+ // Move temp file to final location
1531
+ if (fs.existsSync(outputPath)) {
1532
+ fs.unlinkSync(outputPath);
1533
+ }
1534
+ fs.renameSync(tempFilePath, outputPath);
1535
+
1536
+ // Clean up state file
1537
+ this.cleanupStateFile(stateFilePath);
1538
+
1539
+ // Success celebration
1540
+ console.log(this.colors.success('✅ Download completed!'));
1541
+ console.log(this.colors.primary('📁 File saved to: ') + chalk.underline(outputPath));
1542
+ console.log(this.colors.purple('📊 Total size: ') + this.formatBytes(downloaded));
1543
+
1544
+ if (resuming) {
1545
+ console.log(this.colors.info('🔄 Resumed from: ') + this.formatBytes(startByte));
1546
+ console.log(this.colors.info('📥 Downloaded this session: ') + this.formatBytes(sessionDownloaded));
1547
+ }
1548
+
1549
+ // Random success emoji
1550
+ const celebrationEmojis = ['🥳', '🎊', '🎈', '🌟', '💯', '🚀', '✨', '🔥'];
1551
+ const randomEmoji = celebrationEmojis[Math.floor(Math.random() * celebrationEmojis.length)];
1552
+ console.log(this.colors.success(`${randomEmoji} Successfully downloaded! ${randomEmoji}`));
1553
+
1554
+ } catch (error) {
1555
+ if (this.loadingSpinner && this.loadingSpinner.isSpinning) {
1556
+ this.loadingSpinner.fail(this.colors.error('❌ Connection failed'));
1557
+ }
1558
+ if (this.progressBar) {
1559
+ this.progressBar.stop();
1560
+ }
1561
+
1562
+ // Don't clean up partial file on error - allow resume
1563
+ console.error(this.colors.error.bold('💥 Download failed: ') + this.colors.warning(error.message));
1564
+
1565
+ if (error.name === 'AbortError') {
1566
+ console.log(this.colors.info('💾 Download state saved. You can resume later by running the same command.'));
1567
+ } else {
1568
+ console.log(this.colors.info('💾 Partial download saved. Restart to resume from where it left off.'));
1569
+ }
1570
+
1571
+ throw error;
1572
+ }
1573
+ }
1574
+
1575
+ /**
1576
+ * Clean up resources
1577
+ */
1578
+ cleanup() {
1579
+ if (this.loadingSpinner && this.loadingSpinner.isSpinning) {
1580
+ this.loadingSpinner.stop();
1581
+ }
1582
+ if (this.progressBar) {
1583
+ this.progressBar.stop();
1584
+ }
1585
+ if (this.multiBar) {
1586
+ this.multiBar.stop();
1587
+ }
1588
+ if (this.abortController) {
1589
+ this.abortController.abort();
1590
+ }
1591
+ if (this.keyboardListener) {
1592
+ try {
1593
+ this.keyboardListener.kill();
1594
+ } catch (error) {
1595
+ // Ignore cleanup errors
1596
+ }
1597
+ }
1598
+
1599
+ // Clean up stdin listener
1600
+ if (process.stdin.isTTY) {
1601
+ process.stdin.setRawMode(false);
1602
+ process.stdin.pause();
1603
+ }
1604
+ }
1605
+
1606
+ /**
1607
+ * Set up global keyboard listener for pause/resume and add URL functionality
1608
+ */
1609
+ setupGlobalKeyboardListener() {
1610
+ // Use the fallback keyboard listener which works better in terminal environments
1611
+ this.setupFallbackKeyboardListener();
1612
+ }
1613
+
1614
+ /**
1615
+ * Handle global key press events
1616
+ * @param {string} keyName - The name of the pressed key
1617
+ */
1618
+ async handleGlobalKeyPress(keyName) {
1619
+ if (keyName === 'P') {
1620
+ console.log(this.colors.info('P key pressed - toggling pause/resume'));
1621
+ if (!this.isPaused) {
1622
+ this.pauseAll();
1623
+ } else {
1624
+ this.resumeAll();
1625
+ }
1626
+ } else if (keyName === 'A' && !this.isAddingUrl) {
1627
+ console.log(this.colors.info('A key pressed - adding URL'));
1628
+ await this.promptForNewUrl();
1629
+ }
1630
+ }
1631
+
1632
+ /**
1633
+ * Prompt user for a new URL to download
1634
+ */
1635
+ async promptForNewUrl() {
1636
+ this.isAddingUrl = true;
1637
+
1638
+ try {
1639
+ console.log(this.colors.cyan('\n📥 Enter URL to add (or press Enter to cancel):'));
1640
+
1641
+ const rl = readline.createInterface({
1642
+ input: process.stdin,
1643
+ output: process.stdout
1644
+ });
1645
+
1646
+ const newUrl = await new Promise((resolve) => {
1647
+ rl.question('', (answer) => {
1648
+ rl.close();
1649
+ resolve(answer.trim());
1650
+ });
1651
+ });
1652
+
1653
+ if (newUrl && this.isValidUrl(newUrl)) {
1654
+ console.log(this.colors.success(`✅ Adding URL: ${newUrl}`));
1655
+
1656
+ // Generate filename for new URL
1657
+ const newFilename = this.generateFilename(newUrl);
1658
+ const newOutputPath = path.isAbsolute(newFilename) ? newFilename : path.join(process.cwd(), newFilename);
1659
+
1660
+ // Ensure output directory exists
1661
+ const outputDir = path.dirname(newOutputPath);
1662
+ try {
1663
+ if (!fs.existsSync(outputDir)) {
1664
+ fs.mkdirSync(outputDir, { recursive: true });
1665
+ }
1666
+ } catch (error) {
1667
+ console.error(this.colors.red.bold('❌ Could not create output directory: ') + error.message);
1668
+ return;
1669
+ }
1670
+
1671
+ // Check if we're in multiple download mode (multiBar exists)
1672
+ if (this.multiBar) {
1673
+ // Add to multiple downloads
1674
+ await this.addToMultipleDownloads(newUrl, newOutputPath, newFilename);
1675
+ } else {
1676
+ // Start new single download in background
1677
+ this.downloadFile(newUrl, newOutputPath).catch(error => {
1678
+ console.error(this.colors.error(`❌ Failed to download ${newFilename}: ${error.message}`));
1679
+ });
1680
+ }
1681
+
1682
+ console.log(this.colors.success('🚀 New download started!'));
1683
+ } else if (newUrl) {
1684
+ console.log(this.colors.red('❌ Invalid URL provided.'));
1685
+ } else {
1686
+ console.log(this.colors.yellow('⚠️ No URL provided, cancelling.'));
1687
+ }
1688
+ } catch (error) {
1689
+ console.error(this.colors.red('❌ Error adding URL: ') + error.message);
1690
+ } finally {
1691
+ this.isAddingUrl = false;
1692
+
1693
+ if (this.isPaused) {
1694
+ console.log(this.colors.warning('⏸️ Still paused. Press p to resume, a to add URL.'));
1695
+ } else {
1696
+ console.log(this.colors.success('▶️ Downloads active. Press p to pause, a to add URL.'));
1697
+ }
1698
+ }
1699
+ }
1700
+
1701
+ /**
1702
+ * Add a new download to the multiple downloads queue
1703
+ * @param {string} url - The URL to download
1704
+ * @param {string} outputPath - The output path
1705
+ * @param {string} filename - The filename
1706
+ */
1707
+ async addToMultipleDownloads(url, outputPath, filename) {
1708
+ // Create new progress bar for the added download
1709
+ const spinnerType = this.getRandomSpinner();
1710
+ const spinnerFrames = this.getSpinnerFrames(spinnerType);
1711
+ const spinnerWidth = this.getSpinnerWidth(spinnerFrames[0]);
1712
+ const maxFilenameLength = this.COL_FILENAME - spinnerWidth;
1713
+ const truncatedName = this.truncateFilename(filename, maxFilenameLength);
1714
+ const fileBarColor = this.getRandomBarColor();
1715
+ const fileBarGlue = this.getRandomBarGlueColor();
1716
+ const barSize = this.calculateBarSize(spinnerFrames[0], this.COL_BAR);
1717
+
1718
+ const newDownload = {
1719
+ url: url,
1720
+ outputPath: outputPath,
1721
+ filename: filename
1722
+ };
1723
+
1724
+ const newFileBar = {
1725
+ bar: this.multiBar.create(100, 0, {
1726
+ filename: truncatedName,
1727
+ spinner: spinnerFrames[0],
1728
+ speed: this.formatSpeed('0B'),
1729
+ progress: this.formatProgress(0, 0),
1730
+ downloadedDisplay: this.formatBytesCompact(0),
1731
+ totalDisplay: this.formatTotalDisplay(0),
1732
+ etaFormatted: this.formatETA(0),
1733
+ percentage: ' 0'.padStart(3)
1734
+ }, {
1735
+ format: this.colors.yellow('{filename}') + ' ' +
1736
+ this.colors.cyan('{spinner}') + ' ' +
1737
+ fileBarColor + '{bar}\u001b[0m' + ' ' +
1738
+ this.colors.success('{percentage}%') + ' ' +
1739
+ this.colors.info('{downloadedDisplay}') + ' ' +
1740
+ this.colors.info('{totalDisplay}') + ' ' +
1741
+ this.colors.purple('{speed}') + ' ' +
1742
+ this.colors.pink('{etaFormatted}'),
1743
+ barCompleteChar: '█',
1744
+ barIncompleteChar: '░',
1745
+ barGlue: fileBarGlue,
1746
+ barsize: barSize
1747
+ }),
1748
+ spinnerFrames,
1749
+ spinnerIndex: 0,
1750
+ lastSpinnerUpdate: Date.now(),
1751
+ lastFrameUpdate: Date.now(),
1752
+ download: { ...newDownload, index: this.getCurrentDownloadCount() }
1753
+ };
1754
+
1755
+ // Start the new download
1756
+ this.downloadSingleFileWithBar(newFileBar, this.getMasterBar(), this.getCurrentDownloadCount() + 1, {
1757
+ totalDownloaded: 0,
1758
+ totalSize: 0,
1759
+ individualSpeeds: [],
1760
+ individualSizes: [],
1761
+ individualDownloaded: [],
1762
+ individualStartTimes: [],
1763
+ lastTotalUpdate: Date.now(),
1764
+ lastTotalDownloaded: 0,
1765
+ actualTotalSize: 0
1766
+ }).catch(error => {
1767
+ console.error(this.colors.error(`❌ Failed to download ${newDownload.filename}: ${error.message}`));
1768
+ });
1769
+ }
1770
+
1771
+ /**
1772
+ * Get current download count (for multiple downloads)
1773
+ * @returns {number} Current number of downloads
1774
+ */
1775
+ getCurrentDownloadCount() {
1776
+ // This is a placeholder - in a real implementation, you'd track this
1777
+ return 1;
1778
+ }
1779
+
1780
+ /**
1781
+ * Get master bar (for multiple downloads)
1782
+ * @returns {Object} Master progress bar
1783
+ */
1784
+ getMasterBar() {
1785
+ // This is a placeholder - in a real implementation, you'd return the actual master bar
1786
+ return null;
1787
+ }
1788
+
1789
+ /**
1790
+ * Set up fallback keyboard listener using readline
1791
+ */
1792
+ setupFallbackKeyboardListener() {
1793
+ if (process.stdin.isTTY) {
1794
+ process.stdin.setRawMode(true);
1795
+ process.stdin.resume();
1796
+ process.stdin.setEncoding('utf8');
1797
+
1798
+ const handleKeypress = async (str) => {
1799
+ // Handle Ctrl+C to exit
1800
+ if (str === '\u0003') {
1801
+ console.log(this.colors.yellow.bold('\n🛑 Download cancelled by user'));
1802
+ process.exit(0);
1803
+ }
1804
+
1805
+ // Handle 'p' key for pause/resume
1806
+ if (str.toLowerCase() === 'p') {
1807
+ console.log(this.colors.info('P key pressed - toggling pause/resume'));
1808
+ if (!this.isPaused) {
1809
+ this.pauseAll();
1810
+ } else {
1811
+ this.resumeAll();
1812
+ }
1813
+ }
1814
+
1815
+ // Handle 'a' key for adding URL
1816
+ if (str.toLowerCase() === 'a' && !this.isAddingUrl) {
1817
+ console.log(this.colors.info('A key pressed - adding URL'));
1818
+ await this.promptForNewUrl();
1819
+ }
1820
+ };
1821
+
1822
+ process.stdin.on('data', handleKeypress);
1823
+ console.log(this.colors.info('💡 Keyboard listener active: Press p to pause/resume, a to add URL'));
1824
+ }
1825
+ }
1826
+
1827
+ /**
1828
+ * Set up keyboard listeners for single file download (legacy method)
1829
+ * @param {string} url - The URL being downloaded
1830
+ * @param {string} outputPath - The output path
1831
+ */
1832
+ setupSingleFileKeyboardListeners(url, outputPath) {
1833
+ // Use the global keyboard listener instead
1834
+ this.setupGlobalKeyboardListener();
1835
+ return null; // Return null since we're using global listener
1836
+ }
1837
+
1838
+ /**
1839
+ * Validate URL format
1840
+ * @param {string} url - URL to validate
1841
+ * @returns {boolean} - Whether URL is valid
1842
+ */
1843
+ isValidUrl(url) {
1844
+ try {
1845
+ new URL(url);
1846
+ return true;
1847
+ } catch {
1848
+ return false;
1849
+ }
1850
+ }
1851
+
1852
+ /**
1853
+ * Get file extension from URL
1854
+ * @param {string} url - URL to extract extension from
1855
+ * @returns {string} - File extension or empty string
1856
+ */
1857
+ getFileExtension(url) {
1858
+ try {
1859
+ const pathname = new URL(url).pathname;
1860
+ return path.extname(pathname).toLowerCase();
1861
+ } catch {
1862
+ return '';
1863
+ }
1864
+ }
1865
+
1866
+ /**
1867
+ * Generate filename from URL
1868
+ * @param {string} url - Download URL
1869
+ * @returns {string} - Generated filename
1870
+ */
1871
+ generateFilename(url) {
1872
+ try {
1873
+ const filename = path.basename(new URL(url).pathname);
1874
+ return filename || 'downloaded-file';
1875
+ } catch (error) {
1876
+ return 'downloaded-file';
1877
+ }
1878
+ }
1879
+
1880
+ setPauseCallback(cb) {
1881
+ this.pauseCallback = cb;
1882
+ }
1883
+
1884
+ setResumeCallback(cb) {
1885
+ this.resumeCallback = cb;
1886
+ }
1887
+
1888
+ setAbortController(controller) {
1889
+ this.abortControllers.push(controller);
1890
+ }
1891
+
1892
+ clearAbortControllers() {
1893
+ this.abortControllers = [];
1894
+ }
1895
+
1896
+ pauseAll() {
1897
+ this.isPaused = true;
1898
+ console.log(this.colors.warning('⏸️ Pausing all downloads...'));
1899
+ // Don't abort controllers on pause, just set the flag
1900
+ if (this.pauseCallback) this.pauseCallback();
1901
+ }
1902
+
1903
+ resumeAll() {
1904
+ this.isPaused = false;
1905
+ console.log(this.colors.success('▶️ Resuming all downloads...'));
1906
+ if (this.resumeCallback) this.resumeCallback();
1907
+ }
1908
+ }
1909
+
1910
+ // --- Main CLI logic ---
1911
+ // Only run CLI when this file is executed directly, not when imported
1912
+ if (import.meta.url === `file://${process.argv[1]}`) {
1913
+ const argv = new ArgParser()
1914
+ .usage('Usage: grab <url> [options]')
1915
+ .command('$0 <url>', 'Fetch data from API endpoint or download files')
1916
+ .option('no-save', {
1917
+ type: 'boolean',
1918
+ default: false,
1919
+ describe: 'Don\'t save output to file, just print to console'
1920
+ })
1921
+ .option('output', {
1922
+ alias: 'o',
1923
+ type: 'string',
1924
+ describe: 'Output filename (default: output.json)',
1925
+ default: null
1926
+ })
1927
+ .option('params', {
1928
+ alias: 'p',
1929
+ type: 'string',
1930
+ describe: 'JSON string of query parameters (e.g., \'{"key":"value"}\')',
1931
+ coerce: (arg) => {
1932
+ if (!arg) return {};
1933
+ try { return JSON.parse(arg); } catch (e) { throw new Error(`Invalid JSON in params: ${arg}`); }
1934
+ }
1935
+ })
1936
+ .help()
1937
+ .alias('h', 'help')
1938
+ .version('1.0.0')
1939
+ .strict()
1940
+ .parseSync();
1941
+
1942
+ const urls = argv.urls || [];
1943
+ const params = argv.params || {};
1944
+ const outputFile = argv.output;
1945
+ const noSave = argv['no-save'];
1946
+
1947
+ // --- Mode detection ---
1948
+ const anyFileUrl = urls.some(isFileUrl);
1949
+ const isDownloadMode = urls.length > 1 || anyFileUrl;
1950
+
1951
+ (async () => {
1952
+ if (isDownloadMode) {
1953
+ // --- Download Mode ---
1954
+ const downloader = new ColorFileDownloader();
1955
+ // Prepare download objects
1956
+ const downloads = urls.map((url, i) => {
1957
+ let filename = null;
1958
+ // If user provided output, use it for the first file
1959
+ if (i === 0 && outputFile) filename = outputFile;
1960
+ return { url, outputPath: filename };
1961
+ });
1962
+ // Show detected downloads in a table
1963
+ const detectedTable = new Table({
1964
+ head: ['#', 'URL'],
1965
+ colWidths: [4, 80],
1966
+ colAligns: ['right', 'left'],
1967
+ style: { 'padding-left': 1, 'padding-right': 1, head: [], border: [] }
1968
+ });
1969
+ downloads.forEach((download, index) => {
1970
+ detectedTable.push([
1971
+ (index + 1).toString(),
1972
+ download.url
1973
+ ]);
1974
+ });
1975
+ console.log(chalk.cyan.bold(`Detected ${downloads.length} download(s):`));
1976
+ console.log(detectedTable.toString());
1977
+ console.log('');
1978
+ // Prepare download objects with filenames
1979
+ const downloadObjects = downloads.map((download, index) => {
1980
+ let actualUrl = download.url;
1981
+ let filename = download.outputPath;
1982
+ if (!filename) filename = downloader.generateFilename(actualUrl);
1983
+ const outputPath = path.isAbsolute(filename) ? filename : path.join(process.cwd(), filename);
1984
+ const outputDir = path.dirname(outputPath);
1985
+ try { if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }); } catch (error) {
1986
+ console.error(chalk.red.bold('❌ Could not create output directory: ') + error.message);
1987
+ process.exit(1);
1988
+ }
1989
+ return {
1990
+ url: actualUrl,
1991
+ outputPath,
1992
+ filename: path.basename(filename)
1993
+ };
1994
+ });
1995
+ // Show download queue in a table
1996
+ const queueTable = new Table({
1997
+ head: ['#', 'Filename', 'Output Path'],
1998
+ colWidths: [4, 32, 54],
1999
+ colAligns: ['right', 'left', 'left'],
2000
+ style: { 'padding-left': 1, 'padding-right': 1, head: [], border: [] }
2001
+ });
2002
+ downloadObjects.forEach((downloadObj, index) => {
2003
+ queueTable.push([
2004
+ (index + 1).toString(),
2005
+ downloadObj.filename,
2006
+ downloadObj.outputPath
2007
+ ]);
2008
+ });
2009
+ console.log(chalk.cyan.bold('\nDownload Queue:'));
2010
+ console.log(queueTable.toString());
2011
+ console.log('');
2012
+ try {
2013
+ await downloader.downloadMultipleFiles(downloadObjects);
2014
+ // Display individual file stats in a table
2015
+ const statsTable = new Table({
2016
+ head: ['Filename', 'Size', 'Created'],
2017
+ colWidths: [32, 14, 25],
2018
+ colAligns: ['left', 'right', 'left'],
2019
+ style: { 'padding-left': 1, 'padding-right': 1, head: [], border: [] }
2020
+ });
2021
+ downloadObjects.forEach((downloadObj) => {
2022
+ try {
2023
+ const stats = fs.statSync(downloadObj.outputPath);
2024
+ statsTable.push([
2025
+ downloadObj.filename,
2026
+ downloader.formatBytes(stats.size),
2027
+ stats.birthtime.toLocaleString()
2028
+ ]);
2029
+ } catch (error) {
2030
+ statsTable.push([
2031
+ downloadObj.filename,
2032
+ 'Error',
2033
+ 'Could not read'
2034
+ ]);
2035
+ }
2036
+ });
2037
+ console.log(chalk.cyan.bold('\nFile Details:'));
2038
+ console.log(statsTable.toString());
2039
+ } catch (error) {
2040
+ console.error(chalk.red.bold('Failed to download files: ') + chalk.yellow(error.message));
2041
+ process.exit(1);
2042
+ }
2043
+ downloader.cleanup();
2044
+ } else {
2045
+ // --- API Mode ---
2046
+ const url = urls[0];
2047
+ const startTime = process.hrtime();
2048
+ try {
2049
+ const res = await grab(url, params);
2050
+ if (res.error) log(`\n\nStatus: ❌ ${res.error}`);
2051
+ let filePath = null;
2052
+ let outputData;
2053
+ let isTextData = false;
2054
+ if (typeof res.data === 'string') { outputData = res.data; isTextData = true; }
2055
+ else if (Buffer.isBuffer(res.data) || res.data instanceof Uint8Array) { outputData = res.data; isTextData = false; }
2056
+ else if (res.data instanceof Blob) { const arrayBuffer = await res.data.arrayBuffer(); outputData = Buffer.from(arrayBuffer); isTextData = false; }
2057
+ else if (res.data && typeof res.data === 'object') { outputData = JSON.stringify(res.data, null, 2); isTextData = true; }
2058
+ else { outputData = String(res.data); isTextData = true; }
2059
+ if (!noSave) {
2060
+ const urlPath = new URL(url).pathname;
2061
+ const urlExt = path.extname(urlPath);
2062
+ const defaultExt = isTextData ? '.json' : (urlExt || '.bin');
2063
+ filePath = outputFile ? path.resolve(outputFile) : path.resolve(process.cwd(), `output${defaultExt}`);
2064
+ if (isTextData) fs.writeFileSync(filePath, outputData, 'utf8');
2065
+ else fs.writeFileSync(filePath, outputData);
2066
+ const [seconds, nanoseconds] = process.hrtime(startTime);
2067
+ const elapsedMs = (seconds + nanoseconds / 1e9).toFixed(2);
2068
+ const stats = fs.statSync(filePath);
2069
+ const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(1);
2070
+ log(`⏱️ ${elapsedMs}s 📦 ${fileSizeMB}MB ✅ Saved to: ${filePath}`);
2071
+ } else {
2072
+ if (isTextData) {
2073
+ log(outputData);
2074
+ } else {
2075
+ log(`Binary data received (${outputData.length} bytes). Use --output to save to file.`);
2076
+ }
2077
+ }
2078
+ } catch (error) {
2079
+ log(`Error: ${error.message}`, {color: 'red'});
2080
+ process.exit(1);
2081
+ }
2082
+ }
2083
+ })();
2084
+ }