build spotify-to-navidrome migrator with recovery flow

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

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

@@ -0,0 +1,277 @@
package spotify
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"navimigrate/internal/model"
)
const baseURL = "https://api.spotify.com/v1"
type Client struct {
httpClient *http.Client
token string
progress ProgressFunc
}
type ProgressFunc func(message string)
func NewClient(token string) *Client {
return &Client{
httpClient: &http.Client{Timeout: 30 * time.Second},
token: token,
}
}
func (c *Client) SetProgress(fn ProgressFunc) {
c.progress = fn
}
func (c *Client) FetchPlaylistsByID(ctx context.Context, ids []string) ([]model.Playlist, error) {
out := make([]model.Playlist, 0, len(ids))
for i, id := range ids {
pl, err := c.FetchPlaylistByID(ctx, id)
if err != nil {
return nil, err
}
c.notifyProgress(fmt.Sprintf("Spotify playlist URLs: %d/%d", i+1, len(ids)))
out = append(out, pl)
}
return out, nil
}
func (c *Client) FetchPlaylistByID(ctx context.Context, playlistID string) (model.Playlist, error) {
type response struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
endpoint := fmt.Sprintf("%s/playlists/%s?fields=id,name,description", baseURL, url.PathEscape(playlistID))
var meta response
if err := c.getJSON(ctx, endpoint, &meta); err != nil {
return model.Playlist{}, fmt.Errorf("fetch playlist metadata %s: %w", playlistID, err)
}
tracks, err := c.fetchPlaylistTracks(ctx, meta.ID, meta.Name)
if err != nil {
return model.Playlist{}, fmt.Errorf("fetch playlist tracks %s: %w", meta.Name, err)
}
return model.Playlist{
SourceID: meta.ID,
Name: meta.Name,
Description: meta.Description,
Tracks: tracks,
}, nil
}
func (c *Client) fetchPlaylistTracks(ctx context.Context, playlistID, playlistName string) ([]model.Track, error) {
type trackObj struct {
ID string `json:"id"`
Name string `json:"name"`
DurationMS int `json:"duration_ms"`
Explicit bool `json:"explicit"`
ExternalIDs struct {
ISRC string `json:"isrc"`
} `json:"external_ids"`
Album struct {
Name string `json:"name"`
} `json:"album"`
Artists []struct {
Name string `json:"name"`
} `json:"artists"`
}
type item struct {
Track *trackObj `json:"track"`
}
type page struct {
Items []item `json:"items"`
Next string `json:"next"`
Total int `json:"total"`
}
var out []model.Track
next := fmt.Sprintf("%s/playlists/%s/tracks?limit=100", baseURL, url.PathEscape(playlistID))
loadedTracks := 0
totalTracks := 0
for next != "" {
var p page
if err := c.getJSON(ctx, next, &p); err != nil {
return nil, err
}
if p.Total > 0 {
totalTracks = p.Total
}
for _, it := range p.Items {
if it.Track == nil || it.Track.ID == "" {
continue
}
loadedTracks++
out = append(out, toModelTrack(
it.Track.ID,
it.Track.Name,
it.Track.Album.Name,
it.Track.DurationMS,
it.Track.ExternalIDs.ISRC,
it.Track.Explicit,
it.Track.Artists,
))
}
if totalTracks > 0 {
c.notifyProgress(fmt.Sprintf("Playlist (%s): tracks %d/%d", playlistName, loadedTracks, totalTracks))
} else {
c.notifyProgress(fmt.Sprintf("Playlist (%s): tracks %d", playlistName, loadedTracks))
}
next = p.Next
}
return out, nil
}
func (c *Client) FetchLikedSongs(ctx context.Context) ([]model.Track, error) {
type trackObj struct {
ID string `json:"id"`
Name string `json:"name"`
DurationMS int `json:"duration_ms"`
Explicit bool `json:"explicit"`
ExternalIDs struct {
ISRC string `json:"isrc"`
} `json:"external_ids"`
Album struct {
Name string `json:"name"`
} `json:"album"`
Artists []struct {
Name string `json:"name"`
} `json:"artists"`
}
type item struct {
Track *trackObj `json:"track"`
}
type page struct {
Items []item `json:"items"`
Next string `json:"next"`
Total int `json:"total"`
}
var out []model.Track
next := baseURL + "/me/tracks?limit=50"
loaded := 0
total := 0
for next != "" {
var p page
if err := c.getJSON(ctx, next, &p); err != nil {
return nil, err
}
if p.Total > 0 {
total = p.Total
}
for _, it := range p.Items {
if it.Track == nil || it.Track.ID == "" {
continue
}
loaded++
out = append(out, toModelTrack(
it.Track.ID,
it.Track.Name,
it.Track.Album.Name,
it.Track.DurationMS,
it.Track.ExternalIDs.ISRC,
it.Track.Explicit,
it.Track.Artists,
))
}
if total > 0 {
c.notifyProgress(fmt.Sprintf("Liked songs: %d/%d", loaded, total))
} else {
c.notifyProgress(fmt.Sprintf("Liked songs: %d", loaded))
}
next = p.Next
}
return out, nil
}
func (c *Client) notifyProgress(msg string) {
if c.progress != nil {
c.progress(msg)
}
}
func toModelTrack(id, name, album string, durationMS int, isrc string, explicit bool, artists []struct {
Name string `json:"name"`
}) model.Track {
artistNames := make([]string, 0, len(artists))
for _, a := range artists {
if strings.TrimSpace(a.Name) != "" {
artistNames = append(artistNames, a.Name)
}
}
return model.Track{
SourceID: id,
Title: name,
Artists: artistNames,
Album: album,
DurationMS: durationMS,
ISRC: strings.ToUpper(strings.TrimSpace(isrc)),
Explicit: explicit,
}
}
func (c *Client) getJSON(ctx context.Context, endpoint string, out any) error {
var lastErr error
for attempt := 1; attempt <= 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = err
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
continue
}
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
time.Sleep(time.Duration(attempt) * time.Second)
continue
}
if resp.StatusCode >= 500 {
resp.Body.Close()
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
continue
}
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return fmt.Errorf("spotify api error (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return err
}
return nil
}
if lastErr == nil {
lastErr = fmt.Errorf("spotify request failed after retries")
}
return lastErr
}