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,586 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 下载服务:归档下载任务的排队、执行与取消。
|
|
3
|
+
* 单并发:上游限流严格,且归档本身是服务端打包,串行更稳。
|
|
4
|
+
* 任务状态落在 downloads 表,进度经 SSE 推送。进程退出后未完成任务标记为失败,不做断点续传。
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { activeAccount } from "../config/index.js";
|
|
11
|
+
import { describeError } from "../platform/errors.js";
|
|
12
|
+
import { APP_DIR_NAME } from "../platform/paths.js";
|
|
13
|
+
import { getDispatcher, LoginRequiredError, resolveArchiveUrl, } from "../eh/index.js";
|
|
14
|
+
import { downloadFile, joinIfRelative } from "./download-file.js";
|
|
15
|
+
import { libraryFolderName } from "./local-library.js";
|
|
16
|
+
export function createDownloadService(ctx, upstream, options) {
|
|
17
|
+
const log = options.logger ?? (() => undefined);
|
|
18
|
+
const listeners = new Set();
|
|
19
|
+
const controllers = new Map();
|
|
20
|
+
/*
|
|
21
|
+
* 每个任务当前那一次执行的令牌。
|
|
22
|
+
* 取消是异步生效的:任务被取消后马上又点了重试,上一次执行的收尾会晚一步跑,
|
|
23
|
+
* 那时它会用 cancelled 把刚排上的队盖掉(界面上表现为「点了重试没反应」)。
|
|
24
|
+
* 收尾前先比对令牌,只有还是自己那一轮才允许改状态。
|
|
25
|
+
*/
|
|
26
|
+
const executions = new Map();
|
|
27
|
+
let executionToken = 0;
|
|
28
|
+
const speeds = new Map();
|
|
29
|
+
let running = false;
|
|
30
|
+
const db = ctx.db.raw;
|
|
31
|
+
function rowOf(id) {
|
|
32
|
+
const row = db.prepare("select * from downloads where id = ?").get(id);
|
|
33
|
+
return row ?? null;
|
|
34
|
+
}
|
|
35
|
+
function toTask(row) {
|
|
36
|
+
const bytesTotal = row.bytes_total;
|
|
37
|
+
return {
|
|
38
|
+
id: row.id,
|
|
39
|
+
gid: row.gid,
|
|
40
|
+
token: row.token,
|
|
41
|
+
title: row.title,
|
|
42
|
+
resolution: row.resolution,
|
|
43
|
+
status: row.status,
|
|
44
|
+
progress: row.page_count !== null && row.page_count > 0
|
|
45
|
+
? row.pages_done / row.page_count
|
|
46
|
+
: bytesTotal === null || bytesTotal <= 0
|
|
47
|
+
? null
|
|
48
|
+
: row.bytes_done / bytesTotal,
|
|
49
|
+
bytesDone: row.bytes_done,
|
|
50
|
+
bytesTotal,
|
|
51
|
+
speedBps: speeds.get(row.id) ?? null,
|
|
52
|
+
pagesDone: row.pages_done,
|
|
53
|
+
pageCount: row.page_count,
|
|
54
|
+
outputPath: row.output_path,
|
|
55
|
+
error: row.error,
|
|
56
|
+
createdAt: row.created_at,
|
|
57
|
+
updatedAt: row.updated_at,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function patchRow(id, patch) {
|
|
61
|
+
const current = rowOf(id);
|
|
62
|
+
if (current === null) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const next = { ...current, ...patch, updated_at: Math.floor(Date.now() / 1000) };
|
|
66
|
+
db.prepare(`update downloads set title = ?, status = ?, bytes_done = ?, bytes_total = ?,
|
|
67
|
+
pages_done = ?, page_count = ?, output_path = ?, error = ?, updated_at = ? where id = ?`).run(next.title, next.status, next.bytes_done, next.bytes_total, next.pages_done, next.page_count, next.output_path, next.error, next.updated_at, next.id);
|
|
68
|
+
broadcast(next);
|
|
69
|
+
}
|
|
70
|
+
function broadcast(row) {
|
|
71
|
+
const task = toTask(row);
|
|
72
|
+
for (const listener of listeners) {
|
|
73
|
+
try {
|
|
74
|
+
listener(task);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// 单个订阅者异常不影响其余订阅者。
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function archiveOptions(signal) {
|
|
82
|
+
const account = activeAccount(ctx.auth.get());
|
|
83
|
+
const site = account?.site ?? ctx.user.get().preferredSite;
|
|
84
|
+
if (account === null) {
|
|
85
|
+
return { site, signal };
|
|
86
|
+
}
|
|
87
|
+
const cookies = {
|
|
88
|
+
ipb_member_id: account.cookies.ipbMemberId,
|
|
89
|
+
ipb_pass_hash: account.cookies.ipbPassHash,
|
|
90
|
+
};
|
|
91
|
+
if (account.cookies.igneous !== "") {
|
|
92
|
+
cookies["igneous"] = account.cookies.igneous;
|
|
93
|
+
}
|
|
94
|
+
if (account.cookies.ipbSessionId !== undefined) {
|
|
95
|
+
cookies["ipb_session_id"] = account.cookies.ipbSessionId;
|
|
96
|
+
}
|
|
97
|
+
return { site, cookies, signal };
|
|
98
|
+
}
|
|
99
|
+
/** 取回标题用于展示。失败时保留 gid,不写错误状态。 */
|
|
100
|
+
async function fillTitle(id, gid, token, signal) {
|
|
101
|
+
let title = "";
|
|
102
|
+
try {
|
|
103
|
+
const info = await upstream.gdata([[gid, token]], {
|
|
104
|
+
timeoutMs: TITLE_TIMEOUT_MS,
|
|
105
|
+
signal,
|
|
106
|
+
});
|
|
107
|
+
title = info[0]?.title ?? "";
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (signal.aborted) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
log("warn", `标题读取失败:#${gid} ${describeError(error)}`);
|
|
114
|
+
}
|
|
115
|
+
if (title === "") {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const current = rowOf(id);
|
|
119
|
+
if (current === null || current.title !== "") {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
patchRow(id, { title });
|
|
123
|
+
}
|
|
124
|
+
/** 下载根目录:配置优先,未配置时用默认落点。 */
|
|
125
|
+
function directoryOf() {
|
|
126
|
+
const configured = ctx.user.get().download.directory;
|
|
127
|
+
return configured === "" ? options.fallbackDirectory(ctx) : configured;
|
|
128
|
+
}
|
|
129
|
+
/** 取画廊信息快照写进元数据。失败不影响下载本身,只是本地卡片少了信息。 */
|
|
130
|
+
async function loadSummary(row, signal) {
|
|
131
|
+
if (options.summarise === undefined) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
return await options.summarise(row.gid, row.token, signal);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (!signal.aborted) {
|
|
139
|
+
log("warn", `画廊信息读取失败:#${row.gid} ${describeError(error)}`);
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/*
|
|
145
|
+
* 取详情页快照一并落盘。这份快照就是本地浏览时用的详情:
|
|
146
|
+
* 存下来之后,播放器与详情页不必再向上游要详情(离线也能看标签、评分、页数)。
|
|
147
|
+
* 取不到不算下载失败:元数据里留 null,浏览时再按普通画廊走一次上游。
|
|
148
|
+
*/
|
|
149
|
+
async function loadDetail(row) {
|
|
150
|
+
try {
|
|
151
|
+
return await options.gallery.detail(row.gid, row.token);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
log("warn", `画廊详情读取失败:#${row.gid} ${describeError(error)}`);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** 这次取不到详情时沿用上一版落盘的快照,已有的详情不会被覆盖掉。 */
|
|
159
|
+
async function detailFor(row, fetched) {
|
|
160
|
+
if (fetched !== null) {
|
|
161
|
+
return fetched;
|
|
162
|
+
}
|
|
163
|
+
return await options.library.storedDetail(row.gid, row.token);
|
|
164
|
+
}
|
|
165
|
+
/*
|
|
166
|
+
* 落盘位置:<下载根>/<分辨率>@<画廊名>/。
|
|
167
|
+
* 归档先下到目录里的 archive.zip,解压出图片后按 keepArchive 决定是否留着。
|
|
168
|
+
*/
|
|
169
|
+
function folderFor(row) {
|
|
170
|
+
const title = row.title === "" ? `g${row.gid}` : row.title;
|
|
171
|
+
const folder = libraryFolderName(row.resolution, title);
|
|
172
|
+
const path = joinIfRelative(directoryOf(), folder);
|
|
173
|
+
return { folder, path, archive: join(path, "archive.zip") };
|
|
174
|
+
}
|
|
175
|
+
/** 逐页下载的 resolution 形如 pages-org / pages-res。 */
|
|
176
|
+
function isPageMode(resolution) {
|
|
177
|
+
return resolution === "pages-org" || resolution === "pages-res";
|
|
178
|
+
}
|
|
179
|
+
/** 从图片地址猜扩展名,猜不到按 jpg。 */
|
|
180
|
+
function extensionOf(url) {
|
|
181
|
+
const matched = /\.([A-Za-z0-9]{2,5})(?:\?|#|$)/.exec(url);
|
|
182
|
+
const extension = matched?.[1]?.toLowerCase();
|
|
183
|
+
return extension === undefined || extension.length > 4 ? "jpg" : extension;
|
|
184
|
+
}
|
|
185
|
+
/*
|
|
186
|
+
* 逐页下载。
|
|
187
|
+
* 图片页(showpage)本身不需要登录,因此没有账号也能把整本下下来。
|
|
188
|
+
* 代价是要逐页向上游要地址,而上游限流严格(默认 5 秒一次),页数多时很慢。
|
|
189
|
+
* 支持续传:目录里已经下好的页一律留着不重下,只补缺的那些(见 prepareResume)。
|
|
190
|
+
*/
|
|
191
|
+
async function runPageTask(id, row, controller) {
|
|
192
|
+
const quality = row.resolution === "pages-org" ? "org" : "res";
|
|
193
|
+
const info = await options.gallery.detail(row.gid, row.token);
|
|
194
|
+
const total = info.pageCount;
|
|
195
|
+
// 标题要先补上再算目录名:落盘目录是 <分辨率>@<画廊名>,用兜底名建出来的目录不好认。
|
|
196
|
+
patchRow(id, {
|
|
197
|
+
title: row.title === "" ? info.title : row.title,
|
|
198
|
+
page_count: total,
|
|
199
|
+
pages_done: 0,
|
|
200
|
+
bytes_done: 0,
|
|
201
|
+
bytes_total: null,
|
|
202
|
+
});
|
|
203
|
+
const current = rowOf(id) ?? row;
|
|
204
|
+
const location = folderFor(current);
|
|
205
|
+
await mkdir(location.path, { recursive: true });
|
|
206
|
+
// 续传:同一版已经下过的那几页留着,进度从它们的数量与字节数起算。
|
|
207
|
+
const kept = await prepareResume(location.folder, current, total);
|
|
208
|
+
const have = new Set(kept.map((item) => item.page));
|
|
209
|
+
let bytes = kept.reduce((sum, item) => sum + item.bytes, 0);
|
|
210
|
+
let done = have.size;
|
|
211
|
+
if (done > 0) {
|
|
212
|
+
patchRow(id, { pages_done: done, bytes_done: bytes });
|
|
213
|
+
log("info", `续传:#${row.gid} 已有 ${done}/${total} 页,只补缺的`);
|
|
214
|
+
}
|
|
215
|
+
let pageToken;
|
|
216
|
+
// 速度:与归档下载一样按「这次的增量 / 距上次的间隔」算
|
|
217
|
+
let lastBytes = bytes;
|
|
218
|
+
let lastAt = Date.now();
|
|
219
|
+
for (let page = 1; page <= total; page += 1) {
|
|
220
|
+
if (controller.signal.aborted) {
|
|
221
|
+
throw new Error("下载已取消");
|
|
222
|
+
}
|
|
223
|
+
if (have.has(page)) {
|
|
224
|
+
// 已经下过的页不重下:图片页请求贵(上游限流),下过就保留。
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const item = await options.gallery.imagePage(row.gid, row.token, page, pageToken);
|
|
228
|
+
if (item.nextPageToken !== null) {
|
|
229
|
+
pageToken = item.nextPageToken;
|
|
230
|
+
}
|
|
231
|
+
const url = quality === "org" && item.originalImageUrl !== null
|
|
232
|
+
? item.originalImageUrl
|
|
233
|
+
: item.imageUrl;
|
|
234
|
+
const target = join(location.path, `${String(page).padStart(4, "0")}.${extensionOf(url)}`);
|
|
235
|
+
const result = await downloadFile({
|
|
236
|
+
url,
|
|
237
|
+
targetPath: target,
|
|
238
|
+
headers: { referer: "https://e-hentai.org/" },
|
|
239
|
+
dispatcher: getDispatcher() ?? undefined,
|
|
240
|
+
// 图床是 H@H 节点,连不上或读到一半断掉都常见,因此重试两次
|
|
241
|
+
attempts: PAGE_ATTEMPTS,
|
|
242
|
+
signal: controller.signal,
|
|
243
|
+
onProgress: (progress) => {
|
|
244
|
+
const total = bytes + progress.bytesDone;
|
|
245
|
+
const now = Date.now();
|
|
246
|
+
const elapsed = (now - lastAt) / 1000;
|
|
247
|
+
if (elapsed > 0) {
|
|
248
|
+
speeds.set(id, Math.round((total - lastBytes) / elapsed));
|
|
249
|
+
}
|
|
250
|
+
lastBytes = total;
|
|
251
|
+
lastAt = now;
|
|
252
|
+
patchRow(id, { bytes_done: total });
|
|
253
|
+
},
|
|
254
|
+
}).catch((error) => {
|
|
255
|
+
if (controller.signal.aborted) {
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
// 带上页号:整本重下时才知道卡在哪一页
|
|
259
|
+
throw new Error(`第 ${page}/${total} 页下载失败`, { cause: error });
|
|
260
|
+
});
|
|
261
|
+
bytes += result.bytes;
|
|
262
|
+
done = page;
|
|
263
|
+
lastBytes = bytes;
|
|
264
|
+
lastAt = Date.now();
|
|
265
|
+
patchRow(id, { pages_done: done, bytes_done: bytes });
|
|
266
|
+
}
|
|
267
|
+
speeds.delete(id);
|
|
268
|
+
const summary = await loadSummary(row, controller.signal);
|
|
269
|
+
// 逐页这条路本来就要读一次详情(要页数),这一份也一并落盘,不再重复请求上游
|
|
270
|
+
const detail = await detailFor(row, info);
|
|
271
|
+
// 阅读进度不能被重下抹掉:这一版之前读过就照原样带过来。
|
|
272
|
+
const previous = await options.library.find(row.gid, row.resolution);
|
|
273
|
+
await options.library.write({
|
|
274
|
+
schemaVersion: 1,
|
|
275
|
+
gid: row.gid,
|
|
276
|
+
token: row.token,
|
|
277
|
+
title: summary?.title ?? (row.title === "" ? `g${row.gid}` : row.title),
|
|
278
|
+
resolution: row.resolution,
|
|
279
|
+
pageCount: total,
|
|
280
|
+
files: done,
|
|
281
|
+
sizeBytes: bytes,
|
|
282
|
+
downloadedAt: Math.floor(Date.now() / 1000),
|
|
283
|
+
lastReadPage: previous?.lastReadPage ?? 0,
|
|
284
|
+
lastReadAt: previous?.lastReadAt ?? null,
|
|
285
|
+
archive: null,
|
|
286
|
+
summary,
|
|
287
|
+
detail,
|
|
288
|
+
}, location.folder);
|
|
289
|
+
// 收尾:清掉下载标记,页文件与元数据都留着。
|
|
290
|
+
await options.library.endDownload(location.folder);
|
|
291
|
+
patchRow(id, { status: "completed", output_path: location.path, error: null });
|
|
292
|
+
log("info", `逐页下载完成:${location.path}(${done} 页,${bytes} 字节${have.size > 0 ? `,其中续传 ${have.size} 页` : ""})`);
|
|
293
|
+
}
|
|
294
|
+
/*
|
|
295
|
+
* 续传准备:判断目录里已有的页能不能接着用。
|
|
296
|
+
* 认得出是同一版(同一个 gid + 分辨率)就留着,只有确认换了目标才清空:
|
|
297
|
+
* - 有下载标记且标记是同一版 -> 接着下
|
|
298
|
+
* - 有标记但不是同一版(换了版本或分辨率)-> 清空重下,否则页序会串
|
|
299
|
+
* - 没标记但目录里是另一个 gid 的成品(升级下载、标题又正好相同)-> 清空重下
|
|
300
|
+
* - 没标记、也没法确认是谁的(手放的文件、更老版本留下的)-> 留着不删,只记一条日志
|
|
301
|
+
*/
|
|
302
|
+
async function prepareResume(folder, row, total) {
|
|
303
|
+
const state = await options.library.downloadState(folder);
|
|
304
|
+
const sameAsState = state !== null && state.gid === row.gid && state.resolution === row.resolution;
|
|
305
|
+
// 目录里已有的成品是谁的:文件夹名相同时靠它区分「同一版重下」与「另一版的更新」。
|
|
306
|
+
const owned = state === null
|
|
307
|
+
? ((await options.library.list()).find((item) => item.folder === folder) ?? null)
|
|
308
|
+
: null;
|
|
309
|
+
const otherVersion = state !== null ? !sameAsState : owned !== null && owned.gid !== row.gid;
|
|
310
|
+
let kept = [];
|
|
311
|
+
if (otherVersion) {
|
|
312
|
+
const existing = await options.library.existingPages(folder);
|
|
313
|
+
if (existing.length > 0) {
|
|
314
|
+
const who = state !== null ? `#${state.gid}` : `#${owned?.gid ?? 0}`;
|
|
315
|
+
log("warn", `目录已有 ${existing.length} 页且属于 ${who},不是这一版:清空后重下`);
|
|
316
|
+
await options.library.clearPages(folder);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
kept = await options.library.existingPages(folder);
|
|
321
|
+
if (kept.length > 0 && state === null && owned === null) {
|
|
322
|
+
log("warn", `目录已有 ${kept.length} 页但没有任何下载记录,无法确认属于哪一版,按续传处理`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
await options.library.beginDownload(folder, {
|
|
326
|
+
gid: row.gid,
|
|
327
|
+
token: row.token,
|
|
328
|
+
resolution: row.resolution,
|
|
329
|
+
pageCount: total,
|
|
330
|
+
startedAt: Math.floor(Date.now() / 1000),
|
|
331
|
+
});
|
|
332
|
+
return kept;
|
|
333
|
+
}
|
|
334
|
+
async function runTask(id) {
|
|
335
|
+
let row = rowOf(id);
|
|
336
|
+
if (row === null || row.status !== "queued") {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const controller = new AbortController();
|
|
340
|
+
const token = (executionToken += 1);
|
|
341
|
+
controllers.set(id, controller);
|
|
342
|
+
executions.set(id, token);
|
|
343
|
+
patchRow(id, { status: "running", error: null });
|
|
344
|
+
log("info", `开始下载 #${row.gid} ${row.resolution}`);
|
|
345
|
+
try {
|
|
346
|
+
// 逐页下载(游客也能下):不碰归档接口,逐页取图片地址后直接落盘。
|
|
347
|
+
if (isPageMode(row.resolution)) {
|
|
348
|
+
await runPageTask(id, row, controller);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (row.title === "") {
|
|
352
|
+
// 落盘文件名取自标题。上游无响应时按超时放行,文件名退回 gid。
|
|
353
|
+
await fillTitle(id, row.gid, row.token, controller.signal);
|
|
354
|
+
row = rowOf(id) ?? row;
|
|
355
|
+
}
|
|
356
|
+
const url = await resolveArchiveUrl(row.gid, row.token, row.resolution, row.resolution === "hath", archiveOptions(controller.signal));
|
|
357
|
+
if (controller.signal.aborted) {
|
|
358
|
+
throw new Error("下载已取消");
|
|
359
|
+
}
|
|
360
|
+
if (url === null) {
|
|
361
|
+
// H@H 下载由客户端在后台取回,本地队列到此结束
|
|
362
|
+
patchRow(id, { status: "completed", error: "H@H 下载已提交,由客户端取回" });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const location = folderFor(row);
|
|
366
|
+
await mkdir(location.path, { recursive: true });
|
|
367
|
+
let lastBytes = 0;
|
|
368
|
+
let lastAt = Date.now();
|
|
369
|
+
const result = await downloadFile({
|
|
370
|
+
url,
|
|
371
|
+
targetPath: location.archive,
|
|
372
|
+
headers: { referer: "https://e-hentai.org/" },
|
|
373
|
+
dispatcher: getDispatcher() ?? undefined,
|
|
374
|
+
// 归档动辄几百 MB,重试代价高,只给一次机会。
|
|
375
|
+
attempts: ARCHIVE_ATTEMPTS,
|
|
376
|
+
signal: controller.signal,
|
|
377
|
+
onProgress: (progress) => {
|
|
378
|
+
const now = Date.now();
|
|
379
|
+
const elapsed = (now - lastAt) / 1000;
|
|
380
|
+
if (elapsed > 0) {
|
|
381
|
+
speeds.set(id, Math.round((progress.bytesDone - lastBytes) / elapsed));
|
|
382
|
+
}
|
|
383
|
+
lastBytes = progress.bytesDone;
|
|
384
|
+
lastAt = now;
|
|
385
|
+
const current = rowOf(id);
|
|
386
|
+
if (current !== null) {
|
|
387
|
+
patchRow(id, {
|
|
388
|
+
bytes_done: progress.bytesDone,
|
|
389
|
+
bytes_total: progress.bytesTotal,
|
|
390
|
+
title: current.title,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
speeds.delete(id);
|
|
396
|
+
patchRow(id, { bytes_done: result.bytes, bytes_total: result.bytes });
|
|
397
|
+
// 下完就解压:图片按页序重命名后落在同一个目录里,播放器才能按页取本地文件。
|
|
398
|
+
log("info", `归档下载完成,开始解压:${location.archive}`);
|
|
399
|
+
const files = await options.library.extractArchive(location.archive, location.folder);
|
|
400
|
+
const keepArchive = ctx.user.get().download.keepArchive;
|
|
401
|
+
if (!keepArchive) {
|
|
402
|
+
await rm(location.archive, { force: true });
|
|
403
|
+
}
|
|
404
|
+
const keep = keepArchive ? "archive.zip" : null;
|
|
405
|
+
const summary = await loadSummary(row, controller.signal);
|
|
406
|
+
// 归档这条路本来只取 gdata 快照,这里补一次详情(多半命中详情缓存),
|
|
407
|
+
// 把详情页那份数据一并落盘,本地浏览才不会再次请求上游
|
|
408
|
+
const detail = await detailFor(row, await loadDetail(row));
|
|
409
|
+
const meta = {
|
|
410
|
+
schemaVersion: 1,
|
|
411
|
+
gid: row.gid,
|
|
412
|
+
token: row.token,
|
|
413
|
+
title: summary?.title ?? (row.title === "" ? `g${row.gid}` : row.title),
|
|
414
|
+
resolution: row.resolution,
|
|
415
|
+
pageCount: summary?.pageCount ?? files,
|
|
416
|
+
files,
|
|
417
|
+
sizeBytes: result.bytes,
|
|
418
|
+
downloadedAt: Math.floor(Date.now() / 1000),
|
|
419
|
+
lastReadPage: 0,
|
|
420
|
+
lastReadAt: null,
|
|
421
|
+
archive: keep,
|
|
422
|
+
summary,
|
|
423
|
+
detail,
|
|
424
|
+
};
|
|
425
|
+
await options.library.write(meta, location.folder);
|
|
426
|
+
patchRow(id, {
|
|
427
|
+
status: "completed",
|
|
428
|
+
output_path: location.path,
|
|
429
|
+
error: null,
|
|
430
|
+
});
|
|
431
|
+
log("info", `下载完成:${location.path}(${files} 页,${result.bytes} 字节${keep === null ? ",归档已删" : ",归档保留"})`);
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
speeds.delete(id);
|
|
435
|
+
const aborted = controller.signal.aborted;
|
|
436
|
+
// 已经被重新排队(新一轮执行接了手)时,这次收尾不写任何状态
|
|
437
|
+
if (executions.get(id) === token) {
|
|
438
|
+
patchRow(id, {
|
|
439
|
+
status: aborted ? "cancelled" : "failed",
|
|
440
|
+
error: aborted ? "已取消" : describeError(error),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
log(aborted ? "info" : "warn", `下载${aborted ? "已取消" : "失败"}:#${row.gid} ${describeError(error)}`);
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
if (executions.get(id) === token) {
|
|
447
|
+
executions.delete(id);
|
|
448
|
+
controllers.delete(id);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/** 单并发循环:每次取一条排队任务执行。 */
|
|
453
|
+
async function pump() {
|
|
454
|
+
if (running) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
running = true;
|
|
458
|
+
try {
|
|
459
|
+
for (;;) {
|
|
460
|
+
const next = db
|
|
461
|
+
.prepare("select id from downloads where status = 'queued' order by created_at limit 1")
|
|
462
|
+
.get();
|
|
463
|
+
if (next === undefined) {
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
await runTask(next.id);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
running = false;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
list() {
|
|
475
|
+
const rows = db
|
|
476
|
+
.prepare("select * from downloads order by created_at desc")
|
|
477
|
+
.all();
|
|
478
|
+
return rows.map(toTask);
|
|
479
|
+
},
|
|
480
|
+
async create(input) {
|
|
481
|
+
const id = randomUUID();
|
|
482
|
+
const now = Math.floor(Date.now() / 1000);
|
|
483
|
+
const resolution = input.viaHath === true ? "hath" : input.resolution;
|
|
484
|
+
/*
|
|
485
|
+
* 归档只有登录后才拿得到,还要消耗 GP。未登录时不进入队列:
|
|
486
|
+
* 排进去只会等到执行时才失败,界面上凭空多出一条失败任务,此处直接报错更清楚,
|
|
487
|
+
* 由界面改用逐页下载(pages-org / pages-res,showpage 不需要登录)。
|
|
488
|
+
*/
|
|
489
|
+
if (!isPageMode(resolution) && activeAccount(ctx.auth.get()) === null) {
|
|
490
|
+
throw new LoginRequiredError("归档下载需要登录并消耗 GP;未登录时请改用逐页下载");
|
|
491
|
+
}
|
|
492
|
+
db.prepare(`insert into downloads (id, gid, token, title, resolution, status, bytes_done, bytes_total,
|
|
493
|
+
pages_done, page_count, output_path, error, created_at, updated_at)
|
|
494
|
+
values (?, ?, ?, '', ?, 'queued', 0, null, 0, null, null, null, ?, ?)`).run(id, input.gid, input.token, resolution, now, now);
|
|
495
|
+
const row = rowOf(id);
|
|
496
|
+
if (row === null) {
|
|
497
|
+
throw new Error("任务写入失败");
|
|
498
|
+
}
|
|
499
|
+
log("info", `已加入队列:#${input.gid}`);
|
|
500
|
+
broadcast(row);
|
|
501
|
+
const task = toTask(row);
|
|
502
|
+
void pump();
|
|
503
|
+
return task;
|
|
504
|
+
},
|
|
505
|
+
async retry(id) {
|
|
506
|
+
const row = rowOf(id);
|
|
507
|
+
if (row === null) {
|
|
508
|
+
throw new Error(`任务不存在:${id}`);
|
|
509
|
+
}
|
|
510
|
+
// 只在终态上重排:排队中或进行中的任务不该被重置。
|
|
511
|
+
if (row.status === "failed" || row.status === "cancelled") {
|
|
512
|
+
// 上一轮可能还在收尾(取消是异步的):作废它的令牌,避免它把这次排队盖掉。
|
|
513
|
+
executions.delete(id);
|
|
514
|
+
controllers.get(id)?.abort();
|
|
515
|
+
// 进度一起清掉:留着上一轮的页数会让排队中的进度条先显示旧分数。
|
|
516
|
+
patchRow(id, {
|
|
517
|
+
status: "queued",
|
|
518
|
+
bytes_done: 0,
|
|
519
|
+
bytes_total: null,
|
|
520
|
+
pages_done: 0,
|
|
521
|
+
error: null,
|
|
522
|
+
});
|
|
523
|
+
log("info", `重新排队:#${row.gid} ${row.resolution}`);
|
|
524
|
+
const next = rowOf(id);
|
|
525
|
+
if (next !== null) {
|
|
526
|
+
broadcast(next);
|
|
527
|
+
const task = toTask(next);
|
|
528
|
+
void pump();
|
|
529
|
+
return task;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return toTask(row);
|
|
533
|
+
},
|
|
534
|
+
async cancel(id) {
|
|
535
|
+
const row = rowOf(id);
|
|
536
|
+
if (row === null) {
|
|
537
|
+
throw new Error(`任务不存在:${id}`);
|
|
538
|
+
}
|
|
539
|
+
if (row.status === "running") {
|
|
540
|
+
controllers.get(id)?.abort();
|
|
541
|
+
patchRow(id, { status: "cancelled", error: "已取消" });
|
|
542
|
+
return { id, cancelled: true, removedFromList: false };
|
|
543
|
+
}
|
|
544
|
+
if (row.status === "queued") {
|
|
545
|
+
patchRow(id, { status: "cancelled", error: "已取消" });
|
|
546
|
+
return { id, cancelled: true, removedFromList: false };
|
|
547
|
+
}
|
|
548
|
+
// 已结束的任务只从列表移除,落盘文件保留。
|
|
549
|
+
db.prepare("delete from downloads where id = ?").run(id);
|
|
550
|
+
speeds.delete(id);
|
|
551
|
+
return { id, cancelled: false, removedFromList: true };
|
|
552
|
+
},
|
|
553
|
+
onChange(listener) {
|
|
554
|
+
listeners.add(listener);
|
|
555
|
+
return () => {
|
|
556
|
+
listeners.delete(listener);
|
|
557
|
+
};
|
|
558
|
+
},
|
|
559
|
+
recover() {
|
|
560
|
+
const result = db
|
|
561
|
+
.prepare("update downloads set status = 'failed', error = '进程退出,任务中断', updated_at = ? where status in ('queued', 'running')")
|
|
562
|
+
.run(Math.floor(Date.now() / 1000));
|
|
563
|
+
const count = Number(result.changes ?? 0);
|
|
564
|
+
if (count > 0) {
|
|
565
|
+
log("warn", `${count} 条未完成任务已标记为失败(不支持断点续传)`);
|
|
566
|
+
}
|
|
567
|
+
return count;
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
/** 标题只用于文件名,读不到就按 gid 命名,不因此中断任务 */
|
|
572
|
+
const TITLE_TIMEOUT_MS = 4000;
|
|
573
|
+
/** 单张图片的重试次数。图床节点不稳定,重试比整本重下便宜。 */
|
|
574
|
+
const PAGE_ATTEMPTS = 3;
|
|
575
|
+
/** 归档的重试次数。体积大,只给一次机会。 */
|
|
576
|
+
const ARCHIVE_ATTEMPTS = 2;
|
|
577
|
+
/*
|
|
578
|
+
* 未配置下载目录时的落点:~/ehbrowser/download。
|
|
579
|
+
* 便携模式下收进 <HOME>/download,整个目录可以一起搬走。
|
|
580
|
+
*/
|
|
581
|
+
export function defaultDownloadDirectory(ctx) {
|
|
582
|
+
if (ctx.paths.sources.dataDir === "portable-home") {
|
|
583
|
+
return join(ctx.paths.dataDir, "download");
|
|
584
|
+
}
|
|
585
|
+
return join(homedir(), APP_DIR_NAME, "download");
|
|
586
|
+
}
|