You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
- 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>
- 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
+
117
164
### Playback inputs
118
165
119
166
`PlaybackInput` currently supports:
120
167
121
168
-`File(PathBuf)` — local files and cached remote tracks
122
169
-`HttpStream { .. }` — direct HTTP-backed stream reader
123
170
-`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
124
174
125
175
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.
126
176
@@ -152,7 +202,7 @@ There are now two major UI modes:
152
202
153
203
- single local or remote track: `repeat_infinite()`
154
204
- 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
156
206
- remote playlists are re-resolved each full loop so expiring service URLs are less likely to be reused forever
157
207
158
208
### Threading model
@@ -163,6 +213,7 @@ There are now two major UI modes:
163
213
- the visualizer reads from `sample_buf: Arc<Mutex<VecDeque<f32>>>`
164
214
- prefetch uses a background worker thread
165
215
- 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`
166
217
- 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`
167
218
168
219
## Notable Design Decisions
@@ -179,6 +230,18 @@ There are now two major UI modes:
179
230
(`SessionOutcome::BackToHistory`) rather than exiting. Quitting playback with
180
231
`q` still exits the app (`SessionOutcome::Quit`). This is intentional — a
181
232
"jukebox historian" accumulates links that inevitably rot.
**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.
182
245
-`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`).
Copy file name to clipboardExpand all lines: README.md
+52-1Lines changed: 52 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -14,6 +14,7 @@ It supports:
14
14
- YouTube URLs (including live streams)
15
15
- SoundCloud URLs
16
16
- HypeM URLs
17
+
- Spotify tracks, playlists, and albums (Spotify Premium required)
17
18
- single tracks and playlists
18
19
- infinite looping for single tracks
19
20
- whole-playlist looping for playlists
@@ -23,7 +24,7 @@ It supports:
23
24
- default no-arg startup into playlist history
24
25
- SQLite-backed playback history and favorites
25
26
- 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`)
27
28
- animated terminal/tab title with playback, pause, and loading status
28
29
29
30
## Install
@@ -121,6 +122,26 @@ looper play --url "https://hypem.com/track/2gq0d/CHVRCHES+-+Clearest+Blue"
121
122
looper play --url "https://www.youtube.com/playlist?list=PLFgquLnL59alCl_2TQvOiD5Vgm1hCaGSI"
122
123
```
123
124
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
+
124
145
## How Remote Playback Works
125
146
126
147
- startup opens the local SQLite database, runs embedded migrations, and then begins loading playback
@@ -139,6 +160,33 @@ Current behavior is intentionally pragmatic:
139
160
fast with a helpful message instead of hanging
140
161
- SoundCloud and HypeM prefer a stream-first path and fall back to cached download when needed
141
162
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
+
142
190
## Data and Cache Locations
143
191
144
192
Remote tracks are cached locally after download:
@@ -148,6 +196,9 @@ Remote tracks are cached locally after download:
148
196
| macOS |`~/Library/Caches/sh.kbr.looper/`|
149
197
| Linux |`~/.cache/looper/`|
150
198
199
+
Spotify keeps its cached credentials, encrypted audio cache, and album art in a
200
+
`spotify/` subfolder of the cache directory above.
201
+
151
202
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.
152
203
153
204
- startup applies pending embedded migrations automatically — no manual steps needed when upgrading
0 commit comments