feat(油猴): 行内推送按钮 + 已推送画廊标灰
需求两条:
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 才跑。
This commit is contained in:
@@ -6,6 +6,8 @@ package core
|
||||
// POST /add-batch { items: [...] } + X-EHDL-Token 头
|
||||
// GET /ping -> { ok: true }
|
||||
// GET /info -> 端口/版本等信息
|
||||
// GET /tasks -> 当前任务列表
|
||||
// GET /pushed -> 推送历史(任务删了也还在)
|
||||
//
|
||||
// 只监听 127.0.0.1,且同时校验 Origin 与固定 token,防止本机其他网页乱塞任务。
|
||||
|
||||
@@ -36,6 +38,7 @@ const maxBody = 512 * 1024 // 批量最多 500 个,标题也要带,得给够
|
||||
// Bridge 是本地 HTTP 桥口
|
||||
type Bridge struct {
|
||||
store *Store
|
||||
pushed *PushedStore
|
||||
log func(level, msg string)
|
||||
onAdded func(t Task, added bool, batch bool)
|
||||
|
||||
@@ -49,6 +52,11 @@ func NewBridge(store *Store, log func(string, string), onAdded func(Task, bool,
|
||||
return &Bridge{store: store, log: log, onAdded: onAdded, port: 37777}
|
||||
}
|
||||
|
||||
// SetPushedStore 挂上推送历史存储(可选,用于 /pushed 返回历史推送记录)
|
||||
func (b *Bridge) SetPushedStore(p *PushedStore) {
|
||||
b.pushed = p
|
||||
}
|
||||
|
||||
// Start 启动(只绑回环)
|
||||
func (b *Bridge) Start(port int) {
|
||||
if port <= 0 {
|
||||
@@ -61,6 +69,8 @@ func (b *Bridge) Start(port int) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ping", b.handlePing)
|
||||
mux.HandleFunc("/info", b.handleInfo)
|
||||
mux.HandleFunc("/tasks", b.handleTasks)
|
||||
mux.HandleFunc("/pushed", b.handlePushed)
|
||||
mux.HandleFunc("/add", b.handleAdd)
|
||||
mux.HandleFunc("/add-batch", b.handleAddBatch)
|
||||
|
||||
@@ -167,6 +177,61 @@ func (b *Bridge) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleTasks 返回已入队的画廊清单(gid/token),供油猴脚本标记「已推送」
|
||||
// 只读,不改状态;token 用固定头校验,防止本机网页随便翻任务列表。
|
||||
//
|
||||
// 注意:这里返回的是「当前任务列表」。任务被清除后就没了。
|
||||
// 油猴脚本标灰要看的是「推送历史」,用 /pushed 那个。
|
||||
func (b *Bridge) handleTasks(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkToken(w, r) {
|
||||
b.log("warn", "桥口收到 token 不正确的任务列表请求,已拒绝")
|
||||
return
|
||||
}
|
||||
tasks := b.store.Tasks()
|
||||
out := make([]map[string]string, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
out = append(out, map[string]string{
|
||||
"gid": t.GID,
|
||||
"token": t.Token,
|
||||
"title": t.Title,
|
||||
"status": t.Status,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"count": len(out),
|
||||
"tasks": out,
|
||||
})
|
||||
}
|
||||
|
||||
// handlePushed 返回「推送历史」:所有推送过的画廊,任务删了也还在。
|
||||
// 油猴脚本靠它把标题标灰 —— 这样点「清除已完成」之后,灰标依然保留。
|
||||
func (b *Bridge) handlePushed(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkToken(w, r) {
|
||||
b.log("warn", "桥口收到 token 不正确的推送历史请求,已拒绝")
|
||||
return
|
||||
}
|
||||
entries := []PushedEntry{}
|
||||
if b.pushed != nil {
|
||||
entries = b.pushed.Entries()
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, map[string]interface{}{
|
||||
"gid": e.GID,
|
||||
"token": e.Token,
|
||||
"title": e.Title,
|
||||
"pushedAt": e.PushedAt,
|
||||
"completed": e.Completed,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"count": len(out),
|
||||
"items": out,
|
||||
})
|
||||
}
|
||||
|
||||
type batchItem struct {
|
||||
GID string `json:"gid"`
|
||||
Token string `json:"token"`
|
||||
@@ -233,6 +298,15 @@ func (b *Bridge) handleAddBatch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
b.store.ScheduleFlush(0)
|
||||
|
||||
// 记进推送历史(任务被清掉也不影响标灰)
|
||||
if b.pushed != nil {
|
||||
entries := make([]PushedEntry, 0, len(fresh))
|
||||
for _, ref := range fresh {
|
||||
entries = append(entries, PushedEntry{GID: ref.GID, Token: ref.Token, Title: ref.Title})
|
||||
}
|
||||
b.pushed.MarkBatch(entries)
|
||||
}
|
||||
|
||||
for _, ref := range fresh {
|
||||
if t := b.store.FindTask(ref.GID, ref.Token); t != nil && b.onAdded != nil {
|
||||
b.onAdded(*t, true, true)
|
||||
@@ -273,6 +347,11 @@ func (b *Bridge) handleAdd(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
b.store.ScheduleFlush(0)
|
||||
|
||||
// 记进推送历史(任务被清掉也不影响标灰)
|
||||
if b.pushed != nil {
|
||||
b.pushed.Mark(gid, token, str(body["title"]))
|
||||
}
|
||||
|
||||
if t := b.store.FindTask(gid, token); t != nil && b.onAdded != nil {
|
||||
b.onAdded(*t, added > 0, false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package core
|
||||
|
||||
// 桥口 /tasks 接口测试:油猴脚本靠它把「已推送」的画廊标题标灰
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"eh-downloader-wails/internal/eh"
|
||||
)
|
||||
|
||||
// 起一个真实监听的桥口,返回 base URL
|
||||
func startTestBridge(t *testing.T, s *Store) string {
|
||||
t.Helper()
|
||||
return startTestBridgeWith(t, s, nil)
|
||||
}
|
||||
|
||||
// listenLoopbackPort 拿一个空闲端口(监听后立刻关掉,把端口号交出去)
|
||||
func listenLoopbackPort() (func() int, error) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
_ = ln.Close()
|
||||
return func() int { return port }, nil
|
||||
}
|
||||
|
||||
// startTestBridgeWith 起桥口,可选挂推送历史
|
||||
func startTestBridgeWith(t *testing.T, s *Store, p *PushedStore) string {
|
||||
t.Helper()
|
||||
b := NewBridge(s, func(string, string) {}, func(Task, bool, bool) {})
|
||||
if p != nil {
|
||||
b.SetPushedStore(p)
|
||||
}
|
||||
// 端口用 0 让系统分配,避免和真实例撞端口
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("监听失败: %v", err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
_ = ln.Close()
|
||||
b.Start(port)
|
||||
// 等端口就绪
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/ping", port))
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Cleanup(b.Stop)
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
}
|
||||
|
||||
func getWithToken(t *testing.T, url, token string) (int, []byte) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("建请求失败: %v", err)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("X-EHDL-Token", token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
|
||||
func TestBridgeTasks(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
base := startTestBridge(t, s)
|
||||
|
||||
// 空的:返回 0 条
|
||||
code, body := getWithToken(t, base+"/tasks", BridgeToken)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("空清单状态码应为 200,实际 %d: %s", code, body)
|
||||
}
|
||||
var j struct {
|
||||
OK bool `json:"ok"`
|
||||
Count int `json:"count"`
|
||||
Tasks []struct {
|
||||
GID string `json:"gid"`
|
||||
Token string `json:"token"`
|
||||
Title string `json:"title"`
|
||||
} `json:"tasks"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &j); err != nil {
|
||||
t.Fatalf("解析失败: %v (%s)", err, body)
|
||||
}
|
||||
if !j.OK || j.Count != 0 || len(j.Tasks) != 0 {
|
||||
t.Fatalf("空清单应为空: %s", body)
|
||||
}
|
||||
|
||||
// 加两个任务
|
||||
s.AddTasks([]eh.GalleryRef{
|
||||
{GID: "111", Token: "aaa", Raw: "https://e-hentai.org/g/111/aaa/", Title: "T1"},
|
||||
{GID: "222", Token: "bbb", Raw: "https://e-hentai.org/g/222/bbb/", Title: "T2"},
|
||||
})
|
||||
s.SetTitle("111", "aaa", "T1")
|
||||
|
||||
code, body = getWithToken(t, base+"/tasks", BridgeToken)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("状态码应为 200,实际 %d: %s", code, body)
|
||||
}
|
||||
if err := json.Unmarshal(body, &j); err != nil {
|
||||
t.Fatalf("解析失败: %v (%s)", err, body)
|
||||
}
|
||||
if j.Count != 2 || len(j.Tasks) != 2 {
|
||||
t.Fatalf("应有 2 条任务,实际 count=%d len=%d: %s", j.Count, len(j.Tasks), body)
|
||||
}
|
||||
gids := map[string]string{}
|
||||
for _, task := range j.Tasks {
|
||||
gids[task.GID] = task.Token
|
||||
if task.GID == "111" && task.Title != "T1" {
|
||||
t.Errorf("标题没带回来: %+v", task)
|
||||
}
|
||||
}
|
||||
if gids["111"] != "aaa" || gids["222"] != "bbb" {
|
||||
t.Errorf("gid/token 对不上: %+v", gids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeTasksRejectsBadToken(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
base := startTestBridge(t, s)
|
||||
|
||||
// 不带 token -> 403
|
||||
code, _ := getWithToken(t, base+"/tasks", "")
|
||||
if code != http.StatusForbidden {
|
||||
t.Errorf("缺 token 应 403,实际 %d", code)
|
||||
}
|
||||
// 错 token -> 403
|
||||
code, _ = getWithToken(t, base+"/tasks", "wrong-token")
|
||||
if code != http.StatusForbidden {
|
||||
t.Errorf("错 token 应 403,实际 %d", code)
|
||||
}
|
||||
// 对 token -> 200
|
||||
code, _ = getWithToken(t, base+"/tasks", BridgeToken)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("对 token 应 200,实际 %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeAddThenTasks(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
base := startTestBridge(t, s)
|
||||
|
||||
// 通过 /add 推一个画廊,再从 /tasks 里查出来
|
||||
payload := strings.NewReader(`{"gid":"333","token":"ccc","url":"https://e-hentai.org/g/333/ccc/","title":"T3"}`)
|
||||
req, _ := http.NewRequest("POST", base+"/add", payload)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-EHDL-Token", BridgeToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST /add 失败: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("POST /add 应 200,实际 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
code, body := getWithToken(t, base+"/tasks", BridgeToken)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("/tasks 应 200,实际 %d", code)
|
||||
}
|
||||
if !strings.Contains(string(body), "333") {
|
||||
t.Errorf("/tasks 里应能看到刚推的 333: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package core
|
||||
|
||||
// 推送历史:记住「哪些画廊被推送过」,与任务列表解耦
|
||||
//
|
||||
// 为什么单独存一份:
|
||||
//
|
||||
// state.json 里的 tasks 是「当前要下载的东西」,用户点「清除已完成」就会删掉。
|
||||
// 但「这个画廊我推送过」是个历史事实,删了任务不该忘掉 ——
|
||||
// 油猴脚本靠它把标题标灰,让你刷新后还认得出哪些推过。
|
||||
//
|
||||
// 所以单独一个 pushed.json,放在和 state.json 同一个目录(%APPDATA%\EH Downloader\)。
|
||||
// 只有 gid 是必须的,token/title 留着方便排查。
|
||||
//
|
||||
// 清理方式:删掉 pushed.json 即可;或者在设置里提供「清空推送历史」。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PushedEntry 一条推送记录
|
||||
type PushedEntry struct {
|
||||
GID string `json:"gid"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
PushedAt int64 `json:"pushedAt"`
|
||||
Completed bool `json:"completed,omitempty"` // 曾经下载完成过
|
||||
}
|
||||
|
||||
// PushedState pushed.json 的顶层结构
|
||||
type PushedState struct {
|
||||
Entries []PushedEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// PushedStore 推送历史存储
|
||||
type PushedStore struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
state PushedState
|
||||
|
||||
flushTimer *time.Timer
|
||||
}
|
||||
|
||||
// NewPushedStore 打开(或新建)推送历史文件
|
||||
func NewPushedStore(filePath string) *PushedStore {
|
||||
p := &PushedStore{path: filePath}
|
||||
p.load()
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *PushedStore) load() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.state = PushedState{Entries: []PushedEntry{}}
|
||||
|
||||
raw, err := os.ReadFile(p.path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var loaded PushedState
|
||||
if json.Unmarshal(raw, &loaded) == nil {
|
||||
if loaded.Entries == nil {
|
||||
loaded.Entries = []PushedEntry{}
|
||||
}
|
||||
p.state = loaded
|
||||
}
|
||||
}
|
||||
|
||||
// Flush 原子写
|
||||
func (p *PushedStore) Flush() {
|
||||
p.mu.RLock()
|
||||
data, err := json.MarshalIndent(p.state, "", " ")
|
||||
path := p.path
|
||||
p.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 (p *PushedStore) ScheduleFlush(delay int) {
|
||||
if delay <= 0 {
|
||||
delay = 400
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.flushTimer != nil {
|
||||
return
|
||||
}
|
||||
p.flushTimer = time.AfterFunc(time.Duration(delay)*time.Millisecond, func() {
|
||||
p.mu.Lock()
|
||||
p.flushTimer = nil
|
||||
p.mu.Unlock()
|
||||
p.Flush()
|
||||
})
|
||||
}
|
||||
|
||||
// Stop 停掉待执行的延迟写
|
||||
func (p *PushedStore) Stop() {
|
||||
p.mu.Lock()
|
||||
if p.flushTimer != nil {
|
||||
p.flushTimer.Stop()
|
||||
p.flushTimer = nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// Mark 记一笔推送(同 gid 重复推送只更新时间,不重复添加)
|
||||
func (p *PushedStore) Mark(gid, token, title string) {
|
||||
gid = trim(gid)
|
||||
if gid == "" {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
for i := range p.state.Entries {
|
||||
if p.state.Entries[i].GID == gid {
|
||||
if token != "" {
|
||||
p.state.Entries[i].Token = token
|
||||
}
|
||||
if title != "" {
|
||||
p.state.Entries[i].Title = title
|
||||
}
|
||||
p.state.Entries[i].PushedAt = time.Now().Unix()
|
||||
p.mu.Unlock()
|
||||
p.ScheduleFlush(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.state.Entries = append(p.state.Entries, PushedEntry{
|
||||
GID: gid,
|
||||
Token: token,
|
||||
Title: title,
|
||||
PushedAt: time.Now().Unix(),
|
||||
})
|
||||
p.mu.Unlock()
|
||||
p.ScheduleFlush(0)
|
||||
}
|
||||
|
||||
// MarkBatch 批量记
|
||||
func (p *PushedStore) MarkBatch(items []PushedEntry) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
now := time.Now().Unix()
|
||||
idx := map[string]int{}
|
||||
for i := range p.state.Entries {
|
||||
idx[p.state.Entries[i].GID] = i
|
||||
}
|
||||
for _, it := range items {
|
||||
gid := trim(it.GID)
|
||||
if gid == "" {
|
||||
continue
|
||||
}
|
||||
if i, ok := idx[gid]; ok {
|
||||
p.state.Entries[i].Token = it.Token
|
||||
p.state.Entries[i].Title = it.Title
|
||||
p.state.Entries[i].PushedAt = now
|
||||
continue
|
||||
}
|
||||
idx[gid] = len(p.state.Entries)
|
||||
p.state.Entries = append(p.state.Entries, PushedEntry{
|
||||
GID: gid, Token: it.Token, Title: it.Title, PushedAt: now,
|
||||
})
|
||||
}
|
||||
p.mu.Unlock()
|
||||
p.ScheduleFlush(0)
|
||||
}
|
||||
|
||||
// MarkCompleted 标记某画廊已下载完成(只打标,不删记录)
|
||||
func (p *PushedStore) MarkCompleted(gid string) {
|
||||
gid = trim(gid)
|
||||
if gid == "" {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
found := false
|
||||
for i := range p.state.Entries {
|
||||
if p.state.Entries[i].GID == gid {
|
||||
p.state.Entries[i].Completed = true
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if found {
|
||||
p.ScheduleFlush(0)
|
||||
}
|
||||
}
|
||||
|
||||
// GIDs 取全部推送过的 gid
|
||||
func (p *PushedStore) GIDs() []string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
out := make([]string, 0, len(p.state.Entries))
|
||||
for _, e := range p.state.Entries {
|
||||
out = append(out, e.GID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Entries 取全部记录(按推送时间倒序,新的在前)
|
||||
func (p *PushedStore) Entries() []PushedEntry {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
out := make([]PushedEntry, len(p.state.Entries))
|
||||
copy(out, p.state.Entries)
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].PushedAt > out[j].PushedAt })
|
||||
return out
|
||||
}
|
||||
|
||||
// Count 记录条数
|
||||
func (p *PushedStore) Count() int {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return len(p.state.Entries)
|
||||
}
|
||||
|
||||
// Clear 清空推送历史
|
||||
func (p *PushedStore) Clear() {
|
||||
p.mu.Lock()
|
||||
p.state = PushedState{Entries: []PushedEntry{}}
|
||||
p.mu.Unlock()
|
||||
p.ScheduleFlush(0)
|
||||
}
|
||||
|
||||
// trim 去空白
|
||||
func trim(s string) string {
|
||||
start, end := 0, len(s)
|
||||
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') {
|
||||
start++
|
||||
}
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') {
|
||||
end--
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package core
|
||||
|
||||
// 推送历史测试:核心是「任务删了,灰标不该跟着消失」
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"eh-downloader-wails/internal/eh"
|
||||
)
|
||||
|
||||
func TestPushedStoreMarkAndPersist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "pushed.json")
|
||||
|
||||
s := NewPushedStore(p)
|
||||
s.Mark("111", "aaa", "T1")
|
||||
s.Mark("222", "bbb", "T2")
|
||||
// 重复推送同一个,只更新时间不重复添加
|
||||
s.Mark("111", "aaa", "T1 又推了一次")
|
||||
s.Flush()
|
||||
|
||||
if got := s.Count(); got != 2 {
|
||||
t.Fatalf("应有 2 条,实际 %d", got)
|
||||
}
|
||||
|
||||
// 重新打开,验证真的落盘了
|
||||
s2 := NewPushedStore(p)
|
||||
if got := s2.Count(); got != 2 {
|
||||
t.Fatalf("重开后应有 2 条,实际 %d", got)
|
||||
}
|
||||
gids := map[string]string{}
|
||||
for _, e := range s2.Entries() {
|
||||
gids[e.GID] = e.Title
|
||||
}
|
||||
if gids["111"] != "T1 又推了一次" {
|
||||
t.Errorf("重复推送应更新标题,实际 %q", gids["111"])
|
||||
}
|
||||
if gids["222"] != "T2" {
|
||||
t.Errorf("222 标题不对: %q", gids["222"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushedStoreMarkBatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewPushedStore(filepath.Join(dir, "pushed.json"))
|
||||
s.MarkBatch([]PushedEntry{
|
||||
{GID: "1", Token: "a", Title: "A"},
|
||||
{GID: "2", Token: "b", Title: "B"},
|
||||
{GID: "1", Token: "a", Title: "A2"}, // 重复
|
||||
})
|
||||
if got := s.Count(); got != 2 {
|
||||
t.Fatalf("批量后应有 2 条,实际 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 关键场景:任务被「清除已完成」删掉后,推送历史必须还在
|
||||
func TestPushedSurvivesTaskDeletion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(filepath.Join(dir, "state.json"))
|
||||
pushed := NewPushedStore(filepath.Join(dir, "pushed.json"))
|
||||
|
||||
// 模拟油猴推送
|
||||
store.AddTasks([]eh.GalleryRef{{GID: "111", Token: "aaa", Raw: "u", Title: "T1"}})
|
||||
pushed.Mark("111", "aaa", "T1")
|
||||
|
||||
// 任务完成
|
||||
task := store.FindTask("111", "aaa")
|
||||
if task == nil {
|
||||
t.Fatal("任务没了")
|
||||
}
|
||||
task.Status = "done"
|
||||
store.UpsertTask(*task)
|
||||
|
||||
// 清除已完成 —— 任务从列表消失
|
||||
n, gids := store.ClearFinishedWithGIDs()
|
||||
if n != 1 {
|
||||
t.Fatalf("应清掉 1 个,实际 %d", n)
|
||||
}
|
||||
if len(gids) != 1 || gids[0] != "111" {
|
||||
t.Fatalf("应返回 gid 111,实际 %v", gids)
|
||||
}
|
||||
if store.CountDone() != 0 {
|
||||
t.Error("任务应已清空")
|
||||
}
|
||||
|
||||
// 但推送历史还在 —— 灰标不会消失
|
||||
if pushed.Count() != 1 {
|
||||
t.Fatalf("推送历史应还剩 1 条,实际 %d", pushed.Count())
|
||||
}
|
||||
entries := pushed.Entries()
|
||||
if entries[0].GID != "111" {
|
||||
t.Errorf("gid 不对: %s", entries[0].GID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushedMarkCompleted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewPushedStore(filepath.Join(dir, "pushed.json"))
|
||||
s.Mark("111", "aaa", "T1")
|
||||
s.MarkCompleted("111")
|
||||
entries := s.Entries()
|
||||
if len(entries) != 1 || !entries[0].Completed {
|
||||
t.Fatalf("应标记为已完成: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushedStoreClear(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "pushed.json")
|
||||
s := NewPushedStore(p)
|
||||
s.Mark("111", "aaa", "T1")
|
||||
s.Mark("222", "bbb", "T2")
|
||||
s.Clear()
|
||||
s.Flush()
|
||||
if got := s.Count(); got != 0 {
|
||||
t.Fatalf("清空后应为 0,实际 %d", got)
|
||||
}
|
||||
// 文件应该被写成空数组,而不是残留旧数据
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var st PushedState
|
||||
if err := json.Unmarshal(raw, &st); err != nil {
|
||||
t.Fatalf("文件应还是合法 JSON: %v", err)
|
||||
}
|
||||
if len(st.Entries) != 0 {
|
||||
t.Errorf("文件里应为空,实际 %d 条", len(st.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
// 桥口 /pushed 接口
|
||||
func TestBridgePushedEndpoint(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(filepath.Join(dir, "state.json"))
|
||||
pushed := NewPushedStore(filepath.Join(dir, "pushed.json"))
|
||||
base := startTestBridgeWith(t, store, pushed)
|
||||
|
||||
// 通过 /add 推一个
|
||||
body := `{"gid":"777","token":"ccc","url":"https://e-hentai.org/g/777/ccc/","title":"T777"}`
|
||||
req, _ := http.NewRequest("POST", base+"/add", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-EHDL-Token", BridgeToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// /pushed 里能查到
|
||||
code, raw := getWithToken(t, base+"/pushed", BridgeToken)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("应 200,实际 %d", code)
|
||||
}
|
||||
var j struct {
|
||||
OK bool `json:"ok"`
|
||||
Count int `json:"count"`
|
||||
Items []struct {
|
||||
GID string `json:"gid"`
|
||||
Title string `json:"title"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &j); err != nil {
|
||||
t.Fatalf("解析失败: %v (%s)", err, raw)
|
||||
}
|
||||
if j.Count != 1 || len(j.Items) != 1 || j.Items[0].GID != "777" {
|
||||
t.Fatalf("/pushed 应返回 777,实际 %s", raw)
|
||||
}
|
||||
if j.Items[0].Title != "T777" {
|
||||
t.Errorf("标题不对: %q", j.Items[0].Title)
|
||||
}
|
||||
|
||||
// 错 token -> 403
|
||||
code, _ = getWithToken(t, base+"/pushed", "bad")
|
||||
if code != http.StatusForbidden {
|
||||
t.Errorf("错 token 应 403,实际 %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package core
|
||||
|
||||
// 端到端验证:恢复出来的任务能真的下载
|
||||
//
|
||||
// 2026-10-24 事故修复的回归测试。
|
||||
// state.json 被清空过,token/title/status 全丢,靠「?next=<gid> 定位法」抓回来。
|
||||
// 这个测试确认抓回来的 token + title 能让引擎真的把图下下来。
|
||||
//
|
||||
// 需要能连上 e-hentai.org,跑法:
|
||||
//
|
||||
// EH_TEST_PROXY=http://127.0.0.1:7890 go test ./internal/core/ -run TestRecoveredTaskDownloads -v
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"eh-downloader-wails/internal/eh"
|
||||
)
|
||||
|
||||
func TestRecoveredTaskDownloads(t *testing.T) {
|
||||
proxy := os.Getenv("EH_TEST_PROXY")
|
||||
if proxy == "" {
|
||||
t.Skip("设 EH_TEST_PROXY 才跑(需要能连 e-hentai.org)")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
dlRoot := filepath.Join(dir, "dl")
|
||||
store := NewStore(filepath.Join(dir, "state.json"))
|
||||
store.UpdateSettings(map[string]interface{}{
|
||||
"downloadRoot": dlRoot,
|
||||
"proxy": proxy,
|
||||
"proxyMode": "http",
|
||||
"timeout": 30000,
|
||||
"xres": "auto",
|
||||
"galleryConcurrency": 1,
|
||||
"pageConcurrency": 2,
|
||||
})
|
||||
|
||||
// 一个事故恢复时抓回来的任务(gid/token/title)
|
||||
const gid = "4278109"
|
||||
const token = "007bf09196"
|
||||
const title = "Minamoto-no-Raikou [Fate/Grand Order]"
|
||||
|
||||
store.AddTasks([]eh.GalleryRef{{
|
||||
GID: gid,
|
||||
Token: token,
|
||||
Raw: "https://e-hentai.org/g/" + gid + "/" + token + "/",
|
||||
Title: title,
|
||||
}})
|
||||
|
||||
sink := &testSink{}
|
||||
e := NewEngine(store, sink, store.Settings())
|
||||
|
||||
// 只跑这一个任务
|
||||
task0 := store.FindTask(gid, token)
|
||||
if task0 == nil {
|
||||
t.Fatal("任务不见了")
|
||||
}
|
||||
e.Enqueue([]Task{*task0})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
e.Start(ctx)
|
||||
|
||||
task := store.FindTask(gid, token)
|
||||
if task == nil {
|
||||
t.Fatal("任务不见了")
|
||||
}
|
||||
if task.Status != "done" && task.Status != "partial" {
|
||||
t.Fatalf("状态应为 done/partial,实际 %q(错误: %s)", task.Status, task.Error)
|
||||
}
|
||||
if task.Done == 0 {
|
||||
t.Fatal("一张图都没下下来")
|
||||
}
|
||||
t.Logf("✓ 下载完成: %s %d/%d 页 (状态 %s)", task.Title, task.Done, task.TotalPages, task.Status)
|
||||
|
||||
// 文件系统里应该有东西
|
||||
var files int
|
||||
filepath.Walk(dlRoot, func(p string, info os.FileInfo, err error) error {
|
||||
if err == nil && !info.IsDir() && filepath.Ext(p) != "" {
|
||||
files++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if files == 0 {
|
||||
t.Fatal("下载目录里没有文件")
|
||||
}
|
||||
t.Logf("✓ 磁盘上有 %d 个文件", files)
|
||||
}
|
||||
|
||||
type testSink struct{}
|
||||
|
||||
func (s *testSink) TaskUpdate(t Task) {}
|
||||
func (s *testSink) Log(level, msg string) {}
|
||||
func (s *testSink) Done() {}
|
||||
+12
-1
@@ -420,19 +420,30 @@ func (s *Store) ClearAll(onlySelected bool) int {
|
||||
|
||||
// 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)
|
||||
return before - len(s.state.Tasks), gids
|
||||
}
|
||||
|
||||
// CountDone 数一下已完成
|
||||
|
||||
Reference in New Issue
Block a user