Files
2026-10-11 12:56:38 +08:00

289 lines
7.1 KiB
Go
Raw Permalink 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
// 画廊目录清单(.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
}