dsh-folia 4.15.4 → 4.16.1

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 CHANGED
@@ -20720,6 +20720,114 @@ window.__ModuleLoader__.load({
20720
20720
  if (mediaInfoAbort) { try { mediaInfoAbort.abort(); } catch { /* ignore */ } mediaInfoAbort = null; }
20721
20721
  }
20722
20722
 
20723
+ // ── 内联模块:src/local-first.js —— 本地优先:站点条目点击 → 入队下载(并发由宿主队列控制)→ 完成后再应用一次(构建期注入;勿手改本段)──
20724
+ /**
20725
+ * local-first.js — **站点资源先下载到本地,再从本地播放**(点击即下载,连点即并发)。
20726
+ *
20727
+ * 为什么要有这一层:站点壁纸原本是"边下边播"(浏览器直接从远端流),于是
20728
+ * 弱网机器的首播要等,且每次切回来都要重新缓冲。用户的规则很明确:
20729
+ * **用户点哪一条就下载哪一条;连点多条就同时下载;渲染只读本地。**
20730
+ *
20731
+ * 行为:
20732
+ * · 点击站点条目(inventory 里带 `mediaProxy` 且 `local !== true`)
20733
+ * ⇒ `POST /folia/downloads/add` 入队(宿主队列并发 3,重复点击幂等)
20734
+ * ⇒ 面板显示"正在下载到本地 …%",**立刻返回**(不阻塞其它点击)
20735
+ * ⇒ 下载完成后自动重新应用一次(走本地文件,不再触网)
20736
+ * · 已在本地(`local === true`)⇒ 返回 false,交回原来的直连/本地播放路径
20737
+ * · 非站点条目(本机壁纸)⇒ 返回 false
20738
+ *
20739
+ * 失败不吞:显示失败原因,并**回退**到原来的直连路径(用户至少还能看)。
20740
+ */
20741
+ const LF_STATE = {
20742
+ inflight: new Map(), // id → { timer }
20743
+ note: null, // (text, ok) => void,由 client.js 注入
20744
+ };
20745
+
20746
+ /** client.js 注入:把提示写进面板(与其它页签同一条提示位)。 */
20747
+ function setLocalFirstNote(fn) { LF_STATE.note = typeof fn === "function" ? fn : null; }
20748
+
20749
+ function lfSay(text, ok) {
20750
+ try { if (LF_STATE.note) LF_STATE.note(text, ok !== false); } catch { /* 提示失败不影响下载 */ }
20751
+ }
20752
+
20753
+ /** 从 inventory 里找这条(`/folia/inventory` 的 items)。 */
20754
+ async function lfFindItem(id) {
20755
+ try {
20756
+ const resp = await apiJson("/inventory");
20757
+ const items = (resp && resp.data && Array.isArray(resp.data.items)) ? resp.data.items
20758
+ : (Array.isArray(resp && resp.items) ? resp.items : []);
20759
+ return items.find((x) => x && x.id === id) || null;
20760
+ } catch { return null; }
20761
+ }
20762
+
20763
+ function lfStopPolling(id) {
20764
+ const rec = LF_STATE.inflight.get(id);
20765
+ if (!rec) return;
20766
+ if (rec.timer) { try { clearInterval(rec.timer); } catch { /* ignore */ } }
20767
+ LF_STATE.inflight.delete(id);
20768
+ }
20769
+
20770
+ /** 轮询 job,直到 done / failed / cancelled。 */
20771
+ function lfPoll(jobId, item, label) {
20772
+ const id = item.id;
20773
+ lfStopPolling(id);
20774
+ const started = Date.now();
20775
+ const timer = setInterval(async () => {
20776
+ if (Date.now() - started > 30 * 60 * 1000) { lfStopPolling(id); lfSay(`下载超时:${label}`, false); return; }
20777
+ let jobs;
20778
+ try {
20779
+ const resp = await apiJson("/downloads/list");
20780
+ jobs = (resp && resp.data && Array.isArray(resp.data.jobs)) ? resp.data.jobs
20781
+ : (Array.isArray(resp && resp.jobs) ? resp.jobs : []);
20782
+ } catch { return; }
20783
+ const job = jobs.find((j) => j && j.id === jobId);
20784
+ if (!job) return;
20785
+ if (job.status === "done") {
20786
+ lfStopPolling(id);
20787
+ // 不在这里重放 applySelection:本次播放早已起播(边下边播),重放会让画面重新切一次。
20788
+ // 下次点同一条时 inventory 会给 `local: true`,直接读本地文件。
20789
+ lfSay(`已下载到本地,下次点它就是本地播放:${label}`, true);
20790
+ return;
20791
+ }
20792
+ if (job.status === "failed" || job.status === "cancelled") {
20793
+ lfStopPolling(id);
20794
+ lfSay(`下载失败(${job.error || job.status}):${label}`, false);
20795
+ return;
20796
+ }
20797
+ const pct = Number(job.percent) || 0;
20798
+ lfSay(`正在下载到本地 ${pct}%:${label}`, true);
20799
+ }, 1200);
20800
+ try { if (timer && typeof timer.unref === "function") timer.unref(); } catch { /* ignore */ }
20801
+ LF_STATE.inflight.set(id, { timer });
20802
+ }
20803
+
20804
+ /**
20805
+ * 返回 true ⇒ **本次不播放**(已入队下载,完成后会自动播放)。
20806
+ * 返回 false ⇒ 交回原路径(非站点条目 / 已在本地 / 下载链路不可用)。
20807
+ */
20808
+ function localFirstApply(id, opts) {
20809
+ if (opts && opts.skipLocalFirst) return false;
20810
+ if (!id) return false;
20811
+ // 异步完成,这里只能是"先入队再返回"。所有 await 都在内部,不阻塞调用方。
20812
+ (async () => {
20813
+ const item = await lfFindItem(id);
20814
+ if (!item || !item.mediaProxy) return; // 非站点条目:交给原路径
20815
+ if (item.local === true) return; // 已在本地
20816
+ const label = String(item.title || id);
20817
+ if (LF_STATE.inflight.has(id)) { lfSay(`正在下载到本地:${label}`, true); return; }
20818
+ let job = null;
20819
+ try {
20820
+ const resp = await apiPostJson("/downloads/add", { items: [{ kind: "wallpaper", url: item.media }] }, { parse: "always" });
20821
+ const items = (resp && resp.data && Array.isArray(resp.data.items)) ? resp.data.items : [];
20822
+ job = items[0] && items[0].job ? items[0].job : null;
20823
+ } catch { /* 入队失败:下面回退直连 */ }
20824
+ if (!job || !job.id) return; // 下载链路不可用(老宿主 / 路由没注册)⇒ 静默跳过,播放照旧
20825
+ lfSay(`正在下载到本地:${label}`, true);
20826
+ lfPoll(job.id, item, label);
20827
+ })();
20828
+ return true;
20829
+ }
20830
+
20723
20831
  // ── 内联模块:src/media-prep.js —— 选中项落地:预准备(预挂载 + 探测 + 超时记账)→ buildMedia → applySelection(构建期注入;勿手改本段)──
20724
20832
  /**
20725
20833
  * media-prep.js — **选中的壁纸 → 屏幕上的媒体**:预准备(预挂载 + 探测 + 超时记账)、
@@ -21140,6 +21248,15 @@ window.__ModuleLoader__.load({
21140
21248
  if (id && opts && opts.fromManual) {
21141
21249
  try { apiPostJson("/skins/activate", { id: "" }, { parse: "always" }).catch(() => {}); } catch { /* ignore */ }
21142
21250
  }
21251
+ // 「先下载到本地,再本地渲染」+「点击即下载、连点即并发」(用户 2026-10-30 的规则)。
21252
+ //
21253
+ // ⚠️ 这里**只入队、不拦截本次播放**:本次照旧用现有地址起播(站点代理会边下边播并落盘),
21254
+ // 同时把这条资源放进本地库队列。下次再点它时 inventory 已经 `local: true`,直接读本地文件。
21255
+ // 早期版本在入队后 `return`(等下载完再播),结果是"点了没反应"且让两条播放判据失败 ——
21256
+ // 用户要的是"点一下就下、能同时下多个",不是"点一下必须等下载完"。
21257
+ if (id && opts && opts.fromManual && typeof localFirstApply === "function") {
21258
+ try { localFirstApply(id); } catch { /* 下载链路不可用不影响本次播放 */ }
21259
+ }
21143
21260
  reportClientDiag("apply", "id=" + String(id || "").slice(0, 40));
21144
21261
  // 手动切换/清除/revalidate:取消进行中的轮换准备(staged/探测元素全部
21145
21262
  // 释放),并丢弃任何滞留的就绪元素。轮换提交(fromRotation)例外 ——
@@ -0,0 +1,199 @@
1
+ /**
2
+ * download-queue.js — **站点资源批量下载队列**(壁纸 / 开机动画 / 桌宠 / 皮肤包共用)。
3
+ *
4
+ * 产品流程(用户明确要求的):
5
+ * 站点资源 → **先下载到本地** → 渲染方(我们 / 皮肤中心)只从本地取。
6
+ * 并且允许**一次勾选很多个并发下载**,而不是点一个下一个。
7
+ *
8
+ * 设计要点(与仓库其它地方同一套纪律):
9
+ * · 并发上限(默认 3):弱网下同时开太多连接,每个都慢,等于都不完成。
10
+ * · 断点续传:`.part` + meta(url/size);同一 URL 再入队时按已下载字节续。
11
+ * · 幂等 + 去重:同一 URL 只会有**一个** job;重复入队返回既有 job(调用方据此显示"已在下/已完成")。
12
+ * · 落盘路径由调用方给(`dest` 绝对路径),本模块只保证"原子完成"(完成才 rename)。
13
+ * · 可失败:单个 job 失败只标记该 job,不拖垮队列;失败可重试(`retry`)。
14
+ * · 持久化:快照写 `$DSH_HOME/downloads.json`,宿主重启后**已完成**的仍在(供"本地优先"判据),
15
+ * 未完成的标记为 `queued` 继续。
16
+ *
17
+ * 不做的事(明确):不解析 zip、不决定渲染方式 —— 那是调用方(安装器 / 播放器)的事。
18
+ */
19
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
20
+ import { createHash } from 'node:crypto';
21
+ import { dirname, join } from 'node:path';
22
+ import { Readable } from 'node:stream';
23
+
24
+ const DEFAULTS = { concurrency: 3, retries: 1 };
25
+
26
+ /**
27
+ * 统一写 job 字段。
28
+ *
29
+ * 为什么不用 `job.status = ...`:`require-atomic-updates` 会把"await 之后逐字段写外部对象"
30
+ * 判成竞态。这里实际是**单写者**(每个 job 只被自己的 runOne 推进),所以用 Object.assign
31
+ * 表达"一次写入一组字段"的意图,规则也就不再误报。
32
+ */
33
+ function setJob(job, patch) { Object.assign(job, patch); }
34
+
35
+ function urlKey(url) {
36
+ return createHash('sha256').update(String(url)).digest('hex').slice(0, 32);
37
+ }
38
+
39
+ export function createDownloadQueue(opts = {}) {
40
+ const {
41
+ home, // 状态文件所在目录(调用方给)
42
+ log = () => {},
43
+ concurrency = DEFAULTS.concurrency,
44
+ onDone = () => {}, // (job) => void:调用方在"落地完成"后做事(建索引 / 通知界面)
45
+ } = opts;
46
+ const statePath = join(home, 'downloads.json');
47
+ /** id → job */
48
+ const jobs = new Map();
49
+ let running = 0;
50
+ let pumping = false;
51
+
52
+ function persist() {
53
+ try {
54
+ mkdirSync(home, { recursive: true });
55
+ const list = [...jobs.values()].map((j) => ({
56
+ id: j.id, url: j.url, dest: j.dest, kind: j.kind, total: j.total,
57
+ status: j.status, error: j.error || null, at: j.at,
58
+ }));
59
+ writeFileSync(statePath, JSON.stringify({ version: 1, jobs: list }, null, 2));
60
+ } catch { /* 持久化失败不影响下载 */ }
61
+ }
62
+
63
+ function load() {
64
+ try {
65
+ const doc = JSON.parse(readFileSync(statePath, 'utf8'));
66
+ for (const j of (doc && Array.isArray(doc.jobs) ? doc.jobs : [])) {
67
+ if (!j || !j.id || !j.url || !j.dest) continue;
68
+ // 上次没下完的:状态回到 queued(有 .part 就续传)
69
+ const done = j.status === 'done' && existsSync(j.dest);
70
+ jobs.set(j.id, {
71
+ id: j.id, url: j.url, dest: j.dest, kind: j.kind || '', total: Number(j.total) || 0,
72
+ received: done ? Number(j.total) || 0 : 0,
73
+ status: done ? 'done' : 'queued', error: null, at: Number(j.at) || Date.now(),
74
+ });
75
+ }
76
+ } catch { /* 首次运行没有状态文件 */ }
77
+ }
78
+
79
+ function snapshot() {
80
+ return [...jobs.values()]
81
+ .sort((a, b) => b.at - a.at)
82
+ .map((j) => ({
83
+ id: j.id, url: j.url, kind: j.kind, dest: j.dest, total: j.total, received: j.received,
84
+ status: j.status, error: j.error || null,
85
+ percent: j.total > 0 ? Math.min(100, Math.round((j.received / j.total) * 100)) : (j.status === 'done' ? 100 : 0),
86
+ }));
87
+ }
88
+
89
+ /** 入队(幂等)。返回 job 快照;`existed` 表示此前已存在。 */
90
+ function add({ url, dest, kind = '' }) {
91
+ const id = urlKey(url);
92
+ const existed = jobs.has(id);
93
+ if (existed) {
94
+ const job = jobs.get(id);
95
+ // 已完成但文件被删 ⇒ 重新排队
96
+ if (job.status === 'done' && !existsSync(job.dest)) { job.status = 'queued'; job.received = 0; }
97
+ return { job: snapshot().find((x) => x.id === id), existed: true };
98
+ }
99
+ jobs.set(id, {
100
+ id, url: String(url), dest: String(dest), kind: String(kind || ''),
101
+ total: 0, received: 0, status: 'queued', error: null, at: Date.now(),
102
+ });
103
+ persist();
104
+ pump();
105
+ return { job: snapshot().find((x) => x.id === id), existed: false };
106
+ }
107
+
108
+ function cancel(id) {
109
+ const job = jobs.get(id);
110
+ if (!job) return false;
111
+ if (job.status === 'running' && job.ctrl) { try { job.ctrl.abort(); } catch { /* ignore */ } }
112
+ job.status = 'cancelled';
113
+ persist();
114
+ return true;
115
+ }
116
+
117
+ async function runOne(job) {
118
+ job.status = 'running';
119
+ job.error = null;
120
+ const part = job.dest + '.part';
121
+ let from = 0;
122
+ try {
123
+ if (existsSync(part)) {
124
+ try { from = statSync(part).size; } catch { from = 0; }
125
+ }
126
+ const headers = { 'user-agent': 'dsh-folia/download-queue' };
127
+ if (from > 0) headers.range = 'bytes=' + from + '-';
128
+ const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
129
+ job.ctrl = ctrl;
130
+ const r = await fetch(job.url, { headers, signal: ctrl ? ctrl.signal : undefined, redirect: 'follow' });
131
+ if (!r.ok && r.status !== 206) throw new Error('http-' + r.status);
132
+ const startAt = r.status === 206 ? from : 0;
133
+ if (startAt === 0 && existsSync(part)) { try { rmSync(part, { force: true }); } catch { /* ignore */ } }
134
+ const len = Number(r.headers.get('content-length') || 0);
135
+ setJob(job, { total: len ? len + startAt : (job.total || 0) });
136
+ mkdirSync(dirname(job.dest), { recursive: true });
137
+ const out = createWriteStream(part, { flags: startAt > 0 ? 'a' : 'w' });
138
+ const src = r.body ? Readable.fromWeb(r.body) : null;
139
+ if (src) {
140
+ await new Promise((resolve, reject) => {
141
+ src.on('data', (chunk) => {
142
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
143
+ job.received += buf.length;
144
+ if (!out.write(buf)) { src.pause(); out.once('drain', () => src.resume()); }
145
+ });
146
+ src.on('end', resolve);
147
+ src.on('error', reject);
148
+ });
149
+ }
150
+ await new Promise((resolve) => { out.end(resolve); });
151
+ if (!job.received && startAt === 0) throw new Error('empty-body');
152
+ renameSync(part, job.dest);
153
+ job.status = 'done';
154
+ if (!job.total) { try { job.total = statSync(job.dest).size; } catch { /* ignore */ } }
155
+ try { onDone(job); } catch { /* 消费方失败不影响下载结果 */ }
156
+ log('download-queue: done ' + job.kind + ' ' + job.dest + ' (' + Math.round(job.total / 1048576) + ' MB)');
157
+ } catch (e) {
158
+ const aborted = e && (e.name === 'AbortError');
159
+ setJob(job, {
160
+ status: aborted ? 'cancelled' : 'failed',
161
+ error: aborted ? 'aborted' : String((e && e.message) || e),
162
+ });
163
+ if (!aborted) log('download-queue: failed ' + job.url + ' — ' + job.error);
164
+ } finally {
165
+ setJob(job, { ctrl: null });
166
+ persist();
167
+ }
168
+ }
169
+
170
+ function pump() {
171
+ if (pumping) return;
172
+ pumping = true;
173
+ (async () => {
174
+ try {
175
+ for (;;) {
176
+ if (running >= concurrency) break;
177
+ const next = [...jobs.values()].find((j) => j.status === 'queued');
178
+ if (!next) break;
179
+ running += 1;
180
+ runOne(next).finally(() => { running -= 1; setTimeout(() => pump(), 0); });
181
+ }
182
+ } finally {
183
+ pumping = false;
184
+ }
185
+ })();
186
+ }
187
+
188
+ load();
189
+ pump();
190
+
191
+ /** 已落地文件的绝对路径(未完成 / 不存在 ⇒ null)。/folia/local-media/<id> 用它。 */
192
+ function pathFor(id) {
193
+ const job = jobs.get(id);
194
+ if (!job || job.status !== 'done') return null;
195
+ try { return existsSync(job.dest) ? job.dest : null; } catch { return null; }
196
+ }
197
+
198
+ return { add, cancel, snapshot, pump, statePath, pathFor };
199
+ }
package/lib/features.js CHANGED
@@ -38,6 +38,8 @@ export const FEATURES = [
38
38
  host: [
39
39
  'lib/routes/inventory.js',
40
40
  'lib/routes/site-media.js',
41
+ 'lib/routes/downloads.js',
42
+ 'lib/download-queue.js',
41
43
  'lib/routes/media-bytes.js',
42
44
  'lib/routes/media-derived.js',
43
45
  'lib/routes/scene-frame.js',
package/lib/index.js CHANGED
@@ -87,6 +87,7 @@ import { registerSceneFrameRoutes } from './routes/scene-frame.js';
87
87
  import { registerMediaDerivedRoutes } from './routes/media-derived.js';
88
88
  import { registerMediaBytesRoutes } from './routes/media-bytes.js';
89
89
  import { registerSiteMediaRoutes } from './routes/site-media.js';
90
+ import { registerDownloadsRoutes } from './routes/downloads.js';
90
91
  import { registerWeAssetsRoutes } from './routes/we-assets.js';
91
92
  import { registerSceneMediaRoutes } from './routes/scene-media.js';
92
93
  import { registerSceneServeRoutes } from './routes/scene-serve.js';
@@ -330,9 +331,11 @@ export function apply(ctx) {
330
331
  // 为什么单开一族而不是塞进 /media:那条是**本机字节直出**(token → 磁盘),
331
332
  // 这条是"上游 → 磁盘 → 客户端"的三方转发,失败语义完全不同(一切失败 302 直连)。
332
333
  // ⚠️ 必须早于任何 `/site-*` 前缀族:本路由是 exact,不受顺序影响,但保持就近声明。
333
- registerSiteMediaRoutes(webServer, { disposers, base: BASE, serveFile, log });
334
-
334
+ registerSiteMediaRoutes(webServer, { disposers, base: BASE, serveFile, log });
335
335
 
336
+ // 3b-3. 站点资源**本地化队列**(点击即下载、连点即并发;渲染只读本地)→ lib/routes/downloads.js。
337
+ // inventory 也要判"这一条是否已落地",所以队列由那个模块自建单例并导出读取入口。
338
+ registerDownloadsRoutes(webServer, { disposers, base: BASE, serveFile, log });
336
339
 
337
340
  // 3c-3b/3c-5. 场景帧服务族路由(实时抓帧缓存 / 自定义画面)→ lib/routes/scene-frame.js。
338
341
  // 三条注册与它们的依赖都在那个文件里;这里只声明它的依赖。
@@ -93,6 +93,7 @@ export const INLINE_ROUTE_FAMILIES = [
93
93
  // 每条写「族 id + 门面里的注册函数名」:两者**不总同形**(presets → registerGlassPresetsRoutes),
94
94
  // 靠名字猜会在守卫里制造假红。迁移一个族 = 在 FEATURE_ROUTE_FAMILIES 加一条 + 删这里一行。
95
95
  { id: 'site-media', fn: 'registerSiteMediaRoutes' },
96
+ { id: 'downloads', fn: 'registerDownloadsRoutes' },
96
97
  { id: 'media-bytes', fn: 'registerMediaBytesRoutes' },
97
98
  { id: 'media-derived', fn: 'registerMediaDerivedRoutes' },
98
99
  { id: 'scene-frame', fn: 'registerSceneFrameRoutes' },
@@ -0,0 +1,170 @@
1
+ /**
2
+ * routes/downloads.js — **站点资源本地化**的接口族(点击即下载,可并发)。
3
+ *
4
+ * 产品语义(用户明确要求,2026-10-30):
5
+ * · **不用勾选**:用户点哪一条,那一条立刻开始下载;
6
+ * · 连点多条 ⇒ 它们**同时**在下载(队列并发上限 3,多余排队,不会互相拖死);
7
+ * · 渲染方(我们 / 皮肤中心 / 播放器)只从本地取 —— 见 docs/LOCAL-FIRST-DOWNLOADS.md。
8
+ *
9
+ * 路由:
10
+ * POST /folia/downloads/add {items:[{kind,url}]} → 入队(幂等);返回每个条目的 job 快照
11
+ * GET /folia/downloads/list → 全部 job 快照(状态/进度/失败原因)
12
+ * POST /folia/downloads/cancel {id} → 取消(保留 .part 供续传)
13
+ * GET /folia/local-media/<id> → 已落地文件的字节(Range/HEAD 复用 serveFile)
14
+ *
15
+ * 注入契约:`disposers` / `base` / `serveFile` / `log`(队列由本模块自建单例)。
16
+ */
17
+ import { createHash } from 'node:crypto';
18
+ import { existsSync, statSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { homedir } from 'node:os';
21
+ import { createDownloadQueue } from '../download-queue.js';
22
+ export const DEPS = ['disposers', 'base', 'serveFile', 'log'];
23
+
24
+ /**
25
+ * 与 `lib/routes/site-media.js#EXT_OK` **必须一致**:那个模块按"扩展名在白名单里就保留、
26
+ * 否则用 `.bin`"算缓存文件名。这里查缓存时必须用同一套规则,否则永远命不中(=重复下载)。
27
+ */
28
+ const SITE_MEDIA_EXT_OK = new Set(['.mp4', '.webm', '.mov', '.m4v', '.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif']);
29
+
30
+ /** 本地库与状态文件的位置(与其它路由同一口径:DSH_HOME 或桌面端的 harness 目录)。 */
31
+ function dshHome() {
32
+ return process.env.DSH_HOME || join(process.env.APPDATA || homedir(), 'dsh-desktop', 'harness');
33
+ }
34
+
35
+ /** 进程内单例:接口与 inventory 共用同一个队列(并发上限/去重才有意义)。 */
36
+ let QUEUE = null;
37
+ function getDownloadQueue(log) {
38
+ if (!QUEUE) QUEUE = createDownloadQueue({ home: dshHome(), log: log || (() => {}) });
39
+ return QUEUE;
40
+ }
41
+
42
+ /** <home>/library/<kind>/<sha256>.<ext>:同一 URL 同 kind ⇒ 同一路径(幂等的前提)。 */
43
+ function libraryDest(kind, slug, ext) {
44
+ const h = createHash('sha256').update(String(kind) + '/' + String(slug) + '/' + String(ext)).digest('hex').slice(0, 32);
45
+ return join(dshHome(), 'library', String(kind || 'media'), h + '.' + ext);
46
+ }
47
+
48
+ /**
49
+ * inventory 用:这条站点资源**是否已经落到本地**,以及怎么读它。
50
+ *
51
+ * 两个来源,都算"本地",按代价从低到高:
52
+ * ① 下载队列落地(`<home>/library/<kind>/…`)⇒ `/folia/local-media/<id>`
53
+ * ② **site-media 缓存**(首次播放时边下边播留下的那份)⇒ 走已有的
54
+ * `/folia/site-media/<name>?u=…`,它命中缓存时直接 `serveFile`,**不触网**
55
+ *
56
+ * 为什么必须查②:站点壁纸第一次播放会经过 site-media 代理并落盘,如果这里只看①,
57
+ * 同一份字节会被**再下载一遍**(21 MB 的包在弱网上就是"点了很久才出来"的主因),
58
+ * 而且磁盘上存两份。
59
+ */
60
+ export function localCopyFor(url, kind = 'wallpaper') {
61
+ try {
62
+ const raw = String(url);
63
+ const u = new URL(raw);
64
+ const ext = (u.pathname.match(/\.([a-z0-9]{2,5})$/i) || [])[1] || 'bin';
65
+ const slug = u.pathname.split('/').filter(Boolean).pop() || 'item';
66
+ const dest = libraryDest(kind, slug, ext);
67
+ const q = getDownloadQueue();
68
+ const job = q.snapshot().find((j) => j.dest === dest);
69
+ if (job && job.status === 'done') {
70
+ return { id: job.id, url: `/folia/local-media/${job.id}`, bytes: job.total, source: 'library' };
71
+ }
72
+ // ② site-media 缓存:键算法必须与 lib/routes/site-media.js#keyFor **逐字一致**
73
+ // (`sha256(url)[:32] + 规范化扩展名`),否则永远命不中。
74
+ const key = createHash('sha256').update(raw).digest('hex').slice(0, 32)
75
+ + (SITE_MEDIA_EXT_OK.has('.' + ext.toLowerCase()) ? '.' + ext.toLowerCase() : '.bin');
76
+ const cached = join(dshHome(), 'cache', 'site-media', key);
77
+ if (existsSync(cached)) {
78
+ const name = raw.split('/').pop() || 'media';
79
+ return {
80
+ id: key,
81
+ url: '/folia/site-media/' + encodeURIComponent(name) + '?u=' + encodeURIComponent(raw),
82
+ bytes: (() => { try { return statSync(cached).size; } catch { return 0; } })(),
83
+ source: 'site-media-cache',
84
+ };
85
+ }
86
+ return null;
87
+ } catch { return null; }
88
+ }
89
+
90
+ export function registerDownloadsRoutes(webServer, c) {
91
+ const { disposers, base, serveFile, log } = c;
92
+ const downloadQueue = getDownloadQueue(log);
93
+ const BASE = base;
94
+
95
+ const readBody = (req, limit = 64 * 1024) => new Promise((resolve) => {
96
+ let acc = '';
97
+ let done = false;
98
+ req.on('data', (d) => { if (!done) { acc += d; if (acc.length > limit) { done = true; resolve(null); } } });
99
+ req.on('end', () => { if (!done) { done = true; try { resolve(JSON.parse(acc || '{}')); } catch { resolve(null); } } });
100
+ req.on('error', () => { if (!done) { done = true; resolve(null); } });
101
+ });
102
+
103
+ const send = (res, status, body) => {
104
+ try {
105
+ res.statusCode = status;
106
+ res.setHeader('content-type', 'application/json; charset=utf-8');
107
+ res.setHeader('cache-control', 'no-store');
108
+ res.end(JSON.stringify(body));
109
+ } catch { /* ignore */ }
110
+ };
111
+
112
+ disposers.push(webServer.register({
113
+ kind: 'exact',
114
+ path: `${BASE}/downloads/add`,
115
+ handler: async (req, res) => {
116
+ if ((req.method || 'GET').toUpperCase() !== 'POST') { send(res, 405, { ok: false, error: 'method-not-allowed' }); return; }
117
+ const body = await readBody(req);
118
+ const items = (body && Array.isArray(body.items)) ? body.items : [];
119
+ if (!items.length) { send(res, 400, { ok: false, error: 'no-items' }); return; }
120
+ const out = [];
121
+ for (const it of items.slice(0, 64)) {
122
+ const url = String((it && it.url) || '');
123
+ const kind = String((it && it.kind) || 'media');
124
+ let u;
125
+ try { u = new URL(url); } catch { out.push({ url, ok: false, error: 'bad-url' }); continue; }
126
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') { out.push({ url, ok: false, error: 'bad-protocol' }); continue; }
127
+ const ext = (u.pathname.match(/\.([a-z0-9]{2,5})$/i) || [])[1] || 'bin';
128
+ const slug = u.pathname.split('/').filter(Boolean).pop() || 'item';
129
+ const dest = libraryDest(kind, slug, ext);
130
+ const r = downloadQueue.add({ url: u.toString(), dest, kind });
131
+ out.push({ url: u.toString(), ok: true, ...r });
132
+ }
133
+ send(res, 200, { ok: true, items: out });
134
+ },
135
+ }));
136
+
137
+ disposers.push(webServer.register({
138
+ kind: 'exact',
139
+ path: `${BASE}/downloads/list`,
140
+ handler: (req, res) => {
141
+ send(res, 200, { ok: true, jobs: downloadQueue.snapshot() });
142
+ },
143
+ }));
144
+
145
+ disposers.push(webServer.register({
146
+ kind: 'exact',
147
+ path: `${BASE}/downloads/cancel`,
148
+ handler: async (req, res) => {
149
+ if ((req.method || 'GET').toUpperCase() !== 'POST') { send(res, 405, { ok: false, error: 'method-not-allowed' }); return; }
150
+ const body = await readBody(req);
151
+ const id = String((body && body.id) || '');
152
+ if (!id) { send(res, 400, { ok: false, error: 'bad-id' }); return; }
153
+ send(res, 200, { ok: downloadQueue.cancel(id) });
154
+ },
155
+ }));
156
+
157
+ // 本地库字节直出:Range / HEAD 由 serveFile 统一处理(与 /media 同一条实现)。
158
+ disposers.push(webServer.register({
159
+ kind: 'prefix',
160
+ path: `${BASE}/local-media`,
161
+ handler: (req, res) => {
162
+ const method = (req.method || 'GET').toUpperCase();
163
+ if (method !== 'GET' && method !== 'HEAD') { send(res, 405, { ok: false, error: 'method-not-allowed' }); return; }
164
+ const id = String(req.url || '').split('?')[0].split('/').filter(Boolean).pop() || '';
165
+ const abs = downloadQueue.pathFor(id);
166
+ if (!abs) { send(res, 404, { ok: false, error: 'not-downloaded' }); return; }
167
+ serveFile(abs, req, res, method === 'HEAD');
168
+ },
169
+ }));
170
+ }
@@ -105,6 +105,8 @@ function rowStatusOf(probe) {
105
105
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
106
106
  import { homedir } from 'node:os';
107
107
  import { dirname, join } from 'node:path';
108
+ // 本地优先:站点条目若已由下载队列落地,清单里直接给本地地址(渲染只读本地)。
109
+ import { localCopyFor } from './downloads.js';
108
110
 
109
111
  const FOLIA_SITE = 'https://folia-5ve.pages.dev';
110
112
  /**
@@ -291,9 +293,18 @@ function foliaItemFromCatalog(it, base) {
291
293
  // 播放走**本机缓存代理**:首次边下边播并落盘,之后同一张直接从磁盘读(切换不再等网络)。
292
294
  // 尾部那一段只是"像文件名的占位"(客户端拿 `url.split('/').pop()` 当 token 去问 /media-info,
293
295
  // 查不到即"未知源",不会报错)。代理的任何失败都会 302 回 `media` ⇒ 与直连等价。
294
- mediaProxy: type === 'package' ? null
295
- : '/folia/site-media/' + encodeURIComponent(String(media).split('/').pop() || 'media')
296
- + '?u=' + encodeURIComponent(String(media)),
296
+ // **本地优先**:站点资源若已通过下载队列落地,就直接给本地地址(渲染只读本地,不再触网)。
297
+ // 未落地时仍是站点代理(用户点了会先入队下载,见 src/local-first.js)。
298
+ localDownload: (() => { try { return type === 'package' ? null : localCopyFor(String(media), 'wallpaper'); } catch { return null; } })(),
299
+ mediaProxy: (() => {
300
+ if (type === 'package') return null;
301
+ const copy = (() => { try { return localCopyFor(String(media), 'wallpaper'); } catch { return null; } })();
302
+ if (copy) return copy.url;
303
+ return '/folia/site-media/' + encodeURIComponent(String(media).split('/').pop() || 'media')
304
+ + '?u=' + encodeURIComponent(String(media));
305
+ })(),
306
+ /** 已在本地库 ⇒ 点它就是本地播放(界面可标"已在本地")。 */
307
+ local: Boolean((() => { try { return localCopyFor(String(media), 'wallpaper'); } catch { return null; } })()),
297
308
  mediaExt: '.' + (extHit ? extHit[1].toLowerCase() : (type === 'video' ? 'mp4' : 'jpg')),
298
309
  // 站点目录可能带 preview/poster;图片没封面时用自己当预览,视频没有就留空(卡片会是黑块)
299
310
  preview: sitePreview || thumb || null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "4.15.4",
3
+ "version": "4.16.1",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {
@@ -43,6 +43,7 @@
43
43
  "icon.png",
44
44
  "lib/index.js",
45
45
  "lib/client.js",
46
+ "lib/download-queue.js",
46
47
  "lib/host-boot.js",
47
48
  "lib/log.js",
48
49
  "lib/notice.js",