package config import ( "errors" "flag" "fmt" "os" "strconv" "strings" ) type Config struct { SpotifyClientID string SpotifyRedirect string SpotifyScopes []string SpotifyManual bool IncludeLiked bool LikedPlaylist string RememberSpotify bool SessionFile string AddDownloaded string AddDownloadedForce bool NavidromeURL string NavidromeUsername string NavidromePassword string NavidromeSelfTest bool NavidromeSelfTestWrite bool NavidromeSelfTestQuery string DryRun bool ReportPath string Concurrency int MatchThreshold float64 PlaylistURLs []string QobuzDownloadMissing bool QobuzManifestPath string QobuzDLPath string QobuzOutputPath string QobuzUsername string QobuzPassword string QobuzAppID string QobuzAppSecret string QobuzQuality int } 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 URL cannot be empty") } *m = append(*m, v) return nil } func Load() (Config, error) { var cfg Config var playlistURLs multiFlag defaultScopes := "playlist-read-private,playlist-read-collaborative,user-library-read" defaultConcurrency := envIntOr("NAVIMIGRATE_CONCURRENCY", 4) const defaultQobuzAppID = "312369995" const defaultQobuzAppSecret = "e79f8b9be485692b0e5f9dd895826368" 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("NAVIMIGRATE_SPOTIFY_MANUAL_CODE", true), "Enter Spotify callback code/URL manually instead of running local callback server") flag.BoolVar(&cfg.IncludeLiked, "liked", envBoolOr("NAVIMIGRATE_INCLUDE_LIKED", false), "Include Spotify liked songs") flag.StringVar(&cfg.LikedPlaylist, "liked-playlist-name", envOr("NAVIMIGRATE_LIKED_NAME", "Spotify Liked Songs"), "Name of the generated liked-songs playlist on Navidrome") flag.BoolVar(&cfg.RememberSpotify, "remember-spotify", envBoolOr("NAVIMIGRATE_REMEMBER_SPOTIFY", true), "Persist Spotify refresh token and reuse session") flag.StringVar(&cfg.SessionFile, "session-file", envOr("NAVIMIGRATE_SESSION_FILE", "~/.config/navimigrate/session.json"), "Path to saved session file") flag.StringVar(&cfg.AddDownloaded, "add-downloaded-manifest", envOr("NAVIMIGRATE_ADD_DOWNLOADED_MANIFEST", ""), "Re-match and add tracks from previously generated missing-download manifest") flag.BoolVar(&cfg.AddDownloadedForce, "add-downloaded-force", envBoolOr("NAVIMIGRATE_ADD_DOWNLOADED_FORCE", false), "Force re-adding entries already marked as added in manifest") flag.StringVar(&cfg.NavidromeURL, "navidrome-url", envOr("NAVIDROME_URL", ""), "Navidrome base URL (example: https://music.example.com)") flag.StringVar(&cfg.NavidromeUsername, "navidrome-username", envOr("NAVIDROME_USERNAME", ""), "Navidrome username") flag.StringVar(&cfg.NavidromePassword, "navidrome-password", envOr("NAVIDROME_PASSWORD", ""), "Navidrome password") flag.BoolVar(&cfg.NavidromeSelfTest, "navidrome-self-test", envBoolOr("NAVIMIGRATE_NAVIDROME_SELF_TEST", false), "Run Navidrome ping/search checks and exit (skips Spotify)") flag.BoolVar(&cfg.NavidromeSelfTestWrite, "navidrome-self-test-write", envBoolOr("NAVIMIGRATE_NAVIDROME_SELF_TEST_WRITE", false), "When --navidrome-self-test is set, also create a test playlist and add one track") flag.StringVar(&cfg.NavidromeSelfTestQuery, "navidrome-self-test-query", envOr("NAVIMIGRATE_NAVIDROME_SELF_TEST_QUERY", "Daft Punk One More Time"), "Search query used for --navidrome-self-test") flag.BoolVar(&cfg.DryRun, "dry-run", envBoolOr("NAVIMIGRATE_DRY_RUN", false), "Resolve matches only, do not create or mutate Navidrome playlists") flag.StringVar(&cfg.ReportPath, "report", envOr("NAVIMIGRATE_REPORT", "transfer-report.json"), "Report output path") flag.IntVar(&cfg.Concurrency, "concurrency", defaultConcurrency, "Concurrent track matching workers") flag.Float64Var(&cfg.MatchThreshold, "match-threshold", envFloatOr("NAVIMIGRATE_MATCH_THRESHOLD", 45), "Minimum score required for a track match") flag.Var(&playlistURLs, "playlist-url", "Spotify playlist URL/URI/ID to transfer (repeatable)") flag.BoolVar(&cfg.QobuzDownloadMissing, "qobuz-download-missing", envBoolOr("NAVIMIGRATE_QOBUZ_DOWNLOAD_MISSING", false), "For unmatched tracks, search Qobuz and download their albums via qobuz-dl") flag.StringVar(&cfg.QobuzManifestPath, "qobuz-manifest", envOr("NAVIMIGRATE_QOBUZ_MANIFEST", "missing-downloads.json"), "Path to missing-download manifest JSON") flag.StringVar(&cfg.QobuzDLPath, "qobuz-dl-path", envOr("NAVIMIGRATE_QOBUZ_DL_PATH", "qobuz-dl"), "Path to qobuz-dl binary, or qobuz-dl project directory (run via go run .)") flag.StringVar(&cfg.QobuzOutputPath, "qobuz-output", envOr("NAVIMIGRATE_QOBUZ_OUTPUT", ""), "Output directory used by qobuz-dl downloads") 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", envOr("QOBUZ_APP_ID", defaultQobuzAppID), "Qobuz app ID") flag.StringVar(&cfg.QobuzAppSecret, "qobuz-app-secret", envOr("QOBUZ_APP_SECRET", defaultQobuzAppSecret), "Qobuz app secret") flag.IntVar(&cfg.QobuzQuality, "qobuz-quality", envIntOr("NAVIMIGRATE_QOBUZ_QUALITY", 6), "Quality passed to qobuz-dl (5=MP3,6=CD,7=HiRes96,27=HiRes192)") if err := flag.CommandLine.Parse(preprocessArgs(os.Args[1:])); err != nil { return Config{}, err } 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 strings.TrimSpace(c.NavidromeURL) == "" { return fmt.Errorf("navidrome URL is required") } if strings.TrimSpace(c.NavidromeUsername) == "" || strings.TrimSpace(c.NavidromePassword) == "" { return fmt.Errorf("navidrome username/password are required") } if c.NavidromeSelfTest { if strings.TrimSpace(c.NavidromeSelfTestQuery) == "" { return fmt.Errorf("navidrome self-test query cannot be empty") } if c.Concurrency < 1 { return fmt.Errorf("concurrency must be >= 1") } if c.MatchThreshold < 0 { return fmt.Errorf("match-threshold must be >= 0") } return nil } if strings.TrimSpace(c.AddDownloaded) != "" { if c.Concurrency < 1 { return fmt.Errorf("concurrency must be >= 1") } if c.MatchThreshold < 0 { return fmt.Errorf("match-threshold must be >= 0") } return nil } if strings.TrimSpace(c.SpotifyClientID) == "" { return fmt.Errorf("spotify client id is required") } 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 len(c.PlaylistURLs) == 0 && !c.IncludeLiked { return fmt.Errorf("at least one --playlist-url or --liked is required") } if c.IncludeLiked && strings.TrimSpace(c.LikedPlaylist) == "" { return fmt.Errorf("liked-playlist-name cannot be empty when --liked is set") } if c.IncludeLiked && !containsScope(c.SpotifyScopes, "user-library-read") { return fmt.Errorf("spotify scope user-library-read is required when --liked is set") } if c.RememberSpotify && strings.TrimSpace(c.SessionFile) == "" { return fmt.Errorf("session-file cannot be empty when remember-spotify is enabled") } if c.Concurrency < 1 { return fmt.Errorf("concurrency must be >= 1") } if c.MatchThreshold < 0 { return fmt.Errorf("match-threshold must be >= 0") } if c.QobuzDownloadMissing { if strings.TrimSpace(c.QobuzManifestPath) == "" { return fmt.Errorf("qobuz-manifest cannot be empty when qobuz-download-missing is enabled") } if strings.TrimSpace(c.QobuzOutputPath) == "" { return fmt.Errorf("qobuz-output is required when qobuz-download-missing is enabled") } if strings.TrimSpace(c.QobuzUsername) == "" || strings.TrimSpace(c.QobuzPassword) == "" { return fmt.Errorf("qobuz username/password are required when qobuz-download-missing is enabled") } if strings.TrimSpace(c.QobuzAppID) == "" || strings.TrimSpace(c.QobuzAppSecret) == "" { return fmt.Errorf("qobuz app id/secret are required when qobuz-download-missing is enabled") } } 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 containsScope(scopes []string, wanted string) bool { wanted = strings.ToLower(strings.TrimSpace(wanted)) for _, s := range scopes { if strings.ToLower(strings.TrimSpace(s)) == wanted { return true } } return false } type boolFlag interface { IsBoolFlag() bool } 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 } if boolFlagNames[name] { continue } 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 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 envFloatOr(key string, fallback float64) float64 { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } n, err := strconv.ParseFloat(v, 64) if err != nil { return fallback } return n }