Fix/spotify feb 2026 api migration#64
Closed
lacymorrow wants to merge 81 commits intobjarneo:mainfrom
Closed
Conversation
Integrates Spotify streaming directly into cliamp's Beep audio pipeline, giving full EQ, visualizer, and gapless playback support for Spotify Premium accounts. Architecture: - external/spotify/streamer.go: Bridges go-librespot AudioSource (interleaved float32) to beep.StreamSeekCloser ([2]float64 pairs) - external/spotify/session.go: OAuth2 authentication with credential persistence in ~/.config/cliamp/spotify_credentials.json - external/spotify/provider.go: playlist.Provider using Spotify Web API for playlists/tracks, go-librespot player.NewStream for audio - player/player.go: StreamerFactory hook for custom URI schemes - player/pipeline.go: spotify:track:xxx URI detection and routing - config/config.go: [spotify] section with enabled flag Enable with `enabled = true` under `[spotify]` in config.toml. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… to avoid Client-Token header rate limits
…potifyTokenCredentials The spclient's internal login5 token gets aggressively rate-limited (429 with Retry-After: 86400) when used against api.spotify.com. Root cause: it's not a standard Web API token. Fix: Run our own OAuth2 flow with the same client_id/scopes, capture the access_token for Web API calls, and pass it to go-librespot via SpotifyTokenCredentials for session auth. Also: custom callback server serves HTML with window.close() script, fixing the browser tab not auto-closing after auth.
The go-librespot internal client_id (65b708073fc0480ea92a077233ca87bd) is shared across all librespot users and gets aggressively rate-limited for Web API calls (Retry-After: 86400). Now requires a registered Spotify Developer app: - client_id in config.toml [spotify] section - Fixed callback port 19872 for redirect URI - OAuth2 PKCE flow (no client_secret needed) Config: [spotify] enabled = true client_id = "your-client-id" Spotify Developer app redirect URI: http://127.0.0.1:19872/login
The bufio.Scanner goroutine for Enter-to-retry kept reading stdin after OAuth completed, stealing input from Bubbletea's raw terminal handler. Replaced with raw os.Stdin.Read + authDone channel to stop cleanly.
…each launch The spclient's login5 token gets 429'd on Web API. On second launch (stored credentials), we were trying refreshWebAPIToken which tested the spclient token — always fails. Now does a quick OAuth2 PKCE flow on each launch to get a fresh Web API token. Falls back to full interactive auth if the token flow fails.
go-librespot's Player.getUnrestrictedTrack dereferences CountryCode to check media restrictions. We never set it, causing a nil pointer panic. Default to 'US'.
- Set Stream=false on Spotify tracks — they're seekable, not live streams. The TUI's togglePlayPause treats Stream=true as live (stop+replay on resume), which was restarting songs from the beginning. - Expanded OAuth2 scopes to full standard Web API set (playlist modify, library modify, playback state, recently played, top tracks, follows). Internal Spotify scopes that cause 'Illegal scope' are documented and excluded. - Fetch user's country from /v1/me for accurate media restriction checks instead of hardcoding 'US'.
- Playlist view now fills available terminal height instead of being capped at 5 items. Recalculates on window resize. 'x' key toggles between compact (5) and full height. Minimum 3 items. - Persist OAuth2 refresh token in spotify_credentials.json. On subsequent launches, silently refresh the Web API token without opening a browser. Falls back to interactive auth if refresh fails. - Extract spotifyOAuthConfig() helper shared by doWebAPIAuth and silentTokenRefresh.
Add <meta charset="utf-8"> to both callback HTML pages so the checkmark emoji renders correctly in all browsers.
Previous calculation used 12 + vis.Rows for fixed UI lines, but missed: - Frame padding (2 lines from Padding(1,3)) - Controls render as 2 lines (VOL + EQ), not 1 Corrected to 17 + vis.Rows. Playlist no longer overflows the terminal.
Album separators (── Album Name (Year) ──) are rendered between tracks from different albums, taking extra lines. adjustScroll only counted track items, so the cursor would move past the visible area before scrolling kicked in. Now counts rendered lines (tracks + separators) to determine when to scroll.
The frame and panelWidth were hardcoded to 80/74 chars, causing the help bar to wrap to the next line on wider terminals. Now dynamically sizes to the terminal width on WindowSizeMsg. Album separators, seek bar, controls, and visualizer all scale to the available width.
Previous fix assumed every track had an album separator. Now uses renderedLineCount() helper that accurately counts lines (tracks + separators) for any range. adjustScroll walks backward from cursor to find the right scroll offset when mixed separator/no-separator tracks are present.
The manual fixed-line count (17 + vis.Rows) was wrong — missed various multi-line renders, frame padding, etc. Now renders all non-playlist sections into a probe frame and uses lipgloss.Height() to measure the actual pixel height. Guarantees plVisible matches the real available space regardless of theme, controls layout, or status lines.
Previous code had dynMax == plVisible always (broken comparison). Now toggles between 5 and full dynamic height using same probe measurement as WindowSizeMsg.
Probe measurement was consistently 1-2 lines optimistic, causing the bottom of the playlist to clip. Add a 1-line safety margin.
Root cause: renderPlaylist() did `visible := min(m.plVisible, len(tracks))` which conflated rendered line count with track count. When plVisible=15 and len(tracks)=8, visible was capped to 8 — but 8 tracks from different albums render as 16 lines (8 separators + 8 tracks), overflowing. Fix: - Remove min(plVisible, len(tracks)) — plVisible is the rendered line budget - Remove scroll clamping that used track count as line count - Add budget check before separator+track pair: if only 1 line left but need 2 (separator + track), break instead of overflowing - Remove -2 magic buffer from probe measurement — it's now exact - plVisible = height - lipgloss.Height(probeFrame) + 1 (no fudge factors)
- Use strconv.Atoi instead of fmt.Sscanf for year parsing - Add comment explaining Stream: false (pause/resume requires it) - Handle io.ReadAll error on non-OK HTTP response bodies - Log saveCreds errors instead of silently discarding - Log http.Serve errors (filter net.ErrClosed for clean shutdown) - Log country code fetch errors with stderr fallback - Add TODO for configurable bitrate
Integrates Spotify streaming directly into cliamp's Beep audio pipeline, giving full EQ, visualizer, and gapless playback support for Spotify Premium accounts. Architecture: - external/spotify/streamer.go: Bridges go-librespot AudioSource (interleaved float32) to beep.StreamSeekCloser ([2]float64 pairs) - external/spotify/session.go: OAuth2 authentication with credential persistence in ~/.config/cliamp/spotify_credentials.json - external/spotify/provider.go: playlist.Provider using Spotify Web API for playlists/tracks, go-librespot player.NewStream for audio - player/player.go: StreamerFactory hook for custom URI schemes - player/pipeline.go: spotify:track:xxx URI detection and routing - config/config.go: [spotify] section with enabled flag Enable with `enabled = true` under `[spotify]` in config.toml. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Added Spotify section with setup instructions (Developer app, config, OAuth) - Updated install methods: go install, pre-built binaries, build from source - Removed Homebrew tap update from release workflow (fork-specific)
The webAPIToken was set once during init and never refreshed. Spotify access tokens expire after 1 hour, causing all Web API calls to fail. Now uses oauth2.TokenSource which automatically refreshes the token using the stored refresh token when it expires. No more browser re-authentication after 1 hour — tokens refresh transparently in the background.
They were only visible in the Ctrl+K keymap overlay but not in the always-visible bottom controls line.
Saves shuffle and repeat preferences to config.toml when toggled via z/r keys, so they survive restarts across all providers.
They were at positions 23-24, below the 12-line visible window. Moved them right after volume controls so they're visible without scrolling.
Each help hint has a priority. When the combined width exceeds the terminal width, the lowest-priority hints are dropped first: 100 Spc(⏯) 95 Q(Quit) 90 <>(Trk) 80 +-(Vol) 70 ←→(Seek) 60 Ctrl+K(Keys) 50 Tab(Focus) 40 /(Search) 30 a(Queue) 20 z(Shfl) 20 r(Rpt) On a narrow terminal you still see play/pause, track nav, vol, and quit. On wide terminals everything shows.
Show a status message via saveMsg when persisting fails instead of silently ignoring the error. Addresses Gemini Code Assist review feedback.
When a track is already playing, selecting a new playlist now loads the tracks without stopping playback or auto-playing. Users can browse playlists freely while listening. Auto-play only triggers when nothing is currently playing.
Show shuffle/repeat keys in help bar and persist state
fix: don't auto-play when selecting Spotify playlist during playback
When go-librespot fails to retrieve an audio key (e.g. code 2 from Spotify's AP server due to expired/revoked session), the provider now: 1. Detects auth-related errors (KeyProviderError, DeadlineExceeded) 2. Tears down the dead session and clears stored credentials 3. Triggers a fresh OAuth2 interactive flow automatically 4. Retries the stream once with the new session This prevents users from getting stuck in an error loop — no manual credential deletion or CLI commands needed. Adds Session.Reconnect() for hot-swapping the session/player, and deleteCreds() to clear stale stored credentials.
Create the new session before tearing down the old one so s.sess and s.player are never nil while the mutex is unlocked. Old session/player are closed after the atomic swap completes. Addresses Gemini review comment on PR #4.
fix: auto re-auth on Spotify AES key / session errors
…ble, queue fixes Resolved conflicts: - config/config.go: kept both SpotifyConfig (ours) + ScrobbleEnabled (upstream) - ui/keys.go: kept our shuffle/repeat at top of keymap, took upstream 'Save/download' wording, added config import - ui/view.go: adopted upstream albumSeparator() helper with our budget overflow guard - ui/view.go: integrated upstream Find/Lyrics hints into our priority-based help bar
* origin/main: fix: avoid nil window in Reconnect (address review) fix: auto re-auth on Spotify AES key / session errors
Users no longer need to register a Spotify Developer app. When no client_id is configured, a random ID is selected from a built-in fallback pool to spread rate-limit load across apps. - Add external/spotify/fallback.go with FallbackClientID() - Add SpotifyConfig.ResolveClientID() with user > fallback priority - SpotifyConfig.IsSet() now returns true when enabled (even without client_id) - Update config.toml.example with Spotify section docs
Spotify deprecated several endpoints and renamed fields (effective Mar 9, 2026 for existing dev mode apps). See: https://developer.spotify.com/documentation/web-api/tutorials/february-2026-migration-guide Changes: - Playlist tracks endpoint: /playlists/{id}/tracks → /playlists/{id}/items - Playlist response field: track → item (with backwards-compat fallback) - Playlist metadata field: tracks.total → items.total (with fallback) - Remove GET /v1/me call for country (field removed from API), default to US - Remove user-read-email scope (email no longer returned by GET /me) - Remove unused 'io' import from session.go - Document removed fields in scope comments (popularity, available_markets, external_ids, country, followers, product)
…ching
Three optimizations to reduce rate limit pressure:
1. Use 'fields' parameter on both /me/playlists and /playlists/{id}/items
to request only the fields we actually parse. Smaller payloads, lower
API cost per call.
2. Cache playlist tracks by snapshot_id. When Playlists() is called, we
store each playlist's snapshot_id. If the snapshot hasn't changed on
the next Tracks() call, we return cached results without hitting the
API at all.
3. Include snapshot_id in playlist list fields so cache invalidation is
automatic — changed playlists get re-fetched, unchanged ones don't.
Ref: https://developer.spotify.com/documentation/web-api/concepts/rate-limits
Don't attempt OAuth flow if both user config and fallback pool are empty — prevents the broken authorize URL with empty client_id.
* feature/spotify-fallback-tokens: auto commit fix(spotify): skip session when no client ID is available feat(spotify): fallback client ID pool for zero-config setup
Upstream changes (bjarneo/cliamp c89d022..3745925): - README shortened, docs moved to docs/ directory - New docs: configuration, keybindings, lyrics, streaming, yt-dlp, telemetry - Spotify docs: playlist visibility notes, troubleshooting additions - Anonymous monthly telemetry ping (MAU counter, UUID + version only) - Homebrew dep updates (flac, libvorbis, libogg) Conflict resolutions: - release.yml: kept our goreleaser-based workflow (cleaner than upstream's manual homebrew script) - README.md: took upstream's shortened version (docs now in docs/)
Revert .github/workflows/release.yml and install.sh to upstream/main and remove the added .goreleaser.yml so this branch contains only the Spotify February 2026 API migration changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove dual field parsing (item/track), dual struct fields, nil-fallback logic, and the legacy tracks.total field from ?fields= params. Now exclusively uses the new Feb 2026 API field names (item, items.total). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.