原实现只能「多画廊并行,每个画廊串行下」,单张大画廊下得很慢。 按用户多年使用习惯(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 次请求
507 lines
13 KiB
Go
507 lines
13 KiB
Go
package core
|
||
|
||
// 双层并发 + 扫尾 的集成测试:用 httptest 模拟 EH 服务器,
|
||
// 记录每个时刻有多少请求在飞,验证「页并发」真的生效。
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"eh-downloader-wails/internal/eh"
|
||
)
|
||
|
||
// mockEH 一个可控的 EH 模拟器
|
||
type mockEH struct {
|
||
srv *httptest.Server
|
||
|
||
mu sync.Mutex
|
||
// 记录并发峰值
|
||
inFlight int32
|
||
peakFlight int32
|
||
totalReqs int64
|
||
// 每页失败次数(第一次失败 -> 成功,模拟可重试错误)
|
||
failOnce map[int]int32
|
||
// 哪些页彻底失败
|
||
alwaysFail map[int]bool
|
||
// 慢响应(ms),用于观察并发
|
||
delayMs int32
|
||
}
|
||
|
||
func newMockEH(totalPages int) *mockEH {
|
||
m := &mockEH{
|
||
failOnce: map[int]int32{},
|
||
alwaysFail: map[int]bool{},
|
||
delayMs: 5,
|
||
}
|
||
|
||
mux := http.NewServeMux()
|
||
|
||
// 画廊页(一次性给全,避免分页逻辑干扰测试)
|
||
mux.HandleFunc("/g/", func(w http.ResponseWriter, r *http.Request) {
|
||
m.track(func() {
|
||
var sb strings.Builder
|
||
sb.WriteString(`<html><head><title>[Test] Gallery - E-Hentai Galleries</title>`)
|
||
sb.WriteString(`<meta name="description" content="Free Hentai Doujinshi Gallery: [Test] Gallery: x" />`)
|
||
sb.WriteString(`</head><body><div id="taglist"><table>`)
|
||
sb.WriteString(`<tr><td class="tc">artist:</td><td><div class="gtl"><a href="/tag/artist:x">X</a></div></td></tr>`)
|
||
sb.WriteString(`</table></div>`)
|
||
for i := 1; i <= totalPages; i++ {
|
||
sb.WriteString(fmt.Sprintf(
|
||
`<a href="/s/k%03d/999-%d"><div title="Page %d: %03d.png"></div></a>`,
|
||
i, i, i, i))
|
||
}
|
||
sb.WriteString(fmt.Sprintf(`Showing 1 - %d of %d images</body></html>`, totalPages, totalPages))
|
||
w.Write([]byte(sb.String()))
|
||
})
|
||
})
|
||
|
||
// /s/ 解析页 —— 返回的图片 URL 指向 mock 服务器自己
|
||
mux.HandleFunc("/s/", func(w http.ResponseWriter, r *http.Request) {
|
||
m.track(func() {
|
||
// /s/k001/999-1 -> page 1
|
||
page := 1
|
||
if idx := strings.LastIndex(r.URL.Path, "-"); idx > 0 {
|
||
page, _ = strconv.Atoi(r.URL.Path[idx+1:])
|
||
}
|
||
// 图片 URL 用 mock 服务器自己的地址,否则请求到不了本地。
|
||
// (真实的 hath URL 是随机子域+随机端口,协议逻辑在 eh.go 里测),
|
||
// 这里只验证「解析出的 URL 被拿去下载」这条链路。
|
||
base := m.URL()
|
||
w.Write([]byte(fmt.Sprintf(
|
||
`<html><img src="%s/img/%03d.png?xres=800"/></html>`, base, page)))
|
||
})
|
||
})
|
||
|
||
// 图片本体
|
||
mux.HandleFunc("/img/", func(w http.ResponseWriter, r *http.Request) {
|
||
m.track(func() {
|
||
page := 0
|
||
name := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
||
fmt.Sscanf(name, "%03d.png", &page)
|
||
|
||
m.mu.Lock()
|
||
always := m.alwaysFail[page]
|
||
n := m.failOnce[page]
|
||
if !always && n > 0 {
|
||
m.failOnce[page] = n - 1
|
||
}
|
||
m.mu.Unlock()
|
||
|
||
if always {
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
return
|
||
}
|
||
if n > 0 {
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
return
|
||
}
|
||
w.Write([]byte("image-data-" + name))
|
||
})
|
||
})
|
||
|
||
m.srv = httptest.NewServer(mux)
|
||
return m
|
||
}
|
||
|
||
// track 统计并发峰值
|
||
func (m *mockEH) track(fn func()) {
|
||
cur := atomic.AddInt32(&m.inFlight, 1)
|
||
atomic.AddInt64(&m.totalReqs, 1)
|
||
for {
|
||
old := atomic.LoadInt32(&m.peakFlight)
|
||
if cur <= old || atomic.CompareAndSwapInt32(&m.peakFlight, old, cur) {
|
||
break
|
||
}
|
||
}
|
||
time.Sleep(time.Duration(m.delayMs) * time.Millisecond)
|
||
defer atomic.AddInt32(&m.inFlight, -1)
|
||
fn()
|
||
}
|
||
|
||
func (m *mockEH) URL() string { return m.srv.URL }
|
||
func (m *mockEH) Close() { m.srv.Close() }
|
||
func (m *mockEH) peak() int32 { return atomic.LoadInt32(&m.peakFlight) }
|
||
|
||
// 把 hath URL 指向 mock 服务器
|
||
func (m *mockEH) installBase() func() {
|
||
old := eh.EHBase
|
||
eh.EHBase = m.srv.URL
|
||
return func() { eh.EHBase = old }
|
||
}
|
||
|
||
func newEngineFor(t *testing.T, root string, g, p int) (*Engine, *Store) {
|
||
t.Helper()
|
||
s := NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||
s.UpdateSettings(map[string]interface{}{
|
||
"downloadRoot": root,
|
||
"dirTemplate": "{gid}",
|
||
"galleryConcurrency": g,
|
||
"pageConcurrency": p,
|
||
"proxy": "",
|
||
"proxyMode": "off",
|
||
"timeout": 10000,
|
||
})
|
||
e := NewEngine(s, &noopSink{}, s.Settings())
|
||
return e, s
|
||
}
|
||
|
||
func addOne(t *testing.T, s *Store, gid, token string) {
|
||
t.Helper()
|
||
s.AddTasks([]eh.GalleryRef{{GID: gid, Token: token}})
|
||
}
|
||
|
||
// 页并发真的生效:单画廊 8 并发时,服务器侧应看到并发 > 1
|
||
func TestPageConcurrencyWorks(t *testing.T) {
|
||
const total = 20
|
||
|
||
m := newMockEH(total)
|
||
defer m.Close()
|
||
restore := m.installBase()
|
||
defer restore()
|
||
|
||
root := t.TempDir()
|
||
e, s := newEngineFor(t, root, 1, 8)
|
||
addOne(t, s, "999", "tok")
|
||
|
||
e.Enqueue(s.Tasks())
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
e.Start(ctx)
|
||
|
||
task := s.FindTask("999", "tok")
|
||
if task.Status != "done" {
|
||
t.Fatalf("应全部下完,实际状态 %s (done=%d failed=%d)", task.Status, task.Done, task.Failed)
|
||
}
|
||
if task.Done != total {
|
||
t.Errorf("应下 %d 张,实际 %d", total, task.Done)
|
||
}
|
||
|
||
// 页并发 8 => 峰值并发应该 > 1
|
||
if m.peak() < 2 {
|
||
t.Errorf("页并发 8 时服务器侧并发峰值只有 %d,说明页级并发没生效", m.peak())
|
||
}
|
||
t.Logf("页并发峰值 = %d(期望接近 8)", m.peak())
|
||
|
||
// 文件都要在
|
||
for i := 1; i <= total; i++ {
|
||
name := filepath.Join(root, "999", fmt.Sprintf("%03d.png", i))
|
||
if st, err := os.Stat(name); err != nil || st.Size() == 0 {
|
||
t.Errorf("第 %d 张图没下好", i)
|
||
}
|
||
}
|
||
|
||
// 清单也要写
|
||
mf, err := ReadManifest(filepath.Join(root, "999"))
|
||
if err != nil || mf == nil {
|
||
t.Fatalf("画廊清单没写: %v", err)
|
||
}
|
||
if mf.TotalPages != total || len(mf.DonePages) != total {
|
||
t.Errorf("清单内容不对: total=%d done=%d", mf.TotalPages, len(mf.DonePages))
|
||
}
|
||
}
|
||
|
||
// 页并发=1 时应退化成串行
|
||
func TestPageConcurrencyOneIsSerial(t *testing.T) {
|
||
const total = 8
|
||
|
||
m := newMockEH(total)
|
||
defer m.Close()
|
||
restore := m.installBase()
|
||
defer restore()
|
||
|
||
root := t.TempDir()
|
||
e, s := newEngineFor(t, root, 1, 1)
|
||
addOne(t, s, "888", "tok")
|
||
|
||
e.Enqueue(s.Tasks())
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
e.Start(ctx)
|
||
|
||
if s.FindTask("888", "tok").Status != "done" {
|
||
t.Error("应全部下完")
|
||
}
|
||
if m.peak() > 1 {
|
||
t.Errorf("页并发 1 时不该有并发请求,峰值 %d", m.peak())
|
||
}
|
||
}
|
||
|
||
// 重试:首轮失败(503)后应重试成功,不该进扫尾失败
|
||
func TestRetryThenSucceed(t *testing.T) {
|
||
const total = 4
|
||
|
||
m := newMockEH(total)
|
||
defer m.Close()
|
||
restore := m.installBase()
|
||
defer restore()
|
||
|
||
// 每页都先失败一次
|
||
m.mu.Lock()
|
||
for i := 1; i <= total; i++ {
|
||
m.failOnce[i] = 1
|
||
}
|
||
m.mu.Unlock()
|
||
|
||
root := t.TempDir()
|
||
e, s := newEngineFor(t, root, 1, 4)
|
||
addOne(t, s, "777", "tok")
|
||
|
||
e.Enqueue(s.Tasks())
|
||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||
defer cancel()
|
||
e.Start(ctx)
|
||
|
||
task := s.FindTask("777", "tok")
|
||
if task.Status != "done" {
|
||
t.Errorf("重试后应全部成功,实际 %s (failed=%d)", task.Status, task.Failed)
|
||
}
|
||
if task.Failed != 0 {
|
||
t.Errorf("不该有失败,实际 %d", task.Failed)
|
||
}
|
||
}
|
||
|
||
// 整轮扫尾:首轮必定失败(4 次重试全挂),扫尾再试仍失败 -> 标记 partial
|
||
// 但如果扫尾时恢复成功,应该是 done
|
||
func TestSweepRetrySucceeds(t *testing.T) {
|
||
const total = 3
|
||
|
||
m := newMockEH(total)
|
||
defer m.Close()
|
||
restore := m.installBase()
|
||
defer restore()
|
||
|
||
// 每页失败次数 = 首轮重试次数(4) + 1,确保首轮 4 次全挂,
|
||
// 扫尾时能成功
|
||
m.mu.Lock()
|
||
for i := 1; i <= total; i++ {
|
||
m.failOnce[i] = 5
|
||
}
|
||
m.mu.Unlock()
|
||
|
||
root := t.TempDir()
|
||
e, s := newEngineFor(t, root, 1, 3)
|
||
addOne(t, s, "666", "tok")
|
||
|
||
e.Enqueue(s.Tasks())
|
||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||
defer cancel()
|
||
e.Start(ctx)
|
||
|
||
task := s.FindTask("666", "tok")
|
||
if task.Status != "done" {
|
||
t.Errorf("扫尾后应全部成功,实际 %s (done=%d failed=%d)", task.Status, task.Done, task.Failed)
|
||
}
|
||
if task.Failed != 0 {
|
||
t.Errorf("扫尾后不该有失败,实际 %d", task.Failed)
|
||
}
|
||
for i := 1; i <= total; i++ {
|
||
name := filepath.Join(root, "666", fmt.Sprintf("%03d.png", i))
|
||
if st, err := os.Stat(name); err != nil || st.Size() == 0 {
|
||
t.Errorf("第 %d 张没下好", i)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 彻底失败(一直 503)-> partial,且扫尾也救不回来
|
||
func TestPermanentFailureMarksPartial(t *testing.T) {
|
||
const total = 2
|
||
|
||
m := newMockEH(total)
|
||
defer m.Close()
|
||
restore := m.installBase()
|
||
defer restore()
|
||
|
||
m.mu.Lock()
|
||
for i := 1; i <= total; i++ {
|
||
m.alwaysFail[i] = true
|
||
}
|
||
m.mu.Unlock()
|
||
|
||
root := t.TempDir()
|
||
e, s := newEngineFor(t, root, 1, 2)
|
||
addOne(t, s, "555", "tok")
|
||
|
||
e.Enqueue(s.Tasks())
|
||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||
defer cancel()
|
||
e.Start(ctx)
|
||
|
||
task := s.FindTask("555", "tok")
|
||
if task.Status != "partial" {
|
||
t.Errorf("全失败应标记 partial,实际 %s", task.Status)
|
||
}
|
||
if task.Failed != total {
|
||
t.Errorf("失败数应为 %d,实际 %d", total, task.Failed)
|
||
}
|
||
if len(task.FailedList) != total {
|
||
t.Errorf("失败清单应有 %d 条,实际 %d", total, len(task.FailedList))
|
||
}
|
||
}
|
||
|
||
// 断点续传:已有文件不重下
|
||
func TestResumeWithExistingFiles(t *testing.T) {
|
||
const total = 6
|
||
|
||
m := newMockEH(total)
|
||
defer m.Close()
|
||
restore := m.installBase()
|
||
defer restore()
|
||
|
||
root := t.TempDir()
|
||
dir := filepath.Join(root, "444")
|
||
os.MkdirAll(dir, 0o755)
|
||
|
||
// 预先放好 4 张
|
||
for i := 1; i <= 4; i++ {
|
||
os.WriteFile(filepath.Join(dir, fmt.Sprintf("%03d.png", i)), []byte("image-data"), 0o644)
|
||
}
|
||
|
||
e, s := newEngineFor(t, root, 1, 4)
|
||
addOne(t, s, "444", "tok")
|
||
|
||
before := m.totalReqs
|
||
e.Enqueue(s.Tasks())
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
e.Start(ctx)
|
||
|
||
task := s.FindTask("444", "tok")
|
||
if task.Status != "done" {
|
||
t.Errorf("应下完,实际 %s (done=%d)", task.Status, task.Done)
|
||
}
|
||
if task.Done != total {
|
||
t.Errorf("完成数应为 %d,实际 %d", total, task.Done)
|
||
}
|
||
|
||
// 只该请求剩下的 2 页(每页 2 个请求 = 4 次)
|
||
used := m.totalReqs - before
|
||
if used > 8 {
|
||
t.Errorf("已有 4 张却发了 %d 次请求 —— 断点续传没生效", used)
|
||
}
|
||
t.Logf("续传只用了 %d 次请求(4 张已存在,应只用 4 次)", used)
|
||
}
|
||
|
||
// 多个画廊并行:画廊并发生效
|
||
func TestGalleryConcurrencyWorks(t *testing.T) {
|
||
const (
|
||
galleries = 5
|
||
pages = 4
|
||
)
|
||
|
||
m := newMockEH(pages)
|
||
defer m.Close()
|
||
restore := m.installBase()
|
||
defer restore()
|
||
|
||
root := t.TempDir()
|
||
e, s := newEngineFor(t, root, galleries, 1)
|
||
for i := 1; i <= galleries; i++ {
|
||
gid := strconv.Itoa(300 + i)
|
||
addOne(t, s, gid, "tok")
|
||
}
|
||
|
||
e.Enqueue(s.Tasks())
|
||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||
defer cancel()
|
||
e.Start(ctx)
|
||
|
||
for i := 1; i <= galleries; i++ {
|
||
gid := strconv.Itoa(300 + i)
|
||
task := s.FindTask(gid, "tok")
|
||
if task == nil || task.Status != "done" {
|
||
t.Errorf("画廊 %s 没下完: %v", gid, task)
|
||
}
|
||
}
|
||
|
||
// 画廊并发 5 => 峰值并发应该 > 1
|
||
if m.peak() < 2 {
|
||
t.Errorf("画廊并发 5 时峰值只有 %d,并发没生效", m.peak())
|
||
}
|
||
t.Logf("画廊并发峰值 = %d", m.peak())
|
||
}
|
||
|
||
// 全局限流在引擎里生效
|
||
func TestEngineRespectsRateLimit(t *testing.T) {
|
||
const total = 6
|
||
|
||
m := newMockEH(total)
|
||
defer m.Close()
|
||
restore := m.installBase()
|
||
defer restore()
|
||
|
||
root := t.TempDir()
|
||
e, s := newEngineFor(t, root, 1, 4)
|
||
e.SetRequestInterval(80) // 80ms
|
||
addOne(t, s, "333", "tok")
|
||
|
||
e.Enqueue(s.Tasks())
|
||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||
defer cancel()
|
||
|
||
start := time.Now()
|
||
e.Start(ctx)
|
||
elapsed := time.Since(start)
|
||
|
||
task := s.FindTask("333", "tok")
|
||
if task.Status != "done" {
|
||
t.Fatalf("应下完,实际 %s", task.Status)
|
||
}
|
||
// 6 页 = 6 次 /s/ + 6 次图片 = 12 次请求,至少 11 个间隔 = 880ms
|
||
wantMin := 11 * 80 * time.Millisecond
|
||
if elapsed < wantMin {
|
||
t.Errorf("限流 80ms 时 %d 次请求应至少耗时 %v,实际 %v", 12, wantMin, elapsed)
|
||
}
|
||
t.Logf("12 次请求限流 80ms,实际耗时 %v", elapsed)
|
||
}
|
||
|
||
// 取消:页级并发下点停止要能真的中断
|
||
func TestCancelWithPageConcurrency(t *testing.T) {
|
||
const total = 30
|
||
|
||
m := newMockEH(total)
|
||
defer m.Close()
|
||
m.delayMs = 50 // 放慢点,方便中途取消
|
||
restore := m.installBase()
|
||
defer restore()
|
||
|
||
root := t.TempDir()
|
||
e, s := newEngineFor(t, root, 1, 8)
|
||
addOne(t, s, "222", "tok")
|
||
|
||
e.Enqueue(s.Tasks())
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
defer cancel()
|
||
|
||
done := make(chan struct{})
|
||
go func() { e.Start(ctx); close(done) }()
|
||
|
||
time.Sleep(300 * time.Millisecond)
|
||
e.Stop() // 相当于点「停止」
|
||
|
||
select {
|
||
case <-done:
|
||
case <-time.After(5 * time.Second):
|
||
t.Fatal("取消后应该在 5 秒内返回")
|
||
}
|
||
|
||
task := s.FindTask("222", "tok")
|
||
if task.Status != "queued" {
|
||
t.Errorf("取消后任务应退回 queued 等待续传,实际 %s", task.Status)
|
||
}
|
||
// 已下的文件还在(断点续传的基础)
|
||
dir := filepath.Join(root, "222")
|
||
files, _ := os.ReadDir(dir)
|
||
if len(files) > 0 {
|
||
t.Logf("取消前已下 %d 个文件,续传时会跳过", len(files))
|
||
}
|
||
} |