Skip to content

Commit 2d53dd5

Browse files
program247365claude
andcommitted
feat(spotify): reconnect a dead session at track boundaries
librespot's core Session does not auto-reconnect: if the connection drops (laptop sleep/wake, network change, long stall) it shuts down and stays dead. Previously the session was cached in a OnceLock and reused forever, so after a drop every subsequent track failed. Keep the runtime in the OnceLock (it hosts the playing track's Player, so it must never be dropped) but move the Session into a Mutex<Option<Session>>. A new session() accessor checks is_invalid() and reconnects from cached credentials (no re-login — the cached credential is long-lived) before each track's resolve/open_playback. Playlists now self-heal across a connection blip at the next track. The currently-playing track can't be rescued mid-stream (its Player is bound to the old session), and a single track looping forever won't recover until restart, since it never reaches a new open_playback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent acbe52f commit 2d53dd5

2 files changed

Lines changed: 52 additions & 26 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,13 @@ uses librespot:
144144
- `src/spotify/mod.rs`
145145
- `is_spotify_url`, URL/URI parsing (`open.spotify.com/...`, `intl-xx`
146146
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`)
147+
- a shared runtime (`OnceLock`, never replaced — it hosts player tasks) plus a
148+
rebuildable `Session` (`Mutex<Option<Session>>`). `session()` reconnects from
149+
cached credentials when the previous session `is_invalid()` (sleep/wake,
150+
network change) — librespot's core `Session` does **not** auto-reconnect, so
151+
track transitions self-heal here. `login()` runs the `librespot-oauth`
152+
browser flow once. `Session::new` calls `Handle::current()`, so it must be
153+
built **inside** the runtime (`runtime.block_on`)
151154
- `resolve()``Vec<TrackInfo>` for a track, playlist, or album, fetching
152155
track metadata + album art concurrently (bounded batches). Album art is a
153156
public `i.scdn.co` JPEG keyed by file id, cached under `spotify/art/`
@@ -213,7 +216,7 @@ There are now two major UI modes:
213216
- the visualizer reads from `sample_buf: Arc<Mutex<VecDeque<f32>>>`
214217
- prefetch uses a background worker thread
215218
- 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`
219+
- Spotify owns a process-wide Tokio runtime in `src/spotify/` (`OnceLock`, never replaced — librespot's `Player` and the end-of-track loop listener run on it). The `Session` lives in a `Mutex<Option<Session>>` and is rebuilt by `session()` when `is_invalid()`, so a dropped connection reconnects at the next track (the currently-playing track's `Player` is bound to the old session and can't self-heal mid-stream; a single track looping forever won't recover until restart). The bridge's end-of-track listener is aborted when the `AudioPlayer`'s `SpotifyPlayback` drops, releasing the `Player`
217220
- 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`
218221

219222
## Notable Design Decisions

src/spotify/mod.rs

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
mod sink;
1414

1515
use std::path::{Path, PathBuf};
16-
use std::sync::{Arc, OnceLock};
16+
use std::sync::{Arc, Mutex, OnceLock};
1717
use std::time::Duration;
1818

1919
use color_eyre::eyre::{eyre, Result, WrapErr};
@@ -87,40 +87,41 @@ pub fn login() -> Result<()> {
8787
pub fn resolve(url: &str) -> Result<Vec<TrackInfo>> {
8888
let uri = parse_uri(url)?;
8989
let ctx = ctx()?;
90+
let session = session()?;
9091
match &uri {
9192
SpotifyUri::Track { .. } => {
9293
let info = ctx.runtime.block_on(async {
93-
let track = Track::get(&ctx.session, &uri)
94+
let track = Track::get(&session, &uri)
9495
.await
9596
.map_err(|e| eyre!("failed to fetch Spotify track metadata: {e}"))?;
9697
let track_uri = uri
9798
.to_uri()
9899
.map_err(|e| eyre!("failed to build Spotify track URI: {e}"))?;
99-
ensure_track_available(&ctx.session, &uri).await?;
100+
ensure_track_available(&session, &uri).await?;
100101
let thumbnail = download_cover(&HttpClient::new(), &track, &art_dir()?, 0).await;
101102
Ok::<_, color_eyre::eyre::Report>(track_info(track, track_uri, None, thumbnail))
102103
})?;
103104
Ok(vec![info])
104105
}
105106
SpotifyUri::Playlist { .. } => {
106107
let tracks = ctx.runtime.block_on(async {
107-
let playlist = Playlist::get(&ctx.session, &uri)
108+
let playlist = Playlist::get(&session, &uri)
108109
.await
109110
.map_err(|e| eyre!("failed to fetch Spotify playlist: {e}"))?;
110111
let name = Some(playlist.name().to_string());
111112
let uris: Vec<SpotifyUri> = playlist.tracks().cloned().collect();
112-
fetch_track_infos(&ctx.session, uris, name).await
113+
fetch_track_infos(&session, uris, name).await
113114
})?;
114115
ensure_nonempty(tracks, "playlist")
115116
}
116117
SpotifyUri::Album { .. } => {
117118
let tracks = ctx.runtime.block_on(async {
118-
let album = Album::get(&ctx.session, &uri)
119+
let album = Album::get(&session, &uri)
119120
.await
120121
.map_err(|e| eyre!("failed to fetch Spotify album: {e}"))?;
121122
let name = Some(album.name.clone());
122123
let uris: Vec<SpotifyUri> = album.tracks().cloned().collect();
123-
fetch_track_infos(&ctx.session, uris, name).await
124+
fetch_track_infos(&session, uris, name).await
124125
})?;
125126
ensure_nonempty(tracks, "album")
126127
}
@@ -292,19 +293,20 @@ pub fn open_playback(
292293
repeat: bool,
293294
) -> Result<(SpotifySource, SpotifyPlayback, u32, u16, Option<Duration>)> {
294295
let ctx = ctx()?;
296+
let session = session()?;
295297
let uri =
296298
SpotifyUri::from_uri(track_uri).map_err(|e| eyre!("invalid Spotify track URI: {e}"))?;
297299
let (spotify_sink, source, end_signal) = sink::bridge();
298300

299301
let (player, duration, listener) = ctx.runtime.block_on(async move {
300-
let duration = match Track::get(&ctx.session, &uri).await {
302+
let duration = match Track::get(&session, &uri).await {
301303
Ok(track) => Some(Duration::from_millis(track.duration.max(0) as u64)),
302304
Err(_) => None,
303305
};
304306

305307
let player = Player::new(
306308
PlayerConfig::default(),
307-
ctx.session.clone(),
309+
session,
308310
Box::new(NoOpVolume),
309311
move || Box::new(spotify_sink),
310312
);
@@ -346,41 +348,62 @@ pub fn open_playback(
346348
))
347349
}
348350

349-
/// Shared, connected librespot session plus the runtime its tasks run on.
351+
/// Shared librespot runtime plus a session that can be rebuilt on demand. The
352+
/// runtime is created once and never replaced — it hosts every track's player
353+
/// tasks, so dropping it would kill playback. Only the session is swapped when
354+
/// its connection dies.
350355
struct SpotifyCtx {
351356
runtime: Runtime,
352-
session: Session,
357+
session: Mutex<Option<Session>>,
353358
}
354359

355360
static CTX: OnceLock<SpotifyCtx> = OnceLock::new();
356361

357-
/// Lazily connect (once per process) and return the shared context. Calls
358-
/// after the first reuse the same connection.
362+
/// The shared runtime + session slot, created once per process. Does not
363+
/// connect — that happens lazily in [`session`].
359364
fn ctx() -> Result<&'static SpotifyCtx> {
360365
if let Some(ctx) = CTX.get() {
361366
return Ok(ctx);
362367
}
363-
let ctx = init_ctx()?;
368+
let ctx = SpotifyCtx {
369+
runtime: build_runtime()?,
370+
session: Mutex::new(None),
371+
};
364372
// A racing thread may have set it first; that's fine, drop ours.
365373
let _ = CTX.set(ctx);
366374
Ok(CTX.get().expect("context was just set"))
367375
}
368376

369-
fn init_ctx() -> Result<SpotifyCtx> {
370-
let runtime = build_runtime()?;
377+
/// A connected librespot session (a cheap `Arc` clone). Reconnects from cached
378+
/// credentials if the previous session died — e.g. after sleep/wake or a
379+
/// network change — so playback recovers at the next track instead of failing.
380+
/// librespot's core `Session` does not auto-reconnect, so we do it here. Called
381+
/// at each track boundary (resolve / open_playback), never inside an async
382+
/// context, so the blocking reconnect is safe.
383+
fn session() -> Result<Session> {
384+
let ctx = ctx()?;
385+
let mut slot = ctx.session.lock().unwrap();
386+
let needs_connect = slot.as_ref().map_or(true, |session| session.is_invalid());
387+
if needs_connect {
388+
*slot = Some(connect_session(&ctx.runtime)?);
389+
}
390+
Ok(slot.as_ref().expect("session connected above").clone())
391+
}
392+
393+
/// Connect a fresh session from cached OAuth credentials (no re-login needed —
394+
/// the cached credential is long-lived). `Session::new` calls
395+
/// `Handle::current()`, so it must be built inside the runtime.
396+
fn connect_session(runtime: &Runtime) -> Result<Session> {
371397
let cache = open_cache()?;
372398
let credentials = cache
373399
.credentials()
374400
.ok_or_else(|| eyre!("not logged in to Spotify — run `looper spotify login` first"))?;
375-
// `Session::new` calls `Handle::current()`, so build + connect it inside the
376-
// runtime context.
377-
let session = runtime
401+
runtime
378402
.block_on(async move {
379403
let session = Session::new(SessionConfig::default(), Some(cache));
380404
session.connect(credentials, true).await.map(|()| session)
381405
})
382-
.wrap_err("failed to connect to Spotify (is your Premium login still valid?)")?;
383-
Ok(SpotifyCtx { runtime, session })
406+
.wrap_err("failed to connect to Spotify (is your Premium login still valid?)")
384407
}
385408

386409
fn open_cache() -> Result<Cache> {

0 commit comments

Comments
 (0)