Fix stale native playback routing after streaming recovery#298
Conversation
|
found another annoying bug introduced by me: on startup there's now always no active palyback device |
|
Added the follow-up fix for the startup NO_ACTIVE_DEVICE case. Explicit playback now resolves a usable target first, preferring a confirmed active/saved external device when available and otherwise activating the local native spotatui player, while keeping the stale name-only matching guard. I also made resume-only playback avoid blindly calling native play() when the recovered/native player has no loaded track, and covered the target-selection behavior with tests. |
LargeModGames
left a comment
There was a problem hiding this comment.
Thanks for the follow-up, this is a solid fix for the recovery edge case from #293. Pulling the activation decision out into a pure should_activate_native_for_playback(NativeActivationContext) with table tests is the right move, that logic was getting hard to reason about inline and now it's actually verifiable. The native_load_request extraction is clean too, and the (None, None) arm collapsing to an early return is safe since the resume path already returns before it.
One note for visibility: every change here is under #[cfg(feature = "streaming")], and CI only runs --no-default-features --features telemetry, so none of this is compiled or tested on CI. I built it locally to check, cargo clippy --no-default-features --features streaming,telemetry -- -D warnings passes and the 7 new native_activation_* tests pass, so we're good there, just flagging that green CI doesn't cover this path.
Worth a look (non-blocking):
-
The fresh-native-activity check (
native_track_info.is_some() || native_is_playing == Some(true) || last_device_activationwithin 5s) is now copy-pasted in four spots:app.rs,playback.rs(twice), andevents.rs. It's the same thing as thenative_has_fresh_activityyou already compute inshould_activate_native_streaming_for_playback. Four copies of a routing predicate (plus the magic5) will drift the first time someone tweaks the window. Could you fold it into anApphelper likehas_fresh_native_activity()and call it everywhere, including to build theNativeActivationContext? -
is_no_active_device_errormatches ontext.contains("404")and"Not Found"off the error string. That'll fire on any error whose message happens to contain "404" (a URL, an id), not just a genuine no-active-device. TheNO_ACTIVE_DEVICE/No active devicechecks are the precise signal, the bare404is the loose part. If we can match on the HTTP status instead that'd be safer, otherwise a short comment on why 404 is safe to treat as no-device here. -
disconnect_streaming_playernow clearscurrent_playback_contextunconditionally, but the comment right above it still explains the Connect-disconnect case where the API context is legitimately the new device. It dispatchesGetCurrentPlaybackright after so it should repopulate, I just want to confirm that's intentional and not a regression of the transferred-playback handling from #269/#254. A one-line comment there would help the next reader.
Otherwise looks good. Once the dedup is in I'm happy to merge.
LargeModGames
left a comment
There was a problem hiding this comment.
Thanks, this addresses all three cleanly.
- The dedup is exactly what I had in mind:
App::has_fresh_native_activity()with the window pulled into aFRESH_NATIVE_ACTIVITY_WINDOWconst, and all four call sites now go through it. The three helper tests covering the metadata / playing / activation-window paths are a nice bonus. is_no_active_device_errortightening is the part I'm happiest with. Dropping the bare404/Not Foundmatches and keying offno_active_device/no active deviceis the right call, and I checked it still fires on the real error:requests.rsbuilds the string as"Spotify API {status} failed: {body}", and Spotify's NO_ACTIVE_DEVICE response carries both"reason":"NO_ACTIVE_DEVICE"and the "No active device found" message in the body, so the lowercased match still hits. Thedoes_not_match_generic_not_foundtest locking out a stray404in a URL is a good guard.- Comment on the unconditional
current_playback_contextclear makes the intent clear for the next reader.
I built it locally again, cargo clippy --no-default-features --features streaming,telemetry -- -D warnings is clean and all five new tests pass. LGTM, merging.
## Summary Fixes the ghost-device symptom of #297 (also reported in #72): users accumulate multiple dead "spotatui" entries in Spotify's Connect device list. Two root causes: 1. **Random device id per session.** `SessionConfig::default()` in librespot 0.8.0 generates a fresh UUID device id every time, so each app launch and each in-app streaming recovery registered a brand-new Connect device. The device id is now generated once, persisted to `<streaming_cache>/device_id`, and reused by every session. 2. **Old spirc never shut down.** Replacing `app.streaming_player` just dropped the Arc, leaving the old librespot session registered with Spotify. `player.shutdown()` is now called at all three replacement sites (`disconnect_streaming_player`, the recovery success branch, and `request_native_streaming_recovery_if_disconnected`), with an `impl Drop for StreamingPlayer` as a backstop. This intentionally does not touch `src/infra/network/playback.rs`, so it should not conflict with #298, which covers the other #297 symptoms (404 NO_ACTIVE_DEVICE routing and silent resume). Pre-existing ghost entries are not deleted; they age out of Spotify's list once nothing re-registers them. ## Testing - `cargo check` (default features, compiles the streaming code): passed - `cargo test` (default features): 249 passed, including 3 new unit tests for the device id helpers - `cargo fmt --all` - `cargo clippy --no-default-features --features telemetry -- -D warnings`: passed - `cargo test --no-default-features --features telemetry`: 233 passed Note: the new unit tests only compile under default features (streaming), not in the CI telemetry lane. Manual verification: run the app, restart a few times, and force a recovery (e.g. transfer playback away in the official client); the Connect picker should show exactly one "spotatui" whose device id matches the persisted file and the `Initializing Spirc with device_id=...` log line every time.
Summary
Fixes a native streaming recovery edge case where spotatui could keep treating a stale Spotify API
spotatuidevice snapshot as the active native player after librespot disconnected and recovered. This edge-case became more plausible with the changes added in scope of #293This change makes name-only native device matches valid only when there is fresh local native activity, native playback metadata, or a very recent explicit activation. It also clears stale playback context on native disconnect so old API state cannot keep re-poisoning playback routing.
Testing
cargo fmt --all -- --checkcargo clippy --no-default-features --features telemetry -- -D warningscargo test --no-default-features --features telemetrycargo check