first commit

This commit is contained in:
2026-10-11 12:56:38 +08:00
commit 7d21f317a4
41 changed files with 10758 additions and 0 deletions
+288
View File
@@ -0,0 +1,288 @@
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
}