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
}
@@ -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)
}
})
}
}