build spotify-to-navidrome migrator with recovery flow
This commit is contained in:
304
internal/config/config.go
Normal file
304
internal/config/config.go
Normal 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
358
internal/match/matcher.go
Normal 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]
|
||||
}
|
||||
64
internal/match/matcher_test.go
Normal file
64
internal/match/matcher_test.go
Normal 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
44
internal/model/model.go
Normal 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"`
|
||||
}
|
||||
381
internal/navidrome/client.go
Normal file
381
internal/navidrome/client.go
Normal 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
341
internal/qobuz/client.go
Normal 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[:])
|
||||
}
|
||||
23
internal/qobuz/client_test.go
Normal file
23
internal/qobuz/client_test.go
Normal 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
101
internal/qobuz/signer.go
Normal 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:])
|
||||
}
|
||||
81
internal/recovery/manifest.go
Normal file
81
internal/recovery/manifest.go
Normal 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
|
||||
}
|
||||
112
internal/recovery/qobuzdl.go
Normal file
112
internal/recovery/qobuzdl.go
Normal 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
|
||||
}
|
||||
438
internal/recovery/recovery.go
Normal file
438
internal/recovery/recovery.go
Normal 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
24
internal/report/report.go
Normal 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
|
||||
}
|
||||
96
internal/session/session.go
Normal file
96
internal/session/session.go
Normal 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
|
||||
}
|
||||
42
internal/session/session_test.go
Normal file
42
internal/session/session_test.go
Normal 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
314
internal/spotify/auth.go
Normal 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
277
internal/spotify/client.go
Normal 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
|
||||
}
|
||||
43
internal/spotify/playlist_url.go
Normal file
43
internal/spotify/playlist_url.go
Normal 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")
|
||||
}
|
||||
24
internal/spotify/playlist_url_test.go
Normal file
24
internal/spotify/playlist_url_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
206
internal/transfer/transfer.go
Normal file
206
internal/transfer/transfer.go
Normal 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] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user