Files
ggjuku/internal/app/cli.go
T
2026-09-30 10:19:23 +08:00

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)
}
}