diff --git a/README.md b/README.md index 821e1ef..8050361 100644 --- a/README.md +++ b/README.md @@ -247,24 +247,57 @@ https://oclzqqk.gyktyewfonyf.hath.network:39656/h/4e9af9f.../keystamp=1791448200 ### 画廊目录清单(.ehdl.json) -每个画廊目录下会写一份 `.ehdl.json`: +每个画廊目录下会写一份 `.ehdl.json`。首次下载时(解析完画廊页)写入,之后每次跑都会更新。 ```json { - "version": 1, + "version": 2, "gid": "4239274", "token": "55e069b6b9", "title": "[Noxe] 0820 Qingxiao", "totalPages": 206, "donePages": [1, 2, 3, 5], "failedPages": [4], + "pageMap": { + "1": { "page": 1, "file": "001.png", "sPath": "/s/bb06b5db77/4239250-1", "finished": true }, + "2": { "page": 2, "file": "002.png", "sPath": "/s/cc11ee22ff/4239250-2", "finished": true } + }, + "meta": { "artist": "Noxe", "group": "CriticalHAI", "tags": ["qingxiao"] }, "updatedAt": 1791475732842 } ``` -作用:断点续传的依据是「文件在且非空」,但那有个盲区 —— 如果 `state.json` -丢了,程序就不知道这个画廊**原本应该有多少张**。清单补上了这个认知, -扫目录就能算出「应该 206 张,现在只有 4 张」。 +**关键作用:跳过重新解析。** + +画廊页要翻 `?p=N` 分页收集全部页(206 张 = 11 次请求)。有了 `pageMap` 里的 +`/s/` 路径,程序重启后可以直接下载,不必再解析画廊页。 + +``` +首次:解析画廊页(11次) → 写清单 → 下载 +重开:读清单(0次请求) → 直接下载 +``` + +**为什么 `pageMap` 存 `/s/` 路径而不是图片直链?** + +hath 直链是 `随机子域 + 随机端口 + 时间戳签名`,每次请求都不一样且会过期 —— +存下来也没法用(就算手动复制到浏览器打开,下次也会失效)。 +`/s/` 路径是稳定的,用它每次实时换取直链。 + +### 清单失效怎么办 + +如果清单里的 `/s/` 路径过期了(EH 换 key、或作者更新了画廊),下载会失败。 +程序会自动检测:**待下载页几乎全失败 → 重新解析一次画廊页 → 用新路径重试**。 + +日志里会看到: + +``` +画廊 4239274 用本地清单(206 页),跳过重新解析 +画廊 4239274:本地清单的页面路径似乎已失效,重新解析画廊页 +画廊 4239274:重新解析后全部下载完成 +``` + +如果重新解析后还是失败(画廊真被删了/受限制了),才会标记「部分失败」。 +这时候按标题手动搜一次重新导入即可。 状态文件: - Windows:`%APPDATA%\EH Downloader\state.json` @@ -316,6 +349,8 @@ go test -race ./... # 竞态检测 - **全局限流**(并发穿透验证 + 精确计时) - **整轮扫尾**(首轮 4 次重试全挂 → 扫尾救回 / 永久失败 → partial) - **画廊清单**(读写往返、目录扫描、state.json 丢失后的恢复) +- **清单优先**:第二次下载完全跳过画廊页解析(实测画廊页请求数不增) +- **清单降级**:v1 旧清单 / 损坏清单 / `/s/` 路径过期,三种情况都能正确回退 ## 免责声明 diff --git a/internal/core/engine.go b/internal/core/engine.go index 51117dc..ac9bd96 100644 --- a/internal/core/engine.go +++ b/internal/core/engine.go @@ -267,6 +267,67 @@ func (e *Engine) safeRunTask(ctx context.Context, t Task) { e.runTask(ctx, t) } +// existingGallery 是磁盘上已存在的画廊信息 +type existingGallery struct { + dir string + manifest *Manifest +} + +// findExistingGallery 在下载根目录里找这个 gid 已存在的画廊。 +// +// 两种匹配方式: +// 1. 按 .ehdl.json 清单里的 gid 精确匹配(最可靠,拿到目录绝对路径) +// 2. 没有清单时,退而按「模板算出的目录名」或「旧版标题目录名」猜 +// +// 找到就返回,下载时可直接复用清单里的 /s/ 路径,省掉画廊页解析。 +func (e *Engine) findExistingGallery(root string, task Task) *existingGallery { + // 1) 按清单里的 gid 匹配 + if md := FindManifestByGID(root, task.GID); md != nil { + return &existingGallery{dir: md.Dir, manifest: md.Manifest} + } + + // 2) 没有清单:按可能的目录名猜(换个模板/旧版标题目录的情况) + for _, name := range e.guessDirNames(task, nil) { + p := filepath.Join(root, name) + if st, err := os.Stat(p); err == nil && st.IsDir() { + m, _ := ReadManifest(p) + return &existingGallery{dir: p, manifest: m} + } + } + return nil +} + +// guessDirNames 猜这个画廊可能的目录名(当前模板算出来的 + 旧版标题目录) +// +// meta 优先用已找到的清单里那份(那是上次解析时拿到的真值), +// 没有清单时才用任务标题试算。 +func (e *Engine) guessDirNames(task Task, meta *eh.GalleryMeta) []string { + tpl := e.store.Settings().DirTemplate + if tpl == "" { + tpl = "{title}" + } + + probe := eh.Gallery{GID: task.GID, Title: task.Title} + if meta != nil { + probe.Meta = *meta + } + if probe.Title == "" { + probe.Title = task.GID + } + + var names []string + if d := BuildDir(tpl, &probe); d != "" { + names = append(names, d) + } + // 旧版:只按标题建目录 + if task.Title != "" { + if s := SanitizeSegment(task.Title, task.GID); s != "" { + names = append(names, s) + } + } + return names +} + func (e *Engine) runTask(ctx context.Context, task Task) { task.Status = "running" task.Error = "" @@ -276,15 +337,45 @@ func (e *Engine) runTask(ctx context.Context, task Task) { task.FailedList = []FailedItem{} e.save(task) - // ① 画廊页(内部自动翻 ?p=N 分页收集全部页) - gallery, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token) - if err != nil { - if ctx.Err() != nil { - e.markCanceled(task) + root := e.store.Settings().DownloadRoot + if root == "" { + root = DefaultDownloadRoot() + } + + // ------------------------------------------------------------ + // 先看磁盘上有没有这个画廊的目录和清单。 + // 清单存在 => 之前解析过了,直接拿来下载,省掉 N 次画廊页分页请求。 + // ------------------------------------------------------------ + existing := e.findExistingGallery(root, task) + + var gallery *eh.Gallery + usedManifest := false + + if existing != nil { + if g, ok := existing.manifest.GalleryFromManifest(); ok { + gallery = g + usedManifest = true + e.log("info", fmt.Sprintf( + "画廊 %s 用本地清单(%d 页),跳过重新解析", task.GID, len(g.Pages))) + } else { + // 目录在、清单是旧版本(v1,没有 /s/ 路径)—— 仍需解析 + e.log("info", fmt.Sprintf( + "画廊 %s 有目录但清单不含页面路径,仍需重新解析", task.GID)) + } + } + + // 没有可用清单(或清单太旧)→ 正常解析画廊页 + if gallery == nil { + g, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token) + if err != nil { + if ctx.Err() != nil { + e.markCanceled(task) + return + } + e.fail(task, fmt.Errorf("解析画廊页失败: %w", err)) return } - e.fail(task, fmt.Errorf("解析画廊页失败: %w", err)) - return + gallery = g } task.Title = gallery.Title @@ -295,10 +386,6 @@ func (e *Engine) runTask(ctx context.Context, task Task) { e.save(task) // 目录按模板生成 - root := e.store.Settings().DownloadRoot - if root == "" { - root = DefaultDownloadRoot() - } dir := filepath.Join(root, task.Dir) // 老版本一律用「标题」当目录名。用户中途换了模板时, @@ -316,6 +403,14 @@ func (e *Engine) runTask(ctx context.Context, task Task) { } } } + // 清单里的目录名对不上时(比如换了模板),以磁盘上实际存在的为准 + if existing != nil && existing.dir != "" && existing.dir != dir { + dir = existing.dir + rel, err := filepath.Rel(root, dir) + if err == nil { + task.Dir = rel + } + } if err := os.MkdirAll(dir, 0o755); err != nil { e.fail(task, fmt.Errorf("创建目录失败: %w", err)) @@ -332,6 +427,7 @@ func (e *Engine) runTask(ctx context.Context, task Task) { token: task.Token, title: gallery.Title, total: len(gallery.Pages), + meta: gallery.Meta, engine: e, } @@ -340,29 +436,7 @@ func (e *Engine) runTask(ctx context.Context, task Task) { 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.safeDownloadPage(ctx, p) - } - }() - } - wg.Wait() - } + runPageWorkers(ctx, e, st, st.pending) // ---- 整轮扫尾:把刚失败的页再扫一遍 ---- if st.failedCount() > 0 && ctx.Err() == nil { @@ -371,28 +445,7 @@ func (e *Engine) runTask(ctx context.Context, task Task) { 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.safeDownloadPage(ctx, p) - } - }() - } - wg.Wait() + runPageWorkers(ctx, e, st, retry) if st.failedCount() > 0 { e.log("warn", fmt.Sprintf( @@ -403,6 +456,31 @@ func (e *Engine) runTask(ctx context.Context, task Task) { } } + // ---- 清单失效自愈 ---- + // 如果这次是靠清单跳过解析的,但所有待下页几乎全失败, + // 说明清单里的 /s/ 路径已经过期(EH 换了 key,或作者更新了画廊)。 + // 这时重新解析一次画廊页,拿到新的 /s/ 路径再试。 + if usedManifest && ctx.Err() == nil && st.allFailed() { + e.log("warn", fmt.Sprintf( + "画廊 %s:本地清单的页面路径似乎已失效,重新解析画廊页", task.GID)) + + if fresh, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token); err == nil { + // 用新解析结果重跑:重置页状态 + st.reset(fresh) + st.scanExisting() + st.saveManifest() + + retry := st.collectAllPending() + runPageWorkers(ctx, e, st, retry) + + if st.failedCount() == 0 { + e.log("ok", fmt.Sprintf("画廊 %s:重新解析后全部下载完成", task.GID)) + } + } else if ctx.Err() == nil { + e.log("error", fmt.Sprintf("画廊 %s:重新解析失败:%v", task.GID, err)) + } + } + st.saveManifest() if ctx.Err() != nil { @@ -419,6 +497,35 @@ func (e *Engine) runTask(ctx context.Context, task Task) { e.save(t) } +// runPageWorkers 用页级并发下载给定页面(阻塞直到完成) +func runPageWorkers(ctx context.Context, e *Engine, st *pageState, pages []eh.GalleryPageInfo) { + if len(pages) == 0 { + return + } + n := e.PageConcurrency() + if n > len(pages) { + n = len(pages) + } + + work := make(chan eh.GalleryPageInfo, len(pages)) + for _, p := range pages { + work <- p + } + close(work) + + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for p := range work { + st.safeDownloadPage(ctx, p) + } + }() + } + wg.Wait() +} + // pageState 是「一个画廊的页级下载状态」,所有字段都由 mutex 保护, // 因为页级 worker 是并发的。 type pageState struct { @@ -434,12 +541,51 @@ type pageState struct { pages []eh.GalleryPageInfo pending []eh.GalleryPageInfo + meta eh.GalleryMeta done map[int]bool failed map[int]string // 页码 -> 错误 failedL []FailedItem } +// reset 用新的解析结果重置页状态(清单失效后重新解析时用) +func (s *pageState) reset(g *eh.Gallery) { + s.mu.Lock() + defer s.mu.Unlock() + s.pages = g.Pages + s.pending = nil + s.total = len(g.Pages) + s.title = g.Title + s.meta = g.Meta +} + +// allFailed 判断是否「有待下页面且全部失败」—— 用来识别清单过期 +func (s *pageState) allFailed() bool { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.pending) == 0 { + return false // 没有待下页面,不是清单的问题 + } + // 只下了一页而它失败了不算(可能只是单页问题) + if len(s.pending) == 1 && len(s.done) > 0 { + return false + } + return len(s.done) == 0 && len(s.failed) > 0 +} + +// collectAllPending 取出所有还没成功的页 +func (s *pageState) collectAllPending() []eh.GalleryPageInfo { + s.mu.Lock() + defer s.mu.Unlock() + var out []eh.GalleryPageInfo + for _, p := range s.pages { + if !s.done[p.Page] { + out = append(out, p) + } + } + return out +} + // scanExisting 扫描已有文件(断点续传的第一依据:文件在且非空 = 完成) func (s *pageState) scanExisting() { s.mu.Lock() @@ -597,11 +743,32 @@ func (s *pageState) markCanceled() { s.engine.markCanceled(s.snapshot()) } +// saveManifest 写画廊目录清单。 +// +// 存的是每页的 /s/ 路径(稳定),不是 hath 直链(随机子域+随机端口+时间戳签名, +// 每次都变、存了也没用)。下次重开程序时,读这份清单就能跳过画廊页解析。 func (s *pageState) saveManifest() { s.mu.Lock() done := keysOf(s.done) failed := keysOf(s.failed) t := *s.task + + meta := s.meta + pageMap := make(map[string]ManifestPage, len(s.pages)) + for _, p := range s.pages { + k := strconv.Itoa(p.Page) + errMsg := "" + if msg, bad := s.failed[p.Page]; bad { + errMsg = msg + } + pageMap[k] = ManifestPage{ + Page: p.Page, + File: targetName(p, s.gid, s.total), + SPath: p.SPath, + Finished: s.done[p.Page], + Error: errMsg, + } + } s.mu.Unlock() WriteManifest(s.dir, Manifest{ @@ -611,6 +778,8 @@ func (s *pageState) saveManifest() { TotalPages: s.total, DonePages: done, FailedPage: failed, + PageMap: pageMap, + Meta: &meta, CreatedAt: t.CreatedAt, }) } diff --git a/internal/core/manifest.go b/internal/core/manifest.go index f76de4a..2148b7c 100644 --- a/internal/core/manifest.go +++ b/internal/core/manifest.go @@ -15,29 +15,109 @@ import ( "encoding/json" "os" "path/filepath" + "sort" "strconv" "time" + + "eh-downloader-wails/internal/eh" ) // ManifestFileName 清单文件名 const ManifestFileName = ".ehdl.json" -// ManifestVersion 清单格式版本(将来结构变了靠这个迁移) -const ManifestVersion = 1 +// ManifestVersion 清单格式版本 +// +// v1: 只有 donePages/failedPages(页码列表) +// v2: 增加 pageMap —— 每页的 /s/ 路径与文件名。有了它, +// +// 重开程序时可以直接下载,不必重新解析画廊页(省掉 N 次分页请求)。 +// +// 注意这里存的是 /s/ 路径而不是 hath 直链 —— 直链带随机子域+随机端口+ +// 时间戳签名,每次都变、还会过期,存了也没用。 +const ManifestVersion = 2 + +// ManifestPage 单页信息 +type ManifestPage struct { + Page int `json:"page"` + File string `json:"file"` + SPath string `json:"sPath"` + Finished bool `json:"finished"` + Error string `json:"error,omitempty"` +} // Manifest 单个画廊的下载清单 type Manifest struct { - Version int `json:"version"` - GID string `json:"gid"` - Token string `json:"token"` - Title string `json:"title"` - TotalPages int `json:"totalPages"` - DonePages []int `json:"donePages"` - FailedPage []int `json:"failedPages"` - CreatedAt int64 `json:"createdAt"` - UpdatedAt int64 `json:"updatedAt"` - Tool string `json:"tool"` - Note string `json:"note,omitempty"` + Version int `json:"version"` + GID string `json:"gid"` + Token string `json:"token"` + Title string `json:"title"` + TotalPages int `json:"totalPages"` + + // v1 字段(保留:便于人工查看,也兼容旧清单) + DonePages []int `json:"donePages"` + FailedPage []int `json:"failedPages"` + + // v2:页码 -> 页面信息(含 /s/ 路径) + PageMap map[string]ManifestPage `json:"pageMap,omitempty"` + + // v2:画廊元信息。目录模板(如 {artist}/{group})依赖它, + // 不存的话从清单重建时会退化到兜底值,导致目录名和之前对不上。 + Meta *eh.GalleryMeta `json:"meta,omitempty"` + + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + Tool string `json:"tool"` + Note string `json:"note,omitempty"` +} + +// HasPageMap 该清单是否包含 /s/ 路径(v2 才有) +func (m *Manifest) HasPageMap() bool { + return m != nil && m.Version >= 2 && len(m.PageMap) > 0 +} + +// GalleryFromManifest 用清单重建一个 Gallery(跳过画廊页解析)。 +// 只有 v2 清单且信息完整时才返回 ok=true。 +func (m *Manifest) GalleryFromManifest() (*eh.Gallery, bool) { + if !m.HasPageMap() { + return nil, false + } + + pages := make([]eh.GalleryPageInfo, 0, len(m.PageMap)) + for _, mp := range m.PageMap { + if mp.SPath == "" { + return nil, false // 信息不全,宁可重新解析 + } + pages = append(pages, eh.GalleryPageInfo{ + Page: mp.Page, + SPath: mp.SPath, + Filename: mp.File, + }) + } + if len(pages) == 0 { + return nil, false + } + + // 页码排序 + sort.Slice(pages, func(i, j int) bool { return pages[i].Page < pages[j].Page }) + + total := m.TotalPages + if total < len(pages) { + total = len(pages) + } + + meta := eh.GalleryMeta{} + if m.Meta != nil { + meta = *m.Meta + } + + return &eh.Gallery{ + GID: m.GID, + Token: m.Token, + Title: m.Title, + TotalPages: total, + Pages: pages, + Meta: meta, + }, true } // WriteManifest 写画廊清单(原子写) @@ -98,14 +178,29 @@ func ReadManifest(dir string) (*Manifest, error) { return &m, nil } +// ManifestWithDir 是清单 + 它所在的目录 +type ManifestWithDir struct { + Dir string + Manifest *Manifest +} + // ScanManifests 扫描下载根目录下所有画廊清单(跳过隐藏目录/非画廊目录) func ScanManifests(root string) []*Manifest { + out := make([]*Manifest, 0) + for _, md := range ScanManifestsWithDir(root) { + out = append(out, md.Manifest) + } + return out +} + +// ScanManifestsWithDir 同上,但带上目录路径(定位已有画廊时需要) +func ScanManifestsWithDir(root string) []ManifestWithDir { entries, err := os.ReadDir(root) if err != nil { return nil } - var out []*Manifest + var out []ManifestWithDir for _, e := range entries { if !e.IsDir() { continue @@ -114,15 +209,26 @@ func ScanManifests(root string) []*Manifest { if len(e.Name()) > 0 && e.Name()[0] == '.' { continue } - m, err := ReadManifest(filepath.Join(root, e.Name())) + dir := filepath.Join(root, e.Name()) + m, err := ReadManifest(dir) if err != nil || m == nil { continue } - out = append(out, m) + out = append(out, ManifestWithDir{Dir: dir, Manifest: m}) } return out } +// FindManifestByGID 按 gid 找清单 +func FindManifestByGID(root, gid string) *ManifestWithDir { + for _, md := range ScanManifestsWithDir(root) { + if md.Manifest.GID == gid { + return &md + } + } + return nil +} + // ReconcileTask 拿清单里的信息补全任务(state.json 缺信息时的兜底)。 // 返回是否真的补进了新东西。 func ReconcileTask(t *Task, m *Manifest) bool { diff --git a/internal/core/manifest_priority_test.go b/internal/core/manifest_priority_test.go new file mode 100644 index 0000000..ffac69c --- /dev/null +++ b/internal/core/manifest_priority_test.go @@ -0,0 +1,366 @@ +package core + +// 清单优先(跳过重新解析)的测试 +// +// 目标行为: +// +// 第一次下载 -> 解析画廊页 -> 写清单(含 /s/ 路径) +// 第二次下载 -> 读到清单 -> 直接下载,不再请求画廊页 +// +// 以及降级:清单过期时(/s/ 路径失效)应自动重新解析一次。 + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "eh-downloader-wails/internal/eh" +) + +// countingMockEH 在 mockEH 基础上统计画廊页请求次数,并可让 /s/ 失效 +type countingMockEH struct { + *mockEH + galleryReqs int64 + sPathBroken bool +} + +func newCountingMockEH(totalPages int) *countingMockEH { + cm := &countingMockEH{mockEH: newMockEH(totalPages)} + + inner := cm.mockEH.srv.Config.Handler + cm.mockEH.srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/g/") { + atomic.AddInt64(&cm.galleryReqs, 1) + } + if strings.HasPrefix(r.URL.Path, "/s/") && cm.sPathBroken { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("not found")) + return + } + inner.ServeHTTP(w, r) + }) + return cm +} + +func (c *countingMockEH) galleryCount() int64 { return atomic.LoadInt64(&c.galleryReqs) } + +// 清单里应该存有每页的 /s/ 路径 +func TestManifestStoresSPaths(t *testing.T) { + const total = 5 + + m := newMockEH(total) + defer m.Close() + restore := m.installBase() + defer restore() + + root := t.TempDir() + e, s := newEngineFor(t, root, 1, 3) + addOne(t, s, "555001", "tok") + + e.Enqueue(s.Tasks()) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + e.Start(ctx) + + if s.FindTask("555001", "tok").Status != "done" { + t.Fatal("第一次下载应该成功") + } + + dir := filepath.Join(root, "555001") + mf, err := ReadManifest(dir) + if err != nil || mf == nil { + t.Fatalf("清单没写出来: %v", err) + } + if mf.Version < 2 { + t.Errorf("清单版本应为 2(含 /s/ 路径),实际 %d", mf.Version) + } + if !mf.HasPageMap() { + t.Fatal("清单应该有 PageMap") + } + if len(mf.PageMap) != total { + t.Errorf("PageMap 应有 %d 项,实际 %d", total, len(mf.PageMap)) + } + for i := 1; i <= total; i++ { + k := strconv.Itoa(i) + mp, ok := mf.PageMap[k] + if !ok { + t.Errorf("PageMap 缺第 %d 页", i) + continue + } + if mp.SPath == "" { + t.Errorf("第 %d 页没存 /s/ 路径", i) + } + if !strings.Contains(mp.SPath, "/s/") { + t.Errorf("第 %d 页的路径不对: %s", i, mp.SPath) + } + if mp.File == "" { + t.Errorf("第 %d 页没存文件名", i) + } + } + // 元信息要存下来(目录模板依赖) + if mf.Meta == nil { + t.Error("清单该存 meta,目录模板依赖它") + } +} + +// 核心:第二次下载应跳过画廊页解析 +func TestManifestSkipsReparse(t *testing.T) { + const total = 6 + + m := newCountingMockEH(total) + defer m.Close() + restore := m.installBase() + defer restore() + + root := t.TempDir() + dir := filepath.Join(root, "555002") + os.MkdirAll(dir, 0o755) + // 预先下好 3 张 + for i := 1; i <= 3; 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, "555002", "tok") + + // 先跑一次把清单建起来(这次会解析) + e.Enqueue(s.Tasks()) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + e.Start(ctx) + + if s.FindTask("555002", "tok").Status != "done" { + t.Fatal("第一次应该下完") + } + firstGalleryReqs := m.galleryCount() + if firstGalleryReqs == 0 { + t.Fatal("第一次应该请求过画廊页") + } + + // 删掉后 3 张,模拟「续传」场景 + for i := 4; i <= total; i++ { + os.Remove(filepath.Join(dir, fmt.Sprintf("%03d.png", i))) + } + + // 第二次运行:应该用清单,不请求画廊页 + e2, s2 := newEngineFor(t, root, 1, 4) + s2.AddTasks([]eh.GalleryRef{{GID: "555002", Token: "tok"}}) + // 让任务进入待下载状态 + stored := s2.FindTask("555002", "tok") + stored.Status = "partial" + s2.UpsertTask(*stored) + + before := m.galleryCount() + e2.Enqueue(s2.Tasks()) + ctx2, cancel2 := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel2() + e2.Start(ctx2) + + after := m.galleryCount() + if after != before { + t.Errorf("第二次应完全跳过画廊页解析,但多发了 %d 次请求", after-before) + } + + task := s2.FindTask("555002", "tok") + if task.Status != "done" { + t.Errorf("第二次应补完剩余页,实际 %s (done=%d)", task.Status, task.Done) + } + // 文件都该在 + for i := 1; i <= total; i++ { + p := filepath.Join(dir, fmt.Sprintf("%03d.png", i)) + if st, err := os.Stat(p); err != nil || st.Size() == 0 { + t.Errorf("第 %d 张图缺失", i) + } + } +} + +// v1 旧清单(没有 PageMap)应触发重新解析,而不是直接失败 +func TestLegacyManifestFallsBackToReparse(t *testing.T) { + const total = 4 + + m := newCountingMockEH(total) + defer m.Close() + restore := m.installBase() + defer restore() + + root := t.TempDir() + dir := filepath.Join(root, "555003") + os.MkdirAll(dir, 0o755) + + // 写一个 v1 旧清单(只有 donePages,没有 pageMap) + WriteManifest(dir, Manifest{ + Version: 1, + GID: "555003", + Token: "tok", + Title: "[Test] Gallery", + TotalPages: total, + DonePages: []int{}, + }) + // 强制把版本降回 v1 并删掉 pageMap + raw, _ := os.ReadFile(filepath.Join(dir, ManifestFileName)) + s := string(raw) + s = strings.Replace(s, `"pageMap"`, `"_pageMap"`, 1) + s = strings.Replace(s, `"version": 2`, `"version": 1`, 1) + os.WriteFile(filepath.Join(dir, ManifestFileName), []byte(s), 0o644) + + mf, _ := ReadManifest(dir) + if mf.HasPageMap() { + t.Fatal("构造的应该是没有 pageMap 的旧清单") + } + + // 运行:应该回退到解析画廊页,并把清单升级成 v2 + e, s2 := newEngineFor(t, root, 1, 3) + addOne(t, s2, "555003", "tok") + + e.Enqueue(s2.Tasks()) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + e.Start(ctx) + + if m.galleryCount() == 0 { + t.Error("旧清单没有 /s/ 路径,应该重新解析画廊页") + } + if s2.FindTask("555003", "tok").Status != "done" { + t.Error("应该能下完") + } + + // 清单应该被升级成 v2 + mf2, _ := ReadManifest(dir) + if mf2 == nil || !mf2.HasPageMap() { + t.Error("跑完之后清单应该升级成 v2(含 pageMap)") + } +} + +// 清单过期(/s/ 返回 404)应自动重新解析一次 +func TestStaleManifestAutoReparse(t *testing.T) { + const total = 4 + + m := newCountingMockEH(total) + defer m.Close() + restore := m.installBase() + defer restore() + + root := t.TempDir() + dir := filepath.Join(root, "555004") + os.MkdirAll(dir, 0o755) + + // 造一个「看起来有效」但 sPath 指向 /s/ 的清单 + pm := map[string]ManifestPage{} + for i := 1; i <= total; i++ { + pm[strconv.Itoa(i)] = ManifestPage{ + Page: i, + File: fmt.Sprintf("%03d.png", i), + SPath: fmt.Sprintf("%s/s/deadkey/555004-%d", m.URL(), i), + } + } + WriteManifest(dir, Manifest{ + GID: "555004", Token: "tok", Title: "[Test] Gallery", + TotalPages: total, PageMap: pm, + }) + // 让 /s/ 全部失效 —— 模拟清单过期 + m.sPathBroken = true + + e, s2 := newEngineFor(t, root, 1, 2) + addOne(t, s2, "555004", "tok") + + e.Enqueue(s2.Tasks()) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + e.Start(ctx) + + // 应该检测到清单失效 -> 重新解析 -> 但 /s/ 还是坏的,所以仍会失败。 + // 关键是它不该无声崩溃,且失败要记录清楚。 + task := s2.FindTask("555004", "tok") + if task == nil { + t.Fatal("任务不见了") + } + if task.Status != "partial" && task.Status != "failed" { + t.Errorf("清单失效时应标记失败,实际 %s", task.Status) + } + // 至少应该尝试过重新解析画廊页 + if m.galleryCount() == 0 { + t.Error("清单失效后应该自动重新解析画廊页") + } +} + +// 清单损坏时应回退到解析,不能崩 +func TestCorruptManifestFallsBack(t *testing.T) { + const total = 3 + + m := newMockEH(total) + defer m.Close() + restore := m.installBase() + defer restore() + + root := t.TempDir() + dir := filepath.Join(root, "555005") + os.MkdirAll(dir, 0o755) + os.WriteFile(filepath.Join(dir, ManifestFileName), []byte("{坏的 json"), 0o644) + + e, s := newEngineFor(t, root, 1, 2) + addOne(t, s, "555005", "tok") + + e.Enqueue(s.Tasks()) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + e.Start(ctx) + + if s.FindTask("555005", "tok").Status != "done" { + t.Error("清单坏了也该能正常下载") + } + // 跑完应该把坏清单修好 + mf, err := ReadManifest(dir) + if err != nil || mf == nil || !mf.HasPageMap() { + t.Error("跑完应该重写一份正常的 v2 清单") + } +} + +// GalleryFromManifest 的单元行为 +func TestGalleryFromManifest(t *testing.T) { + pm := map[string]ManifestPage{ + "1": {Page: 1, File: "001.png", SPath: "/s/a/1-1"}, + "3": {Page: 3, File: "003.png", SPath: "/s/a/1-3"}, + "2": {Page: 2, File: "002.png", SPath: "/s/a/1-2"}, + } + m := &Manifest{GID: "1", Token: "t", Title: "T", TotalPages: 3, PageMap: pm, Version: 2} + + g, ok := m.GalleryFromManifest() + if !ok { + t.Fatal("v2 清单应该能重建 Gallery") + } + if len(g.Pages) != 3 { + t.Errorf("页数不对: %d", len(g.Pages)) + } + // 必须按页码排序 + for i, p := range g.Pages { + if p.Page != i+1 { + t.Errorf("第 %d 项页码应 %d,实际 %d", i, i+1, p.Page) + } + } + + // v1 清单不能重建 + v1 := &Manifest{Version: 1, TotalPages: 3} + if _, ok := v1.GalleryFromManifest(); ok { + t.Error("v1 清单不该能重建(没有 /s/ 路径)") + } + // nil 不能重建 + var nilM *Manifest + if _, ok := nilM.GalleryFromManifest(); ok { + t.Error("nil 清单不该能重建") + } + // 信息不全(某页没 sPath)也不能重建 + bad := &Manifest{Version: 2, PageMap: map[string]ManifestPage{ + "1": {Page: 1, File: "1.png", SPath: "/s/a/1-1"}, + "2": {Page: 2, File: "2.png"}, // 缺 SPath + }} + if _, ok := bad.GalleryFromManifest(); ok { + t.Error("缺 /s/ 路径的清单不该能重建,应该重新解析") + } +} \ No newline at end of file