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
}

348
internal/match/matcher.go Normal file
View File

@@ -0,0 +1,348 @@
package match
import (
"context"
"fmt"
"math"
"regexp"
"sort"
"strings"
"sync"
"qtransfer/internal/model"
"qtransfer/internal/qobuz"
)
type Searcher interface {
SearchTracks(ctx context.Context, query string, limit int) ([]qobuz.Track, error)
}
type Matcher struct {
searcher Searcher
cacheMu sync.RWMutex
cache map[string][]qobuz.Track
}
func NewMatcher(searcher Searcher) *Matcher {
return &Matcher{
searcher: searcher,
cache: map[string][]qobuz.Track{},
}
}
func (m *Matcher) MatchTrack(ctx context.Context, src model.Track) model.MatchedTrack {
queries := m.buildQueries(src)
if len(queries) == 0 {
return model.MatchedTrack{Source: src, Matched: false, Reason: "no usable metadata"}
}
type scored struct {
track qobuz.Track
score float64
query string
}
best := scored{score: -999}
seen := map[int64]struct{}{}
for _, q := range queries {
candidates, err := m.searchCached(ctx, q)
if err != nil {
continue
}
for _, c := range candidates {
if _, ok := seen[c.ID]; ok {
continue
}
seen[c.ID] = struct{}{}
score := scoreCandidate(src, c)
if score > best.score {
best = scored{track: c, score: score, query: q}
}
}
}
if best.track.ID == 0 {
return model.MatchedTrack{Source: src, Matched: false, Reason: "no candidates"}
}
if best.score >= 45 {
return model.MatchedTrack{
Source: src,
QobuzID: best.track.ID,
Score: best.score,
Query: best.query,
Matched: true,
}
}
reason := fmt.Sprintf("best score %.1f below threshold", best.score)
return model.MatchedTrack{
Source: src,
QobuzID: best.track.ID,
Score: best.score,
Query: best.query,
Matched: false,
Reason: reason,
}
}
func (m *Matcher) searchCached(ctx context.Context, q string) ([]qobuz.Track, error) {
q = strings.TrimSpace(q)
if q == "" {
return nil, nil
}
m.cacheMu.RLock()
if v, ok := m.cache[q]; ok {
m.cacheMu.RUnlock()
return v, nil
}
m.cacheMu.RUnlock()
res, err := m.searcher.SearchTracks(ctx, q, 20)
if err != nil {
return nil, err
}
m.cacheMu.Lock()
m.cache[q] = res
m.cacheMu.Unlock()
return res, nil
}
func (m *Matcher) buildQueries(src model.Track) []string {
title := strings.TrimSpace(src.Title)
if title == "" {
return nil
}
artist := ""
if len(src.Artists) > 0 {
artist = src.Artists[0]
}
latinTitle := strings.TrimSpace(transliterateToLatin(title))
latinArtist := strings.TrimSpace(transliterateToLatin(artist))
queries := []string{}
if src.ISRC != "" {
queries = append(queries, src.ISRC)
}
queries = append(queries, strings.TrimSpace(title+" "+artist))
if latinTitle != "" {
queries = append(queries, strings.TrimSpace(latinTitle+" "+latinArtist))
}
cleanTitle := cleanTitle(title)
if cleanTitle != title {
queries = append(queries, strings.TrimSpace(cleanTitle+" "+artist))
latinClean := strings.TrimSpace(transliterateToLatin(cleanTitle))
if latinClean != "" {
queries = append(queries, strings.TrimSpace(latinClean+" "+latinArtist))
}
}
queries = append(queries, title)
if latinTitle != "" {
queries = append(queries, latinTitle)
}
uniq := map[string]struct{}{}
out := make([]string, 0, len(queries))
for _, q := range queries {
q = strings.TrimSpace(q)
if q == "" {
continue
}
if _, ok := uniq[q]; ok {
continue
}
uniq[q] = struct{}{}
out = append(out, q)
}
return out
}
func scoreCandidate(src model.Track, dst qobuz.Track) float64 {
score := 0.0
if src.ISRC != "" && strings.EqualFold(src.ISRC, dst.ISRC) {
score += 60
}
score += 25 * similarity(normalize(src.Title), normalize(joinTitle(dst.Title, dst.Version)))
primaryArtist := ""
if len(src.Artists) > 0 {
primaryArtist = src.Artists[0]
}
if primaryArtist != "" {
score += 20 * similarity(normalize(primaryArtist), normalize(dst.Artist))
}
if src.DurationMS > 0 && dst.Duration > 0 {
delta := math.Abs(float64(src.DurationMS/1000 - dst.Duration))
switch {
case delta <= 2:
score += 10
case delta <= 5:
score += 7
case delta <= 10:
score += 4
case delta > 25:
score -= 6
}
}
nt := normalize(src.Title)
dt := normalize(joinTitle(dst.Title, dst.Version))
if !strings.Contains(nt, "live") && strings.Contains(dt, "live") {
score -= 8
}
if !strings.Contains(nt, "remix") && strings.Contains(dt, "remix") {
score -= 6
}
if strings.Contains(dt, "karaoke") {
score -= 12
}
return score
}
func joinTitle(title, version string) string {
v := strings.TrimSpace(version)
if v == "" {
return title
}
return title + " " + v
}
var nonAlphaNum = regexp.MustCompile(`[^a-z0-9]+`)
func normalize(s string) string {
s = transliterateToLatin(s)
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "&", " and ")
s = nonAlphaNum.ReplaceAllString(s, " ")
tokens := strings.Fields(s)
return strings.Join(tokens, " ")
}
var cyrillicToLatin = map[rune]string{
'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "e", 'ж': "zh", 'з': "z", 'и': "i", 'й': "i",
'к': "k", 'л': "l", 'м': "m", 'н': "n", 'о': "o", 'п': "p", 'р': "r", 'с': "s", 'т': "t", 'у': "u", 'ф': "f",
'х': "h", 'ц': "ts", 'ч': "ch", 'ш': "sh", 'щ': "shch", 'ъ': "", 'ы': "y", 'ь': "", 'э': "e", 'ю': "yu", 'я': "ya",
'і': "i", 'ї': "yi", 'є': "ye", 'ґ': "g",
'А': "a", 'Б': "b", 'В': "v", 'Г': "g", 'Д': "d", 'Е': "e", 'Ё': "e", 'Ж': "zh", 'З': "z", 'И': "i", 'Й': "i",
'К': "k", 'Л': "l", 'М': "m", 'Н': "n", 'О': "o", 'П': "p", 'Р': "r", 'С': "s", 'Т': "t", 'У': "u", 'Ф': "f",
'Х': "h", 'Ц': "ts", 'Ч': "ch", 'Ш': "sh", 'Щ': "shch", 'Ъ': "", 'Ы': "y", 'Ь': "", 'Э': "e", 'Ю': "yu", 'Я': "ya",
'І': "i", 'Ї': "yi", 'Є': "ye", 'Ґ': "g",
}
func transliterateToLatin(s string) string {
if s == "" {
return s
}
b := strings.Builder{}
b.Grow(len(s) + 8)
for _, r := range s {
if v, ok := cyrillicToLatin[r]; ok {
b.WriteString(v)
continue
}
b.WriteRune(r)
}
return b.String()
}
var cleanupRe = regexp.MustCompile(`(?i)\s*\(([^)]*(remaster|remastered|live|mono|stereo|version|deluxe|explicit|clean|bonus)[^)]*)\)|\s*-\s*(remaster(ed)?|live|version|edit|radio edit).*`)
func cleanTitle(s string) string {
clean := cleanupRe.ReplaceAllString(s, "")
clean = strings.TrimSpace(clean)
if clean == "" {
return s
}
return clean
}
func similarity(a, b string) float64 {
if a == "" || b == "" {
return 0
}
if a == b {
return 1
}
ta := tokenSet(a)
tb := tokenSet(b)
if len(ta) == 0 || len(tb) == 0 {
return 0
}
inter := 0
for t := range ta {
if _, ok := tb[t]; ok {
inter++
}
}
if inter == 0 {
return 0
}
jaccard := float64(inter) / float64(len(ta)+len(tb)-inter)
lev := levenshteinRatio(a, b)
return (jaccard * 0.6) + (lev * 0.4)
}
func tokenSet(s string) map[string]struct{} {
parts := strings.Fields(s)
set := make(map[string]struct{}, len(parts))
for _, p := range parts {
set[p] = struct{}{}
}
return set
}
func levenshteinRatio(a, b string) float64 {
ar := []rune(a)
br := []rune(b)
if len(ar) == 0 || len(br) == 0 {
return 0
}
d := levenshtein(ar, br)
maxLen := len(ar)
if len(br) > maxLen {
maxLen = len(br)
}
return 1 - float64(d)/float64(maxLen)
}
func levenshtein(a, b []rune) int {
dp := make([]int, len(b)+1)
for j := 0; j <= len(b); j++ {
dp[j] = j
}
for i := 1; i <= len(a); i++ {
prev := dp[0]
dp[0] = i
for j := 1; j <= len(b); j++ {
tmp := dp[j]
cost := 0
if a[i-1] != b[j-1] {
cost = 1
}
dp[j] = min3(
dp[j]+1,
dp[j-1]+1,
prev+cost,
)
prev = tmp
}
}
return dp[len(b)]
}
func min3(a, b, c int) int {
arr := []int{a, b, c}
sort.Ints(arr)
return arr[0]
}

View File

@@ -0,0 +1,28 @@
package match
import (
"strings"
"testing"
"qtransfer/internal/model"
)
func TestNormalizeTransliteratesCyrillic(t *testing.T) {
got := normalize("детство")
if got != "detstvo" {
t.Fatalf("expected detstvo, got %q", got)
}
}
func TestBuildQueriesIncludesLatinVariant(t *testing.T) {
m := &Matcher{}
q := m.buildQueries(model.Track{
Title: "детство",
Artists: []string{"Rauf & Faik"},
})
joined := strings.Join(q, "\n")
if !strings.Contains(strings.ToLower(joined), "detstvo") {
t.Fatalf("expected transliterated query to include detstvo, got %v", q)
}
}

53
internal/model/model.go Normal file
View File

@@ -0,0 +1,53 @@
package model
type Track struct {
SourceID string `json:"source_id,omitempty"`
Title string `json:"title"`
Artists []string `json:"artists"`
Album string `json:"album,omitempty"`
DurationMS int `json:"duration_ms,omitempty"`
ISRC string `json:"isrc,omitempty"`
Explicit bool `json:"explicit,omitempty"`
}
type Playlist struct {
SourceID string `json:"source_id,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Tracks []Track `json:"tracks"`
}
type Library struct {
UserID string `json:"user_id"`
DisplayName string `json:"display_name"`
Playlists []Playlist `json:"playlists"`
LikedSongs []Track `json:"liked_songs"`
LikedName string `json:"liked_name"`
SourceSystem string `json:"source_system"`
}
type MatchedTrack struct {
Source Track `json:"source"`
QobuzID int64 `json:"qobuz_id,omitempty"`
Score float64 `json:"score"`
Query string `json:"query,omitempty"`
Reason string `json:"reason,omitempty"`
Matched bool `json:"matched"`
}
type PlaylistTransferResult struct {
Name string `json:"name"`
TargetID int64 `json:"target_id,omitempty"`
TotalTracks int `json:"total_tracks"`
MatchedTracks int `json:"matched_tracks"`
AddedTracks int `json:"added_tracks"`
Unmatched []MatchedTrack `json:"unmatched"`
Errors []string `json:"errors"`
}
type TransferReport struct {
StartedAt string `json:"started_at"`
EndedAt string `json:"ended_at"`
DryRun bool `json:"dry_run"`
Results []PlaylistTransferResult `json:"results"`
}

372
internal/qobuz/client.go Normal file
View File

@@ -0,0 +1,372 @@
package qobuz
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const baseURL = "https://www.qobuz.com/api.json/0.2"
const defaultUA = "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717"
const defaultAppVersion = "9.7.0.3"
const defaultDevicePlatform = "android"
const defaultDeviceModel = "Nexus 6P"
const defaultDeviceOSVersion = "9"
type Client struct {
httpClient *http.Client
appID string
appSecret string
token string
}
type Track struct {
ID int64
Title string
Version string
Duration int
ISRC string
Artist string
Album string
}
func NewClient(appID, appSecret string) *Client {
return &Client{
httpClient: &http.Client{Timeout: 30 * time.Second},
appID: appID,
appSecret: appSecret,
}
}
func (c *Client) SetToken(token string) {
c.token = strings.TrimSpace(token)
}
func (c *Client) Token() string {
return c.token
}
func (c *Client) Login(ctx context.Context, username, password string) error {
type oauthResponse struct {
OAuth2 struct {
AccessToken string `json:"access_token"`
} `json:"oauth2"`
AccessToken string `json:"access_token"`
}
rawPassword := strings.TrimSpace(password)
md5Password := md5Hex(rawPassword)
attempts := []struct {
Method string
Password string
}{
{Method: http.MethodGet, Password: md5Password},
{Method: http.MethodGet, Password: rawPassword},
{Method: http.MethodPost, Password: md5Password},
{Method: http.MethodPost, Password: rawPassword},
}
var lastErr error
for _, a := range attempts {
params := url.Values{}
params.Set("username", username)
params.Set("password", a.Password)
var out oauthResponse
var err error
if a.Method == http.MethodPost {
err = c.postFormSigned(ctx, "/oauth2/login", params, &out)
} else {
err = c.getSigned(ctx, "/oauth2/login", params, &out)
}
if err != nil {
lastErr = err
continue
}
token := strings.TrimSpace(out.OAuth2.AccessToken)
if token == "" {
token = strings.TrimSpace(out.AccessToken)
}
if token == "" {
lastErr = fmt.Errorf("qobuz login response missing access_token")
continue
}
c.token = token
return nil
}
if lastErr == nil {
lastErr = fmt.Errorf("qobuz login failed")
}
return lastErr
}
func (c *Client) VerifyAuth(ctx context.Context) error {
var out map[string]any
if err := c.getUnsigned(ctx, "/user/get", url.Values{}, &out); err == nil {
return nil
}
if err := c.getSigned(ctx, "/user/get", url.Values{}, &out); err != nil {
return fmt.Errorf("verify auth failed for both unsigned and signed user/get: %w", err)
}
return nil
}
func (c *Client) SearchTracks(ctx context.Context, query string, limit int) ([]Track, error) {
if limit <= 0 {
limit = 8
}
params := url.Values{}
params.Set("query", query)
params.Set("limit", strconv.Itoa(limit))
params.Set("offset", "0")
type response struct {
Tracks struct {
Items []struct {
ID int64 `json:"id"`
Title string `json:"title"`
Version string `json:"version"`
Duration int `json:"duration"`
ISRC string `json:"isrc"`
Performer struct {
Name string `json:"name"`
} `json:"performer"`
Album struct {
Title string `json:"title"`
} `json:"album"`
} `json:"items"`
} `json:"tracks"`
}
var out response
if err := c.getSigned(ctx, "/track/search", params, &out); err != nil {
return nil, err
}
res := make([]Track, 0, len(out.Tracks.Items))
for _, it := range out.Tracks.Items {
res = append(res, Track{
ID: it.ID,
Title: it.Title,
Version: it.Version,
Duration: it.Duration,
ISRC: strings.ToUpper(strings.TrimSpace(it.ISRC)),
Artist: it.Performer.Name,
Album: it.Album.Title,
})
}
return res, nil
}
func (c *Client) CreatePlaylist(ctx context.Context, name, description string, isPublic bool) (int64, error) {
form := url.Values{}
form.Set("name", name)
form.Set("description", description)
if isPublic {
form.Set("is_public", "true")
} else {
form.Set("is_public", "false")
}
form.Set("is_collaborative", "false")
var out struct {
ID int64 `json:"id"`
}
if err := c.postFormSigned(ctx, "/playlist/create", form, &out); err != nil {
return 0, err
}
if out.ID == 0 {
return 0, fmt.Errorf("playlist/create returned empty playlist id")
}
return out.ID, nil
}
func (c *Client) AddTracksToPlaylist(ctx context.Context, playlistID int64, trackIDs []int64) error {
if len(trackIDs) == 0 {
return nil
}
chunks := chunk(trackIDs, 100)
for _, ch := range chunks {
ids := make([]string, 0, len(ch))
for _, id := range ch {
ids = append(ids, strconv.FormatInt(id, 10))
}
form := url.Values{}
form.Set("playlist_id", strconv.FormatInt(playlistID, 10))
form.Set("track_ids", strings.Join(ids, ","))
form.Set("no_duplicate", "true")
var out map[string]any
if err := c.postFormSigned(ctx, "/playlist/addTracks", form, &out); err != nil {
return err
}
}
return nil
}
func (c *Client) DeletePlaylist(ctx context.Context, playlistID int64) error {
params := url.Values{}
params.Set("playlist_id", strconv.FormatInt(playlistID, 10))
var out map[string]any
if err := c.getSigned(ctx, "/playlist/delete", params, &out); err != nil {
return err
}
return nil
}
func (c *Client) getSigned(ctx context.Context, path string, params url.Values, out any) error {
query := cloneValues(params)
ts, sig := signGet(path, c.appSecret, query)
query.Set("app_id", c.appID)
query.Set("request_ts", ts)
query.Set("request_sig", sig)
return c.doJSON(ctx, http.MethodGet, path, query, url.Values{}, out)
}
func (c *Client) getUnsigned(ctx context.Context, path string, params url.Values, out any) error {
query := cloneValues(params)
query.Set("app_id", c.appID)
return c.doJSON(ctx, http.MethodGet, path, query, url.Values{}, out)
}
func (c *Client) postFormSigned(ctx context.Context, path string, form url.Values, out any) error {
for _, includeValues := range []bool{false, true} {
query := url.Values{}
ts, sig := signPost(path, c.appSecret, form, includeValues)
query.Set("app_id", c.appID)
query.Set("request_ts", ts)
query.Set("request_sig", sig)
err := c.doJSON(ctx, http.MethodPost, path, query, form, out)
if err == nil {
return nil
}
if !isSigError(err) {
return err
}
}
return fmt.Errorf("qobuz request signature rejected for %s", path)
}
func (c *Client) doJSON(ctx context.Context, method, path string, query, form url.Values, out any) error {
u := baseURL + path
if len(query) > 0 {
u += "?" + query.Encode()
}
bodyEncoded := ""
if method == http.MethodPost {
bodyEncoded = form.Encode()
}
var lastErr error
for attempt := 1; attempt <= 4; attempt++ {
var body io.Reader
if method == http.MethodPost {
body = strings.NewReader(bodyEncoded)
}
req, err := http.NewRequestWithContext(ctx, method, u, body)
if err != nil {
return err
}
req.Header.Set("User-Agent", defaultUA)
req.Header.Set("Accept", "application/json")
req.Header.Set("X-App-Id", c.appID)
req.Header.Set("X-App-Version", defaultAppVersion)
req.Header.Set("X-Device-Platform", defaultDevicePlatform)
req.Header.Set("X-Device-Model", defaultDeviceModel)
req.Header.Set("X-Device-Os-Version", defaultDeviceOSVersion)
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("X-User-Auth-Token", c.token)
}
if method == http.MethodPost {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = err
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
continue
}
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
resp.Body.Close()
time.Sleep(time.Duration(attempt) * time.Second)
continue
}
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return fmt.Errorf("qobuz api error (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
defer resp.Body.Close()
if out == nil {
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return err
}
return nil
}
if lastErr == nil {
lastErr = fmt.Errorf("qobuz request failed after retries")
}
return lastErr
}
func chunk(ids []int64, size int) [][]int64 {
if size <= 0 {
size = 100
}
out := make([][]int64, 0, (len(ids)+size-1)/size)
for i := 0; i < len(ids); i += size {
j := i + size
if j > len(ids) {
j = len(ids)
}
out = append(out, ids[i:j])
}
return out
}
func cloneValues(v url.Values) url.Values {
res := url.Values{}
for k, values := range v {
cp := make([]string, len(values))
copy(cp, values)
res[k] = cp
}
return res
}
func isSigError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "signature") || strings.Contains(msg, "request_sig")
}
func md5Hex(s string) string {
h := md5.Sum([]byte(s))
return hex.EncodeToString(h[:])
}

View File

@@ -0,0 +1,48 @@
package qobuz
import (
"context"
"os"
"testing"
"time"
)
func TestLiveLoginVerifyAndSearch(t *testing.T) {
username := os.Getenv("QOBUZ_IT_USERNAME")
password := os.Getenv("QOBUZ_IT_PASSWORD")
if username == "" || password == "" {
t.Skip("set QOBUZ_IT_USERNAME and QOBUZ_IT_PASSWORD to run live integration test")
}
appID := os.Getenv("QOBUZ_IT_APP_ID")
if appID == "" {
appID = "312369995"
}
appSecret := os.Getenv("QOBUZ_IT_APP_SECRET")
if appSecret == "" {
appSecret = "e79f8b9be485692b0e5f9dd895826368"
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
c := NewClient(appID, appSecret)
if err := c.Login(ctx, username, password); err != nil {
t.Fatalf("login failed: %v", err)
}
if c.token == "" {
t.Fatalf("login succeeded but token is empty")
}
if err := c.VerifyAuth(ctx); err != nil {
t.Fatalf("verify auth failed: %v", err)
}
tracks, err := c.SearchTracks(ctx, "Daft Punk One More Time", 5)
if err != nil {
t.Fatalf("search failed: %v", err)
}
if len(tracks) == 0 {
t.Fatalf("search returned no results")
}
}

101
internal/qobuz/signer.go Normal file
View File

@@ -0,0 +1,101 @@
package qobuz
import (
"crypto/md5"
"encoding/hex"
"net/url"
"sort"
"strings"
"time"
)
func signGet(path, appSecret string, query url.Values) (requestTS, requestSig string) {
ts := nowTS()
method := methodName(path)
keys := make([]string, 0, len(query))
for k := range query {
if k == "app_id" || k == "request_ts" || k == "request_sig" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
b := strings.Builder{}
b.WriteString(method)
for _, k := range keys {
vals := query[k]
if len(vals) == 0 {
continue
}
b.WriteString(k)
b.WriteString(vals[0])
}
b.WriteString(ts)
b.WriteString(appSecret)
h := md5.Sum([]byte(b.String()))
return ts, hex.EncodeToString(h[:])
}
func signPost(path, appSecret string, form url.Values, includeValues bool) (requestTS, requestSig string) {
ts := nowTS()
method := methodName(path)
keys := make([]string, 0, len(form))
for k := range form {
if k == "app_id" || k == "request_ts" || k == "request_sig" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
b := strings.Builder{}
b.WriteString(method)
for _, k := range keys {
b.WriteString(k)
if includeValues {
vals := form[k]
if len(vals) > 0 {
b.WriteString(vals[0])
}
}
}
b.WriteString(ts)
b.WriteString(appSecret)
h := md5.Sum([]byte(b.String()))
return ts, hex.EncodeToString(h[:])
}
func methodName(path string) string {
return strings.ReplaceAll(strings.Trim(path, "/"), "/", "")
}
func nowTS() string {
return strconvI64(time.Now().Unix())
}
func strconvI64(v int64) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
buf := [20]byte{}
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}

24
internal/report/report.go Normal file
View File

@@ -0,0 +1,24 @@
package report
import (
"encoding/json"
"fmt"
"os"
"qtransfer/internal/model"
)
func Write(path string, rep model.TransferReport) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create report file: %w", err)
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
if err := enc.Encode(rep); err != nil {
return fmt.Errorf("encode report: %w", err)
}
return nil
}

View File

@@ -0,0 +1,98 @@
package session
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type Data struct {
Spotify SpotifyState `json:"spotify"`
Qobuz QobuzState `json:"qobuz"`
Monitor map[string]string `json:"monitor"`
Meta map[string]string `json:"meta,omitempty"`
}
type SpotifyState struct {
ClientID string `json:"client_id,omitempty"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
Scope string `json:"scope,omitempty"`
}
type QobuzState struct {
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
AccessToken string `json:"access_token,omitempty"`
}
func DefaultPath() string {
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
return ".qtransfer-session.json"
}
return filepath.Join(home, ".config", "qtransfer", "session.json")
}
func ResolvePath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return DefaultPath()
}
if path == "~" {
return DefaultPath()
}
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err == nil && home != "" {
return filepath.Join(home, path[2:])
}
}
return path
}
func Load(path string) (Data, error) {
path = ResolvePath(path)
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return Data{}, nil
}
return Data{}, err
}
var d Data
if err := json.Unmarshal(b, &d); err != nil {
return Data{}, fmt.Errorf("parse session file: %w", err)
}
if d.Monitor == nil {
d.Monitor = map[string]string{}
}
return d, nil
}
func Save(path string, data Data) error {
path = ResolvePath(path)
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create session directory: %w", err)
}
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("encode session: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return fmt.Errorf("write session temp file: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("replace session file: %w", err)
}
return nil
}

332
internal/spotify/auth.go Normal file
View File

@@ -0,0 +1,332 @@
package spotify
import (
"bufio"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"strings"
"time"
)
type AuthConfig struct {
ClientID string
RedirectURI string
Scopes []string
ManualCode bool
}
type Token struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
}
func LoginWithPKCE(ctx context.Context, cfg AuthConfig) (Token, error) {
redirectURL, err := url.Parse(cfg.RedirectURI)
if err != nil {
return Token{}, fmt.Errorf("invalid redirect URI: %w", err)
}
codeVerifier, err := randomURLSafe(64)
if err != nil {
return Token{}, err
}
state, err := randomURLSafe(24)
if err != nil {
return Token{}, err
}
h := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(h[:])
authURL := "https://accounts.spotify.com/authorize?" + url.Values{
"response_type": []string{"code"},
"client_id": []string{cfg.ClientID},
"scope": []string{strings.Join(cfg.Scopes, " ")},
"redirect_uri": []string{cfg.RedirectURI},
"state": []string{state},
"code_challenge_method": []string{"S256"},
"code_challenge": []string{codeChallenge},
}.Encode()
if cfg.ManualCode {
return loginManual(ctx, cfg, authURL, state, codeVerifier)
}
tok, err := loginWithCallbackServer(ctx, cfg, redirectURL, authURL, state, codeVerifier)
if err == nil {
return tok, nil
}
if strings.Contains(strings.ToLower(err.Error()), "listen callback server") {
fmt.Println("Local callback server unavailable; falling back to manual code entry.")
return loginManual(ctx, cfg, authURL, state, codeVerifier)
}
return Token{}, err
}
func loginWithCallbackServer(ctx context.Context, cfg AuthConfig, redirectURL *url.URL, authURL, state, codeVerifier string) (Token, error) {
codeCh := make(chan string, 1)
errCh := make(chan error, 1)
mux := http.NewServeMux()
server := &http.Server{
Addr: redirectURL.Host,
Handler: mux,
}
mux.HandleFunc(redirectURL.Path, func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("state") != state {
http.Error(w, "Invalid state", http.StatusBadRequest)
select {
case errCh <- fmt.Errorf("state mismatch in spotify callback"):
default:
}
return
}
if e := q.Get("error"); e != "" {
http.Error(w, "Spotify authorization failed", http.StatusBadRequest)
select {
case errCh <- fmt.Errorf("spotify auth error: %s", e):
default:
}
return
}
code := q.Get("code")
if code == "" {
http.Error(w, "Missing authorization code", http.StatusBadRequest)
select {
case errCh <- fmt.Errorf("spotify callback missing code"):
default:
}
return
}
_, _ = io.WriteString(w, "Spotify authorization complete. You can close this tab.")
select {
case codeCh <- code:
default:
}
})
ln, err := net.Listen("tcp", redirectURL.Host)
if err != nil {
return Token{}, fmt.Errorf("listen callback server: %w", err)
}
go func() {
if serveErr := server.Serve(ln); serveErr != nil && serveErr != http.ErrServerClosed {
select {
case errCh <- serveErr:
default:
}
}
}()
_ = openBrowser(authURL)
fmt.Printf("Open this URL in your browser if it did not open automatically:\n%s\n\n", authURL)
var code string
select {
case <-ctx.Done():
_ = server.Shutdown(context.Background())
return Token{}, ctx.Err()
case e := <-errCh:
_ = server.Shutdown(context.Background())
return Token{}, e
case code = <-codeCh:
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
return exchangeCode(ctx, cfg, code, codeVerifier)
}
func loginManual(ctx context.Context, cfg AuthConfig, authURL, expectedState, codeVerifier string) (Token, error) {
_ = openBrowser(authURL)
fmt.Printf("Open this URL in your browser if it did not open automatically:\n%s\n\n", authURL)
fmt.Printf("After Spotify redirects to %s, copy the full URL from your browser and paste it here.\n", cfg.RedirectURI)
fmt.Println("If your browser only shows a code, you can paste that code directly.")
scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Print("Paste callback URL/code: ")
if !scanner.Scan() {
if scanner.Err() != nil {
return Token{}, scanner.Err()
}
return Token{}, fmt.Errorf("stdin closed before spotify auth code was provided")
}
select {
case <-ctx.Done():
return Token{}, ctx.Err()
default:
}
input := strings.TrimSpace(scanner.Text())
if input == "" {
continue
}
code, err := extractAuthCode(input, expectedState)
if err != nil {
fmt.Printf("Could not parse code: %v\n", err)
continue
}
return exchangeCode(ctx, cfg, code, codeVerifier)
}
}
func extractAuthCode(input, expectedState string) (string, error) {
input = strings.TrimSpace(input)
if !strings.Contains(input, "code=") && !strings.Contains(input, "://") {
return input, nil
}
queryString := ""
if strings.Contains(input, "://") {
u, err := url.Parse(input)
if err != nil {
return "", fmt.Errorf("invalid callback URL")
}
queryString = u.RawQuery
} else {
queryString = strings.TrimPrefix(input, "?")
}
q, err := url.ParseQuery(queryString)
if err != nil {
return "", fmt.Errorf("invalid callback query")
}
if e := q.Get("error"); e != "" {
return "", fmt.Errorf("spotify returned error: %s", e)
}
if gotState := q.Get("state"); expectedState != "" && gotState != "" && gotState != expectedState {
return "", fmt.Errorf("state mismatch")
}
code := strings.TrimSpace(q.Get("code"))
if code == "" {
return "", fmt.Errorf("missing code parameter")
}
return code, nil
}
func exchangeCode(ctx context.Context, cfg AuthConfig, code, codeVerifier string) (Token, error) {
body := url.Values{
"client_id": []string{cfg.ClientID},
"grant_type": []string{"authorization_code"},
"code": []string{code},
"redirect_uri": []string{cfg.RedirectURI},
"code_verifier": []string{codeVerifier},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://accounts.spotify.com/api/token", strings.NewReader(body.Encode()))
if err != nil {
return Token{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return Token{}, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return Token{}, fmt.Errorf("spotify token exchange failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var tok Token
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
return Token{}, err
}
if tok.AccessToken == "" {
return Token{}, fmt.Errorf("spotify token response missing access_token")
}
return tok, nil
}
func RefreshAccessToken(ctx context.Context, clientID, refreshToken string) (Token, error) {
body := url.Values{
"client_id": []string{clientID},
"grant_type": []string{"refresh_token"},
"refresh_token": []string{refreshToken},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://accounts.spotify.com/api/token", strings.NewReader(body.Encode()))
if err != nil {
return Token{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return Token{}, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return Token{}, fmt.Errorf("spotify refresh failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var tok Token
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
return Token{}, err
}
if tok.AccessToken == "" {
return Token{}, fmt.Errorf("spotify refresh response missing access_token")
}
if tok.RefreshToken == "" {
tok.RefreshToken = refreshToken
}
return tok, nil
}
func randomURLSafe(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func openBrowser(u string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "linux":
cmd = exec.Command("xdg-open", u)
case "darwin":
cmd = exec.Command("open", u)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", u)
default:
return fmt.Errorf("unsupported OS for auto-open")
}
return cmd.Start()
}

375
internal/spotify/client.go Normal file
View File

@@ -0,0 +1,375 @@
package spotify
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"qtransfer/internal/model"
)
const baseURL = "https://api.spotify.com/v1"
type Client struct {
httpClient *http.Client
token string
progress ProgressFunc
}
type ProgressFunc func(message string)
type User struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
}
func NewClient(token string) *Client {
return &Client{
httpClient: &http.Client{Timeout: 30 * time.Second},
token: token,
}
}
func (c *Client) SetProgress(fn ProgressFunc) {
c.progress = fn
}
func (c *Client) GetCurrentUser(ctx context.Context) (User, error) {
var u User
err := c.getJSON(ctx, baseURL+"/me", &u)
return u, err
}
func (c *Client) FetchLibrary(ctx context.Context, likedName string) (model.Library, error) {
c.notifyProgress("Fetching Spotify profile...")
user, err := c.GetCurrentUser(ctx)
if err != nil {
return model.Library{}, err
}
c.notifyProgress("Fetching Spotify playlists...")
pls, err := c.FetchPlaylists(ctx)
if err != nil {
return model.Library{}, err
}
c.notifyProgress("Fetching Spotify liked songs...")
liked, err := c.FetchLikedSongs(ctx)
if err != nil {
return model.Library{}, err
}
lib := model.Library{
UserID: user.ID,
DisplayName: user.DisplayName,
Playlists: pls,
LikedSongs: liked,
LikedName: likedName,
SourceSystem: "spotify",
}
return lib, nil
}
func (c *Client) FetchPlaylists(ctx context.Context) ([]model.Playlist, error) {
type playlistLite struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
type page struct {
Items []playlistLite `json:"items"`
Next string `json:"next"`
Total int `json:"total"`
}
var out []model.Playlist
next := baseURL + "/me/playlists?limit=50"
loadedPlaylists := 0
totalPlaylists := 0
for next != "" {
var p page
if err := c.getJSON(ctx, next, &p); err != nil {
return nil, err
}
if p.Total > 0 {
totalPlaylists = p.Total
}
for _, item := range p.Items {
loadedPlaylists++
if totalPlaylists > 0 {
c.notifyProgress(fmt.Sprintf("Spotify playlists: %d/%d", loadedPlaylists, totalPlaylists))
} else {
c.notifyProgress(fmt.Sprintf("Spotify playlists: %d", loadedPlaylists))
}
tracks, err := c.fetchPlaylistTracks(ctx, item.ID, item.Name, loadedPlaylists, totalPlaylists)
if err != nil {
return nil, fmt.Errorf("fetch playlist tracks %s: %w", item.Name, err)
}
out = append(out, model.Playlist{
SourceID: item.ID,
Name: item.Name,
Description: item.Description,
Tracks: tracks,
})
}
next = p.Next
}
return out, nil
}
func (c *Client) FetchPlaylistsByID(ctx context.Context, ids []string) ([]model.Playlist, error) {
out := make([]model.Playlist, 0, len(ids))
for i, id := range ids {
pl, err := c.FetchPlaylistByID(ctx, id)
if err != nil {
return nil, err
}
c.notifyProgress(fmt.Sprintf("Spotify playlist urls: %d/%d", i+1, len(ids)))
out = append(out, pl)
}
return out, nil
}
func (c *Client) FetchPlaylistByID(ctx context.Context, playlistID string) (model.Playlist, error) {
type response struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
endpoint := fmt.Sprintf("%s/playlists/%s?fields=id,name,description", baseURL, url.PathEscape(playlistID))
var meta response
if err := c.getJSON(ctx, endpoint, &meta); err != nil {
return model.Playlist{}, fmt.Errorf("fetch playlist metadata %s: %w", playlistID, err)
}
tracks, err := c.fetchPlaylistTracks(ctx, meta.ID, meta.Name, 0, 0)
if err != nil {
return model.Playlist{}, fmt.Errorf("fetch playlist tracks %s: %w", meta.Name, err)
}
return model.Playlist{
SourceID: meta.ID,
Name: meta.Name,
Description: meta.Description,
Tracks: tracks,
}, nil
}
func (c *Client) fetchPlaylistTracks(ctx context.Context, playlistID, playlistName string, playlistIndex, playlistTotal int) ([]model.Track, error) {
type trackObj struct {
ID string `json:"id"`
Name string `json:"name"`
DurationMS int `json:"duration_ms"`
Explicit bool `json:"explicit"`
ExternalIDs struct {
ISRC string `json:"isrc"`
} `json:"external_ids"`
Album struct {
Name string `json:"name"`
} `json:"album"`
Artists []struct {
Name string `json:"name"`
} `json:"artists"`
}
type item struct {
Track *trackObj `json:"track"`
}
type page struct {
Items []item `json:"items"`
Next string `json:"next"`
Total int `json:"total"`
}
var out []model.Track
next := fmt.Sprintf("%s/playlists/%s/tracks?limit=100", baseURL, url.PathEscape(playlistID))
loadedTracks := 0
totalTracks := 0
for next != "" {
var p page
if err := c.getJSON(ctx, next, &p); err != nil {
return nil, err
}
if p.Total > 0 {
totalTracks = p.Total
}
for _, it := range p.Items {
if it.Track == nil || it.Track.ID == "" {
continue
}
loadedTracks++
out = append(out, toModelTrack(
it.Track.ID,
it.Track.Name,
it.Track.Album.Name,
it.Track.DurationMS,
it.Track.ExternalIDs.ISRC,
it.Track.Explicit,
it.Track.Artists,
))
}
prefix := "Playlist"
if playlistTotal > 0 {
prefix = fmt.Sprintf("Playlist %d/%d", playlistIndex, playlistTotal)
} else if playlistIndex > 0 {
prefix = fmt.Sprintf("Playlist %d", playlistIndex)
}
if totalTracks > 0 {
c.notifyProgress(fmt.Sprintf("%s (%s): tracks %d/%d", prefix, playlistName, loadedTracks, totalTracks))
} else {
c.notifyProgress(fmt.Sprintf("%s (%s): tracks %d", prefix, playlistName, loadedTracks))
}
next = p.Next
}
return out, nil
}
func (c *Client) FetchLikedSongs(ctx context.Context) ([]model.Track, error) {
type trackObj struct {
ID string `json:"id"`
Name string `json:"name"`
DurationMS int `json:"duration_ms"`
Explicit bool `json:"explicit"`
ExternalIDs struct {
ISRC string `json:"isrc"`
} `json:"external_ids"`
Album struct {
Name string `json:"name"`
} `json:"album"`
Artists []struct {
Name string `json:"name"`
} `json:"artists"`
}
type item struct {
Track *trackObj `json:"track"`
}
type page struct {
Items []item `json:"items"`
Next string `json:"next"`
Total int `json:"total"`
}
var out []model.Track
next := baseURL + "/me/tracks?limit=50"
loaded := 0
total := 0
for next != "" {
var p page
if err := c.getJSON(ctx, next, &p); err != nil {
return nil, err
}
if p.Total > 0 {
total = p.Total
}
for _, it := range p.Items {
if it.Track == nil || it.Track.ID == "" {
continue
}
loaded++
out = append(out, toModelTrack(
it.Track.ID,
it.Track.Name,
it.Track.Album.Name,
it.Track.DurationMS,
it.Track.ExternalIDs.ISRC,
it.Track.Explicit,
it.Track.Artists,
))
}
if total > 0 {
c.notifyProgress(fmt.Sprintf("Liked songs: %d/%d", loaded, total))
} else {
c.notifyProgress(fmt.Sprintf("Liked songs: %d", loaded))
}
next = p.Next
}
return out, nil
}
func (c *Client) notifyProgress(msg string) {
if c.progress != nil {
c.progress(msg)
}
}
func toModelTrack(id, name, album string, durationMS int, isrc string, explicit bool, artists []struct {
Name string "json:\"name\""
}) model.Track {
artistNames := make([]string, 0, len(artists))
for _, a := range artists {
if strings.TrimSpace(a.Name) != "" {
artistNames = append(artistNames, a.Name)
}
}
return model.Track{
SourceID: id,
Title: name,
Artists: artistNames,
Album: album,
DurationMS: durationMS,
ISRC: strings.ToUpper(strings.TrimSpace(isrc)),
Explicit: explicit,
}
}
func (c *Client) getJSON(ctx context.Context, endpoint string, out any) error {
var lastErr error
for attempt := 1; attempt <= 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = err
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
continue
}
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
time.Sleep(time.Duration(attempt) * time.Second)
continue
}
if resp.StatusCode >= 500 {
resp.Body.Close()
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
continue
}
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return fmt.Errorf("spotify api error (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return err
}
return nil
}
if lastErr == nil {
lastErr = fmt.Errorf("spotify request failed after retries")
}
return lastErr
}

View File

@@ -0,0 +1,43 @@
package spotify
import (
"fmt"
"net/url"
"strings"
)
func ParsePlaylistID(input string) (string, error) {
s := strings.TrimSpace(input)
if s == "" {
return "", fmt.Errorf("empty playlist input")
}
if strings.HasPrefix(s, "spotify:playlist:") {
id := strings.TrimSpace(strings.TrimPrefix(s, "spotify:playlist:"))
if id == "" {
return "", fmt.Errorf("invalid spotify URI")
}
return id, nil
}
if !strings.Contains(s, "://") {
return s, nil
}
u, err := url.Parse(s)
if err != nil {
return "", fmt.Errorf("invalid playlist URL")
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
for i := 0; i < len(parts)-1; i++ {
if parts[i] == "playlist" {
id := strings.TrimSpace(parts[i+1])
if id == "" {
return "", fmt.Errorf("missing playlist id in URL")
}
return id, nil
}
}
return "", fmt.Errorf("could not find playlist id in URL")
}

View File

@@ -0,0 +1,224 @@
package transfer
import (
"context"
"fmt"
"strings"
"sync"
"time"
"qtransfer/internal/model"
)
type QobuzWriter interface {
CreatePlaylist(ctx context.Context, name, description string, isPublic bool) (int64, error)
AddTracksToPlaylist(ctx context.Context, playlistID int64, trackIDs []int64) error
}
type TrackMatcher interface {
MatchTrack(ctx context.Context, src model.Track) model.MatchedTrack
}
type Config struct {
DryRun bool
PublicPlaylists bool
Concurrency int
LikedName string
Progress ProgressFunc
}
type ProgressFunc func(message string)
func Run(ctx context.Context, cfg Config, writer QobuzWriter, matcher TrackMatcher, playlists []model.Playlist, likedSongs []model.Track, includeLiked bool) (model.TransferReport, error) {
rep := model.TransferReport{
StartedAt: time.Now().UTC().Format(time.RFC3339),
DryRun: cfg.DryRun,
}
all := make([]model.Playlist, 0, len(playlists)+1)
all = append(all, playlists...)
if includeLiked {
all = append(all, model.Playlist{
Name: cfg.LikedName,
Tracks: likedSongs,
})
}
totalPlaylists := len(all)
for i, pl := range all {
result := processPlaylist(ctx, cfg, writer, matcher, pl, i+1, totalPlaylists)
rep.Results = append(rep.Results, result)
notify(cfg, fmt.Sprintf(
"Transfer %d/%d done: %s | matched %d/%d | added %d | unmatched %d",
i+1,
totalPlaylists,
shortName(pl.Name),
result.MatchedTracks,
result.TotalTracks,
result.AddedTracks,
len(result.Unmatched),
))
}
rep.EndedAt = time.Now().UTC().Format(time.RFC3339)
notify(cfg, "Transfer processing complete")
return rep, nil
}
func processPlaylist(ctx context.Context, cfg Config, writer QobuzWriter, matcher TrackMatcher, pl model.Playlist, playlistIndex, playlistTotal int) model.PlaylistTransferResult {
res := model.PlaylistTransferResult{
Name: pl.Name,
TotalTracks: len(pl.Tracks),
Errors: []string{},
Unmatched: []model.MatchedTrack{},
}
notify(cfg, fmt.Sprintf("Transfer %d/%d matching: %s (0/%d)", playlistIndex, playlistTotal, shortName(pl.Name), len(pl.Tracks)))
matched, unmatched := matchTracks(ctx, matcher, pl.Tracks, cfg.Concurrency, func(done, total int) {
notify(cfg, fmt.Sprintf("Transfer %d/%d matching: %s (%d/%d)", playlistIndex, playlistTotal, shortName(pl.Name), done, total))
})
res.MatchedTracks = len(matched)
res.Unmatched = unmatched
if cfg.DryRun {
res.AddedTracks = len(uniqueIDs(matched))
notify(cfg, fmt.Sprintf("Transfer %d/%d dry-run: %s resolved %d matches", playlistIndex, playlistTotal, shortName(pl.Name), res.AddedTracks))
return res
}
notify(cfg, fmt.Sprintf("Transfer %d/%d creating playlist: %s", playlistIndex, playlistTotal, shortName(pl.Name)))
playlistID, err := writer.CreatePlaylist(ctx, pl.Name, sanitizeDescription(pl.Description), cfg.PublicPlaylists)
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("create playlist: %v", err))
notify(cfg, fmt.Sprintf("Transfer %d/%d failed creating playlist: %s", playlistIndex, playlistTotal, shortName(pl.Name)))
return res
}
res.TargetID = playlistID
ids := uniqueIDs(matched)
if len(ids) == 0 {
notify(cfg, fmt.Sprintf("Transfer %d/%d no matched tracks to add: %s", playlistIndex, playlistTotal, shortName(pl.Name)))
return res
}
notify(cfg, fmt.Sprintf("Transfer %d/%d adding %d track(s): %s", playlistIndex, playlistTotal, len(ids), shortName(pl.Name)))
if err := writer.AddTracksToPlaylist(ctx, playlistID, ids); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("add tracks: %v", err))
notify(cfg, fmt.Sprintf("Transfer %d/%d failed adding tracks: %s", playlistIndex, playlistTotal, shortName(pl.Name)))
return res
}
res.AddedTracks = len(ids)
notify(cfg, fmt.Sprintf("Transfer %d/%d added %d track(s): %s", playlistIndex, playlistTotal, res.AddedTracks, shortName(pl.Name)))
return res
}
func matchTracks(ctx context.Context, matcher TrackMatcher, tracks []model.Track, concurrency int, progress func(done, total int)) ([]int64, []model.MatchedTrack) {
if concurrency < 1 {
concurrency = 1
}
type job struct {
idx int
trk model.Track
}
type out struct {
idx int
res model.MatchedTrack
}
jobs := make(chan job)
results := make(chan out)
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
m := matcher.MatchTrack(ctx, j.trk)
results <- out{idx: j.idx, res: m}
}
}()
}
go func() {
for i, t := range tracks {
jobs <- job{idx: i, trk: t}
}
close(jobs)
wg.Wait()
close(results)
}()
ordered := make([]model.MatchedTrack, len(tracks))
total := len(tracks)
step := 1
if total > 100 {
step = total / 100
}
done := 0
for r := range results {
ordered[r.idx] = r.res
done++
if progress != nil {
if done == 1 || done == total || done%step == 0 {
progress(done, total)
}
}
}
matched := make([]int64, 0, len(tracks))
unmatched := make([]model.MatchedTrack, 0)
for _, r := range ordered {
if r.Matched && r.QobuzID > 0 {
matched = append(matched, r.QobuzID)
} else {
unmatched = append(unmatched, r)
}
}
return matched, unmatched
}
func uniqueIDs(ids []int64) []int64 {
seen := map[int64]struct{}{}
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func sanitizeDescription(s string) string {
s = strings.TrimSpace(s)
if len(s) <= 1000 {
return s
}
return s[:1000]
}
func notify(cfg Config, msg string) {
if cfg.Progress != nil {
cfg.Progress(msg)
}
}
func shortName(s string) string {
const limit = 48
s = strings.TrimSpace(s)
if len(s) <= limit {
return s
}
if limit <= 3 {
return s[:limit]
}
return s[:limit-3] + "..."
}

174
internal/ui/select.go Normal file
View File

@@ -0,0 +1,174 @@
package ui
import (
"bufio"
"fmt"
"os"
"sort"
"strconv"
"strings"
"qtransfer/internal/model"
)
func ResolveSelection(lib model.Library, allPlaylists, includeLiked, nonInteractive bool, names []string) ([]model.Playlist, bool, error) {
if allPlaylists || len(names) > 0 || nonInteractive {
selected, liked, err := selectByFlags(lib, allPlaylists, includeLiked, names)
if err != nil {
return nil, false, err
}
if nonInteractive && len(selected) == 0 && !liked {
return nil, false, fmt.Errorf("no selection provided in non-interactive mode (use --all, --playlist, or --liked)")
}
if len(selected) == 0 && !liked && !nonInteractive {
return nil, false, fmt.Errorf("no playlists selected")
}
return selected, liked, nil
}
return interactiveSelection(lib)
}
func selectByFlags(lib model.Library, allPlaylists, includeLiked bool, names []string) ([]model.Playlist, bool, error) {
if allPlaylists {
return lib.Playlists, includeLiked, nil
}
if len(names) == 0 {
return nil, includeLiked, nil
}
lookup := map[string]model.Playlist{}
for _, p := range lib.Playlists {
lookup[strings.ToLower(strings.TrimSpace(p.Name))] = p
}
selected := make([]model.Playlist, 0, len(names))
missing := []string{}
seen := map[string]struct{}{}
for _, n := range names {
k := strings.ToLower(strings.TrimSpace(n))
p, ok := lookup[k]
if !ok {
missing = append(missing, n)
continue
}
if _, exists := seen[p.SourceID]; exists {
continue
}
seen[p.SourceID] = struct{}{}
selected = append(selected, p)
}
if len(missing) > 0 {
return nil, false, fmt.Errorf("playlist(s) not found: %s", strings.Join(missing, ", "))
}
return selected, includeLiked, nil
}
func interactiveSelection(lib model.Library) ([]model.Playlist, bool, error) {
fmt.Println("Fetched Spotify data:")
for i, p := range lib.Playlists {
fmt.Printf(" %2d) %s (%d tracks)\n", i+1, p.Name, len(p.Tracks))
}
fmt.Printf(" L) %s (%d tracks)\n", lib.LikedName, len(lib.LikedSongs))
fmt.Println("\nSelect playlists to transfer. Examples: 1,2,5-8,L or A for all playlists + liked songs")
scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Print("Selection: ")
if !scanner.Scan() {
if scanner.Err() != nil {
return nil, false, scanner.Err()
}
return nil, false, fmt.Errorf("input closed")
}
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
idxs, liked, all, err := parseSelection(line, len(lib.Playlists))
if err != nil {
fmt.Printf("Invalid selection: %v\n", err)
continue
}
if all {
return lib.Playlists, true, nil
}
selected := make([]model.Playlist, 0, len(idxs))
for _, idx := range idxs {
selected = append(selected, lib.Playlists[idx-1])
}
if len(selected) == 0 && !liked {
fmt.Println("No playlists selected. Please choose at least one playlist or L.")
continue
}
return selected, liked, nil
}
}
func parseSelection(s string, max int) ([]int, bool, bool, error) {
parts := strings.Split(strings.ToUpper(s), ",")
set := map[int]struct{}{}
liked := false
all := false
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
switch p {
case "A", "ALL":
all = true
liked = true
continue
case "L", "LIKED":
liked = true
continue
}
if strings.Contains(p, "-") {
r := strings.SplitN(p, "-", 2)
if len(r) != 2 {
return nil, false, false, fmt.Errorf("invalid range %q", p)
}
start, err := strconv.Atoi(strings.TrimSpace(r[0]))
if err != nil {
return nil, false, false, fmt.Errorf("invalid range start in %q", p)
}
end, err := strconv.Atoi(strings.TrimSpace(r[1]))
if err != nil {
return nil, false, false, fmt.Errorf("invalid range end in %q", p)
}
if start < 1 || end < 1 || start > max || end > max || end < start {
return nil, false, false, fmt.Errorf("range out of bounds: %q", p)
}
for i := start; i <= end; i++ {
set[i] = struct{}{}
}
continue
}
n, err := strconv.Atoi(p)
if err != nil {
return nil, false, false, fmt.Errorf("invalid token %q", p)
}
if n < 1 || n > max {
return nil, false, false, fmt.Errorf("playlist index out of bounds: %d", n)
}
set[n] = struct{}{}
}
idxs := make([]int, 0, len(set))
for i := range set {
idxs = append(idxs, i)
}
sort.Ints(idxs)
return idxs, liked, all, nil
}