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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

### Fixed

- **Ghost `spotatui` Connect devices**: The native streaming device id is now generated once and persisted in the streaming cache, and the old librespot session is shut down whenever the player is replaced, so app restarts and streaming recoveries no longer leave a trail of dead `spotatui` entries in Spotify's device list ([#297](https://github.com/LargeModGames/spotatui/issues/297)).
- **Native device selection and playback startup**: Made the local `spotatui` Connect device selectable when Spotify's devices API omits it, recovered stale native streaming sessions after long idle, fixed `Esc`/`Enter` handling in the device selector, and avoided `NO_ACTIVE_DEVICE` playback failures when native streaming is connected but no Spotify playback context is active ([#292](https://github.com/LargeModGames/spotatui/issues/292)).
- **Search box no longer traps focus on submit**: Pressing `Enter` to run a search now always moves focus to the results list, including when re-searching while already on the Search screen (previously focus stayed stuck in the input box) ([#191](https://github.com/LargeModGames/spotatui/issues/191)).
- **Cover-art load failures are non-fatal**: A failed album-image fetch is now logged and ignored instead of surfacing a blocking error and aborting the now-playing update, so playback metadata keeps updating when artwork can't be loaded ([#142](https://github.com/LargeModGames/spotatui/issues/142)).
Expand Down
3 changes: 3 additions & 0 deletions src/core/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1403,6 +1403,9 @@ impl App {
return false;
}

// Stop the old spirc before dropping our reference so the dead session
// doesn't linger as a ghost Connect device (#297).
player.shutdown();
self.streaming_player = None;
self.is_streaming_active = false;
self.native_activation_pending = false;
Expand Down
9 changes: 9 additions & 0 deletions src/infra/player/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ async fn handle_streaming_recovery(mut ctx: StreamingRecoveryContext) {
let recovered_player = Arc::new(recovered_player);
{
let mut app = ctx.app.lock().await;
// A disconnected old player may still be referenced here; shut its
// spirc down before replacing it so it can't leave a ghost device (#297).
if let Some(old) = app.streaming_player.take() {
if !Arc::ptr_eq(&old, &recovered_player) {
old.shutdown();
}
}
app.streaming_player = Some(Arc::clone(&recovered_player));
app.set_status_message("Native streaming recovered.", 6);
if request.reselect_device {
Expand Down Expand Up @@ -633,6 +640,8 @@ async fn disconnect_streaming_player(
let reselect_device = allow_reselect_device && current_playback_matches_native(&app_lock, player);

app_lock.streaming_player = None;
// Stop the old Connect session so it doesn't linger as a ghost device (#297).
player.shutdown();
app_lock.is_streaming_active = false;
app_lock.native_activation_pending = false;
app_lock.native_device_id = None;
Expand Down
100 changes: 98 additions & 2 deletions src/infra/player/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,10 +343,15 @@ impl StreamingPlayer {
resolve_streaming_credentials(&cache, auth_mode)?;

// Create session configuration using spotify-player's client_id
let session_config = SessionConfig {
let mut session_config = SessionConfig {
client_id: SPOTIFY_PLAYER_CLIENT_ID.to_string(),
..Default::default()
};
// Reuse a persisted device id so every launch and recovery registers as the
// same Spotify Connect device instead of accumulating ghost entries (#297).
if let Some(device_id) = get_or_create_device_id(cache_path.as_deref()) {
session_config.device_id = device_id;
}

// Create session (Spirc will handle connection)
let session = Session::new(session_config, Some(cache));
Expand Down Expand Up @@ -674,6 +679,15 @@ impl StreamingPlayer {
}
}

impl Drop for StreamingPlayer {
fn drop(&mut self) {
// Backstop: stop the spirc when the last reference drops so a replaced
// player can't linger as a ghost Connect device (#297). Idempotent.
self.spirc_alive.store(false, Ordering::Relaxed);
let _ = self.spirc.shutdown();
}
}

// Re-export PlayerEvent for use in other modules
pub use librespot_playback::player::PlayerEvent;

Expand All @@ -687,9 +701,54 @@ fn should_retry_with_fresh_credentials(
auth_error && used_cached && !already_retried
}

/// Stable Connect device id, persisted in the streaming cache dir so every
/// launch and every in-app recovery registers as the same device (#297).
fn get_or_create_device_id(cache_path: Option<&std::path::Path>) -> Option<String> {
let cache_path = cache_path?;
let id_file = cache_path.join("device_id");
if let Ok(existing) = std::fs::read_to_string(&id_file) {
let trimmed = existing.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
let id = new_device_id_string();
let _ = std::fs::create_dir_all(cache_path);
let _ = std::fs::write(&id_file, &id);
Some(id)
}

/// Hyphenated UUID-v4-shaped string, matching librespot's default device id format.
fn new_device_id_string() -> String {
use rand::RngCore;
let mut b = [0u8; 16];
rand::thread_rng().fill_bytes(&mut b);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
b[0],
b[1],
b[2],
b[3],
b[4],
b[5],
b[6],
b[7],
b[8],
b[9],
b[10],
b[11],
b[12],
b[13],
b[14],
b[15]
)
}

#[cfg(test)]
mod tests {
use super::should_retry_with_fresh_credentials;
use super::{get_or_create_device_id, new_device_id_string, should_retry_with_fresh_credentials};

#[test]
fn auth_failure_with_cached_creds_triggers_retry() {
Expand Down Expand Up @@ -720,6 +779,43 @@ mod tests {
fn success_never_triggers_retry() {
assert!(!should_retry_with_fresh_credentials(false, true, false));
}

#[test]
fn device_id_string_is_uuid_v4_shaped() {
let id = new_device_id_string();
assert_eq!(id.len(), 36);
for (i, c) in id.char_indices() {
match i {
8 | 13 | 18 | 23 => assert_eq!(c, '-'),
14 => assert_eq!(c, '4'),
19 => assert!(matches!(c, '8' | '9' | 'a' | 'b')),
_ => assert!(c.is_ascii_hexdigit()),
}
}
}

#[test]
fn device_id_persists_across_calls() {
let dir = std::env::temp_dir().join(format!("spotatui_device_id_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);

let first = get_or_create_device_id(Some(&dir)).unwrap();
let second = get_or_create_device_id(Some(&dir)).unwrap();
assert_eq!(first, second);
assert_eq!(
std::fs::read_to_string(dir.join("device_id"))
.unwrap()
.trim(),
first
);

let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn device_id_none_without_cache_path() {
assert!(get_or_create_device_id(None).is_none());
}
}

/// Helper to get the default cache path for streaming
Expand Down
Loading