package spotify import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "qtransfer/internal/model" ) const baseURL = "https://api.spotify.com/v1" type Client struct { httpClient *http.Client token string progress ProgressFunc } type ProgressFunc func(message string) type User struct { ID string `json:"id"` DisplayName string `json:"display_name"` } 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) GetCurrentUser(ctx context.Context) (User, error) { var u User err := c.getJSON(ctx, baseURL+"/me", &u) return u, err } func (c *Client) FetchLibrary(ctx context.Context, likedName string) (model.Library, error) { c.notifyProgress("Fetching Spotify profile...") user, err := c.GetCurrentUser(ctx) if err != nil { return model.Library{}, err } c.notifyProgress("Fetching Spotify playlists...") pls, err := c.FetchPlaylists(ctx) if err != nil { return model.Library{}, err } c.notifyProgress("Fetching Spotify liked songs...") liked, err := c.FetchLikedSongs(ctx) if err != nil { return model.Library{}, err } lib := model.Library{ UserID: user.ID, DisplayName: user.DisplayName, Playlists: pls, LikedSongs: liked, LikedName: likedName, SourceSystem: "spotify", } return lib, nil } func (c *Client) FetchPlaylists(ctx context.Context) ([]model.Playlist, error) { type playlistLite struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` } type page struct { Items []playlistLite `json:"items"` Next string `json:"next"` Total int `json:"total"` } var out []model.Playlist next := baseURL + "/me/playlists?limit=50" loadedPlaylists := 0 totalPlaylists := 0 for next != "" { var p page if err := c.getJSON(ctx, next, &p); err != nil { return nil, err } if p.Total > 0 { totalPlaylists = p.Total } for _, item := range p.Items { loadedPlaylists++ if totalPlaylists > 0 { c.notifyProgress(fmt.Sprintf("Spotify playlists: %d/%d", loadedPlaylists, totalPlaylists)) } else { c.notifyProgress(fmt.Sprintf("Spotify playlists: %d", loadedPlaylists)) } tracks, err := c.fetchPlaylistTracks(ctx, item.ID, item.Name, loadedPlaylists, totalPlaylists) if err != nil { return nil, fmt.Errorf("fetch playlist tracks %s: %w", item.Name, err) } out = append(out, model.Playlist{ SourceID: item.ID, Name: item.Name, Description: item.Description, Tracks: tracks, }) } next = p.Next } return out, nil } 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, 0, 0) 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, playlistIndex, playlistTotal int) ([]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, )) } prefix := "Playlist" if playlistTotal > 0 { prefix = fmt.Sprintf("Playlist %d/%d", playlistIndex, playlistTotal) } else if playlistIndex > 0 { prefix = fmt.Sprintf("Playlist %d", playlistIndex) } if totalTracks > 0 { c.notifyProgress(fmt.Sprintf("%s (%s): tracks %d/%d", prefix, playlistName, loadedTracks, totalTracks)) } else { c.notifyProgress(fmt.Sprintf("%s (%s): tracks %d", prefix, 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 }