Skip to content

Commit acbe52f

Browse files
program247365claude
andcommitted
feat(spotify): SP badge, unavailable-track modal, docs, vergen note
- Add an SP service badge in the playback header (Spotify green), alongside YT/SC/HM. - A directly-requested single Spotify track that's unavailable now shows the "track unavailable" modal instead of playing silence. resolve() probes librespot's AudioItem availability (which accounts for the user's country and relinked alternatives) and returns an error, which the existing resolve_url_with_startup -> Failed path turns into the modal. Playlist/album tracks are still dropped at resolve. The modal detail text is now service-neutral ("track", not "video"). - Document the vergen 9.0.6 pin in Cargo.toml and the Makefile so a future `cargo update` that reintroduces the build break is easy to fix. - Document Spotify throughout README.md and CLAUDE.md: the librespot model, Premium requirement, login flow, in-process decode, looping, album art, caches, and caveats. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 776fc1a commit acbe52f

7 files changed

Lines changed: 159 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,18 @@ But `--url` now accepts:
1818
- YouTube tracks and playlists
1919
- SoundCloud tracks and playlists
2020
- HypeM URLs
21+
- Spotify tracks, playlists, and albums (`https://open.spotify.com/...` or
22+
`spotify:...`), via librespot — **Spotify Premium required**
2123

2224
Behavior:
2325

2426
- local files: play directly
2527
- single tracks: loop forever
2628
- playlists: play each track once, then loop the whole playlist
2729

30+
Spotify needs a one-time `looper spotify login` (OAuth browser flow, credentials
31+
cached). See "Spotify playback model" below.
32+
2833
## Commands
2934

3035
```bash
@@ -50,13 +55,16 @@ cargo test
5055

5156
## External Runtime Dependencies
5257

53-
Remote playback requires:
58+
YouTube / SoundCloud / HypeM playback requires:
5459

5560
- `yt-dlp`
5661
- `ffmpeg`
5762

5863
If YouTube playback fails with `403`, updating `yt-dlp` is the first thing to try.
5964

65+
Spotify requires **neither** `yt-dlp` nor `ffmpeg` (librespot decodes in-process)
66+
— only a Spotify Premium account and a one-time `looper spotify login`.
67+
6068
## Install via Homebrew
6169

6270
```bash
@@ -77,6 +85,13 @@ Tap repo: https://github.com/program247365/homebrew-tap
7785
- `stream-download` — HTTP/process-backed stream readers
7886
- `tokio` — runtime used by streamed/process-backed audio inputs
7987
- `structopt` — CLI parsing
88+
- `librespot-core` / `-playback` / `-metadata` / `-oauth` — Spotify session,
89+
in-process decode, metadata, and OAuth login. `librespot-playback` is built
90+
with `default-features = false` so its bundled (older) rodio backend stays out
91+
of the tree — looper feeds a custom `Sink` instead. **`vergen` is pinned to
92+
9.0.6 in `Cargo.lock`**; a `cargo update` that pulls vergen 9.1.0 breaks
93+
librespot-core's build script (vergen-lib trait mismatch) — re-pin with
94+
`cargo update -p vergen --precise 9.0.6` (also noted in `Cargo.toml`/`Makefile`).
8095

8196
## Architecture
8297

@@ -90,7 +105,10 @@ Tap repo: https://github.com/program247365/homebrew-tap
90105
- `src/tui.rs` — playback TUI and loading TUI rendering
91106
- `src/download.rs` — loading/progress state models and helpers for formatting bytes/speed/ETA
92107
- `src/plugin/` — remote service resolution and `yt-dlp` integration
93-
- `src/playback_input.rs` — playback input abstraction (`File`, `HttpStream`, `ProcessStdout`) plus pending-download metadata
108+
- `src/spotify/` — Spotify via librespot: shared session, OAuth login, metadata
109+
resolution, the librespot-`Sink`→rodio-`Source` bridge. `main.rs` routes the
110+
`spotify login` subcommand here. See "Spotify playback model" below.
111+
- `src/playback_input.rs` — playback input abstraction (`File`, `HttpStream`, `ProcessStdout`, `Spotify`) plus pending-download metadata
94112

95113
### Remote playback model
96114

@@ -114,13 +132,45 @@ The project now uses a hybrid remote architecture.
114132
- `src/plugin/hypem.rs`
115133
- prefers stream-first resolution and falls back to download-first
116134

135+
`plugin::resolve_url` intercepts Spotify URLs/URIs **before** the `yt-dlp`
136+
availability check and dispatches to `crate::spotify::resolve`, so Spotify works
137+
without `yt-dlp` installed.
138+
139+
### Spotify playback model
140+
141+
Spotify is not a `yt-dlp` plugin — it has no downloadable audio. `src/spotify/`
142+
uses librespot:
143+
144+
- `src/spotify/mod.rs`
145+
- `is_spotify_url`, URL/URI parsing (`open.spotify.com/...`, `intl-xx`
146+
prefixes, `spotify:` URIs)
147+
- a shared, lazily-connected `Session` (`OnceLock`) built from cached OAuth
148+
credentials; `login()` runs the `librespot-oauth` browser flow once.
149+
`Session::new` calls `Handle::current()`, so it must be built **inside** the
150+
runtime (`runtime.block_on`)
151+
- `resolve()``Vec<TrackInfo>` for a track, playlist, or album, fetching
152+
track metadata + album art concurrently (bounded batches). Album art is a
153+
public `i.scdn.co` JPEG keyed by file id, cached under `spotify/art/`
154+
- `ensure_track_available()` uses librespot's `AudioItem` availability to fail
155+
a single unplayable track at resolve time, so `resolve_url_with_startup`
156+
surfaces the "track unavailable" modal instead of playing silence
157+
- `src/spotify/sink.rs` — the bridge. librespot's `Player` pushes decoded PCM
158+
into a custom `Sink`; a bounded channel carries it to a rodio `Source`. The
159+
sink blocks under backpressure (throttling the decoder to real time); the
160+
source yields silence on underrun. An `EndSignal` lets the source end on
161+
demand: single tracks loop forever (listener re-`load`s on `EndOfTrack`),
162+
playlist tracks finish so `play_loop`'s `sink.empty()` advances to the next.
163+
117164
### Playback inputs
118165

119166
`PlaybackInput` currently supports:
120167

121168
- `File(PathBuf)` — local files and cached remote tracks
122169
- `HttpStream { .. }` — direct HTTP-backed stream reader
123170
- `ProcessStdout { .. }` — process-backed stream through `stream-download`
171+
- `Spotify { track_uri }` — handled in `AudioPlayer::new` by the librespot
172+
bridge (`src/spotify/`); never reaches the file/stream reader path. Pause works
173+
via rodio backpressure; seek is a no-op
124174

125175
Note that YouTube is intentionally on the cached-file path right now because direct/process streaming proved less reliable than download-first with current `yt-dlp` behavior.
126176

@@ -152,7 +202,7 @@ There are now two major UI modes:
152202

153203
- single local or remote track: `repeat_infinite()`
154204
- playlist: play each track once, then loop the playlist
155-
- `PrefetchWorker` in `play_loop.rs` uses a bounded channel and background thread to cache current/next tracks where applicable
205+
- `PrefetchWorker` in `play_loop.rs` uses a bounded channel and background thread to cache current/next tracks where applicable. **Spotify tracks are skipped** by the prefetcher (they stream in-process via librespot and have no `source_url` to download), so there is a brief loading screen between Spotify playlist tracks
156206
- remote playlists are re-resolved each full loop so expiring service URLs are less likely to be reused forever
157207

158208
### Threading model
@@ -163,6 +213,7 @@ There are now two major UI modes:
163213
- the visualizer reads from `sample_buf: Arc<Mutex<VecDeque<f32>>>`
164214
- prefetch uses a background worker thread
165215
- some stream-backed audio inputs create a Tokio runtime inside `AudioPlayer`
216+
- Spotify owns a process-wide Tokio runtime + connected `Session` in `src/spotify/` (`OnceLock`); librespot's `Player` and the end-of-track loop listener run on it. The bridge's end-of-track listener is aborted when the `AudioPlayer`'s `SpotifyPlayback` drops, releasing the `Player`
166217
- media-key events from `souvlaki` arrive on the OS-specific thread (macOS: main / AppKit; Linux: souvlaki's own DBus thread) and are forwarded to the TUI thread via an `mpsc::Receiver<KeyCommand>` drained inside `run_loop`
167218

168219
## Notable Design Decisions
@@ -179,6 +230,18 @@ There are now two major UI modes:
179230
(`SessionOutcome::BackToHistory`) rather than exiting. Quitting playback with
180231
`q` still exits the app (`SessionOutcome::Quit`). This is intentional — a
181232
"jukebox historian" accumulates links that inevitably rot.
233+
- Spotify playback uses librespot (reverse-engineered Spotify Connect),
234+
**Premium-only**, with a custom librespot `Sink` feeding rodio so the
235+
visualizer keeps working — rather than librespot's own rodio backend (which
236+
would bypass the sample tap and also drag a second, incompatible rodio into
237+
the tree). `librespot-playback` is therefore `default-features = false`.
238+
- `vergen` is pinned to `9.0.6` (`Cargo.lock`) to keep librespot-core's build
239+
script compiling; see the note in `Cargo.toml`/`Makefile`. Re-pin after a
240+
`cargo update` with `cargo update -p vergen --precise 9.0.6` if the build
241+
fails with a vergen-lib trait mismatch.
242+
- a directly-requested **single** Spotify track that is unavailable is caught at
243+
resolve (`ensure_track_available`) so it shows the modal; unavailable tracks
244+
inside a playlist/album are silently dropped during concurrent metadata fetch.
182245
- `souvlaki` is wired with the `use_zbus` feature so Linux builds don't need `libdbus-1-dev`; macOS uses `MPRemoteCommandCenter` + `MPNowPlayingInfoCenter` directly. Windows is intentionally unwired (would need a hidden message-only HWND + a per-tick `pump_event_queue`).
183246

184247
## Tests

Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ rodio = { version = "0.22", default-features = false, features = ["playback", "m
2929
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
3030
structopt = "0.3.21"
3131
tokio = { version = "1", features = ["rt-multi-thread"] }
32+
# Spotify playback. `librespot-playback` has default-features off on purpose:
33+
# it keeps the bundled (older) rodio audio backend out of the tree so there is
34+
# only one rodio. We feed librespot's decoded PCM through our own custom Sink
35+
# (src/spotify/sink.rs) instead.
36+
#
37+
# NOTE: `vergen` is pinned to 9.0.6 in Cargo.lock. librespot-core's build script
38+
# pulls vergen-gitcl 1.0.8 (which needs vergen ^9.0.6), but vergen 9.1.0 bumps
39+
# its internal vergen-lib to 9.x and collides with vergen-gitcl's vergen-lib
40+
# 0.1.6, breaking the build. If `cargo update` ever reintroduces vergen 9.1.0,
41+
# re-pin with: cargo update -p vergen --precise 9.0.6
3242
librespot-core = "0.8"
3343
librespot-metadata = "0.8"
3444
librespot-oauth = "0.8"

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ help: ## Show available targets
1414
@awk 'BEGIN {FS = ":.*##"; printf "Usage: make \033[36m<target>\033[0m\n\nTargets:\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-16s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
1515

1616
# ── Local development ─────────────────────────────────────────────────────────
17+
#
18+
# NOTE: `vergen` is pinned to 9.0.6 in Cargo.lock to keep librespot-core's build
19+
# script compiling (see the note in Cargo.toml). If a build fails with a
20+
# vergen-lib trait mismatch after `cargo update`, re-pin with:
21+
# cargo update -p vergen --precise 9.0.6
1722

1823
build: ## Build debug binary
1924
cargo build

README.md

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ It supports:
1414
- YouTube URLs (including live streams)
1515
- SoundCloud URLs
1616
- HypeM URLs
17+
- Spotify tracks, playlists, and albums (Spotify Premium required)
1718
- single tracks and playlists
1819
- infinite looping for single tracks
1920
- whole-playlist looping for playlists
@@ -23,7 +24,7 @@ It supports:
2324
- default no-arg startup into playlist history
2425
- SQLite-backed playback history and favorites
2526
- remote download/loading UI with progress, speed, and ETA
26-
- small source badges in the TUI for supported services (`YT`, `SC`, `HM`)
27+
- small source badges in the TUI for supported services (`YT`, `SC`, `HM`, `SP`)
2728
- animated terminal/tab title with playback, pause, and loading status
2829

2930
## Install
@@ -121,6 +122,26 @@ looper play --url "https://hypem.com/track/2gq0d/CHVRCHES+-+Clearest+Blue"
121122
looper play --url "https://www.youtube.com/playlist?list=PLFgquLnL59alCl_2TQvOiD5Vgm1hCaGSI"
122123
```
123124

125+
### Spotify
126+
127+
Spotify requires a **Spotify Premium** account. Authorize looper once via your
128+
browser:
129+
130+
```shell
131+
looper spotify login
132+
```
133+
134+
This opens Spotify's authorization page, then caches credentials so you won't
135+
need to log in again. After that, play tracks, playlists, or albums:
136+
137+
```shell
138+
looper play --url "https://open.spotify.com/track/4PTG3Z6ehGkBFwjybzWkR8"
139+
looper play --url "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M"
140+
looper play --url "https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy"
141+
```
142+
143+
`spotify:` URIs work too. Spotify does **not** require `yt-dlp` or `ffmpeg`.
144+
124145
## How Remote Playback Works
125146

126147
- startup opens the local SQLite database, runs embedded migrations, and then begins loading playback
@@ -139,6 +160,33 @@ Current behavior is intentionally pragmatic:
139160
fast with a helpful message instead of hanging
140161
- SoundCloud and HypeM prefer a stream-first path and fall back to cached download when needed
141162

163+
## How Spotify Playback Works
164+
165+
Spotify is fundamentally different from the `yt-dlp`-backed services. Spotify
166+
exposes no downloadable audio, so looper uses
167+
[librespot](https://github.com/librespot-org/librespot) — the open-source
168+
implementation of the Spotify Connect protocol — to play full tracks. This is
169+
the same library [spotify-player](https://github.com/aome510/spotify-player)
170+
uses, and it has real constraints:
171+
172+
- **Premium is required.** librespot authenticates as a Spotify Connect device;
173+
free accounts can't stream through it.
174+
- **OAuth login, once.** `looper spotify login` runs an OAuth browser flow and
175+
caches reusable credentials under the cache directory. No password is stored.
176+
- **Audio is decoded in-process.** The DRM Ogg/Vorbis stream is decrypted and
177+
decoded by librespot, and its PCM is bridged straight into looper's audio
178+
pipeline and FFT visualizer. There is no MP3 on disk like the other services.
179+
- **Looping** re-loads the track when it ends (single track) or advances to the
180+
next track and loops the whole collection (playlist/album).
181+
- **Album art** is fetched from Spotify's public image CDN and shown in the
182+
visualizer, like the other services.
183+
- **Caveats:** there's a brief loading screen between playlist tracks (no
184+
background prefetch for Spotify), and an unavailable track (removed or
185+
region-locked) shows the "track unavailable" modal instead of playing.
186+
187+
Note: librespot is a reverse-engineered client. Using it is for personal use and
188+
is technically outside Spotify's Terms of Service.
189+
142190
## Data and Cache Locations
143191

144192
Remote tracks are cached locally after download:
@@ -148,6 +196,9 @@ Remote tracks are cached locally after download:
148196
| macOS | `~/Library/Caches/sh.kbr.looper/` |
149197
| Linux | `~/.cache/looper/` |
150198

199+
Spotify keeps its cached credentials, encrypted audio cache, and album art in a
200+
`spotify/` subfolder of the cache directory above.
201+
151202
Playback history and favorites live in a SQLite database (`looper.sqlite3`). Where it lives depends on your sync setup — see [Cross-Device Sync](#cross-device-sync) below.
152203

153204
- startup applies pending embedded migrations automatically — no manual steps needed when upgrading

src/play_loop.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ fn handle_unresolvable_replay(
296296
.title_for_replay_target(replay_target)?
297297
.unwrap_or_else(|| replay_target.to_string());
298298
let title = truncate_title(&title, 60);
299-
let detail = "This video may be private, removed, or region-locked.";
299+
let detail = "This track may be private, removed, or region-locked.";
300300
title_state.set("looper — track unavailable".to_string())?;
301301

302302
loop {

src/spotify/mod.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use librespot_core::cache::Cache;
2424
use librespot_core::config::SessionConfig;
2525
use librespot_core::session::Session;
2626
use librespot_core::spotify_uri::SpotifyUri;
27+
use librespot_metadata::audio::AudioItem;
2728
use librespot_metadata::{Album, Metadata, Playlist, Track};
2829
use librespot_oauth::OAuthClientBuilder;
2930
use librespot_playback::config::PlayerConfig;
@@ -95,6 +96,7 @@ pub fn resolve(url: &str) -> Result<Vec<TrackInfo>> {
9596
let track_uri = uri
9697
.to_uri()
9798
.map_err(|e| eyre!("failed to build Spotify track URI: {e}"))?;
99+
ensure_track_available(&ctx.session, &uri).await?;
98100
let thumbnail = download_cover(&HttpClient::new(), &track, &art_dir()?, 0).await;
99101
Ok::<_, color_eyre::eyre::Report>(track_info(track, track_uri, None, thumbnail))
100102
})?;
@@ -233,6 +235,28 @@ async fn download_cover(
233235
Some(target)
234236
}
235237

238+
/// Error if a track won't play for this account. librespot computes
239+
/// availability against the account's country and follows relinked
240+
/// alternatives, so this matches the player's own verdict: unavailable with no
241+
/// playable alternative. Surfacing it as an error lets the caller show the
242+
/// "track unavailable" modal instead of playing silence. Used only for a single
243+
/// directly-requested track; playlist/album tracks are dropped at resolve.
244+
async fn ensure_track_available(session: &Session, uri: &SpotifyUri) -> Result<()> {
245+
let item = AudioItem::get_file(session, uri.clone()).await.map_err(|_| {
246+
eyre!("this Spotify track couldn't be loaded (it may be removed or region-locked)")
247+
})?;
248+
let has_alternative = item
249+
.alternatives
250+
.as_ref()
251+
.is_some_and(|alts| !alts.is_empty());
252+
if item.availability.is_err() && !has_alternative {
253+
return Err(eyre!(
254+
"this Spotify track is unavailable (removed or region-locked)"
255+
));
256+
}
257+
Ok(())
258+
}
259+
236260
fn art_dir() -> Result<PathBuf> {
237261
let dir = crate::plugin::cache_dir_path()?.join("spotify").join("art");
238262
std::fs::create_dir_all(&dir).wrap_err("failed to create Spotify art cache directory")?;

src/tui.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,6 +1165,7 @@ fn service_badge(service: &Option<String>) -> Span<'static> {
11651165
Some("YouTube") => badge_span("YT", Color::Rgb(255, 90, 90)),
11661166
Some("SoundCloud") => badge_span("SC", Color::Rgb(255, 150, 50)),
11671167
Some("HypeM") => badge_span("HM", Color::Rgb(80, 200, 210)),
1168+
Some("Spotify") => badge_span("SP", Color::Rgb(30, 215, 96)),
11681169
Some("Online") => badge_span("ON", Color::Rgb(180, 180, 200)),
11691170
_ => Span::raw(""),
11701171
}

0 commit comments

Comments
 (0)