first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user