705 lines
21 KiB
Go
705 lines
21 KiB
Go
//go:build !windows
|
|
|
|
package spotify
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
librespot "github.com/devgianlu/go-librespot"
|
|
"github.com/devgianlu/go-librespot/audio"
|
|
"github.com/gopxl/beep/v2"
|
|
|
|
"github.com/bjarneo/cliamp/applog"
|
|
"github.com/bjarneo/cliamp/playlist"
|
|
"github.com/bjarneo/cliamp/provider"
|
|
)
|
|
|
|
// Compile-time interface checks.
|
|
var (
|
|
_ provider.Searcher = (*SpotifyProvider)(nil)
|
|
_ provider.PlaylistWriter = (*SpotifyProvider)(nil)
|
|
_ provider.PlaylistCreator = (*SpotifyProvider)(nil)
|
|
_ provider.CustomStreamer = (*SpotifyProvider)(nil)
|
|
_ provider.Closer = (*SpotifyProvider)(nil)
|
|
)
|
|
|
|
// maxResponseBody limits JSON API responses to 10 MB.
|
|
// SpotifyProvider implements playlist.Provider using the Spotify Web API
|
|
// for playlist/track metadata and go-librespot for audio streaming.
|
|
// playlistCache holds a snapshot_id and the fetched tracks for a playlist,
|
|
// allowing us to skip re-fetching playlists that haven't changed.
|
|
type playlistCache struct {
|
|
snapshotID string
|
|
tracks []playlist.Track
|
|
}
|
|
|
|
type SpotifyProvider struct {
|
|
session *Session
|
|
clientID string
|
|
bitrate int
|
|
userID string // Spotify user ID, fetched lazily on first Playlists() call
|
|
meFetched bool // /v1/me has been attempted this session; suppresses retry on failure
|
|
mu sync.Mutex
|
|
trackCache map[string]*playlistCache // playlist ID → cache entry
|
|
authCancel context.CancelFunc // cancels any in-progress OAuth flow
|
|
|
|
// Playlist list cache to avoid redundant API calls on provider switch.
|
|
listCache []playlist.PlaylistInfo
|
|
listCacheAt time.Time
|
|
}
|
|
|
|
const playlistListCacheTTL = 5 * time.Minute
|
|
|
|
// New creates a SpotifyProvider. If session is nil, authentication is
|
|
// deferred until the user first selects the Spotify provider.
|
|
// bitrate sets the preferred Spotify stream quality in kbps (96, 160, or 320).
|
|
func New(session *Session, clientID string, bitrate int) *SpotifyProvider {
|
|
return &SpotifyProvider{
|
|
session: session,
|
|
clientID: clientID,
|
|
bitrate: bitrate,
|
|
trackCache: make(map[string]*playlistCache),
|
|
}
|
|
}
|
|
|
|
// ensureSession tries to create a session using stored credentials only
|
|
// (no browser). Returns playlist.ErrNeedsAuth if interactive sign-in is needed.
|
|
func (p *SpotifyProvider) ensureSession() error {
|
|
p.mu.Lock()
|
|
if p.session != nil {
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
clientID := p.clientID
|
|
p.mu.Unlock()
|
|
|
|
if clientID == "" {
|
|
return fmt.Errorf("spotify: no client ID available")
|
|
}
|
|
sess, err := NewSessionSilent(context.Background(), clientID)
|
|
if err != nil {
|
|
return playlist.ErrNeedsAuth
|
|
}
|
|
p.mu.Lock()
|
|
p.session = sess
|
|
p.resetSessionScopedStateLocked()
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Authenticate runs the interactive sign-in flow (opens browser, waits for callback).
|
|
// Any previous in-progress OAuth flow is cancelled first to free the callback port.
|
|
func (p *SpotifyProvider) Authenticate() error {
|
|
p.mu.Lock()
|
|
if p.session != nil {
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
if p.authCancel != nil {
|
|
p.authCancel()
|
|
p.authCancel = nil
|
|
}
|
|
clientID := p.clientID
|
|
p.mu.Unlock()
|
|
|
|
if clientID == "" {
|
|
return fmt.Errorf("spotify: no client ID available")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
p.mu.Lock()
|
|
p.authCancel = cancel
|
|
p.mu.Unlock()
|
|
|
|
sess, err := NewSession(ctx, clientID)
|
|
|
|
p.mu.Lock()
|
|
p.authCancel = nil
|
|
p.mu.Unlock()
|
|
cancel()
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.mu.Lock()
|
|
p.session = sess
|
|
p.resetSessionScopedStateLocked()
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Close releases the session if one was created.
|
|
func (p *SpotifyProvider) Close() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.authCancel != nil {
|
|
p.authCancel()
|
|
p.authCancel = nil
|
|
}
|
|
if p.session != nil {
|
|
p.session.Close()
|
|
p.session = nil
|
|
p.resetSessionScopedStateLocked()
|
|
}
|
|
}
|
|
|
|
// resetSessionScopedStateLocked clears /v1/me-derived caches when the session
|
|
// changes. p.mu must be held.
|
|
func (p *SpotifyProvider) resetSessionScopedStateLocked() {
|
|
p.userID = ""
|
|
p.meFetched = false
|
|
}
|
|
|
|
func (p *SpotifyProvider) Name() string { return "Spotify" }
|
|
|
|
// currentUserID returns the authenticated user's Spotify ID, fetched from
|
|
// /v1/me at most once per session. Failures are remembered so a network blip
|
|
// during the first call doesn't trigger a request on every later use.
|
|
// userID is used by playlistAccessible to filter playlists the user doesn't
|
|
// own (which 403 on Tracks() for dev-mode apps).
|
|
func (p *SpotifyProvider) currentUserID(ctx context.Context) string {
|
|
p.mu.Lock()
|
|
if p.meFetched {
|
|
id := p.userID
|
|
p.mu.Unlock()
|
|
return id
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
var me struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if resp, err := p.webAPI(ctx, "GET", "/v1/me", nil); err == nil {
|
|
_ = decodeBody(resp, &me)
|
|
}
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.userID = me.ID
|
|
p.meFetched = true
|
|
return p.userID
|
|
}
|
|
|
|
// Playlists returns the authenticated user's Spotify playlists.
|
|
// Only playlists owned by the user or marked as collaborative are returned;
|
|
// playlists saved from other users are excluded because the Spotify API
|
|
// returns 403 when trying to list their tracks.
|
|
func (p *SpotifyProvider) Playlists() ([]playlist.PlaylistInfo, error) {
|
|
if err := p.ensureSession(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
p.mu.Lock()
|
|
if p.listCache != nil && time.Since(p.listCacheAt) < playlistListCacheTTL {
|
|
cached := slices.Clone(p.listCache)
|
|
p.mu.Unlock()
|
|
return cached, nil
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
userID := p.currentUserID(ctx) // empty string if fetch fails → no filtering
|
|
|
|
var all []playlist.PlaylistInfo
|
|
offset := 0
|
|
limit := spotifyPlaylistPageSize
|
|
|
|
// List of Playlists only includes created playlists by the User.
|
|
// This doesn't include the 'Liked Songs' playlist.
|
|
resp, err := p.webAPI(ctx, "GET", "/v1/me/tracks", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("spotify: your music: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
Total int `json:"total"`
|
|
}
|
|
if err := decodeBody(resp, &result); err != nil {
|
|
return nil, fmt.Errorf("spotify: parse playlists: %w", err)
|
|
}
|
|
|
|
// Unfortunately, the Spotify API doesn't expose the localized display name.
|
|
// i.e. 'Liked Songs' or 'Lieblingssongs' etc.
|
|
// For the moment, "Your Music" must sufficice without adding a localization
|
|
// map.
|
|
all = append(all, playlist.PlaylistInfo{
|
|
ID: "YOUR MUSIC",
|
|
Name: "Your Music",
|
|
TrackCount: result.Total,
|
|
Section: "Library",
|
|
})
|
|
|
|
for {
|
|
query := url.Values{
|
|
"limit": {fmt.Sprintf("%d", limit)},
|
|
"offset": {fmt.Sprintf("%d", offset)},
|
|
// Include owner.id and collaborative to filter inaccessible playlists.
|
|
"fields": {"items(id,name,snapshot_id,collaborative,owner(id),items.total),total"},
|
|
}
|
|
|
|
resp, err := p.webAPI(ctx, "GET", "/v1/me/playlists", query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("spotify: list playlists: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
Items []spotifyPlaylistItem `json:"items"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := decodeBody(resp, &result); err != nil {
|
|
return nil, fmt.Errorf("spotify: parse playlists: %w", err)
|
|
}
|
|
|
|
p.mu.Lock()
|
|
for _, item := range result.Items {
|
|
if !playlistAccessible(item, userID) {
|
|
continue
|
|
}
|
|
count := 0
|
|
if item.Items != nil {
|
|
count = item.Items.Total
|
|
}
|
|
section := "Followed playlists"
|
|
if userID != "" && item.Owner.ID == userID {
|
|
section = "Your playlists"
|
|
}
|
|
all = append(all, playlist.PlaylistInfo{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
TrackCount: count,
|
|
Section: section,
|
|
})
|
|
// Update snapshot_id in cache; if it changed, invalidate cached tracks.
|
|
if cached, ok := p.trackCache[item.ID]; ok {
|
|
if cached.snapshotID != item.SnapshotID {
|
|
delete(p.trackCache, item.ID)
|
|
}
|
|
}
|
|
// Store snapshot_id for later cache checks in Tracks().
|
|
if _, ok := p.trackCache[item.ID]; !ok && item.SnapshotID != "" {
|
|
p.trackCache[item.ID] = &playlistCache{snapshotID: item.SnapshotID}
|
|
}
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
if offset+limit >= result.Total {
|
|
break
|
|
}
|
|
offset += limit
|
|
}
|
|
|
|
// Group playlists by section so the UI can emit one header per group.
|
|
// Library first, then owned, then followed; preserve API order within.
|
|
sectionOrder := map[string]int{
|
|
"Library": 0,
|
|
"Your playlists": 1,
|
|
"Followed playlists": 2,
|
|
}
|
|
sort.SliceStable(all, func(i, j int) bool {
|
|
return sectionOrder[all[i].Section] < sectionOrder[all[j].Section]
|
|
})
|
|
|
|
p.mu.Lock()
|
|
p.listCache = all
|
|
p.listCacheAt = time.Now()
|
|
p.mu.Unlock()
|
|
|
|
return slices.Clone(all), nil
|
|
}
|
|
|
|
// Tracks returns all tracks for the given Spotify playlist ID.
|
|
// Track.Path is set to the canonical spotify: URI for the player to resolve.
|
|
// Results are cached by snapshot_id; unchanged playlists skip the API call.
|
|
func (p *SpotifyProvider) Tracks(playlistID string) ([]playlist.Track, error) {
|
|
if err := p.ensureSession(); err != nil {
|
|
return nil, err
|
|
}
|
|
// Check cache — if we have tracks and the snapshot_id hasn't changed, return cached.
|
|
p.mu.Lock()
|
|
if cached, ok := p.trackCache[playlistID]; ok && cached.tracks != nil {
|
|
tracks := slices.Clone(cached.tracks)
|
|
p.mu.Unlock()
|
|
return tracks, nil
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
var all []playlist.Track
|
|
offset := 0
|
|
limit := spotifyTrackPageSize
|
|
|
|
for {
|
|
var (
|
|
resp *http.Response
|
|
err error
|
|
)
|
|
|
|
if playlistID == "YOUR MUSIC" {
|
|
query := url.Values{
|
|
"limit": {fmt.Sprintf("%d", limit)},
|
|
"offset": {fmt.Sprintf("%d", offset)},
|
|
}
|
|
resp, err = p.webAPI(ctx, "GET", "/v1/me/tracks", query)
|
|
} else {
|
|
query := url.Values{
|
|
"limit": {fmt.Sprintf("%d", limit)},
|
|
"offset": {fmt.Sprintf("%d", offset)},
|
|
"fields": {"items(item(id,name,type,uri,artists(name),album(name,release_date),show(name),release_date,duration_ms,track_number,is_playable,restrictions(reason))),total"},
|
|
}
|
|
path := fmt.Sprintf("/v1/playlists/%s/items", playlistID)
|
|
resp, err = p.webAPI(ctx, "GET", path, query)
|
|
}
|
|
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "403") {
|
|
return nil, fmt.Errorf("spotify: playlist not accessible: only playlists you own or collaborate on can be loaded")
|
|
}
|
|
return nil, fmt.Errorf("spotify: list tracks: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
Items []struct {
|
|
Item *spotifyItem `json:"item"`
|
|
Track *spotifyItem `json:"track"`
|
|
} `json:"items"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := decodeBody(resp, &result); err != nil {
|
|
return nil, fmt.Errorf("spotify: parse tracks: %w", err)
|
|
}
|
|
|
|
for _, item := range result.Items {
|
|
t := item.Item
|
|
if t == nil {
|
|
t = item.Track
|
|
}
|
|
if t == nil || t.ID == "" {
|
|
continue // skip local/unavailable tracks
|
|
}
|
|
all = append(all, trackFromItem(t))
|
|
}
|
|
|
|
if offset+limit >= result.Total {
|
|
break
|
|
}
|
|
offset += limit
|
|
}
|
|
|
|
// Cache the fetched tracks.
|
|
p.mu.Lock()
|
|
if cached, ok := p.trackCache[playlistID]; ok {
|
|
cached.tracks = all
|
|
} else {
|
|
p.trackCache[playlistID] = &playlistCache{tracks: all}
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
return slices.Clone(all), nil
|
|
}
|
|
|
|
// isAuthError returns true if the error is an authentication/session-related
|
|
// failure that can be resolved by re-authenticating.
|
|
func isAuthError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
// context.DeadlineExceeded and context.Canceled are NOT auth errors.
|
|
// They commonly fire during rapid track skipping when a previous NewStream's
|
|
// network fetch is interrupted, and previously caused spurious re-auth
|
|
// attempts (which then escalated to opening a browser tab mid-skip).
|
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
|
return false
|
|
}
|
|
var keyErr *audio.KeyProviderError
|
|
return errors.As(err, &keyErr)
|
|
}
|
|
|
|
// URISchemes returns the URI prefixes handled by this provider.
|
|
// Implements provider.CustomStreamer.
|
|
func (p *SpotifyProvider) URISchemes() []string { return []string{"spotify:"} }
|
|
|
|
// NewStreamer creates a SpotifyStreamer for the given spotify: URI (track or
|
|
// episode).
|
|
// If the stream fails due to an auth error (e.g. expired session, AES key
|
|
// rejection), the player tries a silent reconnect from cached credentials.
|
|
// If that fails — or the retry still hits an auth error — the streamer
|
|
// surfaces playlist.ErrNeedsAuth so the UI can prompt the user to sign in.
|
|
// We deliberately do NOT auto-launch a browser-based OAuth flow from this
|
|
// path: rapid track skipping can produce transient stream errors and a
|
|
// browser tab popping up mid-skip.
|
|
//
|
|
// Implements provider.CustomStreamer.
|
|
func (p *SpotifyProvider) NewStreamer(uri string) (beep.StreamSeekCloser, beep.Format, time.Duration, error) {
|
|
if err := p.ensureSession(); err != nil {
|
|
return nil, beep.Format{}, 0, err
|
|
}
|
|
spotID, err := librespot.SpotifyIdFromUri(uri)
|
|
if err != nil {
|
|
return nil, beep.Format{}, 0, fmt.Errorf("spotify: invalid URI %q: %w", uri, err)
|
|
}
|
|
|
|
tryStream := func() (*spotifyStreamer, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
stream, err := p.session.NewStream(ctx, *spotID, p.bitrate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return newSpotifyStreamer(stream), nil
|
|
}
|
|
|
|
s, err := tryStream()
|
|
if err == nil {
|
|
return s, s.Format(), s.Duration(), nil
|
|
}
|
|
if !isAuthError(err) {
|
|
return nil, beep.Format{}, 0, fmt.Errorf("spotify: new stream: %w", err)
|
|
}
|
|
|
|
// Auth error — try a silent reconnect from cached credentials.
|
|
applog.UserWarn("spotify: stream auth error (%v), attempting silent reconnect...", err)
|
|
|
|
reconnCtx, reconnCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
reconnErr := p.session.Reconnect(reconnCtx)
|
|
reconnCancel()
|
|
|
|
if reconnErr != nil {
|
|
applog.UserWarn("spotify: silent reconnect failed (%v); sign-in required", reconnErr)
|
|
return nil, beep.Format{}, 0, fmt.Errorf("spotify: stream auth error, silent reconnect failed: %w", playlist.ErrNeedsAuth)
|
|
}
|
|
|
|
s, err = tryStream()
|
|
if err == nil {
|
|
return s, s.Format(), s.Duration(), nil
|
|
}
|
|
if !isAuthError(err) {
|
|
return nil, beep.Format{}, 0, fmt.Errorf("spotify: new stream after silent reconnect: %w", err)
|
|
}
|
|
|
|
// Still failing after a silent reconnect — surface ErrNeedsAuth so the
|
|
// UI can prompt the user to sign in. Do NOT open a browser from here.
|
|
applog.UserWarn("spotify: stream still failing after silent reconnect (%v); sign-in required", err)
|
|
return nil, beep.Format{}, 0, fmt.Errorf("spotify: stream auth error after silent reconnect: %w", playlist.ErrNeedsAuth)
|
|
}
|
|
|
|
// webAPI calls the Spotify Web API via the session with retry on 429.
|
|
func (p *SpotifyProvider) webAPI(ctx context.Context, method, path string, query url.Values) (*http.Response, error) {
|
|
return p.webAPIWithBody(ctx, method, path, query, nil, "", http.StatusOK)
|
|
}
|
|
|
|
// webAPIWithBody is like webAPI but accepts an optional request body, content type,
|
|
// and a set of acceptable HTTP status codes (e.g. 200, 201). Retries 429 with
|
|
// exponential backoff (honoring Retry-After when present).
|
|
func (p *SpotifyProvider) webAPIWithBody(ctx context.Context, method, path string, query url.Values, body io.Reader, contentType string, acceptStatus ...int) (*http.Response, error) {
|
|
const maxRetries = 8
|
|
|
|
// Buffer the body so it can be replayed on retry.
|
|
var bodyBytes []byte
|
|
if body != nil {
|
|
var err error
|
|
bodyBytes, err = io.ReadAll(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read request body: %w", err)
|
|
}
|
|
}
|
|
|
|
for attempt := range maxRetries {
|
|
var reqBody io.Reader
|
|
if bodyBytes != nil {
|
|
reqBody = bytes.NewReader(bodyBytes)
|
|
}
|
|
|
|
resp, err := p.session.webApiWithBody(ctx, method, path, query, reqBody, contentType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
resp.Body.Close()
|
|
// On the last attempt there's no retry after the wait, so don't
|
|
// sleep (up to 128s) just to give up; fail now.
|
|
if attempt == maxRetries-1 {
|
|
break
|
|
}
|
|
wait := time.Duration(1<<uint(attempt)) * time.Second
|
|
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
|
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
|
|
wait = time.Duration(secs) * time.Second
|
|
}
|
|
}
|
|
applog.UserWarn("spotify: web api rate-limited on %s, retrying in %v (attempt %d/%d)", path, wait, attempt+1, maxRetries)
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(wait):
|
|
continue
|
|
}
|
|
}
|
|
|
|
ok := slices.Contains(acceptStatus, resp.StatusCode)
|
|
if !ok {
|
|
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
resp.Body.Close()
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("http status %s (failed to read body: %v)", resp.Status, readErr)
|
|
}
|
|
return nil, fmt.Errorf("http status %s: %s", resp.Status, string(respBody))
|
|
}
|
|
return resp, nil
|
|
}
|
|
return nil, fmt.Errorf("spotify: web api rate-limited on %s after %d retries (try re-authenticating)", path, maxRetries)
|
|
}
|
|
|
|
// friendlySearchError rewrites Spotify's misleading 400 "Invalid limit" reply
|
|
// from /v1/search into something a user can act on. Since Nov 27, 2024 Spotify
|
|
// returns this error for developer apps registered in Development Mode — the
|
|
// rest of the API (playback, playlists, library) keeps working, but the
|
|
// catalog endpoints (/v1/search etc.) are blocked. The limit value is fine.
|
|
func friendlySearchError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
msg := err.Error()
|
|
if strings.Contains(msg, "400") && strings.Contains(msg, "Invalid limit") {
|
|
return fmt.Errorf("spotify: search blocked — your client_id is too new. Spotify's Nov 27 2024 change blocks /v1/search for apps in Development Mode (the rest of cliamp still works on your app). Remove client_id from [spotify] in config.toml to use the built-in fallback for search, or apply for Extended Quota Mode")
|
|
}
|
|
return fmt.Errorf("spotify: search: %w", err)
|
|
}
|
|
|
|
// SearchTracks searches Spotify for tracks and podcast episodes, returning up
|
|
// to limit results of each. Episodes (e.g. podcasts) are routed through their
|
|
// spotify:episode: URI so they play correctly.
|
|
// limit is clamped to Spotify's accepted range of 1..50.
|
|
func (p *SpotifyProvider) SearchTracks(ctx context.Context, query string, limit int) ([]playlist.Track, error) {
|
|
if err := p.ensureSession(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if limit < 1 {
|
|
limit = 1
|
|
} else if limit > 50 {
|
|
limit = 50
|
|
}
|
|
|
|
// No market parameter: when the request carries a user OAuth token, Spotify
|
|
// implicitly scopes results to the account's country.
|
|
q := url.Values{
|
|
"q": {query},
|
|
"type": {"track,episode"},
|
|
"limit": {fmt.Sprintf("%d", limit)},
|
|
}
|
|
|
|
resp, err := p.webAPI(ctx, "GET", "/v1/search", q)
|
|
if err != nil {
|
|
return nil, friendlySearchError(err)
|
|
}
|
|
|
|
var result struct {
|
|
Tracks struct {
|
|
Items []*spotifyItem `json:"items"`
|
|
} `json:"tracks"`
|
|
Episodes struct {
|
|
Items []*spotifyItem `json:"items"`
|
|
} `json:"episodes"`
|
|
}
|
|
if err := decodeBody(resp, &result); err != nil {
|
|
return nil, fmt.Errorf("spotify: parse search: %w", err)
|
|
}
|
|
|
|
var tracks []playlist.Track
|
|
for _, items := range [][]*spotifyItem{result.Tracks.Items, result.Episodes.Items} {
|
|
for _, t := range items {
|
|
if t == nil || t.ID == "" {
|
|
continue // skip null/unavailable results
|
|
}
|
|
tracks = append(tracks, trackFromItem(t))
|
|
}
|
|
}
|
|
return tracks, nil
|
|
}
|
|
|
|
// AddTrackToPlaylist adds a track to an existing Spotify playlist.
|
|
// The track's Path is used as the Spotify URI (e.g. "spotify:track:..." or
|
|
// "spotify:episode:..."); the Spotify API accepts either.
|
|
// Implements provider.PlaylistWriter.
|
|
func (p *SpotifyProvider) AddTrackToPlaylist(ctx context.Context, playlistID string, track playlist.Track) error {
|
|
trackURI := track.Path
|
|
if err := p.ensureSession(); err != nil {
|
|
return err
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]any{"uris": []string{trackURI}})
|
|
path := fmt.Sprintf("/v1/playlists/%s/tracks", playlistID)
|
|
|
|
resp, err := p.webAPIWithBody(ctx, "POST", path, nil, bytes.NewReader(body), "application/json", http.StatusOK, http.StatusCreated)
|
|
if err != nil {
|
|
return fmt.Errorf("spotify: add track: %w", err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// Invalidate caches for this playlist.
|
|
p.mu.Lock()
|
|
delete(p.trackCache, playlistID)
|
|
p.listCache = nil
|
|
p.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreatePlaylist creates a new private Spotify playlist and returns its ID.
|
|
func (p *SpotifyProvider) CreatePlaylist(ctx context.Context, name string) (string, error) {
|
|
if err := p.ensureSession(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
userID := p.currentUserID(ctx)
|
|
if userID == "" {
|
|
return "", fmt.Errorf("spotify: could not determine user ID")
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]any{"name": name, "public": false})
|
|
path := fmt.Sprintf("/v1/users/%s/playlists", userID)
|
|
|
|
resp, err := p.webAPIWithBody(ctx, "POST", path, nil, bytes.NewReader(body), "application/json", http.StatusOK, http.StatusCreated)
|
|
if err != nil {
|
|
return "", fmt.Errorf("spotify: create playlist: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := decodeBody(resp, &result); err != nil {
|
|
return "", fmt.Errorf("spotify: parse created playlist: %w", err)
|
|
}
|
|
|
|
// Invalidate playlist list cache.
|
|
p.mu.Lock()
|
|
p.listCache = nil
|
|
p.mu.Unlock()
|
|
|
|
return result.ID, nil
|
|
}
|
|
|
|
// decodeBody reads and decodes a JSON response body, then closes it.
|
|
func decodeBody(resp *http.Response, v any) error {
|
|
defer resp.Body.Close()
|
|
return json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(v)
|
|
}
|