first commit
This commit is contained in:
217
internal/config/config.go
Normal file
217
internal/config/config.go
Normal file
@@ -0,0 +1,217 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user