Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4b7662f
fix: dealer websocket reconnect leaving spirc hung on stale channels
antoinecellerier Mar 8, 2026
29e814a
fix: handle dealer reconnect in-place without restarting spirc
antoinecellerier Mar 13, 2026
c87079b
fix: skip server cleanup on session loss to keep playback alive
antoinecellerier Mar 13, 2026
c77d8cb
fix: save and restore playback state across session reconnects
antoinecellerier Mar 13, 2026
da004f1
diag: log controller identity and transfer contents on connect
antoinecellerier Jun 30, 2026
5adf966
fix: HTTP request timeouts and reconnect watchdog so device can't sil…
antoinecellerier Jul 6, 2026
bb76d15
fix: bound the dealer websocket handshake during reconnect
antoinecellerier Jul 6, 2026
891c730
fix: close the old dealer when spirc restarts with saved state
antoinecellerier Jul 6, 2026
d70aba0
fix: retry via watchdog when re-registration fails after reconnect
antoinecellerier Jul 6, 2026
ace64f7
fix: make reconnect watchdog suppression race-free with a generation …
antoinecellerier Jul 6, 2026
e8a6f62
fix: back off instead of exiting when recovery restarts hit the rate …
antoinecellerier Jul 6, 2026
ceff4a8
fix: time out HTTP body reads on stall, not total transfer time
antoinecellerier Jul 6, 2026
7460f73
refactor: remove unused Dealer::reconnect_receiver
antoinecellerier Jul 6, 2026
9433753
docs: mark Spirc::new return-type change as breaking in changelog
antoinecellerier Jul 6, 2026
fa07cc5
style: rustfmt
antoinecellerier Jul 6, 2026
5fb097a
fix: bound each address attempt in socket::connect
antoinecellerier Jul 11, 2026
a411289
fix: subscribe to player events before session setup in spirc restore
antoinecellerier Jul 11, 2026
bd93e18
fix: re-issue in-flight loads when restoring spirc playback state
antoinecellerier Jul 11, 2026
6b84d8a
fix: treat HTTP 502 as unavailable like 503
antoinecellerier Jul 14, 2026
999ffab
fix: let user actions retry contexts marked unavailable
antoinecellerier Jul 14, 2026
c3b1843
fix: stop loudly when an unresolved context strands a transfer or load
antoinecellerier Jul 14, 2026
b08a5f7
fix: defer unavailable-track handling until the context resolves
antoinecellerier Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- [core] Made `SpotifyId::to_base62`, `SpotifyId::to_base16`, `FileId::to_base16`, `SpotifyUri::to_id`, `SpotifyUri::to_uri` infallible (breaking)
- [connect] `Spirc::new` now returns a future resolving to `Option<SavedPlaybackState>` instead of `()`; pass it to `Spirc::with_saved_state` to restore playback across session reconnects (breaking)

### Fixed

Expand All @@ -24,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [core] Fix default permissions on credentials file and warn user if file is world readable
- [core] Try all resolved addresses for the dealer connection instead of failing after the first one.
- [audio] Try the next CDN URL when a fetch returns a non-206 status instead of only retrying on transport errors, fixing playback failures when the first CDN URL is reachable but does not stream audio.
- [core] Fix dealer websocket reconnect leaving spirc hung on stale subscription channels.
- [connect] Save and restore playback state across session reconnects.
- [core] Add a timeout to control-plane HTTP requests so a half-open connection can't hang the spirc event loop.
- [connect] Add a reconnect watchdog that restarts spirc if the device is not re-registered after a dealer reconnect, so it can't silently disappear from Spotify Connect.

## [0.8.0] - 2025-11-10

Expand Down
3 changes: 3 additions & 0 deletions connect/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,8 @@ tokio = { version = "1", features = ["macros", "sync"] }
tokio-stream = { version = "0.1", default-features = false }
uuid = { version = "1.18", default-features = false, features = ["v4"] }

[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt", "sync", "test-util", "time"] }

[lints]
workspace = true
83 changes: 81 additions & 2 deletions connect/src/context_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,12 @@ impl ContextResolver {
last_try
};

if last_try.is_some() {
debug!("tried loading unavailable context: {resolve}");
if let Some(last_try) = last_try {
info!(
"skipped resolving unavailable context ({resolve}): resolving failed {}s ago, retrying in {}s",
last_try.as_secs(),
RETRY_UNAVAILABLE.saturating_sub(last_try).as_secs()
);
return;
} else if self.queue.contains(&resolve) {
debug!("update for {resolve} is already added");
Expand All @@ -171,6 +175,16 @@ impl ContextResolver {
self.queue.push_back(resolve)
}

/// Adds a context for resolving even if it recently failed, so that a
/// deliberate user action (transfer, load) always gets a fresh attempt
/// instead of being dropped while the context is marked unavailable.
pub fn add_forced(&mut self, resolve: ResolveContext) {
if self.unavailable_contexts.remove(&resolve).is_some() {
info!("resolving unavailable context by user request: {resolve}");
}
self.add(resolve)
}

pub fn add_list(&mut self, resolve: Vec<ResolveContext>) {
for resolve in resolve {
self.add(resolve)
Expand Down Expand Up @@ -344,3 +358,68 @@ impl ContextResolver {
true
}
}

#[cfg(test)]
mod test {
use super::*;
use crate::core::SessionConfig;

fn resolver() -> ContextResolver {
ContextResolver::new(Session::new(SessionConfig::default(), None))
}

fn resolve() -> ResolveContext {
ResolveContext::from_uri(
"spotify:playlist:37i9dQZF1EIhMHNZW8S7ky",
"spotify:track:6ek9SiEj5a65WIs2EV7qiM",
ContextType::Default,
ContextAction::Replace,
)
}

#[tokio::test(start_paused = true)]
async fn add_drops_unavailable_context_until_retry_expires() {
let mut resolver = resolver();

resolver.add(resolve());
assert!(resolver.has_next());

resolver.mark_next_unavailable();
resolver.remove_used_and_invalid();
assert!(!resolver.has_next());

resolver.add(resolve());
assert!(!resolver.has_next());

tokio::time::advance(RETRY_UNAVAILABLE + Duration::from_secs(1)).await;
resolver.add(resolve());
assert!(resolver.has_next());
}

#[tokio::test(start_paused = true)]
async fn add_forced_retries_unavailable_context_immediately() {
let mut resolver = resolver();

resolver.add(resolve());
resolver.mark_next_unavailable();
resolver.remove_used_and_invalid();

resolver.add_forced(resolve());
assert!(resolver.has_next());

resolver.remove_used_and_invalid();
resolver.add(resolve());
assert!(resolver.has_next());
}

#[tokio::test(start_paused = true)]
async fn add_dedups_already_queued_context() {
let mut resolver = resolver();

resolver.add(resolve());
resolver.add(resolve());

resolver.remove_used_and_invalid();
assert!(!resolver.has_next());
}
}
9 changes: 9 additions & 0 deletions connect/src/model.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::{
core::dealer::protocol::SkipTo, protocol::context_player_options::ContextPlayerOptionOverrides,
state::ConnectState,
};

use std::ops::Deref;
Expand Down Expand Up @@ -165,3 +166,11 @@ pub(super) enum SpircPlayStatus {
preloading_of_next_track_triggered: bool,
},
}

/// Playback state saved across session reconnects so the new SpircTask
/// can resume where the old one left off.
pub struct SavedPlaybackState {
pub(super) connect_state: ConnectState,
pub(super) play_status: SpircPlayStatus,
pub(super) play_request_id: Option<u64>,
}
Loading
Loading