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
+327 -82
View File
@@ -49,47 +49,90 @@ type Engine struct {
client *eh.Client
sink EventSink
mu sync.Mutex
concurrency int
running bool
cancel context.CancelFunc
queue []Task
mu sync.Mutex
galleryWorkers int
pageConcurrency int
running bool
cancel context.CancelFunc
queue []Task
}
// NewEngine 建引擎
func NewEngine(store *Store, sink EventSink, settings Settings) *Engine {
e := &Engine{
store: store,
sink: sink,
client: eh.NewClient(time.Duration(settings.Timeout)*time.Millisecond,
settings.Proxy, settings.ProxyMode, settings.Xres),
concurrency: settings.Concurrency,
}
if e.concurrency < 1 {
e.concurrency = 3
}
if e.concurrency > 8 {
e.concurrency = 8
store: store,
sink: sink,
client: eh.NewClient(time.Duration(settings.Timeout)*time.Millisecond, settings.Proxy, settings.ProxyMode, settings.Xres),
galleryWorkers: clampInt(settings.GalleryConcurrency, 1, 20, 5),
pageConcurrency: clampInt(settings.PageConcurrency, 1, 20, 8),
}
e.client.SetRequestInterval(time.Duration(settings.RequestIntervalMs) * time.Millisecond)
return e
}
// clampInt 把值夹到 [lo, hi]。0 或负数视为「没设置」,用默认值。
func clampInt(v, lo, hi, def int) int {
if v <= 0 {
return def
}
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// Client 暴露给 app 层改配置用
func (e *Engine) Client() *eh.Client { return e.client }
// SetConcurrency 运行时改并发
func (e *Engine) SetConcurrency(n int) {
if n < 1 {
n = 1
}
if n > 8 {
n = 8
}
e.mu.Lock()
e.concurrency = n
e.galleryWorkers = clampInt(n, 1, 20, 5)
e.mu.Unlock()
}
// SetGalleryConcurrency 改画廊并发
func (e *Engine) SetGalleryConcurrency(n int) { e.SetConcurrency(n) }
// SetPageConcurrency 改单画廊页并发
func (e *Engine) SetPageConcurrency(n int) {
e.mu.Lock()
e.pageConcurrency = clampInt(n, 1, 20, 8)
e.mu.Unlock()
}
// SetRequestInterval 改全局请求间隔(毫秒)
func (e *Engine) SetRequestInterval(ms int) {
if ms < 0 {
ms = 0
}
e.client.SetRequestInterval(time.Duration(ms) * time.Millisecond)
}
// GalleryConcurrency 当前画廊并发
func (e *Engine) GalleryConcurrency() int {
e.mu.Lock()
defer e.mu.Unlock()
return e.galleryWorkers
}
// PageConcurrency 当前页并发
func (e *Engine) PageConcurrency() int {
e.mu.Lock()
defer e.mu.Unlock()
return e.pageConcurrency
}
// emitTask 把任务状态推给 UI
func (e *Engine) emitTask(t Task) {
if e.sink != nil {
e.sink.TaskUpdate(t)
}
}
func (e *Engine) IsRunning() bool {
e.mu.Lock()
defer e.mu.Unlock()
@@ -138,9 +181,12 @@ func (e *Engine) Start(ctx context.Context) {
return
}
e.running = true
e.mu.Unlock()
ctx, cancel := context.WithCancel(ctx)
e.mu.Lock()
e.cancel = cancel
n := e.concurrency
n := e.galleryWorkers
e.mu.Unlock()
defer func() {
@@ -256,59 +302,266 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
}
e.save(task)
// ② ③ 逐页:解析 URL → 下载
total := len(gallery.Pages)
for _, p := range gallery.Pages {
if ctx.Err() != nil {
e.markCanceled(task)
// ② ③ 页级并发下载
st := &pageState{
task: &task,
dir: dir,
pages: gallery.Pages,
gid: task.GID,
token: task.Token,
title: gallery.Title,
total: len(gallery.Pages),
engine: e,
}
// 先做一遍快速扫描:已存在的直接算完成,剩下的才进队列
st.scanExisting()
st.saveManifest()
// 页级 worker 池
n := e.pageConcurrency
if n > len(st.pending) {
n = len(st.pending)
}
if n > 0 {
var wg sync.WaitGroup
work := make(chan eh.GalleryPageInfo, len(st.pending))
for _, p := range st.pending {
work <- p
}
close(work)
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for p := range work {
st.downloadPage(ctx, p)
}
}()
}
wg.Wait()
}
// ---- 整轮扫尾:把刚失败的页再扫一遍 ----
if st.failedCount() > 0 && ctx.Err() == nil {
e.log("info", fmt.Sprintf("画廊 %s:%d 页首轮失败,开始扫尾重试", task.GID, st.failedCount()))
retry := st.collectFailed()
// 扫尾时清掉 failedList,让结果反映最终状态
st.clearFailures()
var wg sync.WaitGroup
work := make(chan eh.GalleryPageInfo, len(retry))
for _, p := range retry {
work <- p
}
close(work)
n := e.pageConcurrency
if n > len(retry) {
n = len(retry)
}
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for p := range work {
st.downloadPage(ctx, p)
}
}()
}
wg.Wait()
if st.failedCount() > 0 {
e.log("warn", fmt.Sprintf(
"画廊 %s:扫尾后仍有 %d 页失败(换个线路再点「开始下载」可续传)",
task.GID, st.failedCount()))
} else {
e.log("ok", fmt.Sprintf("画廊 %s:扫尾重试全部成功", task.GID))
}
}
st.saveManifest()
if ctx.Err() != nil {
e.markCanceled(task)
return
}
t := st.snapshot()
if t.Failed > 0 {
t.Status = "partial"
} else {
t.Status = "done"
}
e.save(t)
}
// pageState 是「一个画廊的页级下载状态」,所有字段都由 mutex 保护,
// 因为页级 worker 是并发的。
type pageState struct {
mu sync.Mutex
engine *Engine
task *Task
dir string
gid string
token string
title string
total int
pages []eh.GalleryPageInfo
pending []eh.GalleryPageInfo
done map[int]bool
failed map[int]string // 页码 -> 错误
failedL []FailedItem
}
// scanExisting 扫描已有文件(断点续传的第一依据:文件在且非空 = 完成)
func (s *pageState) scanExisting() {
s.mu.Lock()
defer s.mu.Unlock()
s.done = map[int]bool{}
s.failed = map[int]string{}
s.failedL = nil
if s.task.Pages == nil {
s.task.Pages = map[string]string{}
}
for _, p := range s.pages {
fname := targetName(p, s.gid, s.total)
dest := filepath.Join(s.dir, fname)
if st, err := os.Stat(dest); err == nil && st.Size() > 0 {
s.done[p.Page] = true
s.task.Pages[strconv.Itoa(p.Page)] = "done"
} else {
s.pending = append(s.pending, p)
}
}
s.task.Done = len(s.done)
}
// downloadPage 下单页(解析 URL → 下载),内部含 4 次退避重试
func (s *pageState) downloadPage(ctx context.Context, p eh.GalleryPageInfo) {
if ctx.Err() != nil {
return
}
fname := targetName(p, s.gid, s.total)
dest := filepath.Join(s.dir, fname)
err := s.engine.withRetry(ctx, func() error {
imgURL, e := s.engine.client.ResolveImageURL(ctx, p.SPath)
if e != nil {
return e
}
_, e = s.engine.client.DownloadImage(ctx, imgURL, dest, nil)
return e
})
s.mu.Lock()
k := strconv.Itoa(p.Page)
if err != nil {
if ctx.Err() != nil || errors.Is(err, ErrCanceled) {
s.mu.Unlock()
s.markCanceled()
return
}
fname := targetName(p, task.GID, total)
dest := filepath.Join(dir, fname)
// 断点续传:文件在就算完成(文件系统即真相,状态文件丢了也能续)
if st, err := os.Stat(dest); err == nil && st.Size() > 0 {
task.Done++
task.Pages[strconv.Itoa(p.Page)] = "done"
e.progress(task)
continue
}
page := p.Page
err := e.withRetry(ctx, func() error {
imgURL, err := e.client.ResolveImageURL(ctx, p.SPath)
if err != nil {
return err
}
_, err = e.client.DownloadImage(ctx, imgURL, dest, nil)
return err
})
if err != nil {
if ctx.Err() != nil || errors.Is(err, ErrCanceled) {
e.markCanceled(task)
return
}
task.Failed++
task.Pages[strconv.Itoa(page)] = "failed"
task.FailedList = append(task.FailedList, FailedItem{
Page: page, File: fname, Error: err.Error(),
})
e.progress(task)
continue
}
task.Done++
task.Pages[strconv.Itoa(page)] = "done"
e.progress(task)
}
if task.Failed > 0 {
task.Status = "partial"
s.failed[p.Page] = err.Error()
s.failedL = append(s.failedL, FailedItem{Page: p.Page, File: fname, Error: err.Error()})
s.task.Pages[k] = "failed"
} else {
task.Status = "done"
s.done[p.Page] = true
s.task.Pages[k] = "done"
}
e.save(task)
s.syncCountsLocked()
snap := s.snapshotLocked()
s.mu.Unlock()
s.engine.emitTask(snap)
}
func (s *pageState) syncCountsLocked() {
s.task.Done = len(s.done)
s.task.Failed = len(s.failed)
s.task.FailedList = append([]FailedItem(nil), s.failedL...)
}
func (s *pageState) snapshotLocked() Task {
s.syncCountsLocked()
t := *s.task
return t
}
func (s *pageState) snapshot() Task {
s.mu.Lock()
defer s.mu.Unlock()
return s.snapshotLocked()
}
func (s *pageState) failedCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.failed)
}
func (s *pageState) clearFailures() {
s.mu.Lock()
defer s.mu.Unlock()
s.failed = map[int]string{}
s.failedL = nil
s.task.Failed = 0
s.task.FailedList = []FailedItem{}
}
// collectFailed 收集失败页(清空失败表前先取出)
func (s *pageState) collectFailed() []eh.GalleryPageInfo {
s.mu.Lock()
defer s.mu.Unlock()
failed := intSet(keysOf(s.failed))
var out []eh.GalleryPageInfo
for _, p := range s.pages {
if failed[p.Page] {
out = append(out, p)
}
}
return out
}
// markCanceled 任一页发现取消就整体标记
func (s *pageState) markCanceled() {
s.engine.markCanceled(s.snapshot())
}
func (s *pageState) saveManifest() {
s.mu.Lock()
done := keysOf(s.done)
failed := keysOf(s.failed)
t := *s.task
s.mu.Unlock()
WriteManifest(s.dir, Manifest{
GID: s.gid,
Token: s.token,
Title: s.title,
TotalPages: s.total,
DonePages: done,
FailedPage: failed,
CreatedAt: t.CreatedAt,
})
}
func keysOf[V any](m map[int]V) []int {
out := make([]int, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// targetName 页码补零,保证排序
@@ -395,17 +648,9 @@ func (e *Engine) fail(task Task, err error) {
e.log("error", fmt.Sprintf("画廊 %s 失败:%s", task.GID, msg))
}
func (e *Engine) progress(task Task) {
if e.sink != nil {
e.sink.TaskUpdate(task)
}
}
func (e *Engine) save(task Task) {
task = e.store.UpsertTask(task)
if e.sink != nil {
e.sink.TaskUpdate(task)
}
e.emitTask(task)
}
func (e *Engine) log(level, msg string) {