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