Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/infra/network/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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;
Expand Down
48 changes: 48 additions & 0 deletions src/infra/player/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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"))]
Expand Down Expand Up @@ -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,
);
}
}
_ => {}
}
}
Expand Down
Loading