297 lines
9.5 KiB
Go
297 lines
9.5 KiB
Go
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)
|
|
}
|
|
}
|