Files
NaviMigrate/internal/config/config.go

305 lines
11 KiB
Go

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
}