|
13 | 13 | mod sink; |
14 | 14 |
|
15 | 15 | use std::path::{Path, PathBuf}; |
16 | | -use std::sync::{Arc, OnceLock}; |
| 16 | +use std::sync::{Arc, Mutex, OnceLock}; |
17 | 17 | use std::time::Duration; |
18 | 18 |
|
19 | 19 | use color_eyre::eyre::{eyre, Result, WrapErr}; |
@@ -87,40 +87,41 @@ pub fn login() -> Result<()> { |
87 | 87 | pub fn resolve(url: &str) -> Result<Vec<TrackInfo>> { |
88 | 88 | let uri = parse_uri(url)?; |
89 | 89 | let ctx = ctx()?; |
| 90 | + let session = session()?; |
90 | 91 | match &uri { |
91 | 92 | SpotifyUri::Track { .. } => { |
92 | 93 | let info = ctx.runtime.block_on(async { |
93 | | - let track = Track::get(&ctx.session, &uri) |
| 94 | + let track = Track::get(&session, &uri) |
94 | 95 | .await |
95 | 96 | .map_err(|e| eyre!("failed to fetch Spotify track metadata: {e}"))?; |
96 | 97 | let track_uri = uri |
97 | 98 | .to_uri() |
98 | 99 | .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?; |
100 | 101 | let thumbnail = download_cover(&HttpClient::new(), &track, &art_dir()?, 0).await; |
101 | 102 | Ok::<_, color_eyre::eyre::Report>(track_info(track, track_uri, None, thumbnail)) |
102 | 103 | })?; |
103 | 104 | Ok(vec![info]) |
104 | 105 | } |
105 | 106 | SpotifyUri::Playlist { .. } => { |
106 | 107 | let tracks = ctx.runtime.block_on(async { |
107 | | - let playlist = Playlist::get(&ctx.session, &uri) |
| 108 | + let playlist = Playlist::get(&session, &uri) |
108 | 109 | .await |
109 | 110 | .map_err(|e| eyre!("failed to fetch Spotify playlist: {e}"))?; |
110 | 111 | let name = Some(playlist.name().to_string()); |
111 | 112 | 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 |
113 | 114 | })?; |
114 | 115 | ensure_nonempty(tracks, "playlist") |
115 | 116 | } |
116 | 117 | SpotifyUri::Album { .. } => { |
117 | 118 | let tracks = ctx.runtime.block_on(async { |
118 | | - let album = Album::get(&ctx.session, &uri) |
| 119 | + let album = Album::get(&session, &uri) |
119 | 120 | .await |
120 | 121 | .map_err(|e| eyre!("failed to fetch Spotify album: {e}"))?; |
121 | 122 | let name = Some(album.name.clone()); |
122 | 123 | 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 |
124 | 125 | })?; |
125 | 126 | ensure_nonempty(tracks, "album") |
126 | 127 | } |
@@ -292,19 +293,20 @@ pub fn open_playback( |
292 | 293 | repeat: bool, |
293 | 294 | ) -> Result<(SpotifySource, SpotifyPlayback, u32, u16, Option<Duration>)> { |
294 | 295 | let ctx = ctx()?; |
| 296 | + let session = session()?; |
295 | 297 | let uri = |
296 | 298 | SpotifyUri::from_uri(track_uri).map_err(|e| eyre!("invalid Spotify track URI: {e}"))?; |
297 | 299 | let (spotify_sink, source, end_signal) = sink::bridge(); |
298 | 300 |
|
299 | 301 | 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 { |
301 | 303 | Ok(track) => Some(Duration::from_millis(track.duration.max(0) as u64)), |
302 | 304 | Err(_) => None, |
303 | 305 | }; |
304 | 306 |
|
305 | 307 | let player = Player::new( |
306 | 308 | PlayerConfig::default(), |
307 | | - ctx.session.clone(), |
| 309 | + session, |
308 | 310 | Box::new(NoOpVolume), |
309 | 311 | move || Box::new(spotify_sink), |
310 | 312 | ); |
@@ -346,41 +348,62 @@ pub fn open_playback( |
346 | 348 | )) |
347 | 349 | } |
348 | 350 |
|
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. |
350 | 355 | struct SpotifyCtx { |
351 | 356 | runtime: Runtime, |
352 | | - session: Session, |
| 357 | + session: Mutex<Option<Session>>, |
353 | 358 | } |
354 | 359 |
|
355 | 360 | static CTX: OnceLock<SpotifyCtx> = OnceLock::new(); |
356 | 361 |
|
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`]. |
359 | 364 | fn ctx() -> Result<&'static SpotifyCtx> { |
360 | 365 | if let Some(ctx) = CTX.get() { |
361 | 366 | return Ok(ctx); |
362 | 367 | } |
363 | | - let ctx = init_ctx()?; |
| 368 | + let ctx = SpotifyCtx { |
| 369 | + runtime: build_runtime()?, |
| 370 | + session: Mutex::new(None), |
| 371 | + }; |
364 | 372 | // A racing thread may have set it first; that's fine, drop ours. |
365 | 373 | let _ = CTX.set(ctx); |
366 | 374 | Ok(CTX.get().expect("context was just set")) |
367 | 375 | } |
368 | 376 |
|
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> { |
371 | 397 | let cache = open_cache()?; |
372 | 398 | let credentials = cache |
373 | 399 | .credentials() |
374 | 400 | .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 |
378 | 402 | .block_on(async move { |
379 | 403 | let session = Session::new(SessionConfig::default(), Some(cache)); |
380 | 404 | session.connect(credentials, true).await.map(|()| session) |
381 | 405 | }) |
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?)") |
384 | 407 | } |
385 | 408 |
|
386 | 409 | fn open_cache() -> Result<Cache> { |
|
0 commit comments