画廊页要翻 ?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 增量。
285 lines
6.8 KiB
Go
285 lines
6.8 KiB
Go
package core
|
||
|
||
// 画廊目录清单(.ehdl.json)
|
||
//
|
||
// 为什么需要:断点续传的第一依据是「文件在不在」,但那有个盲区 ——
|
||
// 如果 state.json 丢了或被删,程序就不知道这个画廊「原本应该有多少张」,
|
||
// 也就不知道漏了哪些。
|
||
//
|
||
// 所以每个画廊目录下写一份清单,记录 gid/token/总页数/已完成页码。
|
||
// 哪怕状态文件没了,扫目录也能重建进度。
|
||
//
|
||
// 文件名固定为 .ehdl.json(点开头,Linux/macOS 下也默认隐藏)。
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"time"
|
||
|
||
"eh-downloader-wails/internal/eh"
|
||
)
|
||
|
||
// ManifestFileName 清单文件名
|
||
const ManifestFileName = ".ehdl.json"
|
||
|
||
// 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"`
|
||
|
||
// 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 写画廊清单(原子写)
|
||
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
|
||
}
|
||
|
||
// 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 []ManifestWithDir
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
continue
|
||
}
|
||
// 点开头的是系统/隐藏目录,跳过
|
||
if len(e.Name()) > 0 && e.Name()[0] == '.' {
|
||
continue
|
||
}
|
||
dir := filepath.Join(root, e.Name())
|
||
m, err := ReadManifest(dir)
|
||
if err != nil || m == nil {
|
||
continue
|
||
}
|
||
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 {
|
||
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
|
||
} |