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,276 @@
1
+ /*
2
+ * 账号服务:登录、igneous 维护、账号增删改与状态摘要。
3
+ * 凭据只写入 auth_setting,对外一律脱敏;变更后经 SSE 通知界面。
4
+ */
5
+ import { activeAccount, igneousAgeDays, isCompleteCookies, isIgneousStale, newAccountId, removeAccount as removeFromList, upsertAccount, } from "../config/index.js";
6
+ import { fetchIgneous, forumLogin, hasLoginCookies, isIgneousValue, probeExAccess, } from "../eh/index.js";
7
+ import { describeTransportError } from "../platform/errors.js";
8
+ const EX_ACCESS_META_KEY = "ex_accessible";
9
+ export function createAuthService(ctx, options = {}) {
10
+ const log = options.logger ?? (() => undefined);
11
+ const listeners = new Set();
12
+ function status() {
13
+ const auth = ctx.auth.get();
14
+ const account = activeAccount(auth);
15
+ const cached = ctx.db.getMeta(EX_ACCESS_META_KEY);
16
+ // 账号条目可能仍在但凭据已被清除,故以凭据是否可用判定登录态
17
+ const usable = account !== null && hasLoginCookies(account.cookies);
18
+ return {
19
+ loggedIn: usable,
20
+ activeAccount: account === null ? null : summarize(auth, account),
21
+ accountCount: auth.accounts.length,
22
+ igneousAgeDays: usable ? igneousAgeDays(account) : null,
23
+ igneousStale: usable ? isIgneousStale(account) : false,
24
+ exAccessible: cached === null ? null : cached === "1",
25
+ };
26
+ }
27
+ function broadcast() {
28
+ const next = status();
29
+ for (const listener of listeners) {
30
+ try {
31
+ listener(next);
32
+ }
33
+ catch {
34
+ // 单个订阅者异常不影响其余订阅者
35
+ }
36
+ }
37
+ }
38
+ ctx.auth.onChange(() => {
39
+ broadcast();
40
+ });
41
+ /** 写入账号并设为当前账号 */
42
+ async function persistAccount(account, makeActive) {
43
+ const auth = ctx.auth.get();
44
+ await ctx.auth.patch({
45
+ accounts: upsertAccount(auth.accounts, account),
46
+ activeAccountId: makeActive ? account.id : auth.activeAccountId,
47
+ });
48
+ }
49
+ /**
50
+ * 里站可达性探测
51
+ * 探测失败(连接失败、握手被重置、连接被拒)按「不可达」处理,只记日志:
52
+ * 该结果为可选信息,不应影响登录之类的主流程
53
+ */
54
+ async function probe() {
55
+ const account = activeAccount(ctx.auth.get());
56
+ let accessible = false;
57
+ try {
58
+ accessible = await probeExAccess(account === null
59
+ ? {}
60
+ : {
61
+ ipbMemberId: account.cookies.ipbMemberId,
62
+ ipbPassHash: account.cookies.ipbPassHash,
63
+ igneous: account.cookies.igneous,
64
+ });
65
+ }
66
+ catch (error) {
67
+ log("warn", `里站探测失败,按不可达处理:${describeTransportError(error)}`);
68
+ }
69
+ ctx.db.setMeta(EX_ACCESS_META_KEY, accessible ? "1" : "0");
70
+ log("info", `里站探测:${accessible ? "可达" : "不可达"}`);
71
+ broadcast();
72
+ return accessible;
73
+ }
74
+ /** 补齐 igneous;取不到时只记日志,登录本身仍算成功 */
75
+ async function withIgneous(account) {
76
+ if (isIgneousValue(account.cookies.igneous)) {
77
+ return account;
78
+ }
79
+ let igneous = null;
80
+ try {
81
+ igneous = await fetchIgneous({
82
+ ipbMemberId: account.cookies.ipbMemberId,
83
+ ipbPassHash: account.cookies.ipbPassHash,
84
+ ...(account.cookies.ipbSessionId === undefined
85
+ ? {}
86
+ : { ipbSessionId: account.cookies.ipbSessionId }),
87
+ }, {
88
+ onFailure: (site, error) => {
89
+ log("warn", `取 igneous 时 ${site} 请求失败(继续试下一个):${describeTransportError(error)}`);
90
+ },
91
+ });
92
+ }
93
+ catch (error) {
94
+ // 兜底:取 igneous 失败只影响 igneous,登录本身照常算成功
95
+ log("warn", `未取到 igneous:${describeTransportError(error)}`);
96
+ return account;
97
+ }
98
+ if (igneous === null) {
99
+ log("warn", "未取到 igneous:当前出口节点可能被上游拒绝,里站需换节点后重新登录");
100
+ return account;
101
+ }
102
+ log("info", `已取得 igneous(${igneous.length} 位)`);
103
+ return {
104
+ ...account,
105
+ cookies: { ...account.cookies, igneous },
106
+ igneousUpdatedAt: Math.floor(Date.now() / 1000),
107
+ };
108
+ }
109
+ return {
110
+ status,
111
+ async login(input) {
112
+ log("info", `开始登录:${input.username}`);
113
+ const cookies = await forumLogin({
114
+ username: input.username,
115
+ password: input.password,
116
+ });
117
+ const account = {
118
+ id: newAccountId(),
119
+ label: input.label ?? input.username,
120
+ site: input.site,
121
+ cookies: {
122
+ ipbMemberId: cookies.ipbMemberId,
123
+ ipbPassHash: cookies.ipbPassHash,
124
+ igneous: "",
125
+ ...(cookies.ipbSessionId === undefined
126
+ ? {}
127
+ : { ipbSessionId: cookies.ipbSessionId }),
128
+ },
129
+ igneousUpdatedAt: null,
130
+ };
131
+ const completed = await withIgneous(account);
132
+ await persistAccount(completed, true);
133
+ await probe();
134
+ log("info", `登录完成:${completed.label}`);
135
+ return status();
136
+ },
137
+ async logout() {
138
+ const auth = ctx.auth.get();
139
+ const account = activeAccount(auth);
140
+ if (account === null) {
141
+ return status();
142
+ }
143
+ // 只清 Cookie 与当前标记,保留账号条目便于再次登录
144
+ const cleared = {
145
+ ...account,
146
+ cookies: { ipbMemberId: "", ipbPassHash: "", igneous: "" },
147
+ igneousUpdatedAt: null,
148
+ };
149
+ await ctx.auth.patch({
150
+ accounts: upsertAccount(auth.accounts, cleared),
151
+ activeAccountId: account.id === auth.activeAccountId ? null : auth.activeAccountId,
152
+ });
153
+ log("info", "已清除当前账号的登录态");
154
+ return status();
155
+ },
156
+ async refreshIgneous() {
157
+ const auth = ctx.auth.get();
158
+ const account = activeAccount(auth);
159
+ if (account === null) {
160
+ throw new Error("尚未登录");
161
+ }
162
+ const refreshed = { ...account, cookies: { ...account.cookies, igneous: "" } };
163
+ const completed = await withIgneous(refreshed);
164
+ if (!isIgneousValue(completed.cookies.igneous)) {
165
+ throw new Error("未取到 igneous:当前出口节点可能被上游拒绝");
166
+ }
167
+ await persistAccount(completed, true);
168
+ return status();
169
+ },
170
+ probeExAccess: probe,
171
+ listAccounts() {
172
+ const auth = ctx.auth.get();
173
+ return auth.accounts.map((account) => summarize(auth, account));
174
+ },
175
+ async createAccount(input) {
176
+ if (!isCompleteCookies(input.cookies)) {
177
+ throw new Error("Cookie 不完整:ipb_member_id、ipb_pass_hash、igneous 均必填");
178
+ }
179
+ const now = Math.floor(Date.now() / 1000);
180
+ const account = {
181
+ id: newAccountId(),
182
+ label: input.label,
183
+ site: input.site,
184
+ cookies: {
185
+ ipbMemberId: input.cookies.ipbMemberId,
186
+ ipbPassHash: input.cookies.ipbPassHash,
187
+ igneous: input.cookies.igneous,
188
+ ...(input.cookies.ipbSessionId === undefined
189
+ ? {}
190
+ : { ipbSessionId: input.cookies.ipbSessionId }),
191
+ },
192
+ igneousUpdatedAt: now,
193
+ ...(input.apiKey === undefined ? {} : { apiKey: input.apiKey }),
194
+ };
195
+ await persistAccount(account, ctx.auth.get().activeAccountId === null);
196
+ return summarize(ctx.auth.get(), account);
197
+ },
198
+ async updateAccount(id, patch) {
199
+ const auth = ctx.auth.get();
200
+ const existing = auth.accounts.find((item) => item.id === id);
201
+ if (existing === undefined) {
202
+ throw new Error(`账号不存在:${id}`);
203
+ }
204
+ const next = {
205
+ ...existing,
206
+ ...(patch.label === undefined ? {} : { label: patch.label }),
207
+ ...(patch.apiKey === undefined ? {} : { apiKey: patch.apiKey }),
208
+ ...(patch.cookies === undefined
209
+ ? {}
210
+ : {
211
+ cookies: { ...existing.cookies, ...cleanCookies(patch.cookies) },
212
+ igneousUpdatedAt: patch.cookies.igneous === undefined || patch.cookies.igneous === ""
213
+ ? existing.igneousUpdatedAt
214
+ : Math.floor(Date.now() / 1000),
215
+ }),
216
+ };
217
+ await persistAccount(next, false);
218
+ return summarize(ctx.auth.get(), next);
219
+ },
220
+ async removeAccount(id) {
221
+ const auth = ctx.auth.get();
222
+ const remaining = removeFromList(auth.accounts, id);
223
+ if (remaining.length === auth.accounts.length) {
224
+ return false;
225
+ }
226
+ await ctx.auth.patch({
227
+ accounts: remaining,
228
+ activeAccountId: auth.activeAccountId === id ? null : auth.activeAccountId,
229
+ });
230
+ log("info", `账号已删除:${id}`);
231
+ return true;
232
+ },
233
+ async activateAccount(id) {
234
+ const auth = ctx.auth.get();
235
+ if (!auth.accounts.some((item) => item.id === id)) {
236
+ throw new Error(`账号不存在:${id}`);
237
+ }
238
+ await ctx.auth.patch({ activeAccountId: id });
239
+ log("info", `已切换账号:${id}`);
240
+ return status();
241
+ },
242
+ onChange(listener) {
243
+ listeners.add(listener);
244
+ return () => {
245
+ listeners.delete(listener);
246
+ };
247
+ },
248
+ };
249
+ }
250
+ function summarize(auth, account) {
251
+ return {
252
+ id: account.id,
253
+ label: account.label,
254
+ site: account.site,
255
+ active: activeAccount(auth)?.id === account.id,
256
+ igneousUpdatedAt: account.igneousUpdatedAt,
257
+ hasApiKey: account.apiKey !== undefined,
258
+ };
259
+ }
260
+ /** 只接受非空字段,避免把 undefined 覆盖进已有凭据 */
261
+ function cleanCookies(cookies) {
262
+ const out = {};
263
+ if (cookies.ipbMemberId !== undefined) {
264
+ out.ipbMemberId = cookies.ipbMemberId;
265
+ }
266
+ if (cookies.ipbPassHash !== undefined) {
267
+ out.ipbPassHash = cookies.ipbPassHash;
268
+ }
269
+ if (cookies.igneous !== undefined) {
270
+ out.igneous = cookies.igneous;
271
+ }
272
+ if (cookies.ipbSessionId !== undefined) {
273
+ out.ipbSessionId = cookies.ipbSessionId;
274
+ }
275
+ return out;
276
+ }
@@ -0,0 +1,227 @@
1
+ /*
2
+ * 配置服务:配置层之上的语义与装配。
3
+ * 负责内部配置与线上 DTO 的映射、跨字段校验、脱敏与变更广播,不处理 HTTP 细节。
4
+ * 结构校验(类型、范围)由 config/validate.ts 负责,此层只管跨字段规则。
5
+ */
6
+ import { ConfigInvalidError, activeAccount, igneousAgeDays, isIgneousStale, } from "../config/index.js";
7
+ /** 线上 DTO 中不落到配置文件里的字段:由服务层计算,patch 时忽略。 */
8
+ const WIRE_ONLY_PATHS = ["schemaVersion", "network.proxy.hasCredentials"];
9
+ export function createConfigService(ctx) {
10
+ const listeners = new Set();
11
+ function snapshot() {
12
+ return {
13
+ setting: toWireSetting(ctx.user.get(), ctx.auth.get()),
14
+ origins: collectOrigins(ctx.user),
15
+ };
16
+ }
17
+ ctx.user.onChange(() => {
18
+ const next = snapshot();
19
+ for (const listener of listeners) {
20
+ try {
21
+ listener(next);
22
+ }
23
+ catch {
24
+ // 单个订阅者异常不影响其余订阅者。
25
+ }
26
+ }
27
+ });
28
+ return {
29
+ snapshot,
30
+ async patchUserSetting(patch) {
31
+ const internal = toInternalPatch(patch);
32
+ if (Object.keys(internal).length > 0) {
33
+ const issues = validateSemantics(mergeSettings(ctx.user.get(), internal));
34
+ if (issues.length > 0) {
35
+ throw new ConfigInvalidError(issues);
36
+ }
37
+ await ctx.user.patch(internal);
38
+ }
39
+ return snapshot();
40
+ },
41
+ pathsInfo() {
42
+ return {
43
+ configDir: ctx.paths.configDir,
44
+ dataDir: ctx.paths.dataDir,
45
+ cacheDir: ctx.paths.cacheDir,
46
+ sources: {
47
+ configDir: ctx.paths.sources.configDir,
48
+ dataDir: ctx.paths.sources.dataDir,
49
+ cacheDir: ctx.paths.sources.cacheDir,
50
+ },
51
+ };
52
+ },
53
+ authStatus() {
54
+ const auth = ctx.auth.get();
55
+ const account = activeAccount(auth);
56
+ const activeAccountSummary = account === null
57
+ ? null
58
+ : {
59
+ id: account.id,
60
+ label: account.label,
61
+ site: account.site,
62
+ active: true,
63
+ igneousUpdatedAt: account.igneousUpdatedAt,
64
+ hasApiKey: account.apiKey !== undefined,
65
+ };
66
+ return {
67
+ loggedIn: account !== null,
68
+ activeAccount: activeAccountSummary,
69
+ accountCount: auth.accounts.length,
70
+ igneousAgeDays: igneousAgeDays(account),
71
+ igneousStale: isIgneousStale(account),
72
+ // 里站可达性尚未探测,接入上游请求后填充。
73
+ exAccessible: null,
74
+ };
75
+ },
76
+ onChange(listener) {
77
+ listeners.add(listener);
78
+ return () => {
79
+ listeners.delete(listener);
80
+ };
81
+ },
82
+ };
83
+ }
84
+ /** 内部配置 -> 线上 DTO。hasCredentials 由 auth 层提供,代理凭据本身不回传。 */
85
+ function toWireSetting(setting, auth) {
86
+ return {
87
+ schemaVersion: setting.schemaVersion,
88
+ locale: setting.locale,
89
+ preferredSite: setting.preferredSite,
90
+ network: {
91
+ requestIntervalMs: setting.network.requestIntervalMs,
92
+ maxSequentialRequests: setting.network.maxSequentialRequests,
93
+ requestTimeoutMs: setting.network.requestTimeoutMs,
94
+ proxy: {
95
+ enabled: setting.network.proxy.enabled,
96
+ protocol: setting.network.proxy.protocol,
97
+ host: setting.network.proxy.host,
98
+ port: setting.network.proxy.port,
99
+ hasCredentials: auth.proxyAuth.username !== "",
100
+ },
101
+ },
102
+ viewer: {
103
+ mode: setting.viewer.mode,
104
+ imageQuality: setting.viewer.imageQuality,
105
+ preloadCount: setting.viewer.preloadCount,
106
+ maxCacheMb: setting.viewer.maxCacheMb,
107
+ maxConcurrentLoads: setting.viewer.maxConcurrentLoads,
108
+ autoplay: {
109
+ enabled: setting.viewer.autoplay.enabled,
110
+ intervalSeconds: setting.viewer.autoplay.intervalSeconds,
111
+ loop: setting.viewer.autoplay.loop,
112
+ },
113
+ },
114
+ translate: {
115
+ tags: setting.translate.tags,
116
+ },
117
+ safety: {
118
+ hintEnabled: setting.safety.hintEnabled,
119
+ url: setting.safety.url,
120
+ },
121
+ download: {
122
+ directory: setting.download.directory,
123
+ keepArchive: setting.download.keepArchive,
124
+ preferredResolution: setting.download.preferredResolution,
125
+ concurrency: setting.download.concurrency,
126
+ },
127
+ log: {
128
+ enabled: setting.log.enabled,
129
+ directory: setting.log.directory,
130
+ },
131
+ ui: {
132
+ theme: setting.ui.theme,
133
+ thumbnailSize: setting.ui.thumbnailSize,
134
+ pageSize: setting.ui.pageSize,
135
+ cachedGalleries: setting.ui.cachedGalleries,
136
+ },
137
+ };
138
+ }
139
+ /** 线上 patch -> 内部 patch:剔除只读字段,其余原样深拷贝。 */
140
+ function toInternalPatch(patch) {
141
+ const copy = structuredClone(patch);
142
+ for (const path of WIRE_ONLY_PATHS) {
143
+ removePath(copy, path);
144
+ }
145
+ return copy;
146
+ }
147
+ function removePath(target, path) {
148
+ const keys = path.split(".");
149
+ const last = keys.pop();
150
+ if (last === undefined) {
151
+ return;
152
+ }
153
+ let current = target;
154
+ for (const key of keys) {
155
+ if (current === null || typeof current !== "object" || Array.isArray(current)) {
156
+ return;
157
+ }
158
+ current = current[key];
159
+ }
160
+ if (current !== null && typeof current === "object" && !Array.isArray(current)) {
161
+ delete current[last];
162
+ }
163
+ }
164
+ /** 出现在文件里的字段记为 file,其余记为 default。env / cli 只影响目录,不参与此处。 */
165
+ function collectOrigins(store) {
166
+ const origins = {};
167
+ walkOrigins(store.raw(), "", origins);
168
+ return origins;
169
+ }
170
+ /** 递归遍历嵌套对象,每层都记一条,前端按点分路径取用。 */
171
+ function walkOrigins(node, prefix, out) {
172
+ if (node === null || typeof node !== "object" || Array.isArray(node)) {
173
+ return;
174
+ }
175
+ for (const [key, value] of Object.entries(node)) {
176
+ const path = prefix === "" ? key : `${prefix}.${key}`;
177
+ out[path] = "file";
178
+ walkOrigins(value, path, out);
179
+ }
180
+ }
181
+ /** 在现有配置上叠加 patch,仅用于校验,不写盘 */
182
+ function mergeSettings(base, patch) {
183
+ const merged = structuredClone(base);
184
+ mergeInto(merged, patch);
185
+ return merged;
186
+ }
187
+ function mergeInto(target, patch) {
188
+ for (const [key, value] of Object.entries(patch)) {
189
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
190
+ target[key] = value;
191
+ continue;
192
+ }
193
+ const current = target[key];
194
+ if (current === null || typeof current !== "object" || Array.isArray(current)) {
195
+ target[key] = structuredClone(value);
196
+ continue;
197
+ }
198
+ mergeInto(current, value);
199
+ }
200
+ }
201
+ /** 跨字段规则。 */
202
+ function validateSemantics(setting) {
203
+ const issues = [];
204
+ const directory = setting.download.directory;
205
+ if (directory !== "" && !isAbsolutePath(directory)) {
206
+ issues.push({
207
+ path: "download.directory",
208
+ code: "conflict",
209
+ message: "需为绝对路径,或留空",
210
+ });
211
+ }
212
+ if (setting.network.proxy.enabled && setting.network.proxy.host.trim() === "") {
213
+ issues.push({ path: "network.proxy.host", code: "missing", message: "启用代理时不能为空" });
214
+ }
215
+ if (setting.viewer.preloadCount > setting.ui.pageSize) {
216
+ issues.push({
217
+ path: "viewer.preloadCount",
218
+ code: "conflict",
219
+ message: "不应超过每页条目数",
220
+ });
221
+ }
222
+ return issues;
223
+ }
224
+ /** POSIX 以 / 开头,Windows 为盘符:\ 或 UNC */
225
+ function isAbsolutePath(value) {
226
+ return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
227
+ }
@@ -0,0 +1,193 @@
1
+ /*
2
+ * 画廊详情缓存。
3
+ * 详情页要的三样东西都存这里:信息(detail)、封面(第 1 页的图片地址)、缩略图(预览图分组)。
4
+ * 内存 LRU,条数上限来自设置(ui.cachedGalleries,0 表示不缓存),超出就丢最久没用的那本。
5
+ * 再次进入同一画廊时一个上游请求都不发。
6
+ *
7
+ * 封面存的是 showpage 给的图片地址,地址里的 keystamp 会过期,因此封面单独带 10 分钟有效期。
8
+ * 信息和缩略图是静态内容,只受条数上限约束。
9
+ * 需要「一定要新」的调用方(更新检查、收藏挪版本、进入阅读前的存在性检查)传 fresh,
10
+ * 跳过读取但仍然写回缓存。
11
+ *
12
+ * 同一个键在途的读只发一次上游。多开几个页面同时看同一本,
13
+ * 或者详情页与播放器同时进同一本时,同一份东西不会重复请求很多遍。
14
+ */
15
+ /** 封面地址的有效期。keystamp 过期后图片会 403,因此宁可重新解析一次 */
16
+ const COVER_TTL_MS = 10 * 60 * 1000;
17
+ /** 缓存空着时用来估体积的单个画廊大小,取自实际详情(含标签、评论、预览)的量级。 */
18
+ const NOMINAL_BYTES = 24 * 1024;
19
+ function keyOf(gid, token) {
20
+ return `${gid}:${token}`;
21
+ }
22
+ /** 条目占多少字节:按 JSON 文本长度估,够用来显示「约 X MB」 */
23
+ function sizeOf(entry) {
24
+ let bytes = entry.detail === null ? 0 : JSON.stringify(entry.detail).length;
25
+ if (entry.cover !== null) {
26
+ bytes += JSON.stringify(entry.cover.page).length;
27
+ }
28
+ for (const set of entry.previews.values()) {
29
+ bytes += JSON.stringify(set).length;
30
+ }
31
+ return bytes;
32
+ }
33
+ export function createDetailCache(options) {
34
+ const log = options.logger ?? (() => undefined);
35
+ /** Map 的插入顺序当 LRU 用:命中就删了再放回尾部。 */
36
+ const entries = new Map();
37
+ function limit() {
38
+ const value = options.limit();
39
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
40
+ }
41
+ function touch(key, entry) {
42
+ entries.delete(key);
43
+ entries.set(key, entry);
44
+ }
45
+ /** 超上限就丢最久没用的。设置调小后下一次访问就会收敛。 */
46
+ function trim() {
47
+ const max = limit();
48
+ if (entries.size <= max) {
49
+ return;
50
+ }
51
+ for (const [key] of entries) {
52
+ if (entries.size <= max) {
53
+ break;
54
+ }
55
+ entries.delete(key);
56
+ }
57
+ log("info", `详情缓存超出上限(${max}),已丢弃最久未用的条目`);
58
+ }
59
+ /** 上限为 0 时缓存不该继续占内存,直接清空。条数调小后也一样收敛。 */
60
+ function prune() {
61
+ const max = limit();
62
+ if (max === 0) {
63
+ if (entries.size > 0) {
64
+ entries.clear();
65
+ }
66
+ return;
67
+ }
68
+ trim();
69
+ }
70
+ function entryOf(gid, token) {
71
+ const key = keyOf(gid, token);
72
+ const entry = entries.get(key);
73
+ if (entry === undefined) {
74
+ return null;
75
+ }
76
+ if (entry.detail === null && entry.cover === null && entry.previews.size === 0) {
77
+ entries.delete(key);
78
+ return null;
79
+ }
80
+ touch(key, entry);
81
+ return entry;
82
+ }
83
+ /*
84
+ * 在途的读。同一个键的并发调用共用一次上游请求,完成后立刻从表里摘掉。
85
+ * 共用的是「正在向上游要」这件事,因此 fresh 的调用方也跟着用,不算吃了旧数据。
86
+ */
87
+ const inflight = new Map();
88
+ function share(key, load) {
89
+ const running = inflight.get(key);
90
+ if (running !== undefined) {
91
+ return running;
92
+ }
93
+ const task = load().finally(() => {
94
+ inflight.delete(key);
95
+ });
96
+ inflight.set(key, task);
97
+ return task;
98
+ }
99
+ /** 拿一份可写的条目。不存在的就新建并占位,写回时再 trim。 */
100
+ function writable(gid, token) {
101
+ const existing = entryOf(gid, token);
102
+ if (existing !== null) {
103
+ return existing;
104
+ }
105
+ const fresh = {
106
+ at: Date.now(),
107
+ detail: null,
108
+ cover: null,
109
+ previews: new Map(),
110
+ bytes: 0,
111
+ };
112
+ entries.set(keyOf(gid, token), fresh);
113
+ return fresh;
114
+ }
115
+ function statsOf() {
116
+ prune();
117
+ let bytes = 0;
118
+ for (const entry of entries.values()) {
119
+ bytes += entry.bytes;
120
+ }
121
+ return {
122
+ entries: entries.size,
123
+ max: limit(),
124
+ bytes,
125
+ perGallery: entries.size === 0
126
+ ? NOMINAL_BYTES
127
+ : Math.max(1024, Math.round(bytes / entries.size)),
128
+ };
129
+ }
130
+ return {
131
+ async detail(gid, token, load, fresh = false) {
132
+ const max = limit();
133
+ if (max === 0) {
134
+ prune();
135
+ return share(`detail:${keyOf(gid, token)}`, load);
136
+ }
137
+ const cached = fresh ? null : entryOf(gid, token)?.detail;
138
+ if (cached !== undefined && cached !== null) {
139
+ return cached;
140
+ }
141
+ const detail = await share(`detail:${keyOf(gid, token)}`, load);
142
+ const entry = writable(gid, token);
143
+ entry.detail = detail;
144
+ entry.at = Date.now();
145
+ entry.bytes = sizeOf(entry);
146
+ trim();
147
+ return detail;
148
+ },
149
+ async cover(gid, token, load, fresh = false) {
150
+ const max = limit();
151
+ if (max === 0) {
152
+ prune();
153
+ return share(`cover:${keyOf(gid, token)}`, load);
154
+ }
155
+ const entry = fresh ? null : entryOf(gid, token);
156
+ const cached = entry?.cover;
157
+ if (cached !== undefined && cached !== null && Date.now() - cached.at < COVER_TTL_MS) {
158
+ return cached.page;
159
+ }
160
+ const page = await share(`cover:${keyOf(gid, token)}`, load);
161
+ const target = writable(gid, token);
162
+ target.cover = { at: Date.now(), page };
163
+ target.at = Date.now();
164
+ target.bytes = sizeOf(target);
165
+ trim();
166
+ return page;
167
+ },
168
+ async previews(gid, token, index, load, fresh = false) {
169
+ const max = limit();
170
+ if (max === 0) {
171
+ prune();
172
+ return share(`previews:${keyOf(gid, token)}:${index}`, load);
173
+ }
174
+ const cached = fresh ? undefined : entryOf(gid, token)?.previews.get(index);
175
+ if (cached !== undefined) {
176
+ return cached;
177
+ }
178
+ const set = await share(`previews:${keyOf(gid, token)}:${index}`, load);
179
+ const entry = writable(gid, token);
180
+ entry.previews.set(index, set);
181
+ entry.at = Date.now();
182
+ entry.bytes = sizeOf(entry);
183
+ trim();
184
+ return set;
185
+ },
186
+ stats: statsOf,
187
+ clear() {
188
+ entries.clear();
189
+ inflight.clear();
190
+ return statsOf();
191
+ },
192
+ };
193
+ }