@xiboplayer/cache 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,434 @@
1
+ /**
2
+ * DownloadManager - Standalone file download orchestration
3
+ *
4
+ * Works in both browser and Service Worker contexts.
5
+ * Handles download queue, concurrency control, parallel chunks, and MD5 verification.
6
+ *
7
+ * Architecture:
8
+ * - DownloadQueue: Manages download queue with concurrency control
9
+ * - DownloadTask: Handles individual file download with parallel chunks
10
+ * - MD5Calculator: Calculates MD5 hash (optional, uses SparkMD5 if available)
11
+ *
12
+ * Usage:
13
+ * const dm = new DownloadManager({ concurrency: 4, chunkSize: 50MB });
14
+ * const task = dm.enqueue({ id, type, path, md5 });
15
+ * const blob = await task.wait();
16
+ */
17
+
18
+ const DEFAULT_CONCURRENCY = 4; // Max concurrent file downloads
19
+ const DEFAULT_CHUNK_SIZE = 50 * 1024 * 1024; // 50MB chunks
20
+ const DEFAULT_CHUNKS_PER_FILE = 4; // Parallel chunks per file
21
+
22
+ /**
23
+ * DownloadTask - Handles individual file download
24
+ */
25
+ export class DownloadTask {
26
+ constructor(fileInfo, options = {}) {
27
+ this.fileInfo = fileInfo;
28
+ this.options = options;
29
+ this.downloadedBytes = 0;
30
+ this.totalBytes = 0;
31
+ this.promise = null;
32
+ this.resolve = null;
33
+ this.reject = null;
34
+ this.waiters = []; // Promises waiting for completion
35
+ this.state = 'pending'; // pending, downloading, complete, failed
36
+ // Progressive streaming: callback fired for each chunk as it downloads
37
+ // Set externally before download starts: (chunkIndex, chunkBlob, totalChunks) => Promise
38
+ this.onChunkDownloaded = null;
39
+ }
40
+
41
+ /**
42
+ * Wait for download to complete
43
+ * Returns blob when ready
44
+ */
45
+ async wait() {
46
+ if (this.promise) {
47
+ return this.promise;
48
+ }
49
+
50
+ if (this.state === 'complete') {
51
+ return this.promise;
52
+ }
53
+
54
+ // Create waiter promise
55
+ return new Promise((resolve, reject) => {
56
+ this.waiters.push({ resolve, reject });
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Start download with parallel chunks
62
+ */
63
+ async start() {
64
+ const { id, type, path, md5 } = this.fileInfo;
65
+
66
+ try {
67
+ this.state = 'downloading';
68
+ console.log('[DownloadTask] Starting:', path);
69
+
70
+ // HEAD request to get file size
71
+ const headResponse = await fetch(path, { method: 'HEAD' });
72
+ if (!headResponse.ok) {
73
+ throw new Error(`HEAD request failed: ${headResponse.status}`);
74
+ }
75
+
76
+ this.totalBytes = parseInt(headResponse.headers.get('Content-Length') || '0');
77
+ const contentType = headResponse.headers.get('Content-Type') || 'application/octet-stream';
78
+
79
+ console.log('[DownloadTask] File size:', (this.totalBytes / 1024 / 1024).toFixed(1), 'MB');
80
+
81
+ // Download in chunks if large file
82
+ let blob;
83
+ const chunkSize = this.options.chunkSize || DEFAULT_CHUNK_SIZE;
84
+ const chunksPerFile = this.options.chunksPerFile || DEFAULT_CHUNKS_PER_FILE;
85
+
86
+ if (this.totalBytes > 100 * 1024 * 1024) { // > 100MB
87
+ blob = await this.downloadChunks(path, contentType, chunkSize, chunksPerFile);
88
+ } else {
89
+ blob = await this.downloadFull(path);
90
+ }
91
+
92
+ // Verify MD5 if provided and MD5 calculator available
93
+ if (md5 && this.options.calculateMD5) {
94
+ const calculatedMd5 = await this.options.calculateMD5(blob);
95
+ if (calculatedMd5 && calculatedMd5 !== md5) {
96
+ console.warn('[DownloadTask] MD5 mismatch:', path);
97
+ console.warn('[DownloadTask] Expected:', md5);
98
+ console.warn('[DownloadTask] Got:', calculatedMd5);
99
+ // Continue anyway (kiosk mode)
100
+ }
101
+ }
102
+
103
+ console.log('[DownloadTask] Complete:', path, `(${blob.size} bytes)`);
104
+
105
+ // Mark complete
106
+ this.state = 'complete';
107
+ this.blob = blob;
108
+
109
+ // Resolve all waiters
110
+ this.promise = Promise.resolve(blob);
111
+ for (const waiter of this.waiters) {
112
+ waiter.resolve(blob);
113
+ }
114
+ this.waiters = [];
115
+
116
+ return blob;
117
+
118
+ } catch (error) {
119
+ console.error('[DownloadTask] Failed:', path, error);
120
+ this.state = 'failed';
121
+
122
+ // Reject all waiters
123
+ this.promise = Promise.reject(error);
124
+ this.promise.catch(() => {}); // Prevent unhandled rejection if nobody calls wait()
125
+ for (const waiter of this.waiters) {
126
+ waiter.reject(error);
127
+ }
128
+ this.waiters = [];
129
+
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Download full file (for small files)
136
+ */
137
+ async downloadFull(url) {
138
+ const response = await fetch(url);
139
+ if (!response.ok) {
140
+ throw new Error(`Download failed: ${response.status}`);
141
+ }
142
+
143
+ const blob = await response.blob();
144
+ this.downloadedBytes = blob.size;
145
+ return blob;
146
+ }
147
+
148
+ /**
149
+ * Download file in parallel chunks (for large files)
150
+ * If onChunkDownloaded callback is set, fires it for each chunk as it arrives
151
+ * so the caller can cache chunks progressively (enabling streaming before
152
+ * the entire file is downloaded).
153
+ */
154
+ async downloadChunks(url, contentType, chunkSize, concurrentChunks) {
155
+ // Calculate chunk ranges
156
+ const chunkRanges = [];
157
+ for (let start = 0; start < this.totalBytes; start += chunkSize) {
158
+ const end = Math.min(start + chunkSize - 1, this.totalBytes - 1);
159
+ chunkRanges.push({ start, end, index: chunkRanges.length });
160
+ }
161
+
162
+ // Prioritize chunk 0 (ftyp header) and last chunk (moov atom) for video early playback.
163
+ // Modern browsers seek to end of MP4 for moov, so having both extremes first
164
+ // lets video start playing while middle chunks are still downloading.
165
+ if (chunkRanges.length > 2) {
166
+ const lastChunk = chunkRanges.pop(); // remove last
167
+ chunkRanges.splice(1, 0, lastChunk); // insert after chunk 0
168
+ }
169
+ console.log('[DownloadTask] Downloading', chunkRanges.length, 'chunks (chunk 0 + last prioritized)');
170
+
171
+ // Download chunks in parallel with concurrency limit
172
+ const chunkMap = new Map();
173
+ let nextChunkIndex = 0;
174
+
175
+ const downloadChunk = async (range) => {
176
+ const rangeHeader = `bytes=${range.start}-${range.end}`;
177
+
178
+ try {
179
+ const response = await fetch(url, {
180
+ headers: { 'Range': rangeHeader }
181
+ });
182
+
183
+ if (!response.ok && response.status !== 206) {
184
+ throw new Error(`Chunk ${range.index} failed: ${response.status}`);
185
+ }
186
+
187
+ const chunkBlob = await response.blob();
188
+ chunkMap.set(range.index, chunkBlob);
189
+
190
+ this.downloadedBytes += chunkBlob.size;
191
+ const progress = (this.downloadedBytes / this.totalBytes * 100).toFixed(1);
192
+ console.log('[DownloadTask] Chunk', range.index + 1, '/', chunkRanges.length, `(${progress}%)`);
193
+
194
+ // Progressive streaming: notify caller to cache this chunk immediately
195
+ if (this.onChunkDownloaded) {
196
+ try {
197
+ await this.onChunkDownloaded(range.index, chunkBlob, chunkRanges.length);
198
+ } catch (e) {
199
+ console.warn('[DownloadTask] onChunkDownloaded callback error:', e);
200
+ }
201
+ }
202
+
203
+ // Notify progress if callback provided
204
+ if (this.options.onProgress) {
205
+ this.options.onProgress(this.downloadedBytes, this.totalBytes);
206
+ }
207
+
208
+ return chunkBlob;
209
+
210
+ } catch (error) {
211
+ console.error('[DownloadTask] Chunk', range.index, 'failed:', error);
212
+ throw error;
213
+ }
214
+ };
215
+
216
+ // Download with concurrency control
217
+ const downloadNext = async () => {
218
+ while (nextChunkIndex < chunkRanges.length) {
219
+ const range = chunkRanges[nextChunkIndex++];
220
+ await downloadChunk(range);
221
+ }
222
+ };
223
+
224
+ // Start concurrent downloaders
225
+ const downloaders = [];
226
+ for (let i = 0; i < concurrentChunks; i++) {
227
+ downloaders.push(downloadNext());
228
+ }
229
+ await Promise.all(downloaders);
230
+
231
+ // If progressive caching was used, skip reassembly (chunks are already cached)
232
+ if (this.onChunkDownloaded) {
233
+ // Return a lightweight marker — the real data is already in cache
234
+ return new Blob([], { type: contentType });
235
+ }
236
+
237
+ // Reassemble chunks in order (traditional path for small chunked downloads)
238
+ const orderedChunks = [];
239
+ for (let i = 0; i < chunkRanges.length; i++) {
240
+ orderedChunks.push(chunkMap.get(i));
241
+ }
242
+
243
+ return new Blob(orderedChunks, { type: contentType });
244
+ }
245
+ }
246
+
247
+ /**
248
+ * DownloadQueue - Manages download queue with concurrency control
249
+ */
250
+ export class DownloadQueue {
251
+ constructor(options = {}) {
252
+ this.concurrency = options.concurrency || DEFAULT_CONCURRENCY;
253
+ this.chunkSize = options.chunkSize || DEFAULT_CHUNK_SIZE;
254
+ this.chunksPerFile = options.chunksPerFile || DEFAULT_CHUNKS_PER_FILE;
255
+ this.calculateMD5 = options.calculateMD5; // Optional MD5 calculator function
256
+ this.onProgress = options.onProgress; // Optional progress callback
257
+
258
+ this.queue = [];
259
+ this.active = new Map(); // url -> DownloadTask
260
+ this.running = 0;
261
+ }
262
+
263
+ /**
264
+ * Add file to download queue
265
+ * Returns existing task if already downloading
266
+ */
267
+ enqueue(fileInfo) {
268
+ const { path } = fileInfo;
269
+
270
+ // If already downloading, return existing task
271
+ if (this.active.has(path)) {
272
+ console.log('[DownloadQueue] File already downloading:', path);
273
+ return this.active.get(path);
274
+ }
275
+
276
+ // Create new download task
277
+ const task = new DownloadTask(fileInfo, {
278
+ chunkSize: this.chunkSize,
279
+ chunksPerFile: this.chunksPerFile,
280
+ calculateMD5: this.calculateMD5,
281
+ onProgress: this.onProgress
282
+ });
283
+
284
+ this.active.set(path, task);
285
+ this.queue.push(task);
286
+
287
+ console.log('[DownloadQueue] Enqueued:', path, `(${this.queue.length} pending, ${this.running} active)`);
288
+
289
+ // Start download if capacity available
290
+ this.processQueue();
291
+
292
+ return task;
293
+ }
294
+
295
+ /**
296
+ * Process queue - start downloads up to concurrency limit
297
+ */
298
+ async processQueue() {
299
+ console.log('[DownloadQueue] processQueue:', this.running, 'running,', this.queue.length, 'queued');
300
+
301
+ while (this.running < this.concurrency && this.queue.length > 0) {
302
+ const task = this.queue.shift();
303
+ this.running++;
304
+
305
+ console.log('[DownloadQueue] Starting:', task.fileInfo.path, `(${this.running}/${this.concurrency} active)`);
306
+
307
+ // Start download (don't await - let it run in background)
308
+ // .catch is safe here: errors are already propagated to waiters inside start()
309
+ task.start()
310
+ .catch(() => {}) // Suppress — error handled internally via waiters
311
+ .finally(() => {
312
+ this.running--;
313
+ this.active.delete(task.fileInfo.path);
314
+ console.log('[DownloadQueue] Complete:', task.fileInfo.path, `(${this.running} active, ${this.queue.length} pending)`);
315
+
316
+ // Process next in queue
317
+ this.processQueue();
318
+ });
319
+ }
320
+
321
+ if (this.running >= this.concurrency) {
322
+ console.log('[DownloadQueue] Concurrency limit reached:', this.running, '/', this.concurrency);
323
+ }
324
+ if (this.queue.length === 0 && this.running === 0) {
325
+ console.log('[DownloadQueue] All downloads complete');
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Move a file to the front of the queue (if still queued, not yet started)
331
+ * @param {string} fileType - 'media' or 'layout'
332
+ * @param {string} fileId - File ID
333
+ * @returns {boolean} true if file was found (queued or active)
334
+ */
335
+ prioritize(fileType, fileId) {
336
+ const idx = this.queue.findIndex(task =>
337
+ task.fileInfo.type === fileType && String(task.fileInfo.id) === String(fileId)
338
+ );
339
+
340
+ if (idx > 0) {
341
+ const [task] = this.queue.splice(idx, 1);
342
+ this.queue.unshift(task);
343
+ console.log('[DownloadQueue] Prioritized:', `${fileType}/${fileId}`, '(moved to front of queue)');
344
+ return true;
345
+ }
346
+
347
+ if (idx === 0) {
348
+ console.log('[DownloadQueue] Already at front:', `${fileType}/${fileId}`);
349
+ return true;
350
+ }
351
+
352
+ // Check if already downloading
353
+ for (const [, task] of this.active) {
354
+ if (task.fileInfo.type === fileType && String(task.fileInfo.id) === String(fileId)) {
355
+ console.log('[DownloadQueue] Already downloading:', `${fileType}/${fileId}`);
356
+ return true;
357
+ }
358
+ }
359
+
360
+ console.log('[DownloadQueue] Not found in queue:', `${fileType}/${fileId}`);
361
+ return false;
362
+ }
363
+
364
+ /**
365
+ * Get task by URL (returns null if not downloading)
366
+ */
367
+ getTask(url) {
368
+ return this.active.get(url) || null;
369
+ }
370
+
371
+ /**
372
+ * Get progress for all active downloads
373
+ */
374
+ getProgress() {
375
+ const progress = {};
376
+ for (const [url, task] of this.active.entries()) {
377
+ progress[url] = {
378
+ downloaded: task.downloadedBytes,
379
+ total: task.totalBytes,
380
+ percent: task.totalBytes > 0 ? (task.downloadedBytes / task.totalBytes * 100).toFixed(1) : 0,
381
+ state: task.state
382
+ };
383
+ }
384
+ return progress;
385
+ }
386
+
387
+ /**
388
+ * Cancel all downloads
389
+ */
390
+ clear() {
391
+ this.queue = [];
392
+ this.active.clear();
393
+ this.running = 0;
394
+ }
395
+ }
396
+
397
+ /**
398
+ * DownloadManager - Main API
399
+ */
400
+ export class DownloadManager {
401
+ constructor(options = {}) {
402
+ this.queue = new DownloadQueue(options);
403
+ }
404
+
405
+ /**
406
+ * Enqueue file for download
407
+ * @param {Object} fileInfo - { id, type, path, md5 }
408
+ * @returns {DownloadTask}
409
+ */
410
+ enqueue(fileInfo) {
411
+ return this.queue.enqueue(fileInfo);
412
+ }
413
+
414
+ /**
415
+ * Get download task by URL
416
+ */
417
+ getTask(url) {
418
+ return this.queue.getTask(url);
419
+ }
420
+
421
+ /**
422
+ * Get progress for all downloads
423
+ */
424
+ getProgress() {
425
+ return this.queue.getProgress();
426
+ }
427
+
428
+ /**
429
+ * Clear all downloads
430
+ */
431
+ clear() {
432
+ this.queue.clear();
433
+ }
434
+ }