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

507 lines
13 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 main
// Wails 绑定层:把 Go 侧能力暴露给前端。
// 前端通过 window.go.main.App.XXX() 调用(见 frontend/js/api.js)。
//
// ── 与参考项目(EH)的差异 ─────────────────────────────────────
//
// 任务主键从 gid+token 换成了 id(域名+路径),所有「按 gid 筛选」的接口
// 都相应变成「按 id 筛选」。另外去掉了 xres(分辨率)设置 —— 4KHD 的
// ?w= 只有 1300 一个可用值,改别的实测稳定 429。
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"hd4k-downloader/internal/core"
"hd4k-downloader/internal/hd4k"
)
// App 是 Wails 应用主体
type App struct {
ctx context.Context
store *core.Store
engine *core.Engine
bridge *core.Bridge
// 事件序号(前端用来丢弃旧日志)
logSeq int64
logMu chan struct{}
engineCtx context.Context
engineStop context.CancelFunc
}
// LogEntry 是一条日志
type LogEntry struct {
Seq int64 `json:"seq"`
Level string `json:"level"`
Msg string `json:"msg"`
At int64 `json:"at"`
}
// AddTasksResult 导入结果
type AddTasksResult struct {
Added int `json:"added"`
Skipped int `json:"skipped"`
Invalid int `json:"invalid"`
Warning string `json:"warning"`
}
// StartResult 开始下载的结果
type StartResult struct {
Started bool `json:"started"`
Reason string `json:"reason"`
Count int `json:"count"`
}
// StatusResult 引擎状态
type StatusResult struct {
Running bool `json:"running"`
Queued int `json:"queued"`
}
// DirPreviewResult 目录预览
type DirPreviewResult struct {
OK bool `json:"ok"`
Path string `json:"path"`
Error string `json:"error"`
}
// statePath 状态文件路径(放用户配置目录)
func statePath() string {
dir, err := os.UserConfigDir()
if err != nil {
dir = "."
}
return filepath.Join(dir, core.AppName, "state.json")
}
// NewApp 建应用
func NewApp() *App {
return &App{logMu: make(chan struct{}, 1)}
}
// ---------------------------------------------------------------- 生命周期
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
a.store = core.NewStore(statePath())
s := a.store.Settings()
if s.DownloadRoot == "" {
s.DownloadRoot = core.DefaultDownloadRoot()
a.store.UpdateSettings(map[string]interface{}{"downloadRoot": s.DownloadRoot})
}
if n := a.store.RecoverOnStart(); n > 0 {
a.emitLog("info", fmt.Sprintf("启动时归位了 %d 个中断的任务", n))
}
a.engine = core.NewEngine(a.store, &sink{a}, s)
a.engineCtx, a.engineStop = context.WithCancel(context.Background())
a.bridge = core.NewBridge(
a.store,
a.emitLog,
func(t core.Task, added bool, batch bool) {
a.emitTaskUpdate(t)
how := "已在列表中"
if added {
how = "已加入待下载"
}
title := ""
if t.Title != "" {
title = " · " + t.Title
}
a.emitLog("ok", fmt.Sprintf("油猴推送:%s %s%s", t.Slug, how, title))
// 窗口最小化/隐藏时唤起一下,让用户看得到
wruntime.WindowUnminimise(a.ctx)
wruntime.WindowShow(a.ctx)
},
)
a.bridge.Start(s.BridgePort)
}
func (a *App) shutdown(ctx context.Context) {
if a.engineStop != nil {
a.engineStop()
}
if a.bridge != nil {
a.bridge.Stop()
}
if a.engine != nil {
a.engine.Client().Close()
}
if a.store != nil {
a.store.Stop()
a.store.Flush()
}
}
// sink 把引擎事件转成 Wails 事件
type sink struct{ a *App }
func (s *sink) TaskUpdate(t core.Task) { s.a.emitTaskUpdate(t) }
func (s *sink) Log(level, msg string) { s.a.emitLog(level, msg) }
func (s *sink) Done() { s.a.emitEvent("engine:done", map[string]interface{}{}) }
// ---------------------------------------------------------------- 事件
func (a *App) emitEvent(name string, data interface{}) {
if a.ctx == nil {
return
}
wruntime.EventsEmit(a.ctx, name, data)
}
func (a *App) emitLog(level, msg string) {
// 串行化自增,避免并发下序号乱序
a.logMu <- struct{}{}
a.logSeq++
entry := LogEntry{Seq: a.logSeq, Level: level, Msg: msg, At: time.Now().UnixMilli()}
<-a.logMu
a.emitEvent("log", entry)
}
func (a *App) emitTaskUpdate(t core.Task) {
a.emitEvent("task:update", t)
}
// ---------------------------------------------------------------- 设置
func (a *App) GetSettings() core.Settings {
return a.store.Settings()
}
func (a *App) SetSettings(patch map[string]interface{}) core.Settings {
next := a.store.UpdateSettings(patch)
if _, ok := patch["galleryConcurrency"]; ok {
a.engine.SetGalleryConcurrency(next.GalleryConcurrency)
}
if _, ok := patch["pageConcurrency"]; ok {
a.engine.SetPageConcurrency(next.PageConcurrency)
}
if _, ok := patch["requestIntervalMs"]; ok {
a.engine.SetRequestInterval(next.RequestIntervalMs)
}
if _, ok := patch["timeout"]; ok {
a.engine.Client().SetTimeout(time.Duration(next.Timeout) * time.Millisecond)
}
if _, ok := patch["proxy"]; ok {
a.engine.Client().SetProxy(next.Proxy, next.ProxyMode)
}
if _, ok := patch["proxyMode"]; ok {
a.engine.Client().SetProxy(next.Proxy, next.ProxyMode)
}
// 站点列表变了 → 引擎与桥口的白名单都要跟着更新
if _, ok := patch["sites"]; ok {
a.engine.Client().SetSites(next.Sites)
}
if _, ok := patch["bridgePort"]; ok && a.bridge.Port() != next.BridgePort {
a.bridge.Stop()
a.bridge.Start(next.BridgePort)
}
return next
}
// PreviewDir 目录模板实时预览
func (a *App) PreviewDir(tpl string) DirPreviewResult {
g := previewGallery()
return DirPreviewResult{OK: true, Path: core.BuildDir(tpl, g)}
}
func previewGallery() *hd4k.Gallery {
return &hd4k.Gallery{
ID: "migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html",
Host: "migmg.ssuu.uk",
Slug: "myua-sweetbox-vol52.html",
Title: "Myu_a(뮤아) SWEETBOX Vol.52[190MB-53photos]",
Meta: hd4k.GalleryMeta{Category: "09", Slug: "myua-sweetbox-vol52.html", Host: "migmg.ssuu.uk"},
}
}
// Placeholders 可用占位符
func (a *App) Placeholders() []string {
return core.ListPlaceholders()
}
// Sites 当前支持的站点
func (a *App) Sites() []core.Site {
return a.store.Settings().Sites
}
// EngineInfo 引擎当前的并发配置
type EngineInfo struct {
Running bool `json:"running"`
Queued int `json:"queued"`
GalleryConcurrency int `json:"galleryConcurrency"`
PageConcurrency int `json:"pageConcurrency"`
MaxConcurrent int `json:"maxConcurrent"`
RequestIntervalMs int `json:"requestIntervalMs"`
}
func (a *App) EngineInfo() EngineInfo {
g := a.engine.GalleryConcurrency()
p := a.engine.PageConcurrency()
return EngineInfo{
Running: a.engine.IsRunning(),
Queued: a.engine.QueueLength(),
GalleryConcurrency: g,
PageConcurrency: p,
MaxConcurrent: g * p,
RequestIntervalMs: int(a.engine.Client().RequestInterval().Milliseconds()),
}
}
// ScanManifestsResult 扫描下载目录的结果
type ScanManifestsResult struct {
Found int `json:"found"`
Adopted int `json:"adopted"`
Root string `json:"root"`
Note string `json:"note"`
}
// ScanDownloadDir 扫描下载目录里的画廊清单,把 state.json 里缺的信息补上
func (a *App) ScanDownloadDir() ScanManifestsResult {
root := a.store.Settings().DownloadRoot
if root == "" {
root = core.DefaultDownloadRoot()
}
manifests := core.ScanManifests(root)
res := ScanManifestsResult{Root: root, Found: len(manifests)}
if len(manifests) == 0 {
res.Note = fmt.Sprintf("下载目录里没找到画廊清单(%s)", core.ManifestFileName)
return res
}
for _, m := range manifests {
t := a.store.FindTask(m.ID)
if t == nil {
continue
}
if core.ReconcileTask(t, m) {
a.store.UpsertTask(*t)
res.Adopted++
}
}
res.Note = fmt.Sprintf("扫描到 %d 个画廊清单,补全了 %d 个任务", res.Found, res.Adopted)
return res
}
// ---------------------------------------------------------------- 任务
func (a *App) ListTasks() []core.Task {
return a.store.Tasks()
}
// AddTasks 导入链接文本(每行一条,也支持逗号/制表符分隔)
func (a *App) AddTasks(text string) AddTasksResult {
sites := a.store.Settings().Sites
parsed := hd4k.ParseGalleryInput(text, sites)
if len(parsed) == 0 {
return AddTasksResult{
Invalid: 1,
Warning: "没解析出任何画廊链接。支持形如 https://migmg.ssuu.uk/content/09/xxx.html 的地址。",
}
}
added := a.store.AddTasks(parsed)
a.emitLog("info", fmt.Sprintf("导入 %d 个画廊,新增 %d 个", len(parsed), added))
return AddTasksResult{Added: added, Skipped: len(parsed) - added}
}
// AddURLsFromBridge 供油猴桥口用(批量)
func (a *App) RemoveTask(id string) []core.Task {
a.store.RemoveTask(id)
return a.store.Tasks()
}
func (a *App) ClearFinished() map[string]interface{} {
n := a.store.ClearFinished()
// 顺带清理画廊目录里的小图:占位图/损坏图通常只有几 KB,
// 正文图最小也在 100KB 以上,小于 10KB 的基本都是垃圾,删掉。
// 只删图片文件,清单(.hd4k.json)和 .part 临时文件不动。
root := a.store.Settings().DownloadRoot
if root == "" {
root = core.DefaultDownloadRoot()
}
removed, freed, err := core.SweepSmallImages(root, core.MinImageBytes)
msg := fmt.Sprintf("清除了 %d 个已完成任务(本地已下载文件未动)", n)
if removed > 0 {
msg += fmt.Sprintf(",删除 %d 张过小的图片(< %dKB,释放 %.1f MB)",
removed, core.MinImageBytes/1024, float64(freed)/(1024*1024))
a.emitLog("ok", msg)
} else {
a.emitLog("info", msg)
}
if err != nil {
a.emitLog("warn", "清理小图时有文件跳过:"+err.Error())
}
return map[string]interface{}{
"removed": n,
"tasks": a.store.Tasks(),
"sweptImages": removed,
"sweptFreedKB": freed / 1024,
}
}
func (a *App) CountDone() int { return a.store.CountDone() }
func (a *App) ClearAll(onlySelected bool) map[string]interface{} {
scope := "全部"
if onlySelected {
scope = "选中的"
}
n := a.store.ClearAll(onlySelected)
if n > 0 {
a.emitLog("warn", fmt.Sprintf("删除了%s %d 个任务(本地已下载文件未动)", scope, n))
} else {
a.emitLog("info", "没有可删除的任务")
}
return map[string]interface{}{"removed": n, "tasks": a.store.Tasks()}
}
func (a *App) RequeueFailed() []core.Task {
n := a.store.RequeueFailed()
a.emitLog("info", fmt.Sprintf("重新排队 %d 个任务", n))
return a.store.Tasks()
}
func (a *App) SelectAll(ids []string) []core.Task {
return a.store.SelectAll(ids)
}
// ---------------------------------------------------------------- 引擎
func (a *App) Status() StatusResult {
return StatusResult{Running: a.engine.IsRunning(), Queued: a.engine.QueueLength()}
}
// Start 开始下载。onlyIDs 为空 = 全部待处理任务。
func (a *App) Start(onlyIDs []string) StartResult {
if a.engine.IsRunning() {
return StartResult{Started: false, Reason: "已经在跑了"}
}
all := a.store.Tasks()
targets := make([]core.Task, 0, len(all))
for _, t := range all {
switch t.Status {
case "queued", "failed", "partial":
default:
continue
}
if len(onlyIDs) > 0 && !containsStr(onlyIDs, t.ID) {
continue
}
targets = append(targets, t)
}
if len(targets) == 0 {
return StartResult{Started: false, Reason: "没有待处理的任务"}
}
a.engine.Enqueue(targets)
ctx := a.engineCtx
if ctx == nil {
a.engineCtx, a.engineStop = context.WithCancel(context.Background())
ctx = a.engineCtx
}
go a.engine.Start(ctx)
g := a.engine.GalleryConcurrency()
p := a.engine.PageConcurrency()
iv := a.engine.Client().RequestInterval().Milliseconds()
msg := fmt.Sprintf("开始下载 %d 个画廊(画廊并发 %d × 图并发 %d,最多 %d 张同时下载)",
len(targets), g, p, g*p)
if iv > 0 {
msg += fmt.Sprintf(",请求间隔 %dms", iv)
}
a.emitLog("info", msg)
return StartResult{Started: true, Count: len(targets)}
}
func (a *App) Stop() map[string]interface{} {
a.engine.Stop()
a.emitLog("warn", "已请求停止,等待当前请求结束")
return map[string]interface{}{"stopped": true}
}
// ---------------------------------------------------------------- 系统
func (a *App) PickDir() string {
p, _ := wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "选择下载目录",
})
return p
}
func (a *App) OpenPath(p string) {
wruntime.BrowserOpenURL(a.ctx, "file:///"+filepath.ToSlash(p))
}
func (a *App) ShowItem(p string) {
wruntime.BrowserOpenURL(a.ctx, "file:///"+filepath.ToSlash(filepath.Dir(p)))
}
// ExportFailed 导出失败清单
func (a *App) ExportFailed(id string) string {
var target *core.Task
for _, x := range a.store.Tasks() {
if x.ID == id {
c := x
target = &c
break
}
}
if target == nil || len(target.FailedList) == 0 {
return ""
}
path, _ := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
Title: "导出失败清单",
DefaultFilename: target.Slug + "-failed.txt",
Filters: []wruntime.FileFilter{
{DisplayName: "文本文件 (*.txt)", Pattern: "*.txt"},
},
})
if path == "" {
return ""
}
var sb string
for _, f := range target.FailedList {
sb += fmt.Sprintf("# %d\t%s\t%s\n", f.Page, f.File, f.Error)
}
if err := os.WriteFile(path, []byte(sb), 0o644); err != nil {
a.emitLog("error", "导出失败:"+err.Error())
return ""
}
return path
}
func containsStr(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}