dsh-folia 2.5.1 → 2.5.8
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/lib/boot/index.js +220 -0
- package/lib/client.js +314 -439
- package/lib/index.js +15 -9
- package/lib/routes/boot.js +325 -0
- package/lib/routes/inventory.js +207 -49
- package/lib/routes/modules.js +186 -0
- package/lib/routes/site-thumb.js +109 -0
- package/lib/settings-schema.js +5 -2
- package/package.json +5 -2
- package/lib/routes/skins.js +0 -653
package/lib/index.js
CHANGED
|
@@ -78,6 +78,10 @@ import { assertSubpackagesResolvable, assertSubpackagesResolvableInHost, httpVie
|
|
|
78
78
|
import { createHostSession } from './kernel/host-session.js';
|
|
79
79
|
export { extOf };
|
|
80
80
|
import { registerInventoryRoutes } from './routes/inventory.js';
|
|
81
|
+
import { registerModulesRoutes } from './routes/modules.js';
|
|
82
|
+
import { registerBootRoutes } from './routes/boot.js';
|
|
83
|
+
import { registerSiteThumbRoutes } from './routes/site-thumb.js';
|
|
84
|
+
import { installBootInjection } from './boot/index.js';
|
|
81
85
|
import { registerDiagRoutes } from './routes/diag.js';
|
|
82
86
|
// 遮挡消息桥族(上游新增域模块):hub 处理 x11 遮挡探针,路由对外暴露状态/控制面。
|
|
83
87
|
import { createOcclusionHub } from './occlusion/index.js';
|
|
@@ -94,7 +98,6 @@ import { registerPropsRoutes } from './routes/props.js';
|
|
|
94
98
|
import { registerLiveFrameRoutes } from './routes/live-frame.js';
|
|
95
99
|
import { registerSettingsRoutes } from './routes/settings.js';
|
|
96
100
|
import { registerCacheDirRoutes } from './routes/cache-dir.js';
|
|
97
|
-
import { registerSkinRoutes } from './routes/skins.js';
|
|
98
101
|
// C8′/C6 出户的组件行(P2-1 / P2-2):组件入口**静态** import(不是动态)—— `test/verify-reachability.mjs`
|
|
99
102
|
// 只从 ENTRY 沿 import 边走,动态加载的组件入口会被判定为不可达,棘轮当场变红。
|
|
100
103
|
// 组件名 → 实现表的登记在 `applyComponent` **函数体内**(P2-1b 起;模块级常量会让门面求值期
|
|
@@ -294,6 +297,17 @@ export function apply(ctx) {
|
|
|
294
297
|
registerInventoryRoutes(webServer, {
|
|
295
298
|
disposers, base: BASE, mediaOriginApi, buildInventory, componentsProbe: componentProbe,
|
|
296
299
|
});
|
|
300
|
+
// 数据模块索引(站点侧声明「有哪些数据模块」)→ lib/routes/modules.js。
|
|
301
|
+
// 与 inventory 同一套站点基址;读不到就退回内置集合,绝不让面板因为索引文件而空白。
|
|
302
|
+
registerModulesRoutes(webServer, { disposers, base: BASE });
|
|
303
|
+
|
|
304
|
+
// 开机动画 → lib/routes/boot.js(HTTP 面)+ lib/boot/index.js(注入与屏幕脚本)。
|
|
305
|
+
// 注入是「关就一行都不注」:没有选片时不产生任何脚本、不产生任何请求。
|
|
306
|
+
registerBootRoutes(webServer, { disposers, base: BASE, log });
|
|
307
|
+
|
|
308
|
+
// 站点图片缩略图代理 → lib/routes/site-thumb.js。卡片用 320px 小图,不再每张下 500 KB 原图。
|
|
309
|
+
registerSiteThumbRoutes(webServer, { disposers, base: BASE, ensureImagePreview, log });
|
|
310
|
+
installBootInjection(ctx, (m) => { try { log(m); } catch { /* ignore */ } });
|
|
297
311
|
|
|
298
312
|
// 3./3a. 派生媒体族路由(源元信息 / 抽帧转码 + 进度 / 视频缩略图)→ lib/routes/media-derived.js。
|
|
299
313
|
// 四条注册与它们的依赖都在那个文件里;这里只声明它的依赖。**调用点必须早于下面的
|
|
@@ -496,14 +510,6 @@ export function apply(ctx) {
|
|
|
496
510
|
normalizeUserDir, cacheBaseDir, setCacheDir, armBodyIdleTimeout,
|
|
497
511
|
});
|
|
498
512
|
|
|
499
|
-
// 7c. 「皮肤包」数据源(自建站)→ lib/routes/skins.js。一条 prefix 注册带五个动作
|
|
500
|
-
// (索引 / 安装 / 激活 / 卸载 / 预览图代理)。
|
|
501
|
-
// ⚠️ 渲染不在这里:落到 `harness/skins/<id>/` 之后,应用样式与 hooks 的仍是
|
|
502
|
-
// 皮肤中心(dsh-skin-center)。本族只负责"取、验、落、切"。
|
|
503
|
-
registerSkinRoutes(webServer, {
|
|
504
|
-
disposers, base: BASE, log,
|
|
505
|
-
});
|
|
506
|
-
|
|
507
513
|
// fiber disposer 的语义(先逐个 disposer、再 releaseKernelLease(hostKernel)——内核引用计数
|
|
508
514
|
// 归零才 `transcodeKit.dispose()`——再 destroy 在途流、清 activeStreams / mediaMap)
|
|
509
515
|
// 逐字搬进 lib/kernel/host-session.js 的 `dispose()`。
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* routes/boot.js — 开机动画的 HTTP 面:列候选 / 下载 / 选择 / 删除 / 流式播放。
|
|
3
|
+
*
|
|
4
|
+
* 数据来自两处:
|
|
5
|
+
* · **候选清单** = 模块索引里的 `boot` 模块(`manifest` 指向一份 `{ clips: [...] }`),
|
|
6
|
+
* 站点优先、R2 兜底 —— 复用 `routes/modules.js` 的解析,不另造一套基址。
|
|
7
|
+
* · **本地文件** = `~/.dsh-folia/boot/<id>.mp4`,由「下载」写进来。
|
|
8
|
+
*
|
|
9
|
+
* 播放走 `/folia/boot/clip`,**支持 Range**:注入的 `<video>` 与浏览器都靠它边下边播。
|
|
10
|
+
* 这也是为什么片段必须先落盘 —— 启动路径上不能有网络。
|
|
11
|
+
*/
|
|
12
|
+
import { createReadStream, existsSync, statSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { sendJson } from '../json-response.js';
|
|
15
|
+
import { bodyReader } from '../http-body.js';
|
|
16
|
+
import { resolveModules } from './modules.js';
|
|
17
|
+
import { bootDir, clipExists, clipFile, listLocalClips, readBoot, removeClip, writeBoot } from '../boot/index.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 站点基址。开机动画的候选**从站点分类取**(`?category=boot-animation`),
|
|
21
|
+
* 与壁纸页签用的是同一个接口 —— 站点上看到 3 条,这里就是 3 条,不会各说各话。
|
|
22
|
+
*/
|
|
23
|
+
const DEFAULT_SITE = 'https://folia-5ve.pages.dev';
|
|
24
|
+
const BOOT_CATEGORY = 'boot-animation';
|
|
25
|
+
function siteBase() {
|
|
26
|
+
const raw = process.env.DSH_FOLIA_SITE;
|
|
27
|
+
if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return null;
|
|
28
|
+
const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : DEFAULT_SITE;
|
|
29
|
+
return base.replace(/\/+$/, '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CLIP_MAX_BYTES = 64 * 1024 * 1024;
|
|
33
|
+
const FETCH_TIMEOUT_MS = 60000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 读完一个小 JSON 请求体。
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ `bodyReader()` 是**流式**收体器(返回 `{ onData, text() }`),不是 Promise ——
|
|
39
|
+
* 必须自己把 `req` 的 data/end 挂上去。把它 `await` 出来会拿到那个对象,
|
|
40
|
+
* `JSON.parse` 立刻抛、被 catch 成 null,于是每个写操作都报 `bad-id`。
|
|
41
|
+
*/
|
|
42
|
+
function readBodyText(req, maxBytes) {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
let tooLarge = false;
|
|
45
|
+
const reader = bodyReader(req, { maxBytes, onOverflow: () => { tooLarge = true; } });
|
|
46
|
+
const finish = () => resolve(tooLarge ? '' : reader.text());
|
|
47
|
+
try {
|
|
48
|
+
req.on('data', reader.onData);
|
|
49
|
+
req.on('end', finish);
|
|
50
|
+
req.on('error', () => resolve(''));
|
|
51
|
+
} catch {
|
|
52
|
+
resolve('');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 读请求体里的 `{ id }`。 */
|
|
58
|
+
async function readIdBody(req) {
|
|
59
|
+
try {
|
|
60
|
+
const text = await readBodyText(req, 64 * 1024);
|
|
61
|
+
const doc = JSON.parse(text || '{}');
|
|
62
|
+
const id = String((doc && doc.id) || '').trim();
|
|
63
|
+
return /^[a-z0-9][a-z0-9._-]*$/i.test(id) ? id : null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 候选清单:模块索引里的 `boot` 模块 → 拉它的 manifest → `{ clips: [...] }`。
|
|
71
|
+
* 失败一律返回空数组(面板显示"没有候选"),绝不抛。
|
|
72
|
+
*/
|
|
73
|
+
async function fetchCandidates() {
|
|
74
|
+
// ① 站点分类接口(与壁纸同一个接口,只是 category 不同)
|
|
75
|
+
const site = siteBase();
|
|
76
|
+
if (site) {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(site + '/api/public/wallpapers?category=' + BOOT_CATEGORY, {
|
|
79
|
+
headers: { accept: 'application/json' },
|
|
80
|
+
signal: AbortSignal.timeout(15000),
|
|
81
|
+
});
|
|
82
|
+
if (res.ok) {
|
|
83
|
+
const doc = await res.json();
|
|
84
|
+
const items = (doc && doc.data && Array.isArray(doc.data.items)) ? doc.data.items : [];
|
|
85
|
+
const clips = items.map((it) => ({
|
|
86
|
+
id: String((it && (it.slug || it.id)) || ''),
|
|
87
|
+
title: String((it && it.title) || (it && it.slug) || ''),
|
|
88
|
+
// 站点接口给的是相对路径(/media/...),下载时要绝对地址 —— 这里补上,
|
|
89
|
+
// 否则 fetch 一个相对路径会当场失败。
|
|
90
|
+
url: (() => { const u = String((it && it.url) || ''); return /^https?:/i.test(u) ? u : (u ? site + u : ''); })(),
|
|
91
|
+
bytes: Number(it && it.bytes) || null,
|
|
92
|
+
sha256: it && it.sha256 ? String(it.sha256) : null,
|
|
93
|
+
durationSec: it && it.durationMs ? Number(it.durationMs) / 1000 : null,
|
|
94
|
+
faststart: true,
|
|
95
|
+
})).filter((c) => c.id && c.url);
|
|
96
|
+
if (clips.length) return { clips, error: null };
|
|
97
|
+
}
|
|
98
|
+
} catch { /* 落到模块清单 */ }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ② 模块索引里的 boot 清单(站点不可达时的兜底)
|
|
102
|
+
try {
|
|
103
|
+
const index = await resolveModules(false);
|
|
104
|
+
const mod = (index.modules || []).find((m) => m.id === 'boot');
|
|
105
|
+
if (!mod || !mod.manifest || mod.status === 'disabled') return { clips: [], error: 'no-module' };
|
|
106
|
+
const res = await fetch(mod.manifest, {
|
|
107
|
+
headers: { accept: 'application/json' },
|
|
108
|
+
signal: AbortSignal.timeout(15000),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) return { clips: [], error: 'http-' + res.status };
|
|
111
|
+
const doc = await res.json();
|
|
112
|
+
const list = Array.isArray(doc && doc.clips) ? doc.clips : [];
|
|
113
|
+
const base = mod.manifest.replace(/\/[^/]*$/, '');
|
|
114
|
+
const clips = list.map((c) => {
|
|
115
|
+
const file = String((c && (c.file || c.id)) || '');
|
|
116
|
+
const url = c && c.url ? String(c.url) : (base + '/' + file);
|
|
117
|
+
return {
|
|
118
|
+
id: String((c && c.id) || file.replace(/\.mp4$/i, '')),
|
|
119
|
+
title: String((c && (c.name || c.title)) || c.id || file),
|
|
120
|
+
url,
|
|
121
|
+
bytes: Number(c && c.bytes) || null,
|
|
122
|
+
sha256: c && c.sha256 ? String(c.sha256) : null,
|
|
123
|
+
durationSec: Number(c && c.durationSec) || null,
|
|
124
|
+
faststart: c && c.faststart === true,
|
|
125
|
+
};
|
|
126
|
+
}).filter((c) => c.id && c.url);
|
|
127
|
+
return { clips, error: null };
|
|
128
|
+
} catch (e) {
|
|
129
|
+
return { clips: [], error: 'network' };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function registerBootRoutes(webServer, c) {
|
|
134
|
+
const { disposers, base: BASE, log } = c;
|
|
135
|
+
|
|
136
|
+
/* ① 候选 + 本地状态(面板一次拿全) */
|
|
137
|
+
disposers.push(webServer.register({
|
|
138
|
+
kind: 'exact',
|
|
139
|
+
path: `${BASE}/boot/list`,
|
|
140
|
+
handler: async (req, res) => {
|
|
141
|
+
let force = false;
|
|
142
|
+
try { force = new URL(req.url || '/', 'http://x').searchParams.get('refresh') === '1'; } catch { /* ignore */ }
|
|
143
|
+
if (force) await resolveModules(true);
|
|
144
|
+
const [boot, candidates] = await Promise.all([Promise.resolve(readBoot()), fetchCandidates()]);
|
|
145
|
+
const local = listLocalClips();
|
|
146
|
+
const localById = new Map(local.map((x) => [x.id, x]));
|
|
147
|
+
const clips = candidates.clips.map((c) => {
|
|
148
|
+
const have = localById.get(c.id) || null;
|
|
149
|
+
return { ...c, installed: Boolean(have), localBytes: have ? have.bytes : null };
|
|
150
|
+
});
|
|
151
|
+
// 本地有、清单里没有的(我们换了站点/清单):也列出来,否则用户删不掉。
|
|
152
|
+
const known = new Set(clips.map((c) => c.id));
|
|
153
|
+
for (const it of local) {
|
|
154
|
+
if (known.has(it.id)) continue;
|
|
155
|
+
clips.push({
|
|
156
|
+
id: it.id, title: it.title, url: null, bytes: null, sha256: null,
|
|
157
|
+
durationSec: it.durationSec, faststart: true,
|
|
158
|
+
installed: true, localBytes: it.bytes, orphan: true,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
sendJson(res, 200, {
|
|
162
|
+
ok: true,
|
|
163
|
+
enabled: boot.enabled,
|
|
164
|
+
clipId: boot.clipId,
|
|
165
|
+
enterMode: boot.enterMode,
|
|
166
|
+
fadeMs: boot.fadeMs,
|
|
167
|
+
minShowMs: boot.minShowMs,
|
|
168
|
+
maxHoldMs: boot.maxHoldMs,
|
|
169
|
+
sound: boot.sound,
|
|
170
|
+
clips,
|
|
171
|
+
error: candidates.error,
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
/* ② 下载一条到本地 */
|
|
177
|
+
disposers.push(webServer.register({
|
|
178
|
+
kind: 'exact',
|
|
179
|
+
path: `${BASE}/boot/download`,
|
|
180
|
+
handler: async (req, res) => {
|
|
181
|
+
const id = await readIdBody(req);
|
|
182
|
+
if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
|
|
183
|
+
const { clips } = await fetchCandidates();
|
|
184
|
+
const item = clips.find((c) => c.id === id);
|
|
185
|
+
if (!item) { sendJson(res, 404, { ok: false, error: 'not-in-catalog' }); return; }
|
|
186
|
+
const dest = clipFile(id);
|
|
187
|
+
if (!dest) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
|
|
188
|
+
try {
|
|
189
|
+
const r = await fetch(item.url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
190
|
+
if (!r.ok) { sendJson(res, 502, { ok: false, error: 'http-' + r.status }); return; }
|
|
191
|
+
const ct = String(r.headers.get('content-type') || '');
|
|
192
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
193
|
+
// 站点把未知路径回落到 HTML —— 这类"伪 200"绝不能当成视频存下来(否则每次开机黑屏)。
|
|
194
|
+
if (!/video\//i.test(ct) || buf.length < 1024 || buf.toString('latin1', 4, 8) !== 'ftyp') {
|
|
195
|
+
sendJson(res, 422, { ok: false, error: 'not-a-video', detail: ct || 'no content-type' });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (buf.length > CLIP_MAX_BYTES) { sendJson(res, 413, { ok: false, error: 'too-large' }); return; }
|
|
199
|
+
writeFileSync(dest, buf);
|
|
200
|
+
try {
|
|
201
|
+
writeFileSync(join(bootDir(), id + '.json'), JSON.stringify({
|
|
202
|
+
title: item.title, durationSec: item.durationSec, bytes: buf.length, sha256: item.sha256 || null,
|
|
203
|
+
}, null, 2) + '\n');
|
|
204
|
+
} catch { /* meta 只是锦上添花 */ }
|
|
205
|
+
if (typeof log === 'function') log(`[boot] downloaded ${id} (${(buf.length / 1048576).toFixed(1)} MB)`);
|
|
206
|
+
sendJson(res, 200, { ok: true, id, bytes: buf.length });
|
|
207
|
+
} catch (e) {
|
|
208
|
+
sendJson(res, 500, { ok: false, error: 'fetch-failed', detail: String((e && e.message) || e) });
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
}));
|
|
212
|
+
|
|
213
|
+
/* ③ 选择(并顺手启用) */
|
|
214
|
+
disposers.push(webServer.register({
|
|
215
|
+
kind: 'exact',
|
|
216
|
+
path: `${BASE}/boot/select`,
|
|
217
|
+
handler: async (req, res) => {
|
|
218
|
+
const id = await readIdBody(req);
|
|
219
|
+
if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
|
|
220
|
+
if (!clipExists(id)) { sendJson(res, 404, { ok: false, error: 'not-installed' }); return; }
|
|
221
|
+
const boot = writeBoot({ clipId: id, enabled: true });
|
|
222
|
+
if (typeof log === 'function') log(`[boot] selected ${id}`);
|
|
223
|
+
sendJson(res, 200, { ok: true, ...boot });
|
|
224
|
+
},
|
|
225
|
+
}));
|
|
226
|
+
|
|
227
|
+
/* ④ 开关 */
|
|
228
|
+
disposers.push(webServer.register({
|
|
229
|
+
kind: 'exact',
|
|
230
|
+
path: `${BASE}/boot/toggle`,
|
|
231
|
+
handler: async (req, res) => {
|
|
232
|
+
let enabled = false;
|
|
233
|
+
try {
|
|
234
|
+
const text = await readBodyText(req, 8 * 1024);
|
|
235
|
+
const doc = JSON.parse(text || '{}');
|
|
236
|
+
enabled = Boolean(doc && doc.enabled);
|
|
237
|
+
} catch { /* 坏请求当 false */ }
|
|
238
|
+
const boot = writeBoot({ enabled });
|
|
239
|
+
sendJson(res, 200, { ok: true, ...boot });
|
|
240
|
+
},
|
|
241
|
+
}));
|
|
242
|
+
|
|
243
|
+
/* ⑤ 设置(淡出 / 最短显示 / 最长停留) */
|
|
244
|
+
disposers.push(webServer.register({
|
|
245
|
+
kind: 'exact',
|
|
246
|
+
path: `${BASE}/boot/settings`,
|
|
247
|
+
handler: async (req, res) => {
|
|
248
|
+
let patch = {};
|
|
249
|
+
try {
|
|
250
|
+
const text = await readBodyText(req, 8 * 1024);
|
|
251
|
+
const doc = JSON.parse(text || '{}');
|
|
252
|
+
if (doc && typeof doc === 'object') patch = doc;
|
|
253
|
+
} catch { /* 坏请求 = 不改 */ }
|
|
254
|
+
const boot = writeBoot({
|
|
255
|
+
...(patch.enterMode !== undefined ? { enterMode: String(patch.enterMode) } : {}),
|
|
256
|
+
...(patch.fadeMs !== undefined ? { fadeMs: Number(patch.fadeMs) } : {}),
|
|
257
|
+
...(patch.minShowMs !== undefined ? { minShowMs: Number(patch.minShowMs) } : {}),
|
|
258
|
+
...(patch.maxHoldMs !== undefined ? { maxHoldMs: Number(patch.maxHoldMs) } : {}),
|
|
259
|
+
});
|
|
260
|
+
sendJson(res, 200, { ok: true, ...boot });
|
|
261
|
+
},
|
|
262
|
+
}));
|
|
263
|
+
|
|
264
|
+
/* ⑥ 删除本地片段 */
|
|
265
|
+
disposers.push(webServer.register({
|
|
266
|
+
kind: 'exact',
|
|
267
|
+
path: `${BASE}/boot/remove`,
|
|
268
|
+
handler: async (req, res) => {
|
|
269
|
+
const id = await readIdBody(req);
|
|
270
|
+
if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
|
|
271
|
+
const boot = readBoot();
|
|
272
|
+
if (boot.clipId === id) writeBoot({ clipId: '', enabled: false });
|
|
273
|
+
const ok = removeClip(id);
|
|
274
|
+
sendJson(res, 200, { ok, id });
|
|
275
|
+
},
|
|
276
|
+
}));
|
|
277
|
+
|
|
278
|
+
/* ⑦ 流式播放(Range) */
|
|
279
|
+
disposers.push(webServer.register({
|
|
280
|
+
kind: 'exact',
|
|
281
|
+
path: `${BASE}/boot/clip`,
|
|
282
|
+
handler: async (req, res) => {
|
|
283
|
+
const boot = readBoot();
|
|
284
|
+
const id = boot.clipId;
|
|
285
|
+
const file = clipFile(id);
|
|
286
|
+
if (!file || !existsSync(file)) { res.statusCode = 404; res.end('no clip'); return; }
|
|
287
|
+
const size = statSync(file).size;
|
|
288
|
+
const range = String((req.headers && req.headers.range) || '');
|
|
289
|
+
const type = 'video/mp4';
|
|
290
|
+
// Range:`bytes=start-end` —— Chromium 播放本地大文件时会先发一小段探测请求。
|
|
291
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
|
|
292
|
+
if (m) {
|
|
293
|
+
let start = m[1] === '' ? null : Number(m[1]);
|
|
294
|
+
let end = m[2] === '' ? null : Number(m[2]);
|
|
295
|
+
if (start === null && end !== null) { start = Math.max(0, size - end); end = size - 1; }
|
|
296
|
+
if (start === null) start = 0;
|
|
297
|
+
if (end === null || end >= size) end = size - 1;
|
|
298
|
+
if (start > end || start >= size) {
|
|
299
|
+
res.statusCode = 416;
|
|
300
|
+
if (typeof res.setHeader === 'function') res.setHeader('content-range', `bytes */${size}`);
|
|
301
|
+
res.end();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (typeof res.setHeader === 'function') {
|
|
305
|
+
res.setHeader('content-type', type);
|
|
306
|
+
res.setHeader('content-length', String(end - start + 1));
|
|
307
|
+
res.setHeader('content-range', `bytes ${start}-${end}/${size}`);
|
|
308
|
+
res.setHeader('accept-ranges', 'bytes');
|
|
309
|
+
res.setHeader('cache-control', 'no-store');
|
|
310
|
+
}
|
|
311
|
+
res.statusCode = 206;
|
|
312
|
+
createReadStream(file, { start, end }).pipe(res);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (typeof res.setHeader === 'function') {
|
|
316
|
+
res.setHeader('content-type', type);
|
|
317
|
+
res.setHeader('content-length', String(size));
|
|
318
|
+
res.setHeader('accept-ranges', 'bytes');
|
|
319
|
+
res.setHeader('cache-control', 'no-store');
|
|
320
|
+
}
|
|
321
|
+
res.statusCode = 200;
|
|
322
|
+
createReadStream(file).pipe(res);
|
|
323
|
+
},
|
|
324
|
+
}));
|
|
325
|
+
}
|
package/lib/routes/inventory.js
CHANGED
|
@@ -84,8 +84,13 @@ function rowStatusOf(probe) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/* ── Folia 站点数据源 ────────────────────────────────────────────────────────
|
|
87
|
-
* 把自建站点(默认 `https://
|
|
88
|
-
*
|
|
87
|
+
* 把自建站点(默认 `https://pub-1275321f146e479eaac54948f4cdad6f.r2.dev`)清单里的条目
|
|
88
|
+
* 并进 inventory,于是「本机 Wallpaper Engine 工坊 + 上传库 + 站点」三者在客户端看来是
|
|
89
|
+
* 同一个壁纸库。
|
|
90
|
+
*
|
|
91
|
+
* 为什么默认值是 folia-5ve.pages.dev:站点后台(D1 + admin)产出 /data/catalog.json,
|
|
92
|
+
* 素材 `<base>/boot/*`)。旧站 `folia-major-3t6.pages.dev` 已废弃 —— 但**换站不需要改代码**,
|
|
93
|
+
* 用 `DSH_FOLIA_SITE` 覆盖即可。
|
|
89
94
|
*
|
|
90
95
|
* 契约:
|
|
91
96
|
* · 地址由 `DSH_FOLIA_SITE` 覆盖;`=off` 关闭整条通道(测试/离线场景用)。
|
|
@@ -95,9 +100,35 @@ function rowStatusOf(probe) {
|
|
|
95
100
|
* · 产出条目**自带绝对 URL**(`media` / `preview`),客户端 `apiUrl()` 对绝对 URL 原样放行。
|
|
96
101
|
*
|
|
97
102
|
* 站点格式(`<site>/manifest.json`):`{ images: [{ file, title, url, preview, kind?, animated? }] }`
|
|
98
|
-
* —— 与 `folia-wallpaper-site`
|
|
103
|
+
* —— 与 `folia-wallpaper-site` 产物一致;`url`/`preview` 可以是相对路径(相对站点基址)。
|
|
104
|
+
*/
|
|
105
|
+
const FOLIA_SITE = 'https://folia-5ve.pages.dev';
|
|
106
|
+
/**
|
|
107
|
+
* 站点数据源的两个可能形态,按顺序尝试:
|
|
108
|
+
* · `/data/catalog.json` —— 站点(D1 + admin)产出的目录,**当前的真源**
|
|
109
|
+
* · `/manifest.json` —— 早期静态清单,保留兼容(站点上没有 catalog 时回退)
|
|
110
|
+
* 顺序有讲究:catalog 是站点后台维护的,manifest 是我们手写的;两者都在时以站点为准。
|
|
111
|
+
*/
|
|
112
|
+
/**
|
|
113
|
+
* 站点的**实时接口**。它比静态快照优先:快照只在部署时生成(我们实测过它停在某个时间点,
|
|
114
|
+
* 而站点后台早就加了新图),接口才是"你传完就能看到"的那条路。
|
|
115
|
+
*
|
|
116
|
+
* 形状:`{ ok, data: { items: [...], total, hasMore, nextCursor } }`,按游标分页。
|
|
99
117
|
*/
|
|
100
|
-
const
|
|
118
|
+
const FOLIA_API_PATH = '/api/public/wallpapers';
|
|
119
|
+
/**
|
|
120
|
+
* 只要壁纸分类。站点分类不止壁纸(还有 `boot-animation` / `skin-pack` / 桌宠),
|
|
121
|
+
* 而它们的 `kind` 同样是 `dynamic`/`static` —— 只按类型判会把开机动画当成壁纸拉进来。
|
|
122
|
+
* 服务端支持 `?category=`,优先用它(少传无用数据);客户端的 `foliaIsWallpaperCategory`
|
|
123
|
+
* 是兜底,防服务端忽略参数或以后新增分类。
|
|
124
|
+
*/
|
|
125
|
+
const FOLIA_API_CATEGORY = 'wallpaper';
|
|
126
|
+
/** 明确**不是**壁纸的分类。没列进来的(含未来新增的)一律放行 —— 宁可多显示,不静默丢。 */
|
|
127
|
+
const FOLIA_NON_WALLPAPER_CATEGORIES = ['boot-animation', 'skin-pack', 'skin', 'pet', 'pets', 'assistant-skin'];
|
|
128
|
+
/** 分页上限:防止站点游标异常时无限翻页(每页 12~24,20 页足够几百条)。 */
|
|
129
|
+
const FOLIA_API_MAX_PAGES = 20;
|
|
130
|
+
const FOLIA_CATALOG_PATH = '/data/catalog.json';
|
|
131
|
+
const FOLIA_MANIFEST_PATH = '/manifest.json';
|
|
101
132
|
const FOLIA_TTL_MS = 60000;
|
|
102
133
|
const FOLIA_TIMEOUT_DEFAULT_MS = 4000;
|
|
103
134
|
const FOLIA_TIMEOUT_MIN_MS = 1000;
|
|
@@ -133,6 +164,106 @@ function foliaAbs(base, u) {
|
|
|
133
164
|
return base + (s.startsWith('/') ? s : '/' + s);
|
|
134
165
|
}
|
|
135
166
|
|
|
167
|
+
/**
|
|
168
|
+
* 条目类型。优先级:**mime** > kind > URL 后缀。
|
|
169
|
+
*
|
|
170
|
+
* 站点目录的条目长这样:`{ slug, title, kind: 'dynamic', mime: 'video/mp4', url: '/media/...' }` ——
|
|
171
|
+
* 它没有 `file` 字段,而且 `kind: 'dynamic'` 既可能是视频也可能是有动画的图,所以 mime 最可靠。
|
|
172
|
+
*/
|
|
173
|
+
function foliaTypeOfEntry(it) {
|
|
174
|
+
const mime = String((it && it.mime) || '').toLowerCase();
|
|
175
|
+
if (mime.startsWith('video/')) return 'video';
|
|
176
|
+
if (mime.startsWith('image/')) return 'image';
|
|
177
|
+
const kind = String((it && it.kind) || '').toLowerCase();
|
|
178
|
+
if (kind === 'video' || kind === 'dynamic') return 'video';
|
|
179
|
+
if (kind === 'image') return 'image';
|
|
180
|
+
const probe = String((it && (it.file || it.url || it.slug)) || '');
|
|
181
|
+
if (/\.(mp4|webm|mov|m4v)$/i.test(probe)) return 'video';
|
|
182
|
+
if (/\.(jpg|jpeg|png|webp|gif|avif)$/i.test(probe)) return 'image';
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 条目的分类 slug(没有就返回空串)。 */
|
|
187
|
+
function foliaCategoryOf(it) {
|
|
188
|
+
const c = it && it.category;
|
|
189
|
+
return String((c && c.slug) || '').trim().toLowerCase();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 它是不是一条"壁纸"。黑名单制:只有明确属于其它模块的分类才排除。
|
|
194
|
+
* 不用白名单,是因为站点以后加分类时白名单会静默把新东西吞掉。
|
|
195
|
+
*/
|
|
196
|
+
function foliaIsWallpaperCategory(it) {
|
|
197
|
+
const slug = foliaCategoryOf(it);
|
|
198
|
+
if (!slug) return true;
|
|
199
|
+
return !FOLIA_NON_WALLPAPER_CATEGORIES.includes(slug);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 站点目录的一条 → inventory 条目。判不出类型/没有媒体地址就返回 null。 */
|
|
203
|
+
function foliaItemFromCatalog(it, base) {
|
|
204
|
+
const type = foliaTypeOfEntry(it);
|
|
205
|
+
if (!type) return null;
|
|
206
|
+
const media = foliaAbs(base, it && it.url);
|
|
207
|
+
if (!media) return null;
|
|
208
|
+
const slug = String((it && (it.slug || it.id)) || '');
|
|
209
|
+
// 站点图片的预览走宿主缩略图代理:卡片只下 ~10 KB,不再下 500 KB 原图。
|
|
210
|
+
// (视频没有封面,保持 null —— 卡片会显示「无预览」,不会去拉整段视频。)
|
|
211
|
+
const thumb = type === 'image' ? '/folia/site-thumb?u=' + encodeURIComponent(media) : null;
|
|
212
|
+
const extHit = String(media).match(/\.([a-z0-9]{2,5})(?:[?#]|$)/i);
|
|
213
|
+
const num = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : undefined);
|
|
214
|
+
return {
|
|
215
|
+
id: 'folia-' + (slug || media),
|
|
216
|
+
title: String((it && it.title) || slug || 'Folia'),
|
|
217
|
+
type, // 'video' | 'image'
|
|
218
|
+
contentrating: null,
|
|
219
|
+
playable: true,
|
|
220
|
+
media, // 绝对 URL
|
|
221
|
+
mediaExt: '.' + (extHit ? extHit[1].toLowerCase() : (type === 'video' ? 'mp4' : 'jpg')),
|
|
222
|
+
// 站点目录可能带 preview/poster;图片没封面时用自己当预览,视频没有就留空(卡片会是黑块)
|
|
223
|
+
preview: thumb || foliaAbs(base, it && (it.preview || it.poster)) || null,
|
|
224
|
+
frameUrl: null,
|
|
225
|
+
schemeColor: null,
|
|
226
|
+
sceneLiveSrc: null,
|
|
227
|
+
sceneVideo: null,
|
|
228
|
+
sceneAudio: null,
|
|
229
|
+
propsUrl: null,
|
|
230
|
+
liveFrame: null,
|
|
231
|
+
source: 'folia',
|
|
232
|
+
bytes: num(it && it.bytes),
|
|
233
|
+
width: num(it && it.width),
|
|
234
|
+
height: num(it && it.height),
|
|
235
|
+
durationMs: num(it && it.durationMs),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 旧静态清单 `{ images: [...] }` 的一条 → inventory 条目。 */
|
|
240
|
+
function foliaItemFromManifest(it, base) {
|
|
241
|
+
const type = foliaTypeOfEntry(it);
|
|
242
|
+
if (!type) return null;
|
|
243
|
+
const file = String((it && (it.file || it.id)) || '');
|
|
244
|
+
const media = foliaAbs(base, it && it.url);
|
|
245
|
+
if (!media) return null;
|
|
246
|
+
const ext = (file.split('.').pop() || (type === 'video' ? 'mp4' : 'jpg')).toLowerCase();
|
|
247
|
+
return {
|
|
248
|
+
id: 'folia-' + (file || media),
|
|
249
|
+
title: String((it && it.title) || file || 'Folia'),
|
|
250
|
+
type,
|
|
251
|
+
contentrating: null,
|
|
252
|
+
playable: true,
|
|
253
|
+
media,
|
|
254
|
+
mediaExt: '.' + ext,
|
|
255
|
+
preview: foliaAbs(base, it && it.preview) || (type === 'image' ? media : null),
|
|
256
|
+
frameUrl: null,
|
|
257
|
+
schemeColor: null,
|
|
258
|
+
sceneLiveSrc: null,
|
|
259
|
+
sceneVideo: null,
|
|
260
|
+
sceneAudio: null,
|
|
261
|
+
propsUrl: null,
|
|
262
|
+
liveFrame: null,
|
|
263
|
+
source: 'folia',
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
136
267
|
/** 条目类型:先看 `kind`,回落到文件后缀。判不出返回 null(丢弃)。 */
|
|
137
268
|
function foliaTypeOf(it) {
|
|
138
269
|
const file = String(it && it.file ? it.file : '');
|
|
@@ -160,65 +291,92 @@ async function foliaRemote(force = false) {
|
|
|
160
291
|
}
|
|
161
292
|
|
|
162
293
|
const startedAt = Date.now();
|
|
163
|
-
const url = base + '/manifest.json';
|
|
164
294
|
/** @type {any[]} */
|
|
165
295
|
let items = [];
|
|
166
296
|
let error = null;
|
|
297
|
+
let usedPath = FOLIA_CATALOG_PATH;
|
|
167
298
|
|
|
168
|
-
|
|
299
|
+
/** 抓一个 JSON;返回 { doc, error },永不抛。 */
|
|
300
|
+
const fetchJson = async (path) => {
|
|
169
301
|
const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
|
|
170
302
|
? AbortSignal.timeout(foliaTimeoutMs())
|
|
171
303
|
: undefined;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
error
|
|
175
|
-
} else {
|
|
176
|
-
let doc;
|
|
304
|
+
try {
|
|
305
|
+
const res = await fetch(base + path, { headers: { accept: 'application/json' }, signal });
|
|
306
|
+
if (!res.ok) return { doc: null, error: 'http-' + res.status };
|
|
177
307
|
try {
|
|
178
|
-
doc
|
|
308
|
+
return { doc: await res.json(), error: null };
|
|
179
309
|
} catch {
|
|
180
|
-
error
|
|
181
|
-
}
|
|
182
|
-
if (doc) {
|
|
183
|
-
const list = Array.isArray(doc.images) ? doc.images : [];
|
|
184
|
-
for (const it of list) {
|
|
185
|
-
const type = foliaTypeOf(it);
|
|
186
|
-
if (!type) continue;
|
|
187
|
-
const file = String((it && (it.file || it.id)) || '');
|
|
188
|
-
const media = foliaAbs(base, it && it.url);
|
|
189
|
-
if (!media) continue;
|
|
190
|
-
const ext = (file.split('.').pop() || (type === 'video' ? 'mp4' : 'jpg')).toLowerCase();
|
|
191
|
-
items.push({
|
|
192
|
-
id: 'folia-' + (file || media),
|
|
193
|
-
title: String((it && it.title) || file || 'Folia'),
|
|
194
|
-
type, // 'video' | 'image'
|
|
195
|
-
contentrating: null,
|
|
196
|
-
playable: true,
|
|
197
|
-
media, // 绝对 URL
|
|
198
|
-
mediaExt: '.' + ext,
|
|
199
|
-
preview: foliaAbs(base, it && it.preview) || (type === 'image' ? media : null),
|
|
200
|
-
frameUrl: null,
|
|
201
|
-
schemeColor: null,
|
|
202
|
-
sceneLiveSrc: null,
|
|
203
|
-
sceneVideo: null,
|
|
204
|
-
sceneAudio: null,
|
|
205
|
-
propsUrl: null,
|
|
206
|
-
liveFrame: null,
|
|
207
|
-
source: 'folia', // 客户端可据此区分来源(未知字段会被忽略)
|
|
208
|
-
});
|
|
209
|
-
}
|
|
310
|
+
return { doc: null, error: 'json' };
|
|
210
311
|
}
|
|
312
|
+
} catch (e) {
|
|
313
|
+
const code = String((e && (e.name || e.code)) || '');
|
|
314
|
+
const msg = String((e && e.message) || '');
|
|
315
|
+
if (/timeout|abort/i.test(code) || /timeout|aborted/i.test(msg)) return { doc: null, error: 'timeout' };
|
|
316
|
+
if (/cert|tls|ssl/i.test(msg)) return { doc: null, error: 'tls' };
|
|
317
|
+
return { doc: null, error: 'network' };
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 拉接口的全部条目(按游标翻页)。任何一页失败就停,用已拿到的那些 ——
|
|
323
|
+
* 半份列表也比空列表有用。
|
|
324
|
+
*/
|
|
325
|
+
const fetchApiItems = async () => {
|
|
326
|
+
const out = [];
|
|
327
|
+
let cursor = null;
|
|
328
|
+
for (let page = 0; page < FOLIA_API_MAX_PAGES; page++) {
|
|
329
|
+
const scope = '?category=' + encodeURIComponent(FOLIA_API_CATEGORY);
|
|
330
|
+
const qs = cursor ? FOLIA_API_PATH + scope + '&cursor=' + encodeURIComponent(cursor) : FOLIA_API_PATH + scope;
|
|
331
|
+
const { doc, error } = await fetchJson(qs);
|
|
332
|
+
if (!doc || !doc.ok || !doc.data) return { items: out, error: error || 'api-shape' };
|
|
333
|
+
const items = Array.isArray(doc.data.items) ? doc.data.items : [];
|
|
334
|
+
out.push(...items);
|
|
335
|
+
if (!doc.data.hasMore || !doc.data.nextCursor) return { items: out, error: null };
|
|
336
|
+
cursor = doc.data.nextCursor;
|
|
211
337
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
338
|
+
return { items: out, error: null };
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// ① 站点**接口**(真源):`{ ok, data: { items: [...] } }`
|
|
342
|
+
const api = await fetchApiItems();
|
|
343
|
+
if (api.items.length) {
|
|
344
|
+
for (const it of api.items) {
|
|
345
|
+
if (!foliaIsWallpaperCategory(it)) continue; // 兜底:服务端过滤失效时也不把开机动画当壁纸
|
|
346
|
+
const one = foliaItemFromCatalog(it, base);
|
|
347
|
+
if (one) items.push(one);
|
|
348
|
+
}
|
|
349
|
+
usedPath = FOLIA_API_PATH + '?category=' + FOLIA_API_CATEGORY;
|
|
218
350
|
}
|
|
219
351
|
|
|
352
|
+
// ② 站点目录(静态快照):`{ wallpapers: [{ slug, mime, url, ... }] }`
|
|
353
|
+
if (items.length === 0) {
|
|
354
|
+
const cat = await fetchJson(FOLIA_CATALOG_PATH);
|
|
355
|
+
if (cat.doc && Array.isArray(cat.doc.wallpapers)) {
|
|
356
|
+
for (const it of cat.doc.wallpapers) {
|
|
357
|
+
if (!foliaIsWallpaperCategory(it)) continue; // 快照里也混着开机动画,同样按分类排掉
|
|
358
|
+
const one = foliaItemFromCatalog(it, base);
|
|
359
|
+
if (one) items.push(one);
|
|
360
|
+
}
|
|
361
|
+
if (items.length === 0) error = 'empty';
|
|
362
|
+
} else {
|
|
363
|
+
// ② 回退到旧静态清单:`{ images: [{ file, url, ... }] }`
|
|
364
|
+
usedPath = FOLIA_MANIFEST_PATH;
|
|
365
|
+
const man = await fetchJson(FOLIA_MANIFEST_PATH);
|
|
366
|
+
if (man.doc && Array.isArray(man.doc.images)) {
|
|
367
|
+
for (const it of man.doc.images) {
|
|
368
|
+
const one = foliaItemFromManifest(it, base);
|
|
369
|
+
if (one) items.push(one);
|
|
370
|
+
}
|
|
371
|
+
if (items.length === 0) error = 'empty';
|
|
372
|
+
} else {
|
|
373
|
+
// 两条都不成立:报 catalog 的错(它是真源,错因更有诊断价值)
|
|
374
|
+
error = cat.error || man.error || 'network';
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
220
378
|
const status = {
|
|
221
|
-
url,
|
|
379
|
+
url: base + usedPath,
|
|
222
380
|
ok: error === null,
|
|
223
381
|
count: items.length,
|
|
224
382
|
ms: Date.now() - startedAt,
|