package main import ( "bufio" "context" "crypto/sha1" "encoding/hex" "fmt" "os" "os/signal" "sort" "strings" "syscall" "time" "qtransfer/internal/config" "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 } 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{} } 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 } 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); 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) error { if len(selection.Playlists) == 0 && !selection.IncludeLiked { return fmt.Errorf("monitor mode requires at least one playlist or --liked") } 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\n", cfg.MonitorInterval) 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 transferred.") } 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 { transferCfg := transfer.Config{ DryRun: cfg.DryRun, PublicPlaylists: cfg.PublicPlaylists, Concurrency: cfg.Concurrency, LikedName: cfg.LikedPlaylist, Progress: func(msg string) { fmt.Printf("\r%-140s", msg) }, } likedToTransfer := []model.Track{} if likedChanged { likedToTransfer = currentLiked } if _, err := transfer.Run(ctx, transferCfg, qb, matcher, changedPlaylists, likedToTransfer, likedChanged); err != nil { return fmt.Errorf("monitor transfer failed: %w", err) } fmt.Print("\r") fmt.Println("Monitor transfer 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 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{} } 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 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) }