package config import ( "errors" "flag" "fmt" "os" "strconv" "strings" "time" "qtransfer/internal/session" ) type Config struct { Command string ConfigFile string SpotifyClientID string SpotifyRedirect string SpotifyScopes []string SpotifyManual bool SessionPath string RememberCreds bool QobuzUsername string QobuzPassword string QobuzAppID string QobuzAppSecret string QobuzSelfTest bool QobuzTestWrite bool QobuzTestQuery string Monitor bool MonitorOnce bool MonitorTransfer bool MonitorInterval time.Duration SyncMode string TargetPlaylistID int64 LikedPlaylist string DryRun bool ReportPath string Concurrency int PlaylistNames []string PlaylistURLs []string AllPlaylists bool IncludeLiked bool NonInteractive bool PublicPlaylists bool } type multiFlag []string func (m *multiFlag) String() string { return strings.Join(*m, ",") } func (m *multiFlag) Set(v string) error { v = strings.TrimSpace(v) if v == "" { return errors.New("playlist name cannot be empty") } *m = append(*m, v) return nil } func Load() (Config, error) { var cfg Config var playlists multiFlag var playlistURLs multiFlag command := "run" parseArgs := os.Args[1:] if len(parseArgs) > 0 { first := strings.ToLower(strings.TrimSpace(parseArgs[0])) if first == "login" || first == "logout" { command = "login" if first == "logout" { command = "logout" } parseArgs = parseArgs[1:] } } defaultScopes := "playlist-read-private,playlist-read-collaborative,user-library-read" defaultAppID := envOr("QOBUZ_APP_ID", "312369995") defaultAppSecret := envOr("QOBUZ_APP_SECRET", "e79f8b9be485692b0e5f9dd895826368") defaultConcurrency := envIntOr("QTRANSFER_CONCURRENCY", 4) flag.StringVar(&cfg.ConfigFile, "config", envOr("QTRANSFER_CONFIG", ""), "Path to TOML config file with playlist sync jobs") flag.StringVar(&cfg.SpotifyClientID, "spotify-client-id", envOr("SPOTIFY_CLIENT_ID", ""), "Spotify app client ID") flag.StringVar(&cfg.SpotifyRedirect, "spotify-redirect-uri", envOr("SPOTIFY_REDIRECT_URI", "http://127.0.0.1:8888/callback"), "Spotify OAuth redirect URI") scopes := flag.String("spotify-scopes", envOr("SPOTIFY_SCOPES", defaultScopes), "Comma-separated Spotify OAuth scopes") flag.BoolVar(&cfg.SpotifyManual, "spotify-manual-code", envBoolOr("QTRANSFER_SPOTIFY_MANUAL_CODE", true), "Enter Spotify callback code/URL manually instead of running a local callback server") flag.StringVar(&cfg.SessionPath, "session-file", envOr("QTRANSFER_SESSION_FILE", session.DefaultPath()), "Session file path for cached tokens/credentials") flag.BoolVar(&cfg.RememberCreds, "remember-creds", envBoolOr("QTRANSFER_REMEMBER_CREDS", true), "Store/reuse credentials and tokens in session file") flag.StringVar(&cfg.QobuzUsername, "qobuz-username", envOr("QOBUZ_USERNAME", ""), "Qobuz account username/email") flag.StringVar(&cfg.QobuzPassword, "qobuz-password", envOr("QOBUZ_PASSWORD", ""), "Qobuz account password") flag.StringVar(&cfg.QobuzAppID, "qobuz-app-id", defaultAppID, "Qobuz app ID") flag.StringVar(&cfg.QobuzAppSecret, "qobuz-app-secret", defaultAppSecret, "Qobuz app secret") flag.BoolVar(&cfg.QobuzSelfTest, "qobuz-self-test", envBoolOr("QTRANSFER_QOBUZ_SELF_TEST", false), "Run Qobuz login/verify/search checks and exit (skips Spotify)") flag.BoolVar(&cfg.QobuzTestWrite, "qobuz-self-test-write", envBoolOr("QTRANSFER_QOBUZ_SELF_TEST_WRITE", false), "When --qobuz-self-test is set, also create a test playlist and add one track") flag.StringVar(&cfg.QobuzTestQuery, "qobuz-self-test-query", envOr("QTRANSFER_QOBUZ_SELF_TEST_QUERY", "Daft Punk One More Time"), "Search query used for --qobuz-self-test") flag.BoolVar(&cfg.Monitor, "monitor", envBoolOr("QTRANSFER_MONITOR", false), "Monitor selected playlists for updates") flag.BoolVar(&cfg.MonitorOnce, "monitor-once", envBoolOr("QTRANSFER_MONITOR_ONCE", false), "Run a single monitor check then exit") flag.BoolVar(&cfg.MonitorTransfer, "monitor-transfer", envBoolOr("QTRANSFER_MONITOR_TRANSFER", false), "When monitoring, sync changed playlists to Qobuz") flag.DurationVar(&cfg.MonitorInterval, "monitor-interval", envDurationOr("QTRANSFER_MONITOR_INTERVAL", 5*time.Minute), "Monitor polling interval (e.g. 2m, 30s)") flag.StringVar(&cfg.SyncMode, "sync-mode", strings.ToLower(envOr("QTRANSFER_SYNC_MODE", "append")), "Sync mode for monitor-transfer: append or mirror") flag.Int64Var(&cfg.TargetPlaylistID, "target-playlist-id", envInt64Or("QTRANSFER_TARGET_PLAYLIST_ID", 0), "Bind source playlist (single URL only) to existing Qobuz playlist ID") flag.BoolVar(&cfg.DryRun, "dry-run", envBoolOr("QTRANSFER_DRY_RUN", false), "Resolve matches only, do not create or mutate Qobuz playlists") flag.StringVar(&cfg.ReportPath, "report", envOr("QTRANSFER_REPORT", "transfer-report.json"), "Report output path") flag.IntVar(&cfg.Concurrency, "concurrency", defaultConcurrency, "Concurrent track matching workers") flag.StringVar(&cfg.LikedPlaylist, "liked-playlist-name", envOr("QTRANSFER_LIKED_NAME", "Spotify Liked Songs"), "Name of the generated liked-songs playlist on Qobuz") flag.BoolVar(&cfg.AllPlaylists, "all", false, "Transfer all Spotify playlists") flag.BoolVar(&cfg.IncludeLiked, "liked", false, "Include Spotify liked songs") flag.BoolVar(&cfg.NonInteractive, "non-interactive", false, "Disable interactive playlist selection prompts") flag.BoolVar(&cfg.PublicPlaylists, "public-playlists", false, "Create public playlists on Qobuz (default private)") flag.Var(&playlists, "playlist", "Playlist name to transfer (repeatable)") flag.Var(&playlistURLs, "playlist-url", "Spotify playlist URL/URI/ID to transfer (repeatable)") if err := flag.CommandLine.Parse(preprocessArgs(parseArgs)); err != nil { return Config{}, err } cfg.Command = command cfg.PlaylistNames = playlists cfg.PlaylistURLs = playlistURLs cfg.SpotifyScopes = splitComma(*scopes) cfg.SyncMode = strings.ToLower(strings.TrimSpace(cfg.SyncMode)) cfg.SyncMode = strings.ToLower(strings.TrimSpace(cfg.SyncMode)) if err := cfg.Validate(); err != nil { return Config{}, err } return cfg, nil } func (c Config) Validate() error { if c.Command == "logout" { if strings.TrimSpace(c.SessionPath) == "" { return fmt.Errorf("session file path cannot be empty") } return nil } if strings.TrimSpace(c.QobuzAppID) == "" || strings.TrimSpace(c.QobuzAppSecret) == "" { return fmt.Errorf("qobuz app id and secret are required") } if c.QobuzSelfTest && strings.TrimSpace(c.QobuzTestQuery) == "" { return fmt.Errorf("qobuz self-test query cannot be empty") } if c.MonitorInterval < 2*time.Second { return fmt.Errorf("monitor interval must be at least 2s") } if c.SyncMode != "append" && c.SyncMode != "mirror" { return fmt.Errorf("invalid sync mode %q (expected append or mirror)", c.SyncMode) } if c.TargetPlaylistID < 0 { return fmt.Errorf("target-playlist-id must be >= 0") } if strings.TrimSpace(c.SessionPath) == "" { return fmt.Errorf("session file path cannot be empty") } if !c.QobuzSelfTest && c.Command != "login" { if strings.TrimSpace(c.SpotifyRedirect) == "" { return fmt.Errorf("spotify redirect URI is required") } if len(c.SpotifyScopes) == 0 { return fmt.Errorf("at least one Spotify scope is required") } } if c.Concurrency < 1 { return fmt.Errorf("concurrency must be >= 1") } return nil } func splitComma(s string) []string { parts := strings.Split(s, ",") res := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { res = append(res, p) } } return res } type boolFlag interface { IsBoolFlag() bool } // preprocessArgs moves non-flag positional arguments to the end so that flags // can appear in any order relative to non-flag arguments. func preprocessArgs(args []string) []string { boolFlagNames := make(map[string]bool) flag.CommandLine.VisitAll(func(f *flag.Flag) { if bf, ok := f.Value.(boolFlag); ok && bf.IsBoolFlag() { boolFlagNames[f.Name] = true } }) var flagArgs []string var positional []string i := 0 for i < len(args) { arg := args[i] if !strings.HasPrefix(arg, "-") { positional = append(positional, arg) i++ continue } flagArgs = append(flagArgs, arg) i++ name := strings.TrimLeft(arg, "-") if idx := strings.Index(name, "="); idx >= 0 { continue // value embedded in --flag=value form } if boolFlagNames[name] { continue // bool flags don't consume a following value } if i < len(args) && !strings.HasPrefix(args[i], "-") { flagArgs = append(flagArgs, args[i]) i++ } } return append(flagArgs, positional...) } func envOr(key, fallback string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return fallback } func envIntOr(key string, fallback int) int { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } n, err := strconv.Atoi(v) if err != nil { return fallback } return n } func envInt64Or(key string, fallback int64) int64 { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } n, err := strconv.ParseInt(v, 10, 64) if err != nil { return fallback } return n } func envBoolOr(key string, fallback bool) bool { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } b, err := strconv.ParseBool(v) if err != nil { return fallback } return b } func envDurationOr(key string, fallback time.Duration) time.Duration { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } d, err := time.ParseDuration(v) if err != nil { return fallback } return d }