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.
- package/dist/grab-api.cjs.js +2 -0
- package/dist/grab-api.cjs.js.map +1 -0
- package/dist/grab-api.d.ts +321 -0
- package/dist/grab-api.es.js +495 -0
- package/dist/grab-api.es.js.map +1 -0
- package/dist/icons.cjs.js +2 -0
- package/dist/icons.cjs.js.map +1 -0
- package/dist/icons.d.ts +235 -0
- package/dist/icons.es.js +114 -0
- package/dist/icons.es.js.map +1 -0
- package/package.json +78 -0
- package/readme.md +156 -0
- package/src/grab-cli.js +316 -0
- package/src/grab-url-cli.js +2013 -0
|
@@ -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;
|