package config import ( "errors" "flag" "fmt" "os" "strconv" "strings" "time" "qtransfer/internal/session" ) type Config struct { Command 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 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.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, transfer playlists that changed") flag.DurationVar(&cfg.MonitorInterval, "monitor-interval", envDurationOr("QTRANSFER_MONITOR_INTERVAL", 5*time.Minute), "Monitor polling interval (e.g. 2m, 30s)") 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(parseArgs); err != nil { return Config{}, err } cfg.Command = command cfg.PlaylistNames = playlists cfg.PlaylistURLs = playlistURLs cfg.SpotifyScopes = splitComma(*scopes) 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 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 } 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 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 }