diff --git a/internal/app/cli.go b/internal/app/cli.go new file mode 100644 index 0000000..973c1e3 --- /dev/null +++ b/internal/app/cli.go @@ -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) + } +} diff --git a/internal/app/config_defaults.go b/internal/app/config_defaults.go new file mode 100644 index 0000000..3ccb3b5 --- /dev/null +++ b/internal/app/config_defaults.go @@ -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 +} diff --git a/internal/app/config_runtime.go b/internal/app/config_runtime.go new file mode 100644 index 0000000..3b27b27 --- /dev/null +++ b/internal/app/config_runtime.go @@ -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) +} diff --git a/internal/app/config_store.go b/internal/app/config_store.go new file mode 100644 index 0000000..f8ab490 --- /dev/null +++ b/internal/app/config_store.go @@ -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 +} diff --git a/internal/app/desktop_picker.go b/internal/app/desktop_picker.go new file mode 100644 index 0000000..f5b565b --- /dev/null +++ b/internal/app/desktop_picker.go @@ -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}) +} diff --git a/internal/app/desktop_picker_windows.go b/internal/app/desktop_picker_windows.go new file mode 100644 index 0000000..4ad8e5b --- /dev/null +++ b/internal/app/desktop_picker_windows.go @@ -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} + } +} diff --git a/internal/app/diagnostics.go b/internal/app/diagnostics.go new file mode 100644 index 0000000..e85bc7a --- /dev/null +++ b/internal/app/diagnostics.go @@ -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()}) +} diff --git a/internal/app/diagnostics_test.go b/internal/app/diagnostics_test.go new file mode 100644 index 0000000..9c5a6ea --- /dev/null +++ b/internal/app/diagnostics_test.go @@ -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") + } +} diff --git a/internal/app/download_checkpoint.go b/internal/app/download_checkpoint.go new file mode 100644 index 0000000..60213b5 --- /dev/null +++ b/internal/app/download_checkpoint.go @@ -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 +} diff --git a/internal/app/download_directories.go b/internal/app/download_directories.go new file mode 100644 index 0000000..faeadcf --- /dev/null +++ b/internal/app/download_directories.go @@ -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 "" +}