原实现只能「多画廊并行,每个画廊串行下」,单张大画廊下得很慢。 按用户多年使用习惯(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 次请求
179 lines
4.1 KiB
Go
179 lines
4.1 KiB
Go
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
|
||
} |