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