build spotify-to-navidrome migrator with recovery flow
This commit is contained in:
304
internal/config/config.go
Normal file
304
internal/config/config.go
Normal file
@@ -0,0 +1,304 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user