需求两条:
1) 列表页的复选框改成按钮,点一下直接推送到工具
2) 推送过但没点进去的画廊,标题也要变灰(像浏览过的),
这样刷新后还认得出哪些推过
一、行内推送按钮
复选框 + 底部「批量推送」两步走太啰嗦,列表页每行改成「📤 推送」
胶囊按钮,点一下直接调 /add。按钮三态:
📤 推送 -> ⏳ 推送中(禁用) -> ✓ 已推送(变绿)
底部悬浮条保留,改为「全部推送」+「重置状态」+ 本页计数。
二、标灰::visited 伪造不了,只能自己记
E-Hentai 的灰色来自自带样式
a:visited .glink, a:active .glink { color:#8F6063 }
但 :visited 是浏览器**受保护**伪类:脚本查 matches(':visited')
永远返回 false,加 class 也触发不了站点的原生规则。
所以改成自己记一份「推送过」的清单,再照抄站点的色值手动染色,
观感与原生 :visited 一致(hover 恢复站点的 #8F4701)。
三、清单存哪:工具端为主,浏览器端为辅
新增 internal/core/pushed.go —— PushedStore 独立于 Store,
单独存 %APPDATA%\EH Downloader\pushed.json。
为什么单独一个文件:state.json 里的 tasks 是「当前要下载的东西」,
用户点「清除已完成」就删掉了;但「这个画廊我推送过」是历史事实,
删了任务不该忘掉。所以推送历史与任务列表解耦。
新增桥口 GET /pushed 返回推送历史(与 GET /tasks 语义不同:
后者是当前任务列表,会被清除)。
油猴脚本侧:
- localStorage(ehdl_pushed_gids)先渲染,刷新后立刻可见,
工具不在线也能用
- 工具在线时用 /pushed 校正一次(localStorage 可能被清,
工具才是权威),以工具为准整体覆盖
- GET /tasks 保留给需要看当前任务列表的场合
四、附带:state.json 事故的恢复工具
tools/recoverstate —— 修复被写坏的 state.json:
1) token 先从下载目录的 .ehdl.json 清单里找
2) 找不到的用 E-Hentai 的 ?next=<gid> 定位法去站点抓
(该 URL 会返回以 gid 开头的 25 条列表,正则取 token,
offset 0..5 轮着试)
3) 抓不到的说明画廊在站点上已被删除
-dry 预演不写盘,写盘前先备份原文件。
配套回归测试:
- internal/core/pushed_test.go 推送历史读写、任务删除后仍保留
- internal/core/recover_e2e_test.go 恢复出的任务能真下载(71/71 页)
- internal/eh/recover_verify_test.go 恢复出的 token 能解析画廊
- app_test.go 点「清除已完成」后灰标不消失
- internal/core/bridge_test.go /tasks 与 /pushed 的 token 校验
后两者需要能连 e-hentai.org,设 EH_TEST_PROXY 才跑。
518 lines
12 KiB
Go
518 lines
12 KiB
Go
package core
|
|
|
|
// 持久化:JSON 状态文件(原子写)
|
|
//
|
|
// 为什么不用 SQLite:断点续传的真正依据是「文件系统里文件在不在」,
|
|
// 状态文件只需记住画廊级别的进度。几百个任务的规模下 JSON 完全够用。
|
|
//
|
|
// 写文件用「临时文件 + rename」保证原子性。
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"eh-downloader-wails/internal/eh"
|
|
)
|
|
|
|
// 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"` // 毫秒
|
|
Xres string `json:"xres"` // auto | org | 数字(长边上限)
|
|
BridgePort int `json:"bridgePort"`
|
|
DirTemplate string `json:"dirTemplate"`
|
|
|
|
// 内部字段:是否已探测过代理(不暴露给前端)
|
|
Probed bool `json:"_probed"`
|
|
}
|
|
|
|
// FailedItem 单页失败记录
|
|
type FailedItem struct {
|
|
Page int `json:"page"`
|
|
File string `json:"file"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
// Task 一个画廊的下载任务
|
|
type Task struct {
|
|
GID string `json:"gid"`
|
|
Token string `json:"token"`
|
|
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"`
|
|
TotalPages int `json:"totalPages"`
|
|
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,
|
|
Xres: "auto",
|
|
BridgePort: 37777,
|
|
DirTemplate: "{title}",
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
var loaded State
|
|
if json.Unmarshal(raw, &loaded) == nil {
|
|
// 补齐缺失字段(向后兼容)
|
|
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 loaded.Settings.Xres == "" {
|
|
loaded.Settings.Xres = def.Xres
|
|
}
|
|
if loaded.Tasks == nil {
|
|
loaded.Tasks = []Task{}
|
|
}
|
|
s.state = loaded
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
return s.state.Settings
|
|
}
|
|
|
|
// UpdateSettings 合并设置
|
|
func (s *Store) UpdateSettings(patch map[string]interface{}) Settings {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
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 {
|
|
s.state.Settings = next
|
|
}
|
|
s.startFlushLocked()
|
|
return s.state.Settings
|
|
}
|
|
|
|
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 找任务
|
|
func (s *Store) FindTask(gid, token string) *Task {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for i := range s.state.Tasks {
|
|
if s.state.Tasks[i].GID == gid && s.state.Tasks[i].Token == token {
|
|
c := s.state.Tasks[i].clone()
|
|
return &c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindTaskByGID 只按 gid 找(清单恢复用 —— token 可能对不上)
|
|
func (s *Store) FindTaskByGID(gid string) *Task {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for i := range s.state.Tasks {
|
|
if s.state.Tasks[i].GID == gid {
|
|
c := s.state.Tasks[i].clone()
|
|
return &c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddTasks 批量加任务(已存在的跳过),返回新增数
|
|
func (s *Store) AddTasks(refs []eh.GalleryRef) int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
added := 0
|
|
now := time.Now().UnixMilli()
|
|
for _, r := range refs {
|
|
if r.GID == "" || r.Token == "" {
|
|
continue
|
|
}
|
|
exists := false
|
|
for _, t := range s.state.Tasks {
|
|
if t.GID == r.GID && t.Token == r.Token {
|
|
exists = true
|
|
break
|
|
}
|
|
}
|
|
if exists {
|
|
continue
|
|
}
|
|
s.state.Tasks = append([]Task{{
|
|
GID: r.GID,
|
|
Token: r.Token,
|
|
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(gid, token, title string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.state.Tasks {
|
|
if s.state.Tasks[i].GID == gid && s.state.Tasks[i].Token == token {
|
|
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].GID == t.GID && s.state.Tasks[i].Token == t.Token {
|
|
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(gid, token string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.state.Tasks {
|
|
if s.state.Tasks[i].GID == gid && s.state.Tasks[i].Token == token {
|
|
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 {
|
|
n, _ := s.ClearFinishedWithGIDs()
|
|
return n
|
|
}
|
|
|
|
// ClearFinishedWithGIDs 只清 done,并返回被清掉的 gid 列表。
|
|
// 用途:清掉任务后,把「这些画廊推送过、而且下载完了」记进推送历史,
|
|
// 这样油猴脚本标灰时能区分「推过但没下完」和「推过而且已完成」。
|
|
func (s *Store) ClearFinishedWithGIDs() (int, []string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
before := len(s.state.Tasks)
|
|
kept := make([]Task, 0, before)
|
|
var gids []string
|
|
for _, t := range s.state.Tasks {
|
|
if t.Status != "done" {
|
|
kept = append(kept, t)
|
|
continue
|
|
}
|
|
gids = append(gids, t.GID)
|
|
}
|
|
s.state.Tasks = kept
|
|
s.startFlushLocked()
|
|
return before - len(s.state.Tasks), gids
|
|
}
|
|
|
|
// 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":
|
|
s.state.Tasks[i].Status = "queued"
|
|
s.state.Tasks[i].Error = ""
|
|
n++
|
|
}
|
|
}
|
|
if n > 0 {
|
|
s.startFlushLocked()
|
|
}
|
|
return n
|
|
}
|
|
|
|
// RecoverOnStart 启动时把残留的 running/downloading 归位
|
|
func (s *Store) RecoverOnStart() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
n := 0
|
|
for i := range s.state.Tasks {
|
|
if s.state.Tasks[i].Status == "running" || s.state.Tasks[i].Status == "downloading" {
|
|
s.state.Tasks[i].Status = "queued"
|
|
n++
|
|
}
|
|
}
|
|
if n > 0 {
|
|
s.startFlushLocked()
|
|
}
|
|
return n
|
|
}
|
|
|
|
// SelectAll 标记勾选状态
|
|
func (s *Store) SelectAll(gids []string) []Task {
|
|
set := make(map[string]bool, len(gids))
|
|
for _, g := range gids {
|
|
set[g] = true
|
|
}
|
|
s.mu.Lock()
|
|
for i := range s.state.Tasks {
|
|
s.state.Tasks[i].Selected = set[s.state.Tasks[i].GID]
|
|
}
|
|
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
|
|
}
|