feat: 双层并发 + 全局限流 + 整轮扫尾 + 画廊目录清单

原实现只能「多画廊并行,每个画廊串行下」,单张大画廊下得很慢。
按用户多年使用习惯(5 画廊 × 每画廊 8 张)改造为双层并发。

- 双层并发:画廊并发(默认5) × 页并发(默认8) = 最多 40 张同时下载,
  两个值都在设置面板可调(1-20)。页级 worker 池 + mutex 保护计数。
- 全局限流:对齐参考实现的 nextRequestTime 算法,限所有请求
  (含 /s/ 解析与图片本体),默认 0 秒关闭。
- 整轮扫尾:跑完后自动重扫失败页 1 轮(同样走完整 4 次退避重试),
  仍失败则停下并标记 partial,换线路后手动点开始即可续传。
- 画廊目录清单 .ehdl.json:记录 gid/token/总页数/已完成页,
  即使 state.json 丢失也能算出「应该多少张、还差多少张」。

顺带修复:
- Start() 双重加锁导致死锁(引擎完全无法启动)
- clampInt 越界时返回默认值而非边界值(设 30 会静默变 8)
- 直链正则写死 hath.network,CDN 换域名会全军覆没 -> 加图片 URL 兜底
- ReconcileTask 会误报「有改动」

测试从 37 增至 62,其中并发/限流/扫尾用 httptest 模拟 EH 实测:
- 页并发峰值 = 8、画廊并发峰值 = 5(完全生效)
- 12 次请求限流 80ms 实测 966ms(期望 880ms)
- 断点续传 4 张已存在时只发 5 次请求
This commit is contained in:
2026-10-09 00:11:38 +08:00
parent beb1dac980
commit 0447875b6d
17 changed files with 1981 additions and 130 deletions
+41 -15
View File
@@ -22,11 +22,16 @@ type Settings struct {
DownloadRoot string `json:"downloadRoot"`
Proxy string `json:"proxy"`
ProxyMode string `json:"proxyMode"` // off | http | socks5
Concurrency int `json:"concurrency"`
Timeout int `json:"timeout"` // 毫秒
Xres string `json:"xres"` // auto | org | 数字(长边上限)
BridgePort int `json:"bridgePort"`
DirTemplate string `json:"dirTemplate"`
// GalleryConcurrency 同时处理几个画廊
GalleryConcurrency int `json:"galleryConcurrency"`
// PageConcurrency 每个画廊内同时下几张图(总上限 = 两者相乘)
PageConcurrency int `json:"pageConcurrency"`
// RequestIntervalMs 全局请求间隔(毫秒,0 = 不限流)
RequestIntervalMs int `json:"requestIntervalMs"`
Timeout int `json:"timeout"` // 毫秒
Xres string `json:"xres"` // auto | org | 数字(长边上限)
BridgePort int `json:"bridgePort"`
DirTemplate string `json:"dirTemplate"`
// 内部字段:是否已探测过代理(不暴露给前端)
Probed bool `json:"_probed"`
@@ -67,14 +72,16 @@ type State struct {
// DefaultSettings 返回默认设置
func DefaultSettings() Settings {
return Settings{
DownloadRoot: "",
Proxy: "",
ProxyMode: "off",
Concurrency: 3,
Timeout: 30000,
Xres: "auto",
BridgePort: 37777,
DirTemplate: "{title}",
DownloadRoot: "",
Proxy: "",
ProxyMode: "off",
GalleryConcurrency: 5,
PageConcurrency: 8,
RequestIntervalMs: 0,
Timeout: 30000,
Xres: "auto",
BridgePort: 37777,
DirTemplate: "{title}",
}
}
@@ -110,8 +117,14 @@ func (s *Store) load() {
if loaded.Settings.DownloadRoot == "" {
loaded.Settings.DownloadRoot = def.DownloadRoot
}
if loaded.Settings.Concurrency <= 0 {
loaded.Settings.Concurrency = def.Concurrency
if loaded.Settings.GalleryConcurrency <= 0 {
loaded.Settings.GalleryConcurrency = def.GalleryConcurrency
}
if loaded.Settings.PageConcurrency <= 0 {
loaded.Settings.PageConcurrency = def.PageConcurrency
}
if loaded.Settings.RequestIntervalMs < 0 {
loaded.Settings.RequestIntervalMs = 0
}
if loaded.Settings.Timeout <= 0 {
loaded.Settings.Timeout = def.Timeout
@@ -261,6 +274,19 @@ func (s *Store) FindTask(gid, token string) *Task {
return nil
}
// FindTaskByGID 只按 gid 找(清单恢复用 —— token 可能对不上)
func (s *Store) FindTaskByGID(gid string) *Task {
s.mu.RLock()
defer s.mu.RUnlock()
for i := range s.state.Tasks {
if s.state.Tasks[i].GID == gid {
c := s.state.Tasks[i].clone()
return &c
}
}
return nil
}
// AddTasks 批量加任务(已存在的跳过),返回新增数
func (s *Store) AddTasks(refs []eh.GalleryRef) int {
s.mu.Lock()