Files
hd4k-downloader/internal/core/store.go
T
2026-10-11 12:56:38 +08:00

578 lines
14 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
// 持久化:JSON 状态文件(原子写)
//
// 为什么不用 SQLite:断点续传的真正依据是「文件系统里文件在不在」,
// 状态文件只需记住画廊级别的进度。几百个任务的规模下 JSON 完全够用。
//
// 写文件用「临时文件 + rename」保证原子性。
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"time"
"hd4k-downloader/internal/hd4k"
)
// Site 站点配置(core 包里的镜像类型,避免前端到处引用 hd4k 包)
type Site = hd4k.Site
// Settings 用户设置
type Settings struct {
DownloadRoot string `json:"downloadRoot"`
Proxy string `json:"proxy"`
ProxyMode string `json:"proxyMode"` // off | http | socks5
// GalleryConcurrency 同时处理几个画廊
GalleryConcurrency int `json:"galleryConcurrency"`
// PageConcurrency 每个画廊内同时下几张图(总上限 = 两者相乘)
PageConcurrency int `json:"pageConcurrency"`
// RequestIntervalMs 全局请求间隔(毫秒,0 = 不限流)
RequestIntervalMs int `json:"requestIntervalMs"`
Timeout int `json:"timeout"` // 毫秒
BridgePort int `json:"bridgePort"`
DirTemplate string `json:"dirTemplate"`
// Sites 支持的站点域名列表。换域名时在界面里加一条即可,不用改代码。
Sites []Site `json:"sites"`
// 内部字段:是否已探测过代理(不暴露给前端)
Probed bool `json:"_probed"`
}
// FailedItem 单页失败记录
type FailedItem struct {
Page int `json:"page"`
File string `json:"file"`
Error string `json:"error"`
}
// Task 一个画廊的下载任务
//
// 唯一标识用 ID(域名 + 规范化路径),而不是 EH 那种 gid+token。
// 因为 4KHD 这类站点没有全局唯一 ID,URL 就是唯一标识;而且站点会换域名,
// 带上域名才能区分「同一个 slug 在不同域名上是两个画廊」。
type Task struct {
ID string `json:"id"`
URL string `json:"url"`
Host string `json:"host"`
Path string `json:"path"`
Slug string `json:"slug"`
Raw string `json:"raw"`
Title string `json:"title"`
Status string `json:"status"` // queued|running|downloading|partial|done|failed
Done int `json:"done"`
Failed int `json:"failed"`
// Total 图片总数(不是 HTML 分页数)
Total int `json:"total"`
Error string `json:"error"`
Dir string `json:"dir"`
Pages map[string]string `json:"pages"`
FailedList []FailedItem `json:"failedList"`
Selected bool `json:"selected"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
// State 状态文件顶层结构
type State struct {
Settings Settings `json:"settings"`
Tasks []Task `json:"tasks"`
}
// DefaultSettings 返回默认设置
func DefaultSettings() Settings {
return Settings{
DownloadRoot: "",
Proxy: "",
ProxyMode: "off",
GalleryConcurrency: 5,
PageConcurrency: 8,
RequestIntervalMs: 0,
Timeout: 30000,
BridgePort: 37778,
DirTemplate: "{title}",
Sites: hd4k.DefaultSites(),
}
}
// Store 是带锁的 JSON 状态存储,并发安全
type Store struct {
mu sync.RWMutex
path string
state State
flushTimer *time.Timer
dirty bool
}
// NewStore 打开(或新建)状态文件
func NewStore(filePath string) *Store {
s := &Store{path: filePath}
s.load()
return s
}
func (s *Store) load() {
s.mu.Lock()
defer s.mu.Unlock()
s.state = State{Settings: DefaultSettings(), Tasks: []Task{}}
raw, err := os.ReadFile(s.path)
if err != nil {
return
}
var loaded State
if json.Unmarshal(raw, &loaded) != nil {
return
}
// 补齐缺失字段(向后兼容 / 手工改坏配置时不至于崩)
def := DefaultSettings()
if loaded.Settings.DownloadRoot == "" {
loaded.Settings.DownloadRoot = def.DownloadRoot
}
if loaded.Settings.GalleryConcurrency <= 0 {
loaded.Settings.GalleryConcurrency = def.GalleryConcurrency
}
if loaded.Settings.PageConcurrency <= 0 {
loaded.Settings.PageConcurrency = def.PageConcurrency
}
if loaded.Settings.RequestIntervalMs < 0 {
loaded.Settings.RequestIntervalMs = 0
}
if loaded.Settings.Timeout <= 0 {
loaded.Settings.Timeout = def.Timeout
}
if loaded.Settings.BridgePort <= 0 {
loaded.Settings.BridgePort = def.BridgePort
}
if loaded.Settings.DirTemplate == "" {
loaded.Settings.DirTemplate = def.DirTemplate
}
if len(loaded.Settings.Sites) == 0 {
loaded.Settings.Sites = def.Sites
} else {
// 迁移:旧版 state.json 里存的站点没有 aliases 字段,
// 而别名归一(www.4khd.com → migmg.ssuu.uk)靠的就是它。
// 不补的话升级后老用户永远用不上别名 —— 实测同一个画廊会因为
// 「从哪个域名复制的链接」变成两个不同任务。
// 只补 aliases/scheme,用户的 label/enabled 保持不动。
loaded.Settings.Sites = mergeSiteAliases(loaded.Settings.Sites, def.Sites)
}
if loaded.Tasks == nil {
loaded.Tasks = []Task{}
}
s.state = loaded
}
// mergeSiteAliases 把默认站点里的 aliases/scheme 补进已持久化的站点列表。
//
// 老版本的 state.json 没有 aliases 字段。别名归一(www.4khd.com → migmg.ssuu.uk)
// 全靠它 —— 不补的话升级后同一个画廊会因为「从哪个域名复制的链接」变成两个任务。
// 用户自己的 label / enabled / scheme 一律保留,只在为空时才用默认值。
func mergeSiteAliases(have, def []Site) []Site {
byHost := map[string]int{}
for i, s := range have {
byHost[strings.ToLower(strings.TrimSpace(s.Host))] = i
}
for _, d := range def {
key := strings.ToLower(strings.TrimSpace(d.Host))
i, ok := byHost[key]
if !ok {
// 默认站点不在用户列表里 —— 用户可能删过,尊重用户的选择,不擅自加回
continue
}
if len(have[i].Aliases) == 0 && len(d.Aliases) > 0 {
have[i].Aliases = append([]string(nil), d.Aliases...)
}
if have[i].Scheme == "" && d.Scheme != "" {
have[i].Scheme = d.Scheme
}
if have[i].Label == "" {
have[i].Label = d.Label
}
}
return have
}
// Flush 原子写:先写临时文件再 rename
func (s *Store) Flush() {
s.mu.RLock()
data, err := json.MarshalIndent(s.state, "", " ")
path := s.path
s.dirty = false
s.mu.RUnlock()
if err != nil {
return
}
tmp := path + ".tmp"
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return
}
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return
}
_ = os.Rename(tmp, path)
}
// ScheduleFlush 合并短时间内的多次写,降低磁盘压力
func (s *Store) ScheduleFlush(delay int) {
if delay <= 0 {
delay = 400
}
s.mu.Lock()
defer s.mu.Unlock()
if s.flushTimer != nil {
return
}
s.dirty = true
s.flushTimer = time.AfterFunc(time.Duration(delay)*time.Millisecond, func() {
s.mu.Lock()
s.flushTimer = nil
s.mu.Unlock()
s.Flush()
})
}
// Stop 停掉待执行的延迟写(退出前调用)
func (s *Store) Stop() {
s.mu.Lock()
if s.flushTimer != nil {
s.flushTimer.Stop()
s.flushTimer = nil
}
s.mu.Unlock()
}
// Settings 取当前设置(返回副本)
func (s *Store) Settings() Settings {
s.mu.RLock()
defer s.mu.RUnlock()
out := s.state.Settings
out.Sites = append([]Site(nil), s.state.Settings.Sites...)
return out
}
// UpdateSettings 合并设置
func (s *Store) UpdateSettings(patch map[string]interface{}) Settings {
s.mu.Lock()
// 注意:这里不能用 defer Unlock。返回值必须在解锁**之后**取,
// 而 Go 的 RWMutex 不可重入 —— 持写锁时调 Settings() 会再 RLock
// 同一把锁,永久死锁。症状是「点保存设置后整个程序卡死且不报错」。
// 所以改成手动解锁。
// 站点列表是结构体,切出来单独处理,避免走 map[string]interface{}
// 那条路时被 JSON 解成 []any 导致反序列化失败
if rawSites, ok := patch["sites"]; ok {
delete(patch, "sites")
if b, err := json.Marshal(rawSites); err == nil {
var sites []Site
if json.Unmarshal(b, &sites) == nil {
s.state.Settings.Sites = sites
}
}
}
m, _ := json.Marshal(s.state.Settings)
var cur map[string]interface{}
_ = json.Unmarshal(m, &cur)
for k, v := range patch {
cur[k] = v
}
m2, _ := json.Marshal(cur)
var next Settings
if json.Unmarshal(m2, &next) == nil {
if len(next.Sites) == 0 {
next.Sites = s.state.Settings.Sites
}
s.state.Settings = next
}
s.startFlushLocked()
// 取返回值:拷一份 Sites 切片,别把内部切片漏给调用方
// (调用方改它就等于绕过锁直接改内部状态)。
out := s.state.Settings
out.Sites = append([]Site(nil), s.state.Settings.Sites...)
s.mu.Unlock()
return out
}
func (s *Store) startFlushLocked() {
if s.flushTimer != nil {
return
}
s.dirty = true
s.flushTimer = time.AfterFunc(400*time.Millisecond, func() {
s.mu.Lock()
s.flushTimer = nil
s.mu.Unlock()
s.Flush()
})
}
// Tasks 取全部任务(深拷贝,防止调用方改到内部状态)
func (s *Store) Tasks() []Task {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Task, 0, len(s.state.Tasks))
for _, t := range s.state.Tasks {
out = append(out, t.clone())
}
return out
}
func (t Task) clone() Task {
c := t
if t.Pages != nil {
c.Pages = make(map[string]string, len(t.Pages))
for k, v := range t.Pages {
c.Pages[k] = v
}
}
if t.FailedList != nil {
c.FailedList = append([]FailedItem(nil), t.FailedList...)
}
return c
}
// FindTask 按 ID 找任务
func (s *Store) FindTask(id string) *Task {
s.mu.RLock()
defer s.mu.RUnlock()
for i := range s.state.Tasks {
if s.state.Tasks[i].ID == id {
c := s.state.Tasks[i].clone()
return &c
}
}
return nil
}
// AddTasks 批量加任务(已存在的跳过),返回新增数
func (s *Store) AddTasks(refs []hd4k.GalleryRef) int {
s.mu.Lock()
defer s.mu.Unlock()
added := 0
now := time.Now().UnixMilli()
for _, r := range refs {
if r.ID == "" {
continue
}
exists := false
for _, t := range s.state.Tasks {
if t.ID == r.ID {
exists = true
break
}
}
if exists {
continue
}
s.state.Tasks = append([]Task{{
ID: r.ID,
URL: r.URL,
Host: r.Host,
Path: r.Path,
Slug: r.Slug,
Raw: r.Raw,
Title: r.Title,
Status: "queued",
Pages: map[string]string{},
FailedList: []FailedItem{},
CreatedAt: now,
UpdatedAt: now,
}}, s.state.Tasks...)
added++
}
s.startFlushLocked()
return added
}
// SetTitle 给已有任务补标题(油猴推送时带标题来的)
func (s *Store) SetTitle(id, title string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.state.Tasks {
if s.state.Tasks[i].ID == id {
if s.state.Tasks[i].Title == "" && title != "" {
s.state.Tasks[i].Title = truncate(title, 300)
s.startFlushLocked()
return true
}
return false
}
}
return false
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}
// UpsertTask 更新/插入任务。
//
// ⚠️ 存进去前必须 clone() —— 否则外部(图片级 worker)继续改同一个 map,
// 而 store 的 Flush() 会在后台 json.Marshal 整个 state —— 撞上就是
// fatal error: concurrent map write(不可 recover,进程闪退)。
func (s *Store) UpsertTask(t Task) Task {
s.mu.Lock()
defer s.mu.Unlock()
t.UpdatedAt = time.Now().UnixMilli()
if t.Pages == nil {
t.Pages = map[string]string{}
}
if t.FailedList == nil {
t.FailedList = []FailedItem{}
}
for i := range s.state.Tasks {
if s.state.Tasks[i].ID == t.ID {
if t.CreatedAt == 0 {
t.CreatedAt = s.state.Tasks[i].CreatedAt
}
s.state.Tasks[i] = t.clone()
s.startFlushLocked()
return t.clone()
}
}
if t.CreatedAt == 0 {
t.CreatedAt = t.UpdatedAt
}
stored := t.clone()
s.state.Tasks = append([]Task{stored}, s.state.Tasks...)
s.startFlushLocked()
return stored.clone()
}
// RemoveTask 删单个任务
func (s *Store) RemoveTask(id string) {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.state.Tasks {
if s.state.Tasks[i].ID == id {
s.state.Tasks = append(s.state.Tasks[:i], s.state.Tasks[i+1:]...)
s.startFlushLocked()
return
}
}
}
// ClearAll 清空任务列表(或只删勾选的)。
// ⚠️ 只清任务列表,本地已下载的文件一个都不动。
func (s *Store) ClearAll(onlySelected bool) int {
s.mu.Lock()
defer s.mu.Unlock()
before := len(s.state.Tasks)
kept := make([]Task, 0, before)
for _, t := range s.state.Tasks {
if onlySelected && !t.Selected {
kept = append(kept, t)
}
}
s.state.Tasks = kept
s.startFlushLocked()
return before - len(s.state.Tasks)
}
// ClearFinished 只清 done。partial/failed 保留。
func (s *Store) ClearFinished() int {
s.mu.Lock()
defer s.mu.Unlock()
before := len(s.state.Tasks)
kept := make([]Task, 0, before)
for _, t := range s.state.Tasks {
if t.Status != "done" {
kept = append(kept, t)
}
}
s.state.Tasks = kept
s.startFlushLocked()
return before - len(s.state.Tasks)
}
// CountDone 数一下已完成
func (s *Store) CountDone() int {
s.mu.RLock()
defer s.mu.RUnlock()
n := 0
for _, t := range s.state.Tasks {
if t.Status == "done" {
n++
}
}
return n
}
// RequeueFailed 把失败/部分失败/中断的重新排队
func (s *Store) RequeueFailed() int {
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for i := range s.state.Tasks {
switch s.state.Tasks[i].Status {
case "failed", "partial", "downloading", "running", "parsing":
s.state.Tasks[i].Status = "queued"
s.state.Tasks[i].Error = ""
n++
}
}
if n > 0 {
s.startFlushLocked()
}
return n
}
// RecoverOnStart 启动时把残留的 running/downloading/parsing 归位
func (s *Store) RecoverOnStart() int {
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for i := range s.state.Tasks {
switch s.state.Tasks[i].Status {
case "running", "downloading", "parsing":
s.state.Tasks[i].Status = "queued"
n++
}
}
if n > 0 {
s.startFlushLocked()
}
return n
}
// SelectAll 标记勾选状态
func (s *Store) SelectAll(ids []string) []Task {
set := make(map[string]bool, len(ids))
for _, g := range ids {
set[g] = true
}
s.mu.Lock()
for i := range s.state.Tasks {
s.state.Tasks[i].Selected = set[s.state.Tasks[i].ID]
}
s.startFlushLocked()
out := make([]Task, 0, len(s.state.Tasks))
for _, t := range s.state.Tasks {
out = append(out, t.clone())
}
s.mu.Unlock()
return out
}