286 lines
9.5 KiB
Go
286 lines
9.5 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(preprocessArgs(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 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
|
|
}
|
|
|
|
type boolFlag interface {
|
|
IsBoolFlag() bool
|
|
}
|
|
|
|
// preprocessArgs moves non-flag positional arguments to the end so that flags
|
|
// can appear in any order relative to non-flag arguments.
|
|
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 // value embedded in --flag=value form
|
|
}
|
|
if boolFlagNames[name] {
|
|
continue // bool flags don't consume a following value
|
|
}
|
|
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 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
|
|
}
|