Files
eh-downloader/internal/core/manifest.go
T
toor 0447875b6d feat: 双层并发 + 全局限流 + 整轮扫尾 + 画廊目录清单
原实现只能「多画廊并行,每个画廊串行下」,单张大画廊下得很慢。
按用户多年使用习惯(5 画廊 × 每画廊 8 张)改造为双层并发。

- 双层并发:画廊并发(默认5) × 页并发(默认8) = 最多 40 张同时下载,
  两个值都在设置面板可调(1-20)。页级 worker 池 + mutex 保护计数。
- 全局限流:对齐参考实现的 nextRequestTime 算法,限所有请求
  (含 /s/ 解析与图片本体),默认 0 秒关闭。
- 整轮扫尾:跑完后自动重扫失败页 1 轮(同样走完整 4 次退避重试),
  仍失败则停下并标记 partial,换线路后手动点开始即可续传。
- 画廊目录清单 .ehdl.json:记录 gid/token/总页数/已完成页,
  即使 state.json 丢失也能算出「应该多少张、还差多少张」。

顺带修复:
- Start() 双重加锁导致死锁(引擎完全无法启动)
- clampInt 越界时返回默认值而非边界值(设 30 会静默变 8)
- 直链正则写死 hath.network,CDN 换域名会全军覆没 -> 加图片 URL 兜底
- ReconcileTask 会误报「有改动」

测试从 37 增至 62,其中并发/限流/扫尾用 httptest 模拟 EH 实测:
- 页并发峰值 = 8、画廊并发峰值 = 5(完全生效)
- 12 次请求限流 80ms 实测 966ms(期望 880ms)
- 断点续传 4 张已存在时只发 5 次请求
2026-10-09 00:11:38 +08:00

179 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package core
// 画廊目录清单(.ehdl.json)
//
// 为什么需要:断点续传的第一依据是「文件在不在」,但那有个盲区 ——
// 如果 state.json 丢了或被删,程序就不知道这个画廊「原本应该有多少张」,
// 也就不知道漏了哪些。
//
// 所以每个画廊目录下写一份清单,记录 gid/token/总页数/已完成页码。
// 哪怕状态文件没了,扫目录也能重建进度。
//
// 文件名固定为 .ehdl.json(点开头,Linux/macOS 下也默认隐藏)。
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"time"
)
// ManifestFileName 清单文件名
const ManifestFileName = ".ehdl.json"
// ManifestVersion 清单格式版本(将来结构变了靠这个迁移)
const ManifestVersion = 1
// 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"`
}
// WriteManifest 写画廊清单(原子写)
func WriteManifest(dir string, m Manifest) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
m.Version = ManifestVersion
m.UpdatedAt = time.Now().UnixMilli()
if m.CreatedAt == 0 {
m.CreatedAt = m.UpdatedAt
}
m.Tool = AppName
if m.DonePages == nil {
m.DonePages = []int{}
}
if m.FailedPage == nil {
m.FailedPage = []int{}
}
sortInts(m.DonePages)
sortInts(m.FailedPage)
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
path := filepath.Join(dir, ManifestFileName)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// ReadManifest 读画廊清单,文件不存在返回 (nil, nil)
func ReadManifest(dir string) (*Manifest, error) {
path := filepath.Join(dir, ManifestFileName)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
if m.DonePages == nil {
m.DonePages = []int{}
}
if m.FailedPage == nil {
m.FailedPage = []int{}
}
return &m, nil
}
// ScanManifests 扫描下载根目录下所有画廊清单(跳过隐藏目录/非画廊目录)
func ScanManifests(root string) []*Manifest {
entries, err := os.ReadDir(root)
if err != nil {
return nil
}
var out []*Manifest
for _, e := range entries {
if !e.IsDir() {
continue
}
// 点开头的是系统/隐藏目录,跳过
if len(e.Name()) > 0 && e.Name()[0] == '.' {
continue
}
m, err := ReadManifest(filepath.Join(root, e.Name()))
if err != nil || m == nil {
continue
}
out = append(out, m)
}
return out
}
// ReconcileTask 拿清单里的信息补全任务(state.json 缺信息时的兜底)。
// 返回是否真的补进了新东西。
func ReconcileTask(t *Task, m *Manifest) bool {
if m == nil {
return false
}
changed := false
if t.Title == "" && m.Title != "" {
t.Title = m.Title
changed = true
}
if t.TotalPages == 0 && m.TotalPages > 0 {
t.TotalPages = m.TotalPages
changed = true
}
if t.Pages == nil {
t.Pages = map[string]string{}
}
// 清单说 done 的,补进任务(已存在的键不重复算改动)
for _, p := range m.DonePages {
k := strconv.Itoa(p)
if _, exists := t.Pages[k]; exists {
continue
}
t.Pages[k] = "done"
changed = true
}
return changed
}
func sortInts(s []int) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j] < s[j-1]; j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
}
func intSet(s []int) map[int]bool {
m := make(map[int]bool, len(s))
for _, x := range s {
m[x] = true
}
return m
}
func intsFromSet(m map[int]bool) []int {
out := make([]int, 0, len(m))
for k := range m {
out = append(out, k)
}
sortInts(out)
return out
}