package core // 画廊目录清单(.hd4k.json) // // 为什么需要:断点续传的第一依据是「文件在不在」,但那有个盲区 —— // 如果 state.json 丢了或被删,程序就不知道这个画廊「原本应该有多少张」, // 也就不知道漏了哪些。所以每个画廊目录下写一份清单。 // // ── 与参考项目(EH)的关键差异 ────────────────────────────────── // // EH 的清单存的是 /s/ 路径(不是 hath 直链)—— 直链带随机子域 + 随机端口 + // 时间戳签名,每次都变还会过期,存了也用不了。 // // 4KHD 不用纠结这个:图片直链是 Jetpack Photon 的哈希路径,**完全稳定**, // 存下来随时能用。所以清单里直接存直链,重开程序时连分页都不用翻, // 0 次请求就能开始下载。 // // 文件名固定为 .hd4k.json(点开头,Linux/macOS 下也默认隐藏)。 import ( "encoding/json" "os" "path/filepath" "sort" "strconv" "time" "hd4k-downloader/internal/hd4k" ) // ManifestFileName 清单文件名 const ManifestFileName = ".hd4k.json" // ManifestVersion 清单格式版本 // // v2:页码 -> 图片信息(含稳定的图片直链)。 // 重开程序时直接下载,不必重新翻分页(省掉 N 次请求)。 const ManifestVersion = 2 // ManifestPage 单张图片的信息 type ManifestPage struct { Page int `json:"page"` File string `json:"file"` URL string `json:"url"` // 图片直链(稳定) Finished bool `json:"finished"` Error string `json:"error,omitempty"` } // Manifest 单个画廊的下载清单 type Manifest struct { Version int `json:"version"` ID string `json:"id"` // host + 规范化路径,全局唯一 URL string `json:"url"` // 第 1 页地址 Host string `json:"host"` // 所属站点域名 Path string `json:"path"` Slug string `json:"slug"` Title string `json:"title"` TotalPages int `json:"totalPages"` // HTML 分页数 Total int `json:"total"` // 图片总数 DonePages []int `json:"donePages"` FailedPage []int `json:"failedPages"` // 页码 -> 图片信息(含稳定直链) PageMap map[string]ManifestPage `json:"pageMap,omitempty"` // 画廊元信息。目录模板依赖它,不存的话从清单重建时目录名会退化。 Meta *hd4k.GalleryMeta `json:"meta,omitempty"` CreatedAt int64 `json:"createdAt"` UpdatedAt int64 `json:"updatedAt"` Tool string `json:"tool"` Note string `json:"note,omitempty"` } // HasPageMap 该清单是否包含图片直链 func (m *Manifest) HasPageMap() bool { return m != nil && m.Version >= 2 && len(m.PageMap) > 0 } // GalleryFromManifest 用清单重建一个 Gallery(跳过所有网络请求)。 // 信息不完整时返回 ok=false,调用方退回重新解析。 func (m *Manifest) GalleryFromManifest() (*hd4k.Gallery, bool) { if !m.HasPageMap() { return nil, false } imgs := make([]hd4k.ImageInfo, 0, len(m.PageMap)) for _, mp := range m.PageMap { if mp.URL == "" { return nil, false // 信息不全,宁可重新解析 } imgs = append(imgs, hd4k.ImageInfo{ Index: mp.Page, URL: mp.URL, Filename: mp.File, }) } if len(imgs) == 0 { return nil, false } sort.Slice(imgs, func(i, j int) bool { return imgs[i].Index < imgs[j].Index }) total := m.Total if total < len(imgs) { total = len(imgs) } meta := hd4k.GalleryMeta{} if m.Meta != nil { meta = *m.Meta } return &hd4k.Gallery{ ID: m.ID, URL: m.URL, Host: m.Host, Path: m.Path, Slug: m.Slug, Title: m.Title, TotalPages: m.TotalPages, Total: total, Images: imgs, 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 } // FindManifestByID 按画廊 ID 找清单 func FindManifestByID(root, id string) *ManifestWithDir { for _, md := range ScanManifestsWithDir(root) { if md.Manifest.ID == id { 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.Total == 0 && m.Total > 0 { t.Total = m.Total changed = true } if t.Pages == nil { t.Pages = map[string]string{} } 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 }