Files
2026-07-13 13:39:47 +08:00

421 lines
13 KiB
Go

// MediaGo download service main entry point
package main
import (
"flag"
"os"
"path/filepath"
"runtime"
"github.com/google/uuid"
"caorushizi.cn/mediago/internal/api"
"caorushizi.cn/mediago/internal/api/handler"
"caorushizi.cn/mediago/internal/core"
"caorushizi.cn/mediago/internal/core/runner"
"caorushizi.cn/mediago/internal/core/schema"
"caorushizi.cn/mediago/internal/db"
"caorushizi.cn/mediago/internal/logger"
"caorushizi.cn/mediago/internal/tasklog"
"caorushizi.cn/mediago/pkg/conf"
"github.com/gin-gonic/gin"
)
// @title MediaGo Downloader API
// @version 1.0
// @description MediaGo multi-task download system API documentation
// @description Supports M3U8, Bilibili, and Direct download types
// @description Provides task management, configuration updates, and real-time event streaming
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
// @contact.url https://github.com/caorushizi/mediago-core
// @contact.email support@mediago.local
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
// @host localhost:8080
// @BasePath /api
// @schemes http https
// @tag.name Health
// @tag.description Health check endpoints
// @tag.name Tasks
// @tag.description Download task management endpoints
// @tag.name Config
// @tag.description System configuration endpoints
// @tag.name Events
// @tag.description Real-time event streaming endpoints
// AppConfig stores all startup configuration options (passed via command-line flags or environment variables).
type AppConfig struct {
GinMode string `json:"gin_mode"`
Host string `json:"host"`
Port string `json:"port"`
LogLevel string `json:"log_level"`
LogDir string `json:"log_dir"`
SchemaPath string `json:"schema_path"`
DepsDir string `json:"deps_dir"`
MaxRunner int `json:"max_runner"`
LocalDir string `json:"local_dir"`
DeleteSegments bool `json:"delete_segments"`
Proxy string `json:"proxy"`
UseProxy bool `json:"use_proxy"`
DBPath string `json:"db_path"`
ConfigDir string `json:"config_dir"`
EnableAuth bool `json:"enable_auth"`
StaticDir string `json:"static_dir"`
}
func (c *AppConfig) GetLocalDir() string {
return c.LocalDir
}
func (c *AppConfig) GetDeleteSegments() bool {
return c.DeleteSegments
}
func (c *AppConfig) GetProxy() string {
return c.Proxy
}
func (c *AppConfig) GetUseProxy() bool {
return c.UseProxy
}
func (c *AppConfig) SetLocalDir(dir string) {
c.LocalDir = dir
}
func (c *AppConfig) SetDeleteSegments(del bool) {
c.DeleteSegments = del
}
func (c *AppConfig) SetProxy(proxy string) {
c.Proxy = proxy
}
func (c *AppConfig) SetUseProxy(useProxy bool) {
c.UseProxy = useProxy
}
// getSystemDownloadsDir returns the system downloads directory.
// Prefers $HOME/Downloads; falls back to $HOME if it does not exist.
func getSystemDownloadsDir() string {
home, err := os.UserHomeDir()
if err != nil {
return "."
}
downloads := filepath.Join(home, "Downloads")
if info, err := os.Stat(downloads); err == nil && info.IsDir() {
return downloads
}
return home
}
func main() {
// 1. Initialize the logger with default config first so it is available during config parsing
// if err := logger.Init(logger.DefaultConfig()); err != nil {
// panic("Failed to initialize logger: " + err.Error())
// }
// 2. Initialize and parse configuration
cfg := initConfig()
// 3. Re-initialize the logger using the resolved configuration
logCfg := logger.DefaultConfig()
logCfg.Level = cfg.LogLevel
logCfg.LogDir = cfg.LogDir
if err := logger.Init(logCfg); err != nil {
logger.Fatalf("Failed to reinitialize logger with config: %v", err)
}
defer logger.Sync()
logger.Info("MediaGo Downloader Service Starting...")
logger.Infof("Final Config: %+v", cfg)
// 4. Initialize AppStore configuration (user-level persistent config)
appStore, err := conf.New(conf.Options[AppStore]{
ConfigName: "config",
CWD: cfg.ConfigDir,
Defaults: defaultAppStore(),
})
if err != nil {
logger.Fatalf("Failed to initialize app store: %v", err)
}
logger.Infof("App store initialized at: %s", appStore.Path())
// Ensure machineId is set (generate on first run)
if appStore.Store().MachineId == "" {
newId := uuid.New().String()
_ = appStore.Set("machineId", newId)
logger.Infof("Generated new machineId: %s", newId)
}
// Sync appStore values back into cfg (so cfg reflects the final state of persistent config).
// Note: CLI arguments are no longer written into appStore to avoid overwriting settings
// saved by the user in the UI on every startup. CLI args serve only as initial defaults;
// appStore (user config) takes higher priority.
syncAppStoreToCfg(appStore, cfg)
// If the download directory is empty, is the default value, or does not exist, use the system downloads directory
{
needDefault := cfg.LocalDir == "" || cfg.LocalDir == "./downloads"
if !needDefault {
if info, err := os.Stat(cfg.LocalDir); err != nil || !info.IsDir() {
needDefault = true
}
}
if needDefault {
sysDownloads := getSystemDownloadsDir()
logger.Infof("Download dir %q unavailable, using system default: %s", cfg.LocalDir, sysDownloads)
cfg.LocalDir = sysDownloads
}
// Ensure appStore has the download directory (so UI can read it via /api/config)
if appStore.Store().Local == "" {
_ = appStore.Set("local", cfg.LocalDir)
}
}
// 5. Load JSON Schema configuration
logger.Infof("Loading schemas from: %s", cfg.SchemaPath)
schemas, err := schema.LoadSchemasFromJSON(cfg.SchemaPath)
if err != nil {
logger.Fatalf("Failed to load schemas: %v", err)
}
logger.Infof("Loaded %d download schemas", len(schemas.Schemas))
// 6. Configure downloader binary paths
binMap := getBinaryMap(cfg)
for dt, binPath := range binMap {
logger.Infof("%s downloader: %s", dt, binPath)
if binPath == "" {
continue
}
if info, err := os.Stat(binPath); err != nil {
logger.Warnf("%s binary not found: %v", dt, err)
} else if info.Mode()&0o111 == 0 {
logger.Warnf("%s binary is not executable: %s", dt, binPath)
}
}
// 7. Create core components
r := runner.NewPTYRunner()
downloader := core.NewDownloader(binMap, r, schemas, cfg)
queue := core.NewTaskQueue(downloader, cfg.MaxRunner)
taskLogs := tasklog.NewManager(filepath.Join(cfg.LogDir, "tasks"))
logger.Infof("Task queue initialized (maxRunner=%d)", cfg.MaxRunner)
logger.Infof("Task logs will be stored in %s", filepath.Join(cfg.LogDir, "tasks"))
// 8. Watch appStore changes and sync them to the downloader
appStore.OnDidChange("maxRunner", func(newVal, oldVal any) {
if v, ok := toInt(newVal); ok {
queue.SetMaxRunner(v)
logger.Infof("maxRunner updated to %d via config change", v)
}
})
appStore.OnDidChange("proxy", func(newVal, oldVal any) {
if v, ok := newVal.(string); ok {
cfg.SetProxy(v)
logger.Infof("proxy updated to %q via config change", v)
}
})
appStore.OnDidChange("useProxy", func(newVal, oldVal any) {
if v, ok := newVal.(bool); ok {
cfg.SetUseProxy(v)
logger.Infof("useProxy updated to %v via config change", v)
}
})
appStore.OnDidChange("deleteSegments", func(newVal, oldVal any) {
if v, ok := newVal.(bool); ok {
cfg.SetDeleteSegments(v)
logger.Infof("deleteSegments updated to %v via config change", v)
}
})
appStore.OnDidChange("local", func(newVal, oldVal any) {
if v, ok := newVal.(string); ok {
cfg.SetLocalDir(v)
logger.Infof("localDir updated to %q via config change", v)
}
})
// 9. Initialize database (optional)
var database *db.Database
if cfg.DBPath != "" {
// Ensure the database parent directory exists
if dir := filepath.Dir(cfg.DBPath); dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
logger.Fatalf("Failed to create database directory %s: %v", dir, err)
}
}
var dbErr error
database, dbErr = db.New(cfg.DBPath)
if dbErr != nil {
logger.Fatalf("Failed to open database: %v", dbErr)
}
defer database.Close()
logger.Infof("Database opened: %s", cfg.DBPath)
} else {
logger.Info("No database path provided, running without persistence")
}
// 10. Start HTTP server
confStore := handler.WrapConfStore[AppStore](appStore)
execPath, _ := os.Executable()
server := api.NewServer(queue, taskLogs, database, confStore, api.ServerOptions{
EnableAuth: cfg.EnableAuth,
StaticDir: cfg.StaticDir,
FFmpegBin: getFFmpegBin(cfg),
VideoRoot: cfg.LocalDir,
EnvPaths: handler.EnvPaths{
ConfigDir: cfg.ConfigDir,
BinDir: filepath.Dir(execPath),
Platform: runtime.GOOS,
},
})
addr := cfg.Host + ":" + cfg.Port
gin.SetMode(cfg.GinMode)
logger.Infof("Starting HTTP server on %s", addr)
if err := server.Run(addr); err != nil {
logger.Fatalf("Failed to start server: %v", err)
}
}
// syncAppStoreToCfg reads appStore values back into cfg.
// CLI flags take priority: if --local-dir was explicitly set (not the default),
// the CLI value is kept and written back to appStore.
func syncAppStoreToCfg(store *conf.Conf[AppStore], cfg *AppConfig) {
s := store.Store()
cliLocalDir := cfg.LocalDir
cliExplicit := cliLocalDir != "" && cliLocalDir != "./downloads"
if cliExplicit {
// CLI explicitly set --local-dir, use it and update appStore
_ = store.Set("local", cliLocalDir)
} else if s.Local != "" {
cfg.LocalDir = s.Local
}
cfg.Proxy = s.Proxy
cfg.UseProxy = s.UseProxy
cfg.DeleteSegments = s.DeleteSegments
if s.MaxRunner > 0 {
cfg.MaxRunner = s.MaxRunner
}
}
// initConfig initializes configuration following priority order: CLI flags > environment variables > JSON string > defaults.
func initConfig() *AppConfig {
// Default configuration
cfg := &AppConfig{
GinMode: "release",
Host: "0.0.0.0",
Port: "8080",
LogLevel: "info",
LogDir: "./logs",
SchemaPath: "", // computed later
DepsDir: "",
MaxRunner: 2,
LocalDir: "./downloads",
DeleteSegments: true,
Proxy: "",
UseProxy: false,
ConfigDir: "",
}
// 1. Define command-line flags
flag.StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "Log level (debug/info/warn/error)")
flag.StringVar(&cfg.LogDir, "log-dir", cfg.LogDir, "Log directory")
flag.StringVar(&cfg.DepsDir, "deps-dir", cfg.DepsDir, "Directory containing downloader tool binaries")
flag.StringVar(&cfg.SchemaPath, "schema-path", cfg.SchemaPath, "Path to the download schema config.json")
flag.StringVar(&cfg.Port, "port", cfg.Port, "Server port")
flag.StringVar(&cfg.LocalDir, "local-dir", cfg.LocalDir, "Default download directory")
flag.BoolVar(&cfg.DeleteSegments, "delete-segments", cfg.DeleteSegments, "Delete segments after download")
flag.StringVar(&cfg.Proxy, "proxy", cfg.Proxy, "Proxy for downloader")
flag.BoolVar(&cfg.UseProxy, "use-proxy", cfg.UseProxy, "Enable proxy")
flag.IntVar(&cfg.MaxRunner, "max-runner", cfg.MaxRunner, "Maximum concurrent download runners")
flag.StringVar(&cfg.DBPath, "db-path", cfg.DBPath, "Path to SQLite database file")
flag.StringVar(&cfg.ConfigDir, "config-dir", cfg.ConfigDir, "Directory for persistent config file")
flag.BoolVar(&cfg.EnableAuth, "enable-auth", cfg.EnableAuth, "Enable API key authentication")
flag.StringVar(&cfg.StaticDir, "static-dir", cfg.StaticDir, "Directory to serve static files from (SPA mode)")
flag.Parse()
// 3. Load from environment variables (overrides JSON and defaults)
cfg.GinMode = getEnv("GIN_MODE", cfg.GinMode)
cfg.Host = getEnv("HOST", cfg.Host)
cfg.Port = getEnv("PORT", cfg.Port)
cfg.DBPath = getEnv("DB_PATH", cfg.DBPath)
// If SchemaPath is still empty, compute its default value
if cfg.SchemaPath == "" {
cfg.SchemaPath = getDefaultSchemaPath()
}
// If ConfigDir is empty, default to the LogDir path
if cfg.ConfigDir == "" {
cfg.ConfigDir = cfg.LogDir
}
return cfg
}
// getDefaultSchemaPath returns the default path for the schema config file.
func getDefaultSchemaPath() string {
// Default path: prefer config.json in the same directory as the executable
execPath, _ := os.Executable()
execDir := filepath.Dir(execPath)
localConfig := filepath.Join(execDir, "config.json")
if _, err := os.Stat(localConfig); err == nil {
return localConfig
}
// Fall back to the config file path inside the repository
return "configs/config.json"
}
// exeExt returns ".exe" on Windows, empty string otherwise.
func exeExt() string {
if runtime.GOOS == "windows" {
return ".exe"
}
return ""
}
// getBinaryMap builds the downloader binary path map from a single deps directory.
func getBinaryMap(cfg *AppConfig) map[core.DownloadType]string {
ext := exeExt()
m := make(map[core.DownloadType]string, len(core.BinaryNames))
for dt, name := range core.BinaryNames {
m[dt] = filepath.Join(cfg.DepsDir, name+ext)
}
return m
}
// getFFmpegBin returns the ffmpeg binary path derived from the deps directory.
func getFFmpegBin(cfg *AppConfig) string {
return filepath.Join(cfg.DepsDir, core.FFmpegBinaryName+exeExt())
}
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// toInt converts a JSON-decoded number (float64) or int to int.
func toInt(v any) (int, bool) {
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case int64:
return int(n), true
}
return 0, false
}