grab-url 0.9.132

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