diff --git a/apps/core/cmd/cli/main.go b/apps/core/cmd/cli/main.go new file mode 100644 index 000000000..b195eebe4 --- /dev/null +++ b/apps/core/cmd/cli/main.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "caorushizi.cn/mediago/internal/app" + "caorushizi.cn/mediago/internal/core" + "caorushizi.cn/mediago/internal/logger" +) + +func main() { + if err := newRootCommand().Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func newRootCommand() *cobra.Command { + cfg := app.DefaultConfig() + rootCmd := &cobra.Command{ + Use: "mediago-cli", + Short: "MediaGo command-line interface", + Long: "MediaGo CLI directly uses the core downloader runtime without going through the HTTP API server.", + } + addConfigFlags(rootCmd.PersistentFlags(), cfg) + rootCmd.AddCommand(newDownloadCommand(cfg)) + return rootCmd +} + +func newDownloadCommand(cfg *app.AppConfig) *cobra.Command { + var id string + var typ string + var name string + var folder string + var headers []string + + cmd := &cobra.Command{ + Use: "download ", + Short: "Download a URL directly with MediaGo core", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg.ApplyEnvAndDefaults() + if err := app.InitLogger(cfg); err != nil { + return err + } + defer logger.Sync() + + if id == "" { + id = uuid.New().String() + } + if name == "" { + name = "download-" + uuid.New().String()[:8] + } + + rt, err := app.NewRuntime(cfg) + if err != nil { + return err + } + defer rt.Close() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + params := core.DownloadParams{ + ID: core.TaskID(id), + Type: core.DownloadType(typ), + URL: args[0], + Name: core.SanitizeFilename(name), + Folder: folder, + Headers: headers, + } + + lastPercent := -1 + fmt.Printf("Starting %s download: %s\n", params.Type, params.Name) + err = rt.Downloader.Download(ctx, params, core.Callbacks{ + OnProgress: func(e core.ProgressEvent) { + percent := int(e.Percent) + if percent != lastPercent { + lastPercent = percent + if e.Speed != "" { + fmt.Printf("\r%3d%% %s", percent, e.Speed) + } else { + fmt.Printf("\r%3d%%", percent) + } + } + }, + OnMessage: func(e core.MessageEvent) { + if strings.TrimSpace(e.Message) != "" { + logger.Debugf("[%s] %s", e.ID, e.Message) + } + }, + }) + fmt.Println() + if err != nil { + return err + } + fmt.Println("Download completed") + return nil + }, + } + + cmd.Flags().StringVar(&id, "id", "", "Download task id") + cmd.Flags().StringVarP(&typ, "type", "t", string(core.TypeM3U8), "Download type (m3u8, bilibili, direct, mediago, youtube)") + cmd.Flags().StringVarP(&name, "name", "n", "", "Output file name") + cmd.Flags().StringVar(&folder, "folder", "", "Subdirectory under the download directory") + cmd.Flags().StringArrayVarP(&headers, "header", "H", nil, "HTTP header, can be repeated") + return cmd +} + +func addConfigFlags(flags *pflag.FlagSet, cfg *app.AppConfig) { + flags.StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "Log level (debug/info/warn/error)") + flags.StringVar(&cfg.LogDir, "log-dir", cfg.LogDir, "Log directory") + flags.StringVar(&cfg.DepsDir, "deps-dir", cfg.DepsDir, "Directory containing downloader tool binaries") + flags.StringVar(&cfg.SchemaPath, "schema-path", cfg.SchemaPath, "Path to the download schema config.json") + flags.StringVar(&cfg.LocalDir, "local-dir", cfg.LocalDir, "Default download directory") + flags.BoolVar(&cfg.DeleteSegments, "delete-segments", cfg.DeleteSegments, "Delete segments after download") + flags.StringVar(&cfg.Proxy, "proxy", cfg.Proxy, "Proxy for downloader") + flags.BoolVar(&cfg.UseProxy, "use-proxy", cfg.UseProxy, "Enable proxy") + flags.IntVar(&cfg.MaxRunner, "max-runner", cfg.MaxRunner, "Maximum concurrent download runners") + flags.StringVar(&cfg.DBPath, "db-path", cfg.DBPath, "Path to SQLite database file") + flags.StringVar(&cfg.ConfigDir, "config-dir", cfg.ConfigDir, "Directory for persistent config file") +} diff --git a/apps/core/cmd/server/appstore.go b/apps/core/cmd/server/appstore.go deleted file mode 100644 index aff8c2dbf..000000000 --- a/apps/core/cmd/server/appstore.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -// AppStore holds all user-facing configuration options. -// Field names and defaults match the TypeScript AppStore interface -// in @mediago/shared-common. -type AppStore struct { - Local string `json:"local"` - PromptTone bool `json:"promptTone"` - Proxy string `json:"proxy"` - UseProxy bool `json:"useProxy"` - DeleteSegments bool `json:"deleteSegments"` - OpenInNewWindow bool `json:"openInNewWindow"` - BlockAds bool `json:"blockAds"` - Theme string `json:"theme"` - UseExtension bool `json:"useExtension"` - IsMobile bool `json:"isMobile"` - MaxRunner int `json:"maxRunner"` - Language string `json:"language"` - ShowTerminal bool `json:"showTerminal"` - Privacy bool `json:"privacy"` - MachineId string `json:"machineId"` - DownloadProxySwitch bool `json:"downloadProxySwitch"` - AutoUpgrade bool `json:"autoUpgrade"` - AllowBeta bool `json:"allowBeta"` - CloseMainWindow bool `json:"closeMainWindow"` - AudioMuted bool `json:"audioMuted"` - EnableDocker bool `json:"enableDocker"` - DockerUrl string `json:"dockerUrl"` - EnableMobilePlayer bool `json:"enableMobilePlayer"` - ApiKey string `json:"apiKey"` - PasswordHash string `json:"passwordHash"` -} - -// defaultAppStore returns default config values matching the TS appStoreDefaults. -func defaultAppStore() AppStore { - return AppStore{ - Local: "", - PromptTone: true, - Proxy: "", - UseProxy: false, - DeleteSegments: true, - OpenInNewWindow: false, - BlockAds: true, - Theme: "system", - UseExtension: false, - IsMobile: false, - MaxRunner: 2, - Language: "system", - ShowTerminal: false, - Privacy: false, - MachineId: "", - DownloadProxySwitch: false, - AutoUpgrade: true, - AllowBeta: false, - CloseMainWindow: false, - AudioMuted: true, - EnableDocker: false, - DockerUrl: "", - EnableMobilePlayer: false, - ApiKey: "", - PasswordHash: "", - } -} diff --git a/apps/core/cmd/server/main.go b/apps/core/cmd/server/main.go index 3e3a2cbbd..d269cb8c8 100644 --- a/apps/core/cmd/server/main.go +++ b/apps/core/cmd/server/main.go @@ -3,21 +3,15 @@ package main import ( "flag" + "fmt" "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/app" "caorushizi.cn/mediago/internal/logger" - "caorushizi.cn/mediago/internal/tasklog" - "caorushizi.cn/mediago/pkg/conf" "github.com/gin-gonic/gin" ) @@ -48,227 +42,64 @@ import ( // @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) + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) } - 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) +func run(args []string) error { + cfg := app.DefaultConfig() + fs := flag.NewFlagSet("mediago-core server", flag.ContinueOnError) + registerConfigFlags(fs, cfg) + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + return nil } + return err } + cfg.ApplyEnvAndDefaults() - // 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) + if err := app.InitLogger(cfg); err != nil { + return err } - logger.Infof("Loaded %d download schemas", len(schemas.Schemas)) + defer logger.Sync() - // 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) - } + rt, err := app.NewRuntime(cfg) + if err != nil { + return err } + defer rt.Close() - // 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) - } - }) + return runServer(rt) +} - // 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") - } +func registerConfigFlags(fs *flag.FlagSet, cfg *app.AppConfig) { + fs.StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "Log level (debug/info/warn/error)") + fs.StringVar(&cfg.LogDir, "log-dir", cfg.LogDir, "Log directory") + fs.StringVar(&cfg.DepsDir, "deps-dir", cfg.DepsDir, "Directory containing downloader tool binaries") + fs.StringVar(&cfg.SchemaPath, "schema-path", cfg.SchemaPath, "Path to the download schema config.json") + fs.StringVar(&cfg.Port, "port", cfg.Port, "Server port") + fs.StringVar(&cfg.LocalDir, "local-dir", cfg.LocalDir, "Default download directory") + fs.BoolVar(&cfg.DeleteSegments, "delete-segments", cfg.DeleteSegments, "Delete segments after download") + fs.StringVar(&cfg.Proxy, "proxy", cfg.Proxy, "Proxy for downloader") + fs.BoolVar(&cfg.UseProxy, "use-proxy", cfg.UseProxy, "Enable proxy") + fs.IntVar(&cfg.MaxRunner, "max-runner", cfg.MaxRunner, "Maximum concurrent download runners") + fs.StringVar(&cfg.DBPath, "db-path", cfg.DBPath, "Path to SQLite database file") + fs.StringVar(&cfg.ConfigDir, "config-dir", cfg.ConfigDir, "Directory for persistent config file") + fs.BoolVar(&cfg.EnableAuth, "enable-auth", cfg.EnableAuth, "Enable API key authentication") + fs.StringVar(&cfg.StaticDir, "static-dir", cfg.StaticDir, "Directory to serve static files from (SPA mode)") +} - // 10. Start HTTP server - confStore := handler.WrapConfStore[AppStore](appStore) +func runServer(rt *app.Runtime) error { + cfg := rt.Config + confStore := handler.WrapConfStore[app.AppStore](rt.AppStore) execPath, _ := os.Executable() - server := api.NewServer(queue, taskLogs, database, confStore, api.ServerOptions{ + server := api.NewServer(rt.Queue, rt.TaskLogs, rt.Database, confStore, api.ServerOptions{ EnableAuth: cfg.EnableAuth, StaticDir: cfg.StaticDir, - FFmpegBin: getFFmpegBin(cfg), + FFmpegBin: app.FFmpegBinPath(cfg), VideoRoot: cfg.LocalDir, EnvPaths: handler.EnvPaths{ ConfigDir: cfg.ConfigDir, @@ -280,141 +111,5 @@ func main() { 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 + return server.Run(addr) } diff --git a/apps/core/go.mod b/apps/core/go.mod index eb29f48b9..0edb5f31a 100644 --- a/apps/core/go.mod +++ b/apps/core/go.mod @@ -9,10 +9,13 @@ require ( github.com/gin-gonic/gin v1.11.0 github.com/glebarez/sqlite v1.11.0 github.com/google/uuid v1.6.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.43.0 golang.org/x/text v0.30.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gorm.io/gorm v1.31.1 @@ -43,6 +46,7 @@ require ( github.com/go-playground/validator/v10 v10.28.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -61,7 +65,6 @@ require ( go.uber.org/multierr v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.43.0 // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.46.0 // indirect golang.org/x/sync v0.17.0 // indirect diff --git a/apps/core/go.sum b/apps/core/go.sum index 5b1a3d7c4..88de2e367 100644 --- a/apps/core/go.sum +++ b/apps/core/go.sum @@ -10,6 +10,7 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -20,6 +21,7 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -72,6 +74,8 @@ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbu github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -106,6 +110,11 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/apps/core/internal/app/appstore.go b/apps/core/internal/app/appstore.go new file mode 100644 index 000000000..a134e911c --- /dev/null +++ b/apps/core/internal/app/appstore.go @@ -0,0 +1,63 @@ +package app + +// AppStore holds all user-facing configuration options. +// Field names and defaults match the TypeScript AppStore interface +// in @mediago/shared-common. +type AppStore struct { + Local string `json:"local"` + PromptTone bool `json:"promptTone"` + Proxy string `json:"proxy"` + UseProxy bool `json:"useProxy"` + DeleteSegments bool `json:"deleteSegments"` + OpenInNewWindow bool `json:"openInNewWindow"` + BlockAds bool `json:"blockAds"` + Theme string `json:"theme"` + UseExtension bool `json:"useExtension"` + IsMobile bool `json:"isMobile"` + MaxRunner int `json:"maxRunner"` + Language string `json:"language"` + ShowTerminal bool `json:"showTerminal"` + Privacy bool `json:"privacy"` + MachineId string `json:"machineId"` + DownloadProxySwitch bool `json:"downloadProxySwitch"` + AutoUpgrade bool `json:"autoUpgrade"` + AllowBeta bool `json:"allowBeta"` + CloseMainWindow bool `json:"closeMainWindow"` + AudioMuted bool `json:"audioMuted"` + EnableDocker bool `json:"enableDocker"` + DockerUrl string `json:"dockerUrl"` + EnableMobilePlayer bool `json:"enableMobilePlayer"` + ApiKey string `json:"apiKey"` + PasswordHash string `json:"passwordHash"` +} + +// DefaultAppStore returns default config values matching the TS appStoreDefaults. +func DefaultAppStore() AppStore { + return AppStore{ + Local: "", + PromptTone: true, + Proxy: "", + UseProxy: false, + DeleteSegments: true, + OpenInNewWindow: false, + BlockAds: true, + Theme: "system", + UseExtension: false, + IsMobile: false, + MaxRunner: 2, + Language: "system", + ShowTerminal: false, + Privacy: false, + MachineId: "", + DownloadProxySwitch: false, + AutoUpgrade: true, + AllowBeta: false, + CloseMainWindow: false, + AudioMuted: true, + EnableDocker: false, + DockerUrl: "", + EnableMobilePlayer: false, + ApiKey: "", + PasswordHash: "", + } +} diff --git a/apps/core/internal/app/config.go b/apps/core/internal/app/config.go new file mode 100644 index 000000000..340709d2a --- /dev/null +++ b/apps/core/internal/app/config.go @@ -0,0 +1,107 @@ +package app + +import ( + "os" + "path/filepath" +) + +// AppConfig stores startup configuration options passed by flags or environment. +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 DefaultConfig() *AppConfig { + return &AppConfig{ + GinMode: "release", + Host: "0.0.0.0", + Port: "8080", + LogLevel: "info", + LogDir: "./logs", + SchemaPath: "", + DepsDir: "", + MaxRunner: 2, + LocalDir: "./downloads", + DeleteSegments: true, + Proxy: "", + UseProxy: false, + ConfigDir: "", + } +} + +func (c *AppConfig) ApplyEnvAndDefaults() { + c.GinMode = getEnv("GIN_MODE", c.GinMode) + c.Host = getEnv("HOST", c.Host) + c.Port = getEnv("PORT", c.Port) + c.DBPath = getEnv("DB_PATH", c.DBPath) + + if c.SchemaPath == "" { + c.SchemaPath = getDefaultSchemaPath() + } + if c.ConfigDir == "" { + c.ConfigDir = c.LogDir + } +} + +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 +} + +func getDefaultSchemaPath() string { + execPath, _ := os.Executable() + execDir := filepath.Dir(execPath) + localConfig := filepath.Join(execDir, "config.json") + if _, err := os.Stat(localConfig); err == nil { + return localConfig + } + return "configs/config.json" +} + +func getEnv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/apps/core/internal/app/runtime.go b/apps/core/internal/app/runtime.go new file mode 100644 index 000000000..e30620824 --- /dev/null +++ b/apps/core/internal/app/runtime.go @@ -0,0 +1,232 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/google/uuid" + + "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" +) + +type Runtime struct { + Config *AppConfig + AppStore *conf.Conf[AppStore] + Downloader *core.DownloaderSvc + Queue *core.TaskQueue + TaskLogs *tasklog.Manager + Database *db.Database +} + +func InitLogger(cfg *AppConfig) error { + logCfg := logger.DefaultConfig() + logCfg.Level = cfg.LogLevel + logCfg.LogDir = cfg.LogDir + + if err := logger.Init(logCfg); err != nil { + return fmt.Errorf("failed to initialize logger: %w", err) + } + return nil +} + +func NewRuntime(cfg *AppConfig) (*Runtime, error) { + logger.Info("MediaGo Downloader Core Starting...") + logger.Infof("Final Config: %+v", cfg) + + appStore, err := conf.New(conf.Options[AppStore]{ + ConfigName: "config", + CWD: cfg.ConfigDir, + Defaults: DefaultAppStore(), + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize app store: %w", err) + } + logger.Infof("App store initialized at: %s", appStore.Path()) + + if appStore.Store().MachineId == "" { + newId := uuid.New().String() + _ = appStore.Set("machineId", newId) + logger.Infof("Generated new machineId: %s", newId) + } + + syncAppStoreToCfg(appStore, cfg) + ensureDownloadDir(appStore, cfg) + + logger.Infof("Loading schemas from: %s", cfg.SchemaPath) + schemas, err := schema.LoadSchemasFromJSON(cfg.SchemaPath) + if err != nil { + return nil, fmt.Errorf("failed to load schemas: %w", err) + } + logger.Infof("Loaded %d download schemas", len(schemas.Schemas)) + + 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) + } + } + + 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")) + + 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) + } + }) + + var database *db.Database + if cfg.DBPath != "" { + if dir := filepath.Dir(cfg.DBPath); dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create database directory %s: %w", dir, err) + } + } + var dbErr error + database, dbErr = db.New(cfg.DBPath) + if dbErr != nil { + return nil, fmt.Errorf("failed to open database: %w", dbErr) + } + logger.Infof("Database opened: %s", cfg.DBPath) + } else { + logger.Info("No database path provided, running without persistence") + } + + return &Runtime{ + Config: cfg, + AppStore: appStore, + Downloader: downloader, + Queue: queue, + TaskLogs: taskLogs, + Database: database, + }, nil +} + +func (rt *Runtime) Close() { + if rt.Database != nil { + _ = rt.Database.Close() + } +} + +func syncAppStoreToCfg(store *conf.Conf[AppStore], cfg *AppConfig) { + s := store.Store() + cliLocalDir := cfg.LocalDir + cliExplicit := cliLocalDir != "" && cliLocalDir != "./downloads" + + if cliExplicit { + _ = 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 + } +} + +func ensureDownloadDir(store *conf.Conf[AppStore], cfg *AppConfig) { + 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 + } + if store.Store().Local == "" { + _ = store.Set("local", cfg.LocalDir) + } +} + +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 exeExt() string { + if runtime.GOOS == "windows" { + return ".exe" + } + return "" +} + +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 +} + +func FFmpegBinPath(cfg *AppConfig) string { + return filepath.Join(cfg.DepsDir, core.FFmpegBinaryName+exeExt()) +} + +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 +}