Skip to content

Commit 3388445

Browse files
fix: save and restore playback state across session reconnects
When SpircTask exits due to session loss, it now saves its ConnectState, SpircPlayStatus, and play_request_id into a SavedPlaybackState. main.rs captures this and passes it to Spirc::with_saved_state() when creating the replacement Spirc. The restored SpircTask starts with the saved state. On the first connection_id_update, it updates the playback position to account for elapsed time and re-registers with Spotify showing the correct track and position. The Player is never interrupted. After fix — 6 days of logs (Mar 14-20) showing 5 TCP session losses all recovered with playback state preserved: Mar 18 11:49 — "Connection to server closed." Mar 18 11:52 — "session lost, saving playback state for recovery: Playing { nominal_start_time: 1773834169899, ... }" Mar 18 11:52 — "Spirc shut down with saved playback state, reconnecting" Mar 18 11:52 — "Spirc[1] restoring saved playback state" Mar 18 11:52 — "re-registering with active playback state: Playing { nominal_start_time: 1773834169899, ... }" [3 second recovery, playback never stopped] Mar 19 12:21-12:37 — Two session losses during active playback, both recovered in ~2 seconds with Playing state preserved. Summary over 6 days post-fix (Mar 14-20): - 0 "Spirc shut down unexpectedly" (vs ~2-3/day before fix) - 0 process restarts needed - 5 session TCP losses, all recovered with state preserved - ~20 dealer reconnects, all handled in-place - Process running continuously (Spirc counter reached Spirc[4]) Co-authored-by: Copilot <[email protected]>
1 parent 18eb5be commit 3388445

4 files changed

Lines changed: 86 additions & 21 deletions

File tree

connect/src/model.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use crate::{
2-
core::dealer::protocol::SkipTo, protocol::context_player_options::ContextPlayerOptionOverrides,
2+
core::dealer::protocol::SkipTo,
3+
protocol::context_player_options::ContextPlayerOptionOverrides,
4+
state::ConnectState,
35
};
46

57
use std::ops::Deref;
@@ -165,3 +167,11 @@ pub(super) enum SpircPlayStatus {
165167
preloading_of_next_track_triggered: bool,
166168
},
167169
}
170+
171+
/// Playback state saved across session reconnects so the new SpircTask
172+
/// can resume where the old one left off.
173+
pub struct SavedPlaybackState {
174+
pub(super) connect_state: ConnectState,
175+
pub(super) play_status: SpircPlayStatus,
176+
pub(super) play_request_id: Option<u64>,
177+
}

connect/src/spirc.rs

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use crate::{
1111
session::UserAttributes,
1212
spclient::TransferRequest,
1313
},
14-
model::{LoadRequest, PlayingTrack, SpircPlayStatus},
14+
model::{LoadRequest, PlayingTrack, SavedPlaybackState, SpircPlayStatus},
1515
playback::{
1616
mixer::Mixer,
1717
player::{Player, PlayerEvent, PlayerEventChannel, QueueTrack},
@@ -37,6 +37,7 @@ use librespot_protocol::context_page::ContextPage;
3737
use protobuf::MessageField;
3838
use std::{
3939
future::Future,
40+
mem,
4041
sync::Arc,
4142
sync::atomic::{AtomicUsize, Ordering},
4243
time::{Duration, SystemTime, UNIX_EPOCH},
@@ -163,7 +164,23 @@ impl Spirc {
163164
credentials: Credentials,
164165
player: Arc<Player>,
165166
mixer: Arc<dyn Mixer>,
166-
) -> Result<(Spirc, impl Future<Output = ()>), Error> {
167+
) -> Result<(Spirc, impl Future<Output = Option<SavedPlaybackState>>), Error> {
168+
Self::with_saved_state(config, session, credentials, player, mixer, None).await
169+
}
170+
171+
/// Like [`Spirc::new`], but restores playback state from a previous session.
172+
///
173+
/// When `saved_state` is provided, the new SpircTask picks up where the
174+
/// old one left off — same track, position, and connect state — so the
175+
/// Player can continue without interruption after a session reconnect.
176+
pub async fn with_saved_state(
177+
config: ConnectConfig,
178+
session: Session,
179+
credentials: Credentials,
180+
player: Arc<Player>,
181+
mixer: Arc<dyn Mixer>,
182+
saved_state: Option<SavedPlaybackState>,
183+
) -> Result<(Spirc, impl Future<Output = Option<SavedPlaybackState>>), Error> {
167184
fn extract_connection_id(msg: Message) -> Result<String, Error> {
168185
let connection_id = msg
169186
.headers
@@ -176,7 +193,17 @@ impl Spirc {
176193
debug!("new Spirc[{spirc_id}]");
177194

178195
let emit_set_queue_events = config.emit_set_queue_events;
179-
let connect_state = ConnectState::new(config, &session);
196+
197+
let (connect_state, play_status, play_request_id) = match saved_state {
198+
Some(saved) => {
199+
info!("Spirc[{spirc_id}] restoring saved playback state");
200+
let mut cs = saved.connect_state;
201+
// Update to the new session's ID so Spotify sees us as the same device.
202+
cs.set_session_id(session.session_id());
203+
(cs, saved.play_status, saved.play_request_id)
204+
}
205+
None => (ConnectState::new(config, &session), SpircPlayStatus::Stopped, None),
206+
};
180207

181208
let connection_id_update = session
182209
.dealer()
@@ -235,8 +262,8 @@ impl Spirc {
235262
connect_state,
236263
connect_established: false,
237264

238-
play_request_id: None,
239-
play_status: SpircPlayStatus::Stopped,
265+
play_request_id,
266+
play_status,
240267

241268
connection_id_update,
242269
connect_state_update,
@@ -438,7 +465,7 @@ impl Spirc {
438465
}
439466

440467
impl SpircTask {
441-
async fn run(mut self) {
468+
async fn run(mut self) -> Option<SavedPlaybackState> {
442469
// simplify unwrapping of received item or parsed result
443470
macro_rules! unwrap {
444471
( $next:expr, |$some:ident| $use_some:expr ) => {
@@ -463,7 +490,7 @@ impl SpircTask {
463490

464491
if let Err(why) = self.session.dealer().start().await {
465492
error!("starting dealer failed: {why}");
466-
return;
493+
return None;
467494
}
468495

469496
while !self.session.is_invalid() && !self.shutdown {
@@ -603,8 +630,12 @@ impl SpircTask {
603630
// Session TCP connection died — skip server communication that
604631
// would fail anyway. The Player continues playing from its
605632
// buffer independently; main.rs will create a new session.
606-
warn!("session lost, skipping server cleanup");
607-
return;
633+
warn!("session lost, saving playback state for recovery: {:?}", self.play_status);
634+
return Some(SavedPlaybackState {
635+
connect_state: mem::take(&mut self.connect_state),
636+
play_status: mem::replace(&mut self.play_status, SpircPlayStatus::Stopped),
637+
play_request_id: self.play_request_id.take(),
638+
});
608639
}
609640

610641
if !self.shutdown && self.connect_state.is_active() {
@@ -620,6 +651,7 @@ impl SpircTask {
620651
};
621652

622653
self.session.dealer().close().await;
654+
None
623655
}
624656

625657
fn handle_next_context(&mut self, next_context: Result<Context, Error>) -> bool {
@@ -907,6 +939,19 @@ impl SpircTask {
907939
trace!("Received connection ID update: {connection_id:?}");
908940
self.session.set_connection_id(&connection_id);
909941

942+
// If we have active playback (e.g. restored from saved state),
943+
// update the position before registering so Spotify sees the
944+
// correct track position.
945+
if !matches!(self.play_status, SpircPlayStatus::Stopped) {
946+
info!("re-registering with active playback state: {:?}", self.play_status);
947+
self.connect_state.set_status(&self.play_status);
948+
if self.connect_state.is_playing() {
949+
self.connect_state
950+
.update_position_in_relation(self.now_ms());
951+
}
952+
self.connect_state.set_now(self.now_ms() as u64);
953+
}
954+
910955
let cluster = match self
911956
.connect_state
912957
.notify_new_device_appeared(&self.session)

examples/play_connect.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ async fn main() -> Result<(), Error> {
7171
spirc.play()?;
7272

7373
// starting the connect device and processing the previously "queued" calls
74-
spirc_task.await;
74+
let _ = spirc_task.await;
7575

7676
Ok(())
7777
}

src/main.rs

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1899,6 +1899,7 @@ async fn main() {
18991899
let mut discovery = None;
19001900
let mut connecting = false;
19011901
let mut _event_handler: Option<EventHandler> = None;
1902+
let mut saved_playback_state = None;
19021903

19031904
let mut session = Session::new(setup.session_config.clone(), setup.cache.clone());
19041905

@@ -2049,11 +2050,14 @@ async fn main() {
20492050

20502051
let connect_config = setup.connect_config.clone();
20512052

2052-
let (spirc_, spirc_task_) = match Spirc::new(connect_config,
2053-
session.clone(),
2054-
last_credentials.clone().unwrap_or_default(),
2055-
player.clone(),
2056-
mixer.clone()).await {
2053+
let (spirc_, spirc_task_) = match Spirc::with_saved_state(
2054+
connect_config,
2055+
session.clone(),
2056+
last_credentials.clone().unwrap_or_default(),
2057+
player.clone(),
2058+
mixer.clone(),
2059+
saved_playback_state.take(),
2060+
).await {
20572061
Ok((spirc_, spirc_task_)) => (spirc_, spirc_task_),
20582062
Err(e) => {
20592063
error!("could not initialize spirc: {e}");
@@ -2065,14 +2069,20 @@ async fn main() {
20652069

20662070
connecting = false;
20672071
},
2068-
_ = async {
2069-
if let Some(task) = spirc_task.as_mut() {
2070-
task.await;
2072+
saved = async {
2073+
match spirc_task.as_mut() {
2074+
Some(task) => task.await,
2075+
None => None,
20712076
}
20722077
}, if spirc_task.is_some() && !connecting => {
20732078
spirc_task = None;
2079+
saved_playback_state = saved;
20742080

2075-
warn!("Spirc shut down unexpectedly");
2081+
if saved_playback_state.is_some() {
2082+
info!("Spirc shut down with saved playback state, reconnecting");
2083+
} else {
2084+
warn!("Spirc shut down unexpectedly");
2085+
}
20762086

20772087
let mut reconnect_exceeds_rate_limit = || {
20782088
auto_connect_times.retain(|&t| t.elapsed() < RECONNECT_RATE_LIMIT_WINDOW);
@@ -2112,7 +2122,7 @@ async fn main() {
21122122
}
21132123

21142124
if let Some(spirc_task) = spirc_task {
2115-
shutdown_tasks.spawn(spirc_task);
2125+
shutdown_tasks.spawn(async { spirc_task.await; });
21162126
}
21172127
}
21182128

0 commit comments

Comments
 (0)