package main import ( "bufio" "context" "crypto/sha1" "encoding/hex" "errors" "fmt" "os" "os/signal" "sort" "strings" "sync" "syscall" "time" "qtransfer/internal/config" "qtransfer/internal/jobconfig" "qtransfer/internal/match" "qtransfer/internal/model" "qtransfer/internal/qobuz" "qtransfer/internal/report" "qtransfer/internal/session" "qtransfer/internal/spotify" "qtransfer/internal/transfer" "qtransfer/internal/ui" ) type sourceSelection struct { Playlists []model.Playlist LikedSongs []model.Track IncludeLiked bool } type monitorPlan struct { SyncMode string TargetPlaylistID int64 } func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } func run() error { cfg, err := config.Load() if err != nil { return err } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() sess, err := session.Load(cfg.SessionPath) if err != nil { return err } if !cfg.RememberCreds { sess.Spotify = session.SpotifyState{} sess.Qobuz = session.QobuzState{} } if sess.Playlists == nil { sess.Playlists = map[string]session.PlaylistSyncRef{} } applySessionDefaults(&cfg, &sess) if cfg.Command == "login" { return runLoginCommand(ctx, &cfg, &sess) } if cfg.Command == "logout" { return runLogoutCommand(cfg) } if cfg.QobuzSelfTest { err := runQobuzSelfTest(ctx, cfg, &sess) _ = persistSession(cfg, sess) return err } fileCfg := jobconfig.File{} if strings.TrimSpace(cfg.ConfigFile) != "" { loaded, err := jobconfig.Load(cfg.ConfigFile) if err != nil { return err } fileCfg = loaded if err := applyFileGlobals(&cfg, fileCfg.Global); err != nil { return err } } planURLs, monitorPlans, err := buildPlaylistPlans(cfg, fileCfg.Playlists) if err != nil { return err } if len(planURLs) > 0 { cfg.PlaylistURLs = planURLs } spToken, err := authenticateSpotify(ctx, cfg, &sess) if err != nil { return fmt.Errorf("spotify auth failed: %w", err) } sp := spotify.NewClient(spToken.AccessToken) sp.SetProgress(func(msg string) { fmt.Printf("\r%-130s", msg) }) selection, err := fetchSelection(ctx, cfg, sp) fmt.Print("\r") if err != nil { return err } if cfg.Monitor { if err := runMonitorMode(ctx, cfg, sp, &sess, selection, monitorPlans); err != nil { return err } return persistSession(cfg, sess) } fmt.Printf("Selected %d playlist(s)", len(selection.Playlists)) if selection.IncludeLiked { fmt.Printf(" + liked songs (%d tracks)", len(selection.LikedSongs)) } fmt.Println(".") qb, err := authenticateQobuz(ctx, cfg, &sess) if err != nil { return err } matcher := match.NewMatcher(qb) transferCfg := transfer.Config{ DryRun: cfg.DryRun, PublicPlaylists: cfg.PublicPlaylists, Concurrency: cfg.Concurrency, LikedName: cfg.LikedPlaylist, Progress: func(msg string) { fmt.Printf("\r%-140s", msg) }, } fmt.Println("Starting transfer...") start := time.Now() rep, err := transfer.Run(ctx, transferCfg, qb, matcher, selection.Playlists, selection.LikedSongs, selection.IncludeLiked) if err != nil { return fmt.Errorf("transfer failed: %w", err) } fmt.Print("\r") fmt.Println("Transfer processing complete. ") if err := report.Write(cfg.ReportPath, rep); err != nil { return err } if err := persistSession(cfg, sess); err != nil { return err } printSummary(rep, cfg.ReportPath, time.Since(start), cfg.DryRun) return nil } func fetchSelection(ctx context.Context, cfg config.Config, sp *spotify.Client) (sourceSelection, error) { if len(cfg.PlaylistURLs) > 0 { fmt.Println("Fetching Spotify playlist URL selections...") ids, err := resolvePlaylistIDs(cfg.PlaylistURLs) if err != nil { return sourceSelection{}, err } playlists, err := sp.FetchPlaylistsByID(ctx, ids) if err != nil { return sourceSelection{}, fmt.Errorf("spotify fetch by URL failed: %w", err) } liked := []model.Track{} if cfg.IncludeLiked { liked, err = sp.FetchLikedSongs(ctx) if err != nil { return sourceSelection{}, fmt.Errorf("spotify fetch liked songs failed: %w", err) } } fmt.Println("Fetched Spotify playlists and liked songs.") return sourceSelection{Playlists: playlists, LikedSongs: liked, IncludeLiked: cfg.IncludeLiked}, nil } fmt.Println("Fetching Spotify playlists and liked songs...") lib, err := sp.FetchLibrary(ctx, cfg.LikedPlaylist) if err != nil { return sourceSelection{}, fmt.Errorf("spotify fetch failed: %w", err) } selectedPlaylists, includeLiked, err := ui.ResolveSelection(lib, cfg.AllPlaylists, cfg.IncludeLiked, cfg.NonInteractive, cfg.PlaylistNames) if err != nil { return sourceSelection{}, err } fmt.Println("Fetched Spotify playlists and liked songs.") return sourceSelection{Playlists: selectedPlaylists, LikedSongs: lib.LikedSongs, IncludeLiked: includeLiked}, nil } func runLoginCommand(ctx context.Context, cfg *config.Config, sess *session.Data) error { fmt.Println("QTransfer login setup") fmt.Println("This stores tokens/credentials in your session file for future runs.") scanner := bufio.NewScanner(os.Stdin) if strings.TrimSpace(cfg.SpotifyClientID) == "" { cfg.SpotifyClientID = prompt(scanner, "Spotify client ID", sess.Spotify.ClientID) } if strings.TrimSpace(cfg.SpotifyClientID) != "" { fmt.Println("Starting Spotify authentication...") tok, err := spotify.LoginWithPKCE(ctx, spotify.AuthConfig{ ClientID: cfg.SpotifyClientID, RedirectURI: cfg.SpotifyRedirect, Scopes: cfg.SpotifyScopes, ManualCode: cfg.SpotifyManual, }) if err != nil { return fmt.Errorf("spotify login failed: %w", err) } updateSpotifySession(sess, tok, cfg.SpotifyClientID) fmt.Println("- Spotify login: OK") } else { fmt.Println("- Spotify login: skipped (no client id provided)") } if strings.TrimSpace(cfg.QobuzUsername) == "" { cfg.QobuzUsername = prompt(scanner, "Qobuz username/email", sess.Qobuz.Username) } if strings.TrimSpace(cfg.QobuzPassword) == "" { cfg.QobuzPassword = prompt(scanner, "Qobuz password", sess.Qobuz.Password) } if strings.TrimSpace(cfg.QobuzUsername) != "" && strings.TrimSpace(cfg.QobuzPassword) != "" { qb := qobuz.NewClient(cfg.QobuzAppID, cfg.QobuzAppSecret) if err := qb.Login(ctx, cfg.QobuzUsername, cfg.QobuzPassword); err != nil { return fmt.Errorf("qobuz login failed: %w", err) } if err := qb.VerifyAuth(ctx); err != nil { return fmt.Errorf("qobuz verify auth failed: %w", err) } sess.Qobuz.Username = cfg.QobuzUsername sess.Qobuz.Password = cfg.QobuzPassword sess.Qobuz.AccessToken = qb.Token() fmt.Println("- Qobuz login: OK") } else { fmt.Println("- Qobuz login: skipped (username/password not provided)") } if err := persistSession(*cfg, *sess); err != nil { return err } fmt.Printf("Login complete. Session saved to %s\n", session.ResolvePath(cfg.SessionPath)) fmt.Println("You can now run `qtransfer` without passing username/password each time.") return nil } func runLogoutCommand(cfg config.Config) error { path := session.ResolvePath(cfg.SessionPath) if err := os.Remove(path); err != nil { if os.IsNotExist(err) { fmt.Printf("No session file found at %s\n", path) fmt.Println("Already logged out.") return nil } return fmt.Errorf("remove session file: %w", err) } fmt.Printf("Removed session file %s\n", path) fmt.Println("Logged out. Next run will require login again.") return nil } func prompt(scanner *bufio.Scanner, label, defaultValue string) string { defaultValue = strings.TrimSpace(defaultValue) if defaultValue != "" { fmt.Printf("%s [%s]: ", label, defaultValue) } else { fmt.Printf("%s: ", label) } if !scanner.Scan() { return defaultValue } v := strings.TrimSpace(scanner.Text()) if v == "" { return defaultValue } return v } func runMonitorMode(ctx context.Context, cfg config.Config, sp *spotify.Client, sess *session.Data, selection sourceSelection, plans map[string]monitorPlan) error { if len(selection.Playlists) == 0 && !selection.IncludeLiked { return fmt.Errorf("monitor mode requires at least one playlist or --liked") } if sess.Playlists == nil { sess.Playlists = map[string]session.PlaylistSyncRef{} } fmt.Println("Starting monitor mode...") fmt.Printf("Watching %d playlist(s)", len(selection.Playlists)) if selection.IncludeLiked { fmt.Printf(" + liked songs") } fmt.Printf(" | interval=%s", cfg.MonitorInterval) if cfg.MonitorTransfer { fmt.Printf(" | sync-mode=%s", cfg.SyncMode) } fmt.Println() playlistIDs := make([]string, 0, len(selection.Playlists)) for _, p := range selection.Playlists { playlistIDs = append(playlistIDs, p.SourceID) } qb := (*qobuz.Client)(nil) matcher := (*match.Matcher)(nil) if cfg.MonitorTransfer { var err error qb, err = authenticateQobuz(ctx, cfg, sess) if err != nil { return err } matcher = match.NewMatcher(qb) fmt.Println("Monitor transfer mode enabled: changed playlists will be synced to Qobuz.") } prev := cloneMap(sess.Monitor) if len(prev) == 0 { baseline := buildFingerprintMap(selection.Playlists, selection.LikedSongs, selection.IncludeLiked) sess.Monitor = baseline if err := persistSession(cfg, *sess); err != nil { return err } fmt.Println("Initialized monitor baseline in session file.") prev = cloneMap(baseline) } runCycle := func() error { currentPlaylists, err := sp.FetchPlaylistsByID(ctx, playlistIDs) if err != nil { return fmt.Errorf("monitor fetch playlists failed: %w", err) } currentLiked := []model.Track{} if selection.IncludeLiked { currentLiked, err = sp.FetchLikedSongs(ctx) if err != nil { return fmt.Errorf("monitor fetch liked songs failed: %w", err) } } curr := buildFingerprintMap(currentPlaylists, currentLiked, selection.IncludeLiked) changedPlaylists, likedChanged := detectChanges(prev, curr, currentPlaylists, selection.IncludeLiked) if len(changedPlaylists) == 0 && !likedChanged { fmt.Printf("[%s] No updates detected.\n", time.Now().Format("15:04:05")) return nil } names := make([]string, 0, len(changedPlaylists)) for _, p := range changedPlaylists { names = append(names, p.Name) } sort.Strings(names) if likedChanged { names = append(names, cfg.LikedPlaylist+" (liked)") } fmt.Printf("[%s] Updated: %s\n", time.Now().Format("15:04:05"), strings.Join(names, ", ")) if cfg.MonitorTransfer && qb != nil && matcher != nil { for _, pl := range changedPlaylists { key := "playlist:" + pl.SourceID plan := plans[pl.SourceID] if err := syncChangedPlaylist(ctx, cfg, qb, matcher, key, pl, plan, sess); err != nil { fmt.Printf("Sync error for %s: %v\n", pl.Name, err) curr[key] = prev[key] } } if likedChanged { plan := monitorPlan{SyncMode: cfg.SyncMode} likedPlaylist := model.Playlist{Name: cfg.LikedPlaylist, Tracks: currentLiked} if err := syncChangedPlaylist(ctx, cfg, qb, matcher, "liked", likedPlaylist, plan, sess); err != nil { fmt.Printf("Sync error for liked songs: %v\n", err) curr["liked"] = prev["liked"] } } fmt.Print("\r") fmt.Println("Monitor sync cycle complete. ") } prev = curr sess.Monitor = cloneMap(curr) return persistSession(cfg, *sess) } if err := runCycle(); err != nil { return err } if cfg.MonitorOnce { fmt.Println("Monitor once completed.") return nil } ticker := time.NewTicker(cfg.MonitorInterval) defer ticker.Stop() for { select { case <-ctx.Done(): fmt.Println("Monitor stopped.") return nil case <-ticker.C: if err := runCycle(); err != nil { fmt.Printf("Monitor cycle error: %v\n", err) } } } } func authenticateSpotify(ctx context.Context, cfg config.Config, sess *session.Data) (spotify.Token, error) { if cfg.RememberCreds && strings.TrimSpace(sess.Spotify.AccessToken) != "" && time.Now().Before(sess.Spotify.ExpiresAt.Add(-30*time.Second)) { return spotify.Token{ AccessToken: sess.Spotify.AccessToken, RefreshToken: sess.Spotify.RefreshToken, Scope: sess.Spotify.Scope, }, nil } if strings.TrimSpace(cfg.SpotifyClientID) == "" { return spotify.Token{}, fmt.Errorf("spotify client id required (set --spotify-client-id once or run `qtransfer login`)") } if cfg.RememberCreds && strings.TrimSpace(sess.Spotify.RefreshToken) != "" { fmt.Println("Refreshing Spotify access token from session...") tok, err := spotify.RefreshAccessToken(ctx, cfg.SpotifyClientID, sess.Spotify.RefreshToken) if err == nil { updateSpotifySession(sess, tok, cfg.SpotifyClientID) return tok, nil } fmt.Printf("Spotify token refresh failed, falling back to login: %v\n", err) } fmt.Println("Starting Spotify authentication...") tok, err := spotify.LoginWithPKCE(ctx, spotify.AuthConfig{ ClientID: cfg.SpotifyClientID, RedirectURI: cfg.SpotifyRedirect, Scopes: cfg.SpotifyScopes, ManualCode: cfg.SpotifyManual, }) if err != nil { return spotify.Token{}, err } if cfg.RememberCreds { updateSpotifySession(sess, tok, cfg.SpotifyClientID) } return tok, nil } func authenticateQobuz(ctx context.Context, cfg config.Config, sess *session.Data) (*qobuz.Client, error) { qb := qobuz.NewClient(cfg.QobuzAppID, cfg.QobuzAppSecret) if cfg.RememberCreds && strings.TrimSpace(sess.Qobuz.AccessToken) != "" { qb.SetToken(sess.Qobuz.AccessToken) if err := qb.VerifyAuth(ctx); err == nil { return qb, nil } } if strings.TrimSpace(cfg.QobuzUsername) == "" || strings.TrimSpace(cfg.QobuzPassword) == "" { return nil, fmt.Errorf("qobuz username/password required (pass flags once or enable --remember-creds with existing session)") } fmt.Println("Authenticating with Qobuz...") if err := qb.Login(ctx, cfg.QobuzUsername, cfg.QobuzPassword); err != nil { return nil, fmt.Errorf("qobuz login failed: %w", err) } if err := qb.VerifyAuth(ctx); err != nil { return nil, fmt.Errorf("qobuz auth verification failed: %w", err) } if cfg.RememberCreds { sess.Qobuz.Username = cfg.QobuzUsername sess.Qobuz.Password = cfg.QobuzPassword sess.Qobuz.AccessToken = qb.Token() } return qb, nil } func runQobuzSelfTest(ctx context.Context, cfg config.Config, sess *session.Data) error { fmt.Println("Running Qobuz self-test...") qb, err := authenticateQobuz(ctx, cfg, sess) if err != nil { return err } fmt.Println("- Login: OK") if err := qb.VerifyAuth(ctx); err != nil { return fmt.Errorf("qobuz verify auth failed: %w", err) } fmt.Println("- user/get auth check: OK") results, err := qb.SearchTracks(ctx, cfg.QobuzTestQuery, 5) if err != nil { return fmt.Errorf("qobuz search failed: %w", err) } fmt.Printf("- Search '%s': %d result(s)\n", cfg.QobuzTestQuery, len(results)) if cfg.QobuzTestWrite { name := fmt.Sprintf("QTransfer SelfTest %d", time.Now().Unix()) playlistID, err := qb.CreatePlaylist(ctx, name, "temporary playlist created by qtransfer self-test", false) if err != nil { return fmt.Errorf("qobuz create playlist failed: %w", err) } fmt.Printf("- Create playlist: OK (id=%d)\n", playlistID) if len(results) > 0 { if err := qb.AddTracksToPlaylist(ctx, playlistID, []int64{results[0].ID}); err != nil { if delErr := qb.DeletePlaylist(context.Background(), playlistID); delErr != nil { fmt.Printf("- Cleanup test playlist after add failure: failed (%v)\n", delErr) } return fmt.Errorf("qobuz add tracks failed: %w", err) } fmt.Println("- Add first search result to playlist: OK") } else { fmt.Println("- Add first search result: skipped (no search results)") } if err := qb.DeletePlaylist(ctx, playlistID); err != nil { fmt.Printf("- Cleanup test playlist: failed (%v)\n", err) } else { fmt.Println("- Cleanup test playlist: OK") } } fmt.Println("Qobuz self-test passed.") return nil } func resolvePlaylistIDs(inputs []string) ([]string, error) { seen := map[string]struct{}{} out := make([]string, 0, len(inputs)) for _, in := range inputs { id, err := spotify.ParsePlaylistID(in) if err != nil { return nil, fmt.Errorf("invalid playlist-url %q: %w", in, err) } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } if len(out) == 0 { return nil, fmt.Errorf("no playlist URLs provided") } return out, nil } func applyFileGlobals(cfg *config.Config, g jobconfig.GlobalConfig) error { if g.Monitor != nil { cfg.Monitor = *g.Monitor } if g.MonitorOnce != nil { cfg.MonitorOnce = *g.MonitorOnce } if g.MonitorTransfer != nil { cfg.MonitorTransfer = *g.MonitorTransfer } if strings.TrimSpace(g.MonitorInterval) != "" { d, err := time.ParseDuration(strings.TrimSpace(g.MonitorInterval)) if err != nil { return fmt.Errorf("invalid global monitor_interval %q: %w", g.MonitorInterval, err) } cfg.MonitorInterval = d } if strings.TrimSpace(g.SyncMode) != "" { cfg.SyncMode = strings.ToLower(strings.TrimSpace(g.SyncMode)) } if g.IncludeLiked != nil { cfg.IncludeLiked = *g.IncludeLiked } if g.DryRun != nil { cfg.DryRun = *g.DryRun } if g.PublicPlaylists != nil { cfg.PublicPlaylists = *g.PublicPlaylists } if g.Concurrency != nil { cfg.Concurrency = *g.Concurrency } if strings.TrimSpace(g.ReportPath) != "" { cfg.ReportPath = strings.TrimSpace(g.ReportPath) } return cfg.Validate() } func buildPlaylistPlans(cfg config.Config, fileEntries []jobconfig.PlaylistEntry) ([]string, map[string]monitorPlan, error) { urls := make([]string, 0, len(cfg.PlaylistURLs)+len(fileEntries)) for _, u := range cfg.PlaylistURLs { u = strings.TrimSpace(u) if u != "" { urls = append(urls, u) } } plans := map[string]monitorPlan{} for _, raw := range urls { id, err := spotify.ParsePlaylistID(raw) if err != nil { return nil, nil, fmt.Errorf("invalid playlist-url %q: %w", raw, err) } plans[id] = monitorPlan{SyncMode: cfg.SyncMode, TargetPlaylistID: cfg.TargetPlaylistID} } for i, entry := range fileEntries { if !entry.IsEnabled() { continue } raw := strings.TrimSpace(entry.URL) id, err := spotify.ParsePlaylistID(raw) if err != nil { return nil, nil, fmt.Errorf("invalid config playlist entry %d url %q: %w", i+1, entry.URL, err) } mode := strings.ToLower(strings.TrimSpace(entry.SyncMode)) if mode == "" { mode = cfg.SyncMode } if mode == "" { mode = "append" } if entry.TargetPlaylistID > 0 && mode == "mirror" { return nil, nil, fmt.Errorf("playlist entry %d cannot use mirror mode with target_playlist_id", i+1) } plans[id] = monitorPlan{SyncMode: mode, TargetPlaylistID: entry.TargetPlaylistID} urls = append(urls, raw) } if cfg.TargetPlaylistID > 0 { uniqueIDs := map[string]struct{}{} for id := range plans { uniqueIDs[id] = struct{}{} } if len(uniqueIDs) != 1 { return nil, nil, fmt.Errorf("--target-playlist-id can only be used with exactly one source playlist URL") } } seen := map[string]struct{}{} deduped := make([]string, 0, len(urls)) for _, u := range urls { u = strings.TrimSpace(u) if u == "" { continue } if _, ok := seen[u]; ok { continue } seen[u] = struct{}{} deduped = append(deduped, u) } return deduped, plans, nil } func buildFingerprintMap(playlists []model.Playlist, liked []model.Track, includeLiked bool) map[string]string { m := make(map[string]string, len(playlists)+1) for _, p := range playlists { if strings.TrimSpace(p.SourceID) == "" { continue } m["playlist:"+p.SourceID] = playlistFingerprint(p) } if includeLiked { m["liked"] = trackListFingerprint(liked) } return m } func detectChanges(prev, curr map[string]string, playlists []model.Playlist, includeLiked bool) ([]model.Playlist, bool) { changed := []model.Playlist{} playlistByID := map[string]model.Playlist{} for _, p := range playlists { playlistByID[p.SourceID] = p } for key, now := range curr { if prev[key] == now { continue } if strings.HasPrefix(key, "playlist:") { id := strings.TrimPrefix(key, "playlist:") if p, ok := playlistByID[id]; ok { changed = append(changed, p) } } } likedChanged := false if includeLiked && prev["liked"] != curr["liked"] { likedChanged = true } return changed, likedChanged } func playlistFingerprint(pl model.Playlist) string { h := sha1.New() h.Write([]byte(pl.SourceID)) h.Write([]byte("|")) h.Write([]byte(pl.Name)) h.Write([]byte("|")) h.Write([]byte(trackListFingerprint(pl.Tracks))) return hex.EncodeToString(h.Sum(nil)) } func trackListFingerprint(tracks []model.Track) string { h := sha1.New() for _, t := range tracks { id := strings.TrimSpace(t.SourceID) if id == "" { id = strings.ToLower(strings.TrimSpace(t.Title + "|" + strings.Join(t.Artists, ",") + "|" + t.Album)) } h.Write([]byte(id)) h.Write([]byte("\n")) } return hex.EncodeToString(h.Sum(nil)) } func applySessionDefaults(cfg *config.Config, sess *session.Data) { if strings.TrimSpace(cfg.SpotifyClientID) == "" { cfg.SpotifyClientID = sess.Spotify.ClientID } if strings.TrimSpace(cfg.QobuzUsername) == "" { cfg.QobuzUsername = sess.Qobuz.Username } if strings.TrimSpace(cfg.QobuzPassword) == "" { cfg.QobuzPassword = sess.Qobuz.Password } } func updateSpotifySession(sess *session.Data, tok spotify.Token, clientID string) { if strings.TrimSpace(clientID) != "" { sess.Spotify.ClientID = strings.TrimSpace(clientID) } sess.Spotify.AccessToken = tok.AccessToken if tok.RefreshToken != "" { sess.Spotify.RefreshToken = tok.RefreshToken } sess.Spotify.Scope = tok.Scope if tok.ExpiresIn > 0 { sess.Spotify.ExpiresAt = time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second) } } func persistSession(cfg config.Config, sess session.Data) error { if !cfg.RememberCreds { sess.Spotify = session.SpotifyState{} sess.Qobuz = session.QobuzState{} } if sess.Monitor == nil { sess.Monitor = map[string]string{} } if sess.Playlists == nil { sess.Playlists = map[string]session.PlaylistSyncRef{} } return session.Save(cfg.SessionPath, sess) } func cloneMap(in map[string]string) map[string]string { out := map[string]string{} for k, v := range in { out[k] = v } return out } func syncChangedPlaylist(ctx context.Context, cfg config.Config, qb *qobuz.Client, matcher *match.Matcher, key string, pl model.Playlist, plan monitorPlan, sess *session.Data) error { mode := strings.ToLower(strings.TrimSpace(plan.SyncMode)) if mode == "" { mode = cfg.SyncMode } if mode == "" { mode = "append" } if plan.TargetPlaylistID > 0 && mode == "mirror" { return fmt.Errorf("mirror mode is not supported with explicit target playlist id (%d)", plan.TargetPlaylistID) } prev := sess.Playlists[key] matchedIDs, unmatched := matchPlaylistTracks(ctx, matcher, pl.Tracks, cfg.Concurrency, func(done, total int) { fmt.Printf("\r%-140s", fmt.Sprintf("Matching %s (%d/%d)", pl.Name, done, total)) }) matchedIDs = uniqueIDs(matchedIDs) fmt.Print("\r") fingerprint := playlistFingerprint(pl) if key == "liked" { fingerprint = trackListFingerprint(pl.Tracks) } if cfg.DryRun { fmt.Printf("Dry-run sync %s: matched=%d/%d unmatched=%d\n", pl.Name, len(matchedIDs), len(pl.Tracks), len(unmatched)) sess.Playlists[key] = session.PlaylistSyncRef{ SourceName: pl.Name, QobuzPlaylistID: prev.QobuzPlaylistID, Fingerprint: fingerprint, } return nil } targetID := prev.QobuzPlaylistID if plan.TargetPlaylistID > 0 { targetID = plan.TargetPlaylistID } if mode == "mirror" && targetID > 0 { if err := qb.DeletePlaylist(ctx, targetID); err != nil { fmt.Printf("Warning: failed deleting old playlist %d for mirror sync: %v\n", targetID, err) } targetID = 0 } if targetID == 0 { createdID, err := qb.CreatePlaylist(ctx, pl.Name, sanitizeDescription(pl.Description), cfg.PublicPlaylists) if err != nil { return fmt.Errorf("create qobuz playlist: %w", err) } targetID = createdID } if len(matchedIDs) > 0 { if err := qb.AddTracksToPlaylist(ctx, targetID, matchedIDs); err != nil { if errors.Is(err, qobuz.ErrDuplicateTracks) { fmt.Printf("No new tracks added for %s: matched tracks already exist in Qobuz playlist %d.\n", pl.Name, targetID) } else { return fmt.Errorf("add tracks to qobuz playlist %d: %w", targetID, err) } } } sess.Playlists[key] = session.PlaylistSyncRef{ SourceName: pl.Name, QobuzPlaylistID: targetID, Fingerprint: fingerprint, } fmt.Printf("Synced %s (%s): qobuz=%d matched=%d/%d unmatched=%d\n", pl.Name, mode, targetID, len(matchedIDs), len(pl.Tracks), len(unmatched)) return nil } func matchPlaylistTracks(ctx context.Context, matcher *match.Matcher, 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 && (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 printSummary(rep model.TransferReport, reportPath string, elapsed time.Duration, dryRun bool) { mode := "TRANSFER" if dryRun { mode = "DRY-RUN" } fmt.Printf("\n%s complete in %s\n", mode, elapsed.Round(time.Second)) totalMatched := 0 totalAdded := 0 totalUnmatched := 0 totalErrors := 0 for _, r := range rep.Results { unmatched := len(r.Unmatched) totalMatched += r.MatchedTracks totalAdded += r.AddedTracks totalUnmatched += unmatched totalErrors += len(r.Errors) targetInfo := "" if r.TargetID > 0 { targetInfo = fmt.Sprintf(" -> Qobuz %d", r.TargetID) } fmt.Printf("- %s%s: %d total, %d matched, %d added, %d unmatched\n", r.Name, targetInfo, r.TotalTracks, r.MatchedTracks, r.AddedTracks, unmatched) if len(r.Errors) > 0 { fmt.Printf(" errors: %s\n", strings.Join(r.Errors, " | ")) } } fmt.Printf("\nTotals: matched=%d added=%d unmatched=%d errors=%d\n", totalMatched, totalAdded, totalUnmatched, totalErrors) fmt.Printf("Report written to %s\n", reportPath) }