ehbrowser 1.0.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.
Files changed (66) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +531 -0
  3. package/dist/api/contract.js +7 -0
  4. package/dist/api/dto/auth.js +5 -0
  5. package/dist/api/dto/common.js +6 -0
  6. package/dist/api/dto/download.js +5 -0
  7. package/dist/api/dto/favorite.js +5 -0
  8. package/dist/api/dto/gallery.js +43 -0
  9. package/dist/api/dto/index.js +6 -0
  10. package/dist/api/dto/library.js +5 -0
  11. package/dist/api/dto/log.js +5 -0
  12. package/dist/api/dto/playlist.js +8 -0
  13. package/dist/api/dto/settings.js +5 -0
  14. package/dist/api/dto/storage.js +5 -0
  15. package/dist/api/dto/translate.js +6 -0
  16. package/dist/api/envelope.js +41 -0
  17. package/dist/api/events.js +20 -0
  18. package/dist/api/index.js +12 -0
  19. package/dist/api/routes.js +516 -0
  20. package/dist/config/atomic.js +85 -0
  21. package/dist/config/auth-setting.js +97 -0
  22. package/dist/config/cli.js +19 -0
  23. package/dist/config/db.js +168 -0
  24. package/dist/config/index.js +48 -0
  25. package/dist/config/json-store.js +219 -0
  26. package/dist/config/migrations.js +100 -0
  27. package/dist/config/schema.js +108 -0
  28. package/dist/config/user-setting.js +21 -0
  29. package/dist/config/validate.js +173 -0
  30. package/dist/eh/api.js +106 -0
  31. package/dist/eh/archives.js +100 -0
  32. package/dist/eh/auth.js +89 -0
  33. package/dist/eh/cookies.js +65 -0
  34. package/dist/eh/favorites.js +94 -0
  35. package/dist/eh/html.js +158 -0
  36. package/dist/eh/http.js +214 -0
  37. package/dist/eh/index.js +13 -0
  38. package/dist/eh/types.js +11 -0
  39. package/dist/eh/urls.js +81 -0
  40. package/dist/main.js +170 -0
  41. package/dist/platform/errors.js +76 -0
  42. package/dist/platform/open-external.js +90 -0
  43. package/dist/platform/os.js +28 -0
  44. package/dist/platform/paths.js +140 -0
  45. package/dist/server.js +1074 -0
  46. package/dist/services/auth-service.js +276 -0
  47. package/dist/services/config-service.js +227 -0
  48. package/dist/services/detail-cache.js +193 -0
  49. package/dist/services/detail-store.js +208 -0
  50. package/dist/services/download-file.js +127 -0
  51. package/dist/services/download-service.js +586 -0
  52. package/dist/services/favorite-service.js +237 -0
  53. package/dist/services/gallery-service.js +403 -0
  54. package/dist/services/local-library.js +471 -0
  55. package/dist/services/log-service.js +139 -0
  56. package/dist/services/playlist-service.js +85 -0
  57. package/dist/services/search-cache.js +78 -0
  58. package/dist/services/storage-service.js +27 -0
  59. package/dist/services/translate-service.js +208 -0
  60. package/dist/services/update-service.js +268 -0
  61. package/dist/services/upstream-service.js +64 -0
  62. package/dist/services/zip.js +210 -0
  63. package/dist/web/assets/index-C0tAHpmx.css +1 -0
  64. package/dist/web/assets/index-myYEnJ1q.js +4 -0
  65. package/dist/web/index.html +13 -0
  66. package/package.json +63 -0
@@ -0,0 +1,471 @@
1
+ /*
2
+ * 本地库:下载落盘的画廊目录、目录内的 .ehbrowser 元数据、按页取图。
3
+ *
4
+ * 落盘形状为 <下载根>/<分辨率>@<画廊名>/,图片按页序重命名为 0001.jpg 这样的文件名。
5
+ * 元数据只记条数与读到第几页,避免把上千个文件名写进一个小文件。
6
+ *
7
+ * 目录本身是唯一事实来源,手动修改目录内容或删除目录内文件都能如实反映出来。
8
+ */
9
+ import { readdir, readFile, rm, stat } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import { writeFileAtomic } from "../config/atomic.js";
12
+ import { extractEntry, isDirectory, readZipEntries } from "./zip.js";
13
+ /** 元数据文件名,与规格中的命名一致 */
14
+ export const META_FILENAME = ".ehbrowser";
15
+ const META_SCHEMA_VERSION = 1;
16
+ /** 可识别的图片后缀。解压时只取这些后缀,取图时按同样规则匹配 */
17
+ const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp", "avif"]);
18
+ /** 页序文件名:0001.jpg */
19
+ const PAGE_FILE = /^(\d{1,6})\.([A-Za-z0-9]+)$/;
20
+ /**
21
+ * 下载中的标记。写在画廊目录里,记录当前下载的是哪一版。
22
+ * 逐页下载被打断后重来时用它判断目录里的页文件是否属于同一本:是则续下,否则清空。
23
+ */
24
+ export const DOWNLOAD_STATE_FILENAME = ".ehbrowser.downloading";
25
+ /** 元数据中会进入接口的字段。schemaVersion 与详情快照不出接口 */
26
+ export function toLocalGallery(meta) {
27
+ return {
28
+ gid: meta.gid,
29
+ token: meta.token,
30
+ title: meta.title,
31
+ resolution: meta.resolution,
32
+ folder: meta.folder,
33
+ path: meta.path,
34
+ pageCount: meta.pageCount,
35
+ files: meta.files,
36
+ sizeBytes: meta.sizeBytes,
37
+ downloadedAt: meta.downloadedAt,
38
+ lastReadPage: meta.lastReadPage,
39
+ lastReadAt: meta.lastReadAt,
40
+ archive: meta.archive,
41
+ summary: meta.summary,
42
+ };
43
+ }
44
+ /**
45
+ * 画廊名清洗:各平台非法字符取并集,并去掉控制字符与首尾的空格和点号。
46
+ * Windows 不允许名称以点或空格结尾,这里统一处理;长度截断到 100 字,避免路径过长。
47
+ */
48
+ export function safeGalleryName(title) {
49
+ const cleaned = title
50
+ .replace(/[\u0000-\u001f<>:"/\\|?*]/g, " ")
51
+ .replace(/\s+/g, " ")
52
+ .trim()
53
+ .replace(/[. ]+$/, "");
54
+ const capped = cleaned.slice(0, 100).trim();
55
+ return capped === "" ? "untitled" : capped;
56
+ }
57
+ /** 画廊文件夹名:<分辨率>@<清洗后的画廊名> */
58
+ export function libraryFolderName(resolution, title) {
59
+ return `${safeGalleryName(resolution)}@${safeGalleryName(title)}`;
60
+ }
61
+ function isSummary(value) {
62
+ if (typeof value !== "object" || value === null) {
63
+ return false;
64
+ }
65
+ const item = value;
66
+ return typeof item.gid === "number" && typeof item.title === "string";
67
+ }
68
+ /** 详情快照比列表项多了标签分组,缺少该字段即不是一份可用的详情 */
69
+ function isDetail(value) {
70
+ return isSummary(value) && Array.isArray(value.tagGroups);
71
+ }
72
+ /**
73
+ * 从详情快照里取出列表项需要的字段。
74
+ * 只有详情、没有列表项快照时用它兜底,避免把整份详情放进列表响应。
75
+ */
76
+ export function summaryOfDetail(detail) {
77
+ return {
78
+ gid: detail.gid,
79
+ token: detail.token,
80
+ title: detail.title,
81
+ titleJpn: detail.titleJpn,
82
+ category: detail.category,
83
+ thumbUrl: detail.thumbUrl,
84
+ uploader: detail.uploader,
85
+ postedAt: detail.postedAt,
86
+ pageCount: detail.pageCount,
87
+ sizeBytes: detail.sizeBytes,
88
+ rating: detail.rating,
89
+ torrentCount: detail.torrentCount,
90
+ expunged: detail.expunged,
91
+ tags: detail.tags,
92
+ language: detail.language,
93
+ favorite: detail.favorite,
94
+ };
95
+ }
96
+ /** 读入的元数据按陌生数据校验,文件损坏时仅将该目录视为不是本地画廊 */
97
+ function parseMeta(text, folder) {
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse(text);
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ if (typeof parsed !== "object" || parsed === null) {
106
+ return null;
107
+ }
108
+ const raw = parsed;
109
+ const gid = Number(raw["gid"]);
110
+ const resolution = typeof raw["resolution"] === "string" ? raw["resolution"] : "";
111
+ if (!Number.isFinite(gid) || resolution === "") {
112
+ return null;
113
+ }
114
+ const number = (key) => {
115
+ const value = Number(raw[key]);
116
+ return Number.isFinite(value) ? value : 0;
117
+ };
118
+ const detail = isDetail(raw["detail"]) ? raw["detail"] : null;
119
+ return {
120
+ schemaVersion: number("schemaVersion") || META_SCHEMA_VERSION,
121
+ gid,
122
+ token: typeof raw["token"] === "string" ? raw["token"] : "",
123
+ title: typeof raw["title"] === "string" ? raw["title"] : folder,
124
+ resolution,
125
+ pageCount: number("pageCount"),
126
+ files: number("files"),
127
+ sizeBytes: number("sizeBytes"),
128
+ downloadedAt: number("downloadedAt"),
129
+ lastReadPage: number("lastReadPage"),
130
+ lastReadAt: raw["lastReadAt"] === null ? null : number("lastReadAt"),
131
+ archive: typeof raw["archive"] === "string" ? raw["archive"] : null,
132
+ summary: isSummary(raw["summary"])
133
+ ? raw["summary"]
134
+ : detail === null
135
+ ? null
136
+ : summaryOfDetail(detail),
137
+ detail,
138
+ folder,
139
+ path: "",
140
+ };
141
+ }
142
+ export function createLocalLibrary(ctx, options) {
143
+ /** 页序索引:文件夹到「页号 -> 文件名」的映射。取图每页都要用到,因此缓存 */
144
+ const pageIndex = new Map();
145
+ /** 目录扫描结果,避免一次页面渲染中反复读盘 */
146
+ let scannedAt = 0;
147
+ let scanned = null;
148
+ const SCAN_TTL_MS = 2000;
149
+ function root() {
150
+ const configured = ctx.user.get().download.directory;
151
+ return configured === "" ? options.fallbackDirectory(ctx) : configured;
152
+ }
153
+ async function readMeta(folder) {
154
+ const path = join(root(), folder);
155
+ let text;
156
+ try {
157
+ text = await readFile(join(path, META_FILENAME), "utf8");
158
+ }
159
+ catch {
160
+ // 没有元数据的目录不是本地画廊:可能正在下载,也可能是用户自行放入的内容
161
+ return null;
162
+ }
163
+ const meta = parseMeta(text, folder);
164
+ if (meta === null) {
165
+ return null;
166
+ }
167
+ return { ...meta, path };
168
+ }
169
+ async function scan() {
170
+ if (scanned !== null && Date.now() - scannedAt < SCAN_TTL_MS) {
171
+ return scanned;
172
+ }
173
+ const directory = root();
174
+ let entries;
175
+ try {
176
+ entries = await readdir(directory, { withFileTypes: true });
177
+ }
178
+ catch {
179
+ // 目录尚不存在,本地库为空
180
+ scanned = [];
181
+ scannedAt = Date.now();
182
+ return scanned;
183
+ }
184
+ const found = [];
185
+ for (const entry of entries) {
186
+ if (!entry.isDirectory()) {
187
+ continue;
188
+ }
189
+ const meta = await readMeta(entry.name);
190
+ if (meta !== null) {
191
+ found.push(meta);
192
+ }
193
+ }
194
+ found.sort((a, b) => b.downloadedAt - a.downloadedAt);
195
+ scanned = found;
196
+ scannedAt = Date.now();
197
+ return found;
198
+ }
199
+ /**
200
+ * 页号到文件名的映射。文件名即解压时按页序写下的 0001.jpg。
201
+ * 按页号取值而不是按排序后的下标:中间缺少某个页号时返回 null,
202
+ * 不会把后一页当成前一页。
203
+ */
204
+ async function pagesOf(folder) {
205
+ const path = join(root(), folder);
206
+ const cached = pageIndex.get(path);
207
+ if (cached !== undefined) {
208
+ return cached;
209
+ }
210
+ let names;
211
+ try {
212
+ names = await readdir(path);
213
+ }
214
+ catch {
215
+ const empty = new Map();
216
+ pageIndex.set(path, empty);
217
+ return empty;
218
+ }
219
+ const result = new Map();
220
+ for (const name of names) {
221
+ const matched = PAGE_FILE.exec(name);
222
+ if (matched === null) {
223
+ continue;
224
+ }
225
+ if (!IMAGE_EXTENSIONS.has(matched[2].toLowerCase())) {
226
+ continue;
227
+ }
228
+ const page = Number(matched[1]);
229
+ if (!result.has(page)) {
230
+ result.set(page, name);
231
+ }
232
+ }
233
+ pageIndex.set(path, result);
234
+ return result;
235
+ }
236
+ /**
237
+ * 目录里已有的页,按页号排序(页号与字节数)。
238
+ * 不经过 pagesOf 的页索引缓存:下载进行中目录持续变化,缓存中的结果可能是刚建目录时的空快照,
239
+ * 用它判断续传会把已经下载完成的页当成不存在,从而重复下载。
240
+ */
241
+ async function existingPages(folder) {
242
+ const path = join(root(), folder);
243
+ let names;
244
+ try {
245
+ names = await readdir(path);
246
+ }
247
+ catch {
248
+ return [];
249
+ }
250
+ const result = [];
251
+ for (const name of names) {
252
+ const matched = PAGE_FILE.exec(name);
253
+ if (matched === null || !IMAGE_EXTENSIONS.has(matched[2].toLowerCase())) {
254
+ continue;
255
+ }
256
+ let bytes = 0;
257
+ try {
258
+ bytes = (await stat(join(path, name))).size;
259
+ }
260
+ catch {
261
+ // 文件在此期间被删除等情况,按不存在处理
262
+ continue;
263
+ }
264
+ if (bytes > 0) {
265
+ result.push({ page: Number(matched[1]), bytes });
266
+ }
267
+ }
268
+ result.sort((a, b) => a.page - b.page);
269
+ return result;
270
+ }
271
+ async function downloadState(folder) {
272
+ let text;
273
+ try {
274
+ text = await readFile(join(root(), folder, DOWNLOAD_STATE_FILENAME), "utf8");
275
+ }
276
+ catch {
277
+ return null;
278
+ }
279
+ try {
280
+ const raw = JSON.parse(text);
281
+ const gid = Number(raw["gid"]);
282
+ const resolution = typeof raw["resolution"] === "string" ? raw["resolution"] : "";
283
+ if (!Number.isFinite(gid) || resolution === "") {
284
+ return null;
285
+ }
286
+ return {
287
+ gid,
288
+ token: typeof raw["token"] === "string" ? raw["token"] : "",
289
+ resolution,
290
+ pageCount: Number(raw["pageCount"]) || 0,
291
+ startedAt: Number(raw["startedAt"]) || 0,
292
+ };
293
+ }
294
+ catch {
295
+ return null;
296
+ }
297
+ }
298
+ async function beginDownload(folder, state) {
299
+ const path = join(root(), folder);
300
+ await writeFileAtomic(join(path, DOWNLOAD_STATE_FILENAME), `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
301
+ }
302
+ async function endDownload(folder) {
303
+ await rm(join(root(), folder, DOWNLOAD_STATE_FILENAME), { force: true });
304
+ }
305
+ /** 内部使用:包含快照在内的整份元数据。写回时用它保留 detail 这类不经过接口的字段 */
306
+ async function findStored(gid, resolution) {
307
+ const all = await scan();
308
+ return all.find((item) => item.gid === gid && item.resolution === resolution) ?? null;
309
+ }
310
+ async function find(gid, resolution) {
311
+ const stored = await findStored(gid, resolution);
312
+ return stored === null ? null : toLocalGallery(stored);
313
+ }
314
+ /**
315
+ * 落盘的详情快照。scan 已按下载时间从新到旧排序,取第一条命中的即为最近那一份。
316
+ * token 只在两边都非空时比对:手动改过目录的元数据不应因此被当成另一本。
317
+ */
318
+ async function storedDetail(gid, token) {
319
+ const all = await scan();
320
+ const hit = all.find((item) => item.gid === gid &&
321
+ item.detail !== null &&
322
+ (token === "" || item.token === "" || item.token === token));
323
+ return hit?.detail ?? null;
324
+ }
325
+ async function saveDetail(gid, detail) {
326
+ const targets = (await scan()).filter((item) => item.gid === gid);
327
+ let written = 0;
328
+ for (const target of targets) {
329
+ // 先读盘上现状再写:下载进行中可能刚改过进度,不能用扫描缓存中的那份覆盖回盘
330
+ const current = await readMeta(target.folder);
331
+ if (current === null) {
332
+ continue;
333
+ }
334
+ await write({
335
+ ...current,
336
+ detail,
337
+ summary: current.summary ?? summaryOfDetail(detail),
338
+ }, target.folder);
339
+ written += 1;
340
+ }
341
+ return written;
342
+ }
343
+ function invalidate() {
344
+ pageIndex.clear();
345
+ scanned = null;
346
+ scannedAt = 0;
347
+ }
348
+ async function write(meta, folder) {
349
+ const path = join(root(), folder);
350
+ await writeFileAtomic(join(path, META_FILENAME), `${JSON.stringify(meta, null, 2)}\n`, {
351
+ mode: 0o600,
352
+ });
353
+ // 只写元数据不改变目录成员,页索引丢弃即可
354
+ pageIndex.delete(path);
355
+ /*
356
+ * 扫描缓存中的那份是写入之前的,就地替换为新的。
357
+ * 不这么做的话,写入后 2 秒内再读(例如刚写完阅读进度、紧接着又执行一次)拿到的仍是旧值,
358
+ * 元数据会被旧值覆盖回去。
359
+ */
360
+ if (scanned !== null) {
361
+ const entry = { ...meta, folder, path };
362
+ const at = scanned.findIndex((item) => item.folder === folder);
363
+ scanned =
364
+ at === -1
365
+ ? [...scanned, entry]
366
+ : scanned.map((item, index) => (index === at ? entry : item));
367
+ scannedAt = Date.now();
368
+ }
369
+ }
370
+ /** 清除目录里的页文件(只删按页序命名的文件,元数据与归档均保留) */
371
+ async function clearPages(folder) {
372
+ const target = join(root(), folder);
373
+ for (const name of await readdir(target).catch(() => [])) {
374
+ if (PAGE_FILE.test(name)) {
375
+ await rm(join(target, name), { force: true });
376
+ }
377
+ }
378
+ pageIndex.delete(target);
379
+ }
380
+ /**
381
+ * 解压归档。只取图片,按归档中的顺序重命名为 0001.jpg 这样的页序文件名。
382
+ * 顺序即页序:上游打包时即为该次序,重命名后取第几页就是取第几个文件。
383
+ */
384
+ async function extractArchive(archivePath, folder) {
385
+ const entries = await readZipEntries(archivePath);
386
+ const target = join(root(), folder);
387
+ // 重新下载同一个画廊时先清除旧的页文件,避免上一版多出的页留在目录里
388
+ for (const name of await readdir(target).catch(() => [])) {
389
+ if (PAGE_FILE.test(name)) {
390
+ await rm(join(target, name), { force: true });
391
+ }
392
+ }
393
+ let page = 0;
394
+ for (const entry of entries) {
395
+ if (isDirectory(entry)) {
396
+ continue;
397
+ }
398
+ const name = entry.name;
399
+ // 资源叉与隐藏文件不属于内容
400
+ if (name.startsWith("__MACOSX/") ||
401
+ name.split("/").some((part) => part.startsWith("."))) {
402
+ continue;
403
+ }
404
+ const dot = name.lastIndexOf(".");
405
+ if (dot === -1) {
406
+ continue;
407
+ }
408
+ const extension = name.slice(dot + 1).toLowerCase();
409
+ if (!IMAGE_EXTENSIONS.has(extension)) {
410
+ continue;
411
+ }
412
+ page += 1;
413
+ const file = join(target, `${String(page).padStart(4, "0")}.${extension}`);
414
+ await extractEntry(archivePath, entry, file);
415
+ }
416
+ if (page === 0) {
417
+ throw new Error("归档里没有可用的图片");
418
+ }
419
+ invalidate();
420
+ return page;
421
+ }
422
+ return {
423
+ root,
424
+ async list() {
425
+ return (await scan()).map(toLocalGallery);
426
+ },
427
+ find,
428
+ storedDetail,
429
+ saveDetail,
430
+ async imagePath(gid, resolution, page) {
431
+ const gallery = await findStored(gid, resolution);
432
+ if (gallery === null || page < 1) {
433
+ return null;
434
+ }
435
+ const pages = await pagesOf(gallery.folder);
436
+ const name = pages.get(page);
437
+ return name === undefined ? null : join(gallery.path, name);
438
+ },
439
+ async saveProgress(gid, resolution, page) {
440
+ const gallery = await findStored(gid, resolution);
441
+ if (gallery === null) {
442
+ return null;
443
+ }
444
+ // 整份元数据一并写回:详情快照这类不经过接口的字段不能因为写进度而丢失
445
+ const next = {
446
+ ...gallery,
447
+ lastReadPage: Math.max(0, Math.floor(page)),
448
+ lastReadAt: Math.floor(Date.now() / 1000),
449
+ };
450
+ await write(next, gallery.folder);
451
+ return toLocalGallery({ ...next, folder: gallery.folder, path: gallery.path });
452
+ },
453
+ write,
454
+ existingPages,
455
+ downloadState,
456
+ beginDownload,
457
+ endDownload,
458
+ clearPages,
459
+ extractArchive,
460
+ async remove(gid, resolution) {
461
+ const gallery = await findStored(gid, resolution);
462
+ if (gallery === null) {
463
+ return false;
464
+ }
465
+ await rm(gallery.path, { recursive: true, force: true });
466
+ invalidate();
467
+ return true;
468
+ },
469
+ invalidate,
470
+ };
471
+ }
@@ -0,0 +1,139 @@
1
+ /*
2
+ * 日志
3
+ * 各服务通过这里输出:既按原来的格式打到控制台,也按天追加到日志目录下的文件,
4
+ * 还会经 SSE 的 log.appended 推给界面,服务页能直接看到。
5
+ * 目录默认 ~/ehbrowser/logs(便携模式落在 <数据目录>/logs),可在设置 > 日志里改;
6
+ * 每次写入都重新解析目录,因此改完立即生效,不必重启。
7
+ * 写文件是排队异步做的,失败不影响调用方;落盘格式为
8
+ * 「2026-09-21 00:35:12 [info] [download] 消息」,一行一条,同一天共用一个文件。
9
+ */
10
+ import { appendFile, mkdir } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import { isAbsolute, join, normalize } from "node:path";
13
+ /** 内存里保留的条数。界面初次打开时先铺一份,够用即可 */
14
+ const RECENT_LIMIT = 500;
15
+ const FILE_PREFIX = "ehbrowser";
16
+ const FILE_SUFFIX = ".log";
17
+ /** 默认日志目录:便携模式跟着数据目录走,否则就是 ~/ehbrowser/logs */
18
+ export function defaultLogDirectory(ctx) {
19
+ if (ctx.paths.sources.dataDir === "portable-home") {
20
+ return join(ctx.paths.dataDir, "logs");
21
+ }
22
+ return join(homedir(), "ehbrowser", "logs");
23
+ }
24
+ /** 展开 ~ 与 ~/…,相对路径按当前工作目录补全 */
25
+ function absolute(input) {
26
+ const trimmed = input.trim();
27
+ const expanded = trimmed === "~"
28
+ ? homedir()
29
+ : trimmed.startsWith("~/") || trimmed.startsWith("~\\")
30
+ ? join(homedir(), trimmed.slice(2))
31
+ : trimmed;
32
+ return normalize(isAbsolute(expanded) ? expanded : join(process.cwd(), expanded));
33
+ }
34
+ function pad(value) {
35
+ return String(value).padStart(2, "0");
36
+ }
37
+ /** 本地时间的 YYYY-MM-DD,用作文件名 */
38
+ function dayOf(at) {
39
+ const value = new Date(at * 1000);
40
+ return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
41
+ }
42
+ /** 本地时间的 YYYY-MM-DD HH:mm:ss,用作行首时间戳 */
43
+ function stampOf(at) {
44
+ const value = new Date(at * 1000);
45
+ return `${dayOf(at)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
46
+ }
47
+ function lineOf(entry) {
48
+ const tag = entry.tag === "" ? "" : ` [${entry.tag}]`;
49
+ return `${stampOf(entry.at)} [${entry.level}]${tag} ${entry.message}\n`;
50
+ }
51
+ /** 控制台输出的格式:与文件里的区别是省去日期,来源前缀与原来的写法一致 */
52
+ function consoleOf(entry, keepLevel) {
53
+ const tag = entry.tag === "" ? "" : `[${entry.tag}] `;
54
+ return keepLevel ? `${tag}${entry.level}: ${entry.message}` : entry.message;
55
+ }
56
+ export function createLogService(ctx, options) {
57
+ const listeners = new Set();
58
+ /** 环形缓冲,新的在后 */
59
+ const recent = [];
60
+ /** 写文件的串行队列:不 await,但保证顺序,退出时 flush 等它 */
61
+ let queue = Promise.resolve();
62
+ /** 目录创建失败等只报一次,避免日志重复输出 */
63
+ let reported = false;
64
+ function defaultDirectory() {
65
+ return options.fallbackDirectory(ctx);
66
+ }
67
+ function directoryOf() {
68
+ const configured = ctx.user.get().log.directory;
69
+ return configured.trim() === "" ? defaultDirectory() : absolute(configured);
70
+ }
71
+ function fileOf(at) {
72
+ return join(directoryOf(), `${FILE_PREFIX}-${dayOf(at)}${FILE_SUFFIX}`);
73
+ }
74
+ /** 落盘。失败只报一次,日志写入失败不影响服务运行 */
75
+ function write(entry) {
76
+ if (!ctx.user.get().log.enabled) {
77
+ return;
78
+ }
79
+ const path = fileOf(entry.at);
80
+ const text = lineOf(entry);
81
+ queue = queue
82
+ .then(async () => {
83
+ await mkdir(directoryOf(), { recursive: true });
84
+ await appendFile(path, text, "utf8");
85
+ })
86
+ .catch((error) => {
87
+ if (!reported) {
88
+ reported = true;
89
+ console.error(`[log] error: 日志写入失败(${directoryOf()}):${error instanceof Error ? error.message : String(error)}`);
90
+ }
91
+ });
92
+ }
93
+ /** 记一条 */
94
+ function append(level, message, tag = "") {
95
+ const entry = { level, tag, message, at: Math.floor(Date.now() / 1000) };
96
+ recent.push(entry);
97
+ if (recent.length > RECENT_LIMIT) {
98
+ recent.splice(0, recent.length - RECENT_LIMIT);
99
+ }
100
+ // tag 为空的是入口自己的消息(启动横幅之类),控制台上保持原样不加级别前缀
101
+ console.log(consoleOf(entry, tag !== ""));
102
+ write(entry);
103
+ for (const listener of listeners) {
104
+ try {
105
+ listener(entry);
106
+ }
107
+ catch {
108
+ // 单个订阅者异常不影响其余订阅者
109
+ }
110
+ }
111
+ }
112
+ return {
113
+ logger(tag) {
114
+ return (level, message) => {
115
+ append(level, message, tag);
116
+ };
117
+ },
118
+ append,
119
+ state(limit) {
120
+ const count = limit === undefined || limit <= 0 ? 100 : Math.min(limit, RECENT_LIMIT);
121
+ return {
122
+ enabled: ctx.user.get().log.enabled,
123
+ directory: directoryOf(),
124
+ file: `${FILE_PREFIX}-${dayOf(Math.floor(Date.now() / 1000))}${FILE_SUFFIX}`,
125
+ defaultDirectory: defaultDirectory(),
126
+ entries: recent.slice(-count),
127
+ };
128
+ },
129
+ onChange(listener) {
130
+ listeners.add(listener);
131
+ return () => {
132
+ listeners.delete(listener);
133
+ };
134
+ },
135
+ async flush() {
136
+ await queue;
137
+ },
138
+ };
139
+ }
@@ -0,0 +1,85 @@
1
+ /*
2
+ * 用户播放列表:加入、排序、进度与当前项,全部落在 SQLite 里,重启不丢。
3
+ * 这一份与「画廊播放列表」不同:它装的是画廊,手动加入,可以跨画廊连续播。
4
+ */
5
+ import { playlistKeyOf } from "../api/index.js";
6
+ /** meta 表里记当前播放项用哪把键 */
7
+ const CURRENT_KEY_META = "playlist.current";
8
+ function toItem(row) {
9
+ return {
10
+ key: row.key,
11
+ gid: row.gid,
12
+ token: row.token,
13
+ title: row.title,
14
+ thumbUrl: row.thumb_url,
15
+ pageCount: row.page_count,
16
+ resolution: row.resolution === null || row.resolution === "" ? null : row.resolution,
17
+ page: row.page,
18
+ bytesPerPage: row.bytes_per_page,
19
+ addedAt: row.added_at,
20
+ };
21
+ }
22
+ export function createPlaylistService(ctx) {
23
+ const db = ctx.db.raw;
24
+ function rows() {
25
+ return db
26
+ .prepare("select * from playlist_items order by position asc")
27
+ .all();
28
+ }
29
+ function snapshot() {
30
+ return {
31
+ items: rows().map(toItem),
32
+ currentKey: ctx.db.getMeta(CURRENT_KEY_META),
33
+ };
34
+ }
35
+ function touch(key) {
36
+ db.prepare("update playlist_items set updated_at = ? where key = ?").run(Math.floor(Date.now() / 1000), key);
37
+ }
38
+ return {
39
+ snapshot,
40
+ add(input) {
41
+ const key = playlistKeyOf(input.gid, input.resolution);
42
+ const now = Math.floor(Date.now() / 1000);
43
+ const existing = db.prepare("select key from playlist_items where key = ?").get(key);
44
+ if (existing === undefined) {
45
+ const next = db
46
+ .prepare("select coalesce(max(position), 0) + 1 as position from playlist_items")
47
+ .get();
48
+ db.prepare(`insert into playlist_items
49
+ (key, position, gid, token, title, thumb_url, page_count, resolution, page,
50
+ bytes_per_page, added_at, updated_at)
51
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(key, next.position, input.gid, input.token, input.title, input.thumbUrl, input.pageCount, input.resolution ?? null, Math.max(1, input.page ?? 1), Math.max(0, input.bytesPerPage ?? 0), now, now);
52
+ }
53
+ else {
54
+ // 已在列表里:只更新快照字段,保留既有进度与顺序
55
+ db.prepare(`update playlist_items set title = ?, thumb_url = ?, page_count = ?,
56
+ resolution = ?, token = ?, updated_at = ? where key = ?`).run(input.title, input.thumbUrl, input.pageCount, input.resolution ?? null, input.token, now, key);
57
+ }
58
+ return snapshot();
59
+ },
60
+ play(key) {
61
+ const found = db.prepare("select key from playlist_items where key = ?").get(key);
62
+ if (found !== undefined) {
63
+ ctx.db.setMeta(CURRENT_KEY_META, key);
64
+ touch(key);
65
+ }
66
+ return snapshot();
67
+ },
68
+ progress(key, page) {
69
+ db.prepare("update playlist_items set page = ?, updated_at = ? where key = ?").run(Math.max(1, Math.floor(page)), Math.floor(Date.now() / 1000), key);
70
+ return snapshot();
71
+ },
72
+ remove(key) {
73
+ db.prepare("delete from playlist_items where key = ?").run(key);
74
+ if (ctx.db.getMeta(CURRENT_KEY_META) === key) {
75
+ ctx.db.deleteMeta(CURRENT_KEY_META);
76
+ }
77
+ return snapshot();
78
+ },
79
+ clear() {
80
+ db.prepare("delete from playlist_items").run();
81
+ ctx.db.deleteMeta(CURRENT_KEY_META);
82
+ return snapshot();
83
+ },
84
+ };
85
+ }