578 lines
14 KiB
Go
578 lines
14 KiB
Go
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
|
||
}
|