From 2c8d1922667add1b28c5c0d9c379fa41698d2ec7 Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:13:03 +0200 Subject: [PATCH 1/2] fix: stop ghost spotatui Connect devices from accumulating Persist the librespot device id in the streaming cache dir so every launch and recovery registers as the same Spotify Connect device, and shut down the old spirc whenever the streaming player is replaced (explicit shutdown() at all replacement sites plus a Drop backstop). Addresses the ghost-device symptom of #297. --- src/core/app.rs | 3 + src/infra/player/events.rs | 9 +++ src/infra/player/streaming.rs | 100 +++++++++++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src/core/app.rs b/src/core/app.rs index aefffab..40fdbbe 100644 --- a/src/core/app.rs +++ b/src/core/app.rs @@ -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; diff --git a/src/infra/player/events.rs b/src/infra/player/events.rs index 2e0350c..64fc046 100644 --- a/src/infra/player/events.rs +++ b/src/infra/player/events.rs @@ -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 { @@ -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; diff --git a/src/infra/player/streaming.rs b/src/infra/player/streaming.rs index 276b2db..132bb0e 100644 --- a/src/infra/player/streaming.rs +++ b/src/infra/player/streaming.rs @@ -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)); @@ -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; @@ -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 { + 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() { @@ -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 From cc7faef6824ed9202e804325f3e42015f8d22a3c Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:15:44 +0200 Subject: [PATCH 2/2] docs: changelog entry for ghost device fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5d2f43..5743818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)).