@epic-web/workshop-utils 6.56.0 → 6.57.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;
@@ -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,6 +351,12 @@ 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) {
335
361
  const response = await fetch(url).catch((error) => {
336
362
  lastError = error;
@@ -342,16 +368,49 @@ async function downloadMuxVideo({ playbackId, filePath, key, iv, resolution, })
342
368
  lastError = new Error(`Failed to download ${playbackId} from ${url} (${response.status})`);
343
369
  continue;
344
370
  }
371
+ // Get content-length if available for progress tracking
372
+ const contentLengthHeader = response.headers.get('content-length');
373
+ const totalBytes = contentLengthHeader
374
+ ? parseInt(contentLengthHeader, 10)
375
+ : null;
376
+ emitDownloadProgress({
377
+ playbackId,
378
+ bytesDownloaded: 0,
379
+ totalBytes,
380
+ status: 'downloading',
381
+ });
345
382
  await ensureOfflineVideoDir();
346
383
  const tmpPath = `${filePath}.tmp-${randomUUID()}`;
347
384
  const stream = createWriteStream(tmpPath, { mode: 0o600 });
348
385
  const cipher = createOfflineVideoCipher({ key, iv });
386
+ const progressTracker = createProgressTrackingTransform({
387
+ onProgress: (bytesDownloaded) => {
388
+ emitDownloadProgress({
389
+ playbackId,
390
+ bytesDownloaded,
391
+ totalBytes,
392
+ status: 'downloading',
393
+ });
394
+ },
395
+ });
349
396
  const webStream = response.body;
350
- await pipeline(Readable.from(webStream), cipher, stream);
397
+ await pipeline(Readable.from(webStream), progressTracker, cipher, stream);
351
398
  await fs.rename(tmpPath, filePath);
352
399
  const stat = await fs.stat(filePath);
400
+ emitDownloadProgress({
401
+ playbackId,
402
+ bytesDownloaded: stat.size,
403
+ totalBytes: stat.size,
404
+ status: 'complete',
405
+ });
353
406
  return { size: stat.size };
354
407
  }
408
+ emitDownloadProgress({
409
+ playbackId,
410
+ bytesDownloaded: 0,
411
+ totalBytes: null,
412
+ status: 'error',
413
+ });
355
414
  throw lastError ?? new Error(`Unable to download video ${playbackId}`);
356
415
  }
357
416
  async function runOfflineVideoDownloads({ videos, index, keyInfo, workshop, resolution, }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epic-web/workshop-utils",
3
- "version": "6.56.0",
3
+ "version": "6.57.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },