dsh-folia 2.5.7 → 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/client.js +13 -3
- package/lib/index.js +4 -0
- package/lib/routes/inventory.js +4 -1
- package/lib/routes/site-thumb.js +109 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -16991,6 +16991,9 @@ window.__ModuleLoader__.load({
|
|
|
16991
16991
|
const [state, setState] = React.useState(null);
|
|
16992
16992
|
const [busy, setBusy] = React.useState("");
|
|
16993
16993
|
const [note, setNote] = React.useState(null);
|
|
16994
|
+
// 下载没有进度通道(宿主一次性 fetch 完才返回),但等待必须"看得见":
|
|
16995
|
+
// 每秒 +1 的秒表足以让用户知道它没卡死。
|
|
16996
|
+
const [tick, setTick] = React.useState(0);
|
|
16994
16997
|
|
|
16995
16998
|
const load = React.useCallback(async (force) => {
|
|
16996
16999
|
try {
|
|
@@ -17004,9 +17007,16 @@ window.__ModuleLoader__.load({
|
|
|
17004
17007
|
|
|
17005
17008
|
React.useEffect(() => { load(false); }, [load]);
|
|
17006
17009
|
|
|
17010
|
+
React.useEffect(() => {
|
|
17011
|
+
if (!busy) return undefined;
|
|
17012
|
+
const t = setInterval(() => setTick((n) => n + 1), 1000);
|
|
17013
|
+
return () => clearInterval(t);
|
|
17014
|
+
}, [busy]);
|
|
17015
|
+
|
|
17007
17016
|
async function act(kind, payload, label) {
|
|
17008
17017
|
if (busy) return;
|
|
17009
17018
|
setBusy(payload && payload.id ? payload.id : kind);
|
|
17019
|
+
setTick(0);
|
|
17010
17020
|
setNote(null);
|
|
17011
17021
|
try {
|
|
17012
17022
|
const resp = await apiPostJson("/boot/" + kind, payload || {}, { parse: "always" });
|
|
@@ -17120,7 +17130,7 @@ window.__ModuleLoader__.load({
|
|
|
17120
17130
|
}, label);
|
|
17121
17131
|
const actions = [];
|
|
17122
17132
|
if (!c.installed) {
|
|
17123
|
-
actions.push(btn(isBusy ? weT("下载中…") : weT("下载"), () => act("download", { id: c.id }, c.title), false, true));
|
|
17133
|
+
actions.push(btn(isBusy ? (weT("下载中… ") + tick + "s") : weT("下载"), () => act("download", { id: c.id }, c.title), false, true));
|
|
17124
17134
|
} else if (!isActive) {
|
|
17125
17135
|
actions.push(btn(weT("使用"), () => act("select", { id: c.id }, c.title), false, true));
|
|
17126
17136
|
actions.push(btn(weT("删除"), () => act("remove", { id: c.id }, c.title), isBusy, false));
|
|
@@ -17451,7 +17461,7 @@ window.__ModuleLoader__.load({
|
|
|
17451
17461
|
React.createElement("span", { className: "we-qp__item-thumb" },
|
|
17452
17462
|
w.preview
|
|
17453
17463
|
? React.createElement("img", {
|
|
17454
|
-
src: w.preview, alt: "", loading: "lazy",
|
|
17464
|
+
src: w.preview, alt: "", loading: "lazy", decoding: "async",
|
|
17455
17465
|
onError: (e) => { e.target.style.display = "none"; },
|
|
17456
17466
|
onLoad: (e) => { e.target.style.opacity = "1"; },
|
|
17457
17467
|
})
|
|
@@ -17479,7 +17489,7 @@ window.__ModuleLoader__.load({
|
|
|
17479
17489
|
},
|
|
17480
17490
|
w.preview
|
|
17481
17491
|
? React.createElement("img", {
|
|
17482
|
-
src: w.preview, alt: "", loading: "lazy",
|
|
17492
|
+
src: w.preview, alt: "", loading: "lazy", decoding: "async",
|
|
17483
17493
|
onError: (e) => { e.target.style.display = "none"; },
|
|
17484
17494
|
onLoad: (e) => { e.target.style.opacity = "1"; },
|
|
17485
17495
|
})
|
package/lib/index.js
CHANGED
|
@@ -80,6 +80,7 @@ export { extOf };
|
|
|
80
80
|
import { registerInventoryRoutes } from './routes/inventory.js';
|
|
81
81
|
import { registerModulesRoutes } from './routes/modules.js';
|
|
82
82
|
import { registerBootRoutes } from './routes/boot.js';
|
|
83
|
+
import { registerSiteThumbRoutes } from './routes/site-thumb.js';
|
|
83
84
|
import { installBootInjection } from './boot/index.js';
|
|
84
85
|
import { registerDiagRoutes } from './routes/diag.js';
|
|
85
86
|
// 遮挡消息桥族(上游新增域模块):hub 处理 x11 遮挡探针,路由对外暴露状态/控制面。
|
|
@@ -303,6 +304,9 @@ export function apply(ctx) {
|
|
|
303
304
|
// 开机动画 → lib/routes/boot.js(HTTP 面)+ lib/boot/index.js(注入与屏幕脚本)。
|
|
304
305
|
// 注入是「关就一行都不注」:没有选片时不产生任何脚本、不产生任何请求。
|
|
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 });
|
|
306
310
|
installBootInjection(ctx, (m) => { try { log(m); } catch { /* ignore */ } });
|
|
307
311
|
|
|
308
312
|
// 3./3a. 派生媒体族路由(源元信息 / 抽帧转码 + 进度 / 视频缩略图)→ lib/routes/media-derived.js。
|
package/lib/routes/inventory.js
CHANGED
|
@@ -206,6 +206,9 @@ function foliaItemFromCatalog(it, base) {
|
|
|
206
206
|
const media = foliaAbs(base, it && it.url);
|
|
207
207
|
if (!media) return null;
|
|
208
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;
|
|
209
212
|
const extHit = String(media).match(/\.([a-z0-9]{2,5})(?:[?#]|$)/i);
|
|
210
213
|
const num = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : undefined);
|
|
211
214
|
return {
|
|
@@ -217,7 +220,7 @@ function foliaItemFromCatalog(it, base) {
|
|
|
217
220
|
media, // 绝对 URL
|
|
218
221
|
mediaExt: '.' + (extHit ? extHit[1].toLowerCase() : (type === 'video' ? 'mp4' : 'jpg')),
|
|
219
222
|
// 站点目录可能带 preview/poster;图片没封面时用自己当预览,视频没有就留空(卡片会是黑块)
|
|
220
|
-
preview: foliaAbs(base, it && (it.preview || it.poster)) ||
|
|
223
|
+
preview: thumb || foliaAbs(base, it && (it.preview || it.poster)) || null,
|
|
221
224
|
frameUrl: null,
|
|
222
225
|
schemeColor: null,
|
|
223
226
|
sceneLiveSrc: null,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* routes/site-thumb.js — 站点图片的**缩略图代理**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:站点上的壁纸是 1024×1024 起的原图(实测一张 536 KB)。卡片网格一页
|
|
5
|
+
* 9~12 张,一次要下 5 MB 左右,在慢网络上就是"站点壁纸加载很慢"。
|
|
6
|
+
*
|
|
7
|
+
* 做法:宿主把原图抓一次存到缓存,然后用**已有的** `ensureImagePreview`(ffmpeg 缩到
|
|
8
|
+
* 320px、q=5,约 10 KB)生成小图,之后每次都直接发小图。原图只下一次、只在第一次。
|
|
9
|
+
*
|
|
10
|
+
* 三条纪律:
|
|
11
|
+
* · **只代理白名单主机**(站点基址与素材桶),避免变成任意 URL 的开放代理;
|
|
12
|
+
* · 任何失败都 **302 回原图** —— 慢一点但一定能显示,绝不 404;
|
|
13
|
+
* · 缩略图与源图都按 URL 的 sha1 命名,天然幂等,不需要失效逻辑。
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { createReadStream, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { extname, join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const MAX_SOURCE_BYTES = 32 * 1024 * 1024;
|
|
21
|
+
const FETCH_TIMEOUT_MS = 30000;
|
|
22
|
+
|
|
23
|
+
const DEFAULT_SITE = 'https://folia-5ve.pages.dev';
|
|
24
|
+
const DEFAULT_ASSETS = 'https://pub-1275321f146e479eaac54948f4cdad6f.r2.dev';
|
|
25
|
+
|
|
26
|
+
function siteBase() {
|
|
27
|
+
const raw = process.env.DSH_FOLIA_SITE;
|
|
28
|
+
if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return null;
|
|
29
|
+
const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : DEFAULT_SITE;
|
|
30
|
+
return base.replace(/\/+$/, '');
|
|
31
|
+
}
|
|
32
|
+
function assetsBase() {
|
|
33
|
+
const raw = process.env.DSH_FOLIA_ASSETS;
|
|
34
|
+
const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : DEFAULT_ASSETS;
|
|
35
|
+
return base.replace(/\/+$/, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 源图缓存目录(与 config.json 同一个数据根)。 */
|
|
39
|
+
function sourceDir() {
|
|
40
|
+
const base = process.env.DSH_FOLIA_HOME || join(homedir(), '.dsh-folia');
|
|
41
|
+
const d = join(base, 'site-src');
|
|
42
|
+
try { if (!existsSync(d)) mkdirSync(d, { recursive: true }); } catch { /* 只读盘:后面会失败并回退 */ }
|
|
43
|
+
return d;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 只允许抓我们自己的两个主机。 */
|
|
47
|
+
function allowed(url) {
|
|
48
|
+
try {
|
|
49
|
+
const u = new URL(url);
|
|
50
|
+
if (!/^https?:$/.test(u.protocol)) return false;
|
|
51
|
+
const hosts = [siteBase(), assetsBase()].filter(Boolean).map((b) => { try { return new URL(b).host; } catch { return ''; } });
|
|
52
|
+
return hosts.includes(u.host);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function registerSiteThumbRoutes(webServer, c) {
|
|
59
|
+
const { disposers, base: BASE, ensureImagePreview, log } = c;
|
|
60
|
+
|
|
61
|
+
disposers.push(webServer.register({
|
|
62
|
+
kind: 'exact',
|
|
63
|
+
path: `${BASE}/site-thumb`,
|
|
64
|
+
handler: async (req, res) => {
|
|
65
|
+
let target = '';
|
|
66
|
+
try { target = new URL(req.url || '/', 'http://x').searchParams.get('u') || ''; } catch { target = ''; }
|
|
67
|
+
|
|
68
|
+
const redirect = () => {
|
|
69
|
+
res.statusCode = 302;
|
|
70
|
+
if (typeof res.setHeader === 'function') res.setHeader('location', target);
|
|
71
|
+
res.end();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (!target || !allowed(target)) { redirect(); return; }
|
|
75
|
+
|
|
76
|
+
const key = createHash('sha1').update(target).digest('hex');
|
|
77
|
+
let ext = '.jpg';
|
|
78
|
+
try { ext = extname(new URL(target).pathname) || '.jpg'; } catch { /* 用默认 */ }
|
|
79
|
+
const src = join(sourceDir(), key + (ext.length <= 5 ? ext : '.jpg'));
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
if (!existsSync(src) || statSync(src).size === 0) {
|
|
83
|
+
const r = await fetch(target, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
84
|
+
if (!r.ok) { redirect(); return; }
|
|
85
|
+
const ct = String(r.headers.get('content-type') || '');
|
|
86
|
+
if (!/^image\//i.test(ct)) { redirect(); return; }
|
|
87
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
88
|
+
if (buf.length === 0 || buf.length > MAX_SOURCE_BYTES) { redirect(); return; }
|
|
89
|
+
writeFileSync(src, buf);
|
|
90
|
+
}
|
|
91
|
+
// 已有套件:缩到 320px(失败返回 null ⇒ 回落原图)
|
|
92
|
+
const thumb = await ensureImagePreview(src).catch(() => null);
|
|
93
|
+
const file = thumb && existsSync(thumb) ? thumb : src;
|
|
94
|
+
const size = statSync(file).size;
|
|
95
|
+
if (typeof res.setHeader === 'function') {
|
|
96
|
+
res.setHeader('content-type', 'image/jpeg');
|
|
97
|
+
res.setHeader('content-length', String(size));
|
|
98
|
+
res.setHeader('cache-control', 'public, max-age=31536000, immutable');
|
|
99
|
+
}
|
|
100
|
+
res.statusCode = 200;
|
|
101
|
+
createReadStream(file).pipe(res);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (typeof log === 'function') log(`[site-thumb] ${String((e && e.message) || e)}`);
|
|
104
|
+
try { if (existsSync(src)) rmSync(src); } catch { /* ignore */ }
|
|
105
|
+
redirect();
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
}));
|
|
109
|
+
}
|