diff --git a/README.md b/README.md index cd6ed60..821e1ef 100644 --- a/README.md +++ b/README.md @@ -66,14 +66,18 @@ eh-downloader-wails/ ├── internal/ │ ├── eh/ ★ 协议核心(可独立测试,无 UI 依赖) │ │ ├── eh.go ①画廊页分页 → ②/s/ → ③hath直链 → 下载 +│ │ ├── limiter.go 全局限流器 │ │ ├── eh_test.go 单元测试 +│ │ ├── limiter_test.go 限流测试(含并发穿透) │ │ └── e2e_test.go 端到端链路测试(httptest 模拟 EH) │ └── core/ -│ ├── engine.go 任务编排、并发池、退避重试、断点续传 +│ ├── engine.go 任务编排、双层并发、退避重试、整轮扫尾 │ ├── store.go JSON 原子写 + 任务状态机 │ ├── paths.go 目录模板引擎 + 路径安全清洗 +│ ├── manifest.go 画廊目录清单(.ehdl.json) │ ├── bridge.go 本地 HTTP 桥口(供油猴推送) -│ └── *_test.go 单测 +│ ├── *_test.go 单测 +│ └── concurrency_test.go 并发/限流/扫尾集成测试 ├── frontend/ │ ├── index.html 界面(沿用原版) │ ├── js/api.js ★ IPC 适配层:window.eh.* → Wails bindings @@ -189,12 +193,79 @@ https://oclzqqk.gyktyewfonyf.hath.network:39656/h/4e9af9f.../keystamp=1791448200 | 下载目录 | `~/Downloads/EH` | 每个画廊一个子目录 | | 代理模式 | 自动探测 | off / http / socks5 | | 代理地址 | 自动探测 | 常见端口 7890/7897/1087/1080/8080/10808/10809/8888 | -| 并发数 | 3 | 建议 2~4 | +| **画廊并发** | **5** | 同时处理几个画廊(1–20) | +| **单画廊页并发** | **8** | 每个画廊内同时下几张图(1–20) | +| **请求间隔** | **0 秒** | 全局最小请求间隔,0 = 不限流 | | 超时 | 30s | | | 下载分辨率 | 站点默认 | `auto` / `org` / 数字 | | 目录模板 | `{title}` | | | 油猴推送端口 | 37777 | | +### 并发怎么算 + +**总并发 = 画廊并发 × 页并发**,默认 `5 × 8 = 40` 张图同时下载。 + +``` +画廊并发 5 +├── 画廊A:8 张图同时下 +├── 画廊B:8 张图同时下 +├── 画廊C:8 张图同时下 +├── 画廊D:8 张图同时下 +└── 画廊E:8 张图同时下 += 最多 40 张同时下载 +``` + +如果队列里只有 1 个画廊,就只有 1 份并发在跑(8 张),其余 4 个 worker 空转。 + +### 全局限流 + +对齐参考实现(E-Hentai Downloader 的「请求间隔」):用一个全局 `nextRequestTime`, +每个请求排到它之后,再把它往后推 `interval` 毫秒。 + +**限的是所有请求**,包括 `/s/` 解析和图片下载 —— 每页要发 2 个请求,所以 +40 并发在开启限流时也会被串成每 `interval` 放行一个。 + +高并发下频繁遇到 429 时,1~2 秒的间隔很有效。 + +### 失败重试与整轮扫尾 + +单页失败时的退避重试: + +``` +第1次失败 → 等 0.8s + 随机 0~0.4s +第2次失败 → 等 1.6s + 随机 +第3次失败 → 等 3.2s + 随机 +第4次失败 → 放弃,记入失败清单 +``` + +只有「像是临时问题」才重试(连接重置、超时、429/502/503/504、socket hang up)。 +401/403/404、画廊不存在、页面结构变化这类**重试也没用**的直接判死。 + +**整轮扫尾**:所有画廊跑完后,自动把所有失败页**重新扫 1 轮**(同样走完整 4 次重试)。 +扫尾后仍失败就标记「部分失败」并停下 —— 这时你可以换个代理线路,再点一次 +「开始下载」,已下的文件会自动跳过,只补缺的那些。 + +### 画廊目录清单(.ehdl.json) + +每个画廊目录下会写一份 `.ehdl.json`: + +```json +{ + "version": 1, + "gid": "4239274", + "token": "55e069b6b9", + "title": "[Noxe] 0820 Qingxiao", + "totalPages": 206, + "donePages": [1, 2, 3, 5], + "failedPages": [4], + "updatedAt": 1791475732842 +} +``` + +作用:断点续传的依据是「文件在且非空」,但那有个盲区 —— 如果 `state.json` +丢了,程序就不知道这个画廊**原本应该有多少张**。清单补上了这个认知, +扫目录就能算出「应该 206 张,现在只有 4 张」。 + 状态文件: - Windows:`%APPDATA%\EH Downloader\state.json` - macOS:`~/Library/Application Support/EH Downloader/state.json` @@ -231,7 +302,7 @@ go test ./internal/eh/ -v # 协议层(含端到端) go test -race ./... # 竞态检测 ``` -37 个用例覆盖: +62 个用例覆盖: - 输入解析(多行/分隔符/去重/注释) - hath 直链正则(含不该匹配的负例) - xres 改写(auto/org/数字) @@ -241,6 +312,10 @@ go test -race ./... # 竞态检测 - 目录模板展开 + 路径穿越防护 + Windows 保留名 - 断点续传、取消清理 .part - 状态机(清除已完成/失败重排/崩溃归位/文件损坏回落) +- **双层并发**(mock 服务器实测并发峰值 = 8 页 / 5 画廊) +- **全局限流**(并发穿透验证 + 精确计时) +- **整轮扫尾**(首轮 4 次重试全挂 → 扫尾救回 / 永久失败 → partial) +- **画廊清单**(读写往返、目录扫描、state.json 丢失后的恢复) ## 免责声明 diff --git a/app.go b/app.go index c315af6..9abe260 100644 --- a/app.go +++ b/app.go @@ -191,8 +191,14 @@ func (a *App) SetSettings(patch map[string]interface{}) core.Settings { next := a.store.UpdateSettings(patch) // 同步到引擎 - if _, ok := patch["concurrency"]; ok { - a.engine.SetConcurrency(next.Concurrency) + if _, ok := patch["galleryConcurrency"]; ok { + a.engine.SetGalleryConcurrency(next.GalleryConcurrency) + } + if _, ok := patch["pageConcurrency"]; ok { + a.engine.SetPageConcurrency(next.PageConcurrency) + } + if _, ok := patch["requestIntervalMs"]; ok { + a.engine.SetRequestInterval(next.RequestIntervalMs) } if _, ok := patch["timeout"]; ok { a.engine.Client().SetTimeout(time.Duration(next.Timeout) * time.Millisecond) @@ -241,6 +247,69 @@ func (a *App) Placeholders() []string { return core.ListPlaceholders() } +// EngineInfo 引擎当前的并发配置(前端用来显示"总上限 = 画廊 × 页") +type EngineInfo struct { + Running bool `json:"running"` + Queued int `json:"queued"` + GalleryConcurrency int `json:"galleryConcurrency"` + PageConcurrency int `json:"pageConcurrency"` + MaxConcurrent int `json:"maxConcurrent"` + RequestIntervalMs int `json:"requestIntervalMs"` +} + +// EngineInfo 取引擎状态和并发配置 +func (a *App) EngineInfo() EngineInfo { + g := a.engine.GalleryConcurrency() + p := a.engine.PageConcurrency() + return EngineInfo{ + Running: a.engine.IsRunning(), + Queued: a.engine.QueueLength(), + GalleryConcurrency: g, + PageConcurrency: p, + MaxConcurrent: g * p, + RequestIntervalMs: int(a.engine.Client().RequestInterval().Milliseconds()), + } +} + +// ScanManifestsResult 扫描下载目录的结果 +type ScanManifestsResult struct { + Found int `json:"found"` + Adopted int `json:"adopted"` + Root string `json:"root"` + Note string `json:"note"` +} + +// ScanDownloadDir 扫描下载目录里的画廊清单,把 state.json 里缺的信息补上。 +// 场景:用户删了 state.json,靠清单重建"这个画廊应该有多少张"的认知。 +func (a *App) ScanDownloadDir() ScanManifestsResult { + root := a.store.Settings().DownloadRoot + if root == "" { + root = core.DefaultDownloadRoot() + } + + manifests := core.ScanManifests(root) + res := ScanManifestsResult{Root: root, Found: len(manifests)} + if len(manifests) == 0 { + res.Note = "下载目录里没找到画廊清单(.ehdl.json)" + return res + } + + for _, m := range manifests { + // 找到对应任务(按 gid 匹配,token 可能有差异所以只按 gid) + t := a.store.FindTaskByGID(m.GID) + if t == nil { + continue + } + if core.ReconcileTask(t, m) { + a.store.UpsertTask(*t) + res.Adopted++ + } + } + + res.Note = fmt.Sprintf("扫描到 %d 个画廊清单,补全了 %d 个任务", res.Found, res.Adopted) + return res +} + // ---------------------------------------------------------------- 任务 // ListTasks 全部任务 @@ -368,7 +437,16 @@ func (a *App) Start(onlyGids []string) StartResult { } go a.engine.Start(ctx) - a.emitLog("info", fmt.Sprintf("开始下载 %d 个画廊(并发 %d)", len(targets), len(targets))) + g := a.engine.GalleryConcurrency() + p := a.engine.PageConcurrency() + iv := a.engine.Client().RequestInterval().Milliseconds() + msg := fmt.Sprintf("开始下载 %d 个画廊(画廊并发 %d × 页并发 %d,最多 %d 张同时下载)", + len(targets), g, p, g*p) + if iv > 0 { + msg += fmt.Sprintf(",请求间隔 %dms", iv) + } + a.emitLog("info", msg) + return StartResult{Started: true, Count: len(targets)} } diff --git a/frontend/index.html b/frontend/index.html index e71398a..ac10100 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -157,9 +157,23 @@ + + + +