commit f7805ddfd8c198aee9c82d35baa008baff03459c Author: joren Date: Fri Apr 3 21:26:08 2026 +0200 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b667519 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +SPOTIFY_CLIENT_ID= +SPOTIFY_REDIRECT_URI=http://127.0.0.1:8888/callback +SPOTIFY_SCOPES=playlist-read-private,playlist-read-collaborative,user-library-read +QTRANSFER_SPOTIFY_MANUAL_CODE=true +QTRANSFER_SESSION_FILE=~/.config/qtransfer/session.json +QTRANSFER_REMEMBER_CREDS=true + +QOBUZ_USERNAME= +QOBUZ_PASSWORD= +QOBUZ_APP_ID=312369995 +QOBUZ_APP_SECRET=e79f8b9be485692b0e5f9dd895826368 + +QTRANSFER_DRY_RUN=false +QTRANSFER_CONCURRENCY=4 +QTRANSFER_REPORT=transfer-report.json +QTRANSFER_LIKED_NAME=Spotify Liked Songs +QTRANSFER_QOBUZ_SELF_TEST=false +QTRANSFER_QOBUZ_SELF_TEST_WRITE=false +QTRANSFER_QOBUZ_SELF_TEST_QUERY=Daft Punk One More Time +QTRANSFER_MONITOR=false +QTRANSFER_MONITOR_ONCE=false +QTRANSFER_MONITOR_TRANSFER=false +QTRANSFER_MONITOR_INTERVAL=5m diff --git a/README.md b/README.md new file mode 100644 index 0000000..a27f8aa --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# QTransfer + +Spotify -> Qobuz playlist transfer tool in Go. + +## Features + +- Spotify OAuth login via Authorization Code + PKCE (client secret not required). +- Manual Spotify callback code entry (paste callback URL/code) enabled by default. +- Session cache for Spotify/Qobuz credentials and tokens (so you do not need to re-enter each run). +- Fetches all Spotify playlists and liked songs. +- Playlist URL mode (`--playlist-url`) for direct targeted transfers. +- Monitor mode to detect playlist updates (`--monitor`) with optional auto-transfer (`--monitor-transfer`). +- Interactive selection prompt (or non-interactive flags). +- Creates Qobuz playlists and fills them with best-effort track matches. +- Transfers liked songs into a dedicated playlist (not favorites). +- Writes a JSON transfer report with unmatched tracks and errors. + +## Requirements + +- Go 1.22+ +- Spotify app client ID with redirect URI configured (default: `http://127.0.0.1:8888/callback`) +- Qobuz account username/password + +## Build + +```bash +go build ./cmd/qtransfer +``` + +## Usage + +```bash +./qtransfer login +``` + +`qtransfer login` runs an interactive setup and stores Spotify/Qobuz credentials/tokens in the session file. + +After login, run transfers without re-entering credentials: + +```bash +./qtransfer +``` + +For first-time non-interactive usage (without `login`), you can still pass flags: + +```bash +./qtransfer \ + --spotify-client-id "" \ + --qobuz-username "" \ + --qobuz-password "" +``` + +Credentials/tokens are cached in `~/.config/qtransfer/session.json` by default. + +### Useful flags + +- `--all`: transfer all Spotify playlists +- `--liked`: include liked songs as a generated Qobuz playlist +- `--playlist "Name"` (repeatable): transfer specific playlists by exact name +- `--playlist-url "..."` (repeatable): transfer specific Spotify playlists by URL/URI/ID +- `--spotify-manual-code=true|false`: paste callback URL/code manually or use local callback server +- `--remember-creds=true|false`: persist/reuse tokens and credentials in session file +- `--session-file path`: custom session file path (default `~/.config/qtransfer/session.json`) +- `--monitor`: monitor selected playlists for updates +- `--monitor-interval 5m`: monitor polling interval +- `--monitor-once`: run one monitor check and exit +- `--monitor-transfer`: in monitor mode, transfer only changed playlists +- `--qobuz-self-test`: run Qobuz login/verify/search checks and exit (skips Spotify) +- `--qobuz-self-test-write`: when self-test is enabled, also create a test playlist and add one track +- `--qobuz-self-test-query "..."`: search query used during self-test +- `--non-interactive`: fail instead of prompting when no explicit selection is given +- `--dry-run`: resolve matches only, do not create playlists or add tracks +- `--report transfer-report.json`: report output path +- `--public-playlists`: create public Qobuz playlists + +Quick auth check without waiting for Spotify: + +```bash +./qtransfer \ + --qobuz-self-test \ + --qobuz-username "" \ + --qobuz-password "" +``` + +Transfer from direct Spotify playlist URLs: + +```bash +./qtransfer \ + --spotify-client-id "" \ + --qobuz-username "" \ + --qobuz-password "" \ + --playlist-url "https://open.spotify.com/playlist/37i9dQZF1DX0XUsuxWHRQd" \ + --playlist-url "spotify:playlist:37i9dQZF1DWY4xHQp97fN6" +``` + +Monitor selected playlists for changes: + +```bash +./qtransfer \ + --spotify-client-id "" \ + --playlist-url "https://open.spotify.com/playlist/37i9dQZF1DX0XUsuxWHRQd" \ + --monitor --monitor-interval 2m +``` + +Login command: + +```bash +./qtransfer login +``` + +Logout command (removes cached session): + +```bash +./qtransfer logout +``` + +### Environment variables + +- `SPOTIFY_CLIENT_ID` +- `SPOTIFY_REDIRECT_URI` (optional) +- `SPOTIFY_SCOPES` (optional) +- `QTRANSFER_SPOTIFY_MANUAL_CODE` (optional, defaults to true) +- `QTRANSFER_SESSION_FILE` (optional) +- `QTRANSFER_REMEMBER_CREDS` (optional, defaults to true) +- `QTRANSFER_MONITOR` (optional) +- `QTRANSFER_MONITOR_ONCE` (optional) +- `QTRANSFER_MONITOR_TRANSFER` (optional) +- `QTRANSFER_MONITOR_INTERVAL` (optional) +- `QTRANSFER_QOBUZ_SELF_TEST` (optional) +- `QTRANSFER_QOBUZ_SELF_TEST_WRITE` (optional) +- `QTRANSFER_QOBUZ_SELF_TEST_QUERY` (optional) +- `QOBUZ_USERNAME` +- `QOBUZ_PASSWORD` +- `QOBUZ_APP_ID` (optional, defaults to reverse-engineered app id) +- `QOBUZ_APP_SECRET` (optional, defaults to reverse-engineered app secret) diff --git a/cmd/qtransfer/main.go b/cmd/qtransfer/main.go new file mode 100644 index 0000000..99df653 --- /dev/null +++ b/cmd/qtransfer/main.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..51c2c2c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module qtransfer + +go 1.22 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5e1c9fe --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/match/matcher.go b/internal/match/matcher.go new file mode 100644 index 0000000..52a514f --- /dev/null +++ b/internal/match/matcher.go @@ -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] +} diff --git a/internal/match/matcher_test.go b/internal/match/matcher_test.go new file mode 100644 index 0000000..3a5e1e4 --- /dev/null +++ b/internal/match/matcher_test.go @@ -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) + } +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..ac663e3 --- /dev/null +++ b/internal/model/model.go @@ -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"` +} diff --git a/internal/qobuz/client.go b/internal/qobuz/client.go new file mode 100644 index 0000000..2dc042c --- /dev/null +++ b/internal/qobuz/client.go @@ -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[:]) +} diff --git a/internal/qobuz/client_integration_test.go b/internal/qobuz/client_integration_test.go new file mode 100644 index 0000000..ecdccef --- /dev/null +++ b/internal/qobuz/client_integration_test.go @@ -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") + } +} diff --git a/internal/qobuz/signer.go b/internal/qobuz/signer.go new file mode 100644 index 0000000..df82ffc --- /dev/null +++ b/internal/qobuz/signer.go @@ -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:]) +} diff --git a/internal/report/report.go b/internal/report/report.go new file mode 100644 index 0000000..ab302dd --- /dev/null +++ b/internal/report/report.go @@ -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 +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..0af1bad --- /dev/null +++ b/internal/session/session.go @@ -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 +} diff --git a/internal/spotify/auth.go b/internal/spotify/auth.go new file mode 100644 index 0000000..aa83041 --- /dev/null +++ b/internal/spotify/auth.go @@ -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() +} diff --git a/internal/spotify/client.go b/internal/spotify/client.go new file mode 100644 index 0000000..a5520ac --- /dev/null +++ b/internal/spotify/client.go @@ -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 +} diff --git a/internal/spotify/playlist_url.go b/internal/spotify/playlist_url.go new file mode 100644 index 0000000..f86a724 --- /dev/null +++ b/internal/spotify/playlist_url.go @@ -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") +} diff --git a/internal/transfer/transfer.go b/internal/transfer/transfer.go new file mode 100644 index 0000000..a4650f7 --- /dev/null +++ b/internal/transfer/transfer.go @@ -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] + "..." +} diff --git a/internal/ui/select.go b/internal/ui/select.go new file mode 100644 index 0000000..b8374b4 --- /dev/null +++ b/internal/ui/select.go @@ -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 +} diff --git a/main b/main new file mode 100755 index 0000000..e80cc43 Binary files /dev/null and b/main differ diff --git a/transfer-report.json b/transfer-report.json new file mode 100644 index 0000000..fa28484 --- /dev/null +++ b/transfer-report.json @@ -0,0 +1,2767 @@ +{ + "started_at": "2026-04-03T18:56:25Z", + "ended_at": "2026-04-03T18:57:44Z", + "dry_run": false, + "results": [ + { + "name": "Sovietwave 🚀", + "target_id": 61640498, + "total_tracks": 304, + "matched_tracks": 143, + "added_tracks": 135, + "unmatched": [ + { + "source": { + "source_id": "79hHs6yweCIX0pbnCb4T82", + "title": "Танцевать", + "artists": [ + "Бумажные Тигры" + ], + "album": "Ботаника", + "duration_ms": 162000, + "isrc": "AEA0D1958614" + }, + "qobuz_id": 217902753, + "score": 10, + "query": "Танцевать Бумажные Тигры", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3o6XKytJbNzvf7v8xl9pVi", + "title": "В леса", + "artists": [ + "Priroda" + ], + "album": "Ада", + "duration_ms": 127304, + "isrc": "RUA1H1935557" + }, + "qobuz_id": 367392430, + "score": 10, + "query": "В леса Priroda", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "29LZtFMnBk8PmUzg33i4y1", + "title": "Музей космонавтики", + "artists": [ + "Второй этаж поражает" + ], + "album": "Крайности", + "duration_ms": 100000, + "isrc": "RUA1D1914404" + }, + "qobuz_id": 331945012, + "score": 7, + "query": "Музей космонавтики", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0rNTKjcx9U8dItECBN3mO4", + "title": "Наваждение", + "artists": [ + "Холодный звонок" + ], + "album": "Наваждение", + "duration_ms": 155533, + "isrc": "GBSMU6724353", + "explicit": true + }, + "qobuz_id": 170197499, + "score": 10, + "query": "Наваждение Холодный звонок", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6KaiEB8bC8ctifLUfQdXGR", + "title": "Межзвёздная экспедиция", + "artists": [ + "Время Акаций" + ], + "album": "Межзвёздная экспедиция", + "duration_ms": 256000, + "isrc": "AEA0D1913738" + }, + "qobuz_id": 151917820, + "score": 10, + "query": "Межзвёздная экспедиция Время Акаций", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5sBl6Sq4o3OudeyjPfnfpZ", + "title": "Бутылочка", + "artists": [ + "Luna" + ], + "album": "Маг-ни-ты", + "duration_ms": 269609, + "isrc": "FR2X41637747" + }, + "qobuz_id": 53485802, + "score": 10, + "query": "Бутылочка Luna", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0uhI1APF1D4scWNrOUCMIw", + "title": "Остров", + "artists": [ + "My" + ], + "album": "Ближе, Pt. 2", + "duration_ms": 146341, + "isrc": "RUA1D1815561" + }, + "qobuz_id": 245594534, + "score": 0, + "query": "Остров", + "reason": "best score 0.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "61iGGhMOjv8eKlRlqugSXP", + "title": "Дорогой Человек", + "artists": [ + "PERMSKY KRAY" + ], + "album": "Дорогой Человек", + "duration_ms": 250665, + "isrc": "FR96X2193728" + }, + "qobuz_id": 364079808, + "score": 20, + "query": "Дорогой Человек PERMSKY KRAY", + "reason": "best score 20.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "2nwiSrGmCGHiplpypWG3Xg", + "title": "Москва", + "artists": [ + "Штадт" + ], + "album": "СНГ", + "duration_ms": 236308, + "isrc": "RUA1D1818569" + }, + "qobuz_id": 269175387, + "score": 10, + "query": "Москва Штадт", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5VfnmTz5NPAW5a8V9M1ZJ9", + "title": "Things I Don't Need", + "artists": [ + "Human Tetris" + ], + "album": "Things I Don't Need", + "duration_ms": 244052, + "isrc": "TCACU1662891" + }, + "qobuz_id": 360077761, + "score": 25, + "query": "Things I Don't Need Human Tetris", + "reason": "best score 25.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5asI2A5CkGZbdcmrg4Q0Kb", + "title": "На реке", + "artists": [ + "Группа Хмурый" + ], + "album": "Картина 1, No. 16/12", + "duration_ms": 313220, + "isrc": "RUA1H1700957" + }, + "qobuz_id": 255397844, + "score": 0, + "query": "На реке Группа Хмурый", + "reason": "best score 0.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "58ScPBHOWwvr0EZVDG3RiJ", + "title": "Исход", + "artists": [ + "Время Акаций" + ], + "album": "Межзвёздная экспедиция", + "duration_ms": 171000, + "isrc": "AEA0D1913742" + }, + "qobuz_id": 151917824, + "score": 10, + "query": "Исход Время Акаций", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "7D23IgZxuaqlbXW0T00ML4", + "title": "Летодачабабушка", + "artists": [ + "ТеплоЭлектроЦентраль" + ], + "album": "Никаких препятствий", + "duration_ms": 194381, + "isrc": "FR26V1546573" + }, + "qobuz_id": 127227561, + "score": 10, + "query": "Летодачабабушка ТеплоЭлектроЦентраль", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "2KWHEbF62KoSRWzeqTf7I1", + "title": "Красные галстуки", + "artists": [ + "Пустая Электричка" + ], + "album": "Синие ночи", + "duration_ms": 149647, + "isrc": "QZGWX2074502" + }, + "qobuz_id": 257233665, + "score": 10, + "query": "Красные галстуки Пустая Электричка", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5qbvNcgYujcSY3CDrXpB19", + "title": "Таврия", + "artists": [ + "Со мною вот что" + ], + "album": "Добро пожаловать в машину", + "duration_ms": 156000, + "isrc": "GB-SMU-24-16728" + }, + "qobuz_id": 110221261, + "score": 10, + "query": "Таврия Со мною вот что", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "034uMUazaVZttJXQqprCFb", + "title": "Ritm Vremeni", + "artists": [ + "Stereoyunost" + ], + "album": "Ritm Vremeni", + "duration_ms": 398000, + "isrc": "DEAR41520222" + }, + "qobuz_id": 379190949, + "score": 11.435483870967742, + "query": "Ritm Vremeni Stereoyunost", + "reason": "best score 11.4 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1ti48LD5k8NTOAodLLSPXS", + "title": "Поезда", + "artists": [ + "RSAC", + "ELLA" + ], + "album": "Голые факты", + "duration_ms": 227948, + "isrc": "FR10S1855850" + }, + "qobuz_id": 30412136, + "score": 20, + "query": "Поезда RSAC", + "reason": "best score 20.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6YAHZuFeKXWszGKrHVltRn", + "title": "Души стареют быстрее тел", + "artists": [ + "Utro" + ], + "album": "First Album", + "duration_ms": 150230, + "isrc": "FR59Y1800208" + }, + "qobuz_id": 105754875, + "score": 30, + "query": "Души стареют быстрее тел Utro", + "reason": "best score 30.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1cIwIgT8Si472J5EEC5QzE", + "title": "Чужие люди", + "artists": [ + "Luna" + ], + "album": "Заколдованные сны", + "duration_ms": 232143, + "isrc": "RUB421801172" + }, + "qobuz_id": 55759202, + "score": 12.666666666666668, + "query": "Чужие люди Luna", + "reason": "best score 12.7 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6KK1YmL2ouWLDEIcShnhQB", + "title": "Не смотри назад", + "artists": [ + "Артек Электроника" + ], + "album": "Не смотри назад", + "duration_ms": 270588, + "isrc": "RUA3R2061831" + }, + "qobuz_id": 400699263, + "score": 10, + "query": "Не смотри назад Артек Электроника", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "7f5uPGgDUNZu0QDvjVaQFu", + "title": "Отражение лиц в окнах вагонов", + "artists": [ + "Пустая Электричка" + ], + "album": "Синие ночи", + "duration_ms": 137121, + "isrc": "QZGWX2074503" + }, + "qobuz_id": 257233666, + "score": 10, + "query": "Отражение лиц в окнах вагонов Пустая Электричка", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1HCOz9t3hKcedgXz0dwqfT", + "title": "Таврия мокрый асфальт", + "artists": [ + "Со мною вот что" + ], + "album": "Охота 67", + "duration_ms": 155986, + "isrc": "QMFMG1564910" + }, + "qobuz_id": 110221261, + "score": 10, + "query": "Таврия мокрый асфальт Со мною вот что", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0RMNG7O7LqA9jWdVN4qotC", + "title": "Музей Радиотехники", + "artists": [ + "Пустая Электричка" + ], + "album": "Родные Края", + "duration_ms": 169908, + "isrc": "GBSMU8412990" + }, + "qobuz_id": 257233668, + "score": 7, + "query": "Музей Радиотехники Пустая Электричка", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1A0wxpuDMfnKEAYaciLryd", + "title": "Nochnoe Randevu", + "artists": [ + "Chernikovskaya Hata" + ], + "album": "Russian Tour", + "duration_ms": 208000, + "isrc": "CH6541721026" + }, + "qobuz_id": 231225432, + "score": 30, + "query": "Nochnoe Randevu Chernikovskaya Hata", + "reason": "best score 30.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3SNocJzkS03meGYxJ94nUV", + "title": "Я буду рядом", + "artists": [ + "Не Твое Дело" + ], + "album": "Бэст хитс", + "duration_ms": 223231, + "isrc": "TCACQ1664385" + }, + "qobuz_id": 357398332, + "score": 10, + "query": "Я буду рядом Не Твое Дело", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "764JzEww5nDNwdDdg4h0kC", + "title": "М.У.Р. Перестрелка", + "artists": [ + "Артек Электроника" + ], + "album": "Не смотри назад", + "duration_ms": 178285, + "isrc": "RUA3R2061834" + }, + "qobuz_id": 259904352, + "score": 10, + "query": "М.У.Р. Перестрелка Артек Электроника", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4yEdn7dSOGR0qbIfkyEioB", + "title": "Луна", + "artists": [ + "Второй этаж поражает" + ], + "album": "Крайности", + "duration_ms": 172235, + "isrc": "RUA1D1914401" + }, + "qobuz_id": 109659252, + "score": 4, + "query": "Луна Второй этаж поражает", + "reason": "best score 4.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4eWtHT7zdKkl7zSVv7Bsul", + "title": "Над Москвой", + "artists": [ + "Manicure" + ], + "album": "НГ 15", + "duration_ms": 299351, + "isrc": "FR26V1422375" + }, + "qobuz_id": 107356395, + "score": -6, + "query": "Над Москвой Manicure", + "reason": "best score -6.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0DKXR3f4MHyrcsdRM6LB9G", + "title": "Незнакомая сила", + "artists": [ + "Utro" + ], + "album": "First Album", + "duration_ms": 107745, + "isrc": "FR59Y1800202" + }, + "qobuz_id": 105754869, + "score": 30, + "query": "Незнакомая сила Utro", + "reason": "best score 30.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4mbzyakwvx9JrXFyCNiO2D", + "title": "Сколько лет, сколько дней", + "artists": [ + "Труд" + ], + "album": "7", + "duration_ms": 283036, + "isrc": "TCAEA1801379" + }, + "qobuz_id": 251110562, + "score": 0, + "query": "Сколько лет, сколько дней Труд", + "reason": "best score 0.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "22tVFvN7N6G5KovyGIldWh", + "title": "Аэродромы", + "artists": [ + "Junk Riot" + ], + "album": "Бесполезная молодость", + "duration_ms": 186000, + "isrc": "QZ9Y21729724" + }, + "qobuz_id": 80786498, + "score": 20, + "query": "Аэродромы Junk Riot", + "reason": "best score 20.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6FgcB83AKEOSVG3yYk6A1N", + "title": "In the Sky", + "artists": [ + "Leto V Gorode" + ], + "album": "2", + "duration_ms": 242517, + "isrc": "QZES81864210" + }, + "qobuz_id": 35188547, + "score": 21.13235294117647, + "query": "In the Sky", + "reason": "best score 21.1 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4mJvCCgKieMOzGMaT85iQP", + "title": "Гипноз", + "artists": [ + "Убийцы" + ], + "album": "Гипноз", + "duration_ms": 220810, + "isrc": "QM4DW1731844", + "explicit": true + }, + "qobuz_id": 97335331, + "score": 10, + "query": "Гипноз", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3MPshuGBlFbz4z24V9tnJU", + "title": "Снег", + "artists": [ + "Dolphin" + ], + "album": "Юность", + "duration_ms": 182320, + "isrc": "QMFME1590454" + }, + "qobuz_id": 103922244, + "score": 7, + "query": "Снег", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4BFIJnvxBhlny63mmoK91A", + "title": "Последний герой", + "artists": [ + "Kino" + ], + "album": "Последний герой", + "duration_ms": 184306, + "isrc": "RUB421401401" + }, + "qobuz_id": 119052752, + "score": 10, + "query": "Последний герой", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "2XXaFTV36NvPGlHlk0agSI", + "title": "Deltaplane", + "artists": [ + "Pioneerball" + ], + "album": "Deltaplane", + "duration_ms": 338616, + "isrc": "DEH741815034" + }, + "qobuz_id": 353182065, + "score": 20, + "query": "Deltaplane Pioneerball", + "reason": "best score 20.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "7fPOfNlnbFFjkrHAWS4imd", + "title": "Была права", + "artists": [ + "мертвоеморе" + ], + "album": "Мертвоеморе", + "duration_ms": 314062, + "isrc": "FR96X1636903" + }, + "qobuz_id": 123319183, + "score": -6, + "query": "Была права мертвоеморе", + "reason": "best score -6.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5nOMC6ZZ4EEW4CglJLbNOF", + "title": "Photons", + "artists": [ + "SUPERNOVA 1006" + ], + "album": "Unique World", + "duration_ms": 256361, + "isrc": "GBSMU3420067" + }, + "qobuz_id": 156484687, + "score": 20, + "query": "Photons SUPERNOVA 1006", + "reason": "best score 20.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6UY9O8Xoy3FU91zlaC93UE", + "title": "Мечты", + "artists": [ + "Бумажные Тигры" + ], + "album": "Мечты", + "duration_ms": 253636, + "isrc": "AEA0D1944690" + }, + "qobuz_id": 157203894, + "score": 10, + "query": "Мечты Бумажные Тигры", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6RYF2pAlM0ndFP43fqLRXC", + "title": "В наших глазах", + "artists": [ + "Kino" + ], + "album": "Последний герой", + "duration_ms": 223898, + "isrc": "RUB421401395" + }, + "qobuz_id": 132489743, + "score": 1, + "query": "В наших глазах", + "reason": "best score 1.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0aPl9viFldoRVw5sGZr7D7", + "title": "Сад", + "artists": [ + "Utro" + ], + "album": "First Album", + "duration_ms": 223177, + "isrc": "FR59Y1800205" + }, + "qobuz_id": 105754872, + "score": 30, + "query": "Сад Utro", + "reason": "best score 30.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "2MelMth1Mc96RWYW1YNHpL", + "title": "Белые стены", + "artists": [ + "Группа Хмурый" + ], + "album": "Картина 2, No. 17/5", + "duration_ms": 193620, + "isrc": "RUA1H1757829" + }, + "qobuz_id": 351328975, + "score": 7, + "query": "Белые стены Группа Хмурый", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "223YWaZVhcpe7lDN5hKO3E", + "title": "посмотри мне в глаза", + "artists": [ + "невиди́мка" + ], + "album": "посмотри мне в глаза", + "duration_ms": 273391, + "isrc": "FR10S1879363", + "explicit": true + }, + "qobuz_id": 178992108, + "score": 7, + "query": "посмотри мне в глаза невиди́мка", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5IoiEEeLZZ7iyMwJs6Fwsy", + "title": "Я люблю ординарную девушку", + "artists": [ + "Sierpien" + ], + "album": "Реновация", + "duration_ms": 261777, + "isrc": "AEA0Q1873341" + }, + "qobuz_id": 125738904, + "score": 7, + "query": "Я люблю ординарную девушку Sierpien", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1VLWJSnSOGftTkGtXj2S9R", + "title": "Динамо", + "artists": [ + "Убийцы" + ], + "album": "Гипноз", + "duration_ms": 163555, + "isrc": "QM4DW1731846", + "explicit": true + }, + "qobuz_id": 152131595, + "score": 10, + "query": "Динамо Убийцы", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0lMlwZxMHd0Vy7NUR7i9f9", + "title": "Алкоголь", + "artists": [ + "Проспект Космонавтов" + ], + "album": "Алкоголь и Красота", + "duration_ms": 205118, + "isrc": "AEA0Q1715073" + }, + "qobuz_id": 347637801, + "score": 10, + "query": "Алкоголь Проспект Космонавтов", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4lsc263j8tN0N5tDhKswtD", + "title": "Всё напоминает о тебе", + "artists": [ + "Ада" + ], + "album": "Всё напоминает о тебе", + "duration_ms": 196825, + "isrc": "RUA1D1807023" + }, + "qobuz_id": 64020975, + "score": 7, + "query": "Всё напоминает о тебе Ада", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3WVjEp0KB1WDvQlBLMOPAj", + "title": "Первый луч", + "artists": [ + "Труд" + ], + "album": "7", + "duration_ms": 279666, + "isrc": "TCAEA1801375" + }, + "qobuz_id": 381787085, + "score": 0, + "query": "Первый луч Труд", + "reason": "best score 0.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "19BA4sq6q2eoWb4znbAYJM", + "title": "Горячая линия", + "artists": [ + "CREAM SODA", + "LAUD" + ], + "album": "Горячая линия", + "duration_ms": 218488, + "isrc": "FR96X1837922" + }, + "qobuz_id": 127414136, + "score": 27, + "query": "Горячая линия CREAM SODA", + "reason": "best score 27.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0fLTxYvEnYRyyiFhwGRvED", + "title": "Вечер грустных пар", + "artists": [ + "Ploho" + ], + "album": "Вечер грустных пар", + "duration_ms": 203307, + "isrc": "RUA0G1900121" + }, + "qobuz_id": 96669147, + "score": 30, + "query": "Вечер грустных пар Ploho", + "reason": "best score 30.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6uTEULXe0nYGEdsbE3mGUJ", + "title": "Hate Me", + "artists": [ + "Hut" + ], + "album": "Russian Tour", + "duration_ms": 279324, + "isrc": "CH6541721022" + }, + "qobuz_id": 204278918, + "score": 32, + "query": "Hate Me", + "reason": "best score 32.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3I4lTfQkwL4xupKrG5owt1", + "title": "Кино", + "artists": [ + "Park17" + ], + "album": "Russian Tour 2", + "duration_ms": 157413, + "isrc": "CH6541741437" + }, + "qobuz_id": 292058730, + "score": 10, + "query": "Кино Park17", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3uuZgHc57MBoBchsETeecJ", + "title": "Holod", + "artists": [ + "Super Besse" + ], + "album": "63610*", + "duration_ms": 292235, + "isrc": "LVL151500929" + }, + "qobuz_id": 299657611, + "score": 34.5, + "query": "Holod Super Besse", + "reason": "best score 34.5 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5SsXn3hZTWcdhiaKHfpGIq", + "title": "Tak Silno", + "artists": [ + "Super Besse" + ], + "album": "63610*", + "duration_ms": 174866, + "isrc": "LVL151500936" + }, + "qobuz_id": 299657608, + "score": 35.42857142857143, + "query": "Tak Silno Super Besse", + "reason": "best score 35.4 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4pWK4Tahr0t9O634o5wEbb", + "title": "Vsegda", + "artists": [ + "Super Besse" + ], + "album": "La Nuit*", + "duration_ms": 205613, + "isrc": "LVL151703647" + }, + "qobuz_id": 299657605, + "score": 24.954545454545453, + "query": "Vsegda Super Besse", + "reason": "best score 25.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5drVao1HHDRtTDC95myqTt", + "title": "Танцы по расчету", + "artists": [ + "Buerak" + ], + "album": "Танцы по расчету", + "duration_ms": 182000, + "isrc": "FR59R1680938" + }, + "qobuz_id": 131330839, + "score": 10, + "query": "Танцы по расчету Buerak", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5ppnI5PHYFZiTdFK8jqmF7", + "title": "Кожаные туфли", + "artists": [ + "Buerak" + ], + "album": "Танцы по расчету", + "duration_ms": 164136, + "isrc": "FR59R1680935" + }, + "qobuz_id": 131330836, + "score": 10, + "query": "Кожаные туфли Buerak", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "37BoCrCwmSOUcO2jwB7g5W", + "title": "На Заре", + "artists": [ + "Alyans" + ], + "album": "На Заре", + "duration_ms": 346427, + "isrc": "TCADW1853277" + }, + "qobuz_id": 201676764, + "score": 0, + "query": "На Заре Alyans", + "reason": "best score 0.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0Kcn4voMB1WVsBDv2RlYj9", + "title": "Expedicija", + "artists": [ + "Dzierzynski Bitz" + ], + "album": "I II III", + "duration_ms": 303361, + "isrc": "SEYOK1215285" + }, + "qobuz_id": 81745474, + "score": 14, + "query": "Expedicija Dzierzynski Bitz", + "reason": "best score 14.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5mqZLdS94QdjkJuErUXKpK", + "title": "Заткнись и держи меня за руку", + "artists": [ + "RSAC", + "Shura Kuznetsova" + ], + "album": "Голые факты", + "duration_ms": 220000, + "isrc": "FR10S1855845" + }, + "qobuz_id": 241690394, + "score": 0, + "query": "Заткнись и держи меня за руку RSAC", + "reason": "best score 0.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3p4CxmNbNcww33DhEHev0f", + "title": "Prime Time", + "artists": [ + "CREAM SODA", + "LAUD" + ], + "album": "Prime Time", + "duration_ms": 209280, + "isrc": "FR59R1812846", + "explicit": true + }, + "qobuz_id": 131792159, + "score": 25, + "query": "Prime Time CREAM SODA", + "reason": "best score 25.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6NtmAiGDvlVuWM2Jw2wf7Q", + "title": "SSSR", + "artists": [ + "Anons" + ], + "album": "Want to Know Everything", + "duration_ms": 268016, + "isrc": "USWMA9008851" + }, + "qobuz_id": 231837430, + "score": 7.142857142857142, + "query": "SSSR Anons", + "reason": "best score 7.1 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5y9axyAdjPQIOO356S33vJ", + "title": "23:45", + "artists": [ + "Advokaty" + ], + "album": "Russian Tour 3", + "duration_ms": 208509, + "isrc": "CH6541867631" + }, + "qobuz_id": 336489236, + "score": 25, + "query": "23:45 Advokaty", + "reason": "best score 25.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "2qgbt9DvjeRUWtYm4nTnZu", + "title": "Твердые Лбы", + "artists": [ + "Budni" + ], + "album": "Russian Tour 2", + "duration_ms": 160313, + "isrc": "CH6541741440" + }, + "qobuz_id": 395462816, + "score": 4, + "query": "Твердые Лбы Budni", + "reason": "best score 4.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1I2JYk9hEoolPjShRtpDBh", + "title": "Empty Bed", + "artists": [ + "Motorama" + ], + "album": "Alps", + "duration_ms": 204586, + "isrc": "FR59Y1300030" + }, + "qobuz_id": 87479237, + "score": 35, + "query": "Empty Bed Motorama", + "reason": "best score 35.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3dquvbUxeduYIvwORkISfH", + "title": "Опять", + "artists": [ + "Junk Riot" + ], + "album": "Бесполезная молодость", + "duration_ms": 179000, + "isrc": "QZ9Y21729731" + }, + "qobuz_id": 80786498, + "score": 14, + "query": "Опять Junk Riot", + "reason": "best score 14.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "2thHnXf9SagMyDk1DlEN6U", + "title": "Имя твоё", + "artists": [ + "Убийцы" + ], + "album": "Гипноз", + "duration_ms": 205039, + "isrc": "QM4DW1731848", + "explicit": true + }, + "qobuz_id": 36512547, + "score": 4, + "query": "Имя твоё Убийцы", + "reason": "best score 4.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1v9gs2Kw1P25N075Wm8Hoz", + "title": "Царь", + "artists": [ + "Utro" + ], + "album": "Third Album", + "duration_ms": 172591, + "isrc": "FR59Y1700032" + }, + "qobuz_id": 105754874, + "score": 30, + "query": "Царь Utro", + "reason": "best score 30.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "16e7CE5sfrE0NdJ2dhNWBG", + "title": "Тот самый тип", + "artists": [ + "Baron" + ], + "album": "Тот самый тип", + "duration_ms": 180432, + "isrc": "RUA0G1800500" + }, + "qobuz_id": 217482832, + "score": 10, + "query": "Тот самый тип Baron", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3wOZBF7kZbAt05mnk6knJ1", + "title": "Ешь молись люби", + "artists": [ + "Baron" + ], + "album": "Ешь молись люби", + "duration_ms": 196540, + "isrc": "RUA0G1800131" + }, + "qobuz_id": 368365294, + "score": 10, + "query": "Ешь молись люби Baron", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3Ij2OEpAefTAvc8WPooI4c", + "title": "Супервижен", + "artists": [ + "Baron" + ], + "album": "Брошены", + "duration_ms": 198036, + "isrc": "RUA0G1700255" + }, + "qobuz_id": 114943547, + "score": 12.857142857142858, + "query": "Супервижен Baron", + "reason": "best score 12.9 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3OxAWksiB97snm2qs3iuFZ", + "title": "99", + "artists": [ + "Baron" + ], + "album": "99", + "duration_ms": 228000, + "isrc": "QM4DW1768912" + }, + "qobuz_id": 61300874, + "score": 19, + "query": "99 Baron", + "reason": "best score 19.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0Fl6sZArNGPIgsSDzn1sBQ", + "title": "Flame", + "artists": [ + "SUPERNOVA 1006" + ], + "album": "Critical Distance", + "duration_ms": 222000, + "isrc": "QM2PV1803134" + }, + "qobuz_id": 315564359, + "score": 30, + "query": "Flame SUPERNOVA 1006", + "reason": "best score 30.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1q54djpymIN8FARVpvRCSE", + "title": "You Can't Leave Me", + "artists": [ + "Hut" + ], + "album": "One Way Ticket", + "duration_ms": 240080, + "isrc": "TCACG1537634" + }, + "qobuz_id": 30867560, + "score": 29, + "query": "You Can't Leave Me Hut", + "reason": "best score 29.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "52dq5l1egWYYEAvpnalgDa", + "title": "Табачный дым", + "artists": [ + "Группа Хмурый" + ], + "album": "Картина 2, No. 17/5", + "duration_ms": 139200, + "isrc": "RUA1H1757830" + }, + "qobuz_id": 113191986, + "score": -6, + "query": "Табачный дым Группа Хмурый", + "reason": "best score -6.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5TrxtBaPintomyX7fkhjbM", + "title": "Первые на первом", + "artists": [ + "ЩЕНКИ" + ], + "album": "ЩЕНКИ", + "duration_ms": 207529, + "isrc": "QM4TW1764713" + }, + "qobuz_id": 392222700, + "score": 10, + "query": "Первые на первом", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6cqoszHhE7NyeVZ79ECNli", + "title": "Humaths", + "artists": [ + "SUPERNOVA 1006" + ], + "album": "Blackout", + "duration_ms": 158000, + "isrc": "QZARB1887216" + }, + "qobuz_id": 315564362, + "score": 30, + "query": "Humaths SUPERNOVA 1006", + "reason": "best score 30.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5bhiXsmluzPoPtFzajpF93", + "title": "Мой день - твой день", + "artists": [ + "Ритуальные Услуги" + ], + "album": "Типичный запрос в психологию", + "duration_ms": 134577, + "isrc": "RUA1D1823466" + }, + "qobuz_id": 294099266, + "score": 7, + "query": "Мой день - твой день", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1heMZpByfWQ7pG2QkV9cPx", + "title": "Krug", + "artists": [ + "Super Besse" + ], + "album": "La Nuit*", + "duration_ms": 210233, + "isrc": "LVL151703643" + }, + "qobuz_id": 200433446, + "score": 25, + "query": "Krug", + "reason": "best score 25.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3R3dkRhRH1irtOnaVjBppQ", + "title": "Бластеры", + "artists": [ + "Твоё далеко" + ], + "album": "Бластеры", + "duration_ms": 202359, + "isrc": "TCACN1647071" + }, + "qobuz_id": 310110626, + "score": 10, + "query": "Бластеры Твоё далеко", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6vaXaOWxfwlwL7pxnL3myK", + "title": "Холод", + "artists": [ + "Ploho" + ], + "album": "Куда птицы улетают умирать", + "duration_ms": 137000, + "isrc": "QZ5AB1880115" + }, + "qobuz_id": 94071847, + "score": 30, + "query": "Холод Ploho", + "reason": "best score 30.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0GeSAsdyKWMfeKtTtJPAqA", + "title": "GRUZOVIK", + "artists": [ + "YOURA" + ], + "album": "PLAN Б", + "duration_ms": 207360, + "isrc": "FR2X41966602" + }, + "qobuz_id": 274185562, + "score": 30, + "query": "GRUZOVIK YOURA", + "reason": "best score 30.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4LDVgGDj361O8QodPaPftV", + "title": "Советский Парфюм", + "artists": [ + "Buerak" + ], + "album": "Корни", + "duration_ms": 154105, + "isrc": "FR59R1663043" + }, + "qobuz_id": 336327860, + "score": 7, + "query": "Советский Парфюм Buerak", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0pF7OVyZvxN14uhW0gLwVA", + "title": "Оттаявшее Окно", + "artists": [ + "Buerak" + ], + "album": "Зимние Песни", + "duration_ms": 160000, + "isrc": "FR59R1663042" + }, + "qobuz_id": 316682592, + "score": 10, + "query": "Оттаявшее Окно Buerak", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3DcWzszZd6H3sSKqkKdcas", + "title": "Скорость", + "artists": [ + "Model Povedenia" + ], + "album": "Скорость", + "duration_ms": 213390, + "isrc": "FRX201722617" + }, + "qobuz_id": 264532032, + "score": 27, + "query": "Скорость Model Povedenia", + "reason": "best score 27.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1kE0WRvNQy0eeMEY0TXPkm", + "title": "Рыбы", + "artists": [ + "Model Povedenia" + ], + "album": "Кому Что", + "duration_ms": 316341, + "isrc": "USCGJ1677669" + }, + "qobuz_id": 371229610, + "score": 7, + "query": "Рыбы", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "61mmfJ6iPzjMyxjIhUzJnw", + "title": "Твоя Россия", + "artists": [ + "RSAC" + ], + "album": "Голые факты", + "duration_ms": 265511, + "isrc": "FR10S1855841" + }, + "qobuz_id": 225588742, + "score": 14, + "query": "Твоя Россия RSAC", + "reason": "best score 14.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1xn5AaQYSgGGX7ddSUKgjn", + "title": "Первая жизнь", + "artists": [ + "Пожар" + ], + "album": "Первая жизнь", + "duration_ms": 240000, + "isrc": "GBKPL1822394" + }, + "qobuz_id": 262383784, + "score": 4, + "query": "Первая жизнь", + "reason": "best score 4.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "16rO0ucE26IeizQNMwL8kS", + "title": "Прыгай за руки держась", + "artists": [ + "8(913)" + ], + "album": "Новая школа русского рока", + "duration_ms": 154043, + "isrc": "USHM81825341" + }, + "qobuz_id": 174060261, + "score": 14, + "query": "Прыгай за руки держась 8(913)", + "reason": "best score 14.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6tsJz6CSWOlN2j7UL3RDM8", + "title": "Дорога в парк", + "artists": [ + "Model Povedenia", + "Рома Красенглаз" + ], + "album": "Ночные ошибки", + "duration_ms": 243998, + "isrc": "FR59R1781446" + }, + "qobuz_id": 337180042, + "score": 10, + "query": "Дорога в парк Model Povedenia", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "054YDVvc7C6Ut9j3Vwljmd", + "title": "Странный рассказ", + "artists": [ + "ОБРАЗ" + ], + "album": "10", + "duration_ms": 184000, + "isrc": "RUA1D1827015" + }, + "qobuz_id": 187041760, + "score": 10, + "query": "Странный рассказ ОБРАЗ", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6oArqallSI5hwKfvDoJZzi", + "title": "Yunost", + "artists": [ + "Stereoyunost" + ], + "album": "Ritm Vremeni", + "duration_ms": 434823, + "isrc": "DEAR41520226" + }, + "qobuz_id": 49549954, + "score": 19, + "query": "Yunost Stereoyunost", + "reason": "best score 19.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6kYY4INcVwB2iPqMYtDY9x", + "title": "23", + "artists": [ + "Baron" + ], + "album": "Давай лучше не знать, что ждет нас дальше", + "duration_ms": 185458, + "isrc": "RUA0G1900792", + "explicit": true + }, + "qobuz_id": 302433331, + "score": 14.185185185185185, + "query": "23 Baron", + "reason": "best score 14.2 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4lGbFe3ttwMOh8LSXuxlGS", + "title": "2077", + "artists": [ + "Встречные" + ], + "album": "Первому игроку успокоиться", + "duration_ms": 181746, + "isrc": "RUA1D1827669" + }, + "qobuz_id": 239071425, + "score": 19, + "query": "2077 Встречные", + "reason": "best score 19.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4JslcpcH003rVxrledLkEt", + "title": "Работа", + "artists": [ + "The Violent Youth" + ], + "album": "Distant", + "duration_ms": 135186, + "isrc": "AEA0D1940133" + }, + "qobuz_id": 210826659, + "score": 14, + "query": "Работа The Violent Youth", + "reason": "best score 14.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3FOKbB3b1v9Aiv6BXwzFHM", + "title": "Your Mess", + "artists": [ + "SUPERNOVA 1006" + ], + "album": "Technical Support", + "duration_ms": 185374, + "isrc": "QZES71995874" + }, + "qobuz_id": 227081536, + "score": 21.32142857142857, + "query": "Your Mess SUPERNOVA 1006", + "reason": "best score 21.3 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1hJXxEdCJW5GAvZ2I9ixwY", + "title": "Electroenergy", + "artists": [ + "The End of Electronics" + ], + "album": "Electroenergy", + "duration_ms": 239200, + "isrc": "QZFZ71977210" + }, + "qobuz_id": 267653933, + "score": 14, + "query": "Electroenergy The End of Electronics", + "reason": "best score 14.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1CXFNlanFFOecNi82dy7xD", + "title": "Serdze", + "artists": [ + "Leto V Gorode" + ], + "album": "2", + "duration_ms": 250222, + "isrc": "QZES81864207" + }, + "qobuz_id": 72884675, + "score": 14, + "query": "Serdze Leto V Gorode", + "reason": "best score 14.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4alnnfmKOcfg1gbojoLJb0", + "title": "Место наших встреч", + "artists": [ + "Англия" + ], + "album": "Тепло от холодных рук", + "duration_ms": 215406, + "isrc": "RUA1D1902135" + }, + "qobuz_id": 234140094, + "score": 10, + "query": "Место наших встреч Англия", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3VTTpPfkuB5Zr2ZLxeFzMG", + "title": "Лударь", + "artists": [ + "Убийцы" + ], + "album": "Убийцы", + "duration_ms": 161685, + "isrc": "QZ22B1966616", + "explicit": true + }, + "qobuz_id": 152131595, + "score": 10, + "query": "Лударь Убийцы", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6ncUiDhR3inz0U0cpgC0fF", + "title": "ACTIVITY", + "artists": [ + "YOURA" + ], + "album": "ACTIVITY", + "duration_ms": 249678, + "isrc": "FR96X1981964" + }, + "qobuz_id": 130812390, + "score": 13.214285714285715, + "query": "ACTIVITY", + "reason": "best score 13.2 below threshold", + "matched": false + }, + { + "source": { + "source_id": "7gNDoczqxYznKEIDrY1mp8", + "title": "Proletarian Gothic", + "artists": [ + "The End of Electronics" + ], + "album": "Proletarian Gothic", + "duration_ms": 213333, + "isrc": "QZHN91934317" + }, + "qobuz_id": 270913540, + "score": 42.64285714285714, + "query": "Proletarian Gothic The End of Electronics", + "reason": "best score 42.6 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0xiO0Gt1jEcAF3VPVf4pOr", + "title": "Юдоль Печали", + "artists": [ + "Порез на Собаке" + ], + "album": "Фегивербе", + "duration_ms": 279336, + "isrc": "RUA0G1900406" + }, + "qobuz_id": 293357404, + "score": 10, + "query": "Юдоль Печали Порез на Собаке", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0h1Q54bnxYPIH8wM9KZP0V", + "title": "8080", + "artists": [ + "Priroda" + ], + "album": "Ада", + "duration_ms": 198857, + "isrc": "QZGLM1929078" + }, + "qobuz_id": 301455022, + "score": 29, + "query": "8080 Priroda", + "reason": "best score 29.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "64xPmRyF9zySyWKewmeGWE", + "title": "Туманный день", + "artists": [ + "Весна 310" + ], + "album": "Минувших дней память", + "duration_ms": 209552, + "isrc": "QZGLM1983171" + }, + "qobuz_id": 341600033, + "score": 14, + "query": "Туманный день Весна 310", + "reason": "best score 14.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4aj7f1GfDhufVkNZPzMl62", + "title": "Открывая путь в космос", + "artists": [ + "Весна 310" + ], + "album": "По проспектам", + "duration_ms": 253800, + "isrc": "QZGLM1983078" + }, + "qobuz_id": 299092113, + "score": 0, + "query": "Открывая путь в космос Весна 310", + "reason": "best score 0.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "40FVkBiYJLHeuzq9SfOXKV", + "title": "Безмятежность", + "artists": [ + "Весна 310" + ], + "album": "Минувших дней память", + "duration_ms": 148374, + "isrc": "QZGLM1983177" + }, + "qobuz_id": 341600033, + "score": 30, + "query": "Безмятежность Весна 310", + "reason": "best score 30.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "39U2N0ZBsT7FZE8ur4xMzH", + "title": "Уединение", + "artists": [ + "луноберег" + ], + "album": "Видение", + "duration_ms": 161149, + "isrc": "AEA0Q1908871" + }, + "qobuz_id": 276292096, + "score": 0, + "query": "Уединение луноберег", + "reason": "best score 0.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4LDKYR393AY0L7nna2LIBK", + "title": "Загадка", + "artists": [ + "Время Акаций" + ], + "album": "Межзвёздная экспедиция", + "duration_ms": 216000, + "isrc": "AEA0D1913736" + }, + "qobuz_id": 151917818, + "score": 10, + "query": "Загадка Время Акаций", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "495DM35lETVDpYhbHZ24T1", + "title": "Sea of Moscow", + "artists": [ + "Pioneerball" + ], + "album": "Sea of Moscow", + "duration_ms": 297122, + "isrc": "DEH741804501" + }, + "qobuz_id": 151920084, + "score": 19, + "query": "Sea of Moscow Pioneerball", + "reason": "best score 19.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3fHy2yuVB41WJM1kaEHY5R", + "title": "В леса", + "artists": [ + "Priroda" + ], + "album": "Ада", + "duration_ms": 127304, + "isrc": "QZGLM1929072" + }, + "qobuz_id": 367392430, + "score": 10, + "query": "В леса Priroda", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6WSYfybMYzx2JmFemGjVhB", + "title": "Niejeden", + "artists": [ + "Kult" + ], + "album": "Spokojnie", + "duration_ms": 261586, + "isrc": "PLE781200097" + }, + "qobuz_id": 392479154, + "score": 8.166666666666666, + "query": "Niejeden", + "reason": "best score 8.2 below threshold", + "matched": false + }, + { + "source": { + "source_id": "44BuVyAMmkIHHtq8mxlwhA", + "title": "Группа крови", + "artists": [ + "Kino" + ], + "album": "Группа крови", + "duration_ms": 284002, + "isrc": "RUB421401310" + }, + "qobuz_id": 135489982, + "score": 7, + "query": "Группа крови", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3mFRiJ6c2XXkd0Hd9qWP46", + "title": "Arahja", + "artists": [ + "Kult" + ], + "album": "Spokojnie", + "duration_ms": 220013, + "isrc": "PLE781200093" + }, + "qobuz_id": 179244208, + "score": 19, + "query": "Arahja", + "reason": "best score 19.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "279dZt9gv2kkXizN6EU8Ha", + "title": "Totalna stabilizacja", + "artists": [ + "KULT" + ], + "album": "Posłuchaj to do Ciebie (Expanded)", + "duration_ms": 122013, + "isrc": "PLE781200083" + }, + "qobuz_id": 96467627, + "score": 20.5, + "query": "Totalna stabilizacja", + "reason": "best score 20.5 below threshold", + "matched": false + }, + { + "source": { + "source_id": "2I9y1p6HdG7UDWr5jaNk1B", + "title": "Jeźdźcy", + "artists": [ + "Kult" + ], + "album": "Spokojnie", + "duration_ms": 349386, + "isrc": "PLE781200094" + }, + "qobuz_id": 161574780, + "score": 12.683982683982684, + "query": "Jeźdźcy Kult", + "reason": "best score 12.7 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5qhR5ZhTDqFjOvh1oVC6oO", + "title": "Саша", + "artists": [ + "Kino" + ], + "album": "Легенды русского рока: КИНО", + "duration_ms": 245800, + "isrc": "FR10S1775001" + }, + "qobuz_id": 186759833, + "score": 7, + "query": "Саша", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "26sgqMLFOhRrryoaPovlI2", + "title": "Спокойная ночь", + "artists": [ + "Kino" + ], + "album": "Группа крови", + "duration_ms": 364009, + "isrc": "RUB421401318" + }, + "qobuz_id": 367344889, + "score": -6, + "query": "Спокойная ночь Kino", + "reason": "best score -6.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4y3SP8H7xqjsJtVYsDPtNh", + "title": "Камчатка", + "artists": [ + "Kino" + ], + "album": "46", + "duration_ms": 131400, + "isrc": "RUB421800474" + }, + "qobuz_id": 330248261, + "score": 7, + "query": "Камчатка", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6EYUZdDVScNWjJfIwAtSEg", + "title": "Закрой за мной дверь, я ухожу", + "artists": [ + "Kino" + ], + "album": "Группа крови", + "duration_ms": 256096, + "isrc": "RUB421401314" + }, + "qobuz_id": 367344886, + "score": -6, + "query": "Закрой за мной дверь, я ухожу Kino", + "reason": "best score -6.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1ngjjSd73Fgkcf3uOCGnxZ", + "title": "Polska", + "artists": [ + "KULT" + ], + "album": "Posłuchaj to do Ciebie (Expanded)", + "duration_ms": 324013, + "isrc": "PLE781200076" + }, + "qobuz_id": 29994040, + "score": 19, + "query": "Polska KULT", + "reason": "best score 19.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5OBfFzjGI6Evs3iVGJivG4", + "title": "Транквилизатор", + "artists": [ + "Kino" + ], + "album": "46", + "duration_ms": 329040, + "isrc": "RUB421800475" + }, + "qobuz_id": 314630044, + "score": -6, + "query": "Транквилизатор Kino", + "reason": "best score -6.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "14TXj517NaPhpcPeUWkYNM", + "title": "Мне не нравится город Москва", + "artists": [ + "Kino" + ], + "album": "Легенда", + "duration_ms": 227160, + "isrc": "RUB421800464" + }, + "qobuz_id": 378781869, + "score": 10, + "query": "Мне не нравится город Москва Kino", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "7zQbvIgfpeg1Npk9eoQeyD", + "title": "Бездельник №1", + "artists": [ + "Kino" + ], + "album": "45", + "duration_ms": 194040, + "isrc": "RUB421800454" + }, + "qobuz_id": 3618864, + "score": 5.666666666666666, + "query": "Бездельник №1 Kino", + "reason": "best score 5.7 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6kB0sf6BSk1mQLtHzkRGAb", + "title": "Просто хочешь ты знать", + "artists": [ + "Kino" + ], + "album": "45", + "duration_ms": 209160, + "isrc": "RUB421800455" + }, + "qobuz_id": 224190122, + "score": 10, + "query": "Просто хочешь ты знать Kino", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "24FHbVweImIqKzgwkugUtK", + "title": "Пачка сигарет", + "artists": [ + "Kino" + ], + "album": "Легенды русского рока: КИНО", + "duration_ms": 265173, + "isrc": "FR10S1774986" + }, + "qobuz_id": 58120736, + "score": 10, + "query": "Пачка сигарет", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3A5NUIgXCUPNeA5enhqUEe", + "title": "Где-то там", + "artists": [ + "Utro" + ], + "album": "Third Album", + "duration_ms": 289006, + "isrc": "FR59Y1700027" + }, + "qobuz_id": 152619881, + "score": 0, + "query": "Где-то там Utro", + "reason": "best score 0.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3N3ytK9Yk9cL7RCCglBjwL", + "title": "Крайности", + "artists": [ + "Второй этаж поражает" + ], + "album": "Крайности", + "duration_ms": 212467, + "isrc": "RUA1D1914407" + }, + "qobuz_id": 37877528, + "score": 7, + "query": "Крайности Второй этаж поражает", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4PiSrHGaBXpudUHZ60cNGX", + "title": "Я обыватель", + "artists": [ + "Группа Хмурый" + ], + "album": "Картина 1, No. 16/12", + "duration_ms": 190000, + "isrc": "RUA1H1700955" + }, + "qobuz_id": 336924606, + "score": 10, + "query": "Я обыватель Группа Хмурый", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6ZC8Sm6niDwi1YxLxMyW9c", + "title": "Ночные картины", + "artists": [ + "Группа Хмурый" + ], + "album": "Картина 2, No. 17/5", + "duration_ms": 233496, + "isrc": "RUA1H1757831" + }, + "qobuz_id": 96669411, + "score": 10, + "query": "Ночные картины Группа Хмурый", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4JaDViW3wyXJgammbmJLEi", + "title": "Баланс", + "artists": [ + "Кофе" + ], + "album": "Баланс", + "duration_ms": 330690, + "isrc": "TCADY1882982" + }, + "qobuz_id": 387053663, + "score": 7, + "query": "Баланс Кофе", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1rMIP9l5jm496fnTG1GZkD", + "title": "Шельда", + "artists": [ + "ОБРАЗ" + ], + "album": "10", + "duration_ms": 182500, + "isrc": "RUA1D1827018" + }, + "qobuz_id": 311421461, + "score": 10, + "query": "Шельда ОБРАЗ", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "2YK0xea4oNrLmtijUhmBju", + "title": "Война", + "artists": [ + "ОБРАЗ" + ], + "album": "Безымянность", + "duration_ms": 287000, + "isrc": "RUA1D1827920" + }, + "qobuz_id": 141537470, + "score": 7, + "query": "Война", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4dl2G6wuOpFIRTGtrm3iFS", + "title": "Лик", + "artists": [ + "ОБРАЗ" + ], + "album": "10", + "duration_ms": 194000, + "isrc": "RUA1D1827017" + }, + "qobuz_id": 354517405, + "score": 10, + "query": "Лик", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0dAcfhBoCQXjhPb0ba4fYk", + "title": "Ночной променад", + "artists": [ + "СОЮЗ" + ], + "album": "СОЮЗ", + "duration_ms": 248354, + "isrc": "UAKK11700102" + }, + "qobuz_id": 174517675, + "score": 0, + "query": "Ночной променад СОЮЗ", + "reason": "best score 0.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "7ERS2VL9QeKzSrBBfESf0P", + "title": "Troleibusai", + "artists": [ + "Šiaurės kryptis" + ], + "album": "Ledynai", + "duration_ms": 266765, + "isrc": "QZ4JJ1972859" + }, + "qobuz_id": 350310178, + "score": 3.37142857142857, + "query": "Troleibusai Šiaurės kryptis", + "reason": "best score 3.4 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3uTMwljOgDz2s6oXrM1BQO", + "title": "Хочу перемен", + "artists": [ + "Kino" + ], + "album": "Последний герой", + "duration_ms": 292606, + "isrc": "RUB421401394" + }, + "qobuz_id": 24438, + "score": 4, + "query": "Хочу перемен Kino", + "reason": "best score 4.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5kH4o6XuMjvLnPU1ObTe6r", + "title": "Wódka", + "artists": [ + "KULT" + ], + "album": "Posłuchaj to do Ciebie (Expanded)", + "duration_ms": 314506, + "isrc": "PLE781200079" + }, + "qobuz_id": 98483682, + "score": 8.166666666666666, + "query": "Wódka", + "reason": "best score 8.2 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3PkyOSJUaUZ0npKampSyH9", + "title": "Kult", + "artists": [ + "KULT" + ], + "album": "Posłuchaj to do Ciebie (Expanded)", + "duration_ms": 218520, + "isrc": "PLE781200082" + }, + "qobuz_id": 336311080, + "score": 4.833333333333334, + "query": "Kult", + "reason": "best score 4.8 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4itBiAaEfr0Mg3dwYBa5EJ", + "title": "Umarł mój wróg", + "artists": [ + "KULT" + ], + "album": "Posłuchaj to do Ciebie (Expanded)", + "duration_ms": 158506, + "isrc": "PLE781200085" + }, + "qobuz_id": 378525392, + "score": 23.33333333333333, + "query": "Umarł mój wróg KULT", + "reason": "best score 23.3 below threshold", + "matched": false + }, + { + "source": { + "source_id": "11iqTRZTgQxPUeIMwBqoDU", + "title": "Rozmyślania wychowanka", + "artists": [ + "KULT" + ], + "album": "Posłuchaj to do Ciebie (Expanded)", + "duration_ms": 146000, + "isrc": "PLE781200088" + }, + "qobuz_id": 354435173, + "score": 15, + "query": "Rozmyślania wychowanka", + "reason": "best score 15.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "6c80rzvhrsLZCJrAWElx6W", + "title": "Легенда", + "artists": [ + "Kino" + ], + "album": "Группа крови", + "duration_ms": 248054, + "isrc": "RUB421401319" + }, + "qobuz_id": 126238883, + "score": 4, + "query": "Легенда", + "reason": "best score 4.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "06jE36hfsvQpXxOMwqM5Y0", + "title": "Память", + "artists": [ + "Мать Тереза" + ], + "album": "Иллюзия", + "duration_ms": 251013, + "isrc": "FR59R1991943" + }, + "qobuz_id": 94071989, + "score": 7, + "query": "Память", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5PUAe8RKd1zVOo6JX4wrD5", + "title": "77", + "artists": [ + "Leto V Gorode" + ], + "album": "1", + "duration_ms": 248894, + "isrc": "QZES81838059" + }, + "qobuz_id": 72884675, + "score": 14, + "query": "77 Leto V Gorode", + "reason": "best score 14.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1tmF8MUUOa9w4Wg9XFm2f2", + "title": "Дождь для нас", + "artists": [ + "Kino" + ], + "album": "46", + "duration_ms": 207360, + "isrc": "RUB421800476" + }, + "qobuz_id": 92138394, + "score": 10, + "query": "Дождь для нас Kino", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1eqI2Wi3J5jU99SESynPlY", + "title": "Ночь", + "artists": [ + "Kino" + ], + "album": "Ночь", + "duration_ms": 327536, + "isrc": "RUB421401361" + }, + "qobuz_id": 367344889, + "score": 10, + "query": "Ночь Kino", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "2ocnj7yoAevxjgroRRbRHw", + "title": "Если я лгу", + "artists": [ + "Дурное Влияние" + ], + "album": "Неподвижность", + "duration_ms": 269240, + "isrc": "RUA1H1738235" + }, + "qobuz_id": 236742202, + "score": 0, + "query": "Если я лгу Дурное Влияние", + "reason": "best score 0.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5aV6NxUaWtQdDV4K6VSLww", + "title": "Hej, czy nie wiecie", + "artists": [ + "Kult" + ], + "album": "Your Eyes", + "duration_ms": 321013, + "isrc": "PLE781200144" + }, + "qobuz_id": 364609670, + "score": 19, + "query": "Hej, czy nie wiecie Kult", + "reason": "best score 19.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3IEnTBT73UBrJr1U0KoSvG", + "title": "Krew Boga", + "artists": [ + "Kult" + ], + "album": "Kult", + "duration_ms": 163506, + "isrc": "PLE781200063" + }, + "qobuz_id": 315915895, + "score": 11.944444444444443, + "query": "Krew Boga", + "reason": "best score 11.9 below threshold", + "matched": false + }, + { + "source": { + "source_id": "7LkBr4rg4vhZjlDBVGpkkg", + "title": "Друг", + "artists": [ + "Труд" + ], + "album": "1", + "duration_ms": 208561, + "isrc": "TCAEA1814658" + }, + "qobuz_id": 58952498, + "score": 7, + "query": "Друг", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0qlCTm0U6Jcgx29HEYIXrg", + "title": "Группа крови", + "artists": [ + "Kino" + ], + "album": "Последний герой", + "duration_ms": 235107, + "isrc": "RUB421401399" + }, + "qobuz_id": 361265393, + "score": 7, + "query": "Группа крови Kino", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "4g87T7h8coshsA1zJjFFbG", + "title": "Звезда по имени Солнце", + "artists": [ + "Kino" + ], + "album": "Звезда по имени Солнце", + "duration_ms": 225666, + "isrc": "RUB421401323" + }, + "qobuz_id": 285298456, + "score": 10, + "query": "Звезда по имени Солнце Kino", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "7nzMU6ive8Zpv6Puqid46W", + "title": "Пачка сигарет", + "artists": [ + "Kino" + ], + "album": "Звезда по имени Солнце", + "duration_ms": 266017, + "isrc": "RUB421401328" + }, + "qobuz_id": 58120736, + "score": 10, + "query": "Пачка сигарет", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5KBuyLSC7pO8Y8kp250UFg", + "title": "Что-то не то творится", + "artists": [ + "Utro" + ], + "album": "Third Album", + "duration_ms": 226484, + "isrc": "FR59Y1700029" + }, + "qobuz_id": 395322201, + "score": 7, + "query": "Что-то не то творится Utro", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "0c58luGUfo3kkYLwkAQfxO", + "title": "Лето", + "artists": [ + "Kino" + ], + "album": "Чёрный альбом", + "duration_ms": 354671, + "isrc": "RUB421401407" + }, + "qobuz_id": 382324691, + "score": -6, + "query": "Лето Kino", + "reason": "best score -6.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "1SCwVUbxOrqU7NADHm7x7j", + "title": "Кукушка", + "artists": [ + "Kino" + ], + "album": "Чёрный альбом", + "duration_ms": 398658, + "isrc": "RUB421401409" + }, + "qobuz_id": 34593294, + "score": 10, + "query": "Кукушка Kino", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "3fmGzUfQskSBj7g2DEtoxr", + "title": "Белые стены", + "artists": [ + "Группа Хмурый" + ], + "album": "Картина 2, No. 17/5", + "duration_ms": 193620, + "isrc": "RUA1H1757829" + }, + "qobuz_id": 351328975, + "score": 7, + "query": "Белые стены Группа Хмурый", + "reason": "best score 7.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "5WaSBTP8rqk6YckGobgnkz", + "title": "Спокойная ночь", + "artists": [ + "Kino" + ], + "album": "Последний герой", + "duration_ms": 382803, + "isrc": "RUB421401403" + }, + "qobuz_id": 367344889, + "score": -6, + "query": "Спокойная ночь Kino", + "reason": "best score -6.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "16s07AzsIWbQywqbaoOZGU", + "title": "Весна", + "artists": [ + "Dolphin" + ], + "album": "Звезда", + "duration_ms": 291320, + "isrc": "US6R21523021" + }, + "qobuz_id": 800817, + "score": 4, + "query": "Весна Dolphin", + "reason": "best score 4.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "11qffnsT8x96r4NUnsfNjx", + "title": "Нимфа", + "artists": [ + "Аметистовые Вены" + ], + "album": "Так красиво догорает закат", + "duration_ms": 253714, + "isrc": "AEA0D1840623" + }, + "qobuz_id": 202438220, + "score": 10, + "query": "Нимфа Аметистовые Вены", + "reason": "best score 10.0 below threshold", + "matched": false + }, + { + "source": { + "source_id": "7eywk7AnX4wxB6xTECyUwY", + "title": "Электроприборы", + "artists": [ + "Товарищ Астроном" + ], + "album": "Верба", + "duration_ms": 335333, + "isrc": "QZ8LD1996743" + }, + "qobuz_id": 175948575, + "score": -6, + "query": "Электроприборы Товарищ Астроном", + "reason": "best score -6.0 below threshold", + "matched": false + } + ], + "errors": [] + } + ] +}