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 }