Skip to content

Fix/spotify feb 2026 api migration#64

Closed
lacymorrow wants to merge 81 commits intobjarneo:mainfrom
lacymorrow:fix/spotify-feb-2026-api-migration
Closed

Fix/spotify feb 2026 api migration#64
lacymorrow wants to merge 81 commits intobjarneo:mainfrom
lacymorrow:fix/spotify-feb-2026-api-migration

Conversation

@lacymorrow
Copy link
Copy Markdown
Contributor

No description provided.

lacymorrow and others added 30 commits March 3, 2026 15:41
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>
…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>
lacymorrow and others added 28 commits March 4, 2026 00:52
- 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>
@lacymorrow lacymorrow closed this Mar 7, 2026
@lacymorrow lacymorrow mentioned this pull request Mar 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant