Files
QTransfer/internal/config/config.go

247 lines
8.6 KiB
Go

package config
import (
"errors"
"flag"
"fmt"
"os"
"strconv"
"strings"
"time"
"qtransfer/internal/session"
)
type Config struct {
Command string
ConfigFile 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
SyncMode string
TargetPlaylistID int64
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.ConfigFile, "config", envOr("QTRANSFER_CONFIG", ""), "Path to TOML config file with playlist sync jobs")
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, sync changed playlists to Qobuz")
flag.DurationVar(&cfg.MonitorInterval, "monitor-interval", envDurationOr("QTRANSFER_MONITOR_INTERVAL", 5*time.Minute), "Monitor polling interval (e.g. 2m, 30s)")
flag.StringVar(&cfg.SyncMode, "sync-mode", strings.ToLower(envOr("QTRANSFER_SYNC_MODE", "append")), "Sync mode for monitor-transfer: append or mirror")
flag.Int64Var(&cfg.TargetPlaylistID, "target-playlist-id", envInt64Or("QTRANSFER_TARGET_PLAYLIST_ID", 0), "Bind source playlist (single URL only) to existing Qobuz playlist ID")
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)
cfg.SyncMode = strings.ToLower(strings.TrimSpace(cfg.SyncMode))
cfg.SyncMode = strings.ToLower(strings.TrimSpace(cfg.SyncMode))
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 c.SyncMode != "append" && c.SyncMode != "mirror" {
return fmt.Errorf("invalid sync mode %q (expected append or mirror)", c.SyncMode)
}
if c.TargetPlaylistID < 0 {
return fmt.Errorf("target-playlist-id must be >= 0")
}
if c.TargetPlaylistID > 0 && !c.Monitor {
return fmt.Errorf("target-playlist-id is currently supported with --monitor mode")
}
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 envInt64Or(key string, fallback int64) int64 {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
n, err := strconv.ParseInt(v, 10, 64)
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
}