dsh-folia 4.0.0 → 4.0.2
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 +39 -10
- package/lib/index.js +6 -2
- package/lib/routes/boot.js +25 -5
- package/lib/routes/inventory.js +74 -3
- package/lib/routes/modules.js +6 -0
- package/package.json +1 -1
package/lib/boot/index.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* 内联 JSON,启动时连一次请求都不发。
|
|
9
9
|
* 3. **任何异常都必须放行**。注入的脚本顶层 try/catch,超时自毁,绝不把用户挡在门外。
|
|
10
10
|
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
11
12
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
12
13
|
import { homedir } from 'node:os';
|
|
13
14
|
import { extname, join } from 'node:path';
|
|
@@ -67,12 +68,33 @@ export function writeBoot(patch) {
|
|
|
67
68
|
return readBoot();
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
71
|
+
/**
|
|
72
|
+
* id → **磁盘文件名**:ASCII 化 + 短哈希。
|
|
73
|
+
*
|
|
74
|
+
* 为什么不再拿 id 直接当文件名:站点上的 slug 允许中文(实测有一条叫「痘印」),
|
|
75
|
+
* 而严格的正则把它判成非法 ⇒ 用户点下载永远是 `bad-id`。id 可以任意宽容,
|
|
76
|
+
* **文件名必须安全** —— 这两件事不该绑在一起。
|
|
77
|
+
*
|
|
78
|
+
* 规则:非 `[a-zA-Z0-9._-]` 一律折成 `-`,再拼 8 位 sha1 前缀,保证
|
|
79
|
+
* · 永远不会出现路径分隔符 / `..` / 控制字符;
|
|
80
|
+
* · 不同 id 即使折成同一个 ASCII 名也不会互相覆盖;
|
|
81
|
+
* · 原始 id 存在同名 `.json` 里,列表读回来还是原来那个。
|
|
82
|
+
*/
|
|
83
|
+
function clipStem(id) {
|
|
84
|
+
const raw = String(id || '');
|
|
85
|
+
const ascii = raw.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 40) || 'clip';
|
|
86
|
+
const hash = createHash('sha1').update(raw).digest('hex').slice(0, 8);
|
|
87
|
+
return ascii + '-' + hash;
|
|
88
|
+
}
|
|
89
|
+
/** 片段文件。id 可以是任意字符串(含中文),只有磁盘名会被 ASCII 化。 */
|
|
72
90
|
export function clipFile(id) {
|
|
73
|
-
if (!
|
|
74
|
-
|
|
75
|
-
|
|
91
|
+
if (!String(id || '')) return null;
|
|
92
|
+
return join(bootDir(), clipStem(id) + '.mp4');
|
|
93
|
+
}
|
|
94
|
+
/** 片段元数据文件(存原始 id / 标题 / 时长)。 */
|
|
95
|
+
export function clipMetaFile(id) {
|
|
96
|
+
if (!String(id || '')) return null;
|
|
97
|
+
return join(bootDir(), clipStem(id) + '.json');
|
|
76
98
|
}
|
|
77
99
|
export function clipExists(id) {
|
|
78
100
|
const p = clipFile(id);
|
|
@@ -85,14 +107,21 @@ export function listLocalClips() {
|
|
|
85
107
|
const out = [];
|
|
86
108
|
let names = [];
|
|
87
109
|
try { names = readdirSync(dir); } catch { return out; }
|
|
110
|
+
// 以 `.json` 为主:里面有**原始 id**(可能是中文),文件名只是它的 ASCII 代理。
|
|
111
|
+
const stems = new Set();
|
|
88
112
|
for (const n of names) {
|
|
89
|
-
if (extname(n).toLowerCase()
|
|
90
|
-
|
|
91
|
-
|
|
113
|
+
if (extname(n).toLowerCase() === '.json') stems.add(n.slice(0, -5));
|
|
114
|
+
if (extname(n).toLowerCase() === '.mp4') stems.add(n.slice(0, -4));
|
|
115
|
+
}
|
|
116
|
+
for (const stem of stems) {
|
|
117
|
+
const abs = join(dir, stem + '.mp4');
|
|
118
|
+
if (!existsSync(abs)) continue;
|
|
92
119
|
let bytes = 0;
|
|
93
120
|
try { bytes = statSync(abs).size; } catch { continue; }
|
|
121
|
+
if (bytes <= 0) continue;
|
|
94
122
|
let meta = {};
|
|
95
|
-
try { meta = JSON.parse(readFileSync(join(dir,
|
|
123
|
+
try { meta = JSON.parse(readFileSync(join(dir, stem + '.json'), 'utf8')) || {}; } catch { /* 没 meta 也能用 */ }
|
|
124
|
+
const id = String(meta.id || stem);
|
|
96
125
|
out.push({ id, bytes, title: meta.title || id, durationSec: meta.durationSec || null });
|
|
97
126
|
}
|
|
98
127
|
return out;
|
|
@@ -102,7 +131,7 @@ export function removeClip(id) {
|
|
|
102
131
|
const p = clipFile(id);
|
|
103
132
|
if (!p) return false;
|
|
104
133
|
try { if (existsSync(p)) rmSync(p); } catch { return false; }
|
|
105
|
-
try { const m =
|
|
134
|
+
try { const m = clipMetaFile(id); if (m && existsSync(m)) rmSync(m); } catch { /* ignore */ }
|
|
106
135
|
return true;
|
|
107
136
|
}
|
|
108
137
|
|
package/lib/index.js
CHANGED
|
@@ -77,8 +77,8 @@ import { assertSubpackagesResolvable, assertSubpackagesResolvableInHost, httpVie
|
|
|
77
77
|
// `extOf` 反向传给会话段(清单载荷的容器后缀判据 ⇒ 见该文件头)。
|
|
78
78
|
import { createHostSession } from './kernel/host-session.js';
|
|
79
79
|
export { extOf };
|
|
80
|
-
import { registerInventoryRoutes } from './routes/inventory.js';
|
|
81
|
-
import { registerModulesRoutes } from './routes/modules.js';
|
|
80
|
+
import { registerInventoryRoutes, warmInventorySiteCache } from './routes/inventory.js';
|
|
81
|
+
import { registerModulesRoutes, warmModulesCache } from './routes/modules.js';
|
|
82
82
|
import { registerBootRoutes } from './routes/boot.js';
|
|
83
83
|
import { registerSiteThumbRoutes } from './routes/site-thumb.js';
|
|
84
84
|
import { installBootInjection } from './boot/index.js';
|
|
@@ -307,6 +307,10 @@ export function apply(ctx) {
|
|
|
307
307
|
|
|
308
308
|
// 站点图片缩略图代理 → lib/routes/site-thumb.js。卡片用 320px 小图,不再每张下 500 KB 原图。
|
|
309
309
|
registerSiteThumbRoutes(webServer, { disposers, base: BASE, ensureImagePreview, log });
|
|
310
|
+
|
|
311
|
+
// 启动预热(有意不阻塞):站点清单与模块索引各拉一次,让第一次打开面板不冷。
|
|
312
|
+
warmInventorySiteCache();
|
|
313
|
+
warmModulesCache();
|
|
310
314
|
installBootInjection(ctx, (m) => { try { log(m); } catch { /* ignore */ } });
|
|
311
315
|
|
|
312
316
|
// 3./3a. 派生媒体族路由(源元信息 / 抽帧转码 + 进度 / 视频缩略图)→ lib/routes/media-derived.js。
|
package/lib/routes/boot.js
CHANGED
|
@@ -14,7 +14,7 @@ import { join } from 'node:path';
|
|
|
14
14
|
import { sendJson } from '../json-response.js';
|
|
15
15
|
import { bodyReader } from '../http-body.js';
|
|
16
16
|
import { resolveModules } from './modules.js';
|
|
17
|
-
import {
|
|
17
|
+
import { clipExists, clipFile, clipMetaFile, listLocalClips, readBoot, removeClip, writeBoot } from '../boot/index.js';
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* 站点基址。开机动画的候选**从站点分类取**(`?category=boot-animation`),
|
|
@@ -118,7 +118,12 @@ async function readIdBody(req) {
|
|
|
118
118
|
const text = await readBodyText(req, 64 * 1024);
|
|
119
119
|
const doc = JSON.parse(text || '{}');
|
|
120
120
|
const id = String((doc && doc.id) || '').trim();
|
|
121
|
-
|
|
121
|
+
// id 来自站点 slug,**可以是中文**(实测有「痘印」)。这里只挡真正的危险输入:
|
|
122
|
+
// 路径分隔符、控制字符、`..`、超长 —— 其余一律放行,文件名由宿主安全化。
|
|
123
|
+
if (!id || id.length > 200) return null;
|
|
124
|
+
if (/[\u0000-\u001f\u007f/\\]/.test(id)) return null;
|
|
125
|
+
if (id === '.' || id === '..' || id.includes('..')) return null;
|
|
126
|
+
return id;
|
|
122
127
|
} catch {
|
|
123
128
|
return null;
|
|
124
129
|
}
|
|
@@ -128,7 +133,21 @@ async function readIdBody(req) {
|
|
|
128
133
|
* 候选清单:模块索引里的 `boot` 模块 → 拉它的 manifest → `{ clips: [...] }`。
|
|
129
134
|
* 失败一律返回空数组(面板显示"没有候选"),绝不抛。
|
|
130
135
|
*/
|
|
131
|
-
|
|
136
|
+
/** 候选缓存:30 秒。页签每次打开要跳两跳(模块索引 + 站点分类),没必要每次都跳。 */
|
|
137
|
+
const CANDIDATE_TTL_MS = 30000;
|
|
138
|
+
let candidateCache = { at: 0, value: null };
|
|
139
|
+
|
|
140
|
+
async function fetchCandidates(force = false) {
|
|
141
|
+
if (!force && candidateCache.value && Date.now() - candidateCache.at < CANDIDATE_TTL_MS) {
|
|
142
|
+
return candidateCache.value;
|
|
143
|
+
}
|
|
144
|
+
const fresh = await fetchCandidatesUncached();
|
|
145
|
+
// 有结果才缓存(空结果多半是站点恰好不可达,别把它固化 30 秒)。
|
|
146
|
+
if (fresh && fresh.clips && fresh.clips.length) candidateCache = { at: Date.now(), value: fresh };
|
|
147
|
+
return fresh;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function fetchCandidatesUncached() {
|
|
132
151
|
// ① 站点分类接口(与壁纸同一个接口,只是 category 不同)
|
|
133
152
|
const site = siteBase();
|
|
134
153
|
if (site) {
|
|
@@ -199,7 +218,7 @@ export function registerBootRoutes(webServer, c) {
|
|
|
199
218
|
let force = false;
|
|
200
219
|
try { force = new URL(req.url || '/', 'http://x').searchParams.get('refresh') === '1'; } catch { /* ignore */ }
|
|
201
220
|
if (force) await resolveModules(true);
|
|
202
|
-
const [boot, candidates] = await Promise.all([Promise.resolve(readBoot()), fetchCandidates()]);
|
|
221
|
+
const [boot, candidates] = await Promise.all([Promise.resolve(readBoot()), fetchCandidates(force)]);
|
|
203
222
|
const local = listLocalClips();
|
|
204
223
|
const localById = new Map(local.map((x) => [x.id, x]));
|
|
205
224
|
const clips = candidates.clips.map((c) => {
|
|
@@ -259,7 +278,8 @@ export function registerBootRoutes(webServer, c) {
|
|
|
259
278
|
if (buf.length > CLIP_MAX_BYTES) { sendJson(res, 413, { ok: false, error: 'too-large' }); return; }
|
|
260
279
|
writeFileSync(dest, buf);
|
|
261
280
|
try {
|
|
262
|
-
writeFileSync(
|
|
281
|
+
writeFileSync(clipMetaFile(id), JSON.stringify({
|
|
282
|
+
id,
|
|
263
283
|
title: item.title, durationSec: item.durationSec, bytes: buf.length, sha256: item.sha256 || null,
|
|
264
284
|
}, null, 2) + '\n');
|
|
265
285
|
} catch { /* meta 只是锦上添花 */ }
|
package/lib/routes/inventory.js
CHANGED
|
@@ -102,6 +102,10 @@ function rowStatusOf(probe) {
|
|
|
102
102
|
* 站点格式(`<site>/manifest.json`):`{ images: [{ file, title, url, preview, kind?, animated? }] }`
|
|
103
103
|
* —— 与 `folia-wallpaper-site` 产物一致;`url`/`preview` 可以是相对路径(相对站点基址)。
|
|
104
104
|
*/
|
|
105
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
106
|
+
import { homedir } from 'node:os';
|
|
107
|
+
import { dirname, join } from 'node:path';
|
|
108
|
+
|
|
105
109
|
const FOLIA_SITE = 'https://folia-5ve.pages.dev';
|
|
106
110
|
/**
|
|
107
111
|
* 站点数据源的两个可能形态,按顺序尝试:
|
|
@@ -143,7 +147,46 @@ const FOLIA_TIMEOUT_MAX_MS = 15000;
|
|
|
143
147
|
* 说清"为什么没有云端壁纸",而不是静默空库 —— 之前就是静默失败,排查时无从下手。
|
|
144
148
|
* @type {{ at: number, items: any[], status: { url: string, ok: boolean, count: number, ms: number, error: string|null } | null }}
|
|
145
149
|
*/
|
|
146
|
-
|
|
150
|
+
/**
|
|
151
|
+
* 站点清单的磁盘缓存:`~/.dsh-folia/cache/site-items.json`。
|
|
152
|
+
*
|
|
153
|
+
* 为什么落盘:内存缓存只活一个进程。没有它,**每次重启后第一次打开面板**都要等一次
|
|
154
|
+
* 完整的跨境往返(实测 ~800ms),这段时间界面是空的。有了它,冷启动也能立刻出内容。
|
|
155
|
+
*
|
|
156
|
+
* 纪律:读失败一律当"没有缓存"(坏缓存只该慢一次,不该出错);只缓存**成功**的结果,
|
|
157
|
+
* 失败不落盘 —— 否则一次断网会把空列表固化下来,之后每次启动都显示没壁纸。
|
|
158
|
+
*/
|
|
159
|
+
function foliaDiskCachePath() {
|
|
160
|
+
const home = process.env.DSH_FOLIA_HOME || join(homedir(), '.dsh-folia');
|
|
161
|
+
return join(home, 'cache', 'site-items.json');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readFoliaDiskCache() {
|
|
165
|
+
try {
|
|
166
|
+
const doc = JSON.parse(readFileSync(foliaDiskCachePath(), 'utf8'));
|
|
167
|
+
if (!doc || !Array.isArray(doc.items) || !doc.items.length) return null;
|
|
168
|
+
return { at: Number(doc.at) || 0, items: doc.items, status: doc.status || null };
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function writeFoliaDiskCache(entry) {
|
|
175
|
+
try {
|
|
176
|
+
const p = foliaDiskCachePath();
|
|
177
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
178
|
+
writeFileSync(p, JSON.stringify({ at: entry.at, items: entry.items, status: entry.status }));
|
|
179
|
+
} catch { /* 只读盘 / 配额:写不进去只影响下次速度,不影响正确性 */ }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 内存缓存;**用磁盘缓存打底** —— 冷启动第一次请求就能立刻返回旧列表。 */
|
|
183
|
+
let foliaCache = (() => {
|
|
184
|
+
const disk = readFoliaDiskCache();
|
|
185
|
+
return disk ? { at: disk.at, items: disk.items, status: disk.status } : { at: 0, items: [], status: null };
|
|
186
|
+
})();
|
|
187
|
+
|
|
188
|
+
/** 后台刷新是否在途(避免多个请求同时触发同一次刷新)。 */
|
|
189
|
+
let foliaRefreshInflight = false;
|
|
147
190
|
|
|
148
191
|
/** 超时从 env 读,越界收敛到 [1s, 15s]。`DSH_FOLIA_SITE_TIMEOUT_MS=8000` 可放宽弱网。 */
|
|
149
192
|
function foliaTimeoutMs() {
|
|
@@ -291,12 +334,32 @@ function foliaTypeOf(it) {
|
|
|
291
334
|
* `disabled` 通道被 `DSH_FOLIA_SITE=off` 关掉 · `timeout` 超时 · `tls` 证书问题
|
|
292
335
|
* `http-<code>` 状态码非 2xx · `json` 清单不是合法 JSON · `network` 其它网络错误
|
|
293
336
|
*/
|
|
337
|
+
/**
|
|
338
|
+
* 对外入口:**先给已有结果,再后台刷新**(stale-while-revalidate)。
|
|
339
|
+
*
|
|
340
|
+
* 这是面板"打开就能看到东西"的关键:清单过期 ≠ 让用户等。有缓存就立刻返回,
|
|
341
|
+
* 同时后台拉一次新的(下次打开就是新的)。只有**一条缓存都没有**时才会阻塞等待。
|
|
342
|
+
*/
|
|
294
343
|
async function foliaRemote(force = false) {
|
|
295
344
|
const base = foliaSiteBase();
|
|
296
345
|
if (!base) return { items: [], status: null };
|
|
297
|
-
|
|
346
|
+
|
|
347
|
+
const fresh = Date.now() - foliaCache.at < FOLIA_TTL_MS;
|
|
348
|
+
if (!force && foliaCache.items.length) {
|
|
349
|
+
if (!fresh && !foliaRefreshInflight) {
|
|
350
|
+
foliaRefreshInflight = true;
|
|
351
|
+
// 有意不 await:这次先把旧列表发出去,刷新结果留给下一次请求。
|
|
352
|
+
foliaRefresh().catch(() => {}).finally(() => { foliaRefreshInflight = false; });
|
|
353
|
+
}
|
|
298
354
|
return { items: foliaCache.items, status: foliaCache.status };
|
|
299
355
|
}
|
|
356
|
+
return foliaRefresh();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** 真正去站点抓一次(缓存为空、或调用方要求强制刷新时走这里)。 */
|
|
360
|
+
async function foliaRefresh(force = false) {
|
|
361
|
+
const base = foliaSiteBase();
|
|
362
|
+
if (!base) return { items: [], status: null };
|
|
300
363
|
|
|
301
364
|
const startedAt = Date.now();
|
|
302
365
|
/** @type {any[]} */
|
|
@@ -391,11 +454,19 @@ async function foliaRemote(force = false) {
|
|
|
391
454
|
error,
|
|
392
455
|
at: Date.now(),
|
|
393
456
|
};
|
|
394
|
-
//
|
|
457
|
+
// 失败也写内存缓存:否则每次 /inventory 都要再等一次超时。
|
|
395
458
|
foliaCache = { at: Date.now(), items, status };
|
|
459
|
+
// **成功**才落盘(失败了别把空列表固化到磁盘上)。
|
|
460
|
+
if (!error && items.length) writeFoliaDiskCache(foliaCache);
|
|
396
461
|
return { items, status };
|
|
397
462
|
}
|
|
398
463
|
|
|
464
|
+
// 启动预热:插件一装好就在后台抓一次站点清单,这样**重启后的第一次打开面板**也不冷。
|
|
465
|
+
// 有意不 await、失败静默 —— 预热只是优化,绝不能影响启动。
|
|
466
|
+
export function warmInventorySiteCache() {
|
|
467
|
+
try { foliaRemote(true).catch(() => {}); } catch { /* ignore */ }
|
|
468
|
+
}
|
|
469
|
+
|
|
399
470
|
export function registerInventoryRoutes(webServer, c) {
|
|
400
471
|
const { disposers, base: BASE, mediaOriginApi, buildInventory, componentsProbe } = c;
|
|
401
472
|
|
package/lib/routes/modules.js
CHANGED
|
@@ -168,6 +168,12 @@ export async function resolveModules(force = false) {
|
|
|
168
168
|
return payload;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// 启动预热:模块索引很小(~1.6KB),但第一跳要走站点代理;提前拉一次,
|
|
172
|
+
// 开机动画页签第一次打开就不用等这一跳。
|
|
173
|
+
export function warmModulesCache() {
|
|
174
|
+
try { resolveModules(true).catch(() => {}); } catch { /* ignore */ }
|
|
175
|
+
}
|
|
176
|
+
|
|
171
177
|
export function registerModulesRoutes(webServer, c) {
|
|
172
178
|
const { disposers, base: BASE } = c;
|
|
173
179
|
|