@kadi.build/file-manager 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,757 @@
1
+ /**
2
+ * File Streaming Utilities
3
+ *
4
+ * Reusable utilities for efficient file streaming with range request support,
5
+ * progress tracking, and proper caching headers.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import { promises as fsPromises } from 'fs';
10
+ import path from 'path';
11
+ import crypto from 'crypto';
12
+ import { EventEmitter } from 'events';
13
+
14
+ class FileStreamingUtils extends EventEmitter {
15
+ constructor(options = {}) {
16
+ super();
17
+
18
+ this.options = {
19
+ bufferSize: options.bufferSize || 64 * 1024,
20
+ progressInterval: options.progressInterval || 1024 * 1024,
21
+ enableETag: options.enableETag !== false,
22
+ enableLastModified: options.enableLastModified !== false,
23
+ enableCaching: options.enableCaching !== false,
24
+ maxCacheAge: options.maxCacheAge || 3600,
25
+ ...options
26
+ };
27
+ }
28
+
29
+ // ============================================================================
30
+ // MIME TYPE DETECTION
31
+ // ============================================================================
32
+
33
+ getMimeType(filePath) {
34
+ const ext = path.extname(filePath).toLowerCase();
35
+
36
+ const mimeTypes = {
37
+ // Text files
38
+ '.txt': 'text/plain',
39
+ '.md': 'text/markdown',
40
+ '.html': 'text/html',
41
+ '.htm': 'text/html',
42
+ '.css': 'text/css',
43
+ '.xml': 'application/xml',
44
+ '.yaml': 'application/x-yaml',
45
+ '.yml': 'application/x-yaml',
46
+
47
+ // JavaScript/JSON
48
+ '.js': 'application/javascript',
49
+ '.mjs': 'application/javascript',
50
+ '.json': 'application/json',
51
+ '.jsonl': 'application/jsonlines',
52
+
53
+ // Images
54
+ '.png': 'image/png',
55
+ '.jpg': 'image/jpeg',
56
+ '.jpeg': 'image/jpeg',
57
+ '.gif': 'image/gif',
58
+ '.webp': 'image/webp',
59
+ '.svg': 'image/svg+xml',
60
+ '.ico': 'image/x-icon',
61
+ '.bmp': 'image/bmp',
62
+ '.tiff': 'image/tiff',
63
+ '.tif': 'image/tiff',
64
+
65
+ // Archives
66
+ '.zip': 'application/zip',
67
+ '.tar': 'application/x-tar',
68
+ '.gz': 'application/gzip',
69
+ '.tgz': 'application/gzip',
70
+ '.bz2': 'application/x-bzip2',
71
+ '.xz': 'application/x-xz',
72
+ '.7z': 'application/x-7z-compressed',
73
+ '.rar': 'application/vnd.rar',
74
+
75
+ // Container registry specific
76
+ '.layer': 'application/vnd.docker.image.rootfs.diff.tar.gzip',
77
+ '.manifest': 'application/vnd.docker.distribution.manifest.v2+json',
78
+ '.config': 'application/vnd.docker.container.image.v1+json',
79
+
80
+ // Documents
81
+ '.pdf': 'application/pdf',
82
+ '.doc': 'application/msword',
83
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
84
+ '.xls': 'application/vnd.ms-excel',
85
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
86
+ '.ppt': 'application/vnd.ms-powerpoint',
87
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
88
+
89
+ // Audio/Video
90
+ '.mp3': 'audio/mpeg',
91
+ '.wav': 'audio/wav',
92
+ '.ogg': 'audio/ogg',
93
+ '.flac': 'audio/flac',
94
+ '.mp4': 'video/mp4',
95
+ '.avi': 'video/x-msvideo',
96
+ '.mov': 'video/quicktime',
97
+ '.wmv': 'video/x-ms-wmv',
98
+ '.webm': 'video/webm',
99
+ '.mkv': 'video/x-matroska',
100
+
101
+ // Programming languages
102
+ '.py': 'text/x-python',
103
+ '.java': 'text/x-java-source',
104
+ '.cpp': 'text/x-c++src',
105
+ '.c': 'text/x-csrc',
106
+ '.h': 'text/x-chdr',
107
+ '.sh': 'application/x-sh',
108
+ '.bat': 'application/x-bat',
109
+ '.ps1': 'application/x-powershell',
110
+
111
+ // Configuration files
112
+ '.ini': 'text/plain',
113
+ '.conf': 'text/plain',
114
+ '.cfg': 'text/plain',
115
+ '.env': 'text/plain',
116
+ '.properties': 'text/plain',
117
+ '.dockerfile': 'text/plain'
118
+ };
119
+
120
+ return mimeTypes[ext] || 'application/octet-stream';
121
+ }
122
+
123
+ // ============================================================================
124
+ // FILE INFORMATION AND METADATA
125
+ // ============================================================================
126
+
127
+ async getFileInfo(filePath) {
128
+ try {
129
+ const stats = await fsPromises.stat(filePath);
130
+ const fileName = path.basename(filePath);
131
+ const fileExt = path.extname(filePath).toLowerCase();
132
+ const mimeType = this.getMimeType(filePath);
133
+
134
+ return {
135
+ filePath,
136
+ fileName,
137
+ fileExt,
138
+ mimeType,
139
+ size: stats.size,
140
+ mtime: stats.mtime,
141
+ ctime: stats.ctime,
142
+ atime: stats.atime,
143
+ isFile: stats.isFile(),
144
+ isDirectory: stats.isDirectory(),
145
+ mode: stats.mode,
146
+ uid: stats.uid,
147
+ gid: stats.gid,
148
+ dev: stats.dev,
149
+ ino: stats.ino,
150
+ nlink: stats.nlink,
151
+
152
+ etag: this.calculateETag(stats),
153
+ lastModified: stats.mtime.toUTCString(),
154
+ formattedSize: this.formatBytes(stats.size),
155
+
156
+ isLargeFile: stats.size > 100 * 1024 * 1024,
157
+ isContainerLayer: this.isContainerLayer(filePath),
158
+ supportedRanges: true
159
+ };
160
+ } catch (error) {
161
+ throw new Error(`Failed to get file info for ${filePath}: ${error.message}`);
162
+ }
163
+ }
164
+
165
+ isContainerLayer(filePath) {
166
+ const fileName = path.basename(filePath).toLowerCase();
167
+ const isDigestPath = /^[a-f0-9]{64}$/.test(fileName);
168
+ const isLayerExt = ['.layer', '.tar', '.gz', '.tgz'].includes(path.extname(filePath).toLowerCase());
169
+ const hasLayerInPath = filePath.toLowerCase().includes('layer');
170
+ const hasDigestInPath = filePath.toLowerCase().includes('sha256');
171
+
172
+ return isDigestPath || isLayerExt || hasLayerInPath || hasDigestInPath;
173
+ }
174
+
175
+ // ============================================================================
176
+ // ETAG AND CACHING UTILITIES
177
+ // ============================================================================
178
+
179
+ calculateETag(stats) {
180
+ if (!this.options.enableETag) return null;
181
+ const etag = `"${stats.size.toString(16)}-${stats.mtime.getTime().toString(16)}"`;
182
+ return etag;
183
+ }
184
+
185
+ generateResponseHeaders(fileInfo, options = {}) {
186
+ const headers = {
187
+ 'Content-Type': fileInfo.mimeType,
188
+ 'Content-Length': fileInfo.size.toString(),
189
+ 'Accept-Ranges': 'bytes'
190
+ };
191
+
192
+ if (this.options.enableETag && fileInfo.etag) {
193
+ headers['ETag'] = fileInfo.etag;
194
+ }
195
+
196
+ if (this.options.enableLastModified && fileInfo.lastModified) {
197
+ headers['Last-Modified'] = fileInfo.lastModified;
198
+ }
199
+
200
+ if (this.options.enableCaching) {
201
+ headers['Cache-Control'] = `public, max-age=${this.options.maxCacheAge}`;
202
+ const expires = new Date(Date.now() + this.options.maxCacheAge * 1000);
203
+ headers['Expires'] = expires.toUTCString();
204
+ } else {
205
+ headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
206
+ headers['Pragma'] = 'no-cache';
207
+ headers['Expires'] = '0';
208
+ }
209
+
210
+ if (options.additionalHeaders) {
211
+ Object.assign(headers, options.additionalHeaders);
212
+ }
213
+
214
+ if (options.forceDownload) {
215
+ headers['Content-Disposition'] = `attachment; filename="${fileInfo.fileName}"`;
216
+ } else if (options.inlineDisposition !== false) {
217
+ headers['Content-Disposition'] = `inline; filename="${fileInfo.fileName}"`;
218
+ }
219
+
220
+ return headers;
221
+ }
222
+
223
+ // ============================================================================
224
+ // RANGE REQUEST PROCESSING
225
+ // ============================================================================
226
+
227
+ parseRangeHeader(rangeHeader, fileSize) {
228
+ if (!rangeHeader || !rangeHeader.startsWith('bytes=')) {
229
+ return null;
230
+ }
231
+
232
+ const ranges = [];
233
+ const rangeSpec = rangeHeader.replace(/bytes=/, '').split(',');
234
+
235
+ for (const spec of rangeSpec) {
236
+ const range = this.parseRangeSpec(spec.trim(), fileSize);
237
+ if (range) {
238
+ ranges.push(range);
239
+ }
240
+ }
241
+
242
+ return ranges.length > 0 ? ranges : null;
243
+ }
244
+
245
+ parseRangeSpec(spec, fileSize) {
246
+ const parts = spec.split('-');
247
+ if (parts.length !== 2) return null;
248
+
249
+ let start = parts[0] ? parseInt(parts[0], 10) : null;
250
+ let end = parts[1] ? parseInt(parts[1], 10) : null;
251
+
252
+ if ((parts[0] && isNaN(start)) || (parts[1] && isNaN(end))) {
253
+ return null;
254
+ }
255
+
256
+ // Suffix range: -500 (last 500 bytes)
257
+ if (start === null && end !== null) {
258
+ start = Math.max(0, fileSize - end);
259
+ end = fileSize - 1;
260
+ }
261
+ // Prefix range: 1000- (from byte 1000 to end)
262
+ else if (start !== null && end === null) {
263
+ end = fileSize - 1;
264
+ }
265
+ // Full range: 1000-2000
266
+ else if (start !== null && end !== null) {
267
+ if (start > end || start >= fileSize || end >= fileSize) {
268
+ return null;
269
+ }
270
+ } else {
271
+ return null;
272
+ }
273
+
274
+ if (start < 0 || end < 0 || start >= fileSize || end >= fileSize || start > end) {
275
+ return null;
276
+ }
277
+
278
+ return {
279
+ start,
280
+ end,
281
+ length: end - start + 1
282
+ };
283
+ }
284
+
285
+ formatContentRange(start, end, total) {
286
+ return `bytes ${start}-${end}/${total}`;
287
+ }
288
+
289
+ validateRanges(ranges, fileSize) {
290
+ if (!ranges || ranges.length === 0) return false;
291
+
292
+ return ranges.every(range => {
293
+ return range.start >= 0 &&
294
+ range.end < fileSize &&
295
+ range.start <= range.end &&
296
+ range.length > 0;
297
+ });
298
+ }
299
+
300
+ // ============================================================================
301
+ // UTILITY FUNCTIONS
302
+ // ============================================================================
303
+
304
+ formatBytes(bytes, decimals = 2) {
305
+ if (bytes === 0) return '0 B';
306
+
307
+ const k = 1024;
308
+ const dm = decimals < 0 ? 0 : decimals;
309
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
310
+
311
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
312
+
313
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
314
+ }
315
+
316
+ formatDuration(ms) {
317
+ if (ms < 1000) return `${ms}ms`;
318
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
319
+ if (ms < 3600000) return `${(ms / 60000).toFixed(1)}m`;
320
+ return `${(ms / 3600000).toFixed(1)}h`;
321
+ }
322
+
323
+ calculateSpeed(bytes, timeMs) {
324
+ if (timeMs === 0) return 0;
325
+ return bytes / (timeMs / 1000);
326
+ }
327
+
328
+ generateOperationId() {
329
+ return `stream_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
330
+ }
331
+ }
332
+
333
+ // ============================================================================
334
+ // ENHANCED FILE STREAMING WITH PROGRESS TRACKING
335
+ // ============================================================================
336
+
337
+ class FileStreamer extends EventEmitter {
338
+ constructor(fileStreamingUtils, options = {}) {
339
+ super();
340
+
341
+ this.utils = fileStreamingUtils;
342
+ this.options = {
343
+ bufferSize: options.bufferSize || 64 * 1024,
344
+ progressInterval: options.progressInterval || 1024 * 1024,
345
+ timeout: options.timeout || 30000,
346
+ retryAttempts: options.retryAttempts || 3,
347
+ retryDelay: options.retryDelay || 1000,
348
+ ...options
349
+ };
350
+
351
+ this.activeStreams = new Map();
352
+ }
353
+
354
+ async createReadStream(filePath, options = {}) {
355
+ const operationId = this.utils.generateOperationId();
356
+ const fileInfo = await this.utils.getFileInfo(filePath);
357
+
358
+ let range = null;
359
+ if (options.range) {
360
+ const ranges = this.utils.parseRangeHeader(options.range, fileInfo.size);
361
+ if (ranges && ranges.length > 0) {
362
+ range = ranges[0];
363
+ }
364
+ }
365
+
366
+ const start = range ? range.start : 0;
367
+ const end = range ? range.end : fileInfo.size - 1;
368
+ const streamLength = end - start + 1;
369
+
370
+ const streamOptions = {
371
+ start,
372
+ end,
373
+ highWaterMark: this.options.bufferSize
374
+ };
375
+
376
+ const readStream = fs.createReadStream(filePath, streamOptions);
377
+
378
+ const streamInfo = {
379
+ operationId,
380
+ filePath,
381
+ fileInfo,
382
+ range,
383
+ streamLength,
384
+ bytesTransferred: 0,
385
+ startTime: Date.now(),
386
+ lastProgressUpdate: 0,
387
+ speed: 0,
388
+ estimatedTimeRemaining: 0,
389
+ completed: false,
390
+ error: null,
391
+ stream: readStream
392
+ };
393
+
394
+ this.activeStreams.set(operationId, streamInfo);
395
+
396
+ this.setupProgressTracking(streamInfo, options);
397
+ this.setupErrorHandling(streamInfo, options);
398
+
399
+ return {
400
+ operationId,
401
+ stream: readStream,
402
+ fileInfo,
403
+ range,
404
+ streamLength,
405
+ cancel: () => this.cancelStream(operationId),
406
+ getProgress: () => this.getStreamProgress(operationId)
407
+ };
408
+ }
409
+
410
+ setupProgressTracking(streamInfo, options) {
411
+ const { operationId, stream, streamLength, fileInfo } = streamInfo;
412
+
413
+ stream.on('data', (chunk) => {
414
+ streamInfo.bytesTransferred += chunk.length;
415
+
416
+ const now = Date.now();
417
+ const elapsed = now - streamInfo.startTime;
418
+
419
+ if (elapsed > 0) {
420
+ streamInfo.speed = streamInfo.bytesTransferred / (elapsed / 1000);
421
+ }
422
+
423
+ if (streamInfo.speed > 0) {
424
+ const remaining = streamLength - streamInfo.bytesTransferred;
425
+ streamInfo.estimatedTimeRemaining = (remaining / streamInfo.speed) * 1000;
426
+ }
427
+
428
+ const shouldEmitProgress = (
429
+ streamInfo.bytesTransferred - streamInfo.lastProgressUpdate >= this.options.progressInterval ||
430
+ streamInfo.bytesTransferred === chunk.length ||
431
+ streamInfo.bytesTransferred >= streamLength
432
+ );
433
+
434
+ if (shouldEmitProgress) {
435
+ const progress = {
436
+ operationId,
437
+ filePath: streamInfo.filePath,
438
+ fileName: fileInfo.fileName,
439
+ bytesTransferred: streamInfo.bytesTransferred,
440
+ totalBytes: streamLength,
441
+ percentage: Math.round((streamInfo.bytesTransferred / streamLength) * 100),
442
+ speed: streamInfo.speed,
443
+ speedFormatted: this.utils.formatBytes(streamInfo.speed) + '/s',
444
+ estimatedTimeRemaining: streamInfo.estimatedTimeRemaining,
445
+ elapsed: elapsed,
446
+ isRangeRequest: !!streamInfo.range,
447
+ timestamp: new Date()
448
+ };
449
+
450
+ this.emit('progress', progress);
451
+ streamInfo.lastProgressUpdate = streamInfo.bytesTransferred;
452
+ }
453
+ });
454
+
455
+ stream.on('end', () => {
456
+ if (streamInfo.completed) return;
457
+ streamInfo.completed = true;
458
+
459
+ const elapsed = Date.now() - streamInfo.startTime;
460
+ const finalSpeed = streamInfo.bytesTransferred / (elapsed / 1000);
461
+
462
+ const completion = {
463
+ operationId,
464
+ filePath: streamInfo.filePath,
465
+ fileName: fileInfo.fileName,
466
+ bytesTransferred: streamInfo.bytesTransferred,
467
+ totalBytes: streamLength,
468
+ duration: elapsed,
469
+ speed: finalSpeed,
470
+ speedFormatted: this.utils.formatBytes(finalSpeed) + '/s',
471
+ durationFormatted: this.utils.formatDuration(elapsed),
472
+ isRangeRequest: !!streamInfo.range,
473
+ success: true,
474
+ timestamp: new Date()
475
+ };
476
+
477
+ this.emit('complete', completion);
478
+ this.activeStreams.delete(operationId);
479
+ });
480
+ }
481
+
482
+ setupErrorHandling(streamInfo, options) {
483
+ const { operationId, stream } = streamInfo;
484
+
485
+ stream.on('error', (error) => {
486
+ streamInfo.error = error;
487
+ streamInfo.completed = true;
488
+
489
+ const errorInfo = {
490
+ operationId,
491
+ filePath: streamInfo.filePath,
492
+ error: error.message,
493
+ errorCode: error.code,
494
+ bytesTransferred: streamInfo.bytesTransferred,
495
+ totalBytes: streamInfo.streamLength,
496
+ elapsed: Date.now() - streamInfo.startTime,
497
+ timestamp: new Date()
498
+ };
499
+
500
+ this.emit('error', errorInfo);
501
+ this.activeStreams.delete(operationId);
502
+ });
503
+
504
+ if (this.options.timeout > 0) {
505
+ const timeout = setTimeout(() => {
506
+ if (!streamInfo.completed) {
507
+ const timeoutError = new Error(`Stream timeout after ${this.options.timeout}ms`);
508
+ stream.destroy(timeoutError);
509
+ }
510
+ }, this.options.timeout);
511
+
512
+ stream.on('end', () => clearTimeout(timeout));
513
+ stream.on('error', () => clearTimeout(timeout));
514
+ }
515
+ }
516
+
517
+ cancelStream(operationId) {
518
+ const streamInfo = this.activeStreams.get(operationId);
519
+ if (streamInfo && !streamInfo.completed) {
520
+ streamInfo.stream.destroy();
521
+ streamInfo.completed = true;
522
+
523
+ this.emit('cancelled', {
524
+ operationId,
525
+ filePath: streamInfo.filePath,
526
+ bytesTransferred: streamInfo.bytesTransferred,
527
+ totalBytes: streamInfo.streamLength,
528
+ timestamp: new Date()
529
+ });
530
+
531
+ this.activeStreams.delete(operationId);
532
+ }
533
+ }
534
+
535
+ getStreamProgress(operationId) {
536
+ const streamInfo = this.activeStreams.get(operationId);
537
+ if (!streamInfo) return null;
538
+
539
+ const elapsed = Date.now() - streamInfo.startTime;
540
+
541
+ return {
542
+ operationId,
543
+ filePath: streamInfo.filePath,
544
+ fileName: streamInfo.fileInfo.fileName,
545
+ bytesTransferred: streamInfo.bytesTransferred,
546
+ totalBytes: streamInfo.streamLength,
547
+ percentage: Math.round((streamInfo.bytesTransferred / streamInfo.streamLength) * 100),
548
+ speed: streamInfo.speed,
549
+ speedFormatted: this.utils.formatBytes(streamInfo.speed) + '/s',
550
+ estimatedTimeRemaining: streamInfo.estimatedTimeRemaining,
551
+ elapsed: elapsed,
552
+ elapsedFormatted: this.utils.formatDuration(elapsed),
553
+ isRangeRequest: !!streamInfo.range,
554
+ completed: streamInfo.completed,
555
+ timestamp: new Date()
556
+ };
557
+ }
558
+
559
+ listActiveStreams() {
560
+ return Array.from(this.activeStreams.keys()).map(id => this.getStreamProgress(id));
561
+ }
562
+
563
+ cancelAllStreams() {
564
+ const activeIds = Array.from(this.activeStreams.keys());
565
+ activeIds.forEach(id => this.cancelStream(id));
566
+ }
567
+ }
568
+
569
+ // ============================================================================
570
+ // DOWNLOAD PROGRESS TRACKER
571
+ // ============================================================================
572
+
573
+ class DownloadTracker extends EventEmitter {
574
+ constructor(options = {}) {
575
+ super();
576
+
577
+ this.options = {
578
+ trackingInterval: options.trackingInterval || 1000,
579
+ historySize: options.historySize || 100,
580
+ enableRealTimeStats: options.enableRealTimeStats !== false,
581
+ ...options
582
+ };
583
+
584
+ this.downloads = new Map();
585
+ this.history = [];
586
+ this.stats = {
587
+ totalDownloads: 0,
588
+ totalBytes: 0,
589
+ totalDuration: 0,
590
+ averageSpeed: 0,
591
+ activeDownloads: 0,
592
+ completedDownloads: 0,
593
+ failedDownloads: 0
594
+ };
595
+
596
+ if (this.options.enableRealTimeStats) {
597
+ this.startRealTimeTracking();
598
+ }
599
+ }
600
+
601
+ startTracking(downloadInfo) {
602
+ const trackingId = `download_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
603
+
604
+ const download = {
605
+ trackingId,
606
+ startTime: Date.now(),
607
+ ...downloadInfo,
608
+ bytesTransferred: 0,
609
+ speed: 0,
610
+ percentage: 0,
611
+ status: 'active',
612
+ lastUpdate: Date.now()
613
+ };
614
+
615
+ this.downloads.set(trackingId, download);
616
+ this.stats.activeDownloads++;
617
+
618
+ this.emit('downloadStarted', { trackingId, download });
619
+
620
+ return trackingId;
621
+ }
622
+
623
+ updateProgress(trackingId, progress) {
624
+ const download = this.downloads.get(trackingId);
625
+ if (!download || download.status !== 'active') return;
626
+
627
+ const now = Date.now();
628
+ const elapsed = now - download.startTime;
629
+
630
+ Object.assign(download, {
631
+ bytesTransferred: progress.bytesTransferred || download.bytesTransferred,
632
+ totalBytes: progress.totalBytes || download.totalBytes,
633
+ speed: progress.speed || (elapsed > 0 ? download.bytesTransferred / (elapsed / 1000) : 0),
634
+ percentage: progress.percentage || (download.totalBytes > 0 ?
635
+ Math.round((download.bytesTransferred / download.totalBytes) * 100) : 0),
636
+ estimatedTimeRemaining: progress.estimatedTimeRemaining,
637
+ lastUpdate: now
638
+ });
639
+
640
+ this.emit('downloadProgress', { trackingId, download, progress });
641
+ }
642
+
643
+ completeDownload(trackingId, completionInfo = {}) {
644
+ const download = this.downloads.get(trackingId);
645
+ if (!download) return;
646
+
647
+ const now = Date.now();
648
+ const duration = now - download.startTime;
649
+ const finalSpeed = download.bytesTransferred / (duration / 1000);
650
+
651
+ Object.assign(download, {
652
+ status: 'completed',
653
+ endTime: now,
654
+ duration: duration,
655
+ speed: finalSpeed,
656
+ percentage: 100,
657
+ ...completionInfo
658
+ });
659
+
660
+ this.history.unshift(download);
661
+ if (this.history.length > this.options.historySize) {
662
+ this.history.pop();
663
+ }
664
+
665
+ this.stats.activeDownloads--;
666
+ this.stats.completedDownloads++;
667
+ this.stats.totalDownloads++;
668
+ this.stats.totalBytes += download.bytesTransferred;
669
+ this.stats.totalDuration += duration;
670
+ this.stats.averageSpeed = this.stats.totalBytes / (this.stats.totalDuration / 1000);
671
+
672
+ this.downloads.delete(trackingId);
673
+
674
+ this.emit('downloadCompleted', { trackingId, download });
675
+ }
676
+
677
+ failDownload(trackingId, errorInfo = {}) {
678
+ const download = this.downloads.get(trackingId);
679
+ if (!download) return;
680
+
681
+ const now = Date.now();
682
+ const duration = now - download.startTime;
683
+
684
+ Object.assign(download, {
685
+ status: 'failed',
686
+ endTime: now,
687
+ duration: duration,
688
+ error: errorInfo.error || 'Unknown error',
689
+ errorCode: errorInfo.errorCode,
690
+ ...errorInfo
691
+ });
692
+
693
+ this.history.unshift(download);
694
+ if (this.history.length > this.options.historySize) {
695
+ this.history.pop();
696
+ }
697
+
698
+ this.stats.activeDownloads--;
699
+ this.stats.failedDownloads++;
700
+
701
+ this.downloads.delete(trackingId);
702
+
703
+ this.emit('downloadFailed', { trackingId, download, error: errorInfo });
704
+ }
705
+
706
+ getDownload(trackingId) {
707
+ return this.downloads.get(trackingId) || null;
708
+ }
709
+
710
+ listActiveDownloads() {
711
+ return Array.from(this.downloads.values());
712
+ }
713
+
714
+ getHistory(limit = 50) {
715
+ return this.history.slice(0, limit);
716
+ }
717
+
718
+ getStats() {
719
+ return { ...this.stats };
720
+ }
721
+
722
+ startRealTimeTracking() {
723
+ this.trackingInterval = setInterval(() => {
724
+ const now = Date.now();
725
+
726
+ for (const [trackingId, download] of this.downloads.entries()) {
727
+ if (now - download.lastUpdate > 30000) {
728
+ this.failDownload(trackingId, {
729
+ error: 'Download stalled - no progress updates received',
730
+ errorCode: 'STALLED'
731
+ });
732
+ }
733
+ }
734
+
735
+ this.emit('statsUpdate', this.getStats());
736
+ }, this.options.trackingInterval);
737
+ }
738
+
739
+ stopRealTimeTracking() {
740
+ if (this.trackingInterval) {
741
+ clearInterval(this.trackingInterval);
742
+ this.trackingInterval = null;
743
+ }
744
+ }
745
+
746
+ shutdown() {
747
+ this.stopRealTimeTracking();
748
+ this.downloads.clear();
749
+ this.emit('shutdown');
750
+ }
751
+ }
752
+
753
+ export {
754
+ FileStreamingUtils,
755
+ FileStreamer,
756
+ DownloadTracker
757
+ };