Skip to content

Fix stale native playback routing after streaming recovery#298

Merged
LargeModGames merged 3 commits into
LargeModGames:mainfrom
shilicioo:fix/stale-device-matching
Jun 11, 2026
Merged

Fix stale native playback routing after streaming recovery#298
LargeModGames merged 3 commits into
LargeModGames:mainfrom
shilicioo:fix/stale-device-matching

Conversation

@shilicioo

Copy link
Copy Markdown
Contributor

Summary

Fixes a native streaming recovery edge case where spotatui could keep treating a stale Spotify API spotatui device snapshot as the active native player after librespot disconnected and recovered. This edge-case became more plausible with the changes added in scope of #293

This 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 -- --check
    • Passed
  • cargo clippy --no-default-features --features telemetry -- -D warnings
    • Passed
  • cargo test --no-default-features --features telemetry
    • Passed: 235 tests
  • cargo check
    • Passed with default features, including native streaming code

@shilicioo

Copy link
Copy Markdown
Contributor Author

found another annoying bug introduced by me: on startup there's now always no active palyback device
fixing

@shilicioo

Copy link
Copy Markdown
Contributor Author

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 LargeModGames left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_activation within 5s) is now copy-pasted in four spots: app.rs, playback.rs (twice), and events.rs. It's the same thing as the native_has_fresh_activity you already compute in should_activate_native_streaming_for_playback. Four copies of a routing predicate (plus the magic 5) will drift the first time someone tweaks the window. Could you fold it into an App helper like has_fresh_native_activity() and call it everywhere, including to build the NativeActivationContext?

  • is_no_active_device_error matches on text.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. The NO_ACTIVE_DEVICE / No active device checks are the precise signal, the bare 404 is 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_player now clears current_playback_context unconditionally, but the comment right above it still explains the Connect-disconnect case where the API context is legitimately the new device. It dispatches GetCurrentPlayback right 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 LargeModGames left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 a FRESH_NATIVE_ACTIVITY_WINDOW const, 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_error tightening is the part I'm happiest with. Dropping the bare 404 / Not Found matches and keying off no_active_device / no active device is the right call, and I checked it still fires on the real error: requests.rs builds 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. The does_not_match_generic_not_found test locking out a stray 404 in a URL is a good guard.
  • Comment on the unconditional current_playback_context clear 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.

@LargeModGames LargeModGames merged commit c8fb637 into LargeModGames:main Jun 11, 2026
5 checks passed
LargeModGames added a commit that referenced this pull request Jun 11, 2026
## 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants