mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-04 12:33:03 +00:00
first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientCredentialsTokenIsCached(t *testing.T) {
|
||||
var tokenRequests atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/token":
|
||||
tokenRequests.Add(1)
|
||||
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Basic ") {
|
||||
t.Fatalf("missing basic authorization header")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "token-a", "expires_in": 3600, "token_type": "Bearer"})
|
||||
case "/v1/tracks/abc":
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer token-a" {
|
||||
t.Fatalf("got authorization %q", got)
|
||||
}
|
||||
writeTrack(w, "abc")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(Config{
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
AccountsBaseURL: server.URL,
|
||||
APIBaseURL: server.URL + "/v1",
|
||||
})
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, _, err := client.GetTrack(context.Background(), "abc", "US"); err != nil {
|
||||
t.Fatalf("get track %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got := tokenRequests.Load(); got != 1 {
|
||||
t.Fatalf("token requests = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRetriesRateLimitedRequest(t *testing.T) {
|
||||
var calls atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/tracks/abc" {
|
||||
if calls.Add(1) == 1 {
|
||||
w.Header().Set("Retry-After", "0")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
writeTrack(w, "abc")
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(Config{BearerToken: "token", APIBaseURL: server.URL + "/v1", MaxRetries: 1})
|
||||
if _, _, err := client.GetTrack(context.Background(), "abc", "US"); err != nil {
|
||||
t.Fatalf("get track after retry: %v", err)
|
||||
}
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Fatalf("calls = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReportsMalformedJSONAndContextCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(Config{BearerToken: "token", APIBaseURL: server.URL, MaxRetries: 0})
|
||||
if _, _, err := client.GetTrack(context.Background(), "abc", "US"); err == nil {
|
||||
t.Fatal("expected malformed JSON error")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, _, err := client.GetTrack(ctx, "abc", "US"); err == nil {
|
||||
t.Fatal("expected context cancellation error")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTrack(w http.ResponseWriter, id string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(Track{
|
||||
ID: id,
|
||||
Name: "Track",
|
||||
Artists: []Artist{{Name: "Artist"}},
|
||||
Album: Album{Name: "Album", ReleaseDate: "2024-01-01"},
|
||||
DurationMS: int((3 * time.Minute).Milliseconds()),
|
||||
Popularity: 77,
|
||||
ExternalIDs: map[string]string{
|
||||
"isrc": "USRC17607839",
|
||||
},
|
||||
ExternalURLs: map[string]string{
|
||||
"spotify": "https://open.spotify.com/track/" + id,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package spotify
|
||||
|
||||
type Track struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists []Artist `json:"artists"`
|
||||
Album Album `json:"album"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Popularity int `json:"popularity"`
|
||||
Explicit bool `json:"explicit"`
|
||||
ExternalIDs map[string]string `json:"external_ids"`
|
||||
ExternalURLs map[string]string `json:"external_urls"`
|
||||
Type string `json:"type"`
|
||||
IsLocal bool `json:"is_local"`
|
||||
}
|
||||
|
||||
type TrackRef struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
IsLocal bool `json:"is_local"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Genres []string `json:"genres"`
|
||||
ExternalURLs map[string]string `json:"external_urls"`
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Images []Image `json:"images"`
|
||||
Artists []Artist `json:"artists"`
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
URL string `json:"url"`
|
||||
Height int `json:"height"`
|
||||
Width int `json:"width"`
|
||||
}
|
||||
|
||||
type AudioFeatures struct {
|
||||
Danceability float64 `json:"danceability"`
|
||||
Energy float64 `json:"energy"`
|
||||
Loudness float64 `json:"loudness"`
|
||||
Speechiness float64 `json:"speechiness"`
|
||||
Acousticness float64 `json:"acousticness"`
|
||||
Instrumentalness float64 `json:"instrumentalness"`
|
||||
Liveness float64 `json:"liveness"`
|
||||
Valence float64 `json:"valence"`
|
||||
Tempo float64 `json:"tempo"`
|
||||
TimeSignature float64 `json:"time_signature"`
|
||||
Key float64 `json:"key"`
|
||||
Mode float64 `json:"mode"`
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
Tracks struct {
|
||||
Items []Track `json:"items"`
|
||||
} `json:"tracks"`
|
||||
Albums struct {
|
||||
Items []Album `json:"items"`
|
||||
} `json:"albums"`
|
||||
Artists struct {
|
||||
Items []Artist `json:"items"`
|
||||
} `json:"artists"`
|
||||
Playlists struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"items"`
|
||||
} `json:"playlists"`
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
uriPattern = regexp.MustCompile(`(?i)^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$`)
|
||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9]{10,}$`)
|
||||
pathIDPattern = regexp.MustCompile(`^[A-Za-z0-9]+$`)
|
||||
)
|
||||
|
||||
type ParsedSource struct {
|
||||
Type string
|
||||
ID string
|
||||
URL string
|
||||
}
|
||||
|
||||
func ParseSource(sourceType, value string) (ParsedSource, error) {
|
||||
sourceType = strings.ToLower(strings.TrimSpace(sourceType))
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ParsedSource{}, errors.New("source value is required")
|
||||
}
|
||||
|
||||
if sourceType == "" || sourceType == "url" {
|
||||
parsed, err := ParseURL(value)
|
||||
if err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
if sourceType == "url" {
|
||||
return ParsedSource{}, err
|
||||
}
|
||||
}
|
||||
if sourceType == "" {
|
||||
sourceType = "track"
|
||||
}
|
||||
if !validSpotifyType(sourceType) {
|
||||
return ParsedSource{}, errors.New("source type must be track, album, playlist, artist, or url")
|
||||
}
|
||||
if parsed, err := ParseURL(value); err == nil {
|
||||
if parsed.Type != sourceType {
|
||||
return ParsedSource{}, errors.New("source URL type does not match requested type")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
if !idPattern.MatchString(value) {
|
||||
return ParsedSource{}, errors.New("source value must be a Spotify ID, URI, or open.spotify.com URL")
|
||||
}
|
||||
return ParsedSource{Type: sourceType, ID: value, URL: "https://open.spotify.com/" + sourceType + "/" + value}, nil
|
||||
}
|
||||
|
||||
func ParseURL(raw string) (ParsedSource, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ParsedSource{}, errors.New("url is required")
|
||||
}
|
||||
|
||||
if match := uriPattern.FindStringSubmatch(raw); len(match) == 3 {
|
||||
return ParsedSource{Type: strings.ToLower(match[1]), ID: match[2], URL: "https://open.spotify.com/" + strings.ToLower(match[1]) + "/" + match[2]}, nil
|
||||
}
|
||||
|
||||
parsedURL, err := parseURLWithDefaultScheme(raw)
|
||||
if err == nil {
|
||||
if value := parsedURL.Query().Get("uri"); value != "" {
|
||||
return ParseURL(value)
|
||||
}
|
||||
|
||||
host := spotifyHost(parsedURL.Host)
|
||||
switch host {
|
||||
case "open.spotify.com", "play.spotify.com":
|
||||
if parsed, ok := parseSpotifyPath(parsedURL.Path); ok {
|
||||
parsed.URL = canonicalURL(parsed.Type, parsed.ID)
|
||||
return parsed, nil
|
||||
}
|
||||
case "embed.spotify.com":
|
||||
if parsed, ok := parseSpotifyPath(parsedURL.Path); ok {
|
||||
parsed.URL = canonicalURL(parsed.Type, parsed.ID)
|
||||
return parsed, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ParsedSource{}, errors.New("unsupported Spotify URL")
|
||||
}
|
||||
|
||||
func parseURLWithDefaultScheme(raw string) (*url.URL, error) {
|
||||
if strings.Contains(raw, "://") {
|
||||
return url.Parse(raw)
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.HasPrefix(lower, "open.spotify.com/") ||
|
||||
strings.HasPrefix(lower, "play.spotify.com/") ||
|
||||
strings.HasPrefix(lower, "embed.spotify.com/") {
|
||||
return url.Parse("https://" + raw)
|
||||
}
|
||||
return url.Parse(raw)
|
||||
}
|
||||
|
||||
func spotifyHost(host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
host = strings.TrimPrefix(host, "www.")
|
||||
return host
|
||||
}
|
||||
|
||||
func parseSpotifyPath(path string) (ParsedSource, bool) {
|
||||
parts := pathSegments(path)
|
||||
if len(parts) == 0 {
|
||||
return ParsedSource{}, false
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(parts[0]), "intl-") {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if len(parts) > 0 && strings.EqualFold(parts[0], "embed") {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if len(parts) >= 4 && strings.EqualFold(parts[0], "user") && strings.EqualFold(parts[2], "playlist") && pathIDPattern.MatchString(parts[3]) {
|
||||
return ParsedSource{Type: "playlist", ID: parts[3]}, true
|
||||
}
|
||||
itemType := strings.ToLower(parts[0])
|
||||
if len(parts) >= 2 && validSpotifyType(itemType) && pathIDPattern.MatchString(parts[1]) {
|
||||
return ParsedSource{Type: itemType, ID: parts[1]}, true
|
||||
}
|
||||
return ParsedSource{}, false
|
||||
}
|
||||
|
||||
func pathSegments(path string) []string {
|
||||
rawParts := strings.Split(path, "/")
|
||||
parts := make([]string, 0, len(rawParts))
|
||||
for _, part := range rawParts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func canonicalURL(itemType, id string) string {
|
||||
return "https://open.spotify.com/" + itemType + "/" + id
|
||||
}
|
||||
|
||||
func validSpotifyType(value string) bool {
|
||||
switch value {
|
||||
case "track", "album", "playlist", "artist":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package spotify
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sourceType string
|
||||
value string
|
||||
wantType string
|
||||
wantID string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "track URL", sourceType: "url", value: "https://open.spotify.com/track/abc123XYZ?si=ignored", wantType: "track", wantID: "abc123XYZ"},
|
||||
{name: "intl track URL", sourceType: "url", value: "https://open.spotify.com/intl-cs/track/7tFiyTwD0nx5a1eklYtX2J?si=ignored", wantType: "track", wantID: "7tFiyTwD0nx5a1eklYtX2J"},
|
||||
{name: "embed URI URL", sourceType: "url", value: "https://embed.spotify.com/?uri=spotify:track:7tFiyTwD0nx5a1eklYtX2J", wantType: "track", wantID: "7tFiyTwD0nx5a1eklYtX2J"},
|
||||
{name: "album URI", sourceType: "url", value: "spotify:album:album123456", wantType: "album", wantID: "album123456"},
|
||||
{name: "album URL with inferred type", sourceType: "", value: "https://open.spotify.com/album/1GbtB4zTqAsyfZEsm1RZfx", wantType: "album", wantID: "1GbtB4zTqAsyfZEsm1RZfx"},
|
||||
{name: "playlist URL", sourceType: "playlist", value: "https://open.spotify.com/playlist/pl123456", wantType: "playlist", wantID: "pl123456"},
|
||||
{name: "legacy user playlist URL", sourceType: "url", value: "https://open.spotify.com/user/someone/playlist/pl123456", wantType: "playlist", wantID: "pl123456"},
|
||||
{name: "artist ID", sourceType: "artist", value: "artist123456", wantType: "artist", wantID: "artist123456"},
|
||||
{name: "invalid URL", sourceType: "url", value: "https://example.com/track/abc", wantErr: true},
|
||||
{name: "type mismatch", sourceType: "track", value: "https://open.spotify.com/album/abc123456", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseSource(tt.sourceType, tt.value)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parse source: %v", err)
|
||||
}
|
||||
if got.Type != tt.wantType || got.ID != tt.wantID {
|
||||
t.Fatalf("got type=%q id=%q, want type=%q id=%q", got.Type, got.ID, tt.wantType, tt.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user