Files
eh-downloader/internal/core/manifest.go
T
toor 0623c55407 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 增量。
2026-10-09 08:58:18 +08:00

285 lines
6.8 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"
"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
}