@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
- return { status: 'error' };
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 message = error instanceof Error ? error.message : 'Download failed';
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: message,
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) {
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.58.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },