first commit

This commit is contained in:
joren
2026-04-03 21:26:08 +02:00
commit f7805ddfd8
20 changed files with 6033 additions and 0 deletions

217
internal/config/config.go Normal file
View 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
}