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 }