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