Skip to content

Commit f69778d

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 f69778d

4 files changed

Lines changed: 96 additions & 20 deletions

File tree

connect/src/model.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::{
22
core::dealer::protocol::SkipTo, protocol::context_player_options::ContextPlayerOptionOverrides,
3+
state::ConnectState,
34
};
45

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

connect/src/spirc.rs

Lines changed: 64 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,21 @@ 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 => (
206+
ConnectState::new(config, &session),
207+
SpircPlayStatus::Stopped,
208+
None,
209+
),
210+
};
180211

181212
let connection_id_update = session
182213
.dealer()
@@ -235,8 +266,8 @@ impl Spirc {
235266
connect_state,
236267
connect_established: false,
237268

238-
play_request_id: None,
239-
play_status: SpircPlayStatus::Stopped,
269+
play_request_id,
270+
play_status,
240271

241272
connection_id_update,
242273
connect_state_update,
@@ -438,7 +469,7 @@ impl Spirc {
438469
}
439470

440471
impl SpircTask {
441-
async fn run(mut self) {
472+
async fn run(mut self) -> Option<SavedPlaybackState> {
442473
// simplify unwrapping of received item or parsed result
443474
macro_rules! unwrap {
444475
( $next:expr, |$some:ident| $use_some:expr ) => {
@@ -463,7 +494,7 @@ impl SpircTask {
463494

464495
if let Err(why) = self.session.dealer().start().await {
465496
error!("starting dealer failed: {why}");
466-
return;
497+
return None;
467498
}
468499

469500
while !self.session.is_invalid() && !self.shutdown {
@@ -603,8 +634,15 @@ impl SpircTask {
603634
// Session TCP connection died — skip server communication that
604635
// would fail anyway. The Player continues playing from its
605636
// buffer independently; main.rs will create a new session.
606-
warn!("session lost, skipping server cleanup");
607-
return;
637+
warn!(
638+
"session lost, saving playback state for recovery: {:?}",
639+
self.play_status
640+
);
641+
return Some(SavedPlaybackState {
642+
connect_state: mem::take(&mut self.connect_state),
643+
play_status: mem::replace(&mut self.play_status, SpircPlayStatus::Stopped),
644+
play_request_id: self.play_request_id.take(),
645+
});
608646
}
609647

610648
if !self.shutdown && self.connect_state.is_active() {
@@ -620,6 +658,7 @@ impl SpircTask {
620658
};
621659

622660
self.session.dealer().close().await;
661+
None
623662
}
624663

625664
fn handle_next_context(&mut self, next_context: Result<Context, Error>) -> bool {
@@ -907,6 +946,22 @@ impl SpircTask {
907946
trace!("Received connection ID update: {connection_id:?}");
908947
self.session.set_connection_id(&connection_id);
909948

949+
// If we have active playback (e.g. restored from saved state),
950+
// update the position before registering so Spotify sees the
951+
// correct track position.
952+
if !matches!(self.play_status, SpircPlayStatus::Stopped) {
953+
info!(
954+
"re-registering with active playback state: {:?}",
955+
self.play_status
956+
);
957+
self.connect_state.set_status(&self.play_status);
958+
if self.connect_state.is_playing() {
959+
self.connect_state
960+
.update_position_in_relation(self.now_ms());
961+
}
962+
self.connect_state.set_now(self.now_ms() as u64);
963+
}
964+
910965
let cluster = match self
911966
.connect_state
912967
.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: 22 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,9 @@ async fn main() {
21122122
}
21132123

21142124
if let Some(spirc_task) = spirc_task {
2115-
shutdown_tasks.spawn(spirc_task);
2125+
shutdown_tasks.spawn(async {
2126+
spirc_task.await;
2127+
});
21162128
}
21172129
}
21182130

0 commit comments

Comments
 (0)