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,208 @@
1
+ /*
2
+ * 画廊详情的持久缓存。一个画廊一个文件,放在 <缓存目录>/gallery/ 下。
3
+ *
4
+ * 当上游更新,gid/token会更新,同时为了保证缓存秒开,因此将缓存落盘
5
+ *
6
+ * 字段:
7
+ * detail 详情本体,详情页需要
8
+ * usedAt 写入/最后使用时间
9
+ * checkedAt 上次查更新的时间
10
+ * newer 查到的更新版本
11
+ *
12
+ * 读取时更新 mtime,清理按 mtime 判龄,读过就算用过。
13
+ *
14
+ * newer 查到就长期留着,新版本不会自己消失。只有「查过、当时没有」才隔一天重查,
15
+ * 间隔在 gallery-service 里。
16
+ *
17
+ * 缓存目录用户可以自行删除。文件不存在、内容损坏、JSON 不合法等当作没有缓存处理,坏文件直接删除。
18
+ */
19
+ import { mkdir, readdir, readFile, rm, stat, utimes } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+ import { writeFileAtomic } from "../config/atomic.js";
22
+ const SUBDIR = "gallery";
23
+ const DAY_MS = 86_400_000;
24
+ /** token 过滤一次非字母数字字符,避免拼出目录穿越的路径 */
25
+ function fileOf(dir, gid, token) {
26
+ const safe = token.replace(/[^0-9a-zA-Z]/g, "");
27
+ return join(dir, `${gid}-${safe}.json`);
28
+ }
29
+ function isDetail(value) {
30
+ if (typeof value !== "object" || value === null) {
31
+ return false;
32
+ }
33
+ const item = value;
34
+ return typeof item.gid === "number" && typeof item.title === "string";
35
+ }
36
+ function isRelation(value) {
37
+ if (typeof value !== "object" || value === null) {
38
+ return false;
39
+ }
40
+ const item = value;
41
+ return typeof item.gid === "number" && typeof item.token === "string";
42
+ }
43
+ export function createDetailStore(options) {
44
+ const log = options.logger ?? (() => undefined);
45
+ const dir = join(options.cacheDir, SUBDIR);
46
+ async function ensure() {
47
+ await mkdir(dir, { recursive: true, mode: 0o700 });
48
+ }
49
+ /** 读取一条记录,文件损坏时删除并按没有缓存处理 */
50
+ async function read(file) {
51
+ let text;
52
+ try {
53
+ text = await readFile(file, "utf8");
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ try {
59
+ const raw = JSON.parse(text);
60
+ if (!isDetail(raw["detail"])) {
61
+ throw new Error("detail 字段不是详情");
62
+ }
63
+ return {
64
+ detail: raw["detail"],
65
+ usedAt: Number(raw["usedAt"]) || 0,
66
+ checkedAt: raw["checkedAt"] === null ? null : Number(raw["checkedAt"]) || null,
67
+ newer: isRelation(raw["newer"]) ? raw["newer"] : null,
68
+ };
69
+ }
70
+ catch (error) {
71
+ log("warn", `画廊缓存损坏,已丢弃:${file}(${String(error)})`);
72
+ await rm(file, { force: true });
73
+ return null;
74
+ }
75
+ }
76
+ return {
77
+ async get(gid, token) {
78
+ const file = fileOf(dir, gid, token);
79
+ const stored = await read(file);
80
+ if (stored === null) {
81
+ return null;
82
+ }
83
+ // 更新 mtime,清理按 mtime 判龄,读过即视为用过
84
+ const now = new Date();
85
+ await utimes(file, now, now).catch(() => undefined);
86
+ return stored;
87
+ },
88
+ async put(gid, token, detail) {
89
+ await ensure();
90
+ const file = fileOf(dir, gid, token);
91
+ // 保留旧的更新检查结果,已查到的新版本不会消失
92
+ const previous = await read(file);
93
+ const envelope = {
94
+ detail,
95
+ usedAt: Math.floor(Date.now() / 1000),
96
+ checkedAt: previous?.checkedAt ?? null,
97
+ newer: previous?.newer ?? null,
98
+ };
99
+ await writeFileAtomic(file, `${JSON.stringify(envelope)}\n`, { mode: 0o600 });
100
+ },
101
+ async saveCheck(gid, token, newer) {
102
+ const file = fileOf(dir, gid, token);
103
+ const previous = await read(file);
104
+ if (previous === null) {
105
+ // 没有详情时不单独保存检查结果,下次进入该画廊会重新检查
106
+ return;
107
+ }
108
+ const envelope = {
109
+ detail: previous.detail,
110
+ usedAt: previous.usedAt,
111
+ checkedAt: Math.floor(Date.now() / 1000),
112
+ newer,
113
+ };
114
+ await writeFileAtomic(file, `${JSON.stringify(envelope)}\n`, { mode: 0o600 });
115
+ },
116
+ async prune(days) {
117
+ const cutoff = Date.now() - Math.max(0, days) * DAY_MS;
118
+ let removed = 0;
119
+ let bytes = 0;
120
+ let names;
121
+ try {
122
+ names = await readdir(dir);
123
+ }
124
+ catch {
125
+ return { removed, bytes };
126
+ }
127
+ for (const name of names) {
128
+ if (!name.endsWith(".json")) {
129
+ continue;
130
+ }
131
+ const file = join(dir, name);
132
+ try {
133
+ const info = await stat(file);
134
+ if (info.mtimeMs >= cutoff) {
135
+ continue;
136
+ }
137
+ await rm(file, { force: true });
138
+ removed += 1;
139
+ bytes += info.size;
140
+ }
141
+ catch {
142
+ // 遍历途中文件被删除,跳过
143
+ }
144
+ }
145
+ if (removed > 0) {
146
+ log("info", `画廊缓存清理:删掉 ${removed} 条(超过 ${days} 天没用过)`);
147
+ }
148
+ return { removed, bytes };
149
+ },
150
+ async clear() {
151
+ let removed = 0;
152
+ let bytes = 0;
153
+ let names;
154
+ try {
155
+ names = await readdir(dir);
156
+ }
157
+ catch {
158
+ return { removed, bytes };
159
+ }
160
+ for (const name of names) {
161
+ if (!name.endsWith(".json")) {
162
+ continue;
163
+ }
164
+ const file = join(dir, name);
165
+ try {
166
+ const info = await stat(file);
167
+ await rm(file, { force: true });
168
+ removed += 1;
169
+ bytes += info.size;
170
+ }
171
+ catch {
172
+ // 同上
173
+ }
174
+ }
175
+ return { removed, bytes };
176
+ },
177
+ async stats() {
178
+ let entries = 0;
179
+ let bytes = 0;
180
+ let oldest = null;
181
+ let newest = null;
182
+ let names;
183
+ try {
184
+ names = await readdir(dir);
185
+ }
186
+ catch {
187
+ return { entries, bytes, oldestUsedAt: null, newestUsedAt: null };
188
+ }
189
+ for (const name of names) {
190
+ if (!name.endsWith(".json")) {
191
+ continue;
192
+ }
193
+ try {
194
+ const info = await stat(join(dir, name));
195
+ entries += 1;
196
+ bytes += info.size;
197
+ const at = Math.floor(info.mtimeMs / 1000);
198
+ oldest = oldest === null ? at : Math.min(oldest, at);
199
+ newest = newest === null ? at : Math.max(newest, at);
200
+ }
201
+ catch {
202
+ // 同上
203
+ }
204
+ }
205
+ return { entries, bytes, oldestUsedAt: oldest, newestUsedAt: newest };
206
+ },
207
+ };
208
+ }
@@ -0,0 +1,127 @@
1
+ /*
2
+ * 单文件下载。流式写入,边写边上报进度,支持中断,网络层失败可重试。
3
+ * 传输层用 undici 的 fetch:Node 内置 fetch 使用另一份 undici,无法挂载本项目配置的代理。
4
+ */
5
+ import { createWriteStream } from "node:fs";
6
+ import { mkdir, rename, rm } from "node:fs/promises";
7
+ import { dirname, join } from "node:path";
8
+ import { Readable } from "node:stream";
9
+ import { pipeline } from "node:stream/promises";
10
+ import { fetch as undiciFetch } from "undici";
11
+ /** 重试之间的间隔,按次数递增 */
12
+ const RETRY_DELAY_MS = 1500;
13
+ /** HTTP 层面的失败(4xx/5xx):重试会得到同样的结果,因此不重试 */
14
+ class DownloadHttpError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "DownloadHttpError";
18
+ }
19
+ }
20
+ /**
21
+ * 下载到 targetPath,先写 .part 再改名,避免半截文件被当成完整文件
22
+ * 传入 attempts 后会在「连接失败、传输中断」这类失败上重试;HTTP 状态码不对时不重试
23
+ */
24
+ export async function downloadFile(options) {
25
+ const attempts = Math.max(1, options.attempts ?? 1);
26
+ let lastError = new Error("下载失败");
27
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
28
+ try {
29
+ return await downloadOnce(options);
30
+ }
31
+ catch (error) {
32
+ // 用户取消时不再重试;HTTP 错误重试同样没有意义
33
+ if (options.signal?.aborted === true || error instanceof DownloadHttpError) {
34
+ throw error;
35
+ }
36
+ lastError = error;
37
+ if (attempt < attempts) {
38
+ await delay(RETRY_DELAY_MS * attempt, options.signal);
39
+ }
40
+ }
41
+ }
42
+ throw lastError;
43
+ }
44
+ async function downloadOnce(options) {
45
+ await mkdir(dirname(options.targetPath), { recursive: true });
46
+ const partPath = `${options.targetPath}.part`;
47
+ // 超时与调用方的取消信号需同时生效:只传一个会使另一个失效(没有整体超时时请求会一直挂起)
48
+ const timeout = AbortSignal.timeout(options.timeoutMs ?? 10 * 60 * 1000);
49
+ const signal = options.signal === undefined ? timeout : AbortSignal.any([options.signal, timeout]);
50
+ let response;
51
+ try {
52
+ response = await undiciFetch(options.url, {
53
+ headers: options.headers,
54
+ signal,
55
+ dispatcher: options.dispatcher,
56
+ });
57
+ }
58
+ catch (error) {
59
+ // 取消与超时分开处理:原因只挂在 cause 上,不再拼进 message,
60
+ // 外层 describeError 会把 cause 链拼成一句,拼两次会重复
61
+ if (options.signal?.aborted === true) {
62
+ // 取消时不必带上底层的 "This operation was aborted",该内容对用户没有信息量
63
+ throw new Error("下载已取消");
64
+ }
65
+ if (signal.reason instanceof Error && signal.reason.name === "TimeoutError") {
66
+ throw new Error("请求超时", { cause: error });
67
+ }
68
+ throw new Error("请求失败", { cause: error });
69
+ }
70
+ if (!response.ok) {
71
+ throw new DownloadHttpError(`下载失败:HTTP ${response.status}`);
72
+ }
73
+ if (response.body === null) {
74
+ throw new DownloadHttpError("下载失败:响应没有内容");
75
+ }
76
+ const totalHeader = response.headers.get("content-length");
77
+ const bytesTotal = totalHeader === null ? null : Number(totalHeader);
78
+ let bytesDone = 0;
79
+ const interval = options.progressIntervalMs ?? 500;
80
+ let lastReport = 0;
81
+ const source = Readable.fromWeb(response.body);
82
+ source.on("data", (chunk) => {
83
+ bytesDone += chunk.length;
84
+ const now = Date.now();
85
+ if (options.onProgress !== undefined && now - lastReport >= interval) {
86
+ lastReport = now;
87
+ options.onProgress({ bytesDone, bytesTotal });
88
+ }
89
+ });
90
+ try {
91
+ await pipeline(source, createWriteStream(partPath));
92
+ }
93
+ catch (error) {
94
+ await rm(partPath, { force: true });
95
+ if (options.signal?.aborted === true) {
96
+ // 取消时不必带上底层的 "This operation was aborted",该内容对用户没有信息量
97
+ throw new Error("下载已取消");
98
+ }
99
+ throw new Error("写入失败", { cause: error });
100
+ }
101
+ await rm(options.targetPath, { force: true });
102
+ await rename(partPath, options.targetPath);
103
+ options.onProgress?.({ bytesDone, bytesTotal });
104
+ return { bytes: bytesDone, targetPath: options.targetPath };
105
+ }
106
+ /** 重试前的等待。等待期间被取消时直接抛出,不再继续 */
107
+ function delay(ms, signal) {
108
+ return new Promise((resolve, reject) => {
109
+ if (signal?.aborted === true) {
110
+ reject(new Error("下载已取消"));
111
+ return;
112
+ }
113
+ const timer = setTimeout(() => {
114
+ signal?.removeEventListener("abort", onAbort);
115
+ resolve();
116
+ }, ms);
117
+ function onAbort() {
118
+ clearTimeout(timer);
119
+ reject(new Error("下载已取消"));
120
+ }
121
+ signal?.addEventListener("abort", onAbort, { once: true });
122
+ });
123
+ }
124
+ /** 目录为空时直接用文件名,否则拼成相对路径 */
125
+ export function joinIfRelative(directory, name) {
126
+ return directory === "" ? name : join(directory, name);
127
+ }