first commit
This commit is contained in:
375
internal/spotify/client.go
Normal file
375
internal/spotify/client.go
Normal file
@@ -0,0 +1,375 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user