first commit

This commit is contained in:
2026-10-11 12:56:38 +08:00
commit 7d21f317a4
41 changed files with 10758 additions and 0 deletions
+506
View File
@@ -0,0 +1,506 @@
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
}