@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;
|
package/dist/epic-api.server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
362
|
-
const urls =
|
|
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
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
|
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
|
-
|
|
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:
|
|
748
|
+
available: downloadableVideos.length,
|
|
644
749
|
unavailable,
|
|
645
750
|
});
|
|
646
751
|
return {
|
|
647
752
|
state: downloadState,
|
|
648
|
-
available:
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
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",
|