feat: 清单存 /s/ 路径,重开程序跳过画廊页解析
画廊页要翻 ?p=N 分页收集全部页(206 张 = 11 次请求)。之前每次运行都要
重新解析一遍,即使本地已经有完整清单。现改为:
- 清单升级到 v2,pageMap 存每页的 /s/ 路径与文件名
- 下载前先按 gid 找磁盘上的画廊目录,读到 v2 清单就直接下载,不请求画廊页
- 解析完立即写清单,崩溃/关程序也不丢
为什么存 /s/ 路径而不是 hath 直链:
hath 直链是「随机子域 + 随机端口 + 时间戳签名」,每次都变且会过期,
存下来也没法用(手动复制到浏览器打开,下次同样失效)。/s/ 路径是稳定的。
降级策略(都实测过):
- v1 旧清单(没有 pageMap)→ 重新解析,并把清单升级成 v2
- 清单 JSON 损坏 → 重新解析,跑完重写一份正常的
- 清单的 /s/ 路径过期(待下页几乎全失败)→ 自动重新解析一次再重试
- 找不到清单但目录存在(换了目录模板)→ 按新模板名/旧标题名找到目录继续
另外 pageMap 里存了 meta,否则从清单重建时目录模板(如 {artist}/...)
会退化到兜底值,算出来的目录名和之前对不上。
测试从 68 增至 74,其中 TestManifestSkipsReparse 实测第二次运行
画廊页请求数为 0 增量。
This commit is contained in:
@@ -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/` 路径过期,三种情况都能正确回退
|
||||
|
||||
## 免责声明
|
||||
|
||||
|
||||
+225
-56
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
+122
-16
@@ -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 {
|
||||
|
||||
@@ -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("<html>not found</html>"))
|
||||
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/ 路径的清单不该能重建,应该重新解析")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user