305 lines
11 KiB
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
|
|
}
|