feat(app): add internal components batch 1
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultUIListenAddress = "0.0.0.0:8999"
|
||||
|
||||
type boolOverride struct {
|
||||
set bool
|
||||
val bool
|
||||
}
|
||||
|
||||
func (b *boolOverride) String() string { return strconv.FormatBool(b.val) }
|
||||
|
||||
func (b *boolOverride) Set(s string) error {
|
||||
v, err := strconv.ParseBool(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.set = true
|
||||
b.val = v
|
||||
return nil
|
||||
}
|
||||
|
||||
func openBrowser(rawURL string) {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", rawURL)
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
|
||||
default:
|
||||
cmd = exec.Command("xdg-open", rawURL)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Printf(" 自动打开浏览器失败,请手动访问:%s (%v)\n", rawURL, err)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeListen(addr string) string {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
return defaultUIListenAddress
|
||||
}
|
||||
if strings.HasPrefix(addr, ":") {
|
||||
return "0.0.0.0" + addr
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func publicURL(addr string) string {
|
||||
if strings.HasPrefix(addr, "127.0.0.1:") || strings.HasPrefix(addr, "localhost:") {
|
||||
return "http://" + addr
|
||||
}
|
||||
if strings.HasPrefix(addr, "0.0.0.0:") || strings.HasPrefix(addr, "[::]:") {
|
||||
if _, port, ok := strings.Cut(addr, ":"); ok {
|
||||
return "http://127.0.0.1:" + port
|
||||
}
|
||||
}
|
||||
return "http://" + addr
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Print(`果果剧库 - 红果点播与下载工具
|
||||
|
||||
请仅下载你拥有权利或已获授权的视频内容。
|
||||
|
||||
用法:
|
||||
直接执行 go run . 或编译后的程序,默认启动浏览器管理界面
|
||||
|
||||
常用参数:
|
||||
-ui=true|false 启动/关闭本地浏览器管理界面,默认 true
|
||||
-listen 地址 UI 监听地址,默认 0.0.0.0:8999,支持局域网访问
|
||||
-open=true|false UI 模式是否自动打开浏览器,默认 true
|
||||
-admin-user 用户名 管理员账号,首次默认 admin
|
||||
-admin-password 密码 指定管理员密码;首次未指定时随机生成并输出,首次登录须修改
|
||||
-mode all|search|id|list CLI 模式,需配合 -ui=false,默认 all
|
||||
-keyword 关键词 search 模式关键词
|
||||
-id 剧ID id 模式下载单部剧
|
||||
-out 目录 自定义下载目录,默认 ./短剧下载,支持相对或绝对路径
|
||||
-c 并发数 下载并发,默认 2,可在界面设置中修改
|
||||
-pages 页数 每频道扫描页数;红果为 App 每批页数,默认 20
|
||||
-data-dir 目录 配置、缓存和任务目录,默认 ./data
|
||||
-config 文件 首次运行时导入旧版配置(可选)
|
||||
-ffmpeg 路径 ffmpeg 路径,默认从 PATH 查找
|
||||
-refresh CLI 模式强制更新剧库,默认读取本地缓存
|
||||
-more CLI 模式从保存的红果 App 分页位置继续加载
|
||||
-insecure-tls=true|false 是否跳过 TLS 证书校验,默认 false
|
||||
|
||||
示例:
|
||||
juku
|
||||
juku -open=false
|
||||
juku -ui=false -mode all
|
||||
juku -ui=false -mode search -keyword 女总裁
|
||||
juku -ui=false -mode id -id hongguo:7670772535931178008
|
||||
juku -ui=false -mode list
|
||||
`)
|
||||
}
|
||||
|
||||
func Run() {
|
||||
mode := flag.String("mode", "all", "all/search/id/list")
|
||||
keyword := flag.String("keyword", "", "search keyword")
|
||||
id := flag.String("id", "", "drama id")
|
||||
out := flag.String("out", "", "output directory")
|
||||
concurrency := flag.Int("c", 0, "download concurrency")
|
||||
pages := flag.Int("pages", 0, "max pages per sort")
|
||||
configFile := flag.String("config", "", "import a legacy config on first startup")
|
||||
dataDir := flag.String("data-dir", "data", "configuration, cache and task directory")
|
||||
ffmpeg := flag.String("ffmpeg", "", "ffmpeg path")
|
||||
adminUser := flag.String("admin-user", "", "administrator username, default admin on first startup")
|
||||
adminPassword := flag.String("admin-password", "", "administrator password; default random on first startup")
|
||||
uiMode := flag.Bool("ui", true, "start local web UI")
|
||||
listen := flag.String("listen", defaultUIListenAddress, "UI listen address; use 127.0.0.1:8999 for local-only access")
|
||||
open := flag.Bool("open", true, "open browser automatically in UI mode")
|
||||
refresh := flag.Bool("refresh", false, "refresh the CLI library instead of using local cache")
|
||||
more := flag.Bool("more", false, "continue loading the Hongguo App catalog from its saved cursor")
|
||||
var insecureTLS boolOverride
|
||||
flag.Var(&insecureTLS, "insecure-tls", "skip TLS certificate verification")
|
||||
help := flag.Bool("help", false, "show help")
|
||||
flag.Parse()
|
||||
if *help {
|
||||
usage()
|
||||
return
|
||||
}
|
||||
if *refresh && *more {
|
||||
fmt.Fprintln(os.Stderr, "-refresh 和 -more 不能同时使用")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if !flagWasSet("data-dir") && !flagWasSet("config") {
|
||||
if err := preparePortableWorkingDirectory(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "初始化程序目录失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
cfg, configErr := loadApplicationConfig(*dataDir, *configFile, *out)
|
||||
if configErr != nil {
|
||||
fmt.Fprintln(os.Stderr, "初始化配置失败:", publicError(configErr))
|
||||
os.Exit(2)
|
||||
}
|
||||
cfg.adminUsername = firstNonEmpty(*adminUser, os.Getenv("JUKU_ADMIN_USER"), "admin")
|
||||
cfg.adminUserExplicit = flagWasSet("admin-user") || os.Getenv("JUKU_ADMIN_USER") != ""
|
||||
cfg.adminPassword = os.Getenv("JUKU_ADMIN_PASSWORD")
|
||||
if flagWasSet("admin-password") {
|
||||
cfg.adminPassword = *adminPassword
|
||||
}
|
||||
cfg.adminPasswordExplicit = flagWasSet("admin-password") || os.Getenv("JUKU_ADMIN_PASSWORD") != ""
|
||||
if *concurrency > 0 {
|
||||
cfg.Concurrency = *concurrency
|
||||
}
|
||||
if *pages > 0 {
|
||||
cfg.MaxPagesPerSort = *pages
|
||||
}
|
||||
if *ffmpeg != "" {
|
||||
cfg.FFmpeg = *ffmpeg
|
||||
}
|
||||
if insecureTLS.set {
|
||||
cfg.InsecureTLS = insecureTLS.val
|
||||
}
|
||||
if err := cfg.validate(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "配置错误: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
d := NewDownloader(cfg)
|
||||
cfg = d.cfg
|
||||
if err := os.MkdirAll(cfg.OutputDir, 0o755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "创建输出目录失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
_, ffmpegErr := exec.LookPath(portableFFmpeg(cfg.FFmpeg))
|
||||
fmt.Printf("输出目录: %s\n下载并发: %d\nFFmpeg 已找到(后台校验): %t\nTLS证书校验: %t\n", cfg.OutputDir, cfg.Concurrency, ffmpegErr == nil, !cfg.InsecureTLS)
|
||||
d.recordDiagnostic(diagnosticEvent{Level: "info", Event: "app.started", Message: "果果剧库已启动"})
|
||||
fmt.Printf("诊断日志: %s\n", d.diagnostics.path)
|
||||
if ffmpegErr != nil {
|
||||
if automaticFFmpeg(cfg.FFmpeg) {
|
||||
fmt.Println("未找到 FFmpeg,将自动下载匹配当前系统的便携版本;如下载失败,请在界面中调整代理后重试。")
|
||||
} else {
|
||||
fmt.Println("指定的 FFmpeg 路径未找到,请更正 download.ffmpeg 或 -ffmpeg。")
|
||||
}
|
||||
}
|
||||
|
||||
if *uiMode {
|
||||
addr := normalizeListen(*listen)
|
||||
app := NewUIApp(d)
|
||||
pageURL := publicURL(addr)
|
||||
fmt.Printf("监听地址: %s | 管理界面已启动: %s\n", addr, pageURL)
|
||||
if *open {
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
openBrowser(pageURL)
|
||||
}()
|
||||
}
|
||||
if err := app.ListenAndServe(addr); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
d.recordDiagnostic(diagnosticEvent{Event: "app.failed", Message: err.Error()})
|
||||
fmt.Fprintf(os.Stderr, "UI 服务停止: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
var dramas []Drama
|
||||
var err error
|
||||
switch strings.ToLower(*mode) {
|
||||
case "id":
|
||||
if strings.TrimSpace(*id) == "" {
|
||||
fmt.Fprintln(os.Stderr, "-mode id 需要 -id")
|
||||
os.Exit(2)
|
||||
}
|
||||
dramas = []Drama{{ID: strings.TrimSpace(*id), Title: "短剧"}}
|
||||
case "all", "search", "list":
|
||||
if *more {
|
||||
dramas, err = d.RefreshDramas(context.WithValue(ctx, libraryMoreKey{}, true), sourceHongguo)
|
||||
} else if *refresh {
|
||||
dramas, err = d.RefreshDramas(ctx, "")
|
||||
} else {
|
||||
dramas, err = d.GetAllDramas(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " 剧库部分来源失败,继续处理已获取的 %d 部:%v\n", len(dramas), err)
|
||||
if len(dramas) == 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if strings.ToLower(*mode) == "search" {
|
||||
kw := strings.TrimSpace(*keyword)
|
||||
if kw == "" {
|
||||
fmt.Fprintln(os.Stderr, "-mode search 需要 -keyword")
|
||||
os.Exit(2)
|
||||
}
|
||||
var filtered []Drama
|
||||
for _, dr := range dramas {
|
||||
text := dr.DisplayTitle() + " " + dr.Desc + " " + dr.Intro + " " + dr.ChannelName
|
||||
if strings.Contains(strings.ToLower(text), strings.ToLower(kw)) {
|
||||
filtered = append(filtered, dr)
|
||||
}
|
||||
}
|
||||
dramas = filtered
|
||||
fmt.Printf(" 搜索命中:%d 部\n", len(dramas))
|
||||
}
|
||||
if strings.ToLower(*mode) == "list" {
|
||||
for i, dr := range dramas {
|
||||
fmt.Printf("%04d [%s] %s %s\n", i+1, dr.ChannelName, dr.ID, dr.DisplayTitle())
|
||||
}
|
||||
return
|
||||
}
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
if len(dramas) == 0 {
|
||||
fmt.Println("没有待下载短剧。")
|
||||
return
|
||||
}
|
||||
|
||||
var tasks []Task
|
||||
for i, dr := range dramas {
|
||||
fmt.Printf(" [%d/%d] 获取章节:《%s》 %s\n", i+1, len(dramas), dr.DisplayTitle(), dr.ID)
|
||||
dramaTasks, err := d.BuildDramaTasks(ctx, dr)
|
||||
if err != nil {
|
||||
fmt.Printf(" 获取章节失败:《%s》 %v\n", dr.DisplayTitle(), err)
|
||||
continue
|
||||
}
|
||||
tasks = append(tasks, dramaTasks...)
|
||||
}
|
||||
fmt.Printf(" 待处理视频:%d 集\n", len(tasks))
|
||||
results := DownloadTasks(ctx, d, tasks)
|
||||
var ok, fail int
|
||||
for _, r := range results {
|
||||
if r.OK {
|
||||
ok++
|
||||
} else {
|
||||
fail++
|
||||
}
|
||||
}
|
||||
if err := saveFailures(cfg.dataDirectory(), results); err != nil {
|
||||
fmt.Printf(" 写入 failed.json 失败: %v\n", err)
|
||||
}
|
||||
fmt.Printf("\n 完成:成功/跳过 %d,失败 %d\n", ok, fail)
|
||||
if fail > 0 {
|
||||
fmt.Printf("失败详情已写入:%s\n", filepath.Join(cfg.dataDirectory(), "failed.json"))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
const userAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6 Mobile/15E148 Safari/604.1"
|
||||
|
||||
type Config struct {
|
||||
adminUsername string
|
||||
adminPassword string
|
||||
adminUserExplicit bool
|
||||
adminPasswordExplicit bool
|
||||
settingsLoaded bool
|
||||
dataDir string
|
||||
outputDirSetting string
|
||||
OutputDir string `json:"outputDir"`
|
||||
GroupBySource bool `json:"groupBySource"`
|
||||
FFmpeg string `json:"ffmpeg"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
RequestConcurrency int `json:"requestConcurrency"`
|
||||
RequestIntervalMS int `json:"requestIntervalMs"`
|
||||
MaxPagesPerSort int `json:"maxPagesPerSort"`
|
||||
Retries int `json:"retries"`
|
||||
SkipBytes int64 `json:"skipBytes"`
|
||||
InsecureTLS bool `json:"insecureTLS"`
|
||||
ProxyURL string `json:"proxyURL,omitempty"`
|
||||
HongguoURL string `json:"hongguoURL,omitempty"`
|
||||
}
|
||||
|
||||
func defaultConfig() Config {
|
||||
return Config{
|
||||
OutputDir: defaultOutputDir(), FFmpeg: "ffmpeg", Concurrency: 2,
|
||||
RequestConcurrency: 2, RequestIntervalMS: 500, MaxPagesPerSort: 50,
|
||||
Retries: 3, SkipBytes: 512 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultOutputDir() string { return "短剧下载" }
|
||||
|
||||
func applyConfigEnvironment(config *Config) {
|
||||
for variable, target := range map[string]*string{
|
||||
"JUKU_HONGGUO_URL": &config.HongguoURL,
|
||||
"JUKU_PROXY_URL": &config.ProxyURL,
|
||||
"JUKU_OUTPUT_DIR": &config.OutputDir,
|
||||
"JUKU_FFMPEG": &config.FFmpeg,
|
||||
} {
|
||||
if value := os.Getenv(variable); value != "" {
|
||||
*target = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (config Config) validate() error {
|
||||
if _, err := configuredProxy(config.ProxyURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if config.HongguoURL != "" && !isProviderHTTPMediaURL(config.HongguoURL) {
|
||||
return errors.New("红果站点地址必须是有效的 HTTP/HTTPS URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type runtimeSettings struct {
|
||||
Concurrency int `json:"concurrency"`
|
||||
RequestConcurrency int `json:"requestConcurrency"`
|
||||
RequestIntervalMS int `json:"requestIntervalMs"`
|
||||
ProxyURL *string `json:"proxyURL,omitempty"`
|
||||
OutputDir *string `json:"outputDir,omitempty"`
|
||||
GroupBySource *bool `json:"groupBySource,omitempty"`
|
||||
}
|
||||
|
||||
func (settings runtimeSettings) validate() error {
|
||||
if settings.OutputDir != nil {
|
||||
if _, err := normalizedOutputDirectory(*settings.OutputDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if settings.ProxyURL != nil {
|
||||
if _, err := configuredProxy(*settings.ProxyURL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if settings.Concurrency < 1 || settings.Concurrency > 32 {
|
||||
return errors.New("下载并发范围为 1–32")
|
||||
}
|
||||
if settings.RequestConcurrency < 1 || settings.RequestConcurrency > 16 {
|
||||
return errors.New("剧库请求并发范围为 1–16")
|
||||
}
|
||||
if settings.RequestIntervalMS < 100 || settings.RequestIntervalMS > 60000 {
|
||||
return errors.New("同站请求间隔范围为 100–60000 毫秒")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runtimeSettingsPath(directory string) string {
|
||||
return filepath.Join(directory, "juku.json")
|
||||
}
|
||||
|
||||
func loadRuntimeSettings(cfg *Config) {
|
||||
if cfg.settingsLoaded {
|
||||
return
|
||||
}
|
||||
cfg.settingsLoaded = true
|
||||
document, err := readConfigDocument(runtimeSettingsPath(cfg.runtimeSettingsDirectory()))
|
||||
if err == nil {
|
||||
loaded, configErr := document.config()
|
||||
if configErr == nil {
|
||||
loaded.dataDir = cfg.dataDirectory()
|
||||
loaded.settingsLoaded = true
|
||||
*cfg = loaded
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
fmt.Printf("配置读取失败,保留当前设置: %v\n", err)
|
||||
return
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(cfg.OutputDir, "settings.json"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
var settings runtimeSettings
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, &settings)
|
||||
}
|
||||
if err == nil {
|
||||
err = settings.validate()
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Printf(" 界面并发设置读取失败,使用配置文件/默认值: %v\n", err)
|
||||
return
|
||||
}
|
||||
applyRuntimeSettings(cfg, settings)
|
||||
}
|
||||
|
||||
func applyRuntimeSettings(cfg *Config, settings runtimeSettings) {
|
||||
cfg.Concurrency = settings.Concurrency
|
||||
cfg.RequestConcurrency = settings.RequestConcurrency
|
||||
cfg.RequestIntervalMS = settings.RequestIntervalMS
|
||||
if settings.ProxyURL != nil {
|
||||
cfg.ProxyURL = *settings.ProxyURL
|
||||
}
|
||||
if settings.OutputDir != nil {
|
||||
cfg.OutputDir, _ = normalizedOutputDirectory(*settings.OutputDir)
|
||||
}
|
||||
if settings.GroupBySource != nil {
|
||||
cfg.GroupBySource = *settings.GroupBySource
|
||||
}
|
||||
}
|
||||
|
||||
func (a *UIApp) updateRuntimeSettings(writer http.ResponseWriter, request *http.Request) bool {
|
||||
var settings runtimeSettings
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 4096))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&settings); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "无效的并发设置"})
|
||||
return false
|
||||
}
|
||||
if err := settings.validate(); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return false
|
||||
}
|
||||
a.settingsMu.Lock()
|
||||
defer a.settingsMu.Unlock()
|
||||
a.mu.Lock()
|
||||
cfg := a.cfg
|
||||
if settings.ProxyURL == nil {
|
||||
value := a.cfg.ProxyURL
|
||||
settings.ProxyURL = &value
|
||||
}
|
||||
if settings.OutputDir == nil {
|
||||
value := firstNonEmpty(a.nextOutputDir, cfg.outputDirSetting, cfg.OutputDir)
|
||||
settings.OutputDir = &value
|
||||
}
|
||||
if settings.GroupBySource == nil {
|
||||
value := cfg.GroupBySource
|
||||
settings.GroupBySource = &value
|
||||
}
|
||||
a.mu.Unlock()
|
||||
directory, err := normalizedOutputDirectory(*settings.OutputDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return false
|
||||
}
|
||||
settings.OutputDir = &directory
|
||||
if !sameDirectory(directory, cfg.OutputDir) {
|
||||
if err := checkOutputDirectory(directory); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": a.redactError(err)})
|
||||
return false
|
||||
}
|
||||
}
|
||||
if err := saveRuntimeSettings(cfg.runtimeSettingsDirectory(), settings); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "设置保存失败: " + a.redactError(err)})
|
||||
return false
|
||||
}
|
||||
a.downloader.proxyRouter.configure(*settings.ProxyURL)
|
||||
a.downloader.client.CloseIdleConnections()
|
||||
a.mu.Lock()
|
||||
a.cfg.Concurrency = settings.Concurrency
|
||||
a.cfg.RequestConcurrency = settings.RequestConcurrency
|
||||
a.cfg.RequestIntervalMS = settings.RequestIntervalMS
|
||||
a.cfg.ProxyURL = *settings.ProxyURL
|
||||
a.cfg.GroupBySource = *settings.GroupBySource
|
||||
a.downloader.directoryMu.Lock()
|
||||
a.downloader.downloadGrouping = settings.GroupBySource
|
||||
a.downloader.directoryMu.Unlock()
|
||||
a.nextOutputDir = directory
|
||||
a.downloader.limiter.configure(settings.RequestConcurrency, time.Duration(settings.RequestIntervalMS)*time.Millisecond)
|
||||
a.ensureWorkersLocked()
|
||||
a.cond.Broadcast()
|
||||
a.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func saveRuntimeSettings(directory string, settings runtimeSettings) error {
|
||||
if err := settings.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := runtimeSettingsPath(directory)
|
||||
document, err := readConfigDocument(path)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
document.Download.Concurrency = settings.Concurrency
|
||||
document.Network.RequestConcurrency = settings.RequestConcurrency
|
||||
document.Network.RequestIntervalMS = settings.RequestIntervalMS
|
||||
if settings.OutputDir != nil {
|
||||
document.Download.Directory = *settings.OutputDir
|
||||
}
|
||||
if settings.GroupBySource != nil {
|
||||
document.Download.GroupBySource = *settings.GroupBySource
|
||||
}
|
||||
if settings.ProxyURL != nil {
|
||||
document.Network.Proxy = *settings.ProxyURL
|
||||
}
|
||||
return writeConfigDocument(path, document)
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type configDocument struct {
|
||||
Version int `json:"version"`
|
||||
Download downloadSettings `json:"download"`
|
||||
Network networkSettings `json:"network"`
|
||||
Sources *sourceSettings `json:"sources,omitempty"`
|
||||
Advanced *advancedSettings `json:"advanced,omitempty"`
|
||||
}
|
||||
|
||||
type downloadSettings struct {
|
||||
Directory string `json:"directory"`
|
||||
GroupBySource bool `json:"groupBySource,omitempty"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
FFmpeg string `json:"ffmpeg,omitempty"`
|
||||
}
|
||||
|
||||
type networkSettings struct {
|
||||
Proxy string `json:"proxy"`
|
||||
RequestConcurrency int `json:"requestConcurrency"`
|
||||
RequestIntervalMS int `json:"requestIntervalMs"`
|
||||
}
|
||||
|
||||
type sourceSettings struct {
|
||||
Hongguo *siteSettings `json:"hongguo,omitempty"`
|
||||
}
|
||||
|
||||
type siteSettings struct {
|
||||
BaseURL string `json:"baseURL,omitempty"`
|
||||
}
|
||||
|
||||
type advancedSettings struct {
|
||||
MaxPagesPerSort int `json:"maxPagesPerSort,omitempty"`
|
||||
Retries int `json:"retries,omitempty"`
|
||||
SkipBytes int64 `json:"skipBytes,omitempty"`
|
||||
InsecureTLS bool `json:"insecureTLS,omitempty"`
|
||||
}
|
||||
|
||||
func documentFromConfig(cfg Config) configDocument {
|
||||
defaults := defaultConfig()
|
||||
document := configDocument{
|
||||
Version: 1,
|
||||
Download: downloadSettings{Directory: firstNonEmpty(cfg.outputDirSetting, cfg.OutputDir), GroupBySource: cfg.GroupBySource, Concurrency: cfg.Concurrency},
|
||||
Network: networkSettings{Proxy: firstNonEmpty(cfg.ProxyURL, "auto"), RequestConcurrency: cfg.RequestConcurrency, RequestIntervalMS: cfg.RequestIntervalMS},
|
||||
}
|
||||
if cfg.FFmpeg != "" && cfg.FFmpeg != defaults.FFmpeg {
|
||||
document.Download.FFmpeg = cfg.FFmpeg
|
||||
}
|
||||
sources := sourceSettings{}
|
||||
if cfg.HongguoURL != "" {
|
||||
sources.Hongguo = &siteSettings{BaseURL: cfg.HongguoURL}
|
||||
}
|
||||
if sources.Hongguo != nil {
|
||||
document.Sources = &sources
|
||||
}
|
||||
advanced := advancedSettings{InsecureTLS: cfg.InsecureTLS}
|
||||
if cfg.MaxPagesPerSort != defaults.MaxPagesPerSort {
|
||||
advanced.MaxPagesPerSort = cfg.MaxPagesPerSort
|
||||
}
|
||||
if cfg.Retries != defaults.Retries {
|
||||
advanced.Retries = cfg.Retries
|
||||
}
|
||||
if cfg.SkipBytes != defaults.SkipBytes {
|
||||
advanced.SkipBytes = cfg.SkipBytes
|
||||
}
|
||||
if advanced != (advancedSettings{}) {
|
||||
document.Advanced = &advanced
|
||||
}
|
||||
return document
|
||||
}
|
||||
|
||||
func (document configDocument) config() (Config, error) {
|
||||
if document.Version != 1 {
|
||||
return Config{}, fmt.Errorf("不支持的配置版本 %d,请使用兼容的程序版本", document.Version)
|
||||
}
|
||||
cfg := defaultConfig()
|
||||
cfg.OutputDir = document.Download.Directory
|
||||
cfg.GroupBySource = document.Download.GroupBySource
|
||||
cfg.Concurrency = document.Download.Concurrency
|
||||
cfg.FFmpeg = firstNonEmpty(document.Download.FFmpeg, cfg.FFmpeg)
|
||||
cfg.ProxyURL = document.Network.Proxy
|
||||
cfg.RequestConcurrency = document.Network.RequestConcurrency
|
||||
cfg.RequestIntervalMS = document.Network.RequestIntervalMS
|
||||
if sources := document.Sources; sources != nil {
|
||||
if sources.Hongguo != nil {
|
||||
cfg.HongguoURL = sources.Hongguo.BaseURL
|
||||
}
|
||||
}
|
||||
if advanced := document.Advanced; advanced != nil {
|
||||
if advanced.MaxPagesPerSort > 0 {
|
||||
cfg.MaxPagesPerSort = advanced.MaxPagesPerSort
|
||||
}
|
||||
if advanced.Retries > 0 {
|
||||
cfg.Retries = advanced.Retries
|
||||
}
|
||||
if advanced.SkipBytes > 0 {
|
||||
cfg.SkipBytes = advanced.SkipBytes
|
||||
}
|
||||
cfg.InsecureTLS = advanced.InsecureTLS
|
||||
}
|
||||
settings := runtimeSettings{Concurrency: cfg.Concurrency, RequestConcurrency: cfg.RequestConcurrency, RequestIntervalMS: cfg.RequestIntervalMS, ProxyURL: &cfg.ProxyURL, OutputDir: &cfg.OutputDir}
|
||||
if err := settings.validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := cfg.validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func readConfigDocument(path string) (configDocument, error) {
|
||||
document := documentFromConfig(defaultConfig())
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return document, err
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return document, err
|
||||
}
|
||||
if info.Size() > 1<<20 {
|
||||
return document, errors.New("配置文件超过 1 MB")
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(file, (1<<20)+1))
|
||||
content := &document
|
||||
if err := decoder.Decode(&content); err != nil {
|
||||
return document, fmt.Errorf("配置文件格式无效: %w", err)
|
||||
}
|
||||
if content == nil {
|
||||
return document, errors.New("配置文件必须是 JSON 对象")
|
||||
}
|
||||
if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
|
||||
return document, errors.New("配置文件含有多余内容")
|
||||
}
|
||||
if _, err := document.config(); err != nil {
|
||||
return document, err
|
||||
}
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func writeConfigDocument(path string, document configDocument) error {
|
||||
if _, err := document.config(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.CreateTemp(filepath.Dir(path), ".config-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(file.Name())
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
err = encoder.Encode(document)
|
||||
if err == nil {
|
||||
err = file.Sync()
|
||||
}
|
||||
closeErr := file.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
return os.Rename(file.Name(), path)
|
||||
}
|
||||
|
||||
func loadApplicationConfig(dataDirectory, importPath, outputOverride string) (Config, error) {
|
||||
dataDirectory, err := filepath.Abs(dataDirectory)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
path := runtimeSettingsPath(dataDirectory)
|
||||
document, err := readConfigDocument(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
cfg, legacyDirectories, importErr := importLegacyConfig(firstNonEmpty(importPath, "config.json"), outputOverride)
|
||||
if importErr != nil {
|
||||
return Config{}, importErr
|
||||
}
|
||||
document = documentFromConfig(cfg)
|
||||
if _, err := document.config(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := migrateLegacyData(dataDirectory, legacyDirectories); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := writeConfigDocument(path, document); err != nil {
|
||||
return Config{}, fmt.Errorf("首次生成配置失败: %w", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return Config{}, fmt.Errorf("无法读取 %s,原文件未修改: %w", path, err)
|
||||
}
|
||||
cfg, err := document.config()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.settingsLoaded = true
|
||||
cfg.dataDir = dataDirectory
|
||||
applyConfigEnvironment(&cfg)
|
||||
if outputOverride != "" {
|
||||
cfg.OutputDir = outputOverride
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func importLegacyConfig(path, outputOverride string) (Config, []string, error) {
|
||||
cfg := defaultConfig()
|
||||
body, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||
return cfg, nil, fmt.Errorf("旧配置格式错误,未覆盖原文件: %w", err)
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return cfg, nil, err
|
||||
}
|
||||
if cfg.OutputDir == "" {
|
||||
cfg.OutputDir = defaultOutputDir()
|
||||
}
|
||||
if cfg.RequestConcurrency <= 0 {
|
||||
cfg.RequestConcurrency = 2
|
||||
}
|
||||
if cfg.RequestIntervalMS <= 0 {
|
||||
cfg.RequestIntervalMS = 500
|
||||
}
|
||||
directories := []string{firstNonEmpty(outputOverride, cfg.OutputDir), cfg.OutputDir}
|
||||
candidates := []string{filepath.Join(filepath.Dir(path), "juku-settings.json")}
|
||||
for _, directory := range directories {
|
||||
candidates = append(candidates, filepath.Join(directory, "settings.json"))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
body, err = os.ReadFile(candidate)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return cfg, nil, err
|
||||
}
|
||||
var settings runtimeSettings
|
||||
if err := json.Unmarshal(body, &settings); err != nil {
|
||||
return cfg, nil, fmt.Errorf("旧设置格式错误: %w", err)
|
||||
}
|
||||
if err := settings.validate(); err != nil {
|
||||
return cfg, nil, err
|
||||
}
|
||||
applyRuntimeSettings(&cfg, settings)
|
||||
break
|
||||
}
|
||||
return cfg, append(directories, cfg.OutputDir, filepath.Dir(path)), nil
|
||||
}
|
||||
|
||||
func migrateLegacyData(directory string, candidates []string) error {
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range []string{"library.json", "ui-state.json", "failed.json"} {
|
||||
target := filepath.Join(directory, name)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
continue
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if sameDirectory(directory, candidate) {
|
||||
continue
|
||||
}
|
||||
original := filepath.Join(candidate, name)
|
||||
body, err := os.ReadFile(original)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !json.Valid(body) {
|
||||
return fmt.Errorf("旧数据 %s 格式无效,原文件未修改", original)
|
||||
}
|
||||
file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = file.Write(body)
|
||||
if err == nil {
|
||||
err = file.Sync()
|
||||
}
|
||||
closeErr := file.Close()
|
||||
if err != nil || closeErr != nil {
|
||||
_ = os.Remove(target)
|
||||
return errors.Join(err, closeErr)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
const macDirectoryPickerScript = `ObjC.import('AppKit');
|
||||
function run(arguments) {
|
||||
var application = $.NSApplication.sharedApplication;
|
||||
application.setActivationPolicy($.NSApplicationActivationPolicyAccessory);
|
||||
var panel = $.NSOpenPanel.openPanel;
|
||||
panel.setTitle('选择下载文件夹');
|
||||
panel.setPrompt('选择');
|
||||
panel.setCanChooseFiles(false);
|
||||
panel.setCanChooseDirectories(true);
|
||||
panel.setAllowsMultipleSelection(false);
|
||||
panel.setCanCreateDirectories(true);
|
||||
panel.setDirectoryURL($.NSURL.fileURLWithPath(arguments[0]));
|
||||
application.activateIgnoringOtherApps(true);
|
||||
return panel.runModal === $.NSModalResponseOK ? ObjC.unwrap(panel.URL.path) : '';
|
||||
}`
|
||||
|
||||
const windowsDirectoryPickerScript = `$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
[System.Windows.Forms.Application]::EnableVisualStyles()
|
||||
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
|
||||
$owner = New-Object System.Windows.Forms.Form
|
||||
try {
|
||||
$dialog.Description = '选择下载文件夹'
|
||||
$dialog.ShowNewFolderButton = $true
|
||||
$dialog.SelectedPath = $env:JUKU_PICKER_START_DIRECTORY
|
||||
$owner.ShowInTaskbar = $false
|
||||
$owner.TopMost = $true
|
||||
$owner.Opacity = 0
|
||||
$owner.Show()
|
||||
$owner.Activate()
|
||||
if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
[Console]::Write($dialog.SelectedPath)
|
||||
}
|
||||
} finally {
|
||||
$dialog.Dispose()
|
||||
$owner.Dispose()
|
||||
}`
|
||||
|
||||
var configureDirectoryPickerProcess = func(command *exec.Cmd) {}
|
||||
|
||||
func directoryPickerCommand(ctx context.Context, system, initial string) (*exec.Cmd, bool, error) {
|
||||
var command *exec.Cmd
|
||||
cancelExit := false
|
||||
switch system {
|
||||
case "darwin":
|
||||
command = exec.CommandContext(ctx, "osascript", "-l", "JavaScript", "-e", macDirectoryPickerScript, initial)
|
||||
case "windows":
|
||||
encoded := utf16.Encode([]rune(windowsDirectoryPickerScript))
|
||||
body := make([]byte, len(encoded)*2)
|
||||
for index, value := range encoded {
|
||||
body[index*2], body[index*2+1] = byte(value), byte(value>>8)
|
||||
}
|
||||
command = exec.CommandContext(ctx, "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-STA", "-EncodedCommand", base64.StdEncoding.EncodeToString(body))
|
||||
command.Env = append(os.Environ(), "JUKU_PICKER_START_DIRECTORY="+initial)
|
||||
case "linux":
|
||||
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
|
||||
return nil, false, errors.New("当前没有桌面会话,请手动输入下载路径")
|
||||
}
|
||||
if executable, err := exec.LookPath("zenity"); err == nil {
|
||||
command = exec.CommandContext(ctx, executable, "--file-selection", "--directory", "--title=选择下载文件夹", "--filename="+initial+string(os.PathSeparator))
|
||||
} else if executable, err := exec.LookPath("kdialog"); err == nil {
|
||||
command = exec.CommandContext(ctx, executable, "--getexistingdirectory", initial, "--title", "选择下载文件夹")
|
||||
} else {
|
||||
return nil, false, errors.New("当前桌面没有 zenity 或 kdialog,请安装其一或手动输入下载路径")
|
||||
}
|
||||
cancelExit = true
|
||||
default:
|
||||
return nil, false, errors.New("当前系统暂不支持原生文件夹选择,请手动输入下载路径")
|
||||
}
|
||||
configureDirectoryPickerProcess(command)
|
||||
return command, cancelExit, nil
|
||||
}
|
||||
|
||||
func nativeDirectoryPicker(ctx context.Context, initial string) (string, error) {
|
||||
command, cancelExit, err := directoryPickerCommand(ctx, runtime.GOOS, initial)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
output, err := command.Output()
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
var exitError *exec.ExitError
|
||||
if cancelExit && errors.As(err, &exitError) && exitError.ExitCode() == 1 && strings.TrimSpace(string(exitError.Stderr)) == "" {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("系统文件夹选择框启动失败,请确认桌面会话可用,或手动输入路径: %w", err)
|
||||
}
|
||||
return strings.TrimRight(strings.TrimPrefix(string(output), "\ufeff"), "\r\n"), nil
|
||||
}
|
||||
|
||||
func pickerInitialDirectory(initial, fallback string) string {
|
||||
for _, candidate := range []string{initial, fallback, "."} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
absolute, err := filepath.Abs(candidate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for {
|
||||
if info, err := os.Stat(absolute); err == nil && info.IsDir() {
|
||||
return absolute
|
||||
}
|
||||
parent := filepath.Dir(absolute)
|
||||
if parent == absolute {
|
||||
break
|
||||
}
|
||||
absolute = parent
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func directorySelectionPath(absolute string) string {
|
||||
if working, err := os.Getwd(); err == nil {
|
||||
if relative, err := filepath.Rel(working, absolute); err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) {
|
||||
return relative
|
||||
}
|
||||
}
|
||||
return absolute
|
||||
}
|
||||
|
||||
func localDirectoryPickerRequest(request *http.Request) bool {
|
||||
remote, _, err := net.SplitHostPort(request.RemoteAddr)
|
||||
if err != nil || !net.ParseIP(remote).IsLoopback() {
|
||||
return false
|
||||
}
|
||||
host := (&url.URL{Host: request.Host}).Hostname()
|
||||
if !strings.EqualFold(host, "localhost") && !net.ParseIP(host).IsLoopback() {
|
||||
return false
|
||||
}
|
||||
if site := request.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
|
||||
return false
|
||||
}
|
||||
if origin := request.Header.Get("Origin"); origin != "" {
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil || parsed.User != nil || parsed.Scheme != "http" && parsed.Scheme != "https" || !strings.EqualFold(parsed.Host, request.Host) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *UIApp) handleDirectoryPicker(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
if request.Method != http.MethodPost {
|
||||
writer.Header().Set("Allow", http.MethodPost)
|
||||
writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
if !localDirectoryPickerRequest(request) {
|
||||
writeJSON(writer, http.StatusForbidden, map[string]string{"error": "请在运行程序的电脑上使用 localhost 或 127.0.0.1 打开界面;远程访问请手动输入服务器路径"})
|
||||
return
|
||||
}
|
||||
contentType, _, _ := mime.ParseMediaType(request.Header.Get("Content-Type"))
|
||||
if contentType != "application/json" {
|
||||
writeJSON(writer, http.StatusUnsupportedMediaType, map[string]string{"error": "请求必须使用 JSON"})
|
||||
return
|
||||
}
|
||||
var selection struct {
|
||||
InitialPath string `json:"initialPath"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 16384))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&selection); err != nil || len(selection.InitialPath) > 8192 || strings.ContainsAny(selection.InitialPath, "\x00\r\n") {
|
||||
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "无效的初始目录"})
|
||||
return
|
||||
}
|
||||
if !a.directoryPickerMu.TryLock() {
|
||||
writeJSON(writer, http.StatusConflict, map[string]string{"error": "系统文件夹选择框已打开,请先完成或取消选择"})
|
||||
return
|
||||
}
|
||||
defer a.directoryPickerMu.Unlock()
|
||||
a.mu.Lock()
|
||||
output, picker := a.cfg.OutputDir, a.directoryPicker
|
||||
a.mu.Unlock()
|
||||
if picker == nil {
|
||||
picker = nativeDirectoryPicker
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(request.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
path, err := picker(ctx, pickerInitialDirectory(selection.InitialPath, output))
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
writeJSON(writer, http.StatusGatewayTimeout, map[string]string{"error": "文件夹选择超时,请重试或手动输入路径"})
|
||||
} else {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": a.redactError(err)})
|
||||
}
|
||||
return
|
||||
}
|
||||
if path == "" {
|
||||
writeJSON(writer, http.StatusOK, map[string]bool{"canceled": true})
|
||||
return
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !filepath.IsAbs(path) || strings.ContainsAny(path, "\x00\r\n") || !info.IsDir() {
|
||||
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "系统未返回有效文件夹,请重新选择"})
|
||||
return
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
writeJSON(writer, http.StatusOK, map[string]any{"path": path, "selectionPath": directorySelectionPath(path), "canceled": false})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configureDirectoryPickerProcess = func(command *exec.Cmd) {
|
||||
command.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const diagnosticLogBytes = 5 * 1024 * 1024
|
||||
const diagnosticLogBackups = 2
|
||||
|
||||
type diagnosticEvent struct {
|
||||
HTTPStatus int `json:"httpStatus,omitempty"`
|
||||
RequestedQuality int `json:"requestedQuality,omitempty"`
|
||||
Time time.Time `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Event string `json:"event"`
|
||||
Source string `json:"source,omitempty"`
|
||||
DramaID string `json:"dramaId,omitempty"`
|
||||
DramaTitle string `json:"dramaTitle,omitempty"`
|
||||
Episode int `json:"episode,omitempty"`
|
||||
Run uint64 `json:"run,omitempty"`
|
||||
StartSeconds float64 `json:"startSeconds,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
OffsetBytes int64 `json:"offsetBytes,omitempty"`
|
||||
TotalBytes int64 `json:"totalBytes,omitempty"`
|
||||
Attempt int `json:"attempt,omitempty"`
|
||||
Quality int `json:"quality,omitempty"`
|
||||
AvailableQualities []int `json:"availableQualities,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type diagnosticLog struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
limit int64
|
||||
warningOnce sync.Once
|
||||
}
|
||||
|
||||
func newDiagnosticLog(directory string) *diagnosticLog {
|
||||
return &diagnosticLog{path: filepath.Join(directory, "logs", "app.log"), limit: diagnosticLogBytes}
|
||||
}
|
||||
|
||||
func (log *diagnosticLog) write(event diagnosticEvent) error {
|
||||
event.Time = time.Now()
|
||||
if event.Level == "" {
|
||||
event.Level = "error"
|
||||
}
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
log.mu.Lock()
|
||||
defer log.mu.Unlock()
|
||||
if int64(len(data)) > log.limit {
|
||||
return errors.New("单条诊断日志过大")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(log.path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(log.path)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if info != nil && info.Size()+int64(len(data)) > log.limit {
|
||||
if err := log.rotate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
file, err := os.OpenFile(log.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, writeErr := file.Write(data)
|
||||
return errors.Join(writeErr, file.Close())
|
||||
}
|
||||
|
||||
func (log *diagnosticLog) rotate() error {
|
||||
oldest := log.path + "." + strconv.Itoa(diagnosticLogBackups)
|
||||
if err := os.Remove(oldest); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
for index := diagnosticLogBackups; index > 0; index-- {
|
||||
previous := log.path
|
||||
if index > 1 {
|
||||
previous += "." + strconv.Itoa(index-1)
|
||||
}
|
||||
if err := os.Rename(previous, log.path+"."+strconv.Itoa(index)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (downloader *Downloader) recordDiagnostic(event diagnosticEvent) {
|
||||
if downloader == nil || downloader.diagnostics == nil {
|
||||
return
|
||||
}
|
||||
event.Message = truncate(redactErrorString(event.Message), 4096)
|
||||
event.DramaTitle = truncate(redactErrorString(event.DramaTitle), 256)
|
||||
if err := downloader.diagnostics.write(event); err != nil {
|
||||
downloader.diagnostics.warningOnce.Do(func() {
|
||||
fmt.Fprintf(os.Stderr, "诊断日志保存失败:%v\n", publicError(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (downloader *Downloader) recordTaskFailure(event string, task Task, start float64, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
downloader.recordDiagnostic(diagnosticEvent{Event: event, Source: firstNonEmpty(task.Chapter.Source, sourceFromDramaID(task.DramaID)),
|
||||
DramaID: task.DramaID, DramaTitle: task.DramaTitle, Episode: task.Index, StartSeconds: start, Message: err.Error()})
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func readDiagnosticEvents(t *testing.T, path string) []diagnosticEvent {
|
||||
t.Helper()
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
var events []diagnosticEvent
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
var event diagnosticEvent
|
||||
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil || event.Time.IsZero() || event.Event == "" {
|
||||
t.Fatal("invalid diagnostic record", err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func TestDiagnosticLogConcurrentWritesAndRedaction(t *testing.T) {
|
||||
log := newDiagnosticLog(t.TempDir())
|
||||
d := &Downloader{diagnostics: log}
|
||||
var group sync.WaitGroup
|
||||
for index := 0; index < 50; index++ {
|
||||
group.Add(1)
|
||||
go func(index int) {
|
||||
defer group.Done()
|
||||
d.recordDiagnostic(diagnosticEvent{Event: "fixture.failed", Episode: index + 1, Message: "https://user:fixture-password@media.invalid/video?token=fixture-token&key=fixture-media-key&signature=fixture-signature#fixture-fragment"})
|
||||
}(index)
|
||||
}
|
||||
group.Wait()
|
||||
events := readDiagnosticEvents(t, log.path)
|
||||
seen := make(map[int]bool)
|
||||
for _, event := range events {
|
||||
seen[event.Episode] = true
|
||||
for _, secret := range []string{"fixture-token", "fixture-media-key", "fixture-password", "fixture-signature", "fixture-fragment"} {
|
||||
if strings.Contains(event.Message, secret) {
|
||||
t.Fatal("diagnostic log exposed a credential or signed URL parameter")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(events) != 50 || len(seen) != 50 {
|
||||
t.Fatal("concurrent diagnostic events were lost or interleaved")
|
||||
}
|
||||
info, err := os.Stat(log.path)
|
||||
if err != nil || runtime.GOOS != "windows" && info.Mode().Perm()&0077 != 0 {
|
||||
t.Fatal("new diagnostic file was not private", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticLogRotationBoundsFiles(t *testing.T) {
|
||||
log := newDiagnosticLog(t.TempDir())
|
||||
log.limit = 512
|
||||
for index := 0; index < 30; index++ {
|
||||
if err := log.write(diagnosticEvent{Event: "fixture.rotate", Episode: index, Message: strings.Repeat("x", 100)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
files, err := os.ReadDir(filepath.Dir(log.path))
|
||||
if err != nil || len(files) != diagnosticLogBackups+1 {
|
||||
t.Fatal("diagnostic rotation exceeded its file bound", err)
|
||||
}
|
||||
for _, file := range files {
|
||||
info, err := file.Info()
|
||||
if err != nil || info.Size() > log.limit || len(readDiagnosticEvents(t, filepath.Join(filepath.Dir(log.path), file.Name()))) == 0 {
|
||||
t.Fatal("rotation kept an oversized or invalid file", err)
|
||||
}
|
||||
}
|
||||
current := readDiagnosticEvents(t, log.path)
|
||||
if current[len(current)-1].Episode != 29 {
|
||||
t.Fatal("rotation discarded the latest event")
|
||||
}
|
||||
if err := newDiagnosticLog(filepath.Dir(filepath.Dir(log.path))).write(diagnosticEvent{Event: "fixture.restart", Message: "restart"}); err != nil {
|
||||
t.Fatal("reopening diagnostics failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticWriteFailureDoesNotReplacePlaybackError(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(directory, "logs"), []byte("keep"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
log := newDiagnosticLog(directory)
|
||||
if err := log.write(diagnosticEvent{Event: "fixture.failed", Message: "failure"}); err == nil {
|
||||
t.Fatal("unwritable diagnostics unexpectedly succeeded")
|
||||
}
|
||||
d := &Downloader{diagnostics: log}
|
||||
d.recordTaskFailure("playback.failed", Task{}, 49, errors.New("unexpected EOF"))
|
||||
data, err := os.ReadFile(filepath.Join(directory, "logs"))
|
||||
if err != nil || string(data) != "keep" {
|
||||
t.Fatal("diagnostics replaced an existing file", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackFailureLogIncludesEpisodeAndSkipsStoppedRun(t *testing.T) {
|
||||
app, session := prefetchFixtureApp(t)
|
||||
session.tasks[0] = Task{DramaID: "hongguo:7000000000000000001", DramaTitle: "合成播放条目", Index: 1}
|
||||
run, _, err := app.beginPlayback(viewerFixtureContext(app, context.Background()), "fixture", 1, 49, 0, 1, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer run.stop()
|
||||
app.finishPlaybackRun(run, errors.New("unexpected EOF"), false)
|
||||
app.finishPlaybackRun(run, context.Canceled, true)
|
||||
events := readDiagnosticEvents(t, app.downloader.diagnostics.path)
|
||||
failures := 0
|
||||
for _, event := range events {
|
||||
if event.Event == "playback.failed" {
|
||||
failures++
|
||||
if event.DramaID != run.task.DramaID || event.DramaTitle != run.task.DramaTitle || event.Episode != 1 || event.StartSeconds != 49 || event.Message != "unexpected EOF" {
|
||||
t.Fatal("playback failure lost its diagnostic context", fmt.Sprint(event))
|
||||
}
|
||||
}
|
||||
}
|
||||
if failures != 1 {
|
||||
t.Fatal("stopping playback generated a false diagnostic failure")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type downloadCheckpointState struct {
|
||||
URL string `json:"url"`
|
||||
Validator string `json:"validator,omitempty"`
|
||||
Total int64 `json:"total,omitempty"`
|
||||
Complete bool `json:"complete,omitempty"`
|
||||
}
|
||||
|
||||
type downloadCheckpointRequest func(context.Context, int64, string) (*http.Request, error)
|
||||
type downloadCheckpointFetch func(*http.Request) (*http.Response, error)
|
||||
|
||||
type downloadMediaCache struct {
|
||||
directory string
|
||||
}
|
||||
|
||||
func downloadHTTPValidator(header http.Header) string {
|
||||
if value := strings.TrimSpace(header.Get("ETag")); value != "" && !strings.HasPrefix(value, "W/") {
|
||||
return value
|
||||
}
|
||||
if value := strings.TrimSpace(header.Get("Last-Modified")); value != "" {
|
||||
if _, err := http.ParseTime(value); err == nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readDownloadCheckpoint(path, address string) downloadCheckpointState {
|
||||
var state downloadCheckpointState
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil || len(body) > 64<<10 || json.Unmarshal(body, &state) != nil || state.URL != address {
|
||||
return downloadCheckpointState{}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func writeDownloadCheckpoint(path string, state downloadCheckpointState) error {
|
||||
body, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.CreateTemp(filepath.Dir(path), ".download-checkpoint-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := file.Name()
|
||||
defer os.Remove(name)
|
||||
if _, err = file.Write(body); err == nil {
|
||||
err = file.Sync()
|
||||
}
|
||||
if closeErr := file.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(name, path)
|
||||
}
|
||||
|
||||
func checkpointExistingComplete(path string, state downloadCheckpointState, key bool) (int64, bool) {
|
||||
if !state.Complete {
|
||||
return 0, false
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() || info.Size() <= 0 || state.Total > 0 && info.Size() != state.Total || key && info.Size() != 16 {
|
||||
return 0, false
|
||||
}
|
||||
return info.Size(), true
|
||||
}
|
||||
|
||||
func checkpointPartialOffset(path string, state downloadCheckpointState, key bool) int64 {
|
||||
if state.Validator == "" || key {
|
||||
return 0
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() || info.Size() <= 0 || state.Total > 0 && info.Size() > state.Total {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
func runDownloadCheckpoint(ctx context.Context, address, target, statePath string, key bool, build downloadCheckpointRequest, fetch downloadCheckpointFetch, progress func(int64, int64)) (int64, error) {
|
||||
if !isProviderHTTPMediaURL(address) {
|
||||
return 0, errors.New("媒体下载地址无效")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
partial := target + ".part"
|
||||
state := readDownloadCheckpoint(statePath, address)
|
||||
if size, ok := checkpointExistingComplete(target, state, key); ok {
|
||||
if progress != nil {
|
||||
progress(size, size)
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
_ = os.Remove(target)
|
||||
offset := checkpointPartialOffset(partial, state, key)
|
||||
for restart := 0; restart < 2; restart++ {
|
||||
size, reset, err := runDownloadCheckpointAttempt(ctx, address, target, partial, statePath, key, offset, state, build, fetch, progress)
|
||||
if !reset {
|
||||
return size, err
|
||||
}
|
||||
offset, state = 0, downloadCheckpointState{}
|
||||
_ = os.Remove(partial)
|
||||
_ = os.Remove(statePath)
|
||||
}
|
||||
return 0, errors.New("服务器无法恢复下载,请重试")
|
||||
}
|
||||
|
||||
func runDownloadCheckpointAttempt(ctx context.Context, address, target, partial, statePath string, key bool, offset int64, old downloadCheckpointState, build downloadCheckpointRequest, fetch downloadCheckpointFetch, progress func(int64, int64)) (int64, bool, error) {
|
||||
request, err := build(ctx, offset, old.Validator)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
response, err := fetch(request)
|
||||
if err != nil {
|
||||
return offset, false, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if offset > 0 && (response.StatusCode == http.StatusRequestedRangeNotSatisfiable || response.StatusCode == http.StatusPreconditionFailed) {
|
||||
return 0, true, nil
|
||||
}
|
||||
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusPartialContent {
|
||||
return offset, false, fmt.Errorf("媒体下载失败 HTTP %d", response.StatusCode)
|
||||
}
|
||||
if response.StatusCode == http.StatusOK {
|
||||
offset = 0
|
||||
}
|
||||
if !identityMediaResponse(response) {
|
||||
return offset, false, errors.New("服务器返回了压缩或分段媒体响应,无法安全续传")
|
||||
}
|
||||
contentType := strings.ToLower(response.Header.Get("Content-Type"))
|
||||
if !key && (strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/json")) {
|
||||
return offset, false, errors.New("站源返回了错误页面,请刷新下载地址后重试")
|
||||
}
|
||||
total := response.ContentLength
|
||||
validator := downloadHTTPValidator(response.Header)
|
||||
if response.StatusCode == http.StatusPartialContent {
|
||||
span, valid := parseMediaByteRange(response.Header.Get("Content-Range"))
|
||||
if !valid || span.start != offset || response.ContentLength >= 0 && response.ContentLength != span.end-span.start+1 {
|
||||
return offset, false, errors.New("服务器返回的续传范围不正确,已停止下载")
|
||||
}
|
||||
if offset > 0 && (validator != "" && validator != old.Validator || old.Total > 0 && old.Total != span.total) {
|
||||
return 0, true, nil
|
||||
}
|
||||
total = span.total
|
||||
}
|
||||
if key && (total > 64 || offset > 0) {
|
||||
return offset, false, errors.New("视频密钥格式无效")
|
||||
}
|
||||
state := downloadCheckpointState{URL: address, Validator: validator, Total: total}
|
||||
if err := writeDownloadCheckpoint(statePath, state); err != nil {
|
||||
return offset, false, err
|
||||
}
|
||||
flags := os.O_CREATE | os.O_WRONLY
|
||||
if offset == 0 {
|
||||
flags |= os.O_TRUNC
|
||||
} else {
|
||||
flags |= os.O_APPEND
|
||||
}
|
||||
file, err := os.OpenFile(partial, flags, 0o600)
|
||||
if err != nil {
|
||||
return offset, false, err
|
||||
}
|
||||
received := offset
|
||||
buffer := make([]byte, 64<<10)
|
||||
for {
|
||||
count, readErr := response.Body.Read(buffer)
|
||||
if count > 0 {
|
||||
if key && received+int64(count) > 64 {
|
||||
err = errors.New("视频密钥过大")
|
||||
break
|
||||
}
|
||||
written, writeErr := file.Write(buffer[:count])
|
||||
received += int64(written)
|
||||
if writeErr != nil || written != count {
|
||||
err = errors.New("写入下载文件失败,请检查剩余空间")
|
||||
break
|
||||
}
|
||||
if progress != nil {
|
||||
progress(received, total)
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
if readErr != io.EOF {
|
||||
err = errors.New("媒体文件未接收完整,点击继续下载")
|
||||
}
|
||||
break
|
||||
}
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
err = ctxErr
|
||||
break
|
||||
}
|
||||
}
|
||||
if err == nil && (received == 0 || total >= 0 && received != total) {
|
||||
err = errors.New("媒体文件未接收完整,点击继续下载")
|
||||
}
|
||||
if err == nil && key && received != 16 {
|
||||
err = errors.New("视频密钥长度无效")
|
||||
}
|
||||
if err == nil {
|
||||
err = file.Sync()
|
||||
}
|
||||
closeErr := file.Close()
|
||||
if err != nil {
|
||||
return received, false, err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return received, false, closeErr
|
||||
}
|
||||
if err = os.Rename(partial, target); err != nil {
|
||||
return received, false, err
|
||||
}
|
||||
state.Complete, state.Total = true, received
|
||||
if err = writeDownloadCheckpoint(statePath, state); err != nil {
|
||||
return received, false, err
|
||||
}
|
||||
return received, false, nil
|
||||
}
|
||||
|
||||
func directDownloadMedia(media providerMedia, key []byte) bool {
|
||||
if media.Playlist != "" || len(key) > 0 || len(media.HLSKey) > 0 || len(media.CENCKey) > 0 || !validProviderMediaCredentials(media.credentials, providerMediaCredentialReserve) {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(media.URL)
|
||||
return err == nil && strings.HasSuffix(strings.ToLower(parsed.Path), ".mp4")
|
||||
}
|
||||
|
||||
func (d *Downloader) downloadDirectProviderMedia(ctx context.Context, task Task, media providerMedia, key []byte, progress *downloadProgressState) (bool, error) {
|
||||
if !directDownloadMedia(media, key) {
|
||||
return false, nil
|
||||
}
|
||||
candidates := append([]providerMedia{media}, mediaFallbackVariants(media)...)
|
||||
client := *d.client
|
||||
client.Timeout = 0
|
||||
var lastErr error
|
||||
for _, candidate := range candidates {
|
||||
if !directDownloadMedia(candidate, key) {
|
||||
continue
|
||||
}
|
||||
build := func(ctx context.Context, offset int64, validator string) (*http.Request, error) {
|
||||
request, err := http.NewRequestWithContext(providerMediaContext(ctx, candidate.credentials), http.MethodGet, candidate.URL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Referer", candidate.Referer)
|
||||
request.Header.Set("Accept-Encoding", "identity")
|
||||
if offset > 0 {
|
||||
request.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
|
||||
request.Header.Set("If-Range", validator)
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
fetch := func(request *http.Request) (*http.Response, error) {
|
||||
return d.doMediaRequestWithClient(request, &client)
|
||||
}
|
||||
size, err := runDownloadCheckpoint(ctx, candidate.URL, task.OutPath, task.OutPath+".download.json", false, build, fetch, func(received, total int64) {
|
||||
if progress == nil {
|
||||
return
|
||||
}
|
||||
progress.setByteProgress(received, total)
|
||||
progress.report("downloading", false)
|
||||
})
|
||||
if err == nil {
|
||||
if progress != nil {
|
||||
progress.setTotalBytes(size)
|
||||
progress.report("completed", true)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return true, lastErr
|
||||
}
|
||||
|
||||
func prepareDownloadMediaCache(directory string, media providerMedia, key []byte) (*downloadMediaCache, error) {
|
||||
fingerprint := sha256.New()
|
||||
for _, value := range []string{media.URL, media.Referer, media.Playlist, hex.EncodeToString(media.HLSKey), hex.EncodeToString(media.CENCKey), hex.EncodeToString(key), fmt.Sprint(media.Quality)} {
|
||||
_, _ = fingerprint.Write([]byte(value))
|
||||
_, _ = fingerprint.Write([]byte{0})
|
||||
}
|
||||
marker := hex.EncodeToString(fingerprint.Sum(nil))
|
||||
markerPath := filepath.Join(directory, ".fingerprint")
|
||||
if previous, err := os.ReadFile(markerPath); err == nil && strings.TrimSpace(string(previous)) != marker {
|
||||
if err := os.RemoveAll(directory); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeNewViewerFile(markerPath, []byte(marker)); err != nil {
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
return nil, err
|
||||
}
|
||||
if previous, readErr := os.ReadFile(markerPath); readErr != nil || strings.TrimSpace(string(previous)) != marker {
|
||||
return nil, errors.New("下载检查点无法保存")
|
||||
}
|
||||
}
|
||||
return &downloadMediaCache{directory: directory}, nil
|
||||
}
|
||||
|
||||
func (cache *downloadMediaCache) path(address string) (string, string) {
|
||||
sum := sha256.Sum256([]byte(address))
|
||||
base := filepath.Join(cache.directory, hex.EncodeToString(sum[:]))
|
||||
return base + ".bin", base + ".json"
|
||||
}
|
||||
|
||||
func (cache *downloadMediaCache) open(ctx context.Context, proxy *hlsProxy, asset hlsAsset, build func(hlsAsset) (*http.Request, error)) (*os.File, int64, error) {
|
||||
target, statePath := cache.path(asset.remote)
|
||||
buildRequest := func(ctx context.Context, offset int64, validator string) (*http.Request, error) {
|
||||
request, err := build(asset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request = request.WithContext(ctx)
|
||||
request.Header.Set("Accept-Encoding", "identity")
|
||||
if offset > 0 {
|
||||
request.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
|
||||
request.Header.Set("If-Range", validator)
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
size, err := runDownloadCheckpoint(ctx, asset.remote, target, statePath, asset.extension == ".key", buildRequest, proxy.fetch, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
file, err := os.Open(target)
|
||||
return file, size, err
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func dramaSourceFolder(drama Drama) string {
|
||||
source := accountSourceGroup(firstNonEmpty(drama.Source, sourceFromDramaID(drama.ID)))
|
||||
if source == "" {
|
||||
source = accountDramaSource(drama.ID)
|
||||
}
|
||||
for _, choice := range accountSourceChoices {
|
||||
if choice.ID == source {
|
||||
return safeFilename(choice.Name)
|
||||
}
|
||||
}
|
||||
return "其他来源"
|
||||
}
|
||||
|
||||
func readDownloadDirectoryID(directory string) (string, error) {
|
||||
path := filepath.Join(directory, ".drama-id")
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() > 1024 {
|
||||
return "", errors.New("下载目录标识不是有效的普通文件")
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
return strings.TrimSpace(string(body)), err
|
||||
}
|
||||
|
||||
func scanDownloadDirectories(root string) (map[string]string, error) {
|
||||
directories := make(map[string]string)
|
||||
groups := map[string]bool{}
|
||||
for _, choice := range accountSourceChoices {
|
||||
groups[safeFilename(choice.Name)] = true
|
||||
}
|
||||
var scan func(string, bool) error
|
||||
scan = func(parent string, grouped bool) error {
|
||||
entries, err := os.ReadDir(parent)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 {
|
||||
continue
|
||||
}
|
||||
directory := filepath.Join(parent, entry.Name())
|
||||
id, err := readDownloadDirectoryID(directory)
|
||||
if err != nil {
|
||||
|
||||
continue
|
||||
}
|
||||
if id != "" {
|
||||
if sourceFromDramaID(id) != sourceHongguo {
|
||||
continue
|
||||
}
|
||||
if previous, exists := directories[id]; exists && previous != directory {
|
||||
directories[id] = ""
|
||||
} else if !exists {
|
||||
directories[id] = directory
|
||||
}
|
||||
} else if !grouped && groups[entry.Name()] {
|
||||
if err := scan(directory, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
err := scan(root, false)
|
||||
return directories, err
|
||||
}
|
||||
|
||||
func claimDownloadDirectory(directory, id string, known bool) (bool, error) {
|
||||
if err := os.MkdirAll(directory, 0755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
info, err := os.Lstat(directory)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return false, errors.New("下载剧集目录不是普通文件夹")
|
||||
}
|
||||
owner, err := readDownloadDirectoryID(directory)
|
||||
if err != nil || owner != "" {
|
||||
return owner == id, err
|
||||
}
|
||||
if !known {
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
file, err := os.OpenFile(filepath.Join(directory, ".drama-id"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
owner, readErr := readDownloadDirectoryID(directory)
|
||||
return owner == id, readErr
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = file.WriteString(id)
|
||||
if closeErr := file.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func downloadSourceDirectory(root, source string) (string, error) {
|
||||
if err := os.MkdirAll(root, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
directory := filepath.Join(root, source)
|
||||
if err := os.Mkdir(directory, 0755); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Lstat(directory)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", errors.New("下载站源目录不是普通文件夹")
|
||||
}
|
||||
if _, err = os.Lstat(filepath.Join(directory, ".drama-id")); !errors.Is(err, os.ErrNotExist) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", fmt.Errorf("站源目录“%s”与已有剧集目录同名,请更换新下载的根目录;原剧集仍使用原目录", source)
|
||||
}
|
||||
return directory, nil
|
||||
}
|
||||
|
||||
func (d *Downloader) downloadDramaDirectory(drama Drama, title, existing string) (string, error) {
|
||||
var valid bool
|
||||
drama, valid = normalizeHongguoDrama(drama)
|
||||
if !valid {
|
||||
return "", errors.New("此版本仅支持红果剧集")
|
||||
}
|
||||
d.directoryMu.Lock()
|
||||
defer d.directoryMu.Unlock()
|
||||
if drama.ID == "" || len(drama.ID) > 512 || strings.ContainsAny(drama.ID, "\x00\r\n") {
|
||||
return "", errors.New("下载剧集标识无效")
|
||||
}
|
||||
if d.downloadDirectories == nil {
|
||||
var err error
|
||||
d.downloadDirectories, err = scanDownloadDirectories(d.cfg.OutputDir)
|
||||
if err != nil {
|
||||
d.downloadDirectories = nil
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if existing == "" {
|
||||
var found bool
|
||||
existing, found = d.downloadDirectories[drama.ID]
|
||||
if found && existing == "" {
|
||||
return "", errors.New("发现本剧的多个已有目录,请保留下载任务以确定更新位置")
|
||||
}
|
||||
}
|
||||
if existing != "" {
|
||||
ok, err := claimDownloadDirectory(existing, drama.ID, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", errors.New("已有下载目录属于其他剧集,原文件已保留")
|
||||
}
|
||||
d.downloadDirectories[drama.ID] = existing
|
||||
return existing, nil
|
||||
}
|
||||
root := d.cfg.OutputDir
|
||||
grouped := d.cfg.GroupBySource
|
||||
if d.downloadGrouping != nil {
|
||||
grouped = *d.downloadGrouping
|
||||
}
|
||||
if grouped {
|
||||
var err error
|
||||
root, err = downloadSourceDirectory(root, dramaSourceFolder(drama))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
for _, name := range []string{title, title + "_" + hashShort(drama.ID)} {
|
||||
directory, err := safeJoin(root, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ok, err := claimDownloadDirectory(directory, drama.ID, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ok {
|
||||
d.downloadDirectories[drama.ID] = directory
|
||||
return directory, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("下载目录存在同名内容,原文件已保留")
|
||||
}
|
||||
|
||||
func (app *UIApp) dramaDirectoryLocked(id string) string {
|
||||
if directory := app.dramaDirectories[id]; directory != "" {
|
||||
return directory
|
||||
}
|
||||
for _, taskID := range app.taskOrder {
|
||||
if task := app.tasks[taskID]; task != nil && task.DramaID == id && !isChapterPlaceholderTask(task) && task.Path != "" {
|
||||
return filepath.Dir(task.Path)
|
||||
}
|
||||
}
|
||||
if merged := app.merges[id]; merged != nil && merged.OutputPath != "" {
|
||||
return filepath.Dir(merged.OutputPath)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user