feat(app): add internal components batch 1
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultUIListenAddress = "0.0.0.0:8999"
|
||||
|
||||
type boolOverride struct {
|
||||
set bool
|
||||
val bool
|
||||
}
|
||||
|
||||
func (b *boolOverride) String() string { return strconv.FormatBool(b.val) }
|
||||
|
||||
func (b *boolOverride) Set(s string) error {
|
||||
v, err := strconv.ParseBool(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.set = true
|
||||
b.val = v
|
||||
return nil
|
||||
}
|
||||
|
||||
func openBrowser(rawURL string) {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", rawURL)
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
|
||||
default:
|
||||
cmd = exec.Command("xdg-open", rawURL)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Printf(" 自动打开浏览器失败,请手动访问:%s (%v)\n", rawURL, err)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeListen(addr string) string {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
return defaultUIListenAddress
|
||||
}
|
||||
if strings.HasPrefix(addr, ":") {
|
||||
return "0.0.0.0" + addr
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func publicURL(addr string) string {
|
||||
if strings.HasPrefix(addr, "127.0.0.1:") || strings.HasPrefix(addr, "localhost:") {
|
||||
return "http://" + addr
|
||||
}
|
||||
if strings.HasPrefix(addr, "0.0.0.0:") || strings.HasPrefix(addr, "[::]:") {
|
||||
if _, port, ok := strings.Cut(addr, ":"); ok {
|
||||
return "http://127.0.0.1:" + port
|
||||
}
|
||||
}
|
||||
return "http://" + addr
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Print(`果果剧库 - 红果点播与下载工具
|
||||
|
||||
请仅下载你拥有权利或已获授权的视频内容。
|
||||
|
||||
用法:
|
||||
直接执行 go run . 或编译后的程序,默认启动浏览器管理界面
|
||||
|
||||
常用参数:
|
||||
-ui=true|false 启动/关闭本地浏览器管理界面,默认 true
|
||||
-listen 地址 UI 监听地址,默认 0.0.0.0:8999,支持局域网访问
|
||||
-open=true|false UI 模式是否自动打开浏览器,默认 true
|
||||
-admin-user 用户名 管理员账号,首次默认 admin
|
||||
-admin-password 密码 指定管理员密码;首次未指定时随机生成并输出,首次登录须修改
|
||||
-mode all|search|id|list CLI 模式,需配合 -ui=false,默认 all
|
||||
-keyword 关键词 search 模式关键词
|
||||
-id 剧ID id 模式下载单部剧
|
||||
-out 目录 自定义下载目录,默认 ./短剧下载,支持相对或绝对路径
|
||||
-c 并发数 下载并发,默认 2,可在界面设置中修改
|
||||
-pages 页数 每频道扫描页数;红果为 App 每批页数,默认 20
|
||||
-data-dir 目录 配置、缓存和任务目录,默认 ./data
|
||||
-config 文件 首次运行时导入旧版配置(可选)
|
||||
-ffmpeg 路径 ffmpeg 路径,默认从 PATH 查找
|
||||
-refresh CLI 模式强制更新剧库,默认读取本地缓存
|
||||
-more CLI 模式从保存的红果 App 分页位置继续加载
|
||||
-insecure-tls=true|false 是否跳过 TLS 证书校验,默认 false
|
||||
|
||||
示例:
|
||||
juku
|
||||
juku -open=false
|
||||
juku -ui=false -mode all
|
||||
juku -ui=false -mode search -keyword 女总裁
|
||||
juku -ui=false -mode id -id hongguo:7670772535931178008
|
||||
juku -ui=false -mode list
|
||||
`)
|
||||
}
|
||||
|
||||
func Run() {
|
||||
mode := flag.String("mode", "all", "all/search/id/list")
|
||||
keyword := flag.String("keyword", "", "search keyword")
|
||||
id := flag.String("id", "", "drama id")
|
||||
out := flag.String("out", "", "output directory")
|
||||
concurrency := flag.Int("c", 0, "download concurrency")
|
||||
pages := flag.Int("pages", 0, "max pages per sort")
|
||||
configFile := flag.String("config", "", "import a legacy config on first startup")
|
||||
dataDir := flag.String("data-dir", "data", "configuration, cache and task directory")
|
||||
ffmpeg := flag.String("ffmpeg", "", "ffmpeg path")
|
||||
adminUser := flag.String("admin-user", "", "administrator username, default admin on first startup")
|
||||
adminPassword := flag.String("admin-password", "", "administrator password; default random on first startup")
|
||||
uiMode := flag.Bool("ui", true, "start local web UI")
|
||||
listen := flag.String("listen", defaultUIListenAddress, "UI listen address; use 127.0.0.1:8999 for local-only access")
|
||||
open := flag.Bool("open", true, "open browser automatically in UI mode")
|
||||
refresh := flag.Bool("refresh", false, "refresh the CLI library instead of using local cache")
|
||||
more := flag.Bool("more", false, "continue loading the Hongguo App catalog from its saved cursor")
|
||||
var insecureTLS boolOverride
|
||||
flag.Var(&insecureTLS, "insecure-tls", "skip TLS certificate verification")
|
||||
help := flag.Bool("help", false, "show help")
|
||||
flag.Parse()
|
||||
if *help {
|
||||
usage()
|
||||
return
|
||||
}
|
||||
if *refresh && *more {
|
||||
fmt.Fprintln(os.Stderr, "-refresh 和 -more 不能同时使用")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if !flagWasSet("data-dir") && !flagWasSet("config") {
|
||||
if err := preparePortableWorkingDirectory(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "初始化程序目录失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
cfg, configErr := loadApplicationConfig(*dataDir, *configFile, *out)
|
||||
if configErr != nil {
|
||||
fmt.Fprintln(os.Stderr, "初始化配置失败:", publicError(configErr))
|
||||
os.Exit(2)
|
||||
}
|
||||
cfg.adminUsername = firstNonEmpty(*adminUser, os.Getenv("JUKU_ADMIN_USER"), "admin")
|
||||
cfg.adminUserExplicit = flagWasSet("admin-user") || os.Getenv("JUKU_ADMIN_USER") != ""
|
||||
cfg.adminPassword = os.Getenv("JUKU_ADMIN_PASSWORD")
|
||||
if flagWasSet("admin-password") {
|
||||
cfg.adminPassword = *adminPassword
|
||||
}
|
||||
cfg.adminPasswordExplicit = flagWasSet("admin-password") || os.Getenv("JUKU_ADMIN_PASSWORD") != ""
|
||||
if *concurrency > 0 {
|
||||
cfg.Concurrency = *concurrency
|
||||
}
|
||||
if *pages > 0 {
|
||||
cfg.MaxPagesPerSort = *pages
|
||||
}
|
||||
if *ffmpeg != "" {
|
||||
cfg.FFmpeg = *ffmpeg
|
||||
}
|
||||
if insecureTLS.set {
|
||||
cfg.InsecureTLS = insecureTLS.val
|
||||
}
|
||||
if err := cfg.validate(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "配置错误: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
d := NewDownloader(cfg)
|
||||
cfg = d.cfg
|
||||
if err := os.MkdirAll(cfg.OutputDir, 0o755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "创建输出目录失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
_, ffmpegErr := exec.LookPath(portableFFmpeg(cfg.FFmpeg))
|
||||
fmt.Printf("输出目录: %s\n下载并发: %d\nFFmpeg 已找到(后台校验): %t\nTLS证书校验: %t\n", cfg.OutputDir, cfg.Concurrency, ffmpegErr == nil, !cfg.InsecureTLS)
|
||||
d.recordDiagnostic(diagnosticEvent{Level: "info", Event: "app.started", Message: "果果剧库已启动"})
|
||||
fmt.Printf("诊断日志: %s\n", d.diagnostics.path)
|
||||
if ffmpegErr != nil {
|
||||
if automaticFFmpeg(cfg.FFmpeg) {
|
||||
fmt.Println("未找到 FFmpeg,将自动下载匹配当前系统的便携版本;如下载失败,请在界面中调整代理后重试。")
|
||||
} else {
|
||||
fmt.Println("指定的 FFmpeg 路径未找到,请更正 download.ffmpeg 或 -ffmpeg。")
|
||||
}
|
||||
}
|
||||
|
||||
if *uiMode {
|
||||
addr := normalizeListen(*listen)
|
||||
app := NewUIApp(d)
|
||||
pageURL := publicURL(addr)
|
||||
fmt.Printf("监听地址: %s | 管理界面已启动: %s\n", addr, pageURL)
|
||||
if *open {
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
openBrowser(pageURL)
|
||||
}()
|
||||
}
|
||||
if err := app.ListenAndServe(addr); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
d.recordDiagnostic(diagnosticEvent{Event: "app.failed", Message: err.Error()})
|
||||
fmt.Fprintf(os.Stderr, "UI 服务停止: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
var dramas []Drama
|
||||
var err error
|
||||
switch strings.ToLower(*mode) {
|
||||
case "id":
|
||||
if strings.TrimSpace(*id) == "" {
|
||||
fmt.Fprintln(os.Stderr, "-mode id 需要 -id")
|
||||
os.Exit(2)
|
||||
}
|
||||
dramas = []Drama{{ID: strings.TrimSpace(*id), Title: "短剧"}}
|
||||
case "all", "search", "list":
|
||||
if *more {
|
||||
dramas, err = d.RefreshDramas(context.WithValue(ctx, libraryMoreKey{}, true), sourceHongguo)
|
||||
} else if *refresh {
|
||||
dramas, err = d.RefreshDramas(ctx, "")
|
||||
} else {
|
||||
dramas, err = d.GetAllDramas(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " 剧库部分来源失败,继续处理已获取的 %d 部:%v\n", len(dramas), err)
|
||||
if len(dramas) == 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if strings.ToLower(*mode) == "search" {
|
||||
kw := strings.TrimSpace(*keyword)
|
||||
if kw == "" {
|
||||
fmt.Fprintln(os.Stderr, "-mode search 需要 -keyword")
|
||||
os.Exit(2)
|
||||
}
|
||||
var filtered []Drama
|
||||
for _, dr := range dramas {
|
||||
text := dr.DisplayTitle() + " " + dr.Desc + " " + dr.Intro + " " + dr.ChannelName
|
||||
if strings.Contains(strings.ToLower(text), strings.ToLower(kw)) {
|
||||
filtered = append(filtered, dr)
|
||||
}
|
||||
}
|
||||
dramas = filtered
|
||||
fmt.Printf(" 搜索命中:%d 部\n", len(dramas))
|
||||
}
|
||||
if strings.ToLower(*mode) == "list" {
|
||||
for i, dr := range dramas {
|
||||
fmt.Printf("%04d [%s] %s %s\n", i+1, dr.ChannelName, dr.ID, dr.DisplayTitle())
|
||||
}
|
||||
return
|
||||
}
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
if len(dramas) == 0 {
|
||||
fmt.Println("没有待下载短剧。")
|
||||
return
|
||||
}
|
||||
|
||||
var tasks []Task
|
||||
for i, dr := range dramas {
|
||||
fmt.Printf(" [%d/%d] 获取章节:《%s》 %s\n", i+1, len(dramas), dr.DisplayTitle(), dr.ID)
|
||||
dramaTasks, err := d.BuildDramaTasks(ctx, dr)
|
||||
if err != nil {
|
||||
fmt.Printf(" 获取章节失败:《%s》 %v\n", dr.DisplayTitle(), err)
|
||||
continue
|
||||
}
|
||||
tasks = append(tasks, dramaTasks...)
|
||||
}
|
||||
fmt.Printf(" 待处理视频:%d 集\n", len(tasks))
|
||||
results := DownloadTasks(ctx, d, tasks)
|
||||
var ok, fail int
|
||||
for _, r := range results {
|
||||
if r.OK {
|
||||
ok++
|
||||
} else {
|
||||
fail++
|
||||
}
|
||||
}
|
||||
if err := saveFailures(cfg.dataDirectory(), results); err != nil {
|
||||
fmt.Printf(" 写入 failed.json 失败: %v\n", err)
|
||||
}
|
||||
fmt.Printf("\n 完成:成功/跳过 %d,失败 %d\n", ok, fail)
|
||||
if fail > 0 {
|
||||
fmt.Printf("失败详情已写入:%s\n", filepath.Join(cfg.dataDirectory(), "failed.json"))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user