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.
- package/LICENSE +202 -0
- package/README.md +531 -0
- package/dist/api/contract.js +7 -0
- package/dist/api/dto/auth.js +5 -0
- package/dist/api/dto/common.js +6 -0
- package/dist/api/dto/download.js +5 -0
- package/dist/api/dto/favorite.js +5 -0
- package/dist/api/dto/gallery.js +43 -0
- package/dist/api/dto/index.js +6 -0
- package/dist/api/dto/library.js +5 -0
- package/dist/api/dto/log.js +5 -0
- package/dist/api/dto/playlist.js +8 -0
- package/dist/api/dto/settings.js +5 -0
- package/dist/api/dto/storage.js +5 -0
- package/dist/api/dto/translate.js +6 -0
- package/dist/api/envelope.js +41 -0
- package/dist/api/events.js +20 -0
- package/dist/api/index.js +12 -0
- package/dist/api/routes.js +516 -0
- package/dist/config/atomic.js +85 -0
- package/dist/config/auth-setting.js +97 -0
- package/dist/config/cli.js +19 -0
- package/dist/config/db.js +168 -0
- package/dist/config/index.js +48 -0
- package/dist/config/json-store.js +219 -0
- package/dist/config/migrations.js +100 -0
- package/dist/config/schema.js +108 -0
- package/dist/config/user-setting.js +21 -0
- package/dist/config/validate.js +173 -0
- package/dist/eh/api.js +106 -0
- package/dist/eh/archives.js +100 -0
- package/dist/eh/auth.js +89 -0
- package/dist/eh/cookies.js +65 -0
- package/dist/eh/favorites.js +94 -0
- package/dist/eh/html.js +158 -0
- package/dist/eh/http.js +214 -0
- package/dist/eh/index.js +13 -0
- package/dist/eh/types.js +11 -0
- package/dist/eh/urls.js +81 -0
- package/dist/main.js +170 -0
- package/dist/platform/errors.js +76 -0
- package/dist/platform/open-external.js +90 -0
- package/dist/platform/os.js +28 -0
- package/dist/platform/paths.js +140 -0
- package/dist/server.js +1074 -0
- package/dist/services/auth-service.js +276 -0
- package/dist/services/config-service.js +227 -0
- package/dist/services/detail-cache.js +193 -0
- package/dist/services/detail-store.js +208 -0
- package/dist/services/download-file.js +127 -0
- package/dist/services/download-service.js +586 -0
- package/dist/services/favorite-service.js +237 -0
- package/dist/services/gallery-service.js +403 -0
- package/dist/services/local-library.js +471 -0
- package/dist/services/log-service.js +139 -0
- package/dist/services/playlist-service.js +85 -0
- package/dist/services/search-cache.js +78 -0
- package/dist/services/storage-service.js +27 -0
- package/dist/services/translate-service.js +208 -0
- package/dist/services/update-service.js +268 -0
- package/dist/services/upstream-service.js +64 -0
- package/dist/services/zip.js +210 -0
- package/dist/web/assets/index-C0tAHpmx.css +1 -0
- package/dist/web/assets/index-myYEnJ1q.js +4 -0
- package/dist/web/index.html +13 -0
- package/package.json +63 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 搜索结果缓存。存的是「最后一次检索的条件 + 结果」,界面重进搜索页时可以据此直接铺出列表。
|
|
3
|
+
* 只存在于本次运行期内:文件落在缓存目录的 temp 下,启动时先删除,之后由预热检索重新写入。
|
|
4
|
+
* 读取以内存中的副本为准,文件只是本次的落盘副本(便于排查,也让同一进程内的语义清楚)。
|
|
5
|
+
*/
|
|
6
|
+
import { rm } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { ensureDir, isNotFound, readTextIfExists, writeFileAtomic } from "../config/atomic.js";
|
|
9
|
+
/** 缓存目录下的临时子目录,内容可随时删除 */
|
|
10
|
+
export const TEMP_SUBDIR = "temp";
|
|
11
|
+
const CACHE_FILENAME = "search.json";
|
|
12
|
+
function isCache(value) {
|
|
13
|
+
if (typeof value !== "object" || value === null) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const entry = value;
|
|
17
|
+
return (typeof entry.savedAt === "number" &&
|
|
18
|
+
typeof entry.query === "object" &&
|
|
19
|
+
entry.query !== null &&
|
|
20
|
+
typeof entry.result === "object" &&
|
|
21
|
+
entry.result !== null &&
|
|
22
|
+
Array.isArray(entry.result.items));
|
|
23
|
+
}
|
|
24
|
+
export function createSearchCache(cacheDir) {
|
|
25
|
+
const file = join(cacheDir, TEMP_SUBDIR, CACHE_FILENAME);
|
|
26
|
+
let current = null;
|
|
27
|
+
/** 在途的落盘,按顺序串起来,避免两次写入互相覆盖 */
|
|
28
|
+
let writing = Promise.resolve();
|
|
29
|
+
async function readFromDisk() {
|
|
30
|
+
const text = await readTextIfExists(file);
|
|
31
|
+
if (text === null) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(text);
|
|
36
|
+
return isCache(parsed) ? parsed : null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// 坏文件按没有缓存处理,下一次检索会覆盖它
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
file,
|
|
45
|
+
async read() {
|
|
46
|
+
if (current !== null) {
|
|
47
|
+
return current;
|
|
48
|
+
}
|
|
49
|
+
// 内存中没有就读盘。文件是这份缓存的本体,进程外写入的内容也一并接受
|
|
50
|
+
current = await readFromDisk();
|
|
51
|
+
return current;
|
|
52
|
+
},
|
|
53
|
+
save(entry) {
|
|
54
|
+
current = entry;
|
|
55
|
+
writing = writing
|
|
56
|
+
.then(async () => {
|
|
57
|
+
await ensureDir(join(cacheDir, TEMP_SUBDIR));
|
|
58
|
+
await writeFileAtomic(file, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
59
|
+
})
|
|
60
|
+
.catch(() => {
|
|
61
|
+
// 缓存写入失败不影响检索本身,只是下次需要重新获取
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
async discard() {
|
|
65
|
+
// 先等在途的落盘结束,避免删掉刚写入的文件
|
|
66
|
+
await writing;
|
|
67
|
+
current = null;
|
|
68
|
+
await rm(file, { force: true }).catch((error) => {
|
|
69
|
+
if (!isNotFound(error)) {
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
async flush() {
|
|
75
|
+
await writing;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 储存空间。统计本地画廊与画廊缓存的占用,并按时间清理缓存。
|
|
3
|
+
* 只清理详情持久缓存(detail-store),本地画廊的文件不受影响。
|
|
4
|
+
*/
|
|
5
|
+
export function createStorageService(options) {
|
|
6
|
+
async function stats() {
|
|
7
|
+
const [galleries, cache] = await Promise.all([
|
|
8
|
+
options.library.list(),
|
|
9
|
+
options.store.stats(),
|
|
10
|
+
]);
|
|
11
|
+
return {
|
|
12
|
+
libraryBytes: galleries.reduce((sum, item) => sum + item.sizeBytes, 0),
|
|
13
|
+
libraryGalleries: galleries.length,
|
|
14
|
+
cacheBytes: cache.bytes,
|
|
15
|
+
cacheEntries: cache.entries,
|
|
16
|
+
cacheOldestAt: cache.oldestUsedAt,
|
|
17
|
+
cacheNewestAt: cache.newestUsedAt,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
stats,
|
|
22
|
+
async cleanup(days) {
|
|
23
|
+
const removed = await options.store.prune(days);
|
|
24
|
+
return { removed: removed.removed, bytes: removed.bytes, stats: await stats() };
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 标签翻译词库。
|
|
3
|
+
* 数据来自上游项目 EhTagTranslation/Database:该项目每周把整份标签翻译(含简介)打成发布包,
|
|
4
|
+
* 这里按需下载其中最小的一份 db.text.json.gz,剥掉简介只留「原文 -> 中文名」,再存到缓存目录。
|
|
5
|
+
* 数据本体是 CC BY-NC-SA 3.0,不随程序分发,装不装、什么时候删都由用户决定。
|
|
6
|
+
* 没装词库时界面回落到内置的常用翻译表,功能不受影响。
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { gunzipSync } from "node:zlib";
|
|
11
|
+
import { fetch as undiciFetch } from "undici";
|
|
12
|
+
import { getDispatcher } from "../eh/index.js";
|
|
13
|
+
import { describeError } from "../platform/errors.js";
|
|
14
|
+
/** 发布包的固定地址。GitHub 的 latest 会 302 到当前版本的具体资源。 */
|
|
15
|
+
const SOURCE_URL = "https://github.com/EhTagTranslation/Database/releases/latest/download/db.text.json.gz";
|
|
16
|
+
/** 界面上标注的出处 */
|
|
17
|
+
const SOURCE_LABEL = "EhTagTranslation/Database";
|
|
18
|
+
const DOWNLOAD_TIMEOUT_MS = 120000;
|
|
19
|
+
function tagsFile(cacheDir) {
|
|
20
|
+
return join(cacheDir, "translate", "tags.json");
|
|
21
|
+
}
|
|
22
|
+
/** 剥掉简介,只留命名空间名与标签名。 */
|
|
23
|
+
function parseDatabase(text) {
|
|
24
|
+
const document = JSON.parse(text);
|
|
25
|
+
const namespaces = {};
|
|
26
|
+
for (const item of document.data ?? []) {
|
|
27
|
+
const name = typeof item.namespace === "string" ? item.namespace : "";
|
|
28
|
+
if (name === "") {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const label = typeof item.frontMatters?.name === "string" ? item.frontMatters.name : name;
|
|
32
|
+
const tags = {};
|
|
33
|
+
for (const [raw, entry] of Object.entries(item.data ?? {})) {
|
|
34
|
+
if (typeof entry.name === "string" && entry.name !== "") {
|
|
35
|
+
tags[raw.toLowerCase()] = entry.name;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
namespaces[name] = { label, tags };
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
version: versionOf(document.version),
|
|
42
|
+
namespaces,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** 发布包自报的版本号,未必是字符串(可能是数字)。认不出来就给「未知」。 */
|
|
46
|
+
function versionOf(raw) {
|
|
47
|
+
if (typeof raw === "string" && raw !== "") {
|
|
48
|
+
return raw;
|
|
49
|
+
}
|
|
50
|
+
return typeof raw === "number" ? String(raw) : "未知";
|
|
51
|
+
}
|
|
52
|
+
/** 发布包的版本号:跟随重定向后的地址里带着 tag,如 .../download/v7.28221.1/db.text.json.gz */
|
|
53
|
+
function versionFromUrl(url) {
|
|
54
|
+
const matched = /\/download\/v?([^/]+)\//.exec(url);
|
|
55
|
+
return matched?.[1] ?? null;
|
|
56
|
+
}
|
|
57
|
+
function countTags(namespaces) {
|
|
58
|
+
let total = 0;
|
|
59
|
+
for (const item of Object.values(namespaces)) {
|
|
60
|
+
total += Object.keys(item.tags).length;
|
|
61
|
+
}
|
|
62
|
+
return total;
|
|
63
|
+
}
|
|
64
|
+
/** 下载发布包:整份文件与跳转后的地址(地址里带发布号)。 */
|
|
65
|
+
async function downloadPackage() {
|
|
66
|
+
// 走用户配置的代理。GitHub 在国内大多要代理才连得上。
|
|
67
|
+
const dispatcher = getDispatcher() ?? undefined;
|
|
68
|
+
const signal = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
|
|
69
|
+
// 先只看一跳:latest 会 302 到带版本号的地址,版本号就从那里取。
|
|
70
|
+
const head = await undiciFetch(SOURCE_URL, { redirect: "manual", signal, dispatcher });
|
|
71
|
+
const location = head.headers.get("location");
|
|
72
|
+
await head.body?.cancel();
|
|
73
|
+
if (head.status >= 400) {
|
|
74
|
+
throw new Error(`下载失败:HTTP ${head.status}`);
|
|
75
|
+
}
|
|
76
|
+
const response = await undiciFetch(location ?? SOURCE_URL, {
|
|
77
|
+
redirect: "follow",
|
|
78
|
+
signal,
|
|
79
|
+
dispatcher,
|
|
80
|
+
});
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new Error(`下载失败:HTTP ${response.status}`);
|
|
83
|
+
}
|
|
84
|
+
return { location, packed: Buffer.from(await response.arrayBuffer()) };
|
|
85
|
+
}
|
|
86
|
+
export function createTranslateService(options) {
|
|
87
|
+
const log = options.logger ?? (() => undefined);
|
|
88
|
+
const file = tagsFile(options.cacheDir);
|
|
89
|
+
/** 落盘的那一份。首次读取后常驻内存,更新与删除时替换。 */
|
|
90
|
+
let stored = null;
|
|
91
|
+
let loaded = false;
|
|
92
|
+
let updating = false;
|
|
93
|
+
let error = null;
|
|
94
|
+
/** 首次访问时从磁盘读一次。文件损坏按没装处理,不影响启动。 */
|
|
95
|
+
async function ensureLoaded() {
|
|
96
|
+
if (loaded) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
loaded = true;
|
|
100
|
+
try {
|
|
101
|
+
const text = await readFile(file, "utf8");
|
|
102
|
+
const parsed = JSON.parse(text);
|
|
103
|
+
if (typeof parsed.version === "string" && parsed.namespaces !== undefined) {
|
|
104
|
+
stored = parsed;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (caught) {
|
|
108
|
+
const code = caught.code;
|
|
109
|
+
if (code !== "ENOENT") {
|
|
110
|
+
error = `词库读取失败:${describeError(caught)}`;
|
|
111
|
+
log("warn", error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** 代理偶尔会在 TLS 握手阶段断开,重试一次。两次都失败才算失败。 */
|
|
116
|
+
async function download() {
|
|
117
|
+
let last = new Error("下载失败");
|
|
118
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
119
|
+
try {
|
|
120
|
+
return await downloadPackage();
|
|
121
|
+
}
|
|
122
|
+
catch (caught) {
|
|
123
|
+
last = caught;
|
|
124
|
+
log("warn", `词库下载失败(第 ${attempt} 次):${describeError(caught)}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
throw last instanceof Error ? last : new Error(String(last));
|
|
128
|
+
}
|
|
129
|
+
function status() {
|
|
130
|
+
const namespaces = stored?.namespaces ?? {};
|
|
131
|
+
return {
|
|
132
|
+
installed: stored !== null,
|
|
133
|
+
version: stored?.version ?? null,
|
|
134
|
+
updatedAt: stored?.updatedAt ?? null,
|
|
135
|
+
source: SOURCE_URL,
|
|
136
|
+
tagCount: stored === null ? 0 : countTags(namespaces),
|
|
137
|
+
namespaceCount: Object.keys(namespaces).length,
|
|
138
|
+
updating,
|
|
139
|
+
error,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
async status() {
|
|
144
|
+
await ensureLoaded();
|
|
145
|
+
return status();
|
|
146
|
+
},
|
|
147
|
+
async database() {
|
|
148
|
+
await ensureLoaded();
|
|
149
|
+
if (stored === null) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
version: stored.version,
|
|
154
|
+
updatedAt: stored.updatedAt,
|
|
155
|
+
source: SOURCE_LABEL,
|
|
156
|
+
namespaces: stored.namespaces,
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
async update() {
|
|
160
|
+
await ensureLoaded();
|
|
161
|
+
if (updating) {
|
|
162
|
+
return status();
|
|
163
|
+
}
|
|
164
|
+
updating = true;
|
|
165
|
+
error = null;
|
|
166
|
+
try {
|
|
167
|
+
const { location, packed } = await download();
|
|
168
|
+
const parsed = parseDatabase(gunzipSync(packed).toString("utf8"));
|
|
169
|
+
const next = {
|
|
170
|
+
// 发布包里的 version 只是数据格式版本(数字),发布号在地址里。
|
|
171
|
+
version: (location === null ? null : versionFromUrl(location)) ?? parsed.version,
|
|
172
|
+
updatedAt: Math.floor(Date.now() / 1000),
|
|
173
|
+
source: SOURCE_LABEL,
|
|
174
|
+
namespaces: parsed.namespaces,
|
|
175
|
+
};
|
|
176
|
+
await mkdir(join(options.cacheDir, "translate"), { recursive: true });
|
|
177
|
+
// 先写临时文件再改名:中途失败不会留下半份词库。
|
|
178
|
+
const temporary = `${file}.tmp`;
|
|
179
|
+
await writeFile(temporary, JSON.stringify(next), "utf8");
|
|
180
|
+
await rename(temporary, file);
|
|
181
|
+
stored = next;
|
|
182
|
+
loaded = true;
|
|
183
|
+
log("info", `词库已更新:${next.version},${Object.keys(next.namespaces).length} 个命名空间、${countTags(next.namespaces)} 条标签`);
|
|
184
|
+
}
|
|
185
|
+
catch (caught) {
|
|
186
|
+
error = describeError(caught);
|
|
187
|
+
log("warn", `词库更新失败:${error}`);
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
updating = false;
|
|
191
|
+
}
|
|
192
|
+
return status();
|
|
193
|
+
},
|
|
194
|
+
async remove() {
|
|
195
|
+
await ensureLoaded();
|
|
196
|
+
try {
|
|
197
|
+
await rm(file, { force: true });
|
|
198
|
+
}
|
|
199
|
+
catch (caught) {
|
|
200
|
+
error = describeError(caught);
|
|
201
|
+
}
|
|
202
|
+
stored = null;
|
|
203
|
+
loaded = true;
|
|
204
|
+
error = null;
|
|
205
|
+
return status();
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 本地画廊的更新检查。
|
|
3
|
+
*
|
|
4
|
+
* 逐个向上游查询该画廊是否存在更新版本:取详情中的 current 关系,再取新版本的发布时间。
|
|
5
|
+
* 上游限流较严,默认 5 秒一次请求,因此按队列串行查询,查到一条推送一条,
|
|
6
|
+
* 界面边查边显示,不需要等整批查完。
|
|
7
|
+
*
|
|
8
|
+
* 结果只留在内存中,全局共享一份,应用重启即清空。
|
|
9
|
+
*/
|
|
10
|
+
/** 本地画廊的身份:与本地库、播放列表保持一致 */
|
|
11
|
+
function keyOf(target) {
|
|
12
|
+
return target.key;
|
|
13
|
+
}
|
|
14
|
+
/** 本地画廊 -> 检查目标 */
|
|
15
|
+
function targetOf(gallery) {
|
|
16
|
+
return {
|
|
17
|
+
key: `${gallery.gid}-${gallery.resolution}`,
|
|
18
|
+
gid: gallery.gid,
|
|
19
|
+
token: gallery.token,
|
|
20
|
+
title: gallery.summary?.title ?? gallery.title,
|
|
21
|
+
thumbUrl: gallery.summary?.thumbUrl ?? "",
|
|
22
|
+
postedAt: gallery.summary?.postedAt ?? gallery.downloadedAt,
|
|
23
|
+
pageCount: gallery.pageCount,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function createUpdateService(library, gallery, options = {}) {
|
|
27
|
+
const log = options.logger ?? (() => undefined);
|
|
28
|
+
/** key -> 结果。Map 的插入顺序就是检查顺序 */
|
|
29
|
+
const entries = new Map();
|
|
30
|
+
const listeners = new Set();
|
|
31
|
+
const stateListeners = new Set();
|
|
32
|
+
/** 排队未查的 key。值中记录检查目标与来源,收藏与本地画廊共用一条队列 */
|
|
33
|
+
let queue = [];
|
|
34
|
+
/**
|
|
35
|
+
* 尚未查完的条数,含正在查的那一条。
|
|
36
|
+
* 该值不能直接用 queue.length:正在查的那条已从队列中取出,
|
|
37
|
+
* 界面上的「还剩 N」会从 N-1 起算,看起来少一条。
|
|
38
|
+
*/
|
|
39
|
+
let remaining = 0;
|
|
40
|
+
/**
|
|
41
|
+
* 被「删除更新任务」移除的 key。
|
|
42
|
+
* 正在查的那条同样会被移除:其查完的结果需要丢弃,否则数秒后又会重新出现在列表中。
|
|
43
|
+
*/
|
|
44
|
+
const forgotten = new Set();
|
|
45
|
+
const pending = new Map();
|
|
46
|
+
/**
|
|
47
|
+
* 轮次号。reset 时加一:上一轮在途的请求作废,查完也不再写入结果,
|
|
48
|
+
* 否则「清空后只查选中项」会被上一轮迟到的结果污染。
|
|
49
|
+
*/
|
|
50
|
+
let generation = 0;
|
|
51
|
+
/** 当前运行中的轮次,null 表示没有活动循环 */
|
|
52
|
+
let activeRun = null;
|
|
53
|
+
/** 每轮一个取消信号:reset 时中断在途的上游请求 */
|
|
54
|
+
let controller = new AbortController();
|
|
55
|
+
function state() {
|
|
56
|
+
return {
|
|
57
|
+
// 有排队或正在请求的条目都算「检查中」,界面上的进度才不会频繁闪烁
|
|
58
|
+
checking: activeRun !== null || queue.length > 0,
|
|
59
|
+
entries: [...entries.values()],
|
|
60
|
+
pending: remaining,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function publishState() {
|
|
64
|
+
const next = state();
|
|
65
|
+
for (const listener of stateListeners) {
|
|
66
|
+
try {
|
|
67
|
+
listener(next);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// 单个订阅者异常不影响其余订阅者
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function publish(entry) {
|
|
75
|
+
remaining = Math.max(0, remaining - 1);
|
|
76
|
+
// 已被移除的条目:结果丢弃,进度仍然推进
|
|
77
|
+
if (!forgotten.has(entry.key)) {
|
|
78
|
+
entries.set(entry.key, entry);
|
|
79
|
+
for (const listener of listeners) {
|
|
80
|
+
listener(entry);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// 这一条查完后剩余条数减少,进度随之更新一次
|
|
84
|
+
publishState();
|
|
85
|
+
}
|
|
86
|
+
async function checkOne(target, source, signal) {
|
|
87
|
+
const base = {
|
|
88
|
+
key: target.key,
|
|
89
|
+
gid: target.gid,
|
|
90
|
+
source,
|
|
91
|
+
resolution: target.resolution ?? "",
|
|
92
|
+
title: target.title,
|
|
93
|
+
thumbUrl: target.thumbUrl,
|
|
94
|
+
localPostedAt: target.postedAt,
|
|
95
|
+
localPageCount: target.pageCount,
|
|
96
|
+
checkedAt: Math.floor(Date.now() / 1000),
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
// 更新检查必须请求上游接口,不能使用详情缓存,否则刚发布的新版本会被判为「已是最新」
|
|
100
|
+
const detail = await gallery.detail(target.gid, target.token, { fresh: true });
|
|
101
|
+
if (signal.aborted) {
|
|
102
|
+
throw new Error("已取消");
|
|
103
|
+
}
|
|
104
|
+
/*
|
|
105
|
+
* 上游刚取回的详情比本地快照准确,本地这一版的日期与封面以它为准。
|
|
106
|
+
* 收藏条目本地只记录加入收藏的时间,不作修正时「当前」一栏会显示成加入时间。
|
|
107
|
+
* 页数仍用本地记录值:本地下载可能比上游少几页,该差值需要展示给用户。
|
|
108
|
+
*/
|
|
109
|
+
const held = {
|
|
110
|
+
...base,
|
|
111
|
+
title: detail.title === "" ? base.title : detail.title,
|
|
112
|
+
thumbUrl: detail.thumbUrl === "" ? base.thumbUrl : detail.thumbUrl,
|
|
113
|
+
localPostedAt: detail.postedAt > 0 ? detail.postedAt : base.localPostedAt,
|
|
114
|
+
};
|
|
115
|
+
// current 指向自身时同样算「已是最新」
|
|
116
|
+
if (detail.current === null || detail.current.gid === target.gid) {
|
|
117
|
+
return { ...held, latest: null, error: null };
|
|
118
|
+
}
|
|
119
|
+
// 存在更新版本:一并取回其发布时间与页数,界面上需要显示「当前 -> 更新至」
|
|
120
|
+
const newest = await gallery.summaryOf(detail.current.gid, detail.current.token, signal);
|
|
121
|
+
return {
|
|
122
|
+
...held,
|
|
123
|
+
latest: {
|
|
124
|
+
gid: detail.current.gid,
|
|
125
|
+
token: detail.current.token,
|
|
126
|
+
postedAt: newest.postedAt,
|
|
127
|
+
pageCount: newest.pageCount,
|
|
128
|
+
},
|
|
129
|
+
error: null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
return {
|
|
134
|
+
...base,
|
|
135
|
+
latest: null,
|
|
136
|
+
error: error instanceof Error ? error.message : String(error),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* 逐条检查。同一时刻只运行一个循环,每一轮都会检查轮次号:
|
|
142
|
+
* 被 reset 顶替后立即退出,已取回的结果也一并丢弃。
|
|
143
|
+
*/
|
|
144
|
+
async function run(gen) {
|
|
145
|
+
if (activeRun !== null) {
|
|
146
|
+
// 已有循环在运行:它会自行发现轮次变化,此处不再新建
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
activeRun = gen;
|
|
150
|
+
const signal = controller.signal;
|
|
151
|
+
try {
|
|
152
|
+
for (;;) {
|
|
153
|
+
if (gen !== generation) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const key = queue.shift();
|
|
157
|
+
if (key === undefined) {
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
const target = pending.get(key);
|
|
161
|
+
if (target === undefined) {
|
|
162
|
+
// 该条目在排队期间被删除,不再保留在待查队列中
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const entry = await checkOne(target.target, target.source, signal);
|
|
166
|
+
if (gen !== generation) {
|
|
167
|
+
// 查完时已换代:该条属于上一轮,不写入结果
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
publish(entry);
|
|
171
|
+
log("info", entry.error === null
|
|
172
|
+
? `更新检查:#${target.target.gid} ${entry.latest === null ? "已是最新" : "有更新"}`
|
|
173
|
+
: `更新检查失败:#${target.target.gid} ${entry.error}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
if (activeRun === gen) {
|
|
178
|
+
activeRun = null;
|
|
179
|
+
}
|
|
180
|
+
// 换代期间排入的新队列需要继续执行
|
|
181
|
+
if (generation !== gen) {
|
|
182
|
+
void run(generation);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
// 整轮结束:checked 归零,界面据此把按钮恢复成「检查更新*」
|
|
186
|
+
publishState();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
state,
|
|
192
|
+
async check(input) {
|
|
193
|
+
const reset = input.reset === true;
|
|
194
|
+
if (reset) {
|
|
195
|
+
// 作废上一轮:清空结果与队列,并中断在途请求
|
|
196
|
+
generation += 1;
|
|
197
|
+
queue = [];
|
|
198
|
+
remaining = 0;
|
|
199
|
+
forgotten.clear();
|
|
200
|
+
entries.clear();
|
|
201
|
+
controller.abort();
|
|
202
|
+
controller = new AbortController();
|
|
203
|
+
}
|
|
204
|
+
const gen = generation;
|
|
205
|
+
// 检查目标:显式传入时使用传入的条目(收藏页传收藏条目),否则检查全部本地画廊
|
|
206
|
+
const targets = input.targets === undefined || input.targets.length === 0
|
|
207
|
+
? (await library.list()).map((item) => ({
|
|
208
|
+
target: targetOf(item),
|
|
209
|
+
source: "library",
|
|
210
|
+
}))
|
|
211
|
+
: input.targets.map((target) => ({
|
|
212
|
+
target,
|
|
213
|
+
source: "favorites",
|
|
214
|
+
}));
|
|
215
|
+
const wanted = input.keys === undefined || input.keys.length === 0
|
|
216
|
+
? targets
|
|
217
|
+
: targets.filter((item) => input.keys?.includes(item.target.key) === true);
|
|
218
|
+
const queued = new Set(queue);
|
|
219
|
+
for (const item of wanted) {
|
|
220
|
+
const key = item.target.key;
|
|
221
|
+
// 已在队列中的不重复排队;已查过的仅在本次要求重置时重新排队
|
|
222
|
+
if (queued.has(key) || (entries.has(key) && !reset)) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
queued.add(key);
|
|
226
|
+
pending.set(key, item);
|
|
227
|
+
queue.push(key);
|
|
228
|
+
// 显式重查时将其从已删除集合中移除,否则结果会一直被丢弃
|
|
229
|
+
forgotten.delete(key);
|
|
230
|
+
remaining += 1;
|
|
231
|
+
}
|
|
232
|
+
void run(gen);
|
|
233
|
+
const next = state();
|
|
234
|
+
// 排完队先发布一次状态,界面立刻显示「还剩 N」
|
|
235
|
+
publishState();
|
|
236
|
+
return next;
|
|
237
|
+
},
|
|
238
|
+
onChange(listener) {
|
|
239
|
+
listeners.add(listener);
|
|
240
|
+
return () => {
|
|
241
|
+
listeners.delete(listener);
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
onState(listener) {
|
|
245
|
+
stateListeners.add(listener);
|
|
246
|
+
return () => {
|
|
247
|
+
stateListeners.delete(listener);
|
|
248
|
+
};
|
|
249
|
+
},
|
|
250
|
+
forget(keys) {
|
|
251
|
+
const wanted = new Set(keys);
|
|
252
|
+
for (const key of wanted) {
|
|
253
|
+
entries.delete(key);
|
|
254
|
+
pending.delete(key);
|
|
255
|
+
forgotten.add(key);
|
|
256
|
+
}
|
|
257
|
+
// 撤除仍在排队的条目时,计数同步减少,否则「还剩 N」会停在该值上
|
|
258
|
+
const queuedRemoved = queue.filter((key) => wanted.has(key)).length;
|
|
259
|
+
if (queuedRemoved > 0) {
|
|
260
|
+
queue = queue.filter((key) => !wanted.has(key));
|
|
261
|
+
remaining = Math.max(0, remaining - queuedRemoved);
|
|
262
|
+
}
|
|
263
|
+
log("info", `已从更新列表移除 ${wanted.size} 条`);
|
|
264
|
+
publishState();
|
|
265
|
+
return state();
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 上游服务:把配置里的网络设置接到传输层,并用当前账号的 Cookie 组装调用参数
|
|
3
|
+
* 代理(含认证)与请求间隔在配置变更时即时生效
|
|
4
|
+
* SOCKS5 不受支持,配置为该协议时按直连处理并记录警告
|
|
5
|
+
*/
|
|
6
|
+
import { activeAccount } from "../config/index.js";
|
|
7
|
+
import { clearProxyAgent, gdata, gtoken, setProxyAgent, setRequestInterval, showpage, } from "../eh/index.js";
|
|
8
|
+
import { describeError } from "../platform/errors.js";
|
|
9
|
+
export function createUpstreamService(ctx, service, options = {}) {
|
|
10
|
+
const log = options.logger ?? (() => undefined);
|
|
11
|
+
function applyNetwork() {
|
|
12
|
+
const { proxy, requestIntervalMs, maxSequentialRequests } = service.snapshot().setting.network;
|
|
13
|
+
if (!proxy.enabled) {
|
|
14
|
+
clearProxyAgent();
|
|
15
|
+
log("info", "代理未启用,按直连处理");
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
const credentials = ctx.auth.get().proxyAuth;
|
|
19
|
+
try {
|
|
20
|
+
setProxyAgent(`${proxy.protocol}://${proxy.host}:${proxy.port}`, credentials.username === "" ? undefined : credentials.username, credentials.password);
|
|
21
|
+
log("info", `代理已启用:${proxy.protocol}://${proxy.host}:${proxy.port}`);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
clearProxyAgent();
|
|
25
|
+
log("warn", `代理设置失败,按直连处理:${describeError(error)}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
setRequestInterval(requestIntervalMs, maxSequentialRequests);
|
|
29
|
+
}
|
|
30
|
+
function callOptions() {
|
|
31
|
+
const account = activeAccount(ctx.auth.get());
|
|
32
|
+
if (account === null) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const cookies = {
|
|
36
|
+
ipb_member_id: account.cookies.ipbMemberId,
|
|
37
|
+
ipb_pass_hash: account.cookies.ipbPassHash,
|
|
38
|
+
igneous: account.cookies.igneous,
|
|
39
|
+
};
|
|
40
|
+
if (account.cookies.ipbSessionId !== undefined) {
|
|
41
|
+
cookies["ipb_session_id"] = account.cookies.ipbSessionId;
|
|
42
|
+
}
|
|
43
|
+
return { site: account.site, cookies };
|
|
44
|
+
}
|
|
45
|
+
/** 未登录时不追加 Cookie,公开接口仍可用 */
|
|
46
|
+
function withAuth(extra = {}) {
|
|
47
|
+
return { ...(callOptions() ?? {}), ...extra };
|
|
48
|
+
}
|
|
49
|
+
applyNetwork();
|
|
50
|
+
service.onChange(() => {
|
|
51
|
+
applyNetwork();
|
|
52
|
+
});
|
|
53
|
+
ctx.auth.onChange(() => {
|
|
54
|
+
log("info", "账号变更,后续请求使用新凭据");
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
applyNetwork,
|
|
58
|
+
callOptions,
|
|
59
|
+
hasAccount: () => activeAccount(ctx.auth.get()) !== null,
|
|
60
|
+
gdata: (pairs, extra) => gdata(pairs, withAuth(extra)),
|
|
61
|
+
gtoken: (pages) => gtoken(pages, withAuth()),
|
|
62
|
+
showpage: (input) => showpage(input, withAuth()),
|
|
63
|
+
};
|
|
64
|
+
}
|