@epic-web/workshop-utils 6.73.0 → 6.74.1

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,6 +1,42 @@
1
1
  import { z } from 'zod';
2
2
  import { type Timings } from "./timing.server.js";
3
+ declare const EpicVideoMetadataSchema: z.ZodObject<{
4
+ playbackId: z.ZodString;
5
+ assetId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
6
+ status: z.ZodOptional<z.ZodNullable<z.ZodString>>;
7
+ duration: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
8
+ downloads: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
9
+ quality: z.ZodString;
10
+ url: z.ZodString;
11
+ width: z.ZodOptional<z.ZodNumber>;
12
+ height: z.ZodOptional<z.ZodNumber>;
13
+ bitrate: z.ZodOptional<z.ZodNumber>;
14
+ filesize: z.ZodOptional<z.ZodNumber>;
15
+ }, z.core.$strip>>>>;
16
+ }, z.core.$strip>;
3
17
  export type EpicVideoInfos = Record<string, Awaited<ReturnType<typeof getEpicVideoInfo>>>;
18
+ export type EpicVideoMetadata = z.infer<typeof EpicVideoMetadataSchema>;
19
+ export declare function normalizeVideoApiHost(host: string): string;
20
+ export declare function getEpicVideoMetadata({ playbackId, host, accessToken, request, timings, }: {
21
+ playbackId: string;
22
+ host: string;
23
+ accessToken?: string;
24
+ request?: Request;
25
+ timings?: Timings;
26
+ }): Promise<{
27
+ playbackId: string;
28
+ assetId?: string | null | undefined;
29
+ status?: string | null | undefined;
30
+ duration?: number | null | undefined;
31
+ downloads?: {
32
+ quality: string;
33
+ url: string;
34
+ width?: number | undefined;
35
+ height?: number | undefined;
36
+ bitrate?: number | undefined;
37
+ filesize?: number | undefined;
38
+ }[] | null | undefined;
39
+ } | null>;
4
40
  export declare function getEpicVideoInfos(epicWebUrls?: Array<string> | null, { request, timings }?: {
5
41
  request?: Request;
6
42
  timings?: Timings;
@@ -13,6 +49,11 @@ declare function getEpicVideoInfo({ epicVideoEmbed, accessToken, request, timing
13
49
  }): Promise<{
14
50
  transcript: string;
15
51
  muxPlaybackId: string;
52
+ downloadsAvailable: boolean;
53
+ downloadSizes: {
54
+ quality: string;
55
+ size: number | null;
56
+ }[];
16
57
  status: "success";
17
58
  statusCode: number;
18
59
  statusText: string;
@@ -43,6 +43,25 @@ const EpicVideoInfoSchema = z
43
43
  }
44
44
  return data;
45
45
  });
46
+ const EpicVideoDownloadSchema = z.object({
47
+ quality: z.string(),
48
+ url: z.string(),
49
+ width: z.number().optional(),
50
+ height: z.number().optional(),
51
+ bitrate: z.number().optional(),
52
+ filesize: z.number().optional(),
53
+ });
54
+ const EpicVideoDownloadSizeSchema = z.object({
55
+ quality: z.string(),
56
+ size: z.number().nullable(),
57
+ });
58
+ const EpicVideoMetadataSchema = z.object({
59
+ playbackId: z.string(),
60
+ assetId: z.string().nullable().optional(),
61
+ status: z.string().nullable().optional(),
62
+ duration: z.number().nullable().optional(),
63
+ downloads: z.array(EpicVideoDownloadSchema).nullable().optional(),
64
+ });
46
65
  function hmsToSeconds(str) {
47
66
  const p = str.split(':');
48
67
  let s = 0;
@@ -65,6 +84,8 @@ const CachedEpicVideoInfoSchema = z
65
84
  muxPlaybackId: z.string(),
66
85
  duration: z.number().nullable().optional(),
67
86
  durationEstimate: z.number().nullable().optional(),
87
+ downloadsAvailable: z.boolean().optional().default(false),
88
+ downloadSizes: z.array(EpicVideoDownloadSizeSchema).optional().default([]),
68
89
  status: z.literal('success'),
69
90
  statusCode: z.number(),
70
91
  statusText: z.string(),
@@ -85,7 +106,62 @@ const CachedEpicVideoInfoSchema = z
85
106
  }))
86
107
  .or(z.null());
87
108
  const videoInfoLog = log.logger('video-info');
109
+ const videoMetadataLog = log.logger('video-metadata');
88
110
  const EPIC_VIDEO_INFO_CONCURRENCY = 6;
111
+ export function normalizeVideoApiHost(host) {
112
+ if (host === 'epicweb.dev')
113
+ return 'www.epicweb.dev';
114
+ if (host === 'epicreact.dev')
115
+ return 'www.epicreact.dev';
116
+ if (host === 'epicai.pro')
117
+ return 'www.epicai.pro';
118
+ return host;
119
+ }
120
+ export async function getEpicVideoMetadata({ playbackId, host, accessToken, request, timings, }) {
121
+ if (getEnv().EPICSHOP_DEPLOYED)
122
+ return null;
123
+ const normalizedHost = normalizeVideoApiHost(host);
124
+ const key = `epic-video-metadata:${normalizedHost}:${playbackId}`;
125
+ return cachified({
126
+ key,
127
+ request,
128
+ cache: epicApiCache,
129
+ timings,
130
+ ttl: 1000 * 60 * 60,
131
+ swr: 1000 * 60 * 60 * 24 * 365 * 10,
132
+ offlineFallbackValue: null,
133
+ checkValue: EpicVideoMetadataSchema.nullable(),
134
+ async getFreshValue(context) {
135
+ const apiUrl = `https://${normalizedHost}/api/video/${encodeURIComponent(playbackId)}`;
136
+ videoMetadataLog(`making video metadata request to: ${apiUrl}`);
137
+ const response = await fetch(apiUrl, accessToken
138
+ ? { headers: { authorization: `Bearer ${accessToken}` } }
139
+ : undefined).catch((e) => new Response(getErrorMessage(e), { status: 500 }));
140
+ videoMetadataLog(`video metadata response: ${response.status} ${response.statusText}`);
141
+ if (!response.ok) {
142
+ context.metadata.ttl = 1000 * 2;
143
+ context.metadata.swr = 0;
144
+ return null;
145
+ }
146
+ const rawInfo = await response.json();
147
+ const parsedInfo = EpicVideoMetadataSchema.safeParse(rawInfo);
148
+ if (parsedInfo.success) {
149
+ return parsedInfo.data;
150
+ }
151
+ context.metadata.ttl = 1000 * 2;
152
+ context.metadata.swr = 0;
153
+ videoMetadataLog.error(`video metadata parsing failed for ${playbackId}`, {
154
+ host: normalizedHost,
155
+ rawInfo,
156
+ parseError: parsedInfo.error,
157
+ });
158
+ return null;
159
+ },
160
+ }).catch((e) => {
161
+ videoMetadataLog.error(`failed to fetch video metadata for ${playbackId}:`, e);
162
+ return null;
163
+ });
164
+ }
89
165
  export async function getEpicVideoInfos(epicWebUrls, { request, timings } = {}) {
90
166
  if (!epicWebUrls) {
91
167
  videoInfoLog.warn('no epic web URLs provided, returning empty object');
@@ -158,12 +234,33 @@ async function getEpicVideoInfo({ epicVideoEmbed, accessToken, request, timings,
158
234
  }
159
235
  const infoResult = EpicVideoInfoSchema.safeParse(rawInfo);
160
236
  if (infoResult.success) {
237
+ const metadata = await getEpicVideoMetadata({
238
+ playbackId: infoResult.data.muxPlaybackId,
239
+ host: epicUrl.host,
240
+ accessToken,
241
+ request,
242
+ timings,
243
+ });
244
+ const duration = metadata?.duration ?? infoResult.data.duration;
245
+ const downloadSizes = metadata?.downloads
246
+ ?.filter((download) => Boolean(download.url))
247
+ .map((download) => ({
248
+ quality: download.quality,
249
+ size: typeof download.filesize === 'number' &&
250
+ Number.isFinite(download.filesize)
251
+ ? download.filesize
252
+ : null,
253
+ })) ?? [];
254
+ const downloadsAvailable = downloadSizes.length > 0;
161
255
  videoInfoLog(`successfully parsed video info for ${epicVideoEmbed}`);
162
256
  return {
163
257
  status: 'success',
164
258
  statusCode: status,
165
259
  statusText,
166
260
  ...infoResult.data,
261
+ duration,
262
+ downloadsAvailable,
263
+ downloadSizes,
167
264
  };
168
265
  }
169
266
  else {
@@ -0,0 +1,7 @@
1
+ export declare const offlineVideoDownloadResolutions: readonly ["best", "high", "medium", "low"];
2
+ export type OfflineVideoDownloadResolution = (typeof offlineVideoDownloadResolutions)[number];
3
+ export declare const videoDownloadQualityOrder: Record<OfflineVideoDownloadResolution, Array<string>>;
4
+ export declare function getPreferredDownloadSize(downloadSizes: Array<{
5
+ quality: string;
6
+ size: number | null;
7
+ }>, resolution: OfflineVideoDownloadResolution): number | null;
@@ -0,0 +1,27 @@
1
+ export const offlineVideoDownloadResolutions = [
2
+ 'best',
3
+ 'high',
4
+ 'medium',
5
+ 'low',
6
+ ];
7
+ export const videoDownloadQualityOrder = {
8
+ best: ['source', 'highest', 'high', 'medium', 'low'],
9
+ high: ['high', 'medium', 'low', 'highest', 'source'],
10
+ medium: ['medium', 'low', 'high', 'highest', 'source'],
11
+ low: ['low', 'medium', 'high', 'highest', 'source'],
12
+ };
13
+ export function getPreferredDownloadSize(downloadSizes, resolution) {
14
+ if (downloadSizes.length === 0)
15
+ return null;
16
+ const sizeByQuality = new Map(downloadSizes.map((download) => [
17
+ download.quality.toLowerCase(),
18
+ download.size,
19
+ ]));
20
+ const order = videoDownloadQualityOrder[resolution] ?? videoDownloadQualityOrder.best;
21
+ for (const quality of order) {
22
+ const size = sizeByQuality.get(quality);
23
+ if (typeof size === 'number' && Number.isFinite(size) && size > 0)
24
+ return size;
25
+ }
26
+ return (downloadSizes.find((download) => typeof download.size === 'number' && Number.isFinite(download.size))?.size ?? null);
27
+ }
@@ -26,6 +26,8 @@ export type OfflineVideoSummary = {
26
26
  totalVideos: number;
27
27
  downloadedVideos: number;
28
28
  unavailableVideos: number;
29
+ notDownloadableVideos: number;
30
+ remainingDownloadBytes: number;
29
31
  totalBytes: number;
30
32
  downloadState: OfflineVideoDownloadState;
31
33
  };
@@ -34,6 +36,7 @@ export type OfflineVideoStartResult = {
34
36
  available: number;
35
37
  queued: number;
36
38
  unavailable: number;
39
+ notDownloadable: number;
37
40
  alreadyDownloaded: number;
38
41
  };
39
42
  export type OfflineVideoAdminEntry = {
@@ -8,16 +8,11 @@ import { getApps, getExercises, getWorkshopFinished, getWorkshopInstructions, }
8
8
  import { getWorkshopConfig } from "./config.server.js";
9
9
  import { resolvePrimaryDir } from "./data-storage.server.js";
10
10
  import { getAuthInfo, getClientId, getPreferences } from "./db.server.js";
11
- import { getEpicVideoInfos } from "./epic-api.server.js";
11
+ import { getEpicVideoInfos, getEpicVideoMetadata, normalizeVideoApiHost, } from "./epic-api.server.js";
12
12
  import { getEnv } from "./init-env.js";
13
13
  import { logger } from "./logger.js";
14
14
  import { OFFLINE_VIDEO_CRYPTO_VERSION, createOfflineVideoCipher, createOfflineVideoDecipher, createOfflineVideoIv, createOfflineVideoSalt, decodeOfflineVideoIv, deriveOfflineVideoKey, encodeOfflineVideoIv, getCryptoRange, incrementIv, } from "./offline-video-crypto.server.js";
15
- const offlineVideoDownloadResolutions = [
16
- 'best',
17
- 'high',
18
- 'medium',
19
- 'low',
20
- ];
15
+ import { getPreferredDownloadSize, offlineVideoDownloadResolutions, videoDownloadQualityOrder, } from "./offline-video-utils.js";
21
16
  function isOfflineVideoDownloadResolution(value) {
22
17
  return (typeof value === 'string' &&
23
18
  offlineVideoDownloadResolutions.includes(value));
@@ -301,19 +296,29 @@ async function getWorkshopVideoCollection({ request, } = {}) {
301
296
  const epicVideoInfos = await getEpicVideoInfos(embedList, { request });
302
297
  const videos = [];
303
298
  let unavailable = 0;
299
+ let notDownloadable = 0;
304
300
  for (const embed of embedList) {
305
301
  const info = epicVideoInfos[embed];
306
302
  if (!info || info.status !== 'success') {
307
303
  unavailable += 1;
308
304
  continue;
309
305
  }
306
+ const downloadSizes = Array.isArray(info.downloadSizes)
307
+ ? info.downloadSizes
308
+ : [];
309
+ const downloadable = info.downloadsAvailable === true;
310
+ if (!downloadable) {
311
+ notDownloadable += 1;
312
+ }
310
313
  videos.push({
311
314
  playbackId: info.muxPlaybackId,
312
315
  title: info.title ?? embed,
313
316
  url: embed,
317
+ downloadable,
318
+ downloadSizes,
314
319
  });
315
320
  }
316
- return { videos, totalEmbeds: embedUrls.size, unavailable };
321
+ return { videos, totalEmbeds: embedUrls.size, unavailable, notDownloadable };
317
322
  }
318
323
  async function getOfflineVideoDownloadResolution() {
319
324
  const preferences = await getPreferences();
@@ -323,21 +328,96 @@ async function getOfflineVideoDownloadResolution() {
323
328
  }
324
329
  return 'best';
325
330
  }
326
- const muxMp4Variants = {
327
- high: (playbackId) => `https://stream.mux.com/${playbackId}/high.mp4`,
328
- medium: (playbackId) => `https://stream.mux.com/${playbackId}/medium.mp4`,
329
- low: (playbackId) => `https://stream.mux.com/${playbackId}/low.mp4`,
330
- source: (playbackId) => `https://stream.mux.com/${playbackId}.mp4`,
331
- };
332
- const muxResolutionOrder = {
333
- best: ['source', 'high', 'medium', 'low'],
334
- high: ['high', 'medium', 'low', 'source'],
335
- medium: ['medium', 'low', 'high', 'source'],
336
- low: ['low', 'medium', 'high', 'source'],
337
- };
338
- function getMuxMp4Urls(playbackId, resolution) {
339
- const order = muxResolutionOrder[resolution] ?? muxResolutionOrder.best;
340
- return order.map((variant) => muxMp4Variants[variant](playbackId));
331
+ const knownDownloadQualities = new Set([
332
+ 'source',
333
+ 'highest',
334
+ 'high',
335
+ 'medium',
336
+ 'low',
337
+ ]);
338
+ function normalizeDownloadQuality(quality) {
339
+ const normalized = quality.toLowerCase();
340
+ return knownDownloadQualities.has(normalized)
341
+ ? normalized
342
+ : null;
343
+ }
344
+ function sortVideoDownloads(downloads, resolution) {
345
+ const order = videoDownloadQualityOrder[resolution] ?? videoDownloadQualityOrder.best;
346
+ const qualityRank = new Map(order.map((quality, index) => [quality, index]));
347
+ return [...downloads].sort((a, b) => {
348
+ const aQuality = normalizeDownloadQuality(a.quality);
349
+ const bQuality = normalizeDownloadQuality(b.quality);
350
+ const aRank = aQuality
351
+ ? (qualityRank.get(aQuality) ?? order.length)
352
+ : order.length + 1;
353
+ const bRank = bQuality
354
+ ? (qualityRank.get(bQuality) ?? order.length)
355
+ : order.length + 1;
356
+ if (aRank !== bRank)
357
+ return aRank - bRank;
358
+ const aWidth = a.width ?? 0;
359
+ const bWidth = b.width ?? 0;
360
+ if (aWidth !== bWidth)
361
+ return bWidth - aWidth;
362
+ const aBitrate = a.bitrate ?? 0;
363
+ const bBitrate = b.bitrate ?? 0;
364
+ return bBitrate - aBitrate;
365
+ });
366
+ }
367
+ function getVideoApiHost(videoUrl) {
368
+ try {
369
+ const host = new URL(videoUrl).host;
370
+ return normalizeVideoApiHost(host);
371
+ }
372
+ catch (error) {
373
+ log.warn('Unable to parse video URL for metadata', {
374
+ videoUrl,
375
+ error: formatDownloadError(error),
376
+ });
377
+ return null;
378
+ }
379
+ }
380
+ async function getVideoDownloadUrls({ playbackId, videoUrl, resolution, accessToken, }) {
381
+ const host = getVideoApiHost(videoUrl);
382
+ if (!host) {
383
+ log.warn('No video API host found for offline download', {
384
+ playbackId,
385
+ videoUrl,
386
+ });
387
+ return [];
388
+ }
389
+ const metadata = await getEpicVideoMetadata({
390
+ playbackId,
391
+ host,
392
+ accessToken,
393
+ });
394
+ if (!metadata) {
395
+ log.warn('Video metadata unavailable for offline download', {
396
+ playbackId,
397
+ videoUrl,
398
+ });
399
+ return [];
400
+ }
401
+ const downloads = metadata.downloads ?? [];
402
+ if (downloads.length === 0) {
403
+ log.warn('Video metadata missing downloads for offline download', {
404
+ playbackId,
405
+ videoUrl,
406
+ status: metadata.status,
407
+ });
408
+ return [];
409
+ }
410
+ const ordered = sortVideoDownloads(downloads, resolution);
411
+ const urls = ordered.map((download) => download.url).filter(Boolean);
412
+ if (urls.length === 0) {
413
+ log.warn('Video metadata has downloads but all URLs are invalid', {
414
+ playbackId,
415
+ videoUrl,
416
+ downloadsCount: downloads.length,
417
+ });
418
+ return [];
419
+ }
420
+ return urls;
341
421
  }
342
422
  async function isOfflineVideoReady(index, playbackId, keyId, cryptoVersion, workshop) {
343
423
  const entry = index[playbackId];
@@ -358,8 +438,16 @@ async function isOfflineVideoReady(index, playbackId, keyId, cryptoVersion, work
358
438
  return false;
359
439
  }
360
440
  }
361
- async function downloadMuxVideo({ playbackId, filePath, key, iv, resolution, }) {
362
- const urls = getMuxMp4Urls(playbackId, resolution);
441
+ async function downloadVideo({ playbackId, filePath, key, iv, resolution, videoUrl, accessToken, }) {
442
+ const urls = await getVideoDownloadUrls({
443
+ playbackId,
444
+ videoUrl,
445
+ resolution,
446
+ accessToken,
447
+ });
448
+ if (urls.length === 0) {
449
+ throw new Error(`No download URLs available for ${playbackId}`);
450
+ }
363
451
  let lastError = null;
364
452
  const attempts = [];
365
453
  emitDownloadProgress({
@@ -369,11 +457,11 @@ async function downloadMuxVideo({ playbackId, filePath, key, iv, resolution, })
369
457
  status: 'downloading',
370
458
  });
371
459
  for (const url of urls) {
372
- log.info('Attempting mux download', { playbackId, url });
460
+ log.info('Attempting video download', { playbackId, url });
373
461
  const response = await fetch(url).catch((error) => {
374
462
  const message = error instanceof Error ? error.message : String(error);
375
463
  lastError = error instanceof Error ? error : new Error(message);
376
- log.warn('Mux download request failed', {
464
+ log.warn('Video download request failed', {
377
465
  playbackId,
378
466
  url,
379
467
  message,
@@ -385,7 +473,7 @@ async function downloadMuxVideo({ playbackId, filePath, key, iv, resolution, })
385
473
  continue;
386
474
  if (!response.ok || !response.body) {
387
475
  lastError = new Error(`Failed to download ${playbackId} from ${url} (${response.status})`);
388
- log.warn('Mux download response not ok', {
476
+ log.warn('Video download response not ok', {
389
477
  playbackId,
390
478
  url,
391
479
  status: response.status,
@@ -435,7 +523,7 @@ async function downloadMuxVideo({ playbackId, filePath, key, iv, resolution, })
435
523
  totalBytes: stat.size,
436
524
  status: 'complete',
437
525
  });
438
- log.info('Mux download complete', {
526
+ log.info('Video download complete', {
439
527
  playbackId,
440
528
  url,
441
529
  size: stat.size,
@@ -448,7 +536,7 @@ async function downloadMuxVideo({ playbackId, filePath, key, iv, resolution, })
448
536
  totalBytes: null,
449
537
  status: 'error',
450
538
  });
451
- log.error('Mux download failed', {
539
+ log.error('Video download failed', {
452
540
  playbackId,
453
541
  resolution,
454
542
  attempts,
@@ -456,7 +544,7 @@ async function downloadMuxVideo({ playbackId, filePath, key, iv, resolution, })
456
544
  });
457
545
  throw lastError ?? new Error(`Unable to download video ${playbackId}`);
458
546
  }
459
- async function runOfflineVideoDownloads({ videos, index, keyInfo, workshop, resolution, }) {
547
+ async function runOfflineVideoDownloads({ videos, index, keyInfo, workshop, resolution, accessToken, }) {
460
548
  for (const video of videos) {
461
549
  const updatedAt = new Date().toISOString();
462
550
  downloadState.current = {
@@ -484,12 +572,14 @@ async function runOfflineVideoDownloads({ videos, index, keyInfo, workshop, reso
484
572
  index[video.playbackId] = entry;
485
573
  await writeOfflineVideoIndex(index);
486
574
  try {
487
- const { size } = await downloadMuxVideo({
575
+ const { size } = await downloadVideo({
488
576
  playbackId: video.playbackId,
489
577
  filePath: path.join(getOfflineVideoDir(), entry.fileName),
490
578
  key: keyInfo.key,
491
579
  iv,
492
580
  resolution,
581
+ videoUrl: video.url,
582
+ accessToken,
493
583
  });
494
584
  index[video.playbackId] = {
495
585
  ...entry,
@@ -536,29 +626,41 @@ export function getOfflineVideoDownloadState() {
536
626
  }
537
627
  export async function getOfflineVideoSummary({ request, } = {}) {
538
628
  const workshop = getWorkshopIdentity();
539
- const { videos, unavailable } = await getWorkshopVideoCollection({ request });
629
+ const { videos, unavailable, notDownloadable } = await getWorkshopVideoCollection({ request });
540
630
  const index = await readOfflineVideoIndex();
631
+ const resolution = await getOfflineVideoDownloadResolution();
541
632
  const keyInfo = await getOfflineVideoKeyInfo({
542
633
  userId: null,
543
634
  allowUserIdUpdate: false,
544
635
  });
545
636
  let downloadedVideos = 0;
546
637
  let totalBytes = 0;
638
+ let remainingDownloadBytes = 0;
547
639
  for (const video of videos) {
548
640
  const entry = index[video.playbackId];
549
- if (entry?.status === 'ready' &&
550
- keyInfo &&
641
+ const isDownloaded = Boolean(entry?.status === 'ready' &&
642
+ keyInfo?.keyId &&
551
643
  entry.keyId === keyInfo.keyId &&
552
644
  entry.cryptoVersion === keyInfo.config.version &&
553
- hasWorkshop(entry, workshop.id)) {
645
+ hasWorkshop(entry, workshop.id));
646
+ if (isDownloaded && entry) {
554
647
  downloadedVideos += 1;
555
648
  totalBytes += entry.size ?? 0;
649
+ continue;
650
+ }
651
+ if (!video.downloadable)
652
+ continue;
653
+ const size = getPreferredDownloadSize(video.downloadSizes, resolution);
654
+ if (typeof size === 'number') {
655
+ remainingDownloadBytes += size;
556
656
  }
557
657
  }
558
658
  return {
559
659
  totalVideos: videos.length,
560
660
  downloadedVideos,
561
661
  unavailableVideos: unavailable,
662
+ notDownloadableVideos: notDownloadable,
663
+ remainingDownloadBytes,
562
664
  totalBytes,
563
665
  downloadState,
564
666
  };
@@ -606,6 +708,7 @@ export async function startOfflineVideoDownload({ request, } = {}) {
606
708
  available: 0,
607
709
  queued: 0,
608
710
  unavailable: 0,
711
+ notDownloadable: 0,
609
712
  alreadyDownloaded: 0,
610
713
  };
611
714
  }
@@ -615,6 +718,7 @@ export async function startOfflineVideoDownload({ request, } = {}) {
615
718
  available: downloadState.total + downloadState.skipped,
616
719
  queued: downloadState.total,
617
720
  unavailable: 0,
721
+ notDownloadable: 0,
618
722
  alreadyDownloaded: downloadState.skipped,
619
723
  };
620
724
  }
@@ -631,7 +735,8 @@ export async function startOfflineVideoDownload({ request, } = {}) {
631
735
  errors: [],
632
736
  };
633
737
  const workshop = getWorkshopIdentity();
634
- const { videos, unavailable } = await getWorkshopVideoCollection({ request });
738
+ const { videos, unavailable, notDownloadable } = await getWorkshopVideoCollection({ request });
739
+ const downloadableVideos = videos.filter((video) => video.downloadable);
635
740
  const index = await readOfflineVideoIndex();
636
741
  const authInfo = await getAuthInfo();
637
742
  const keyInfo = await getOfflineVideoKeyInfo({
@@ -640,20 +745,21 @@ export async function startOfflineVideoDownload({ request, } = {}) {
640
745
  });
641
746
  if (!keyInfo) {
642
747
  log.warn('Offline video download unavailable: missing key info', {
643
- available: videos.length,
748
+ available: downloadableVideos.length,
644
749
  unavailable,
645
750
  });
646
751
  return {
647
752
  state: downloadState,
648
- available: videos.length,
753
+ available: downloadableVideos.length,
649
754
  queued: 0,
650
755
  unavailable,
756
+ notDownloadable,
651
757
  alreadyDownloaded: 0,
652
758
  };
653
759
  }
654
760
  const downloads = [];
655
761
  let alreadyDownloaded = 0;
656
- for (const video of videos) {
762
+ for (const video of downloadableVideos) {
657
763
  const entry = index[video.playbackId];
658
764
  if (entry?.status === 'ready' &&
659
765
  entry.keyId === keyInfo.keyId &&
@@ -695,6 +801,7 @@ export async function startOfflineVideoDownload({ request, } = {}) {
695
801
  keyInfo,
696
802
  workshop,
697
803
  resolution,
804
+ accessToken: authInfo?.tokenSet.access_token,
698
805
  }).catch((error) => {
699
806
  log.error('Offline video downloads failed', error);
700
807
  downloadState.status = 'error';
@@ -703,9 +810,10 @@ export async function startOfflineVideoDownload({ request, } = {}) {
703
810
  }
704
811
  return {
705
812
  state: downloadState,
706
- available: videos.length,
813
+ available: downloadableVideos.length,
707
814
  queued: downloads.length,
708
815
  unavailable,
816
+ notDownloadable,
709
817
  alreadyDownloaded,
710
818
  };
711
819
  }
@@ -757,12 +865,14 @@ export async function downloadOfflineVideo({ playbackId, title, url, }) {
757
865
  index[playbackId] = entry;
758
866
  await writeOfflineVideoIndex(index);
759
867
  try {
760
- const { size } = await downloadMuxVideo({
868
+ const { size } = await downloadVideo({
761
869
  playbackId,
762
870
  filePath: path.join(getOfflineVideoDir(), entry.fileName),
763
871
  key: keyInfo.key,
764
872
  iv,
765
873
  resolution,
874
+ videoUrl: url,
875
+ accessToken: authInfo?.tokenSet.access_token,
766
876
  });
767
877
  index[playbackId] = {
768
878
  ...entry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epic-web/workshop-utils",
3
- "version": "6.73.0",
3
+ "version": "6.74.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -32,6 +32,7 @@
32
32
  "./playwright.server": "./src/playwright.server.ts",
33
33
  "./notifications.server": "./src/notifications.server.ts",
34
34
  "./offline-videos.server": "./src/offline-videos.server.ts",
35
+ "./offline-video-utils": "./src/offline-video-utils.ts",
35
36
  "./process-manager.server": "./src/process-manager.server.ts",
36
37
  "./test": "./src/test.ts",
37
38
  "./request-context.server": "./src/request-context.server.ts",
@@ -142,6 +143,11 @@
142
143
  "types": "./dist/offline-videos.server.d.ts",
143
144
  "default": "./dist/offline-videos.server.js"
144
145
  },
146
+ "./offline-video-utils": {
147
+ "import": "./dist/offline-video-utils.js",
148
+ "types": "./dist/offline-video-utils.d.ts",
149
+ "default": "./dist/offline-video-utils.js"
150
+ },
145
151
  "./process-manager.server": {
146
152
  "import": "./dist/process-manager.server.js",
147
153
  "types": "./dist/process-manager.server.d.ts",