@epic-web/workshop-utils 6.56.0 → 6.58.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.
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
1
2
|
import { Readable } from 'node:stream';
|
|
2
3
|
type OfflineVideoEntryStatus = 'ready' | 'downloading' | 'error';
|
|
3
4
|
export type OfflineVideoDownloadState = {
|
|
@@ -58,6 +59,18 @@ export type OfflineVideoAsset = {
|
|
|
58
59
|
end: number;
|
|
59
60
|
}) => Readable;
|
|
60
61
|
};
|
|
62
|
+
export type VideoDownloadProgress = {
|
|
63
|
+
playbackId: string;
|
|
64
|
+
bytesDownloaded: number;
|
|
65
|
+
totalBytes: number | null;
|
|
66
|
+
status: 'downloading' | 'complete' | 'error';
|
|
67
|
+
};
|
|
68
|
+
export declare const DOWNLOAD_PROGRESS_EVENTS: {
|
|
69
|
+
readonly PROGRESS: "progress";
|
|
70
|
+
};
|
|
71
|
+
declare class DownloadProgressEmitter extends EventEmitter {
|
|
72
|
+
}
|
|
73
|
+
export declare const downloadProgressEmitter: DownloadProgressEmitter;
|
|
61
74
|
export declare function getOfflineVideoDownloadState(): OfflineVideoDownloadState;
|
|
62
75
|
export declare function getOfflineVideoSummary({ request, }?: {
|
|
63
76
|
request?: Request;
|
|
@@ -72,10 +85,13 @@ export declare function downloadOfflineVideo({ playbackId, title, url, }: {
|
|
|
72
85
|
url: string;
|
|
73
86
|
}): Promise<{
|
|
74
87
|
readonly status: "error";
|
|
88
|
+
readonly message: string;
|
|
75
89
|
} | {
|
|
76
90
|
readonly status: "ready";
|
|
91
|
+
readonly message?: undefined;
|
|
77
92
|
} | {
|
|
78
93
|
readonly status: "downloaded";
|
|
94
|
+
readonly message?: undefined;
|
|
79
95
|
}>;
|
|
80
96
|
export declare function deleteOfflineVideo(playbackId: string, options?: {
|
|
81
97
|
workshopId?: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
2
3
|
import { createReadStream, createWriteStream, promises as fs } from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { Readable, Transform } from 'node:stream';
|
|
@@ -25,6 +26,15 @@ const log = logger('epic:offline-videos');
|
|
|
25
26
|
const offlineVideoDirectoryName = 'offline-videos';
|
|
26
27
|
const offlineVideoIndexFileName = 'index.json';
|
|
27
28
|
const offlineVideoConfigFileName = 'offline-video-config.json';
|
|
29
|
+
export const DOWNLOAD_PROGRESS_EVENTS = {
|
|
30
|
+
PROGRESS: 'progress',
|
|
31
|
+
};
|
|
32
|
+
class DownloadProgressEmitter extends EventEmitter {
|
|
33
|
+
}
|
|
34
|
+
export const downloadProgressEmitter = new DownloadProgressEmitter();
|
|
35
|
+
function emitDownloadProgress(progress) {
|
|
36
|
+
downloadProgressEmitter.emit(DOWNLOAD_PROGRESS_EVENTS.PROGRESS, progress);
|
|
37
|
+
}
|
|
28
38
|
let downloadState = {
|
|
29
39
|
status: 'idle',
|
|
30
40
|
startedAt: null,
|
|
@@ -213,6 +223,16 @@ function createSliceTransform({ skipBytes, takeBytes, }) {
|
|
|
213
223
|
},
|
|
214
224
|
});
|
|
215
225
|
}
|
|
226
|
+
function createProgressTrackingTransform({ onProgress, }) {
|
|
227
|
+
let totalBytes = 0;
|
|
228
|
+
return new Transform({
|
|
229
|
+
transform(chunk, _encoding, callback) {
|
|
230
|
+
totalBytes += chunk.length;
|
|
231
|
+
onProgress(totalBytes);
|
|
232
|
+
callback(null, chunk);
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|
|
216
236
|
function createOfflineVideoReadStream({ filePath, size, key, iv, range, }) {
|
|
217
237
|
if (!range) {
|
|
218
238
|
const decipher = createOfflineVideoDecipher({ key, iv });
|
|
@@ -331,27 +351,83 @@ async function isOfflineVideoReady(index, playbackId, keyId, cryptoVersion, work
|
|
|
331
351
|
async function downloadMuxVideo({ playbackId, filePath, key, iv, resolution, }) {
|
|
332
352
|
const urls = getMuxMp4Urls(playbackId, resolution);
|
|
333
353
|
let lastError = null;
|
|
354
|
+
emitDownloadProgress({
|
|
355
|
+
playbackId,
|
|
356
|
+
bytesDownloaded: 0,
|
|
357
|
+
totalBytes: null,
|
|
358
|
+
status: 'downloading',
|
|
359
|
+
});
|
|
334
360
|
for (const url of urls) {
|
|
361
|
+
log.info('Attempting mux download', { playbackId, url });
|
|
335
362
|
const response = await fetch(url).catch((error) => {
|
|
336
363
|
lastError = error;
|
|
364
|
+
log.warn('Mux download request failed', {
|
|
365
|
+
playbackId,
|
|
366
|
+
url,
|
|
367
|
+
message: lastError.message,
|
|
368
|
+
});
|
|
337
369
|
return null;
|
|
338
370
|
});
|
|
339
371
|
if (!response)
|
|
340
372
|
continue;
|
|
341
373
|
if (!response.ok || !response.body) {
|
|
342
374
|
lastError = new Error(`Failed to download ${playbackId} from ${url} (${response.status})`);
|
|
375
|
+
log.warn('Mux download response not ok', {
|
|
376
|
+
playbackId,
|
|
377
|
+
url,
|
|
378
|
+
status: response.status,
|
|
379
|
+
hasBody: Boolean(response.body),
|
|
380
|
+
});
|
|
343
381
|
continue;
|
|
344
382
|
}
|
|
383
|
+
// Get content-length if available for progress tracking
|
|
384
|
+
const contentLengthHeader = response.headers.get('content-length');
|
|
385
|
+
const totalBytes = contentLengthHeader
|
|
386
|
+
? parseInt(contentLengthHeader, 10)
|
|
387
|
+
: null;
|
|
388
|
+
emitDownloadProgress({
|
|
389
|
+
playbackId,
|
|
390
|
+
bytesDownloaded: 0,
|
|
391
|
+
totalBytes,
|
|
392
|
+
status: 'downloading',
|
|
393
|
+
});
|
|
345
394
|
await ensureOfflineVideoDir();
|
|
346
395
|
const tmpPath = `${filePath}.tmp-${randomUUID()}`;
|
|
347
396
|
const stream = createWriteStream(tmpPath, { mode: 0o600 });
|
|
348
397
|
const cipher = createOfflineVideoCipher({ key, iv });
|
|
398
|
+
const progressTracker = createProgressTrackingTransform({
|
|
399
|
+
onProgress: (bytesDownloaded) => {
|
|
400
|
+
emitDownloadProgress({
|
|
401
|
+
playbackId,
|
|
402
|
+
bytesDownloaded,
|
|
403
|
+
totalBytes,
|
|
404
|
+
status: 'downloading',
|
|
405
|
+
});
|
|
406
|
+
},
|
|
407
|
+
});
|
|
349
408
|
const webStream = response.body;
|
|
350
|
-
await pipeline(Readable.from(webStream), cipher, stream);
|
|
409
|
+
await pipeline(Readable.from(webStream), progressTracker, cipher, stream);
|
|
351
410
|
await fs.rename(tmpPath, filePath);
|
|
352
411
|
const stat = await fs.stat(filePath);
|
|
412
|
+
emitDownloadProgress({
|
|
413
|
+
playbackId,
|
|
414
|
+
bytesDownloaded: stat.size,
|
|
415
|
+
totalBytes: stat.size,
|
|
416
|
+
status: 'complete',
|
|
417
|
+
});
|
|
418
|
+
log.info('Mux download complete', {
|
|
419
|
+
playbackId,
|
|
420
|
+
url,
|
|
421
|
+
size: stat.size,
|
|
422
|
+
});
|
|
353
423
|
return { size: stat.size };
|
|
354
424
|
}
|
|
425
|
+
emitDownloadProgress({
|
|
426
|
+
playbackId,
|
|
427
|
+
bytesDownloaded: 0,
|
|
428
|
+
totalBytes: null,
|
|
429
|
+
status: 'error',
|
|
430
|
+
});
|
|
355
431
|
throw lastError ?? new Error(`Unable to download video ${playbackId}`);
|
|
356
432
|
}
|
|
357
433
|
async function runOfflineVideoDownloads({ videos, index, keyInfo, workshop, resolution, }) {
|
|
@@ -362,6 +438,10 @@ async function runOfflineVideoDownloads({ videos, index, keyInfo, workshop, reso
|
|
|
362
438
|
title: video.title,
|
|
363
439
|
};
|
|
364
440
|
downloadState.updatedAt = updatedAt;
|
|
441
|
+
log.info('Downloading offline video', {
|
|
442
|
+
playbackId: video.playbackId,
|
|
443
|
+
title: video.title,
|
|
444
|
+
});
|
|
365
445
|
const iv = createOfflineVideoIv();
|
|
366
446
|
const entry = {
|
|
367
447
|
playbackId: video.playbackId,
|
|
@@ -492,6 +572,10 @@ export async function startOfflineVideoDownload({ request, } = {}) {
|
|
|
492
572
|
allowUserIdUpdate: true,
|
|
493
573
|
});
|
|
494
574
|
if (!keyInfo) {
|
|
575
|
+
log.warn('Offline video download unavailable: missing key info', {
|
|
576
|
+
available: videos.length,
|
|
577
|
+
unavailable,
|
|
578
|
+
});
|
|
495
579
|
return {
|
|
496
580
|
state: downloadState,
|
|
497
581
|
available: videos.length,
|
|
@@ -531,6 +615,11 @@ export async function startOfflineVideoDownload({ request, } = {}) {
|
|
|
531
615
|
current: null,
|
|
532
616
|
errors: [],
|
|
533
617
|
};
|
|
618
|
+
log.info('Offline video downloads queued', {
|
|
619
|
+
queued: downloads.length,
|
|
620
|
+
skipped: alreadyDownloaded,
|
|
621
|
+
unavailable,
|
|
622
|
+
});
|
|
534
623
|
if (downloads.length > 0) {
|
|
535
624
|
const resolution = await getOfflineVideoDownloadResolution();
|
|
536
625
|
void runOfflineVideoDownloads({
|
|
@@ -554,6 +643,7 @@ export async function startOfflineVideoDownload({ request, } = {}) {
|
|
|
554
643
|
};
|
|
555
644
|
}
|
|
556
645
|
export async function downloadOfflineVideo({ playbackId, title, url, }) {
|
|
646
|
+
log.info('Offline video download requested', { playbackId, title, url });
|
|
557
647
|
const workshop = getWorkshopIdentity();
|
|
558
648
|
const index = await readOfflineVideoIndex();
|
|
559
649
|
const authInfo = await getAuthInfo();
|
|
@@ -561,8 +651,16 @@ export async function downloadOfflineVideo({ playbackId, title, url, }) {
|
|
|
561
651
|
userId: authInfo?.id ?? null,
|
|
562
652
|
allowUserIdUpdate: true,
|
|
563
653
|
});
|
|
564
|
-
if (!keyInfo)
|
|
565
|
-
|
|
654
|
+
if (!keyInfo) {
|
|
655
|
+
log.warn('Offline video download failed: missing key info', {
|
|
656
|
+
playbackId,
|
|
657
|
+
});
|
|
658
|
+
const message = `Unable to download "${title}". Try again later.`;
|
|
659
|
+
return {
|
|
660
|
+
status: 'error',
|
|
661
|
+
message,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
566
664
|
const resolution = await getOfflineVideoDownloadResolution();
|
|
567
665
|
const existing = index[playbackId];
|
|
568
666
|
if (existing?.status === 'ready' &&
|
|
@@ -606,18 +704,21 @@ export async function downloadOfflineVideo({ playbackId, title, url, }) {
|
|
|
606
704
|
updatedAt: new Date().toISOString(),
|
|
607
705
|
};
|
|
608
706
|
await writeOfflineVideoIndex(index);
|
|
707
|
+
log.info('Offline video download complete', { playbackId, size });
|
|
609
708
|
return { status: 'downloaded' };
|
|
610
709
|
}
|
|
611
710
|
catch (error) {
|
|
612
|
-
const
|
|
711
|
+
const detailedMessage = error instanceof Error ? error.message : 'Download failed';
|
|
712
|
+
const message = `Failed to download "${title}". Please try again.`;
|
|
713
|
+
log.error(`Offline video download failed for ${playbackId}`, error);
|
|
613
714
|
index[playbackId] = {
|
|
614
715
|
...entry,
|
|
615
716
|
status: 'error',
|
|
616
|
-
error:
|
|
717
|
+
error: detailedMessage,
|
|
617
718
|
updatedAt: new Date().toISOString(),
|
|
618
719
|
};
|
|
619
720
|
await writeOfflineVideoIndex(index);
|
|
620
|
-
return { status: 'error' };
|
|
721
|
+
return { status: 'error', message };
|
|
621
722
|
}
|
|
622
723
|
}
|
|
623
724
|
export async function deleteOfflineVideo(playbackId, options) {
|