diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d4a8157 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +SPOTIFY_CLIENT_ID= +SPOTIFY_REDIRECT_URI=http://127.0.0.1:8888/callback +SPOTIFY_SCOPES=playlist-read-private,playlist-read-collaborative,user-library-read +NAVIMIGRATE_SPOTIFY_MANUAL_CODE=true +NAVIMIGRATE_REMEMBER_SPOTIFY=true +NAVIMIGRATE_SESSION_FILE=~/.config/navimigrate/session.json +NAVIMIGRATE_INCLUDE_LIKED=false +NAVIMIGRATE_LIKED_NAME=Spotify Liked Songs + +NAVIDROME_URL=https://music.lofitrek.com +NAVIDROME_USERNAME=test_user +NAVIDROME_PASSWORD=test_user + +NAVIMIGRATE_DRY_RUN=false +NAVIMIGRATE_CONCURRENCY=4 +NAVIMIGRATE_MATCH_THRESHOLD=45 +NAVIMIGRATE_REPORT=transfer-report.json + +NAVIMIGRATE_QOBUZ_DOWNLOAD_MISSING=false +NAVIMIGRATE_QOBUZ_MANIFEST=missing-downloads.json +NAVIMIGRATE_QOBUZ_DL_PATH=/home/joren/dev/qobuz-dl +NAVIMIGRATE_QOBUZ_OUTPUT= +NAVIMIGRATE_QOBUZ_QUALITY=6 +QOBUZ_USERNAME= +QOBUZ_PASSWORD= +QOBUZ_APP_ID=312369995 +QOBUZ_APP_SECRET=e79f8b9be485692b0e5f9dd895826368 + +NAVIMIGRATE_ADD_DOWNLOADED_MANIFEST= +NAVIMIGRATE_ADD_DOWNLOADED_FORCE=false + +NAVIMIGRATE_NAVIDROME_SELF_TEST=false +NAVIMIGRATE_NAVIDROME_SELF_TEST_WRITE=false +NAVIMIGRATE_NAVIDROME_SELF_TEST_QUERY=Daft Punk One More Time diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d884e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +navimigrate +transfer-report.json +missing-downloads.json diff --git a/README.md b/README.md index ea01675..a8434c1 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,18 @@ Spotify -> Navidrome playlist transfer tool in Go. - Authenticates with Spotify using Authorization Code + PKCE (no client secret required). - Fetches one or more Spotify playlists by URL/URI/ID. +- Optionally transfers Spotify liked songs as a dedicated playlist. - Searches for matching tracks in Navidrome using Subsonic `search3`. - Creates matching playlists in Navidrome and adds matched tracks. - Writes a JSON transfer report with unmatched tracks and errors. +- Optional recovery mode: for unmatched tracks, search Qobuz and download whole albums using `qobuz-dl`. +- Optional re-add mode: after Navidrome rescan, re-match downloaded tracks and add them to existing playlists. ## Requirements - Go 1.22+ - Spotify app client ID with redirect URI configured (default: `http://127.0.0.1:8888/callback`) +- If using `--liked`, Spotify scopes must include `user-library-read` - Navidrome URL, username, and password ## Build @@ -36,10 +40,32 @@ go build ./cmd/navimigrate ### Useful flags - `--playlist-url "..."` (repeatable): Spotify playlist URL/URI/ID +- `--liked`: include Spotify liked songs in transfer +- `--liked-playlist-name "Spotify Liked Songs"`: target playlist name for liked songs - `--dry-run`: resolve matches only, do not create playlists or add tracks +- `--match-threshold 45`: minimum score needed to accept a match +- `--remember-spotify=true|false`: save and reuse Spotify refresh token (default true) +- `--session-file ~/.config/navimigrate/session.json`: session storage path - `--report transfer-report.json`: report output path - `--concurrency 4`: concurrent track match workers - `--spotify-manual-code=true|false`: manual callback URL/code entry or local callback server +- `--qobuz-download-missing`: search/download albums for unmatched tracks +- `--qobuz-manifest missing-downloads.json`: manifest path used by recovery flows +- `--qobuz-dl-path qobuz-dl`: binary path, or qobuz-dl project directory +- `--qobuz-output /path/to/music`: output directory for qobuz-dl +- `--add-downloaded-manifest missing-downloads.json`: re-add tracks from manifest after Navidrome rescan +- `--add-downloaded-force`: retry entries already marked added + +Liked-only run (no explicit playlist URLs): + +```bash +./navimigrate \ + --spotify-client-id "" \ + --navidrome-url "https://music.lofitrek.com" \ + --navidrome-username "test_user" \ + --navidrome-password "test_user" \ + --liked +``` ### Navidrome self-test @@ -64,18 +90,62 @@ Write-path self-test (creates playlist, adds one track, then deletes playlist): --navidrome-self-test-write ``` +### Missing-track recovery flow + +Step 1: transfer and download unmatched albums from Qobuz: + +```bash +./navimigrate \ + --spotify-client-id "" \ + --navidrome-url "https://music.lofitrek.com" \ + --navidrome-username "test_user" \ + --navidrome-password "test_user" \ + --playlist-url "https://open.spotify.com/playlist/..." \ + --qobuz-download-missing \ + --qobuz-username "" \ + --qobuz-password "" \ + --qobuz-dl-path "/home/joren/dev/qobuz-dl" \ + --qobuz-output "/path/to/downloads" +``` + +Step 2: after Navidrome rescans your library, re-add from manifest: + +```bash +./navimigrate \ + --navidrome-url "https://music.lofitrek.com" \ + --navidrome-username "test_user" \ + --navidrome-password "test_user" \ + --add-downloaded-manifest "missing-downloads.json" +``` + ### Environment variables - `SPOTIFY_CLIENT_ID` - `SPOTIFY_REDIRECT_URI` (optional) - `SPOTIFY_SCOPES` (optional) - `NAVIMIGRATE_SPOTIFY_MANUAL_CODE` (optional, defaults to true) +- `NAVIMIGRATE_REMEMBER_SPOTIFY` (optional, defaults to true) +- `NAVIMIGRATE_SESSION_FILE` (optional) +- `NAVIMIGRATE_INCLUDE_LIKED` (optional) +- `NAVIMIGRATE_LIKED_NAME` (optional) - `NAVIDROME_URL` - `NAVIDROME_USERNAME` - `NAVIDROME_PASSWORD` - `NAVIMIGRATE_DRY_RUN` (optional) - `NAVIMIGRATE_CONCURRENCY` (optional) +- `NAVIMIGRATE_MATCH_THRESHOLD` (optional) - `NAVIMIGRATE_REPORT` (optional) +- `NAVIMIGRATE_QOBUZ_DOWNLOAD_MISSING` (optional) +- `NAVIMIGRATE_QOBUZ_MANIFEST` (optional) +- `NAVIMIGRATE_QOBUZ_DL_PATH` (optional) +- `NAVIMIGRATE_QOBUZ_OUTPUT` (optional) +- `NAVIMIGRATE_QOBUZ_QUALITY` (optional) +- `QOBUZ_USERNAME` (optional, required when qobuz download mode enabled) +- `QOBUZ_PASSWORD` (optional, required when qobuz download mode enabled) +- `QOBUZ_APP_ID` (optional) +- `QOBUZ_APP_SECRET` (optional) +- `NAVIMIGRATE_ADD_DOWNLOADED_MANIFEST` (optional) +- `NAVIMIGRATE_ADD_DOWNLOADED_FORCE` (optional) - `NAVIMIGRATE_NAVIDROME_SELF_TEST` (optional) - `NAVIMIGRATE_NAVIDROME_SELF_TEST_WRITE` (optional) - `NAVIMIGRATE_NAVIDROME_SELF_TEST_QUERY` (optional) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..add0cd5 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module navimigrate + +go 1.22 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3eedcec --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,304 @@ +package config + +import ( + "errors" + "flag" + "fmt" + "os" + "strconv" + "strings" +) + +type Config struct { + SpotifyClientID string + SpotifyRedirect string + SpotifyScopes []string + SpotifyManual bool + IncludeLiked bool + LikedPlaylist string + RememberSpotify bool + SessionFile string + AddDownloaded string + AddDownloadedForce bool + + NavidromeURL string + NavidromeUsername string + NavidromePassword string + + NavidromeSelfTest bool + NavidromeSelfTestWrite bool + NavidromeSelfTestQuery string + + DryRun bool + ReportPath string + Concurrency int + MatchThreshold float64 + PlaylistURLs []string + + QobuzDownloadMissing bool + QobuzManifestPath string + QobuzDLPath string + QobuzOutputPath string + QobuzUsername string + QobuzPassword string + QobuzAppID string + QobuzAppSecret string + QobuzQuality int +} + +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 URL cannot be empty") + } + *m = append(*m, v) + return nil +} + +func Load() (Config, error) { + var cfg Config + var playlistURLs multiFlag + + defaultScopes := "playlist-read-private,playlist-read-collaborative,user-library-read" + defaultConcurrency := envIntOr("NAVIMIGRATE_CONCURRENCY", 4) + const defaultQobuzAppID = "312369995" + const defaultQobuzAppSecret = "e79f8b9be485692b0e5f9dd895826368" + + 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("NAVIMIGRATE_SPOTIFY_MANUAL_CODE", true), "Enter Spotify callback code/URL manually instead of running local callback server") + flag.BoolVar(&cfg.IncludeLiked, "liked", envBoolOr("NAVIMIGRATE_INCLUDE_LIKED", false), "Include Spotify liked songs") + flag.StringVar(&cfg.LikedPlaylist, "liked-playlist-name", envOr("NAVIMIGRATE_LIKED_NAME", "Spotify Liked Songs"), "Name of the generated liked-songs playlist on Navidrome") + flag.BoolVar(&cfg.RememberSpotify, "remember-spotify", envBoolOr("NAVIMIGRATE_REMEMBER_SPOTIFY", true), "Persist Spotify refresh token and reuse session") + flag.StringVar(&cfg.SessionFile, "session-file", envOr("NAVIMIGRATE_SESSION_FILE", "~/.config/navimigrate/session.json"), "Path to saved session file") + flag.StringVar(&cfg.AddDownloaded, "add-downloaded-manifest", envOr("NAVIMIGRATE_ADD_DOWNLOADED_MANIFEST", ""), "Re-match and add tracks from previously generated missing-download manifest") + flag.BoolVar(&cfg.AddDownloadedForce, "add-downloaded-force", envBoolOr("NAVIMIGRATE_ADD_DOWNLOADED_FORCE", false), "Force re-adding entries already marked as added in manifest") + + flag.StringVar(&cfg.NavidromeURL, "navidrome-url", envOr("NAVIDROME_URL", ""), "Navidrome base URL (example: https://music.example.com)") + flag.StringVar(&cfg.NavidromeUsername, "navidrome-username", envOr("NAVIDROME_USERNAME", ""), "Navidrome username") + flag.StringVar(&cfg.NavidromePassword, "navidrome-password", envOr("NAVIDROME_PASSWORD", ""), "Navidrome password") + + flag.BoolVar(&cfg.NavidromeSelfTest, "navidrome-self-test", envBoolOr("NAVIMIGRATE_NAVIDROME_SELF_TEST", false), "Run Navidrome ping/search checks and exit (skips Spotify)") + flag.BoolVar(&cfg.NavidromeSelfTestWrite, "navidrome-self-test-write", envBoolOr("NAVIMIGRATE_NAVIDROME_SELF_TEST_WRITE", false), "When --navidrome-self-test is set, also create a test playlist and add one track") + flag.StringVar(&cfg.NavidromeSelfTestQuery, "navidrome-self-test-query", envOr("NAVIMIGRATE_NAVIDROME_SELF_TEST_QUERY", "Daft Punk One More Time"), "Search query used for --navidrome-self-test") + + flag.BoolVar(&cfg.DryRun, "dry-run", envBoolOr("NAVIMIGRATE_DRY_RUN", false), "Resolve matches only, do not create or mutate Navidrome playlists") + flag.StringVar(&cfg.ReportPath, "report", envOr("NAVIMIGRATE_REPORT", "transfer-report.json"), "Report output path") + flag.IntVar(&cfg.Concurrency, "concurrency", defaultConcurrency, "Concurrent track matching workers") + flag.Float64Var(&cfg.MatchThreshold, "match-threshold", envFloatOr("NAVIMIGRATE_MATCH_THRESHOLD", 45), "Minimum score required for a track match") + flag.Var(&playlistURLs, "playlist-url", "Spotify playlist URL/URI/ID to transfer (repeatable)") + + flag.BoolVar(&cfg.QobuzDownloadMissing, "qobuz-download-missing", envBoolOr("NAVIMIGRATE_QOBUZ_DOWNLOAD_MISSING", false), "For unmatched tracks, search Qobuz and download their albums via qobuz-dl") + flag.StringVar(&cfg.QobuzManifestPath, "qobuz-manifest", envOr("NAVIMIGRATE_QOBUZ_MANIFEST", "missing-downloads.json"), "Path to missing-download manifest JSON") + flag.StringVar(&cfg.QobuzDLPath, "qobuz-dl-path", envOr("NAVIMIGRATE_QOBUZ_DL_PATH", "qobuz-dl"), "Path to qobuz-dl binary, or qobuz-dl project directory (run via go run .)") + flag.StringVar(&cfg.QobuzOutputPath, "qobuz-output", envOr("NAVIMIGRATE_QOBUZ_OUTPUT", ""), "Output directory used by qobuz-dl downloads") + 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", envOr("QOBUZ_APP_ID", defaultQobuzAppID), "Qobuz app ID") + flag.StringVar(&cfg.QobuzAppSecret, "qobuz-app-secret", envOr("QOBUZ_APP_SECRET", defaultQobuzAppSecret), "Qobuz app secret") + flag.IntVar(&cfg.QobuzQuality, "qobuz-quality", envIntOr("NAVIMIGRATE_QOBUZ_QUALITY", 6), "Quality passed to qobuz-dl (5=MP3,6=CD,7=HiRes96,27=HiRes192)") + + if err := flag.CommandLine.Parse(preprocessArgs(os.Args[1:])); err != nil { + return Config{}, err + } + + 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 strings.TrimSpace(c.NavidromeURL) == "" { + return fmt.Errorf("navidrome URL is required") + } + if strings.TrimSpace(c.NavidromeUsername) == "" || strings.TrimSpace(c.NavidromePassword) == "" { + return fmt.Errorf("navidrome username/password are required") + } + + if c.NavidromeSelfTest { + if strings.TrimSpace(c.NavidromeSelfTestQuery) == "" { + return fmt.Errorf("navidrome self-test query cannot be empty") + } + if c.Concurrency < 1 { + return fmt.Errorf("concurrency must be >= 1") + } + if c.MatchThreshold < 0 { + return fmt.Errorf("match-threshold must be >= 0") + } + return nil + } + + if strings.TrimSpace(c.AddDownloaded) != "" { + if c.Concurrency < 1 { + return fmt.Errorf("concurrency must be >= 1") + } + if c.MatchThreshold < 0 { + return fmt.Errorf("match-threshold must be >= 0") + } + return nil + } + + if strings.TrimSpace(c.SpotifyClientID) == "" { + return fmt.Errorf("spotify client id is required") + } + 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 len(c.PlaylistURLs) == 0 && !c.IncludeLiked { + return fmt.Errorf("at least one --playlist-url or --liked is required") + } + if c.IncludeLiked && strings.TrimSpace(c.LikedPlaylist) == "" { + return fmt.Errorf("liked-playlist-name cannot be empty when --liked is set") + } + if c.IncludeLiked && !containsScope(c.SpotifyScopes, "user-library-read") { + return fmt.Errorf("spotify scope user-library-read is required when --liked is set") + } + if c.RememberSpotify && strings.TrimSpace(c.SessionFile) == "" { + return fmt.Errorf("session-file cannot be empty when remember-spotify is enabled") + } + if c.Concurrency < 1 { + return fmt.Errorf("concurrency must be >= 1") + } + if c.MatchThreshold < 0 { + return fmt.Errorf("match-threshold must be >= 0") + } + if c.QobuzDownloadMissing { + if strings.TrimSpace(c.QobuzManifestPath) == "" { + return fmt.Errorf("qobuz-manifest cannot be empty when qobuz-download-missing is enabled") + } + if strings.TrimSpace(c.QobuzOutputPath) == "" { + return fmt.Errorf("qobuz-output is required when qobuz-download-missing is enabled") + } + if strings.TrimSpace(c.QobuzUsername) == "" || strings.TrimSpace(c.QobuzPassword) == "" { + return fmt.Errorf("qobuz username/password are required when qobuz-download-missing is enabled") + } + if strings.TrimSpace(c.QobuzAppID) == "" || strings.TrimSpace(c.QobuzAppSecret) == "" { + return fmt.Errorf("qobuz app id/secret are required when qobuz-download-missing is enabled") + } + } + + 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 containsScope(scopes []string, wanted string) bool { + wanted = strings.ToLower(strings.TrimSpace(wanted)) + for _, s := range scopes { + if strings.ToLower(strings.TrimSpace(s)) == wanted { + return true + } + } + return false +} + +type boolFlag interface { + IsBoolFlag() bool +} + +func preprocessArgs(args []string) []string { + boolFlagNames := make(map[string]bool) + flag.CommandLine.VisitAll(func(f *flag.Flag) { + if bf, ok := f.Value.(boolFlag); ok && bf.IsBoolFlag() { + boolFlagNames[f.Name] = true + } + }) + + var flagArgs []string + var positional []string + + i := 0 + for i < len(args) { + arg := args[i] + if !strings.HasPrefix(arg, "-") { + positional = append(positional, arg) + i++ + continue + } + + flagArgs = append(flagArgs, arg) + i++ + + name := strings.TrimLeft(arg, "-") + if idx := strings.Index(name, "="); idx >= 0 { + continue + } + if boolFlagNames[name] { + continue + } + if i < len(args) && !strings.HasPrefix(args[i], "-") { + flagArgs = append(flagArgs, args[i]) + i++ + } + } + + return append(flagArgs, positional...) +} + +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 envFloatOr(key string, fallback float64) float64 { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback + } + n, err := strconv.ParseFloat(v, 64) + if err != nil { + return fallback + } + return n +} diff --git a/internal/match/matcher.go b/internal/match/matcher.go new file mode 100644 index 0000000..3c07958 --- /dev/null +++ b/internal/match/matcher.go @@ -0,0 +1,358 @@ +package match + +import ( + "context" + "fmt" + "math" + "regexp" + "sort" + "strings" + "sync" + + "navimigrate/internal/model" + "navimigrate/internal/navidrome" +) + +type Searcher interface { + SearchTracks(ctx context.Context, query string, limit int) ([]navidrome.Track, error) +} + +type Matcher struct { + searcher Searcher + threshold float64 + cacheMu sync.RWMutex + cache map[string][]navidrome.Track +} + +func NewMatcher(searcher Searcher, threshold float64) *Matcher { + if threshold < 0 { + threshold = 45 + } + return &Matcher{ + searcher: searcher, + threshold: threshold, + cache: map[string][]navidrome.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 navidrome.Track + score float64 + query string + } + + best := scored{score: -999} + seen := map[string]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 == "" { + return model.MatchedTrack{Source: src, Matched: false, Reason: "no candidates"} + } + + if best.score >= m.threshold { + return model.MatchedTrack{ + Source: src, + TargetID: best.track.ID, + Score: best.score, + Query: best.query, + Matched: true, + } + } + + reason := fmt.Sprintf("best score %.1f below threshold %.1f", best.score, m.threshold) + return model.MatchedTrack{ + Source: src, + TargetID: best.track.ID, + Score: best.score, + Query: best.query, + Matched: false, + Reason: reason, + } +} + +func (m *Matcher) searchCached(ctx context.Context, q string) ([]navidrome.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 navidrome.Track) float64 { + score := 0.0 + + if src.ISRC != "" && hasISRC(dst.ISRCs, src.ISRC) { + score += 60 + } + + score += 25 * similarity(normalize(src.Title), normalize(dst.Title)) + + 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(dst.Title) + 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 hasISRC(candidates []string, wanted string) bool { + wanted = strings.ToUpper(strings.TrimSpace(wanted)) + if wanted == "" { + return false + } + for _, c := range candidates { + if strings.EqualFold(strings.TrimSpace(c), wanted) { + return true + } + } + return false +} + +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..2507811 --- /dev/null +++ b/internal/match/matcher_test.go @@ -0,0 +1,64 @@ +package match + +import ( + "context" + "strings" + "testing" + + "navimigrate/internal/model" + "navimigrate/internal/navidrome" +) + +type fakeSearcher struct { + tracks []navidrome.Track +} + +func (f fakeSearcher) SearchTracks(context.Context, string, int) ([]navidrome.Track, error) { + return f.tracks, nil +} + +func TestNormalizeTransliteratesCyrillic(t *testing.T) { + got := normalize("детство") + if got != "detstvo" { + t.Fatalf("expected detstvo, got %q", got) + } +} + +func TestBuildQueriesIncludesLatinVariant(t *testing.T) { + m := NewMatcher(fakeSearcher{}, 45) + 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) + } +} + +func TestMatchThresholdIsConfigurable(t *testing.T) { + src := model.Track{ + Title: "One More Time", + Artists: []string{"Daft Punk"}, + DurationMS: 317000, + } + candidate := navidrome.Track{ + ID: "track-1", + Title: "One More Time", + Artist: "Daft Punk", + Duration: 317, + } + + m := NewMatcher(fakeSearcher{tracks: []navidrome.Track{candidate}}, 100) + res := m.MatchTrack(context.Background(), src) + if res.Matched { + t.Fatalf("expected no match with high threshold, score=%.1f", res.Score) + } + + m = NewMatcher(fakeSearcher{tracks: []navidrome.Track{candidate}}, 0) + res = m.MatchTrack(context.Background(), src) + if !res.Matched { + t.Fatalf("expected match with low threshold, score=%.1f", res.Score) + } +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..c9a6f52 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,44 @@ +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 MatchedTrack struct { + Source Track `json:"source"` + TargetID string `json:"target_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 string `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/navidrome/client.go b/internal/navidrome/client.go new file mode 100644 index 0000000..ec49487 --- /dev/null +++ b/internal/navidrome/client.go @@ -0,0 +1,381 @@ +package navidrome + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "strings" + "time" +) + +const apiVersion = "1.16.1" +const defaultClientName = "navimigrate" + +type Client struct { + httpClient *http.Client + baseURL string + username string + password string + clientName string +} + +type Track struct { + ID string + Title string + Artist string + Album string + Duration int + ISRCs []string +} + +type subsonicError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type isrcField []string + +func (f *isrcField) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *f = nil + return nil + } + + var one string + if err := json.Unmarshal(data, &one); err == nil { + *f = splitISRC(one) + return nil + } + + var many []string + if err := json.Unmarshal(data, &many); err == nil { + all := make([]string, 0, len(many)) + for _, part := range many { + all = append(all, splitISRC(part)...) + } + *f = uniqueStrings(all) + return nil + } + + return fmt.Errorf("invalid isrc field") +} + +func NewClient(baseURL, username, password string) *Client { + baseURL = strings.TrimSpace(baseURL) + baseURL = strings.TrimRight(baseURL, "/") + return &Client{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + baseURL: baseURL, + username: strings.TrimSpace(username), + password: password, + clientName: defaultClientName, + } +} + +func (c *Client) Ping(ctx context.Context) error { + var out struct { + SubsonicResponse struct { + Status string `json:"status"` + Error *subsonicError `json:"error"` + } `json:"subsonic-response"` + } + if err := c.call(ctx, http.MethodGet, "/rest/ping.view", url.Values{}, &out); err != nil { + return 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", strings.TrimSpace(query)) + params.Set("songCount", fmt.Sprintf("%d", limit)) + params.Set("songOffset", "0") + params.Set("artistCount", "0") + params.Set("albumCount", "0") + + var out struct { + SubsonicResponse struct { + Status string `json:"status"` + Error *subsonicError `json:"error"` + SearchResult3 struct { + Song []struct { + ID string `json:"id"` + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album"` + Duration int `json:"duration"` + IsDir bool `json:"isDir"` + ISRC isrcField `json:"isrc"` + } `json:"song"` + } `json:"searchResult3"` + } `json:"subsonic-response"` + } + + if err := c.call(ctx, http.MethodGet, "/rest/search3.view", params, &out); err != nil { + return nil, err + } + + res := make([]Track, 0, len(out.SubsonicResponse.SearchResult3.Song)) + for _, s := range out.SubsonicResponse.SearchResult3.Song { + if s.IsDir || strings.TrimSpace(s.ID) == "" { + continue + } + res = append(res, Track{ + ID: s.ID, + Title: s.Title, + Artist: s.Artist, + Album: s.Album, + Duration: s.Duration, + ISRCs: []string(s.ISRC), + }) + } + + return res, nil +} + +func (c *Client) CreatePlaylist(ctx context.Context, name string) (string, error) { + params := url.Values{} + params.Set("name", strings.TrimSpace(name)) + + var out struct { + SubsonicResponse struct { + Status string `json:"status"` + Error *subsonicError `json:"error"` + Playlist struct { + ID string `json:"id"` + } `json:"playlist"` + } `json:"subsonic-response"` + } + + if err := c.call(ctx, http.MethodGet, "/rest/createPlaylist.view", params, &out); err != nil { + return "", err + } + id := strings.TrimSpace(out.SubsonicResponse.Playlist.ID) + if id == "" { + return "", fmt.Errorf("createPlaylist returned empty playlist ID") + } + return id, nil +} + +func (c *Client) AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) error { + if len(trackIDs) == 0 { + return nil + } + playlistID = strings.TrimSpace(playlistID) + if playlistID == "" { + return fmt.Errorf("playlist ID cannot be empty") + } + + chunks := chunk(trackIDs, 200) + for _, ch := range chunks { + params := url.Values{} + params.Set("playlistId", playlistID) + for _, id := range ch { + id = strings.TrimSpace(id) + if id == "" { + continue + } + params.Add("songIdToAdd", id) + } + if len(params["songIdToAdd"]) == 0 { + continue + } + + var out struct { + SubsonicResponse struct { + Status string `json:"status"` + Error *subsonicError `json:"error"` + } `json:"subsonic-response"` + } + if err := c.call(ctx, http.MethodPost, "/rest/updatePlaylist.view", params, &out); err != nil { + return err + } + } + + return nil +} + +func (c *Client) DeletePlaylist(ctx context.Context, playlistID string) error { + params := url.Values{} + params.Set("id", strings.TrimSpace(playlistID)) + + var out struct { + SubsonicResponse struct { + Status string `json:"status"` + Error *subsonicError `json:"error"` + } `json:"subsonic-response"` + } + if err := c.call(ctx, http.MethodGet, "/rest/deletePlaylist.view", params, &out); err != nil { + return err + } + return nil +} + +func (c *Client) call(ctx context.Context, method, endpoint string, params url.Values, out any) error { + params = cloneValues(params) + c.addAuth(params) + + var req *http.Request + var err error + + fullURL := c.baseURL + endpoint + if method == http.MethodPost { + req, err = http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(params.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } else { + if len(params) > 0 { + fullURL += "?" + params.Encode() + } + req, err = http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return err + } + } + + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode >= 300 { + return fmt.Errorf("navidrome api error (%d): %s", resp.StatusCode, strings.TrimSpace(string(b))) + } + + if err := json.Unmarshal(b, out); err != nil { + return fmt.Errorf("decode navidrome response: %w", err) + } + + failed, serr, err := parseSubsonicStatus(b) + if err != nil { + return err + } + if failed { + if serr != nil { + return fmt.Errorf("navidrome api failed (%d): %s", serr.Code, serr.Message) + } + return fmt.Errorf("navidrome api failed") + } + + return nil +} + +func parseSubsonicStatus(body []byte) (bool, *subsonicError, error) { + var root struct { + SubsonicResponse struct { + Status string `json:"status"` + Error *subsonicError `json:"error"` + } `json:"subsonic-response"` + } + if err := json.Unmarshal(body, &root); err != nil { + return false, nil, fmt.Errorf("decode subsonic envelope: %w", err) + } + if strings.EqualFold(strings.TrimSpace(root.SubsonicResponse.Status), "ok") { + return false, root.SubsonicResponse.Error, nil + } + return true, root.SubsonicResponse.Error, nil +} + +func (c *Client) addAuth(params url.Values) { + salt := randomSalt(12) + params.Set("u", c.username) + params.Set("s", salt) + params.Set("t", md5Hex(c.password+salt)) + params.Set("v", apiVersion) + params.Set("c", c.clientName) + params.Set("f", "json") +} + +func randomSalt(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyz0123456789" + if n <= 0 { + n = 12 + } + b := make([]byte, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +func md5Hex(s string) string { + h := md5.Sum([]byte(s)) + return hex.EncodeToString(h[:]) +} + +func splitISRC(v string) []string { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + parts := strings.Split(v, ";") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.ToUpper(strings.TrimSpace(p)) + if p != "" { + out = append(out, p) + } + } + return uniqueStrings(out) +} + +func uniqueStrings(in []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(in)) + for _, v := range in { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +func chunk(ids []string, size int) [][]string { + if size <= 0 { + size = 200 + } + out := make([][]string, 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 init() { + rand.Seed(time.Now().UnixNano()) +} diff --git a/internal/qobuz/client.go b/internal/qobuz/client.go new file mode 100644 index 0000000..f9523f9 --- /dev/null +++ b/internal/qobuz/client.go @@ -0,0 +1,341 @@ +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 string + Title string + Version string + Duration int + ISRC string + Artist string + Album string + AlbumID string + AlbumArtist string +} + +type flexString string + +func (f *flexString) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *f = "" + return nil + } + var s string + if err := json.Unmarshal(data, &s); err == nil { + *f = flexString(strings.TrimSpace(s)) + return nil + } + var n json.Number + if err := json.Unmarshal(data, &n); err == nil { + *f = flexString(strings.TrimSpace(n.String())) + return nil + } + return fmt.Errorf("invalid flexible id") +} + +func NewClient(appID, appSecret string) *Client { + return &Client{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + appID: appID, + appSecret: appSecret, + } +} + +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 flexString `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 { + ID flexString `json:"id"` + Title string `json:"title"` + Artist struct { + Name string `json:"name"` + } `json:"artist"` + } `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 { + trackID := strings.TrimSpace(string(it.ID)) + albumID := strings.TrimSpace(string(it.Album.ID)) + if trackID == "" || albumID == "" { + continue + } + res = append(res, Track{ + ID: trackID, + Title: it.Title, + Version: it.Version, + Duration: it.Duration, + ISRC: strings.ToUpper(strings.TrimSpace(it.ISRC)), + Artist: it.Performer.Name, + Album: it.Album.Title, + AlbumID: albumID, + AlbumArtist: it.Album.Artist.Name, + }) + } + return res, 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_test.go b/internal/qobuz/client_test.go new file mode 100644 index 0000000..0a05273 --- /dev/null +++ b/internal/qobuz/client_test.go @@ -0,0 +1,23 @@ +package qobuz + +import ( + "encoding/json" + "testing" +) + +func TestFlexStringUnmarshal(t *testing.T) { + var s flexString + if err := json.Unmarshal([]byte(`"0724384960650"`), &s); err != nil { + t.Fatalf("unmarshal string id failed: %v", err) + } + if string(s) != "0724384960650" { + t.Fatalf("unexpected value %q", string(s)) + } + + if err := json.Unmarshal([]byte(`12345`), &s); err != nil { + t.Fatalf("unmarshal numeric id failed: %v", err) + } + if string(s) != "12345" { + t.Fatalf("unexpected numeric value %q", string(s)) + } +} 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/recovery/manifest.go b/internal/recovery/manifest.go new file mode 100644 index 0000000..f0878d9 --- /dev/null +++ b/internal/recovery/manifest.go @@ -0,0 +1,81 @@ +package recovery + +import ( + "encoding/json" + "fmt" + "os" + + "navimigrate/internal/model" +) + +const ManifestVersion = 1 + +type Manifest struct { + Version int `json:"version"` + GeneratedAt string `json:"generated_at"` + UpdatedAt string `json:"updated_at,omitempty"` + DownloadDir string `json:"download_dir,omitempty"` + QobuzDLPath string `json:"qobuz_dl_path,omitempty"` + Playlists []PlaylistManifest `json:"playlists"` +} + +type PlaylistManifest struct { + Name string `json:"name"` + TargetID string `json:"target_id,omitempty"` + Tracks []TrackManifest `json:"tracks"` +} + +type TrackManifest struct { + Source model.Track `json:"source"` + LookupError string `json:"lookup_error,omitempty"` + + QobuzQuery string `json:"qobuz_query,omitempty"` + QobuzScore float64 `json:"qobuz_score,omitempty"` + QobuzTrackID string `json:"qobuz_track_id,omitempty"` + QobuzAlbumID string `json:"qobuz_album_id,omitempty"` + QobuzAlbumTitle string `json:"qobuz_album_title,omitempty"` + QobuzAlbumArtist string `json:"qobuz_album_artist,omitempty"` + + DownloadAttempted bool `json:"download_attempted,omitempty"` + Downloaded bool `json:"downloaded,omitempty"` + DownloadError string `json:"download_error,omitempty"` + + RematchAttempted bool `json:"rematch_attempted,omitempty"` + Rematched bool `json:"rematched,omitempty"` + RematchTrackID string `json:"rematch_track_id,omitempty"` + RematchReason string `json:"rematch_reason,omitempty"` + + AddAttempted bool `json:"add_attempted,omitempty"` + Added bool `json:"added,omitempty"` + AddError string `json:"add_error,omitempty"` +} + +func Save(path string, m Manifest) error { + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create manifest file: %w", err) + } + defer f.Close() + + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + if err := enc.Encode(m); err != nil { + return fmt.Errorf("encode manifest: %w", err) + } + return nil +} + +func Load(path string) (Manifest, error) { + b, err := os.ReadFile(path) + if err != nil { + return Manifest{}, fmt.Errorf("read manifest file: %w", err) + } + var m Manifest + if err := json.Unmarshal(b, &m); err != nil { + return Manifest{}, fmt.Errorf("decode manifest: %w", err) + } + if m.Version == 0 { + m.Version = ManifestVersion + } + return m, nil +} diff --git a/internal/recovery/qobuzdl.go b/internal/recovery/qobuzdl.go new file mode 100644 index 0000000..41dabe9 --- /dev/null +++ b/internal/recovery/qobuzdl.go @@ -0,0 +1,112 @@ +package recovery + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" +) + +type QobuzDL struct { + Path string + OutputDir string + Username string + Password string + Quality int +} + +var cachedBinaries sync.Map + +func (d QobuzDL) DownloadAlbum(ctx context.Context, albumID string) error { + albumID = strings.TrimSpace(albumID) + if albumID == "" { + return fmt.Errorf("album id must not be empty") + } + url := fmt.Sprintf("https://play.qobuz.com/album/%s", albumID) + + args := []string{} + if strings.TrimSpace(d.OutputDir) != "" { + args = append(args, "--output", d.OutputDir) + } + if d.Quality > 0 { + args = append(args, "--quality", strconv.Itoa(d.Quality)) + } + if strings.TrimSpace(d.Username) != "" { + args = append(args, "--user", strings.TrimSpace(d.Username)) + } + if strings.TrimSpace(d.Password) != "" { + args = append(args, "--pass", d.Password) + } + args = append(args, "url", url) + + name, cmdArgs, workdir, err := resolveCommand(ctx, strings.TrimSpace(d.Path), args) + if err != nil { + return err + } + cmd := exec.CommandContext(ctx, name, cmdArgs...) + if workdir != "" { + cmd.Dir = workdir + } + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("qobuz-dl failed: %w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func resolveCommand(ctx context.Context, path string, args []string) (name string, cmdArgs []string, workdir string, err error) { + if path == "" { + return "qobuz-dl", args, "", nil + } + + info, err := os.Stat(path) + if err == nil && info.IsDir() { + bin, err := ensureBuiltBinary(ctx, path) + if err != nil { + return "", nil, "", err + } + return bin, args, "", nil + } + + if filepath.Base(path) == "." { + dir := filepath.Dir(path) + bin, err := ensureBuiltBinary(ctx, dir) + if err != nil { + return "", nil, "", err + } + return bin, args, "", nil + } + + return path, args, "", nil +} + +func ensureBuiltBinary(ctx context.Context, dir string) (string, error) { + dir = filepath.Clean(dir) + if v, ok := cachedBinaries.Load(dir); ok { + if bin, ok := v.(string); ok && strings.TrimSpace(bin) != "" { + if _, err := os.Stat(bin); err == nil { + return bin, nil + } + } + } + + tmpDir := filepath.Join(os.TempDir(), "navimigrate-qobuzdl") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return "", fmt.Errorf("create temp build directory: %w", err) + } + + out := filepath.Join(tmpDir, "qobuz-dl") + build := exec.CommandContext(ctx, "go", "build", "-o", out, ".") + build.Dir = dir + b, err := build.CombinedOutput() + if err != nil { + return "", fmt.Errorf("build qobuz-dl in %s: %w: %s", dir, err, strings.TrimSpace(string(b))) + } + + cachedBinaries.Store(dir, out) + return out, nil +} diff --git a/internal/recovery/recovery.go b/internal/recovery/recovery.go new file mode 100644 index 0000000..282de74 --- /dev/null +++ b/internal/recovery/recovery.go @@ -0,0 +1,438 @@ +package recovery + +import ( + "context" + "fmt" + "math" + "sort" + "strings" + "time" + + "navimigrate/internal/model" + "navimigrate/internal/qobuz" +) + +type Searcher interface { + SearchTracks(ctx context.Context, query string, limit int) ([]qobuz.Track, error) +} + +type TrackMatcher interface { + MatchTrack(ctx context.Context, src model.Track) model.MatchedTrack +} + +type PlaylistAdder interface { + AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) error +} + +type ProgressFunc func(message string) + +type BuildOptions struct { + DownloadMissing bool + Downloader QobuzDL + Progress ProgressFunc +} + +type ReaddOptions struct { + Force bool + Progress ProgressFunc +} + +type ReaddSummary struct { + Playlists int + Candidates int + Matched int + Added int + Errors int +} + +func BuildManifestFromReport(ctx context.Context, rep model.TransferReport, searcher Searcher, opts BuildOptions) (Manifest, error) { + manifest := Manifest{ + Version: ManifestVersion, + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + DownloadDir: opts.Downloader.OutputDir, + QobuzDLPath: opts.Downloader.Path, + Playlists: make([]PlaylistManifest, 0, len(rep.Results)), + } + + type albumJob struct { + ID string + } + albums := map[string]albumJob{} + totalUnmatched := 0 + for _, pl := range rep.Results { + totalUnmatched += len(pl.Unmatched) + } + processed := 0 + + for _, pl := range rep.Results { + if len(pl.Unmatched) == 0 { + continue + } + playlistEntry := PlaylistManifest{ + Name: pl.Name, + TargetID: pl.TargetID, + Tracks: make([]TrackManifest, 0, len(pl.Unmatched)), + } + + for _, um := range pl.Unmatched { + processed++ + notify(opts.Progress, fmt.Sprintf("Qobuz lookup %d/%d: %s - %s", processed, totalUnmatched, short(pl.Name, 36), short(um.Source.Title, 44))) + entry := TrackManifest{Source: um.Source} + cand, ok, err := findQobuzCandidate(ctx, searcher, um.Source) + if err != nil { + entry.LookupError = err.Error() + } else if ok { + entry.QobuzQuery = cand.Query + entry.QobuzScore = cand.Score + entry.QobuzTrackID = cand.TrackID + entry.QobuzAlbumID = cand.AlbumID + entry.QobuzAlbumTitle = cand.AlbumTitle + entry.QobuzAlbumArtist = cand.AlbumArtist + if strings.TrimSpace(cand.AlbumID) != "" { + albums[cand.AlbumID] = albumJob{ID: cand.AlbumID} + } + } + playlistEntry.Tracks = append(playlistEntry.Tracks, entry) + } + manifest.Playlists = append(manifest.Playlists, playlistEntry) + } + + if opts.DownloadMissing { + albumIDs := make([]string, 0, len(albums)) + for id := range albums { + albumIDs = append(albumIDs, id) + } + sort.Strings(albumIDs) + + downloadState := map[string]error{} + for i, albumID := range albumIDs { + notify(opts.Progress, fmt.Sprintf("Qobuz album download %d/%d: %s", i+1, len(albumIDs), albumID)) + err := opts.Downloader.DownloadAlbum(ctx, albumID) + downloadState[albumID] = err + } + + for pIdx := range manifest.Playlists { + for tIdx := range manifest.Playlists[pIdx].Tracks { + entry := &manifest.Playlists[pIdx].Tracks[tIdx] + if strings.TrimSpace(entry.QobuzAlbumID) == "" { + continue + } + entry.DownloadAttempted = true + if err := downloadState[entry.QobuzAlbumID]; err != nil { + entry.Downloaded = false + entry.DownloadError = err.Error() + } else { + entry.Downloaded = true + } + } + } + } + + notify(opts.Progress, "Qobuz missing-track processing complete") + return manifest, nil +} + +func ReaddDownloadedToPlaylists(ctx context.Context, manifest *Manifest, matcher TrackMatcher, adder PlaylistAdder, opts ReaddOptions) (ReaddSummary, error) { + summary := ReaddSummary{} + for pIdx := range manifest.Playlists { + pl := &manifest.Playlists[pIdx] + if strings.TrimSpace(pl.TargetID) == "" { + continue + } + summary.Playlists++ + + toAdd := make([]string, 0) + entryIdxs := make([]int, 0) + for tIdx := range pl.Tracks { + entry := &pl.Tracks[tIdx] + if !opts.Force && entry.Added { + continue + } + if !entry.Downloaded { + continue + } + + summary.Candidates++ + entry.RematchAttempted = true + res := matcher.MatchTrack(ctx, entry.Source) + if !res.Matched || strings.TrimSpace(res.TargetID) == "" { + entry.Rematched = false + entry.RematchReason = res.Reason + if entry.RematchReason == "" { + entry.RematchReason = "not found in Navidrome after rescan" + } + continue + } + + entry.Rematched = true + entry.RematchReason = "" + entry.RematchTrackID = res.TargetID + toAdd = append(toAdd, res.TargetID) + entryIdxs = append(entryIdxs, tIdx) + summary.Matched++ + } + + if len(toAdd) == 0 { + continue + } + toAdd = uniqueIDs(toAdd) + notify(opts.Progress, fmt.Sprintf("Re-add to playlist %s: %d track(s)", pl.Name, len(toAdd))) + err := adder.AddTracksToPlaylist(ctx, pl.TargetID, toAdd) + if err != nil { + summary.Errors++ + for _, idx := range entryIdxs { + entry := &pl.Tracks[idx] + entry.AddAttempted = true + entry.Added = false + entry.AddError = err.Error() + } + continue + } + + for _, idx := range entryIdxs { + entry := &pl.Tracks[idx] + entry.AddAttempted = true + entry.Added = true + entry.AddError = "" + summary.Added++ + } + } + + manifest.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + notify(opts.Progress, "Re-add from downloaded manifest complete") + return summary, nil +} + +type qobuzCandidate struct { + TrackID string + AlbumID string + AlbumTitle string + AlbumArtist string + Score float64 + Query string +} + +func findQobuzCandidate(ctx context.Context, searcher Searcher, src model.Track) (qobuzCandidate, bool, error) { + queries := buildQueries(src) + if len(queries) == 0 { + return qobuzCandidate{}, false, nil + } + + best := qobuzCandidate{Score: -999} + seenTrack := map[string]struct{}{} + firstErr := error(nil) + errCount := 0 + for _, q := range queries { + res, err := searcher.SearchTracks(ctx, q, 20) + if err != nil { + errCount++ + if firstErr == nil { + firstErr = err + } + continue + } + for _, tr := range res { + if strings.TrimSpace(tr.ID) == "" { + continue + } + if _, ok := seenTrack[tr.ID]; ok { + continue + } + seenTrack[tr.ID] = struct{}{} + sc := score(src, tr) + if sc > best.Score { + best = qobuzCandidate{ + TrackID: tr.ID, + AlbumID: tr.AlbumID, + AlbumTitle: tr.Album, + AlbumArtist: tr.AlbumArtist, + Score: sc, + Query: q, + } + } + } + } + + if errCount == len(queries) && firstErr != nil { + return qobuzCandidate{}, false, fmt.Errorf("qobuz search failed for all queries: %w", firstErr) + } + + if strings.TrimSpace(best.TrackID) == "" || strings.TrimSpace(best.AlbumID) == "" { + return qobuzCandidate{}, false, nil + } + if best.Score < 45 { + return qobuzCandidate{}, false, nil + } + return best, true, nil +} + +func buildQueries(src model.Track) []string { + title := strings.TrimSpace(src.Title) + if title == "" { + return nil + } + artist := "" + if len(src.Artists) > 0 { + artist = strings.TrimSpace(src.Artists[0]) + } + queries := make([]string, 0, 4) + if strings.TrimSpace(src.ISRC) != "" { + queries = append(queries, strings.ToUpper(strings.TrimSpace(src.ISRC))) + } + queries = append(queries, strings.TrimSpace(title+" "+artist)) + queries = append(queries, title) + if strings.TrimSpace(src.Album) != "" { + queries = append(queries, strings.TrimSpace(title+" "+src.Album+" "+artist)) + } + + 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 score(src model.Track, dst qobuz.Track) float64 { + s := 0.0 + if strings.TrimSpace(src.ISRC) != "" && strings.EqualFold(strings.TrimSpace(src.ISRC), strings.TrimSpace(dst.ISRC)) { + s += 60 + } + s += 25 * similarity(normalize(src.Title), normalize(dst.Title)) + if len(src.Artists) > 0 { + s += 20 * similarity(normalize(src.Artists[0]), normalize(dst.Artist)) + } + if src.DurationMS > 0 && dst.Duration > 0 { + delta := math.Abs(float64(src.DurationMS/1000 - dst.Duration)) + switch { + case delta <= 2: + s += 10 + case delta <= 5: + s += 7 + case delta <= 10: + s += 4 + case delta > 25: + s -= 6 + } + } + return s +} + +func normalize(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + repl := strings.NewReplacer("&", " and ", "'", "", "-", " ") + s = repl.Replace(s) + parts := strings.Fields(s) + return strings.Join(parts, " ") +} + +func similarity(a, b string) float64 { + if a == "" || b == "" { + return 0 + } + if a == b { + return 1 + } + ta := tokenSet(a) + tb := tokenSet(b) + 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{} { + set := map[string]struct{}{} + for _, p := range strings.Fields(s) { + 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] +} + +func uniqueIDs(ids []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func notify(fn ProgressFunc, msg string) { + if fn != nil { + fn(msg) + } +} + +func short(s string, max int) string { + s = strings.TrimSpace(s) + if max <= 3 || len(s) <= max { + return s + } + return s[:max-3] + "..." +} diff --git a/internal/report/report.go b/internal/report/report.go new file mode 100644 index 0000000..2620d23 --- /dev/null +++ b/internal/report/report.go @@ -0,0 +1,24 @@ +package report + +import ( + "encoding/json" + "fmt" + "os" + + "navimigrate/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..c1c0499 --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,96 @@ +package session + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +type Data struct { + Spotify SpotifyState `json:"spotify"` +} + +type SpotifyState struct { + ClientID string `json:"client_id,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + TokenType string `json:"token_type,omitempty"` + Scope string `json:"scope,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +func Load(path string) (Data, error) { + path, err := expandPath(path) + if err != nil { + return Data{}, err + } + b, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Data{}, nil + } + return Data{}, fmt.Errorf("read session file: %w", err) + } + var d Data + if err := json.Unmarshal(b, &d); err != nil { + return Data{}, fmt.Errorf("decode session file: %w", err) + } + return d, nil +} + +func Save(path string, d Data) error { + path, err := expandPath(path) + if err != nil { + return err + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create session directory: %w", err) + } + + b, err := json.MarshalIndent(d, "", " ") + if err != nil { + return fmt.Errorf("encode session file: %w", err) + } + + tmp := path + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return fmt.Errorf("write temp session file: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("replace session file: %w", err) + } + return nil +} + +func (s SpotifyState) ExpiresAtTime() time.Time { + t, err := time.Parse(time.RFC3339, strings.TrimSpace(s.ExpiresAt)) + if err != nil { + return time.Time{} + } + return t +} + +func expandPath(path string) (string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", fmt.Errorf("session path cannot be empty") + } + if strings.HasPrefix(path, "~/") || path == "~" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + if path == "~" { + path = home + } else { + path = filepath.Join(home, strings.TrimPrefix(path, "~/")) + } + } + return filepath.Clean(path), nil +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000..6c73b93 --- /dev/null +++ b/internal/session/session_test.go @@ -0,0 +1,42 @@ +package session + +import ( + "path/filepath" + "testing" +) + +func TestSaveLoadRoundTrip(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "session.json") + in := Data{ + Spotify: SpotifyState{ + ClientID: "abc123", + AccessToken: "access-token", + RefreshToken: "refresh-token", + ExpiresAt: "2026-01-02T03:04:05Z", + }, + } + + if err := Save(path, in); err != nil { + t.Fatalf("Save failed: %v", err) + } + + out, err := Load(path) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if out.Spotify.ClientID != in.Spotify.ClientID { + t.Fatalf("ClientID mismatch: got %q want %q", out.Spotify.ClientID, in.Spotify.ClientID) + } + if out.Spotify.AccessToken != in.Spotify.AccessToken { + t.Fatalf("AccessToken mismatch") + } + if out.Spotify.RefreshToken != in.Spotify.RefreshToken { + t.Fatalf("RefreshToken mismatch") + } + if out.Spotify.ExpiresAt != in.Spotify.ExpiresAt { + t.Fatalf("ExpiresAt mismatch: got %q want %q", out.Spotify.ExpiresAt, in.Spotify.ExpiresAt) + } +} diff --git a/internal/spotify/auth.go b/internal/spotify/auth.go new file mode 100644 index 0000000..ff4005c --- /dev/null +++ b/internal/spotify/auth.go @@ -0,0 +1,314 @@ +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 RefreshAccessToken(ctx context.Context, clientID, refreshToken string) (Token, error) { + body := url.Values{ + "client_id": []string{strings.TrimSpace(clientID)}, + "grant_type": []string{"refresh_token"}, + "refresh_token": []string{strings.TrimSpace(refreshToken)}, + } + + tok, err := requestToken(ctx, body) + if err != nil { + return Token{}, fmt.Errorf("spotify refresh failed: %w", err) + } + if tok.RefreshToken == "" { + tok.RefreshToken = strings.TrimSpace(refreshToken) + } + return tok, nil +} + +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}, + } + return requestToken(ctx, body) +} + +func requestToken(ctx context.Context, body url.Values) (Token, error) { + 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 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..7fcf34d --- /dev/null +++ b/internal/spotify/client.go @@ -0,0 +1,277 @@ +package spotify + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "navimigrate/internal/model" +) + +const baseURL = "https://api.spotify.com/v1" + +type Client struct { + httpClient *http.Client + token string + progress ProgressFunc +} + +type ProgressFunc func(message string) + +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) 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) + 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) ([]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, + )) + } + + if totalTracks > 0 { + c.notifyProgress(fmt.Sprintf("Playlist (%s): tracks %d/%d", playlistName, loadedTracks, totalTracks)) + } else { + c.notifyProgress(fmt.Sprintf("Playlist (%s): tracks %d", 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/spotify/playlist_url_test.go b/internal/spotify/playlist_url_test.go new file mode 100644 index 0000000..b370397 --- /dev/null +++ b/internal/spotify/playlist_url_test.go @@ -0,0 +1,24 @@ +package spotify + +import "testing" + +func TestParsePlaylistID(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"spotify:playlist:16DZpOTLqZvdbqxEavLmWk", "16DZpOTLqZvdbqxEavLmWk"}, + {"https://open.spotify.com/playlist/16DZpOTLqZvdbqxEavLmWk?si=abc", "16DZpOTLqZvdbqxEavLmWk"}, + {"16DZpOTLqZvdbqxEavLmWk", "16DZpOTLqZvdbqxEavLmWk"}, + } + + for _, tt := range tests { + got, err := ParsePlaylistID(tt.in) + if err != nil { + t.Fatalf("ParsePlaylistID(%q) returned error: %v", tt.in, err) + } + if got != tt.want { + t.Fatalf("ParsePlaylistID(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/internal/transfer/transfer.go b/internal/transfer/transfer.go new file mode 100644 index 0000000..cc7205f --- /dev/null +++ b/internal/transfer/transfer.go @@ -0,0 +1,206 @@ +package transfer + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "navimigrate/internal/model" +) + +type Writer interface { + CreatePlaylist(ctx context.Context, name string) (string, error) + AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) error +} + +type TrackMatcher interface { + MatchTrack(ctx context.Context, src model.Track) model.MatchedTrack +} + +type Config struct { + DryRun bool + Concurrency int + Progress ProgressFunc +} + +type ProgressFunc func(message string) + +func Run(ctx context.Context, cfg Config, writer Writer, matcher TrackMatcher, playlists []model.Playlist) (model.TransferReport, error) { + rep := model.TransferReport{ + StartedAt: time.Now().UTC().Format(time.RFC3339), + DryRun: cfg.DryRun, + } + + totalPlaylists := len(playlists) + for i, pl := range playlists { + 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 Writer, 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) + 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)) ([]string, []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([]string, 0, len(tracks)) + unmatched := make([]model.MatchedTrack, 0) + for _, r := range ordered { + if r.Matched && strings.TrimSpace(r.TargetID) != "" { + matched = append(matched, r.TargetID) + } else { + unmatched = append(unmatched, r) + } + } + return matched, unmatched +} + +func uniqueIDs(ids []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +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] + "..." +}