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 @@
+
+
+
+