Compare commits

...

1 Commits

Author SHA1 Message Date
caorushizi d654368290 feat(core): add cobra cli entrypoint 2026-05-17 03:37:28 +08:00
8 changed files with 592 additions and 416 deletions
+130
View File
@@ -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 <url>",
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")
}
-63
View File
@@ -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: "",
}
}
+47 -352
View File
@@ -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())
// }
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// 2. Initialize and parse configuration
cfg := initConfig()
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()
// 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)
if err := app.InitLogger(cfg); err != nil {
return 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(),
})
rt, err := app.NewRuntime(cfg)
if err != nil {
logger.Fatalf("Failed to initialize app store: %v", err)
return err
}
logger.Infof("App store initialized at: %s", appStore.Path())
defer rt.Close()
// 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)
}
return runServer(rt)
}
// 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)
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)")
}
// 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)
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)
}
+4 -1
View File
@@ -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
+9
View File
@@ -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=
+63
View File
@@ -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: "",
}
}
+107
View File
@@ -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
}
+232
View File
@@ -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
}