@epic-web/workshop-utils 6.54.1 → 6.55.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.
@@ -0,0 +1,37 @@
1
+ export declare const OFFLINE_VIDEO_BLOCK_SIZE = 16;
2
+ export declare const OFFLINE_VIDEO_CRYPTO_VERSION = 1;
3
+ export type OfflineVideoKeyInfo = {
4
+ key: Buffer;
5
+ keyId: string;
6
+ };
7
+ export type OfflineVideoCryptoRange = {
8
+ alignedStart: number;
9
+ alignedEnd: number;
10
+ skipBytes: number;
11
+ takeBytes: number;
12
+ blockIndex: number;
13
+ };
14
+ export declare function createOfflineVideoSalt(): string;
15
+ export declare function createOfflineVideoIv(): NonSharedBuffer;
16
+ export declare function encodeOfflineVideoIv(iv: Buffer): string;
17
+ export declare function decodeOfflineVideoIv(ivBase64: string): Buffer<ArrayBuffer>;
18
+ export declare function deriveOfflineVideoKey({ salt, clientId, userId, version, }: {
19
+ salt: string;
20
+ clientId: string;
21
+ userId: string | null;
22
+ version: number;
23
+ }): OfflineVideoKeyInfo;
24
+ export declare function incrementIv(iv: Buffer, blockIndex: number): Buffer<ArrayBuffer>;
25
+ export declare function getCryptoRange({ start, end, blockSize, }: {
26
+ start: number;
27
+ end: number;
28
+ blockSize?: number;
29
+ }): OfflineVideoCryptoRange;
30
+ export declare function createOfflineVideoCipher({ key, iv, }: {
31
+ key: Buffer;
32
+ iv: Buffer;
33
+ }): import("crypto").Cipheriv;
34
+ export declare function createOfflineVideoDecipher({ key, iv, }: {
35
+ key: Buffer;
36
+ iv: Buffer;
37
+ }): import("crypto").Decipheriv;
@@ -0,0 +1,41 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, } from 'node:crypto';
2
+ export const OFFLINE_VIDEO_BLOCK_SIZE = 16;
3
+ export const OFFLINE_VIDEO_CRYPTO_VERSION = 1;
4
+ export function createOfflineVideoSalt() {
5
+ return randomBytes(32).toString('base64');
6
+ }
7
+ export function createOfflineVideoIv() {
8
+ return randomBytes(OFFLINE_VIDEO_BLOCK_SIZE);
9
+ }
10
+ export function encodeOfflineVideoIv(iv) {
11
+ return iv.toString('base64');
12
+ }
13
+ export function decodeOfflineVideoIv(ivBase64) {
14
+ return Buffer.from(ivBase64, 'base64');
15
+ }
16
+ export function deriveOfflineVideoKey({ salt, clientId, userId, version, }) {
17
+ const keyInput = `${version}:${salt}:${clientId}:${userId ?? 'anonymous'}`;
18
+ const key = createHash('sha256').update(keyInput).digest();
19
+ const keyId = createHash('sha256').update(key).digest('hex').slice(0, 12);
20
+ return { key, keyId };
21
+ }
22
+ export function incrementIv(iv, blockIndex) {
23
+ const base = BigInt(`0x${iv.toString('hex')}`);
24
+ const next = base + BigInt(blockIndex);
25
+ const hex = next.toString(16).padStart(OFFLINE_VIDEO_BLOCK_SIZE * 2, '0');
26
+ return Buffer.from(hex, 'hex');
27
+ }
28
+ export function getCryptoRange({ start, end, blockSize = OFFLINE_VIDEO_BLOCK_SIZE, }) {
29
+ const alignedStart = Math.floor(start / blockSize) * blockSize;
30
+ const alignedEnd = Math.floor(end / blockSize) * blockSize + (blockSize - 1);
31
+ const skipBytes = start - alignedStart;
32
+ const takeBytes = end - start + 1;
33
+ const blockIndex = alignedStart / blockSize;
34
+ return { alignedStart, alignedEnd, skipBytes, takeBytes, blockIndex };
35
+ }
36
+ export function createOfflineVideoCipher({ key, iv, }) {
37
+ return createCipheriv('aes-256-ctr', key, iv);
38
+ }
39
+ export function createOfflineVideoDecipher({ key, iv, }) {
40
+ return createDecipheriv('aes-256-ctr', key, iv);
41
+ }
@@ -0,0 +1,101 @@
1
+ import { Readable } from 'node:stream';
2
+ type OfflineVideoEntryStatus = 'ready' | 'downloading' | 'error';
3
+ export type OfflineVideoDownloadState = {
4
+ status: 'idle' | 'running' | 'completed' | 'error';
5
+ startedAt: string | null;
6
+ updatedAt: string;
7
+ total: number;
8
+ completed: number;
9
+ skipped: number;
10
+ current: {
11
+ playbackId: string;
12
+ title: string;
13
+ } | null;
14
+ errors: Array<{
15
+ playbackId: string;
16
+ title: string;
17
+ error: string;
18
+ }>;
19
+ };
20
+ type WorkshopIdentity = {
21
+ id: string;
22
+ title: string;
23
+ };
24
+ export type OfflineVideoSummary = {
25
+ totalVideos: number;
26
+ downloadedVideos: number;
27
+ unavailableVideos: number;
28
+ totalBytes: number;
29
+ downloadState: OfflineVideoDownloadState;
30
+ };
31
+ export type OfflineVideoStartResult = {
32
+ state: OfflineVideoDownloadState;
33
+ available: number;
34
+ queued: number;
35
+ unavailable: number;
36
+ alreadyDownloaded: number;
37
+ };
38
+ export type OfflineVideoAdminEntry = {
39
+ playbackId: string;
40
+ title: string;
41
+ url: string;
42
+ status: OfflineVideoEntryStatus;
43
+ size: number | null;
44
+ updatedAt: string;
45
+ };
46
+ export type OfflineVideoAdminWorkshop = WorkshopIdentity & {
47
+ totalBytes: number;
48
+ videos: Array<OfflineVideoAdminEntry>;
49
+ };
50
+ export type OfflineVideoAdminSummary = {
51
+ workshops: Array<OfflineVideoAdminWorkshop>;
52
+ };
53
+ export type OfflineVideoAsset = {
54
+ size: number;
55
+ contentType: string;
56
+ createStream: (range?: {
57
+ start: number;
58
+ end: number;
59
+ }) => Readable;
60
+ };
61
+ export declare function getOfflineVideoDownloadState(): OfflineVideoDownloadState;
62
+ export declare function getOfflineVideoSummary({ request, }?: {
63
+ request?: Request;
64
+ }): Promise<OfflineVideoSummary>;
65
+ export declare function startOfflineVideoDownload({ request, }?: {
66
+ request?: Request;
67
+ }): Promise<OfflineVideoStartResult>;
68
+ export declare function downloadOfflineVideo({ playbackId, title, url, }: {
69
+ playbackId: string;
70
+ title: string;
71
+ url: string;
72
+ }): Promise<{
73
+ readonly status: "error";
74
+ } | {
75
+ readonly status: "ready";
76
+ } | {
77
+ readonly status: "downloaded";
78
+ }>;
79
+ export declare function deleteOfflineVideo(playbackId: string, options?: {
80
+ workshopId?: string;
81
+ }): Promise<{
82
+ readonly status: "missing";
83
+ } | {
84
+ readonly status: "removed";
85
+ } | {
86
+ readonly status: "deleted";
87
+ }>;
88
+ export declare function deleteOfflineVideosForWorkshop(): Promise<{
89
+ readonly deletedFiles: number;
90
+ readonly removedEntries: number;
91
+ }>;
92
+ export declare function deleteOfflineVideosForWorkshopId(workshopId: string): Promise<{
93
+ readonly deletedFiles: number;
94
+ readonly removedEntries: number;
95
+ }>;
96
+ export declare function deleteAllOfflineVideos(): Promise<{
97
+ readonly deletedFiles: number;
98
+ }>;
99
+ export declare function getOfflineVideoAdminSummary(): Promise<OfflineVideoAdminSummary>;
100
+ export declare function getOfflineVideoAsset(playbackId: string): Promise<OfflineVideoAsset | null>;
101
+ export {};
@@ -0,0 +1,739 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createReadStream, createWriteStream, promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { Readable, Transform } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { getApps, getExercises, getWorkshopFinished, getWorkshopInstructions, } from "./apps.server.js";
7
+ import { getWorkshopConfig } from "./config.server.js";
8
+ import { resolvePrimaryDir } from "./data-storage.server.js";
9
+ import { getAuthInfo, getClientId } from "./db.server.js";
10
+ import { getEpicVideoInfos } from "./epic-api.server.js";
11
+ import { getEnv } from "./init-env.js";
12
+ import { logger } from "./logger.js";
13
+ import { OFFLINE_VIDEO_CRYPTO_VERSION, createOfflineVideoCipher, createOfflineVideoDecipher, createOfflineVideoIv, createOfflineVideoSalt, decodeOfflineVideoIv, deriveOfflineVideoKey, encodeOfflineVideoIv, getCryptoRange, incrementIv, } from "./offline-video-crypto.server.js";
14
+ const log = logger('epic:offline-videos');
15
+ const offlineVideoDirectoryName = 'offline-videos';
16
+ const offlineVideoIndexFileName = 'index.json';
17
+ const offlineVideoConfigFileName = 'offline-video-config.json';
18
+ let downloadState = {
19
+ status: 'idle',
20
+ startedAt: null,
21
+ updatedAt: new Date().toISOString(),
22
+ total: 0,
23
+ completed: 0,
24
+ skipped: 0,
25
+ current: null,
26
+ errors: [],
27
+ };
28
+ function getOfflineVideoDir() {
29
+ return path.join(resolvePrimaryDir(), offlineVideoDirectoryName);
30
+ }
31
+ function getOfflineVideoIndexPath() {
32
+ return path.join(getOfflineVideoDir(), offlineVideoIndexFileName);
33
+ }
34
+ function getOfflineVideoConfigPath() {
35
+ return path.join(getOfflineVideoDir(), offlineVideoConfigFileName);
36
+ }
37
+ function getWorkshopIdentity() {
38
+ const env = getEnv();
39
+ let title = 'Unknown workshop';
40
+ try {
41
+ title = getWorkshopConfig().title;
42
+ }
43
+ catch {
44
+ // ignore missing workshop config
45
+ }
46
+ return {
47
+ id: env.EPICSHOP_WORKSHOP_INSTANCE_ID || 'unknown',
48
+ title,
49
+ };
50
+ }
51
+ function getEntryWorkshops(entry) {
52
+ const workshops = Array.isArray(entry.workshops)
53
+ ? entry.workshops.filter((workshop) => typeof workshop?.id === 'string' &&
54
+ typeof workshop?.title === 'string')
55
+ : [];
56
+ return workshops;
57
+ }
58
+ function hasWorkshop(entry, workshopId) {
59
+ const workshops = getEntryWorkshops(entry);
60
+ return workshops.some((workshop) => workshop.id === workshopId);
61
+ }
62
+ function ensureWorkshopOnEntry(entry, workshop) {
63
+ const workshops = getEntryWorkshops(entry);
64
+ if (workshops.some((item) => item.id === workshop.id))
65
+ return entry;
66
+ return { ...entry, workshops: [...workshops, workshop] };
67
+ }
68
+ function removeWorkshopFromEntry(entry, workshopId) {
69
+ const workshops = getEntryWorkshops(entry).filter((workshop) => workshop.id !== workshopId);
70
+ return { ...entry, workshops };
71
+ }
72
+ async function ensureOfflineVideoDir() {
73
+ const dir = getOfflineVideoDir();
74
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
75
+ try {
76
+ await fs.chmod(dir, 0o700);
77
+ }
78
+ catch {
79
+ // ignore chmod failures
80
+ }
81
+ }
82
+ function normalizeEmbedUrl(url) {
83
+ return url.endsWith('/') ? url.slice(0, -1) : url;
84
+ }
85
+ function getPlaybackIdHash(playbackId) {
86
+ return createHash('sha256').update(playbackId).digest('hex');
87
+ }
88
+ function getOfflineVideoFileName(playbackId) {
89
+ return `${getPlaybackIdHash(playbackId)}.mp4`;
90
+ }
91
+ function getOfflineVideoFilePath(playbackId) {
92
+ return path.join(getOfflineVideoDir(), getOfflineVideoFileName(playbackId));
93
+ }
94
+ async function readOfflineVideoIndex() {
95
+ const indexPath = getOfflineVideoIndexPath();
96
+ try {
97
+ const json = await fs.readFile(indexPath, 'utf8');
98
+ return JSON.parse(json);
99
+ }
100
+ catch {
101
+ return {};
102
+ }
103
+ }
104
+ async function readOfflineVideoConfig() {
105
+ const configPath = getOfflineVideoConfigPath();
106
+ try {
107
+ const json = await fs.readFile(configPath, 'utf8');
108
+ const parsed = JSON.parse(json);
109
+ if (!parsed)
110
+ return null;
111
+ const version = typeof parsed.version === 'number'
112
+ ? parsed.version
113
+ : OFFLINE_VIDEO_CRYPTO_VERSION;
114
+ if (version !== OFFLINE_VIDEO_CRYPTO_VERSION)
115
+ return null;
116
+ if (typeof parsed.salt !== 'string')
117
+ return null;
118
+ const userId = typeof parsed.userId === 'string' ? parsed.userId : null;
119
+ return { version, salt: parsed.salt, userId };
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ async function writeOfflineVideoIndex(index) {
126
+ await ensureOfflineVideoDir();
127
+ const tmpPath = path.join(getOfflineVideoDir(), `.tmp-${randomUUID()}`);
128
+ await fs.writeFile(tmpPath, JSON.stringify(index, null, 2), { mode: 0o600 });
129
+ await fs.rename(tmpPath, getOfflineVideoIndexPath());
130
+ }
131
+ async function writeOfflineVideoConfig(config) {
132
+ await ensureOfflineVideoDir();
133
+ const tmpPath = path.join(getOfflineVideoDir(), `.tmp-${randomUUID()}`);
134
+ await fs.writeFile(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600 });
135
+ await fs.rename(tmpPath, getOfflineVideoConfigPath());
136
+ }
137
+ async function ensureOfflineVideoConfig({ userId, }) {
138
+ const existing = await readOfflineVideoConfig();
139
+ let config = existing;
140
+ let shouldWrite = false;
141
+ if (!config) {
142
+ config = {
143
+ version: OFFLINE_VIDEO_CRYPTO_VERSION,
144
+ salt: createOfflineVideoSalt(),
145
+ userId: userId ?? null,
146
+ };
147
+ shouldWrite = true;
148
+ }
149
+ else if (config.version !== OFFLINE_VIDEO_CRYPTO_VERSION) {
150
+ config = {
151
+ version: OFFLINE_VIDEO_CRYPTO_VERSION,
152
+ salt: createOfflineVideoSalt(),
153
+ userId: config.userId ?? null,
154
+ };
155
+ shouldWrite = true;
156
+ }
157
+ if (userId && userId !== config.userId) {
158
+ config = {
159
+ version: OFFLINE_VIDEO_CRYPTO_VERSION,
160
+ salt: createOfflineVideoSalt(),
161
+ userId,
162
+ };
163
+ shouldWrite = true;
164
+ }
165
+ if (shouldWrite) {
166
+ await writeOfflineVideoConfig(config);
167
+ }
168
+ return config;
169
+ }
170
+ async function getOfflineVideoKeyInfo({ userId, allowUserIdUpdate, }) {
171
+ const config = allowUserIdUpdate
172
+ ? await ensureOfflineVideoConfig({ userId })
173
+ : await readOfflineVideoConfig();
174
+ if (!config || config.version !== OFFLINE_VIDEO_CRYPTO_VERSION)
175
+ return null;
176
+ const clientId = await getClientId();
177
+ const keyInfo = deriveOfflineVideoKey({
178
+ salt: config.salt,
179
+ clientId,
180
+ userId: config.userId,
181
+ version: config.version,
182
+ });
183
+ return { ...keyInfo, config };
184
+ }
185
+ function createSliceTransform({ skipBytes, takeBytes, }) {
186
+ let skipped = 0;
187
+ let taken = 0;
188
+ return new Transform({
189
+ transform(chunk, _encoding, callback) {
190
+ let buffer = chunk;
191
+ if (skipped < skipBytes) {
192
+ const toSkip = Math.min(skipBytes - skipped, buffer.length);
193
+ buffer = buffer.slice(toSkip);
194
+ skipped += toSkip;
195
+ }
196
+ if (buffer.length === 0 || taken >= takeBytes) {
197
+ return callback();
198
+ }
199
+ const remaining = takeBytes - taken;
200
+ const output = buffer.slice(0, remaining);
201
+ taken += output.length;
202
+ return callback(null, output);
203
+ },
204
+ });
205
+ }
206
+ function createOfflineVideoReadStream({ filePath, size, key, iv, range, }) {
207
+ if (!range) {
208
+ const decipher = createOfflineVideoDecipher({ key, iv });
209
+ return createReadStream(filePath).pipe(decipher);
210
+ }
211
+ const cryptoRange = getCryptoRange({ start: range.start, end: range.end });
212
+ const alignedEnd = Math.min(cryptoRange.alignedEnd, size - 1);
213
+ const rangeIv = incrementIv(iv, cryptoRange.blockIndex);
214
+ const decipher = createOfflineVideoDecipher({ key, iv: rangeIv });
215
+ const slice = createSliceTransform({
216
+ skipBytes: cryptoRange.skipBytes,
217
+ takeBytes: cryptoRange.takeBytes,
218
+ });
219
+ return createReadStream(filePath, {
220
+ start: cryptoRange.alignedStart,
221
+ end: alignedEnd,
222
+ })
223
+ .pipe(decipher)
224
+ .pipe(slice);
225
+ }
226
+ async function getWorkshopVideoCollection({ request, } = {}) {
227
+ const [workshopInstructions, workshopFinished, exercises, apps] = await Promise.all([
228
+ getWorkshopInstructions({ request }),
229
+ getWorkshopFinished({ request }),
230
+ getExercises({ request }),
231
+ getApps({ request }),
232
+ ]);
233
+ const embedUrls = new Set();
234
+ const addEmbeds = (embeds) => {
235
+ if (!embeds)
236
+ return;
237
+ for (const url of embeds) {
238
+ if (!url)
239
+ continue;
240
+ embedUrls.add(normalizeEmbedUrl(url));
241
+ }
242
+ };
243
+ if (workshopInstructions.compiled.status === 'success') {
244
+ addEmbeds(workshopInstructions.compiled.epicVideoEmbeds);
245
+ }
246
+ if (workshopFinished.compiled.status === 'success') {
247
+ addEmbeds(workshopFinished.compiled.epicVideoEmbeds);
248
+ }
249
+ for (const exercise of exercises) {
250
+ addEmbeds(exercise.instructionsEpicVideoEmbeds);
251
+ addEmbeds(exercise.finishedEpicVideoEmbeds);
252
+ for (const step of exercise.steps ?? []) {
253
+ addEmbeds(step.problem?.epicVideoEmbeds);
254
+ addEmbeds(step.solution?.epicVideoEmbeds);
255
+ }
256
+ }
257
+ for (const app of apps) {
258
+ addEmbeds(app.epicVideoEmbeds);
259
+ }
260
+ const embedList = Array.from(embedUrls);
261
+ const epicVideoInfos = await getEpicVideoInfos(embedList, { request });
262
+ const videos = [];
263
+ let unavailable = 0;
264
+ for (const embed of embedList) {
265
+ const info = epicVideoInfos[embed];
266
+ if (!info || info.status !== 'success') {
267
+ unavailable += 1;
268
+ continue;
269
+ }
270
+ videos.push({
271
+ playbackId: info.muxPlaybackId,
272
+ title: info.title ?? embed,
273
+ url: embed,
274
+ });
275
+ }
276
+ return { videos, totalEmbeds: embedUrls.size, unavailable };
277
+ }
278
+ function getMuxMp4Urls(playbackId) {
279
+ return [
280
+ `https://stream.mux.com/${playbackId}/high.mp4`,
281
+ `https://stream.mux.com/${playbackId}/medium.mp4`,
282
+ `https://stream.mux.com/${playbackId}/low.mp4`,
283
+ `https://stream.mux.com/${playbackId}.mp4`,
284
+ ];
285
+ }
286
+ async function isOfflineVideoReady(index, playbackId, keyId, cryptoVersion, workshop) {
287
+ const entry = index[playbackId];
288
+ if (!entry || entry.status !== 'ready')
289
+ return false;
290
+ if (entry.keyId !== keyId || entry.cryptoVersion !== cryptoVersion)
291
+ return false;
292
+ if (!hasWorkshop(entry, workshop.id))
293
+ return false;
294
+ const filePath = entry.fileName
295
+ ? path.join(getOfflineVideoDir(), entry.fileName)
296
+ : getOfflineVideoFilePath(playbackId);
297
+ try {
298
+ const stat = await fs.stat(filePath);
299
+ return stat.size > 0;
300
+ }
301
+ catch {
302
+ return false;
303
+ }
304
+ }
305
+ async function downloadMuxVideo({ playbackId, filePath, key, iv, }) {
306
+ const urls = getMuxMp4Urls(playbackId);
307
+ let lastError = null;
308
+ for (const url of urls) {
309
+ const response = await fetch(url).catch((error) => {
310
+ lastError = error;
311
+ return null;
312
+ });
313
+ if (!response)
314
+ continue;
315
+ if (!response.ok || !response.body) {
316
+ lastError = new Error(`Failed to download ${playbackId} from ${url} (${response.status})`);
317
+ continue;
318
+ }
319
+ await ensureOfflineVideoDir();
320
+ const tmpPath = `${filePath}.tmp-${randomUUID()}`;
321
+ const stream = createWriteStream(tmpPath, { mode: 0o600 });
322
+ const cipher = createOfflineVideoCipher({ key, iv });
323
+ const webStream = response.body;
324
+ await pipeline(Readable.from(webStream), cipher, stream);
325
+ await fs.rename(tmpPath, filePath);
326
+ const stat = await fs.stat(filePath);
327
+ return { size: stat.size };
328
+ }
329
+ throw lastError ?? new Error(`Unable to download video ${playbackId}`);
330
+ }
331
+ async function runOfflineVideoDownloads({ videos, index, keyInfo, workshop, }) {
332
+ for (const video of videos) {
333
+ const updatedAt = new Date().toISOString();
334
+ downloadState.current = {
335
+ playbackId: video.playbackId,
336
+ title: video.title,
337
+ };
338
+ downloadState.updatedAt = updatedAt;
339
+ const iv = createOfflineVideoIv();
340
+ const entry = {
341
+ playbackId: video.playbackId,
342
+ title: video.title,
343
+ url: video.url,
344
+ fileName: getOfflineVideoFileName(video.playbackId),
345
+ status: 'downloading',
346
+ updatedAt,
347
+ iv: encodeOfflineVideoIv(iv),
348
+ keyId: keyInfo.keyId,
349
+ cryptoVersion: keyInfo.config.version,
350
+ workshops: [workshop],
351
+ };
352
+ index[video.playbackId] = entry;
353
+ await writeOfflineVideoIndex(index);
354
+ try {
355
+ const { size } = await downloadMuxVideo({
356
+ playbackId: video.playbackId,
357
+ filePath: path.join(getOfflineVideoDir(), entry.fileName),
358
+ key: keyInfo.key,
359
+ iv,
360
+ });
361
+ index[video.playbackId] = {
362
+ ...entry,
363
+ status: 'ready',
364
+ size,
365
+ updatedAt: new Date().toISOString(),
366
+ };
367
+ }
368
+ catch (error) {
369
+ const message = error instanceof Error ? error.message : 'Download failed';
370
+ downloadState.errors.push({
371
+ playbackId: video.playbackId,
372
+ title: video.title,
373
+ error: message,
374
+ });
375
+ index[video.playbackId] = {
376
+ ...entry,
377
+ status: 'error',
378
+ error: message,
379
+ updatedAt: new Date().toISOString(),
380
+ };
381
+ log.error(`Download failed for ${video.playbackId}`, error);
382
+ }
383
+ finally {
384
+ downloadState.completed += 1;
385
+ downloadState.current = null;
386
+ downloadState.updatedAt = new Date().toISOString();
387
+ await writeOfflineVideoIndex(index);
388
+ }
389
+ }
390
+ downloadState.status = downloadState.errors.length > 0 ? 'error' : 'completed';
391
+ downloadState.updatedAt = new Date().toISOString();
392
+ }
393
+ export function getOfflineVideoDownloadState() {
394
+ return downloadState;
395
+ }
396
+ export async function getOfflineVideoSummary({ request, } = {}) {
397
+ const workshop = getWorkshopIdentity();
398
+ const { videos, unavailable } = await getWorkshopVideoCollection({ request });
399
+ const index = await readOfflineVideoIndex();
400
+ const keyInfo = await getOfflineVideoKeyInfo({
401
+ userId: null,
402
+ allowUserIdUpdate: false,
403
+ });
404
+ let downloadedVideos = 0;
405
+ let totalBytes = 0;
406
+ for (const video of videos) {
407
+ const entry = index[video.playbackId];
408
+ if (entry?.status === 'ready' &&
409
+ keyInfo &&
410
+ entry.keyId === keyInfo.keyId &&
411
+ entry.cryptoVersion === keyInfo.config.version &&
412
+ hasWorkshop(entry, workshop.id)) {
413
+ downloadedVideos += 1;
414
+ totalBytes += entry.size ?? 0;
415
+ }
416
+ }
417
+ return {
418
+ totalVideos: videos.length,
419
+ downloadedVideos,
420
+ unavailableVideos: unavailable,
421
+ totalBytes,
422
+ downloadState,
423
+ };
424
+ }
425
+ export async function startOfflineVideoDownload({ request, } = {}) {
426
+ if (getEnv().EPICSHOP_DEPLOYED) {
427
+ return {
428
+ state: downloadState,
429
+ available: 0,
430
+ queued: 0,
431
+ unavailable: 0,
432
+ alreadyDownloaded: 0,
433
+ };
434
+ }
435
+ if (downloadState.status === 'running') {
436
+ return {
437
+ state: downloadState,
438
+ available: downloadState.total + downloadState.skipped,
439
+ queued: downloadState.total,
440
+ unavailable: 0,
441
+ alreadyDownloaded: downloadState.skipped,
442
+ };
443
+ }
444
+ // Set status immediately to prevent race condition
445
+ const tempStartedAt = new Date().toISOString();
446
+ downloadState = {
447
+ status: 'running',
448
+ startedAt: tempStartedAt,
449
+ updatedAt: tempStartedAt,
450
+ total: 0,
451
+ completed: 0,
452
+ skipped: 0,
453
+ current: null,
454
+ errors: [],
455
+ };
456
+ const workshop = getWorkshopIdentity();
457
+ const { videos, unavailable } = await getWorkshopVideoCollection({ request });
458
+ const index = await readOfflineVideoIndex();
459
+ const authInfo = await getAuthInfo();
460
+ const keyInfo = await getOfflineVideoKeyInfo({
461
+ userId: authInfo?.id ?? null,
462
+ allowUserIdUpdate: true,
463
+ });
464
+ if (!keyInfo) {
465
+ return {
466
+ state: downloadState,
467
+ available: videos.length,
468
+ queued: 0,
469
+ unavailable,
470
+ alreadyDownloaded: 0,
471
+ };
472
+ }
473
+ const downloads = [];
474
+ let alreadyDownloaded = 0;
475
+ for (const video of videos) {
476
+ const entry = index[video.playbackId];
477
+ if (entry?.status === 'ready' &&
478
+ entry.keyId === keyInfo.keyId &&
479
+ entry.cryptoVersion === keyInfo.config.version) {
480
+ if (!hasWorkshop(entry, workshop.id)) {
481
+ index[video.playbackId] = ensureWorkshopOnEntry(entry, workshop);
482
+ await writeOfflineVideoIndex(index);
483
+ }
484
+ alreadyDownloaded += 1;
485
+ continue;
486
+ }
487
+ if (await isOfflineVideoReady(index, video.playbackId, keyInfo.keyId, keyInfo.config.version, workshop)) {
488
+ alreadyDownloaded += 1;
489
+ continue;
490
+ }
491
+ downloads.push(video);
492
+ }
493
+ const startedAt = new Date().toISOString();
494
+ downloadState = {
495
+ status: downloads.length > 0 ? 'running' : 'completed',
496
+ startedAt,
497
+ updatedAt: startedAt,
498
+ total: downloads.length,
499
+ completed: 0,
500
+ skipped: alreadyDownloaded,
501
+ current: null,
502
+ errors: [],
503
+ };
504
+ if (downloads.length > 0) {
505
+ void runOfflineVideoDownloads({
506
+ videos: downloads,
507
+ index,
508
+ keyInfo,
509
+ workshop,
510
+ }).catch((error) => {
511
+ log.error('Offline video downloads failed', error);
512
+ downloadState.status = 'error';
513
+ downloadState.updatedAt = new Date().toISOString();
514
+ });
515
+ }
516
+ return {
517
+ state: downloadState,
518
+ available: videos.length,
519
+ queued: downloads.length,
520
+ unavailable,
521
+ alreadyDownloaded,
522
+ };
523
+ }
524
+ export async function downloadOfflineVideo({ playbackId, title, url, }) {
525
+ const workshop = getWorkshopIdentity();
526
+ const index = await readOfflineVideoIndex();
527
+ const authInfo = await getAuthInfo();
528
+ const keyInfo = await getOfflineVideoKeyInfo({
529
+ userId: authInfo?.id ?? null,
530
+ allowUserIdUpdate: true,
531
+ });
532
+ if (!keyInfo)
533
+ return { status: 'error' };
534
+ const existing = index[playbackId];
535
+ if (existing?.status === 'ready' &&
536
+ existing.keyId === keyInfo.keyId &&
537
+ existing.cryptoVersion === keyInfo.config.version) {
538
+ const updated = ensureWorkshopOnEntry(existing, workshop);
539
+ if (updated !== existing) {
540
+ index[playbackId] = updated;
541
+ await writeOfflineVideoIndex(index);
542
+ }
543
+ return { status: 'ready' };
544
+ }
545
+ const updatedAt = new Date().toISOString();
546
+ const iv = createOfflineVideoIv();
547
+ const entry = {
548
+ playbackId,
549
+ title,
550
+ url,
551
+ fileName: getOfflineVideoFileName(playbackId),
552
+ status: 'downloading',
553
+ updatedAt,
554
+ iv: encodeOfflineVideoIv(iv),
555
+ keyId: keyInfo.keyId,
556
+ cryptoVersion: keyInfo.config.version,
557
+ workshops: [workshop],
558
+ };
559
+ index[playbackId] = entry;
560
+ await writeOfflineVideoIndex(index);
561
+ try {
562
+ const { size } = await downloadMuxVideo({
563
+ playbackId,
564
+ filePath: path.join(getOfflineVideoDir(), entry.fileName),
565
+ key: keyInfo.key,
566
+ iv,
567
+ });
568
+ index[playbackId] = {
569
+ ...entry,
570
+ status: 'ready',
571
+ size,
572
+ updatedAt: new Date().toISOString(),
573
+ };
574
+ await writeOfflineVideoIndex(index);
575
+ return { status: 'downloaded' };
576
+ }
577
+ catch (error) {
578
+ const message = error instanceof Error ? error.message : 'Download failed';
579
+ index[playbackId] = {
580
+ ...entry,
581
+ status: 'error',
582
+ error: message,
583
+ updatedAt: new Date().toISOString(),
584
+ };
585
+ await writeOfflineVideoIndex(index);
586
+ return { status: 'error' };
587
+ }
588
+ }
589
+ export async function deleteOfflineVideo(playbackId, options) {
590
+ const workshop = options?.workshopId
591
+ ? { id: options.workshopId, title: '' }
592
+ : getWorkshopIdentity();
593
+ const index = await readOfflineVideoIndex();
594
+ const entry = index[playbackId];
595
+ if (!entry)
596
+ return { status: 'missing' };
597
+ const nextEntry = removeWorkshopFromEntry(entry, workshop.id);
598
+ if (nextEntry.workshops && nextEntry.workshops.length > 0) {
599
+ index[playbackId] = nextEntry;
600
+ await writeOfflineVideoIndex(index);
601
+ return { status: 'removed' };
602
+ }
603
+ const filePath = entry.fileName
604
+ ? path.join(getOfflineVideoDir(), entry.fileName)
605
+ : getOfflineVideoFilePath(playbackId);
606
+ delete index[playbackId];
607
+ await writeOfflineVideoIndex(index);
608
+ await fs.rm(filePath, { force: true });
609
+ return { status: 'deleted' };
610
+ }
611
+ export async function deleteOfflineVideosForWorkshop() {
612
+ const workshop = getWorkshopIdentity();
613
+ const index = await readOfflineVideoIndex();
614
+ let deletedFiles = 0;
615
+ let removedEntries = 0;
616
+ for (const [playbackId, entry] of Object.entries(index)) {
617
+ if (!hasWorkshop(entry, workshop.id))
618
+ continue;
619
+ const nextEntry = removeWorkshopFromEntry(entry, workshop.id);
620
+ if (nextEntry.workshops && nextEntry.workshops.length > 0) {
621
+ index[playbackId] = nextEntry;
622
+ continue;
623
+ }
624
+ const filePath = entry.fileName
625
+ ? path.join(getOfflineVideoDir(), entry.fileName)
626
+ : getOfflineVideoFilePath(playbackId);
627
+ delete index[playbackId];
628
+ removedEntries += 1;
629
+ await fs.rm(filePath, { force: true });
630
+ deletedFiles += 1;
631
+ }
632
+ await writeOfflineVideoIndex(index);
633
+ return { deletedFiles, removedEntries };
634
+ }
635
+ export async function deleteOfflineVideosForWorkshopId(workshopId) {
636
+ const index = await readOfflineVideoIndex();
637
+ let deletedFiles = 0;
638
+ let removedEntries = 0;
639
+ for (const [playbackId, entry] of Object.entries(index)) {
640
+ if (!hasWorkshop(entry, workshopId))
641
+ continue;
642
+ const nextEntry = removeWorkshopFromEntry(entry, workshopId);
643
+ if (nextEntry.workshops && nextEntry.workshops.length > 0) {
644
+ index[playbackId] = nextEntry;
645
+ continue;
646
+ }
647
+ const filePath = entry.fileName
648
+ ? path.join(getOfflineVideoDir(), entry.fileName)
649
+ : getOfflineVideoFilePath(playbackId);
650
+ delete index[playbackId];
651
+ removedEntries += 1;
652
+ await fs.rm(filePath, { force: true });
653
+ deletedFiles += 1;
654
+ }
655
+ await writeOfflineVideoIndex(index);
656
+ return { deletedFiles, removedEntries };
657
+ }
658
+ export async function deleteAllOfflineVideos() {
659
+ const index = await readOfflineVideoIndex();
660
+ let deletedFiles = 0;
661
+ for (const entry of Object.values(index)) {
662
+ const filePath = entry.fileName
663
+ ? path.join(getOfflineVideoDir(), entry.fileName)
664
+ : getOfflineVideoFilePath(entry.playbackId);
665
+ await fs.rm(filePath, { force: true });
666
+ deletedFiles += 1;
667
+ }
668
+ await writeOfflineVideoIndex({});
669
+ return { deletedFiles };
670
+ }
671
+ export async function getOfflineVideoAdminSummary() {
672
+ const index = await readOfflineVideoIndex();
673
+ const workshops = new Map();
674
+ for (const [playbackId, entry] of Object.entries(index)) {
675
+ const entryWorkshops = getEntryWorkshops(entry);
676
+ for (const workshop of entryWorkshops) {
677
+ const existing = workshops.get(workshop.id) ?? {
678
+ ...workshop,
679
+ totalBytes: 0,
680
+ videos: [],
681
+ };
682
+ existing.videos.push({
683
+ playbackId,
684
+ title: entry.title,
685
+ url: entry.url,
686
+ status: entry.status,
687
+ size: entry.size ?? null,
688
+ updatedAt: entry.updatedAt,
689
+ });
690
+ existing.totalBytes += entry.size ?? 0;
691
+ workshops.set(workshop.id, existing);
692
+ }
693
+ }
694
+ return {
695
+ workshops: Array.from(workshops.values()).sort((a, b) => a.title.localeCompare(b.title)),
696
+ };
697
+ }
698
+ export async function getOfflineVideoAsset(playbackId) {
699
+ const workshop = getWorkshopIdentity();
700
+ const keyInfo = await getOfflineVideoKeyInfo({
701
+ userId: null,
702
+ allowUserIdUpdate: false,
703
+ });
704
+ if (!keyInfo)
705
+ return null;
706
+ const index = await readOfflineVideoIndex();
707
+ const entry = index[playbackId];
708
+ if (!entry ||
709
+ entry.status !== 'ready' ||
710
+ entry.keyId !== keyInfo.keyId ||
711
+ entry.cryptoVersion !== keyInfo.config.version ||
712
+ !entry.iv ||
713
+ !hasWorkshop(entry, workshop.id)) {
714
+ return null;
715
+ }
716
+ const filePath = entry.fileName
717
+ ? path.join(getOfflineVideoDir(), entry.fileName)
718
+ : getOfflineVideoFilePath(playbackId);
719
+ try {
720
+ const stat = await fs.stat(filePath);
721
+ if (stat.size === 0)
722
+ return null;
723
+ const iv = decodeOfflineVideoIv(entry.iv);
724
+ return {
725
+ size: stat.size,
726
+ contentType: 'video/mp4',
727
+ createStream: (range) => createOfflineVideoReadStream({
728
+ filePath,
729
+ size: stat.size,
730
+ key: keyInfo.key,
731
+ iv,
732
+ range,
733
+ }),
734
+ };
735
+ }
736
+ catch {
737
+ return null;
738
+ }
739
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epic-web/workshop-utils",
3
- "version": "6.54.1",
3
+ "version": "6.55.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -28,6 +28,7 @@
28
28
  "./logger": "./src/logger.ts",
29
29
  "./playwright.server": "./src/playwright.server.ts",
30
30
  "./notifications.server": "./src/notifications.server.ts",
31
+ "./offline-videos.server": "./src/offline-videos.server.ts",
31
32
  "./process-manager.server": "./src/process-manager.server.ts",
32
33
  "./test": "./src/test.ts",
33
34
  "./request-context.server": "./src/request-context.server.ts",
@@ -114,6 +115,10 @@
114
115
  "types": "./dist/notifications.server.d.ts",
115
116
  "import": "./dist/notifications.server.js"
116
117
  },
118
+ "./offline-videos.server": {
119
+ "types": "./dist/offline-videos.server.d.ts",
120
+ "import": "./dist/offline-videos.server.js"
121
+ },
117
122
  "./process-manager.server": {
118
123
  "types": "./dist/process-manager.server.d.ts",
119
124
  "import": "./dist/process-manager.server.js"