From 10f9aebdd3b27993ec7261c6cd99b81344b84217 Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:17:14 +0200 Subject: [PATCH] fix(streaming): keep playback on native device, surface failures (#282) --- README.md | 2 ++ src/infra/network/playback.rs | 17 +++++++++++++ src/infra/player/events.rs | 48 +++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/README.md b/README.md index 4b64ff8..1457022 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,8 @@ spotatui can play audio directly without needing spotifyd or the official Spotif - Premium account required - Context-backed native playback prefers Spotify-visible playback starts when it is safe to do so, while raw URI-list playback stays on the stable direct native path +> **Known limitation — `error audio key 0 1`:** Since late 2025, Spotify rejects librespot's audio-key requests for some accounts (more common on newer accounts), so native playback can't decrypt audio and fails. This is an upstream Spotify change that affects every librespot-based client (not just spotatui) and can't be fixed here. When it happens, spotatui shows a status-bar message — press `d` and pick an official Spotify Connect device (the desktop or mobile Spotify app) to keep listening. Accounts created before ~2020 are typically unaffected. + See the [Native Streaming Wiki](https://github.com/LargeModGames/spotatui/wiki/Native-Streaming) for setup details. ## Configuration diff --git a/src/infra/network/playback.rs b/src/infra/network/playback.rs index c3879c8..b971f66 100644 --- a/src/infra/network/playback.rs +++ b/src/infra/network/playback.rs @@ -225,6 +225,18 @@ async fn is_native_streaming_active_for_playback(network: &Network) -> bool { } } + // The user explicitly selected the native device very recently; honor that + // intent even when the API context hasn't caught up yet (the brief pre-poll + // window). `is_streaming_active` is re-derived from real Spotify state on the + // next poll, so this cannot reintroduce the #254 device hijack. (#282) + if app.is_streaming_active + && app + .last_device_activation + .is_some_and(|instant| instant.elapsed() < Duration::from_secs(5)) + { + return true; + } + // No match - not the active device false } @@ -1076,6 +1088,11 @@ impl PlaybackNetwork for Network { app.is_streaming_active = true; app.native_activation_pending = true; app.native_playback_origin = None; + // Drop the stale previous-device context so playback routing follows the + // native intent (is_streaming_active) until the next poll repopulates it + // — mirrors the non-native transfer branch below. Without this, the first + // play can leak to the official Spotify client / 404 (#282). + app.current_playback_context = None; app.last_device_activation = Some(Instant::now()); app.instant_since_last_current_playback_poll = Instant::now() - Duration::from_secs(6); return; diff --git a/src/infra/player/events.rs b/src/infra/player/events.rs index d5ac41f..c1e7f4d 100644 --- a/src/infra/player/events.rs +++ b/src/infra/player/events.rs @@ -174,6 +174,13 @@ async fn handle_player_events( ) { use chrono::TimeDelta; + // Count consecutive failed (Unavailable) loads so we can escalate the message + // when an account is hit by the upstream Spotify audio-key block (#282). A + // single genuinely-unavailable track only trips the mild message and resets on + // the next successful Playing. + let mut consecutive_unavailable: u32 = 0; + const UNAVAILABLE_ESCALATION_THRESHOLD: u32 = 3; + while let Some(event) = event_rx.recv().await { if !is_current_streaming_player(&app, &player).await { continue; @@ -185,6 +192,8 @@ async fn handle_player_events( track_id, position_ms, } => { + // Playback is actually working: reset the failure streak. + consecutive_unavailable = 0; shared_is_playing.store(true, Ordering::Relaxed); #[cfg(all(feature = "mpris", target_os = "linux"))] @@ -462,6 +471,45 @@ async fn handle_player_events( } return; } + PlayerEvent::Unavailable { track_id, .. } => { + // librespot emits Unavailable when a track can't be loaded — including + // when Spotify rejects the audio key (`error audio key 0 1`), which makes + // decryption fail. This was previously dropped by the `_` arm, so the + // failure was completely silent (#282). Surface it to the user. + consecutive_unavailable += 1; + + // Clear the ghost native track so the playbar doesn't show a track that + // never actually plays, mirroring the EndOfTrack/Stopped arms. Use + // try_lock to avoid stalling on the render loop; skipping a reset is fine. + if let Ok(mut app) = app.try_lock() { + app.song_progress_ms = 0; + app.native_track_info = None; + if let Some(ref mut ctx) = app.current_playback_context { + ctx.is_playing = false; + } + } + + info!( + "native playback unavailable (track {}, consecutive {})", + track_id, consecutive_unavailable + ); + + // Emit on the threshold transitions only (== not >=) so we don't spam the + // same message on every auto-skip during an account-wide failure. + if consecutive_unavailable == 1 { + let mut app = app.lock().await; + app.set_status_message( + "Couldn't play this track natively (unavailable or blocked); skipping.", + 6, + ); + } else if consecutive_unavailable == UNAVAILABLE_ESCALATION_THRESHOLD { + let mut app = app.lock().await; + app.set_status_message( + "Native playback keeps failing — a known upstream Spotify limitation on some accounts that can't be fixed in spotatui. Press 'd' to switch to an official Spotify Connect device.", + 20, + ); + } + } _ => {} } }