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`:
|
每个画廊目录下会写一份 `.ehdl.json`。首次下载时(解析完画廊页)写入,之后每次跑都会更新。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"gid": "4239274",
|
"gid": "4239274",
|
||||||
"token": "55e069b6b9",
|
"token": "55e069b6b9",
|
||||||
"title": "[Noxe] 0820 Qingxiao",
|
"title": "[Noxe] 0820 Qingxiao",
|
||||||
"totalPages": 206,
|
"totalPages": 206,
|
||||||
"donePages": [1, 2, 3, 5],
|
"donePages": [1, 2, 3, 5],
|
||||||
"failedPages": [4],
|
"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
|
"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`
|
- Windows:`%APPDATA%\EH Downloader\state.json`
|
||||||
@@ -316,6 +349,8 @@ go test -race ./... # 竞态检测
|
|||||||
- **全局限流**(并发穿透验证 + 精确计时)
|
- **全局限流**(并发穿透验证 + 精确计时)
|
||||||
- **整轮扫尾**(首轮 4 次重试全挂 → 扫尾救回 / 永久失败 → partial)
|
- **整轮扫尾**(首轮 4 次重试全挂 → 扫尾救回 / 永久失败 → partial)
|
||||||
- **画廊清单**(读写往返、目录扫描、state.json 丢失后的恢复)
|
- **画廊清单**(读写往返、目录扫描、state.json 丢失后的恢复)
|
||||||
|
- **清单优先**:第二次下载完全跳过画廊页解析(实测画廊页请求数不增)
|
||||||
|
- **清单降级**:v1 旧清单 / 损坏清单 / `/s/` 路径过期,三种情况都能正确回退
|
||||||
|
|
||||||
## 免责声明
|
## 免责声明
|
||||||
|
|
||||||
|
|||||||
+220
-51
@@ -267,6 +267,67 @@ func (e *Engine) safeRunTask(ctx context.Context, t Task) {
|
|||||||
e.runTask(ctx, t)
|
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) {
|
func (e *Engine) runTask(ctx context.Context, task Task) {
|
||||||
task.Status = "running"
|
task.Status = "running"
|
||||||
task.Error = ""
|
task.Error = ""
|
||||||
@@ -276,8 +337,36 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
|||||||
task.FailedList = []FailedItem{}
|
task.FailedList = []FailedItem{}
|
||||||
e.save(task)
|
e.save(task)
|
||||||
|
|
||||||
// ① 画廊页(内部自动翻 ?p=N 分页收集全部页)
|
root := e.store.Settings().DownloadRoot
|
||||||
gallery, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token)
|
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 err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
e.markCanceled(task)
|
e.markCanceled(task)
|
||||||
@@ -286,6 +375,8 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
|||||||
e.fail(task, fmt.Errorf("解析画廊页失败: %w", err))
|
e.fail(task, fmt.Errorf("解析画廊页失败: %w", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
gallery = g
|
||||||
|
}
|
||||||
|
|
||||||
task.Title = gallery.Title
|
task.Title = gallery.Title
|
||||||
task.TotalPages = gallery.TotalPages
|
task.TotalPages = gallery.TotalPages
|
||||||
@@ -295,10 +386,6 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
|||||||
e.save(task)
|
e.save(task)
|
||||||
|
|
||||||
// 目录按模板生成
|
// 目录按模板生成
|
||||||
root := e.store.Settings().DownloadRoot
|
|
||||||
if root == "" {
|
|
||||||
root = DefaultDownloadRoot()
|
|
||||||
}
|
|
||||||
dir := filepath.Join(root, task.Dir)
|
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 {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
e.fail(task, fmt.Errorf("创建目录失败: %w", err))
|
e.fail(task, fmt.Errorf("创建目录失败: %w", err))
|
||||||
@@ -332,6 +427,7 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
|||||||
token: task.Token,
|
token: task.Token,
|
||||||
title: gallery.Title,
|
title: gallery.Title,
|
||||||
total: len(gallery.Pages),
|
total: len(gallery.Pages),
|
||||||
|
meta: gallery.Meta,
|
||||||
engine: e,
|
engine: e,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,29 +436,7 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
|||||||
st.saveManifest()
|
st.saveManifest()
|
||||||
|
|
||||||
// 页级 worker 池
|
// 页级 worker 池
|
||||||
n := e.pageConcurrency
|
runPageWorkers(ctx, e, st, st.pending)
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 整轮扫尾:把刚失败的页再扫一遍 ----
|
// ---- 整轮扫尾:把刚失败的页再扫一遍 ----
|
||||||
if st.failedCount() > 0 && ctx.Err() == nil {
|
if st.failedCount() > 0 && ctx.Err() == nil {
|
||||||
@@ -371,28 +445,7 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
|||||||
retry := st.collectFailed()
|
retry := st.collectFailed()
|
||||||
// 扫尾时清掉 failedList,让结果反映最终状态
|
// 扫尾时清掉 failedList,让结果反映最终状态
|
||||||
st.clearFailures()
|
st.clearFailures()
|
||||||
|
runPageWorkers(ctx, e, st, retry)
|
||||||
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()
|
|
||||||
|
|
||||||
if st.failedCount() > 0 {
|
if st.failedCount() > 0 {
|
||||||
e.log("warn", fmt.Sprintf(
|
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()
|
st.saveManifest()
|
||||||
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
@@ -419,6 +497,35 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
|||||||
e.save(t)
|
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 保护,
|
// pageState 是「一个画廊的页级下载状态」,所有字段都由 mutex 保护,
|
||||||
// 因为页级 worker 是并发的。
|
// 因为页级 worker 是并发的。
|
||||||
type pageState struct {
|
type pageState struct {
|
||||||
@@ -434,12 +541,51 @@ type pageState struct {
|
|||||||
|
|
||||||
pages []eh.GalleryPageInfo
|
pages []eh.GalleryPageInfo
|
||||||
pending []eh.GalleryPageInfo
|
pending []eh.GalleryPageInfo
|
||||||
|
meta eh.GalleryMeta
|
||||||
|
|
||||||
done map[int]bool
|
done map[int]bool
|
||||||
failed map[int]string // 页码 -> 错误
|
failed map[int]string // 页码 -> 错误
|
||||||
failedL []FailedItem
|
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 扫描已有文件(断点续传的第一依据:文件在且非空 = 完成)
|
// scanExisting 扫描已有文件(断点续传的第一依据:文件在且非空 = 完成)
|
||||||
func (s *pageState) scanExisting() {
|
func (s *pageState) scanExisting() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -597,11 +743,32 @@ func (s *pageState) markCanceled() {
|
|||||||
s.engine.markCanceled(s.snapshot())
|
s.engine.markCanceled(s.snapshot())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// saveManifest 写画廊目录清单。
|
||||||
|
//
|
||||||
|
// 存的是每页的 /s/ 路径(稳定),不是 hath 直链(随机子域+随机端口+时间戳签名,
|
||||||
|
// 每次都变、存了也没用)。下次重开程序时,读这份清单就能跳过画廊页解析。
|
||||||
func (s *pageState) saveManifest() {
|
func (s *pageState) saveManifest() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
done := keysOf(s.done)
|
done := keysOf(s.done)
|
||||||
failed := keysOf(s.failed)
|
failed := keysOf(s.failed)
|
||||||
t := *s.task
|
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()
|
s.mu.Unlock()
|
||||||
|
|
||||||
WriteManifest(s.dir, Manifest{
|
WriteManifest(s.dir, Manifest{
|
||||||
@@ -611,6 +778,8 @@ func (s *pageState) saveManifest() {
|
|||||||
TotalPages: s.total,
|
TotalPages: s.total,
|
||||||
DonePages: done,
|
DonePages: done,
|
||||||
FailedPage: failed,
|
FailedPage: failed,
|
||||||
|
PageMap: pageMap,
|
||||||
|
Meta: &meta,
|
||||||
CreatedAt: t.CreatedAt,
|
CreatedAt: t.CreatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-5
@@ -15,15 +15,35 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"eh-downloader-wails/internal/eh"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ManifestFileName 清单文件名
|
// ManifestFileName 清单文件名
|
||||||
const ManifestFileName = ".ehdl.json"
|
const ManifestFileName = ".ehdl.json"
|
||||||
|
|
||||||
// ManifestVersion 清单格式版本(将来结构变了靠这个迁移)
|
// ManifestVersion 清单格式版本
|
||||||
const ManifestVersion = 1
|
//
|
||||||
|
// 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 单个画廊的下载清单
|
// Manifest 单个画廊的下载清单
|
||||||
type Manifest struct {
|
type Manifest struct {
|
||||||
@@ -32,14 +52,74 @@ type Manifest struct {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
TotalPages int `json:"totalPages"`
|
TotalPages int `json:"totalPages"`
|
||||||
|
|
||||||
|
// v1 字段(保留:便于人工查看,也兼容旧清单)
|
||||||
DonePages []int `json:"donePages"`
|
DonePages []int `json:"donePages"`
|
||||||
FailedPage []int `json:"failedPages"`
|
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"`
|
CreatedAt int64 `json:"createdAt"`
|
||||||
UpdatedAt int64 `json:"updatedAt"`
|
UpdatedAt int64 `json:"updatedAt"`
|
||||||
Tool string `json:"tool"`
|
Tool string `json:"tool"`
|
||||||
Note string `json:"note,omitempty"`
|
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 写画廊清单(原子写)
|
// WriteManifest 写画廊清单(原子写)
|
||||||
func WriteManifest(dir string, m Manifest) error {
|
func WriteManifest(dir string, m Manifest) error {
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
@@ -98,14 +178,29 @@ func ReadManifest(dir string) (*Manifest, error) {
|
|||||||
return &m, nil
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ManifestWithDir 是清单 + 它所在的目录
|
||||||
|
type ManifestWithDir struct {
|
||||||
|
Dir string
|
||||||
|
Manifest *Manifest
|
||||||
|
}
|
||||||
|
|
||||||
// ScanManifests 扫描下载根目录下所有画廊清单(跳过隐藏目录/非画廊目录)
|
// ScanManifests 扫描下载根目录下所有画廊清单(跳过隐藏目录/非画廊目录)
|
||||||
func ScanManifests(root string) []*Manifest {
|
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)
|
entries, err := os.ReadDir(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var out []*Manifest
|
var out []ManifestWithDir
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if !e.IsDir() {
|
if !e.IsDir() {
|
||||||
continue
|
continue
|
||||||
@@ -114,15 +209,26 @@ func ScanManifests(root string) []*Manifest {
|
|||||||
if len(e.Name()) > 0 && e.Name()[0] == '.' {
|
if len(e.Name()) > 0 && e.Name()[0] == '.' {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m, err := ReadManifest(filepath.Join(root, e.Name()))
|
dir := filepath.Join(root, e.Name())
|
||||||
|
m, err := ReadManifest(dir)
|
||||||
if err != nil || m == nil {
|
if err != nil || m == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, m)
|
out = append(out, ManifestWithDir{Dir: dir, Manifest: m})
|
||||||
}
|
}
|
||||||
return out
|
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 缺信息时的兜底)。
|
// ReconcileTask 拿清单里的信息补全任务(state.json 缺信息时的兜底)。
|
||||||
// 返回是否真的补进了新东西。
|
// 返回是否真的补进了新东西。
|
||||||
func ReconcileTask(t *Task, m *Manifest) bool {
|
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