wallpaper-engine-dsh 0.1.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/README.md +107 -0
- package/README.zh.md +109 -0
- package/cordis.patch.yml +18 -0
- package/lib/client.js +1052 -0
- package/lib/core.js +499 -0
- package/lib/index.js +315 -0
- package/lib/types/client.d.ts +18 -0
- package/lib/types/index.d.ts +50 -0
- package/package.json +81 -0
package/lib/core.js
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wallpaper-engine-dsh — pure logic layer (lib/core.js).
|
|
3
|
+
*
|
|
4
|
+
* Every function here is free of Cordis/DSH imports and side-effect-light:
|
|
5
|
+
* parsing, validation, path arithmetic and filesystem enumeration against
|
|
6
|
+
* explicit roots. The host plugin (lib/index.js) wires these into HTTP routes;
|
|
7
|
+
* the unit tests (test/core.test.mjs) exercise them against fixture trees.
|
|
8
|
+
*
|
|
9
|
+
* Layers:
|
|
10
|
+
* 1. KeyValues (VDF) tokenizer/parser — Steam's libraryfolders.vdf.
|
|
11
|
+
* 2. Steam / Wallpaper Engine discovery — candidate ranking, library scan.
|
|
12
|
+
* 3. Wallpaper project model — project.json validation, type inference,
|
|
13
|
+
* path containment (a project file may never escape its own directory).
|
|
14
|
+
* 4. Playlist model — WE config.json parsing (both schemas), item resolution.
|
|
15
|
+
* 5. HTTP helpers — Range header parsing, MIME table, containment checks.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { join, resolve, normalize, basename, sep } from 'node:path';
|
|
21
|
+
|
|
22
|
+
/** Steam appid for Wallpaper Engine. */
|
|
23
|
+
export const WE_APPID = '431960';
|
|
24
|
+
|
|
25
|
+
/** Wallpaper kinds WE can declare. */
|
|
26
|
+
export const KINDS = Object.freeze(['scene', 'video', 'web', 'application']);
|
|
27
|
+
|
|
28
|
+
/** Kinds this bundle can render in a browser. */
|
|
29
|
+
export const PORTABLE_KINDS = Object.freeze(['video', 'web']);
|
|
30
|
+
|
|
31
|
+
// ── 1. Valve KeyValues (VDF) ─────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse Valve KeyValues text into a plain object.
|
|
35
|
+
*
|
|
36
|
+
* Correct-by-construction tokenizer rather than line regexes:
|
|
37
|
+
* - quoted strings with backslash escapes (`\\` in Windows paths → `\`),
|
|
38
|
+
* - `//` line comments,
|
|
39
|
+
* - bare (unquoted) tokens,
|
|
40
|
+
* - arbitrarily nested blocks.
|
|
41
|
+
* Later duplicate keys win, which matches Steam's own last-write behaviour.
|
|
42
|
+
* Malformed input degrades to a partial tree instead of throwing.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} text
|
|
45
|
+
* @returns {Record<string, unknown>}
|
|
46
|
+
*/
|
|
47
|
+
export function parseKeyValues(text) {
|
|
48
|
+
const src = String(text);
|
|
49
|
+
const n = src.length;
|
|
50
|
+
let i = 0;
|
|
51
|
+
|
|
52
|
+
const skipSpace = () => {
|
|
53
|
+
for (;;) {
|
|
54
|
+
while (i < n && /\s/.test(src[i])) i++;
|
|
55
|
+
if (src[i] === '/' && src[i + 1] === '/') {
|
|
56
|
+
while (i < n && src[i] !== '\n') i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const quoted = () => {
|
|
64
|
+
i++; // opening quote
|
|
65
|
+
let out = '';
|
|
66
|
+
while (i < n) {
|
|
67
|
+
const c = src[i];
|
|
68
|
+
if (c === '"') { i++; return out; }
|
|
69
|
+
if (c === '\\' && i + 1 < n) { out += src[i + 1]; i += 2; continue; }
|
|
70
|
+
out += c; i++;
|
|
71
|
+
}
|
|
72
|
+
return out; // unterminated: keep what we have
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const bare = () => {
|
|
76
|
+
let out = '';
|
|
77
|
+
while (i < n && !/[\s{}"]/.test(src[i])) { out += src[i]; i++; }
|
|
78
|
+
return out;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const token = () => (src[i] === '"' ? quoted() : bare());
|
|
82
|
+
|
|
83
|
+
const value = () => {
|
|
84
|
+
skipSpace();
|
|
85
|
+
if (src[i] !== '{') return token();
|
|
86
|
+
i++;
|
|
87
|
+
const obj = {};
|
|
88
|
+
for (;;) {
|
|
89
|
+
skipSpace();
|
|
90
|
+
if (i >= n) break;
|
|
91
|
+
if (src[i] === '}') { i++; break; }
|
|
92
|
+
const key = token();
|
|
93
|
+
obj[key] = value();
|
|
94
|
+
}
|
|
95
|
+
return obj;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const root = {};
|
|
99
|
+
while (i < n) {
|
|
100
|
+
skipSpace();
|
|
101
|
+
if (i >= n) break;
|
|
102
|
+
const key = token();
|
|
103
|
+
if (!key) { i++; continue; }
|
|
104
|
+
root[key] = value();
|
|
105
|
+
}
|
|
106
|
+
return root;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Extract the Steam library roots that own `appid` from libraryfolders.vdf
|
|
111
|
+
* text. Handles both schemas:
|
|
112
|
+
* modern: "0" { "path" "..." "apps" { "431960" "..." } }
|
|
113
|
+
* legacy: "1" "D:\\SteamLibrary" (no app list → include as probe)
|
|
114
|
+
* Appid matching is EXACT (object key lookup) — a substring check would false-
|
|
115
|
+
* positive on appids like 1431960.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} vdfText
|
|
118
|
+
* @param {string} [appid]
|
|
119
|
+
* @returns {string[]} normalized, de-duplicated library roots
|
|
120
|
+
*/
|
|
121
|
+
export function librariesFromVdfText(vdfText, appid = WE_APPID) {
|
|
122
|
+
const tree = parseKeyValues(vdfText);
|
|
123
|
+
const root = tree && typeof tree === 'object' ? tree.libraryfolders : null;
|
|
124
|
+
if (!root || typeof root !== 'object') return [];
|
|
125
|
+
const libs = [];
|
|
126
|
+
for (const entry of Object.values(root)) {
|
|
127
|
+
if (typeof entry === 'string') {
|
|
128
|
+
if (entry.trim()) libs.push(normalize(entry));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
132
|
+
const path = typeof entry.path === 'string' ? entry.path.trim() : '';
|
|
133
|
+
if (!path) continue;
|
|
134
|
+
const apps = entry.apps && typeof entry.apps === 'object' ? entry.apps : {};
|
|
135
|
+
if (Object.prototype.hasOwnProperty.call(apps, appid)) libs.push(normalize(path));
|
|
136
|
+
}
|
|
137
|
+
return [...new Set(libs)];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── 2. Discovery ─────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Well-known Steam roots per platform, probed when the registry /
|
|
144
|
+
* libraryfolders.vdf trail goes cold.
|
|
145
|
+
* @param {string} [platform]
|
|
146
|
+
* @param {() => string} [home]
|
|
147
|
+
* @returns {string[]}
|
|
148
|
+
*/
|
|
149
|
+
export function steamProbeDirs(platform = process.platform, home = homedir) {
|
|
150
|
+
switch (platform) {
|
|
151
|
+
case 'win32':
|
|
152
|
+
return [
|
|
153
|
+
'C:\\Program Files (x86)\\Steam',
|
|
154
|
+
'C:\\Program Files\\Steam',
|
|
155
|
+
'D:\\Steam',
|
|
156
|
+
'D:\\SteamLibrary',
|
|
157
|
+
'E:\\SteamLibrary',
|
|
158
|
+
];
|
|
159
|
+
case 'darwin':
|
|
160
|
+
return [join(home(), 'Library', 'Application Support', 'Steam')];
|
|
161
|
+
case 'linux':
|
|
162
|
+
return [
|
|
163
|
+
join(home(), '.steam', 'steam'),
|
|
164
|
+
join(home(), '.local', 'share', 'Steam'),
|
|
165
|
+
join(home(), '.var', 'app', 'com.valvesoftware.Steam', '.local', 'share', 'Steam'),
|
|
166
|
+
];
|
|
167
|
+
default:
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Parse `reg query HKCU\Software\Valve\Steam /v SteamPath` output.
|
|
174
|
+
* Pure — the caller runs reg.exe and passes stdout here.
|
|
175
|
+
* @param {string} stdout
|
|
176
|
+
* @returns {string | null}
|
|
177
|
+
*/
|
|
178
|
+
export function steamPathFromRegQuery(stdout) {
|
|
179
|
+
const m = /SteamPath\s+REG_SZ\s+(\S[^\r\n]*)/i.exec(String(stdout));
|
|
180
|
+
return m ? normalize(m[1].trim()) : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The executable that proves a directory is a Wallpaper Engine install.
|
|
185
|
+
* @param {string} platform
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
export function weBinaryName(platform = process.platform) {
|
|
189
|
+
return platform === 'darwin' ? 'Wallpaper Engine.app' : 'wallpaper32.exe';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* First candidate directory that contains the WE binary, in order.
|
|
194
|
+
* @param {string[]} candidates
|
|
195
|
+
* @param {{ existsSync?: (p: string) => boolean, platform?: string }} [io]
|
|
196
|
+
* @returns {string | null}
|
|
197
|
+
*/
|
|
198
|
+
export function findInstallDir(candidates, io = {}) {
|
|
199
|
+
const exists = io.existsSync || existsSync;
|
|
200
|
+
const bin = weBinaryName(io.platform || process.platform);
|
|
201
|
+
const seen = new Set();
|
|
202
|
+
for (const raw of candidates) {
|
|
203
|
+
if (!raw) continue;
|
|
204
|
+
const dir = normalize(String(raw));
|
|
205
|
+
if (seen.has(dir)) continue;
|
|
206
|
+
seen.add(dir);
|
|
207
|
+
if (exists(join(dir, bin))) return dir;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Ranked install-dir candidates: registry root first, then vdf-reported
|
|
214
|
+
* libraries that own WE, then the probe list, then the standalone installer
|
|
215
|
+
* location. Pure ranking — pass it to findInstallDir.
|
|
216
|
+
* @param {{ registryRoot?: string | null, libraryRoots?: string[], platform?: string, home?: () => string }} input
|
|
217
|
+
* @returns {string[]}
|
|
218
|
+
*/
|
|
219
|
+
export function installDirCandidates(input = {}) {
|
|
220
|
+
const probes = steamProbeDirs(input.platform, input.home);
|
|
221
|
+
const roots = [
|
|
222
|
+
...(input.registryRoot ? [input.registryRoot] : []),
|
|
223
|
+
...(input.libraryRoots || []),
|
|
224
|
+
...probes,
|
|
225
|
+
];
|
|
226
|
+
const out = roots.map((r) => join(r, 'steamapps', 'common', 'wallpaper_engine'));
|
|
227
|
+
if ((input.platform || process.platform) === 'win32') {
|
|
228
|
+
out.push('C:\\Program Files (x86)\\Wallpaper Engine');
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Every Steam library root that can hold WE workshop content: the vdf-listed
|
|
235
|
+
* owners of appid PLUS any probe root that has WE installed (the default
|
|
236
|
+
* library is never listed as a "path" entry inside its own
|
|
237
|
+
* libraryfolders.vdf, so it must be recovered by direct inspection).
|
|
238
|
+
* @param {string[]} probes
|
|
239
|
+
* @param {string[]} vdfLibraries
|
|
240
|
+
* @param {{ existsSync?: (p: string) => boolean, platform?: string }} [io]
|
|
241
|
+
* @returns {string[]}
|
|
242
|
+
*/
|
|
243
|
+
export function workshopLibraryRoots(probes, vdfLibraries, io = {}) {
|
|
244
|
+
const exists = io.existsSync || existsSync;
|
|
245
|
+
const bin = weBinaryName(io.platform || process.platform);
|
|
246
|
+
const libs = [...vdfLibraries];
|
|
247
|
+
for (const probe of probes) {
|
|
248
|
+
if (exists(join(probe, 'steamapps', 'common', 'wallpaper_engine', bin))) {
|
|
249
|
+
libs.push(probe);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return [...new Set(libs.map((p) => normalize(p)))];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── 3. Wallpaper project model ───────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/** Last-resort type inference from the main file extension. */
|
|
258
|
+
export function inferType(file) {
|
|
259
|
+
if (/\.(mp4|webm|mkv|avi|mov|m4v)$/i.test(file)) return 'video';
|
|
260
|
+
if (/\.(html?|js)$/i.test(file)) return 'web';
|
|
261
|
+
return 'scene';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Containment check: is `target` inside directory `root` (or equal to it)?
|
|
266
|
+
* Both sides are resolved first; comparison is case-insensitive on win32.
|
|
267
|
+
* Guards against `..` escapes AND the sibling-prefix trap
|
|
268
|
+
* (`/root/abc` must not pass for root `/root/ab`).
|
|
269
|
+
* @param {string} root
|
|
270
|
+
* @param {string} target
|
|
271
|
+
* @param {string} [platform]
|
|
272
|
+
* @returns {boolean}
|
|
273
|
+
*/
|
|
274
|
+
export function isInsideDir(root, target, platform = process.platform) {
|
|
275
|
+
let r = resolve(String(root));
|
|
276
|
+
let t = resolve(String(target));
|
|
277
|
+
if (platform === 'win32') { r = r.toLowerCase(); t = t.toLowerCase(); }
|
|
278
|
+
if (t === r) return true;
|
|
279
|
+
const withSep = r.endsWith(sep) ? r : r + sep;
|
|
280
|
+
return t.startsWith(withSep);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Read and validate one wallpaper project directory.
|
|
285
|
+
* Returns null for anything unusable; never throws.
|
|
286
|
+
* Security invariant: the declared main file and preview must resolve INSIDE
|
|
287
|
+
* the project directory — a hostile project.json cannot point the media
|
|
288
|
+
* server at arbitrary files.
|
|
289
|
+
*
|
|
290
|
+
* @param {string} dir
|
|
291
|
+
* @param {{ readFileSync?: (p: string, e: string) => string, existsSync?: (p: string) => boolean }} [io]
|
|
292
|
+
*/
|
|
293
|
+
export function readProject(dir, io = {}) {
|
|
294
|
+
const read = io.readFileSync || readFileSync;
|
|
295
|
+
const exists = io.existsSync || existsSync;
|
|
296
|
+
const pj = join(dir, 'project.json');
|
|
297
|
+
if (!exists(pj)) return null;
|
|
298
|
+
try {
|
|
299
|
+
const o = JSON.parse(read(pj, 'utf8'));
|
|
300
|
+
if (!o || typeof o !== 'object' || typeof o.file !== 'string' || !o.file.trim()) return null;
|
|
301
|
+
let type = typeof o.type === 'string' ? o.type.toLowerCase() : inferType(o.file);
|
|
302
|
+
if (!KINDS.includes(type)) type = 'scene';
|
|
303
|
+
const fileAbs = resolve(dir, o.file);
|
|
304
|
+
if (!isInsideDir(dir, fileAbs)) return null;
|
|
305
|
+
let previewAbs = null;
|
|
306
|
+
if (typeof o.preview === 'string' && o.preview.trim()) {
|
|
307
|
+
const p = resolve(dir, o.preview);
|
|
308
|
+
if (isInsideDir(dir, p)) previewAbs = p;
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
id: basename(dir),
|
|
312
|
+
title: typeof o.title === 'string' && o.title.trim() ? o.title.trim() : basename(dir),
|
|
313
|
+
type,
|
|
314
|
+
dir,
|
|
315
|
+
fileAbs,
|
|
316
|
+
previewAbs,
|
|
317
|
+
};
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Enumerate every wallpaper project under the given roots (first occurrence
|
|
325
|
+
* of an id wins), sorted case-insensitively by title.
|
|
326
|
+
* @param {string[]} roots
|
|
327
|
+
* @param {{ readdirSync?: (p: string) => string[], statSync?: (p: string) => { isDirectory(): boolean } }} [io]
|
|
328
|
+
*/
|
|
329
|
+
export function enumerateWallpapers(roots, io = {}) {
|
|
330
|
+
const readdir = io.readdirSync || readdirSync;
|
|
331
|
+
const stat = io.statSync || statSync;
|
|
332
|
+
const found = new Map();
|
|
333
|
+
for (const root of roots) {
|
|
334
|
+
let entries;
|
|
335
|
+
try { entries = readdir(root); } catch { continue; }
|
|
336
|
+
for (const entry of entries) {
|
|
337
|
+
const dir = join(root, entry);
|
|
338
|
+
try { if (!stat(dir).isDirectory()) continue; } catch { continue; }
|
|
339
|
+
const proj = readProject(dir, io);
|
|
340
|
+
if (!proj || found.has(proj.id)) continue;
|
|
341
|
+
found.set(proj.id, proj);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return [...found.values()].sort((a, b) =>
|
|
345
|
+
a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The roots to enumerate: built-in project folders inside the install dir,
|
|
350
|
+
* plus the workshop content folder of every owning library.
|
|
351
|
+
* @param {string | null} installDir
|
|
352
|
+
* @param {string[]} libraryRoots
|
|
353
|
+
* @param {{ existsSync?: (p: string) => boolean }} [io]
|
|
354
|
+
* @returns {string[]}
|
|
355
|
+
*/
|
|
356
|
+
export function wallpaperRoots(installDir, libraryRoots, io = {}) {
|
|
357
|
+
const exists = io.existsSync || existsSync;
|
|
358
|
+
const roots = [];
|
|
359
|
+
if (installDir) {
|
|
360
|
+
for (const sub of ['defaultprojects', 'myprojects']) {
|
|
361
|
+
const p = join(installDir, 'projects', sub);
|
|
362
|
+
if (exists(p)) roots.push(p);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
for (const lib of libraryRoots) {
|
|
366
|
+
const ws = join(lib, 'steamapps', 'workshop', 'content', WE_APPID);
|
|
367
|
+
if (exists(ws)) roots.push(ws);
|
|
368
|
+
}
|
|
369
|
+
return roots;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── 4. Playlist model ────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Pull the playlist rows out of one WE config profile. Supports both schemas:
|
|
376
|
+
* modern: profile.general.playlists[]
|
|
377
|
+
* legacy: profile.general.wallpaperconfig.selectedwallpapers.<monitor>.playlist
|
|
378
|
+
*/
|
|
379
|
+
export function playlistRows(profile) {
|
|
380
|
+
const general = profile && typeof profile === 'object' ? profile.general : null;
|
|
381
|
+
if (!general || typeof general !== 'object') return [];
|
|
382
|
+
if (Array.isArray(general.playlists) && general.playlists.length) return general.playlists;
|
|
383
|
+
const selected = general.wallpaperconfig && general.wallpaperconfig.selectedwallpapers;
|
|
384
|
+
if (!selected || typeof selected !== 'object') return [];
|
|
385
|
+
return Object.values(selected)
|
|
386
|
+
.map((monitor) => monitor && monitor.playlist)
|
|
387
|
+
.filter((playlist) => playlist && typeof playlist === 'object');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Parse WE's config.json object into normalized playlists.
|
|
392
|
+
* Rows with no usable items are dropped; exact duplicates (same name + same
|
|
393
|
+
* item list) collapse. Pure — the caller reads the file.
|
|
394
|
+
* @param {unknown} config parsed config.json
|
|
395
|
+
* @returns {{ name: string, items: string[], order: 'sequence'|'random', delay: number|null }[]}
|
|
396
|
+
*/
|
|
397
|
+
export function parsePlaylists(config) {
|
|
398
|
+
if (!config || typeof config !== 'object') return [];
|
|
399
|
+
const result = [];
|
|
400
|
+
const seen = new Set();
|
|
401
|
+
for (const profile of Object.values(config)) {
|
|
402
|
+
for (const [index, row] of playlistRows(profile).entries()) {
|
|
403
|
+
if (!row || typeof row !== 'object') continue;
|
|
404
|
+
const items = Array.isArray(row.items)
|
|
405
|
+
? [...new Set(row.items.filter((item) => typeof item === 'string' && item.trim()))]
|
|
406
|
+
: [];
|
|
407
|
+
if (!items.length) continue;
|
|
408
|
+
const name = typeof row.name === 'string' && row.name.trim()
|
|
409
|
+
? row.name.trim() : `Playlist ${index + 1}`;
|
|
410
|
+
const signature = `${name}\u0000${items.join('\u0000')}`;
|
|
411
|
+
if (seen.has(signature)) continue;
|
|
412
|
+
seen.add(signature);
|
|
413
|
+
const settings = row.settings && typeof row.settings === 'object' ? row.settings : {};
|
|
414
|
+
result.push({
|
|
415
|
+
name,
|
|
416
|
+
items,
|
|
417
|
+
order: settings.order === 'random' ? 'random' : 'sequence',
|
|
418
|
+
delay: typeof settings.delay === 'number' && settings.delay >= 0 ? settings.delay : null,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return result;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Normalized lowercase path key for item matching. */
|
|
426
|
+
export function pathKey(file) {
|
|
427
|
+
return normalize(String(file).replace(/\//g, sep)).toLowerCase();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Resolve one WE playlist item (an absolute or install-relative path string)
|
|
432
|
+
* to an enumerated wallpaper id. Strategy order:
|
|
433
|
+
* 1. exact normalized path match against enumerated main files,
|
|
434
|
+
* 2. workshop url fragment `…/431960/<projectId>/…`,
|
|
435
|
+
* 3. trailing project-folder name (covers install-relative entries).
|
|
436
|
+
* @param {string} item
|
|
437
|
+
* @param {Map<string, string>} byPath pathKey(fileAbs) → id
|
|
438
|
+
* @param {Map<string, unknown>} byId id → project
|
|
439
|
+
* @returns {string | null}
|
|
440
|
+
*/
|
|
441
|
+
export function resolvePlaylistItem(item, byPath, byId) {
|
|
442
|
+
const exact = byPath.get(pathKey(item));
|
|
443
|
+
if (exact) return exact;
|
|
444
|
+
const workshop = /[\\/]431960[\\/]([^\\/]+)(?:[\\/]|$)/i.exec(item);
|
|
445
|
+
if (workshop && byId.has(workshop[1])) return workshop[1];
|
|
446
|
+
const folder = /[\\/]([^\\/]+)[\\/][^\\/]+$/i.exec(item);
|
|
447
|
+
if (folder && byId.has(folder[1])) return folder[1];
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── 5. HTTP helpers ──────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Parse an HTTP Range header for a resource of `size` bytes.
|
|
455
|
+
* Supports `bytes=a-b`, `bytes=a-`, `bytes=-suffix`.
|
|
456
|
+
* @returns {null | { start: number, end: number } | { error: true }}
|
|
457
|
+
* null → no/empty header, serve the whole body (200)
|
|
458
|
+
* error → unsatisfiable or malformed, answer 416
|
|
459
|
+
* start/end → inclusive byte range, answer 206
|
|
460
|
+
*/
|
|
461
|
+
export function parseRangeHeader(header, size) {
|
|
462
|
+
if (!header || typeof header !== 'string') return null;
|
|
463
|
+
if (!Number.isFinite(size) || size < 0) return { error: true };
|
|
464
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
465
|
+
if (!m) return { error: true };
|
|
466
|
+
const [, a, b] = m;
|
|
467
|
+
if (a === '' && b === '') return { error: true };
|
|
468
|
+
let start;
|
|
469
|
+
let end;
|
|
470
|
+
if (a === '') {
|
|
471
|
+
const suffix = parseInt(b, 10);
|
|
472
|
+
if (!Number.isFinite(suffix) || suffix <= 0) return { error: true };
|
|
473
|
+
start = Math.max(0, size - suffix);
|
|
474
|
+
end = size - 1;
|
|
475
|
+
} else {
|
|
476
|
+
start = parseInt(a, 10);
|
|
477
|
+
end = b === '' ? size - 1 : Math.min(parseInt(b, 10), size - 1);
|
|
478
|
+
}
|
|
479
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) return { error: true };
|
|
480
|
+
if (size === 0 || start > end || start >= size) return { error: true };
|
|
481
|
+
return { start, end };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Minimal MIME table for the files this bundle can legitimately serve. */
|
|
485
|
+
export function mimeFor(absPath) {
|
|
486
|
+
const m = /\.([^.]+)$/.exec(String(absPath));
|
|
487
|
+
const ext = m ? m[1].toLowerCase() : '';
|
|
488
|
+
return {
|
|
489
|
+
mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm',
|
|
490
|
+
mkv: 'video/x-matroska', avi: 'video/x-msvideo', mov: 'video/quicktime',
|
|
491
|
+
html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
|
|
492
|
+
js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8',
|
|
493
|
+
css: 'text/css; charset=utf-8', json: 'application/json; charset=utf-8',
|
|
494
|
+
jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
495
|
+
png: 'image/png', webp: 'image/webp', svg: 'image/svg+xml',
|
|
496
|
+
mp3: 'audio/mpeg', ogg: 'audio/ogg', wav: 'audio/wav',
|
|
497
|
+
woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf',
|
|
498
|
+
}[ext] || 'application/octet-stream';
|
|
499
|
+
}
|