@epic-web/workshop-utils 6.55.1 → 6.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/compile-mdx.server.js +80 -63
- package/dist/db.server.d.ts +31 -0
- package/dist/db.server.js +12 -0
- package/dist/offline-videos.server.d.ts +13 -0
- package/dist/offline-videos.server.js +102 -12
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import "./init-env.js";
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { rehypeCodeBlocksShiki } from '@kentcdodds/md-temp';
|
|
5
|
+
import * as cookie from 'cookie';
|
|
5
6
|
import lz from 'lz-string';
|
|
6
7
|
import md5 from 'md5-hex';
|
|
7
8
|
import { bundleMDX } from 'mdx-bundler';
|
|
@@ -12,7 +13,51 @@ import gfm from 'remark-gfm';
|
|
|
12
13
|
import { visit } from 'unist-util-visit';
|
|
13
14
|
import { cachified, compiledInstructionMarkdownCache, compiledMarkdownCache, shouldForceFresh, } from "./cache.server.js";
|
|
14
15
|
import { checkConnection } from "./utils.server.js";
|
|
15
|
-
|
|
16
|
+
const themeCookieName = 'EpicShop_theme';
|
|
17
|
+
const themeHintCookieName = 'EpicShop_CH-prefers-color-scheme';
|
|
18
|
+
function getMermaidTheme(request) {
|
|
19
|
+
if (!request)
|
|
20
|
+
return 'default';
|
|
21
|
+
const cookieHeader = request.headers.get('cookie');
|
|
22
|
+
if (!cookieHeader)
|
|
23
|
+
return 'default';
|
|
24
|
+
const parsed = cookie.parse(cookieHeader);
|
|
25
|
+
const themeCookie = parsed[themeCookieName];
|
|
26
|
+
if (themeCookie === 'dark')
|
|
27
|
+
return 'dark';
|
|
28
|
+
if (themeCookie === 'light')
|
|
29
|
+
return 'default';
|
|
30
|
+
const hintTheme = parsed[themeHintCookieName];
|
|
31
|
+
return hintTheme === 'dark' ? 'dark' : 'default';
|
|
32
|
+
}
|
|
33
|
+
function mdxStringExpressionAttribute(name, value) {
|
|
34
|
+
return {
|
|
35
|
+
type: 'mdxJsxAttribute',
|
|
36
|
+
name,
|
|
37
|
+
value: {
|
|
38
|
+
type: 'mdxJsxAttributeValueExpression',
|
|
39
|
+
value: JSON.stringify(value),
|
|
40
|
+
// This hack brought to you by this: https://github.com/syntax-tree/hast-util-to-estree/blob/e5ccb97e9f42bba90359ea6d0f83a11d74e0dad6/lib/handlers/mdx-expression.js#L35-L38
|
|
41
|
+
// no idea why we're required to have estree here, but I'm pretty sure someone is supposed to add it automatically for us and it just never happens...
|
|
42
|
+
data: {
|
|
43
|
+
estree: {
|
|
44
|
+
type: 'Program',
|
|
45
|
+
sourceType: 'script',
|
|
46
|
+
body: [
|
|
47
|
+
{
|
|
48
|
+
type: 'ExpressionStatement',
|
|
49
|
+
expression: {
|
|
50
|
+
type: 'Literal',
|
|
51
|
+
value,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function remarkMermaidCodeToSvg({ theme }) {
|
|
16
61
|
return async (tree) => {
|
|
17
62
|
const promises = [];
|
|
18
63
|
visit(tree, 'code', (node, index, parent) => {
|
|
@@ -21,79 +66,50 @@ function remarkMermaidCodeToSvg() {
|
|
|
21
66
|
const isConnected = await checkConnection();
|
|
22
67
|
if (isConnected) {
|
|
23
68
|
const compressed = lz.compressToEncodedURIComponent(node.value);
|
|
24
|
-
const url =
|
|
69
|
+
const url = new URL('https://mermaid-to-svg.kentcdodds.workers.dev/svg');
|
|
70
|
+
url.searchParams.set('mermaid', compressed);
|
|
71
|
+
url.searchParams.set('theme', theme);
|
|
25
72
|
const timeout = AbortSignal.timeout(5000);
|
|
26
|
-
const svgResponse = await fetch(url, {
|
|
73
|
+
const svgResponse = await fetch(url, {
|
|
74
|
+
signal: timeout,
|
|
75
|
+
}).catch(() => null);
|
|
27
76
|
if (svgResponse?.ok) {
|
|
28
77
|
const svgText = await svgResponse.text();
|
|
29
78
|
if (svgText) {
|
|
79
|
+
const attributes = [
|
|
80
|
+
{
|
|
81
|
+
type: 'mdxJsxAttribute',
|
|
82
|
+
name: 'code',
|
|
83
|
+
value: node.value,
|
|
84
|
+
},
|
|
85
|
+
mdxStringExpressionAttribute('svg', svgText),
|
|
86
|
+
{
|
|
87
|
+
type: 'mdxJsxAttribute',
|
|
88
|
+
name: 'svgTheme',
|
|
89
|
+
value: theme,
|
|
90
|
+
},
|
|
91
|
+
];
|
|
30
92
|
parent.children[index] = {
|
|
31
93
|
type: 'mdxJsxFlowElement',
|
|
32
|
-
name: '
|
|
33
|
-
attributes
|
|
34
|
-
{
|
|
35
|
-
type: 'mdxJsxAttribute',
|
|
36
|
-
name: 'className',
|
|
37
|
-
value: 'mermaid not-prose',
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
type: 'mdxJsxAttribute',
|
|
41
|
-
name: 'dangerouslySetInnerHTML',
|
|
42
|
-
value: {
|
|
43
|
-
type: 'mdxJsxAttributeValueExpression',
|
|
44
|
-
value: `{__html: ${JSON.stringify(svgText)}}`,
|
|
45
|
-
// This hack brought to you by this: https://github.com/syntax-tree/hast-util-to-estree/blob/e5ccb97e9f42bba90359ea6d0f83a11d74e0dad6/lib/handlers/mdx-expression.js#L35-L38
|
|
46
|
-
// no idea why we're required to have estree here, but I'm pretty sure someone is supposed to add it automatically for us and it just never happens...
|
|
47
|
-
data: {
|
|
48
|
-
estree: {
|
|
49
|
-
type: 'Program',
|
|
50
|
-
sourceType: 'script',
|
|
51
|
-
body: [
|
|
52
|
-
{
|
|
53
|
-
type: 'ExpressionStatement',
|
|
54
|
-
expression: {
|
|
55
|
-
type: 'ObjectExpression',
|
|
56
|
-
properties: [
|
|
57
|
-
{
|
|
58
|
-
type: 'Property',
|
|
59
|
-
method: false,
|
|
60
|
-
shorthand: false,
|
|
61
|
-
computed: false,
|
|
62
|
-
kind: 'init',
|
|
63
|
-
key: {
|
|
64
|
-
type: 'Identifier',
|
|
65
|
-
name: '__html',
|
|
66
|
-
},
|
|
67
|
-
value: {
|
|
68
|
-
type: 'Literal',
|
|
69
|
-
value: svgText,
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
],
|
|
73
|
-
},
|
|
74
|
-
},
|
|
75
|
-
],
|
|
76
|
-
},
|
|
77
|
-
},
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
],
|
|
94
|
+
name: 'Mermaid',
|
|
95
|
+
attributes,
|
|
81
96
|
children: [],
|
|
82
97
|
};
|
|
83
98
|
return;
|
|
84
99
|
}
|
|
85
100
|
}
|
|
86
101
|
}
|
|
102
|
+
const attributes = [
|
|
103
|
+
{
|
|
104
|
+
type: 'mdxJsxAttribute',
|
|
105
|
+
name: 'code',
|
|
106
|
+
value: node.value,
|
|
107
|
+
},
|
|
108
|
+
];
|
|
87
109
|
parent.children[index] = {
|
|
88
110
|
type: 'mdxJsxFlowElement',
|
|
89
111
|
name: 'Mermaid',
|
|
90
|
-
attributes
|
|
91
|
-
{
|
|
92
|
-
type: 'mdxJsxAttribute',
|
|
93
|
-
name: 'code',
|
|
94
|
-
value: node.value,
|
|
95
|
-
},
|
|
96
|
-
],
|
|
112
|
+
attributes,
|
|
97
113
|
children: [],
|
|
98
114
|
};
|
|
99
115
|
})();
|
|
@@ -147,13 +163,14 @@ const rehypePlugins = [
|
|
|
147
163
|
];
|
|
148
164
|
const verboseLog = process.env.EPICSHOP_VERBOSE_LOG === 'true' ? console.log : () => { };
|
|
149
165
|
export async function compileMdx(file, { request, timings, forceFresh, } = {}) {
|
|
166
|
+
const mermaidTheme = getMermaidTheme(request);
|
|
150
167
|
const stat = await fs.promises
|
|
151
168
|
.stat(file)
|
|
152
169
|
.catch((error) => ({ error }));
|
|
153
170
|
if ('error' in stat) {
|
|
154
171
|
throw new Error(`File stat cannot be read: ${stat.error}`);
|
|
155
172
|
}
|
|
156
|
-
const key = `file:${file}`;
|
|
173
|
+
const key = `file:${file}:mermaid:${mermaidTheme}`;
|
|
157
174
|
forceFresh = await shouldForceFresh({ forceFresh, request, key });
|
|
158
175
|
const existingCacheEntry = await compiledInstructionMarkdownCache.get(key);
|
|
159
176
|
if (!forceFresh && existingCacheEntry) {
|
|
@@ -165,10 +182,10 @@ export async function compileMdx(file, { request, timings, forceFresh, } = {}) {
|
|
|
165
182
|
request,
|
|
166
183
|
timings,
|
|
167
184
|
forceFresh,
|
|
168
|
-
getFreshValue: () => compileMdxImpl(file),
|
|
185
|
+
getFreshValue: () => compileMdxImpl(file, { mermaidTheme }),
|
|
169
186
|
});
|
|
170
187
|
}
|
|
171
|
-
async function compileMdxImpl(file) {
|
|
188
|
+
async function compileMdxImpl(file, { mermaidTheme }) {
|
|
172
189
|
let title = null;
|
|
173
190
|
const epicVideoEmbeds = [];
|
|
174
191
|
try {
|
|
@@ -180,7 +197,7 @@ async function compileMdxImpl(file) {
|
|
|
180
197
|
options.remarkPlugins = [
|
|
181
198
|
...(options.remarkPlugins ?? []),
|
|
182
199
|
gfm,
|
|
183
|
-
remarkMermaidCodeToSvg,
|
|
200
|
+
[remarkMermaidCodeToSvg, { theme: mermaidTheme }],
|
|
184
201
|
() => (tree) => {
|
|
185
202
|
visit(tree, 'heading', (node) => {
|
|
186
203
|
if (title)
|
package/dist/db.server.d.ts
CHANGED
|
@@ -113,6 +113,13 @@ declare const DataSchema: z.ZodObject<{
|
|
|
113
113
|
defaultView?: string | undefined;
|
|
114
114
|
activeSidebarTab?: number | undefined;
|
|
115
115
|
}>>>;
|
|
116
|
+
offlineVideo: z.ZodDefault<z.ZodOptional<z.ZodObject<{
|
|
117
|
+
downloadResolution: z.ZodOptional<z.ZodEnum<["best", "high", "medium", "low"]>>;
|
|
118
|
+
}, "strip", z.ZodTypeAny, {
|
|
119
|
+
downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
|
|
120
|
+
}, {
|
|
121
|
+
downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
|
|
122
|
+
}>>>;
|
|
116
123
|
presence: z.ZodDefault<z.ZodOptional<z.ZodObject<{
|
|
117
124
|
optOut: z.ZodBoolean;
|
|
118
125
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -152,6 +159,9 @@ declare const DataSchema: z.ZodObject<{
|
|
|
152
159
|
defaultView?: string | undefined;
|
|
153
160
|
activeSidebarTab?: number | undefined;
|
|
154
161
|
};
|
|
162
|
+
offlineVideo: {
|
|
163
|
+
downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
|
|
164
|
+
};
|
|
155
165
|
presence: {
|
|
156
166
|
optOut: boolean;
|
|
157
167
|
};
|
|
@@ -179,6 +189,9 @@ declare const DataSchema: z.ZodObject<{
|
|
|
179
189
|
defaultView?: string | undefined;
|
|
180
190
|
activeSidebarTab?: number | undefined;
|
|
181
191
|
} | undefined;
|
|
192
|
+
offlineVideo?: {
|
|
193
|
+
downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
|
|
194
|
+
} | undefined;
|
|
182
195
|
presence?: {
|
|
183
196
|
optOut: boolean;
|
|
184
197
|
} | undefined;
|
|
@@ -282,6 +295,9 @@ declare const DataSchema: z.ZodObject<{
|
|
|
282
295
|
defaultView?: string | undefined;
|
|
283
296
|
activeSidebarTab?: number | undefined;
|
|
284
297
|
};
|
|
298
|
+
offlineVideo: {
|
|
299
|
+
downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
|
|
300
|
+
};
|
|
285
301
|
presence: {
|
|
286
302
|
optOut: boolean;
|
|
287
303
|
};
|
|
@@ -333,6 +349,9 @@ declare const DataSchema: z.ZodObject<{
|
|
|
333
349
|
defaultView?: string | undefined;
|
|
334
350
|
activeSidebarTab?: number | undefined;
|
|
335
351
|
} | undefined;
|
|
352
|
+
offlineVideo?: {
|
|
353
|
+
downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
|
|
354
|
+
} | undefined;
|
|
336
355
|
presence?: {
|
|
337
356
|
optOut: boolean;
|
|
338
357
|
} | undefined;
|
|
@@ -390,6 +409,9 @@ export declare function readDb(): Promise<{
|
|
|
390
409
|
defaultView?: string | undefined;
|
|
391
410
|
activeSidebarTab?: number | undefined;
|
|
392
411
|
};
|
|
412
|
+
offlineVideo: {
|
|
413
|
+
downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
|
|
414
|
+
};
|
|
393
415
|
presence: {
|
|
394
416
|
optOut: boolean;
|
|
395
417
|
};
|
|
@@ -487,6 +509,9 @@ export declare function getPreferences(): Promise<{
|
|
|
487
509
|
defaultView?: string | undefined;
|
|
488
510
|
activeSidebarTab?: number | undefined;
|
|
489
511
|
};
|
|
512
|
+
offlineVideo: {
|
|
513
|
+
downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
|
|
514
|
+
};
|
|
490
515
|
presence: {
|
|
491
516
|
optOut: boolean;
|
|
492
517
|
};
|
|
@@ -515,6 +540,9 @@ export declare function setPreferences(preferences: z.input<typeof DataSchema>['
|
|
|
515
540
|
defaultView?: string | undefined;
|
|
516
541
|
activeSidebarTab?: number | undefined;
|
|
517
542
|
};
|
|
543
|
+
offlineVideo: {
|
|
544
|
+
downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
|
|
545
|
+
};
|
|
518
546
|
presence: {
|
|
519
547
|
optOut?: boolean | undefined;
|
|
520
548
|
};
|
|
@@ -549,6 +577,9 @@ export declare function markOnboardingComplete(featureId: string): Promise<{
|
|
|
549
577
|
defaultView?: string | undefined;
|
|
550
578
|
activeSidebarTab?: number | undefined;
|
|
551
579
|
} | undefined;
|
|
580
|
+
offlineVideo?: {
|
|
581
|
+
downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
|
|
582
|
+
} | undefined;
|
|
552
583
|
presence?: {
|
|
553
584
|
optOut: boolean;
|
|
554
585
|
} | undefined;
|
package/dist/db.server.js
CHANGED
|
@@ -38,6 +38,13 @@ export const PlayerPreferencesSchema = z
|
|
|
38
38
|
})
|
|
39
39
|
.optional()
|
|
40
40
|
.default({});
|
|
41
|
+
const OfflineVideoResolutionSchema = z.enum(['best', 'high', 'medium', 'low']);
|
|
42
|
+
const OfflineVideoPreferencesSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
downloadResolution: OfflineVideoResolutionSchema.optional(),
|
|
45
|
+
})
|
|
46
|
+
.optional()
|
|
47
|
+
.default({});
|
|
41
48
|
const PresencePreferencesSchema = z
|
|
42
49
|
.object({
|
|
43
50
|
optOut: z.boolean(),
|
|
@@ -55,6 +62,7 @@ const DataSchema = z.object({
|
|
|
55
62
|
preferences: z
|
|
56
63
|
.object({
|
|
57
64
|
player: PlayerPreferencesSchema,
|
|
65
|
+
offlineVideo: OfflineVideoPreferencesSchema,
|
|
58
66
|
presence: PresencePreferencesSchema,
|
|
59
67
|
playground: z
|
|
60
68
|
.object({
|
|
@@ -267,6 +275,10 @@ export async function setPreferences(preferences) {
|
|
|
267
275
|
...data?.preferences?.player,
|
|
268
276
|
...preferences?.player,
|
|
269
277
|
},
|
|
278
|
+
offlineVideo: {
|
|
279
|
+
...data?.preferences?.offlineVideo,
|
|
280
|
+
...preferences?.offlineVideo,
|
|
281
|
+
},
|
|
270
282
|
presence: {
|
|
271
283
|
...data?.preferences?.presence,
|
|
272
284
|
...preferences?.presence,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
1
2
|
import { Readable } from 'node:stream';
|
|
2
3
|
type OfflineVideoEntryStatus = 'ready' | 'downloading' | 'error';
|
|
3
4
|
export type OfflineVideoDownloadState = {
|
|
@@ -58,6 +59,18 @@ export type OfflineVideoAsset = {
|
|
|
58
59
|
end: number;
|
|
59
60
|
}) => Readable;
|
|
60
61
|
};
|
|
62
|
+
export type VideoDownloadProgress = {
|
|
63
|
+
playbackId: string;
|
|
64
|
+
bytesDownloaded: number;
|
|
65
|
+
totalBytes: number | null;
|
|
66
|
+
status: 'downloading' | 'complete' | 'error';
|
|
67
|
+
};
|
|
68
|
+
export declare const DOWNLOAD_PROGRESS_EVENTS: {
|
|
69
|
+
readonly PROGRESS: "progress";
|
|
70
|
+
};
|
|
71
|
+
declare class DownloadProgressEmitter extends EventEmitter {
|
|
72
|
+
}
|
|
73
|
+
export declare const downloadProgressEmitter: DownloadProgressEmitter;
|
|
61
74
|
export declare function getOfflineVideoDownloadState(): OfflineVideoDownloadState;
|
|
62
75
|
export declare function getOfflineVideoSummary({ request, }?: {
|
|
63
76
|
request?: Request;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
2
3
|
import { createReadStream, createWriteStream, promises as fs } from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { Readable, Transform } from 'node:stream';
|
|
@@ -6,15 +7,34 @@ import { pipeline } from 'node:stream/promises';
|
|
|
6
7
|
import { getApps, getExercises, getWorkshopFinished, getWorkshopInstructions, } from "./apps.server.js";
|
|
7
8
|
import { getWorkshopConfig } from "./config.server.js";
|
|
8
9
|
import { resolvePrimaryDir } from "./data-storage.server.js";
|
|
9
|
-
import { getAuthInfo, getClientId } from "./db.server.js";
|
|
10
|
+
import { getAuthInfo, getClientId, getPreferences } from "./db.server.js";
|
|
10
11
|
import { getEpicVideoInfos } from "./epic-api.server.js";
|
|
11
12
|
import { getEnv } from "./init-env.js";
|
|
12
13
|
import { logger } from "./logger.js";
|
|
13
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
|
+
];
|
|
21
|
+
function isOfflineVideoDownloadResolution(value) {
|
|
22
|
+
return (typeof value === 'string' &&
|
|
23
|
+
offlineVideoDownloadResolutions.includes(value));
|
|
24
|
+
}
|
|
14
25
|
const log = logger('epic:offline-videos');
|
|
15
26
|
const offlineVideoDirectoryName = 'offline-videos';
|
|
16
27
|
const offlineVideoIndexFileName = 'index.json';
|
|
17
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
|
+
}
|
|
18
38
|
let downloadState = {
|
|
19
39
|
status: 'idle',
|
|
20
40
|
startedAt: null,
|
|
@@ -203,6 +223,16 @@ function createSliceTransform({ skipBytes, takeBytes, }) {
|
|
|
203
223
|
},
|
|
204
224
|
});
|
|
205
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
|
+
}
|
|
206
236
|
function createOfflineVideoReadStream({ filePath, size, key, iv, range, }) {
|
|
207
237
|
if (!range) {
|
|
208
238
|
const decipher = createOfflineVideoDecipher({ key, iv });
|
|
@@ -275,13 +305,29 @@ async function getWorkshopVideoCollection({ request, } = {}) {
|
|
|
275
305
|
}
|
|
276
306
|
return { videos, totalEmbeds: embedUrls.size, unavailable };
|
|
277
307
|
}
|
|
278
|
-
function
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
308
|
+
async function getOfflineVideoDownloadResolution() {
|
|
309
|
+
const preferences = await getPreferences();
|
|
310
|
+
const resolution = preferences?.offlineVideo?.downloadResolution;
|
|
311
|
+
if (isOfflineVideoDownloadResolution(resolution)) {
|
|
312
|
+
return resolution;
|
|
313
|
+
}
|
|
314
|
+
return 'best';
|
|
315
|
+
}
|
|
316
|
+
const muxMp4Variants = {
|
|
317
|
+
high: (playbackId) => `https://stream.mux.com/${playbackId}/high.mp4`,
|
|
318
|
+
medium: (playbackId) => `https://stream.mux.com/${playbackId}/medium.mp4`,
|
|
319
|
+
low: (playbackId) => `https://stream.mux.com/${playbackId}/low.mp4`,
|
|
320
|
+
source: (playbackId) => `https://stream.mux.com/${playbackId}.mp4`,
|
|
321
|
+
};
|
|
322
|
+
const muxResolutionOrder = {
|
|
323
|
+
best: ['source', 'high', 'medium', 'low'],
|
|
324
|
+
high: ['high', 'medium', 'low', 'source'],
|
|
325
|
+
medium: ['medium', 'low', 'high', 'source'],
|
|
326
|
+
low: ['low', 'medium', 'high', 'source'],
|
|
327
|
+
};
|
|
328
|
+
function getMuxMp4Urls(playbackId, resolution) {
|
|
329
|
+
const order = muxResolutionOrder[resolution] ?? muxResolutionOrder.best;
|
|
330
|
+
return order.map((variant) => muxMp4Variants[variant](playbackId));
|
|
285
331
|
}
|
|
286
332
|
async function isOfflineVideoReady(index, playbackId, keyId, cryptoVersion, workshop) {
|
|
287
333
|
const entry = index[playbackId];
|
|
@@ -302,9 +348,15 @@ async function isOfflineVideoReady(index, playbackId, keyId, cryptoVersion, work
|
|
|
302
348
|
return false;
|
|
303
349
|
}
|
|
304
350
|
}
|
|
305
|
-
async function downloadMuxVideo({ playbackId, filePath, key, iv, }) {
|
|
306
|
-
const urls = getMuxMp4Urls(playbackId);
|
|
351
|
+
async function downloadMuxVideo({ playbackId, filePath, key, iv, resolution, }) {
|
|
352
|
+
const urls = getMuxMp4Urls(playbackId, resolution);
|
|
307
353
|
let lastError = null;
|
|
354
|
+
emitDownloadProgress({
|
|
355
|
+
playbackId,
|
|
356
|
+
bytesDownloaded: 0,
|
|
357
|
+
totalBytes: null,
|
|
358
|
+
status: 'downloading',
|
|
359
|
+
});
|
|
308
360
|
for (const url of urls) {
|
|
309
361
|
const response = await fetch(url).catch((error) => {
|
|
310
362
|
lastError = error;
|
|
@@ -316,19 +368,52 @@ async function downloadMuxVideo({ playbackId, filePath, key, iv, }) {
|
|
|
316
368
|
lastError = new Error(`Failed to download ${playbackId} from ${url} (${response.status})`);
|
|
317
369
|
continue;
|
|
318
370
|
}
|
|
371
|
+
// Get content-length if available for progress tracking
|
|
372
|
+
const contentLengthHeader = response.headers.get('content-length');
|
|
373
|
+
const totalBytes = contentLengthHeader
|
|
374
|
+
? parseInt(contentLengthHeader, 10)
|
|
375
|
+
: null;
|
|
376
|
+
emitDownloadProgress({
|
|
377
|
+
playbackId,
|
|
378
|
+
bytesDownloaded: 0,
|
|
379
|
+
totalBytes,
|
|
380
|
+
status: 'downloading',
|
|
381
|
+
});
|
|
319
382
|
await ensureOfflineVideoDir();
|
|
320
383
|
const tmpPath = `${filePath}.tmp-${randomUUID()}`;
|
|
321
384
|
const stream = createWriteStream(tmpPath, { mode: 0o600 });
|
|
322
385
|
const cipher = createOfflineVideoCipher({ key, iv });
|
|
386
|
+
const progressTracker = createProgressTrackingTransform({
|
|
387
|
+
onProgress: (bytesDownloaded) => {
|
|
388
|
+
emitDownloadProgress({
|
|
389
|
+
playbackId,
|
|
390
|
+
bytesDownloaded,
|
|
391
|
+
totalBytes,
|
|
392
|
+
status: 'downloading',
|
|
393
|
+
});
|
|
394
|
+
},
|
|
395
|
+
});
|
|
323
396
|
const webStream = response.body;
|
|
324
|
-
await pipeline(Readable.from(webStream), cipher, stream);
|
|
397
|
+
await pipeline(Readable.from(webStream), progressTracker, cipher, stream);
|
|
325
398
|
await fs.rename(tmpPath, filePath);
|
|
326
399
|
const stat = await fs.stat(filePath);
|
|
400
|
+
emitDownloadProgress({
|
|
401
|
+
playbackId,
|
|
402
|
+
bytesDownloaded: stat.size,
|
|
403
|
+
totalBytes: stat.size,
|
|
404
|
+
status: 'complete',
|
|
405
|
+
});
|
|
327
406
|
return { size: stat.size };
|
|
328
407
|
}
|
|
408
|
+
emitDownloadProgress({
|
|
409
|
+
playbackId,
|
|
410
|
+
bytesDownloaded: 0,
|
|
411
|
+
totalBytes: null,
|
|
412
|
+
status: 'error',
|
|
413
|
+
});
|
|
329
414
|
throw lastError ?? new Error(`Unable to download video ${playbackId}`);
|
|
330
415
|
}
|
|
331
|
-
async function runOfflineVideoDownloads({ videos, index, keyInfo, workshop, }) {
|
|
416
|
+
async function runOfflineVideoDownloads({ videos, index, keyInfo, workshop, resolution, }) {
|
|
332
417
|
for (const video of videos) {
|
|
333
418
|
const updatedAt = new Date().toISOString();
|
|
334
419
|
downloadState.current = {
|
|
@@ -357,6 +442,7 @@ async function runOfflineVideoDownloads({ videos, index, keyInfo, workshop, }) {
|
|
|
357
442
|
filePath: path.join(getOfflineVideoDir(), entry.fileName),
|
|
358
443
|
key: keyInfo.key,
|
|
359
444
|
iv,
|
|
445
|
+
resolution,
|
|
360
446
|
});
|
|
361
447
|
index[video.playbackId] = {
|
|
362
448
|
...entry,
|
|
@@ -505,11 +591,13 @@ export async function startOfflineVideoDownload({ request, } = {}) {
|
|
|
505
591
|
errors: [],
|
|
506
592
|
};
|
|
507
593
|
if (downloads.length > 0) {
|
|
594
|
+
const resolution = await getOfflineVideoDownloadResolution();
|
|
508
595
|
void runOfflineVideoDownloads({
|
|
509
596
|
videos: downloads,
|
|
510
597
|
index,
|
|
511
598
|
keyInfo,
|
|
512
599
|
workshop,
|
|
600
|
+
resolution,
|
|
513
601
|
}).catch((error) => {
|
|
514
602
|
log.error('Offline video downloads failed', error);
|
|
515
603
|
downloadState.status = 'error';
|
|
@@ -534,6 +622,7 @@ export async function downloadOfflineVideo({ playbackId, title, url, }) {
|
|
|
534
622
|
});
|
|
535
623
|
if (!keyInfo)
|
|
536
624
|
return { status: 'error' };
|
|
625
|
+
const resolution = await getOfflineVideoDownloadResolution();
|
|
537
626
|
const existing = index[playbackId];
|
|
538
627
|
if (existing?.status === 'ready' &&
|
|
539
628
|
existing.keyId === keyInfo.keyId &&
|
|
@@ -567,6 +656,7 @@ export async function downloadOfflineVideo({ playbackId, title, url, }) {
|
|
|
567
656
|
filePath: path.join(getOfflineVideoDir(), entry.fileName),
|
|
568
657
|
key: keyInfo.key,
|
|
569
658
|
iv,
|
|
659
|
+
resolution,
|
|
570
660
|
});
|
|
571
661
|
index[playbackId] = {
|
|
572
662
|
...entry,
|