218 lines
7.4 KiB
Go
218 lines
7.4 KiB
Go
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
|
|
}
|