first commit

This commit is contained in:
Tomas Dvorak
2026-04-13 17:46:58 +02:00
commit 6e8fedf534
234 changed files with 53808 additions and 0 deletions
@@ -0,0 +1,463 @@
package spotify
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
const (
defaultAccountsBaseURL = "https://accounts.spotify.com"
defaultAPIBaseURL = "https://api.spotify.com/v1"
defaultTimeout = 10 * time.Second
)
var ErrNotConfigured = errors.New("spotify credentials are not configured")
type Config struct {
ClientID string
ClientSecret string
BearerToken string
Market string
AccountsBaseURL string
APIBaseURL string
HTTPClient *http.Client
Timeout time.Duration
MaxRetries int
}
type Client struct {
clientID string
clientSecret string
staticToken string
defaultMarket string
accountsBaseURL string
apiBaseURL string
httpClient *http.Client
timeout time.Duration
maxRetries int
mu sync.Mutex
token string
expiresAt time.Time
lastError string
}
func New(cfg Config) *Client {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultTimeout
}
httpClient := cfg.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: timeout}
}
accountsBaseURL := strings.TrimRight(cfg.AccountsBaseURL, "/")
if accountsBaseURL == "" {
accountsBaseURL = defaultAccountsBaseURL
}
apiBaseURL := strings.TrimRight(cfg.APIBaseURL, "/")
if apiBaseURL == "" {
apiBaseURL = defaultAPIBaseURL
}
maxRetries := cfg.MaxRetries
if maxRetries <= 0 {
maxRetries = 2
}
return &Client{
clientID: strings.TrimSpace(cfg.ClientID),
clientSecret: strings.TrimSpace(cfg.ClientSecret),
staticToken: strings.TrimSpace(cfg.BearerToken),
defaultMarket: strings.ToUpper(strings.TrimSpace(cfg.Market)),
accountsBaseURL: accountsBaseURL,
apiBaseURL: apiBaseURL,
httpClient: httpClient,
timeout: timeout,
maxRetries: maxRetries,
}
}
func (c *Client) Configured() bool {
return c.staticToken != "" || (c.clientID != "" && c.clientSecret != "")
}
func (c *Client) TokenMode() string {
if c.staticToken != "" {
return "static_bearer"
}
if c.clientID != "" && c.clientSecret != "" {
return "client_credentials"
}
return "unconfigured"
}
func (c *Client) LastError() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.lastError
}
func (c *Client) GetTrack(ctx context.Context, id, market string) (Track, []byte, error) {
var out Track
payload, err := c.get(ctx, "/tracks/"+url.PathEscape(id), marketParams(marketOrDefault(market, c.defaultMarket)), &out)
return out, payload, err
}
func (c *Client) GetAudioFeatures(ctx context.Context, id string) (AudioFeatures, []byte, error) {
var out AudioFeatures
payload, err := c.get(ctx, "/audio-features/"+url.PathEscape(id), nil, &out)
return out, payload, err
}
func (c *Client) Search(ctx context.Context, query, itemType, market string, limit int) (SearchResult, []byte, error) {
itemType = strings.ToLower(strings.TrimSpace(itemType))
if itemType == "" {
itemType = "track"
}
if limit <= 0 {
limit = 5
}
if limit > 10 {
limit = 10
}
params := url.Values{}
params.Set("q", query)
params.Set("type", itemType)
params.Set("limit", strconv.Itoa(limit))
if market = marketOrDefault(market, c.defaultMarket); market != "" {
params.Set("market", market)
}
var out SearchResult
payload, err := c.get(ctx, "/search", params, &out)
return out, payload, err
}
func (c *Client) GetAlbumTracks(ctx context.Context, id, market string, limit int) ([]TrackRef, []byte, error) {
return c.getPagedTrackRefs(ctx, "/albums/"+url.PathEscape(id)+"/tracks", "items", market, limit)
}
func (c *Client) GetPlaylistTracks(ctx context.Context, id, market string, limit int) ([]TrackRef, []byte, error) {
limit = normalizeCollectionLimit(limit)
refs := make([]TrackRef, 0, limit)
var lastPayload []byte
for offset := 0; len(refs) < limit; offset += 50 {
params := marketParams(marketOrDefault(market, c.defaultMarket))
params.Set("limit", strconv.Itoa(minInt(50, limit-len(refs))))
params.Set("offset", strconv.Itoa(offset))
params.Set("fields", "items(track(id,is_local,type)),next")
payload, err := c.getRaw(ctx, "/playlists/"+url.PathEscape(id)+"/tracks", params)
if err != nil {
return nil, payload, err
}
lastPayload = payload
var page struct {
Items []struct {
Track TrackRef `json:"track"`
} `json:"items"`
Next string `json:"next"`
}
if err := json.Unmarshal(payload, &page); err != nil {
return nil, payload, fmt.Errorf("decode playlist tracks: %w", err)
}
for _, item := range page.Items {
if item.Track.ID != "" && !item.Track.IsLocal {
refs = append(refs, item.Track)
if len(refs) >= limit {
break
}
}
}
if page.Next == "" || len(page.Items) == 0 {
break
}
}
return refs, lastPayload, nil
}
func (c *Client) GetArtistTopTracks(ctx context.Context, id, market string) ([]Track, []byte, error) {
params := marketParams(marketOrDefault(market, c.defaultMarket))
if params.Get("market") == "" {
params.Set("market", "US")
}
var out struct {
Tracks []Track `json:"tracks"`
}
payload, err := c.get(ctx, "/artists/"+url.PathEscape(id)+"/top-tracks", params, &out)
return out.Tracks, payload, err
}
func (c *Client) getPagedTrackRefs(ctx context.Context, path, listField, market string, limit int) ([]TrackRef, []byte, error) {
limit = normalizeCollectionLimit(limit)
refs := make([]TrackRef, 0, limit)
var lastPayload []byte
for offset := 0; len(refs) < limit; offset += 50 {
params := marketParams(marketOrDefault(market, c.defaultMarket))
params.Set("limit", strconv.Itoa(minInt(50, limit-len(refs))))
params.Set("offset", strconv.Itoa(offset))
payload, err := c.getRaw(ctx, path, params)
if err != nil {
return nil, payload, err
}
lastPayload = payload
var page struct {
Items []TrackRef `json:"items"`
Next string `json:"next"`
}
if err := json.Unmarshal(payload, &page); err != nil {
return nil, payload, fmt.Errorf("decode %s: %w", listField, err)
}
for _, item := range page.Items {
if item.ID != "" {
refs = append(refs, item)
if len(refs) >= limit {
break
}
}
}
if page.Next == "" || len(page.Items) == 0 {
break
}
}
return refs, lastPayload, nil
}
func (c *Client) get(ctx context.Context, path string, params url.Values, out any) ([]byte, error) {
payload, err := c.getRaw(ctx, path, params)
if err != nil {
return payload, err
}
if err := json.Unmarshal(payload, out); err != nil {
return payload, fmt.Errorf("decode spotify response: %w", err)
}
return payload, nil
}
func (c *Client) getRaw(ctx context.Context, path string, params url.Values) ([]byte, error) {
if params == nil {
params = url.Values{}
}
endpoint := c.apiBaseURL + path
if encoded := params.Encode(); encoded != "" {
endpoint += "?" + encoded
}
return c.doJSON(ctx, http.MethodGet, endpoint, nil, true)
}
func (c *Client) accessToken(ctx context.Context) (string, error) {
if c.staticToken != "" {
return c.staticToken, nil
}
if c.clientID == "" || c.clientSecret == "" {
c.setLastError(ErrNotConfigured.Error())
return "", ErrNotConfigured
}
c.mu.Lock()
if c.token != "" && time.Now().Add(60*time.Second).Before(c.expiresAt) {
token := c.token
c.mu.Unlock()
return token, nil
}
c.mu.Unlock()
body := strings.NewReader("grant_type=client_credentials")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.accountsBaseURL+"/api/token", body)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
credential := base64.StdEncoding.EncodeToString([]byte(c.clientID + ":" + c.clientSecret))
req.Header.Set("Authorization", "Basic "+credential)
resp, err := c.httpClient.Do(req)
if err != nil {
c.setLastError(err.Error())
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
_, _ = io.Copy(io.Discard, resp.Body)
err := fmt.Errorf("spotify token request failed with status %d", resp.StatusCode)
c.setLastError(err.Error())
return "", err
}
var decoded struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
c.setLastError(err.Error())
return "", fmt.Errorf("decode spotify token: %w", err)
}
if decoded.AccessToken == "" {
err := errors.New("spotify token response did not include an access token")
c.setLastError(err.Error())
return "", err
}
expiresIn := time.Duration(decoded.ExpiresIn) * time.Second
if expiresIn <= 0 {
expiresIn = time.Hour
}
c.mu.Lock()
c.token = decoded.AccessToken
c.expiresAt = time.Now().Add(expiresIn)
c.lastError = ""
c.mu.Unlock()
return decoded.AccessToken, nil
}
func (c *Client) doJSON(ctx context.Context, method, endpoint string, body []byte, authenticate bool) ([]byte, error) {
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(attempt) * 250 * time.Millisecond):
}
}
var reader io.Reader
if len(body) > 0 {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if authenticate {
token, err := c.accessToken(ctx)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
lastErr = err
continue
}
payload, readErr := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
closeErr := resp.Body.Close()
if readErr != nil {
return payload, readErr
}
if closeErr != nil {
return payload, closeErr
}
if resp.StatusCode >= 200 && resp.StatusCode <= 299 {
c.setLastError("")
return payload, nil
}
if resp.StatusCode == http.StatusUnauthorized {
c.mu.Lock()
c.token = ""
c.expiresAt = time.Time{}
c.mu.Unlock()
}
lastErr = spotifyHTTPError{StatusCode: resp.StatusCode, Body: string(payload)}
if resp.StatusCode == http.StatusTooManyRequests {
wait := retryAfter(resp.Header.Get("Retry-After"))
if wait > 0 && attempt < c.maxRetries {
select {
case <-ctx.Done():
return payload, ctx.Err()
case <-time.After(wait):
continue
}
}
}
if resp.StatusCode < 500 && resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusTooManyRequests {
break
}
}
if lastErr == nil {
lastErr = errors.New("spotify request failed")
}
c.setLastError(lastErr.Error())
return nil, lastErr
}
func (c *Client) setLastError(message string) {
c.mu.Lock()
defer c.mu.Unlock()
c.lastError = message
}
type spotifyHTTPError struct {
StatusCode int
Body string
}
func (e spotifyHTTPError) Error() string {
if e.Body == "" {
return fmt.Sprintf("spotify request failed with status %d", e.StatusCode)
}
return fmt.Sprintf("spotify request failed with status %d", e.StatusCode)
}
func IsNotFound(err error) bool {
var httpErr spotifyHTTPError
return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound
}
func retryAfter(value string) time.Duration {
if value == "" {
return 0
}
if seconds, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
return time.Duration(seconds) * time.Second
}
if when, err := http.ParseTime(value); err == nil {
return time.Until(when)
}
return 0
}
func marketParams(market string) url.Values {
params := url.Values{}
if market = strings.ToUpper(strings.TrimSpace(market)); market != "" {
params.Set("market", market)
}
return params
}
func marketOrDefault(market, fallback string) string {
if market = strings.ToUpper(strings.TrimSpace(market)); market != "" {
return market
}
return strings.ToUpper(strings.TrimSpace(fallback))
}
func normalizeCollectionLimit(limit int) int {
if limit <= 0 {
return 100
}
if limit > 100 {
return 100
}
return limit
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}