Skip to content

Commit 8d849a7

Browse files
program247365claude
andcommitted
feat(spotify): require user API app for search (client-credentials)
Spotify rejects Web API calls made with librespot-session tokens (429 on every endpoint; Mercury keymaster and searchview are retired), so search now uses SPOTIFY_CLIENT_ID/SPOTIFY_CLIENT_SECRET from the user's own free API app via the client-credentials flow, cached in-process. The search overlay shows setup instructions when the variables are missing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9a75333 commit 8d849a7

7 files changed

Lines changed: 181 additions & 46 deletions

File tree

CLAUDE.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,17 @@ uses librespot:
157157
- `ensure_track_available()` uses librespot's `AudioItem` availability to fail
158158
a single unplayable track at resolve time, so `resolve_url_with_startup`
159159
surfaces the "track unavailable" modal instead of playing silence
160-
- `src/spotify/search.rs` — catalog search via the public Web API (`/v1/search`),
161-
authorized with a bearer token minted from the librespot session
162-
(`session.token_provider().get_token(...)` — comma-separated scopes string).
163-
Returns `SearchResults { tracks, albums, playlists }` of `SearchItem`s whose
164-
`uri` is a valid `resolve()` target. Playlist `items` can contain literal
165-
`null`s (post-2024 API changes) — the parser filters them.
160+
- `src/spotify/search.rs` — catalog search via the public Web API
161+
(`/v1/search`). Spotify rejects Web API calls made with librespot-session
162+
tokens (429 on every endpoint; Mercury keymaster/searchview are retired), so
163+
search requires the user's own API app: `SPOTIFY_CLIENT_ID` /
164+
`SPOTIFY_CLIENT_SECRET` env vars feed a client-credentials token, cached
165+
in-process until shortly before expiry. Missing vars produce a multi-line
166+
setup-instructions error shown in the search overlay. Independent of the
167+
Premium login (search works without it; playback doesn't). Returns
168+
`SearchResults { tracks, albums, playlists }` of `SearchItem`s whose `uri`
169+
is a valid `resolve()` target. Playlist `items` can contain literal `null`s
170+
(post-2024 API changes) — the parser filters them.
166171
- `src/spotify/sink.rs` — the bridge. librespot's `Player` pushes decoded PCM
167172
into a custom `Sink`; a bounded channel carries it to a rodio `Source`. The
168173
sink blocks under backpressure (throttling the decoder to real time); the

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,17 @@ looper play --url "https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy"
4646
Press `f` for fullscreen, `space` to pause, `p` for history, `q` to quit. More
4747
examples and keybindings in [Usage & Keys](docs/usage.md).
4848

49+
Want in-app Spotify search (`/` to find and loop a song, album, or playlist)?
50+
That takes a free Spotify API app of your own — one-time setup in
51+
[Spotify → Search](docs/spotify.md#search-optional).
52+
4953
## Documentation
5054

5155
| Guide | What's inside |
5256
|-------|---------------|
5357
| [Installation](docs/installation.md) | Homebrew, build from source, runtime dependencies |
5458
| [Usage & Keys](docs/usage.md) | Every source, playlists, controls, the history panel |
55-
| [Spotify](docs/spotify.md) | Login, playback, and how librespot streaming works |
59+
| [Spotify](docs/spotify.md) | Login, playback, in-app search setup, and how librespot streaming works |
5660
| [Now Playing & Media Keys](docs/now-playing.md) | The OS media-session integration |
5761
| [How Playback Works](docs/playback.md) | The `yt-dlp` model, caching, and quirks |
5862
| [Cross-Device Sync](docs/sync.md) | Share history across machines via a cloud folder |

docs/spotify.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,35 @@ looper play --url "https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy"
2525

2626
`spotify:` URIs work too. Spotify does **not** require `yt-dlp` or `ffmpeg`.
2727

28+
## Search (optional)
29+
30+
Press `/` during playback or in the history browser to search Spotify's catalog
31+
and play a song, album, or playlist without leaving the terminal — see
32+
[Usage & Keys](usage.md) for the keybindings.
33+
34+
Search talks to Spotify's Web API, which requires **your own (free) API app**.
35+
Spotify blocks Web API calls made with librespot's shared client id, so the
36+
playback login above is not enough. One-time setup:
37+
38+
1. Open the [Spotify developer dashboard](https://developer.spotify.com/dashboard)
39+
and create an app (any name; the redirect URI is not used by looper's
40+
search — enter anything valid, e.g. `http://127.0.0.1:8898/login`).
41+
2. Copy the app's **Client ID** and **Client Secret**.
42+
3. Export them in your shell profile:
43+
44+
```shell
45+
export SPOTIFY_CLIENT_ID="your-client-id"
46+
export SPOTIFY_CLIENT_SECRET="your-client-secret"
47+
```
48+
49+
looper exchanges them for a catalog-only token (client-credentials flow — no
50+
browser login, no account data access) and caches it in memory for its ~1 hour
51+
lifetime. If the variables aren't set, pressing `/` still works: the overlay
52+
explains this setup instead of searching.
53+
54+
Searching needs only these variables; *playing* a result still needs the
55+
Premium login above.
56+
2857
## How Spotify playback works
2958

3059
Spotify is fundamentally different from the `yt-dlp`-backed services. It exposes

docs/superpowers/specs/2026-07-03-spotify-search-design.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
# Spotify Search Overlay
22

33
**Date:** 2026-07-03
4-
**Status:** Approved
4+
**Status:** Approved (amended 2026-07-05)
5+
6+
> **Amendment (2026-07-05):** the "mint a Web API token from the librespot
7+
> session" plan failed in live testing — Spotify's Mercury keymaster endpoint
8+
> is retired (403), and login5 tokens get 429 on every `api.spotify.com`
9+
> endpoint (with or without a `client-token` attestation header); Mercury
10+
> `searchview` is 404. Search instead uses the client-credentials flow with a
11+
> user-provided free Spotify API app (`SPOTIFY_CLIENT_ID` /
12+
> `SPOTIFY_CLIENT_SECRET` env vars), with the token cached in-process for its
13+
> ~1h lifetime. Missing credentials render setup instructions in the overlay.
14+
> Search is now independent of the Premium login (needed only for playback).
515
616
## Summary
717

docs/usage.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,11 @@ Sort fields: time played, last played, platform, title, times played.
107107
## Spotify search
108108

109109
`/` opens a Spotify catalog search from the playback screen or the history
110-
browser (requires the one-time `looper spotify login`). Type a query and press
111-
`Enter`; results are grouped into SONGS, ALBUMS, and PLAYLISTS.
110+
browser. It needs a one-time setup — your own free Spotify API app in
111+
`SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` — see
112+
[Spotify → Search](spotify.md#search-optional); without it the overlay shows
113+
the setup steps. Type a query and press `Enter`; results are grouped into
114+
SONGS, ALBUMS, and PLAYLISTS.
112115

113116
| Key | Action |
114117
|-----|--------|

src/spotify/search.rs

Lines changed: 109 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
//! Spotify catalog search via the public Web API.
22
//!
33
//! Playback and metadata go through librespot's own protocols, but librespot
4-
//! exposes no search. The Web API's `/v1/search` does, and the librespot
5-
//! session can mint a bearer token for it — so search needs no second login
6-
//! and no developer app.
4+
//! exposes no search, and Spotify rejects Web API calls made with tokens from
5+
//! librespot's shared client id (every endpoint answers 429; the Mercury
6+
//! keymaster and searchview routes are retired outright). Search therefore
7+
//! needs the user's own — free — Spotify API app: `SPOTIFY_CLIENT_ID` and
8+
//! `SPOTIFY_CLIENT_SECRET` feed a client-credentials token (no browser flow,
9+
//! no user context), cached in-process for its ~1h lifetime.
10+
11+
use std::sync::Mutex;
12+
use std::time::{Duration, Instant};
713

814
use color_eyre::eyre::{eyre, Result, WrapErr};
915
use serde::Deserialize;
10-
use stream_download::http::reqwest::Client as HttpClient;
1116

1217
/// Display limits per section (spec: 8 tracks, 5 albums, 5 playlists).
1318
const TRACK_LIMIT: usize = 8;
@@ -32,34 +37,98 @@ pub struct SearchItem {
3237
pub uri: String,
3338
}
3439

35-
/// Search Spotify's catalog. Blocking: runs one Web API request on the shared
36-
/// Spotify runtime (~300ms). Requires a prior `looper spotify login`.
40+
/// Shown in the search overlay when the API app env vars are missing.
41+
const SETUP_HELP: &str = "Spotify search needs your own (free) Spotify API app:\n 1. create an app at developer.spotify.com/dashboard\n 2. export SPOTIFY_CLIENT_ID=... and SPOTIFY_CLIENT_SECRET=...\n 3. restart looper\nPlayback is unaffected — see the README's \"Spotify search\" section.";
42+
43+
/// Search Spotify's catalog. Blocking (~300ms), called from the TUI thread
44+
/// after a "searching…" frame has been drawn. Requires `SPOTIFY_CLIENT_ID` /
45+
/// `SPOTIFY_CLIENT_SECRET`; does NOT require the librespot Premium login.
3746
pub fn search(query: &str) -> Result<SearchResults> {
38-
let ctx = super::ctx()?;
39-
let session = super::session()?;
40-
let body = ctx.runtime.block_on(async move {
41-
let token = session
42-
.token_provider()
43-
.get_token("user-read-private,playlist-read-private")
44-
.await
45-
.map_err(|e| eyre!("failed to get Spotify API token: {e}"))?;
46-
let response = HttpClient::new()
47-
.get("https://api.spotify.com/v1/search")
48-
.bearer_auth(&token.access_token)
49-
.query(&[("q", query), ("type", "track,album,playlist"), ("limit", "8")])
50-
.send()
51-
.await
52-
.map_err(|e| eyre!("Spotify search request failed: {e}"))?
53-
.error_for_status()
54-
.map_err(|e| eyre!("Spotify search failed: {e}"))?;
55-
response
56-
.text()
57-
.await
58-
.map_err(|e| eyre!("failed to read Spotify search response: {e}"))
59-
})?;
47+
let token = search_token()?;
48+
let response = reqwest::blocking::Client::new()
49+
.get("https://api.spotify.com/v1/search")
50+
.bearer_auth(&token)
51+
.query(&[("q", query), ("type", "track,album,playlist"), ("limit", "8")])
52+
.send()
53+
.map_err(|e| eyre!("Spotify search request failed: {e}"))?
54+
.error_for_status()
55+
.map_err(|e| eyre!("Spotify search failed: {e}"))?;
56+
let body = response
57+
.text()
58+
.map_err(|e| eyre!("failed to read Spotify search response: {e}"))?;
6059
parse_search_response(&body)
6160
}
6261

62+
struct CachedToken {
63+
access_token: String,
64+
expires_at: Instant,
65+
}
66+
67+
static TOKEN: Mutex<Option<CachedToken>> = Mutex::new(None);
68+
69+
/// A valid client-credentials bearer token, fetched with the user's API app
70+
/// and reused until shortly before expiry (~1h).
71+
fn search_token() -> Result<String> {
72+
let mut slot = TOKEN.lock().unwrap();
73+
if let Some(cached) = slot.as_ref() {
74+
if Instant::now() < cached.expires_at {
75+
return Ok(cached.access_token.clone());
76+
}
77+
}
78+
79+
let (client_id, client_secret) = search_app_credentials(
80+
std::env::var("SPOTIFY_CLIENT_ID").ok(),
81+
std::env::var("SPOTIFY_CLIENT_SECRET").ok(),
82+
)?;
83+
84+
#[derive(Deserialize)]
85+
struct TokenResponse {
86+
access_token: String,
87+
expires_in: u64,
88+
}
89+
90+
let body = reqwest::blocking::Client::new()
91+
.post("https://accounts.spotify.com/api/token")
92+
.basic_auth(&client_id, Some(&client_secret))
93+
.form(&[("grant_type", "client_credentials")])
94+
.send()
95+
.map_err(|e| eyre!("Spotify token request failed: {e}"))?
96+
.error_for_status()
97+
.map_err(|e| {
98+
eyre!("Spotify rejected your API app credentials ({e}) — check SPOTIFY_CLIENT_ID / SPOTIFY_CLIENT_SECRET")
99+
})?
100+
.text()
101+
.map_err(|e| eyre!("failed to read Spotify token response: {e}"))?;
102+
let response: TokenResponse =
103+
serde_json::from_str(&body).wrap_err("unexpected Spotify token response")?;
104+
105+
// Refresh a minute early so an in-flight search never carries a token
106+
// that expires mid-request.
107+
let expires_at =
108+
Instant::now() + Duration::from_secs(response.expires_in.saturating_sub(60).max(60));
109+
let token = response.access_token.clone();
110+
*slot = Some(CachedToken {
111+
access_token: response.access_token,
112+
expires_at,
113+
});
114+
Ok(token)
115+
}
116+
117+
/// Validate the env-var pair, pointing at the setup instructions when absent.
118+
/// Split out from `search_token` so the guidance path is testable without
119+
/// touching process-global env state.
120+
fn search_app_credentials(
121+
client_id: Option<String>,
122+
client_secret: Option<String>,
123+
) -> Result<(String, String)> {
124+
match (client_id, client_secret) {
125+
(Some(id), Some(secret)) if !id.trim().is_empty() && !secret.trim().is_empty() => {
126+
Ok((id, secret))
127+
}
128+
_ => Err(eyre!("{SETUP_HELP}")),
129+
}
130+
}
131+
63132
#[derive(Deserialize)]
64133
struct ApiResponse {
65134
#[serde(default)]
@@ -197,7 +266,18 @@ fn parse_search_response(body: &str) -> Result<SearchResults> {
197266
mod tests {
198267
use super::*;
199268

200-
// Live network + login required: cargo test search_smoke -- --ignored
269+
#[test]
270+
fn missing_credentials_point_at_setup_docs() {
271+
let err = search_app_credentials(None, None).unwrap_err();
272+
assert!(err.to_string().contains("developer.spotify.com"));
273+
assert!(err.to_string().contains("SPOTIFY_CLIENT_ID"));
274+
// A set-but-empty variable gets the same guidance.
275+
let err = search_app_credentials(Some("id".into()), Some(" ".into())).unwrap_err();
276+
assert!(err.to_string().contains("developer.spotify.com"));
277+
}
278+
279+
// Live network + SPOTIFY_CLIENT_ID/SPOTIFY_CLIENT_SECRET required:
280+
// cargo test search_smoke -- --ignored
201281
#[test]
202282
#[ignore]
203283
fn search_smoke() {

src/tui.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,13 +1372,17 @@ pub fn draw_search_overlay(frame: &mut ratatui::Frame, panel: &SearchPanelState)
13721372
return;
13731373
}
13741374
SearchStatus::Error(message) => {
1375-
frame.render_widget(
1376-
Paragraph::new(vec![Line::from(Span::styled(
1377-
message.clone(),
1378-
Style::default().fg(Color::Rgb(230, 130, 130)),
1379-
))]),
1380-
chunks[2],
1381-
);
1375+
// Multi-line: the missing-credentials message carries setup steps.
1376+
let lines: Vec<Line> = message
1377+
.lines()
1378+
.map(|line| {
1379+
Line::from(Span::styled(
1380+
line.to_string(),
1381+
Style::default().fg(Color::Rgb(230, 130, 130)),
1382+
))
1383+
})
1384+
.collect();
1385+
frame.render_widget(Paragraph::new(lines), chunks[2]);
13821386
return;
13831387
}
13841388
SearchStatus::Idle => {}

0 commit comments

Comments
 (0)