build spotify-to-navidrome migrator with recovery flow

This commit is contained in:
2026-04-09 03:10:58 +02:00
parent 650a0c6a87
commit c1360a6423
23 changed files with 3383 additions and 0 deletions

304
internal/config/config.go Normal file
View File

@@ -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
}

358
internal/match/matcher.go Normal file
View File

@@ -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]
}

View File

@@ -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)
}
}

44
internal/model/model.go Normal file
View File

@@ -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"`
}

View File

@@ -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())
}

341
internal/qobuz/client.go Normal file
View File

@@ -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[:])
}

View File

@@ -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))
}
}

101
internal/qobuz/signer.go Normal file
View File

@@ -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:])
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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] + "..."
}

24
internal/report/report.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}

314
internal/spotify/auth.go Normal file
View File

@@ -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()
}

277
internal/spotify/client.go Normal file
View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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)
}
}
}

View File

@@ -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] + "..."
}