feat(app): add internal components batch 1

This commit is contained in:
吴璨
2026-09-30 10:19:23 +08:00
parent b094d0a6b1
commit a9d79bbe23
10 changed files with 1927 additions and 0 deletions
+296
View File
@@ -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)
}
}
+63
View File
@@ -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
}
+187
View File
@@ -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)
}
+308
View File
@@ -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
}
+226
View File
@@ -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})
}
+12
View File
@@ -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}
}
}
+120
View File
@@ -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()})
}
+137
View File
@@ -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")
}
}
+350
View File
@@ -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
}
+228
View File
@@ -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 ""
}