diff --git a/Cargo.lock b/Cargo.lock index d8ec74a..60dab2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4129,6 +4129,7 @@ dependencies = [ "librespot-audio", "librespot-core", "librespot-playback", + "librespot-protocol", "log", "rand 0.9.5", "reqwest 0.12.28", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index e74b3fc..5a5ddbc 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -31,6 +31,7 @@ chrono = "0.4" log = "0.4" librespot-audio = { version = "0.8", default-features = false } librespot-core = { version = "0.8", default-features = false, features = ["rustls-tls-native-roots"] } +librespot-protocol = { version = "0.8", default-features = false } librespot-playback = { version = "0.8", default-features = false, features = ["rodio-backend", "rustls-tls-native-roots"] } tauri = { version = "2.11.3", features = [] } tauri-plugin-dialog = "2" diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 76081cf..c613a58 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -121,6 +121,7 @@ impl MenuChecks { struct ConnectionState { connected: bool, needs_reauth: bool, + playback_authorized: bool, missing_scopes: Vec, } @@ -136,6 +137,12 @@ impl ConnectionState { Self { connected: tokens.is_some(), needs_reauth: !missing_scopes.is_empty(), + playback_authorized: tokens + .as_ref() + .and_then(|tokens| tokens.playback_credentials.as_ref()) + .is_some_and(|credentials| { + !credentials.username.is_empty() && !credentials.auth_data.is_empty() + }), missing_scopes, } } @@ -669,25 +676,12 @@ async fn set_settings(app: tauri::AppHandle, mut settings: Settings) -> Result<( settings.spotify_sync_completed = current.spotify_sync_completed; settings.last_full_sync = current.last_full_sync; settings.validate().map_err(|error| error.to_string())?; - // Compare against the ACTIVE backend, not the persisted setting: a failed - // activation (e.g. under-scoped token at startup) leaves the setting on - // "local" while playback fell back to Connect, and re-selecting the radio - // must retry the switch. let wants_local = settings.playback_backend == "local"; - if wants_local != state.playback.is_local_active().await { - let switch = if wants_local { - switch_to_local(&state, settings.volume).await - } else { - state.playback.switch_to_connect().await; - Ok(()) - }; - if let Err(error) = switch { - app.emit("operation-error", error) - .map_err(|error| error.to_string())?; - app.emit("settings-changed", current) - .map_err(|error| error.to_string())?; - return Ok(()); - } + state.playback.set_local_requested(wants_local); + // Local activation is intentionally lazy: playback owns authorization + // prompts, and unrelated preference saves must remain offline-safe. + if !wants_local && state.playback.is_local_active().await { + state.playback.switch_to_connect().await; } state .settings_store @@ -710,18 +704,11 @@ async fn set_settings(app: tauri::AppHandle, mut settings: Settings) -> Result<( } async fn switch_to_local(state: &AppState, volume: u8) -> Result<(), String> { - let stored = state + state .token_store .load() .map_err(|error| error.to_string())? .ok_or_else(|| "Connect to Spotify before enabling built-in playback.".to_string())?; - if !stored - .scopes - .split_whitespace() - .any(|scope| scope == "streaming") - { - return Err("Reconnect to Spotify to grant playback permission (Account → Disconnect, then Connect).".into()); - } let client = provider_from(state)?; if !client .me() @@ -758,7 +745,7 @@ fn stored_connection_state(token_store: &SharedTokenStore) -> Result Result<(), String> { +pub(crate) fn emit_connection_state(app: &tauri::AppHandle) -> Result<(), String> { let state = app.state::(); let connection = stored_connection_state(&state.token_store)?; state @@ -2060,6 +2047,7 @@ pub fn run() { set_settings, spotify_commands::connection_state, spotify_commands::connect_spotify, + spotify_commands::authorize_spotify_playback, spotify_commands::disconnect_spotify, spotify_commands::sync_from_spotify, spotify_commands::spotify_search, @@ -2174,7 +2162,9 @@ pub fn run() { if connection.connected && startup_action == StartupAction::Nothing { log::info!("startup sync skipped; library fresh"); } - let activate_local = connection.connected && settings.playback_backend == "local"; + let activate_local = connection.connected + && connection.playback_authorized + && settings.playback_backend == "local"; let initial_volume = settings.volume; let playback = Arc::new(Playback::new( &settings.repeat, @@ -2187,6 +2177,7 @@ pub fn run() { }, Some(app_data_dir.clone()), )); + playback.set_local_requested(settings.playback_backend == "local"); let media_keys = media_keys::MediaKeys::spawn(app.handle().clone()); app.manage(AppState { library: Mutex::new(library), @@ -3033,12 +3024,14 @@ mod tests { let connected = ConnectionState { connected: true, needs_reauth: false, + playback_authorized: false, missing_scopes: vec![], }; let disconnected = ConnectionState::from_tokens(None); let needs_reauth = ConnectionState { connected: true, needs_reauth: true, + playback_authorized: false, missing_scopes: vec!["playlist-read-private".into()], }; assert_eq!( @@ -3078,9 +3071,14 @@ mod tests { refresh: String::new(), expires_at: 0, scopes: "user-library-read".into(), + playback_credentials: None, }; let current = Tokens { scopes: auth::SCOPES.clone(), + playback_credentials: Some(retune_spotify::tokens::PlaybackCredentials { + username: "user".into(), + auth_data: vec![1, 2, 3], + }), ..legacy.clone() }; @@ -3089,6 +3087,7 @@ mod tests { ConnectionState { connected: true, needs_reauth: true, + playback_authorized: false, missing_scopes: auth::REQUIRED_SCOPES .into_iter() .filter(|scope| *scope != "user-library-read") @@ -3097,13 +3096,26 @@ mod tests { } ); assert_eq!( - stored_connection_state(&shared_token_store(Some(current))).unwrap(), + stored_connection_state(&shared_token_store(Some(current.clone()))).unwrap(), ConnectionState { connected: true, needs_reauth: false, + playback_authorized: true, missing_scopes: vec![], } ); + let empty_playback = Tokens { + playback_credentials: Some(retune_spotify::tokens::PlaybackCredentials { + username: String::new(), + auth_data: vec![], + }), + ..current + }; + assert!( + !stored_connection_state(&shared_token_store(Some(empty_playback))) + .unwrap() + .playback_authorized + ); } #[test] @@ -3115,6 +3127,7 @@ mod tests { refresh: String::new(), expires_at: 0, scopes: "user-library-read".into(), + playback_credentials: None, }; let forbidden = || retune_spotify::Error::Http { endpoint: "/playlists/id/tracks".into(), diff --git a/apps/desktop/src-tauri/src/media_keys.rs b/apps/desktop/src-tauri/src/media_keys.rs index 9fd7360..54f7290 100644 --- a/apps/desktop/src-tauri/src/media_keys.rs +++ b/apps/desktop/src-tauri/src/media_keys.rs @@ -5,7 +5,10 @@ use souvlaki::{ }; use tauri::{Emitter, Manager}; -use crate::{playback::PlayerStateEvent, AppState}; +use crate::{ + playback::{PlayOutcome, PlayerStateEvent}, + AppState, +}; pub struct MediaKeys { controls: Option>, @@ -124,8 +127,22 @@ fn handle_control(app: &tauri::AppHandle, event: MediaControlEvent) { state.playback.set_playing(client.as_deref(), playing).await } PlaybackCommand::Toggle => state.playback.toggle(client.as_deref()).await, - PlaybackCommand::Next => state.playback.next(client).await, - PlaybackCommand::Previous => state.playback.prev(client).await, + PlaybackCommand::Next => match state.playback.next(client).await { + Ok(PlayOutcome::PlaybackAuthorizationRequired(prompt)) => { + state.playback.stop_for_authorization(&app, prompt).await; + Ok(()) + } + Ok(_) => Ok(()), + Err(error) => Err(error), + }, + PlaybackCommand::Previous => match state.playback.prev(client).await { + Ok(PlayOutcome::PlaybackAuthorizationRequired(prompt)) => { + state.playback.stop_for_authorization(&app, prompt).await; + Ok(()) + } + Ok(_) => Ok(()), + Err(error) => Err(error), + }, PlaybackCommand::Seek(seconds) => state.playback.seek(client.as_deref(), seconds).await, }; if let Err(error) = result { diff --git a/apps/desktop/src-tauri/src/playback/local.rs b/apps/desktop/src-tauri/src/playback/local.rs index 4dfaa0a..663fe87 100644 --- a/apps/desktop/src-tauri/src/playback/local.rs +++ b/apps/desktop/src-tauri/src/playback/local.rs @@ -2,7 +2,11 @@ use std::{path::Path, sync::Arc, time::Duration}; use librespot_audio::AudioFetchParams; use librespot_core::{ - authentication::Credentials, cache::Cache, config::SessionConfig, session::Session, + authentication::Credentials, + cache::Cache, + config::SessionConfig, + error::{Error as LibrespotError, ErrorKind}, + session::Session, spotify_uri::SpotifyUri, }; use librespot_playback::{ @@ -11,9 +15,13 @@ use librespot_playback::{ mixer::{self, Mixer, MixerConfig}, player::{Player, PlayerEvent}, }; +use librespot_protocol::authentication::AuthenticationType; +use retune_spotify::tokens::TokenStore; use tokio::sync::mpsc; -use super::{AudioSettings, LiveClient, NeutralEvent, Snapshot}; +use super::{ + AudioSettings, LiveClient, NeutralEvent, PlaybackAuthorizationReason, PlaybackError, Snapshot, +}; struct Runtime { session: Session, @@ -53,11 +61,8 @@ impl LocalBackend { volume: u8, cache_dir: Option<&Path>, audio: AudioSettings, - ) -> Result { - let access = client - .access_token() - .await - .map_err(|error| error.to_string())?; + ) -> Result { + let credentials = stored_credentials(client)?; // Read farther ahead to survive short network stalls. let _ = AudioFetchParams::set(AudioFetchParams { read_ahead_during_playback: Duration::from_secs(30), @@ -66,22 +71,26 @@ impl LocalBackend { let cache = cache_dir.and_then(audio_cache); let session = Session::new(SessionConfig::default(), cache); session - .connect(Credentials::with_access_token(access), false) + .connect(credentials, false) .await - .map_err(|error| error.to_string())?; + .map_err(|error| session_error(client, error))?; + if let Err(error) = session.login5().auth_token().await { + session.shutdown(); + return Err(session_error(client, error)); + } let mixer = mixer::find(None) - .ok_or_else(|| "librespot soft mixer is unavailable.".to_string())?( + .ok_or_else(|| PlaybackError::message("librespot soft mixer is unavailable."))?( MixerConfig { volume_ctrl: VolumeCtrl::Linear, ..MixerConfig::default() }, ) - .map_err(|error| error.to_string())?; + .map_err(|error| PlaybackError::message(error.to_string()))?; mixer.set_volume(soft_volume(volume)); let volume_getter = mixer.get_soft_volume(); let sink = audio_backend::find(Some("rodio".into())) - .ok_or_else(|| "librespot rodio output is unavailable.".to_string())?; + .ok_or_else(|| PlaybackError::message("librespot rodio output is unavailable."))?; let player = Player::new( PlayerConfig { bitrate: bitrate(audio.bitrate), @@ -129,16 +138,16 @@ impl LocalBackend { .is_none_or(|runtime| runtime.session.is_invalid()) } - pub(super) async fn refresh_session(&mut self, client: &LiveClient) -> Result<(), String> { - let access = client - .access_token() - .await - .map_err(|error| error.to_string())?; + pub(super) async fn refresh_session( + &mut self, + client: &LiveClient, + ) -> Result<(), PlaybackError> { + let credentials = stored_credentials(client)?; let (config, cache) = { let runtime = self .runtime .as_ref() - .ok_or("Local playback is unavailable")?; + .ok_or_else(|| PlaybackError::message("Local playback is unavailable"))?; ( runtime.session.config().clone(), runtime.session.cache().map(|cache| cache.as_ref().clone()), @@ -146,22 +155,42 @@ impl LocalBackend { }; let session = Session::new(config, cache); session - .connect(Credentials::with_access_token(access), false) + .connect(credentials, false) .await - .map_err(|error| error.to_string())?; + .map_err(|error| session_error(client, error))?; + if let Err(error) = session.login5().auth_token().await { + session.shutdown(); + return Err(session_error(client, error)); + } let runtime = self .runtime .as_mut() - .ok_or("Local playback is unavailable")?; + .ok_or_else(|| PlaybackError::message("Local playback is unavailable"))?; if runtime.player.is_invalid() { session.shutdown(); - return Err("Local playback stopped while reconnecting to Spotify".into()); + return Err(PlaybackError::message( + "Local playback stopped while reconnecting to Spotify", + )); } runtime.player.set_session(session.clone()); runtime.session = session; Ok(()) } + pub(super) async fn preflight(&self, client: &LiveClient) -> Result<(), PlaybackError> { + let runtime = self + .runtime + .as_ref() + .ok_or_else(|| PlaybackError::message("Local playback is unavailable"))?; + runtime + .session + .login5() + .auth_token() + .await + .map(|_| ()) + .map_err(|error| session_error(client, error)) + } + pub(super) fn play( &mut self, snapshot: Snapshot, @@ -272,6 +301,50 @@ fn bitrate(value: u16) -> Bitrate { } } +fn stored_credentials(client: &LiveClient) -> Result { + let tokens = client + .token_store() + .load() + .map_err(|error| PlaybackError::message(error.to_string()))?; + let credentials = tokens + .and_then(|tokens| tokens.playback_credentials) + .filter(|credentials| !credentials.username.is_empty() && !credentials.auth_data.is_empty()) + .ok_or_else(|| PlaybackError::authorization(PlaybackAuthorizationReason::Missing))?; + Ok(Credentials { + username: Some(credentials.username), + auth_type: AuthenticationType::AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS, + auth_data: credentials.auth_data, + }) +} + +fn session_error(client: &LiveClient, error: LibrespotError) -> PlaybackError { + if !matches!( + error.kind, + ErrorKind::PermissionDenied | ErrorKind::Unauthenticated | ErrorKind::FailedPrecondition + ) { + return PlaybackError::message(error.to_string()); + } + log::warn!( + "Spotify playback authorization rejected during session verification; clearing stored credential (kind={:?}, error={error:?})", + error.kind + ); + match client.token_store().load() { + Ok(Some(mut tokens)) => { + tokens.playback_credentials = None; + match client.token_store().save(&tokens) { + Ok(()) => PlaybackError::authorization(PlaybackAuthorizationReason::Rejected), + Err(clear_error) => PlaybackError::message(format!( + "Spotify playback authorization was rejected and could not be cleared: {clear_error}" + )), + } + } + Ok(None) => PlaybackError::authorization(PlaybackAuthorizationReason::Rejected), + Err(load_error) => PlaybackError::message(format!( + "Spotify playback authorization was rejected and could not be cleared: {load_error}" + )), + } +} + fn audio_cache(app_data_dir: &Path) -> Option { let audio_path = app_data_dir.join("audio-cache"); match Cache::new( @@ -307,7 +380,21 @@ fn monitor( while let Some(event) = receiver.recv().await { if let PlayerEvent::Preloading { track_id } = &event { if let Ok(uri) = track_id.to_uri() { - log::info!("Preload ready: generation={generation} uri={uri}"); + log::info!("Spotify playback operation=preload ready: generation={generation} uri={uri}"); + } + } + if let PlayerEvent::Unavailable { + play_request_id, + track_id, + } = &event + { + match track_id.to_uri() { + Ok(uri) => log::warn!( + "Spotify playback operation=load unavailable: generation={generation} request_id={play_request_id} uri={uri}" + ), + Err(_) => log::warn!( + "Spotify playback operation=load unavailable: generation={generation} request_id={play_request_id}" + ), } } if let Some(event) = neutral_event(event, generation) { @@ -426,6 +513,10 @@ fn neutral_event(event: PlayerEvent, generation: u64) -> Option { #[cfg(test)] mod tests { use super::*; + use retune_spotify::{ + client::{HttpTransport, SpotifyClient}, + tokens::{CachedTokenStore, InMemoryTokenStore, PlaybackCredentials, Tokens}, + }; #[tokio::test] async fn monitor_reports_only_player_event_channel_closure() { @@ -469,4 +560,67 @@ mod tests { assert_eq!(bitrate(320), Bitrate::Bitrate320); assert_eq!(bitrate(0), Bitrate::Bitrate320); } + + #[test] + fn semantic_playback_rejection_clears_only_playback_credentials() { + let tokens: Box = Box::new(InMemoryTokenStore::new(Some(Tokens { + access: "web-access".into(), + refresh: "web-refresh".into(), + expires_at: 0, + scopes: "user-library-read".into(), + playback_credentials: Some(PlaybackCredentials { + username: "user".into(), + auth_data: vec![1, 2, 3], + }), + }))); + let store = Arc::new(CachedTokenStore::new(tokens)); + let client = SpotifyClient::new("test", HttpTransport::new(), Arc::clone(&store)); + let error = LibrespotError::new( + ErrorKind::PermissionDenied, + std::io::Error::other("rejected"), + ); + + assert!(matches!( + session_error(&client, error), + PlaybackError::AuthorizationRequired { + reason: PlaybackAuthorizationReason::Rejected, + .. + } + )); + let saved = store.load().unwrap().unwrap(); + assert_eq!(saved.access, "web-access"); + assert_eq!(saved.refresh, "web-refresh"); + assert!(saved.playback_credentials.is_none()); + } + + #[test] + fn transient_session_error_keeps_playback_credentials() { + let tokens: Box = Box::new(InMemoryTokenStore::new(Some(Tokens { + access: "web-access".into(), + refresh: "web-refresh".into(), + expires_at: 0, + scopes: "user-library-read".into(), + playback_credentials: Some(PlaybackCredentials { + username: "user".into(), + auth_data: vec![1, 2, 3], + }), + }))); + let store = Arc::new(CachedTokenStore::new(tokens)); + let client = SpotifyClient::new("test", HttpTransport::new(), Arc::clone(&store)); + let error = LibrespotError::new( + ErrorKind::Unavailable, + std::io::Error::other("network unavailable"), + ); + + assert!(matches!( + session_error(&client, error), + PlaybackError::Message(_) + )); + assert!(store + .load() + .unwrap() + .unwrap() + .playback_credentials + .is_some()); + } } diff --git a/apps/desktop/src-tauri/src/playback/mod.rs b/apps/desktop/src-tauri/src/playback/mod.rs index d172f3f..2677f04 100644 --- a/apps/desktop/src-tauri/src/playback/mod.rs +++ b/apps/desktop/src-tauri/src/playback/mod.rs @@ -5,7 +5,10 @@ mod reducer; use std::{ path::PathBuf, - sync::{Arc, Mutex}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, time::Duration, }; @@ -24,6 +27,117 @@ type LiveClient = SpotifyClient; const AUDIOBOOK_ERROR: &str = "Audiobook playback isn't supported yet."; const RECONNECT_DELAYS: &[u64] = &[0, 1, 2, 4, 8, 15, 30]; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum PlaybackAuthorizationReason { + Missing, + Rejected, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaybackAuthorizationPrompt { + reason: PlaybackAuthorizationReason, + message: String, + target_track_id: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum PlayOutcome { + Started, + PlaybackAuthorizationRequired(PlaybackAuthorizationPrompt), +} + +#[derive(Debug)] +pub(super) enum PlaybackError { + AuthorizationRequired { + reason: PlaybackAuthorizationReason, + target_track_id: Option, + }, + Message(String), +} + +impl PlaybackError { + fn message(message: impl Into) -> Self { + Self::Message(message.into()) + } + + fn authorization(reason: PlaybackAuthorizationReason) -> Self { + Self::AuthorizationRequired { + reason, + target_track_id: None, + } + } + + fn with_target(self, target_track_id: u64) -> Self { + match self { + Self::AuthorizationRequired { reason, .. } => Self::AuthorizationRequired { + reason, + target_track_id: Some(target_track_id), + }, + error => error, + } + } + + fn into_prompt(self, fallback_target_track_id: u64) -> Option { + match self { + Self::AuthorizationRequired { + reason, + target_track_id, + } => Some(PlaybackAuthorizationPrompt { + reason, + message: reason.message().into(), + target_track_id: target_track_id.unwrap_or(fallback_target_track_id), + }), + Self::Message(_) => None, + } + } + + fn into_string(self) -> String { + match self { + Self::AuthorizationRequired { reason, .. } => reason.message().into(), + Self::Message(message) => message, + } + } +} + +impl std::fmt::Display for PlaybackError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AuthorizationRequired { reason, .. } => formatter.write_str(reason.message()), + Self::Message(message) => formatter.write_str(message), + } + } +} + +impl PlaybackAuthorizationReason { + fn message(self) -> &'static str { + match self { + Self::Missing => "Spotify playback needs one-time authorization before this track can play.", + Self::Rejected => "Spotify rejected the saved playback authorization. Authorize playback again before retrying this track.", + } + } +} + +impl From for PlaybackError { + fn from(message: String) -> Self { + Self::Message(message) + } +} + +impl From<&str> for PlaybackError { + fn from(message: &str) -> Self { + Self::Message(message.into()) + } +} + +impl From for String { + fn from(error: PlaybackError) -> Self { + error.into_string() + } +} + #[derive(Clone, Copy)] pub struct AudioSettings { pub bitrate: u16, @@ -374,6 +488,7 @@ pub struct Playback { receiver: Mutex>>, cache_dir: Option, audio: Mutex, + local_requested: AtomicBool, } impl Default for Playback { @@ -419,9 +534,18 @@ impl Playback { receiver: Mutex::new(Some(receiver)), cache_dir, audio: Mutex::new(audio), + local_requested: AtomicBool::new(false), } } + pub fn set_local_requested(&self, requested: bool) { + self.local_requested.store(requested, Ordering::Relaxed); + } + + fn local_requested(&self) -> bool { + self.local_requested.load(Ordering::Relaxed) + } + pub fn set_audio(&self, audio: AudioSettings) { *self.audio.lock().expect("audio settings mutex poisoned") = audio; } @@ -474,18 +598,32 @@ impl Playback { client: Option>, tracks: Vec, index: usize, - ) -> Result<(), String> { - self.play_with(client, tracks, index, |suffix| { - suffix.shuffle(&mut rand::rng()) - }) - .await + ) -> Result { + let target_track_id = tracks.get(index).map(|track| track.id).unwrap_or(0); + match self + .play_with(client, tracks, index, |suffix| { + suffix.shuffle(&mut rand::rng()) + }) + .await + { + Ok(outcome) => Ok(outcome), + Err(error @ PlaybackError::AuthorizationRequired { .. }) => { + Ok(PlayOutcome::PlaybackAuthorizationRequired( + error + .into_prompt(target_track_id) + .expect("authorization errors produce prompts"), + )) + } + Err(error) => Err(error.into_string()), + } } pub async fn replace_queue( &self, + client: Option>, tracks: Vec, index: usize, - ) -> Result<(), String> { + ) -> Result { if tracks.is_empty() || index >= tracks.len() { return Err("Choose a track to play".into()); } @@ -501,20 +639,42 @@ impl Playback { let snapshot = Snapshot::new_with(tracks, index, state.reducer.shuffle(), |suffix| { suffix.shuffle(&mut rand::rng()) }); - state.reducer.set_snapshot(Some(snapshot.clone())); let repeat = state.reducer.repeat().to_owned(); + let preload = snapshot + .current() + .uri + .starts_with("spotify:") + .then(|| preload_track(&snapshot, &repeat)) + .flatten() + .filter(|track| track.uri.starts_with("spotify:")) + .cloned(); + if self.local_requested() { + if let Some(next) = &preload { + let client = require_spotify(client.as_deref())?; + if let Err(error) = self.ensure_local_backend(&mut state, client).await { + log_authorization_required("preload", &next.uri, &error); + return match error.with_target(next.id) { + error @ PlaybackError::AuthorizationRequired { .. } => { + Ok(PlayOutcome::PlaybackAuthorizationRequired( + error + .into_prompt(next.id) + .expect("authorization errors produce prompts"), + )) + } + error => Err(error.into_string()), + }; + } + } + } + state.reducer.set_snapshot(Some(snapshot.clone())); state .backend .set_shuffle_snapshot(Some(snapshot.clone()), &repeat) .await; - if snapshot.current().uri.starts_with("spotify:") { - if let Some(next) = preload_track(&snapshot, &repeat) { - if next.uri.starts_with("spotify:") { - state.backend.preload(&next.uri)?; - } - } + if let Some(next) = preload { + state.backend.preload(&next.uri)?; } - Ok(()) + Ok(PlayOutcome::Started) } async fn play_with( @@ -523,7 +683,7 @@ impl Playback { tracks: Vec, index: usize, permute: impl FnOnce(&mut [usize]), - ) -> Result<(), String> { + ) -> Result { if tracks.is_empty() || index >= tracks.len() { return Err("Choose a track to play".into()); } @@ -533,12 +693,21 @@ impl Playback { generation, message: AUDIOBOOK_ERROR.into(), }); - return Ok(()); + return Ok(PlayOutcome::Started); } let mut state = self.state.lock().await; let snapshot = Snapshot::new_with(tracks, index, state.reducer.shuffle(), permute); + if self.local_requested() && !is_file_uri(&snapshot.current().uri) { + let client = require_spotify(client.as_deref())?; + if let Err(error) = self.ensure_local_backend(&mut state, client).await { + log_authorization_required("load", &snapshot.current().uri, &error); + return Err(error.with_target(snapshot.current().id)); + } + } state.reducer.set_snapshot(Some(snapshot)); - self.load_current_locked(&mut state, client, true, 0).await + self.load_current_locked(&mut state, client, true, 0) + .await + .map(|_| PlayOutcome::Started) } pub async fn toggle(&self, client: Option<&LiveClient>) -> Result<(), String> { @@ -571,15 +740,25 @@ impl Playback { state.backend.set_playing(client, playing).await } - pub async fn next(&self, client: Option>) -> Result<(), String> { - self.step(client, 1).await + pub async fn next(&self, client: Option>) -> Result { + self.step(client, 1) + .await + .map(|_| PlayOutcome::Started) + .or_else(play_outcome_from_error) } - pub async fn prev(&self, client: Option>) -> Result<(), String> { - self.step(client, -1).await + pub async fn prev(&self, client: Option>) -> Result { + self.step(client, -1) + .await + .map(|_| PlayOutcome::Started) + .or_else(play_outcome_from_error) } - async fn step(&self, client: Option>, direction: i8) -> Result<(), String> { + async fn step( + &self, + client: Option>, + direction: i8, + ) -> Result<(), PlaybackError> { let mut state = self.state.lock().await; self.step_locked(&mut state, client, direction).await } @@ -656,7 +835,18 @@ impl Playback { Ok(()) } + pub(crate) async fn stop_for_authorization( + &self, + app: &tauri::AppHandle, + prompt: PlaybackAuthorizationPrompt, + ) { + let mut state = self.state.lock().await; + self.report_authorization_required(app, &mut state, prompt) + .await; + } + pub async fn switch_to_local(&self, client: &LiveClient, volume: u8) -> Result<(), String> { + self.set_local_requested(true); let audio = *self.audio.lock().expect("audio settings mutex poisoned"); self.switch_to_local_with(Some(client), || async { let state = self.state.lock().await; @@ -671,6 +861,7 @@ impl Playback { audio, ) .await + .map_err(PlaybackError::into_string) }) .await } @@ -700,6 +891,7 @@ impl Playback { } pub async fn switch_to_connect(&self) { + self.set_local_requested(false); let mut state = self.state.lock().await; state.generation = state.generation.wrapping_add(1); let generation = state.generation; @@ -739,14 +931,48 @@ impl Playback { if !state.backend.is_local() || state.reducer.snapshot().is_none() { return Ok(()); } - self.ensure_player(&mut state, client).await + self.ensure_player(&mut state, client) + .await + .map_err(PlaybackError::into_string) + } + + async fn ensure_local_backend( + &self, + state: &mut ControllerState, + client: &LiveClient, + ) -> Result<(), PlaybackError> { + if state.backend.is_local() { + self.ensure_session(state, client).await?; + return Ok(()); + } + + let generation = state.generation.wrapping_add(1); + let audio = *self.audio.lock().expect("audio settings mutex poisoned"); + let local = LocalBackend::activate( + client, + self.events.clone(), + generation, + state.volume, + self.cache_dir.as_deref(), + audio, + ) + .await?; + if let PlayerBackend::Connect(connect) = &mut state.backend { + connect.stop(Some(client)).await?; + } + state.generation = generation; + state.file.set_generation(generation); + state.reducer.activate(generation); + state.volume = local.volume(); + state.backend = PlayerBackend::Local(local); + Ok(()) } async fn ensure_player( &self, state: &mut ControllerState, client: &LiveClient, - ) -> Result<(), String> { + ) -> Result<(), PlaybackError> { let invalid = matches!(&state.backend, PlayerBackend::Local(local) if local.player_is_invalid()); if !invalid { @@ -784,11 +1010,14 @@ impl Playback { &self, state: &mut ControllerState, client: &LiveClient, - ) -> Result<(), String> { + ) -> Result<(), PlaybackError> { self.ensure_player(state, client).await?; let invalid = matches!(&state.backend, PlayerBackend::Local(local) if local.session_is_invalid()); if !invalid { + if let PlayerBackend::Local(local) = &state.backend { + local.preflight(client).await?; + } return Ok(()); } log::info!( @@ -802,12 +1031,19 @@ impl Playback { "Spotify control session replaced; active player preserved generation={}", state.generation ); + if let PlayerBackend::Local(local) = &state.backend { + local.preflight(client).await?; + } Ok(()) } /// Outcome of one reconnect attempt. Superseded means a newer /// generation exists or there is nothing to resume — stop retrying. - async fn try_reconnect(&self, client: &LiveClient, generation: u64) -> Result { + async fn try_reconnect( + &self, + client: &LiveClient, + generation: u64, + ) -> Result { let mut state = self.state.lock().await; if state.generation != generation || !state.backend.is_local() @@ -815,7 +1051,10 @@ impl Playback { { return Ok(false); } - self.ensure_player(&mut state, client).await?; + let target_track_id = state.reducer.snapshot().unwrap().current().id; + self.ensure_player(&mut state, client) + .await + .map_err(|error| error.with_target(target_track_id))?; Ok(true) } @@ -824,7 +1063,7 @@ impl Playback { state: &mut ControllerState, client: Option>, direction: i8, - ) -> Result<(), String> { + ) -> Result<(), PlaybackError> { let wrap = direction > 0 && state.reducer.repeat() == "all"; let Some(snapshot) = state.reducer.snapshot() else { return Ok(()); @@ -834,13 +1073,29 @@ impl Playback { state.file.stop(); return Ok(()); } + if self.local_requested() { + return Ok(state.backend.stop(client.as_deref()).await?); + } let client = require_spotify(client.as_deref())?; self.ensure_player(state, client).await?; - return state.backend.step(client, direction).await; + return state + .backend + .step(client, direction) + .await + .map_err(PlaybackError::from); }; if reject_chapter(&snapshot.track_at(next).uri) { return Err(AUDIOBOOK_ERROR.into()); } + let next_track_id = snapshot.track_at(next).id; + let next_uri = snapshot.track_at(next).uri.clone(); + if self.local_requested() && !is_file_uri(&next_uri) { + let client = require_spotify(client.as_deref())?; + if let Err(error) = self.ensure_local_backend(state, client).await { + log_authorization_required("advance", &next_uri, &error); + return Err(error.with_target(next_track_id)); + } + } state.reducer.snapshot_mut().unwrap().index = next; self.load_current_locked(state, client, true, 0).await } @@ -851,7 +1106,7 @@ impl Playback { client: Option>, playing: bool, position_ms: u32, - ) -> Result<(), String> { + ) -> Result<(), PlaybackError> { let snapshot = state .reducer .snapshot() @@ -861,15 +1116,42 @@ impl Playback { if is_file_uri(&uri) { state.backend.stop(client.as_deref()).await?; state.reducer.queue_load(&uri, playing); - return state.file.load(&uri, playing, position_ms); + return Ok(state.file.load(&uri, playing, position_ms)?); } state.file.stop_silently(); let client = client.ok_or_else(missing_spotify)?; - self.ensure_session(state, client.as_ref()).await?; + if self.local_requested() { + if let Err(error) = self.ensure_local_backend(state, client.as_ref()).await { + log_authorization_required("load", &uri, &error); + return Err(error.with_target(snapshot.current().id)); + } + } else { + self.ensure_session(state, client.as_ref()).await?; + } state.reducer.queue_load(&uri, playing); let repeat = state.reducer.repeat().to_owned(); - state.backend.play(client, snapshot, &repeat).await + Ok(state.backend.play(client, snapshot, &repeat).await?) + } + + async fn report_authorization_required( + &self, + app: &tauri::AppHandle, + state: &mut ControllerState, + prompt: PlaybackAuthorizationPrompt, + ) { + if state.file.is_active() { + state.file.stop(); + } + let client = crate::provider_from(&app.state::()).ok(); + if let Err(error) = state.backend.stop(client.as_deref()).await { + log::warn!("Could not stop playback after authorization rejection: {error}"); + } + let shuffle = state.reducer.state().shuffle; + state.reducer.set_snapshot(None); + let _ = app.emit("player-state", empty_event(false, shuffle)); + let _ = crate::emit_connection_state(app); + let _ = app.emit("playback-authorization-required", prompt); } async fn handle_event( @@ -913,27 +1195,59 @@ impl Playback { let client = crate::provider_from(&app.state::()).ok(); let result = async { let client = require_spotify(client.as_deref())?; - self.ensure_session(&mut state, client).await?; - state.backend.preload(&uri) + if self.local_requested() { + self.ensure_local_backend(&mut state, client).await?; + } else { + self.ensure_session(&mut state, client).await?; + } + Ok(state.backend.preload(&uri)?) } .await; match result { Ok(true) => log::info!("Preload requested: {uri}"), Ok(false) => log::debug!("Preload ignored: {uri}"), + Err(error @ PlaybackError::AuthorizationRequired { .. }) => { + log::warn!( + "Spotify playback authorization failed during speculative preload; current track continues: {error}" + ); + let _ = crate::emit_connection_state(app); + } Err(error) => log::warn!("Unable to preload {uri}: {error}"), } } ReducerAction::Advance => { let client = crate::provider_from(&app.state::()).ok(); if let Err(error) = self.step_locked(&mut state, client, 1).await { - let _ = app.emit("operation-error", error); + match error { + error @ PlaybackError::AuthorizationRequired { .. } => { + let prompt = error + .into_prompt(0) + .expect("authorization errors produce prompts"); + self.report_authorization_required(app, &mut state, prompt) + .await; + } + error => { + let _ = app.emit("operation-error", error.into_string()); + } + } } } ReducerAction::Reload => { let client = crate::provider_from(&app.state::()).ok(); let result = self.load_current_locked(&mut state, client, true, 0).await; if let Err(error) = result { - let _ = app.emit("operation-error", error); + match error { + error @ PlaybackError::AuthorizationRequired { .. } => { + let prompt = error + .into_prompt(0) + .expect("authorization errors produce prompts"); + self.report_authorization_required(app, &mut state, prompt) + .await; + } + error => { + let _ = app.emit("operation-error", error.into_string()); + } + } } } ReducerAction::Invalidate => { @@ -977,6 +1291,16 @@ impl Playback { log::debug!("Playback reconnect superseded"); return; } + Err(error @ PlaybackError::AuthorizationRequired { .. }) => { + let mut state = playback.state.lock().await; + let prompt = error + .into_prompt(0) + .expect("authorization errors produce prompts"); + playback + .report_authorization_required(&app, &mut state, prompt) + .await; + return; + } Err(error) => { log::info!( "Playback reconnect attempt {} failed: {error}", @@ -1000,6 +1324,27 @@ impl Playback { } } +fn play_outcome_from_error(error: PlaybackError) -> Result { + match error { + error @ PlaybackError::AuthorizationRequired { .. } => { + Ok(PlayOutcome::PlaybackAuthorizationRequired( + error + .into_prompt(0) + .expect("authorization errors produce prompts"), + )) + } + error => Err(error.into_string()), + } +} + +fn log_authorization_required(operation: &str, uri: &str, error: &PlaybackError) { + if let PlaybackError::AuthorizationRequired { reason, .. } = error { + log::warn!( + "Spotify playback authorization required operation={operation} uri={uri} reason={reason:?}" + ); + } +} + fn step_index(index: usize, len: usize, direction: i8, wrap: bool) -> Option { if direction < 0 { Some(index.saturating_sub(1)) @@ -1111,6 +1456,15 @@ mod tests { .collect() } + fn client_without_playback_credentials() -> Arc { + let tokens: Box = Box::new(InMemoryTokenStore::new(None)); + Arc::new(SpotifyClient::new( + "test", + HttpTransport::new(), + Arc::new(CachedTokenStore::new(tokens)), + )) + } + #[test] fn shuffle_only_permutes_future_and_restores_duplicate_occurrence() { let duplicate = SnapshotTrack { @@ -1219,10 +1573,16 @@ mod tests { let before = playback.state.lock().await.file.request_id(); assert_eq!( - playback.replace_queue(file_tracks(2), 1).await.unwrap_err(), + playback + .replace_queue(None, file_tracks(2), 1) + .await + .unwrap_err(), "The replacement queue must keep the current track" ); - playback.replace_queue(file_tracks(3), 0).await.unwrap(); + playback + .replace_queue(None, file_tracks(3), 0) + .await + .unwrap(); { let mut state = playback.state.lock().await; @@ -1271,6 +1631,46 @@ mod tests { } } + #[tokio::test] + async fn replacing_queue_waits_for_playback_authorization_before_mutating() { + let playback = Playback::default(); + playback.set_local_requested(true); + let mut current = mixed_tracks().pop().unwrap(); + current.id = 10; + current.uri = "spotify:track:current".into(); + let mut next = current.clone(); + next.id = 11; + next.uri = "spotify:track:next".into(); + playback + .state + .lock() + .await + .reducer + .set_snapshot(Some(Snapshot::new(vec![current.clone()], 0))); + + let outcome = playback + .replace_queue( + Some(client_without_playback_credentials()), + vec![current, next], + 0, + ) + .await + .unwrap(); + + assert!(matches!( + outcome, + PlayOutcome::PlaybackAuthorizationRequired(PlaybackAuthorizationPrompt { + reason: PlaybackAuthorizationReason::Missing, + target_track_id: 11, + .. + }) + )); + let state = playback.state.lock().await; + let snapshot = state.reducer.snapshot().unwrap(); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot.current().id, 10); + } + #[tokio::test] async fn enabled_shuffle_constructs_exact_queue_then_event_advance_and_prev_follow_it() { let playback = Playback::default(); @@ -1437,6 +1837,61 @@ mod tests { assert!(state.reducer.snapshot().is_none()); } + #[tokio::test] + async fn missing_playback_authorization_does_not_commit_a_spotify_queue() { + let playback = Playback::default(); + playback.set_local_requested(true); + let track = mixed_tracks().pop().unwrap(); + let outcome = playback + .play(Some(client_without_playback_credentials()), vec![track], 0) + .await + .unwrap(); + + assert!(matches!( + outcome, + PlayOutcome::PlaybackAuthorizationRequired(PlaybackAuthorizationPrompt { + reason: PlaybackAuthorizationReason::Missing, + target_track_id: 2, + .. + }) + )); + assert!(playback.state.lock().await.reducer.snapshot().is_none()); + } + + #[tokio::test] + async fn local_files_remain_playable_without_playback_authorization() { + let playback = Playback::default(); + playback.set_local_requested(true); + + assert_eq!( + playback.play(None, file_tracks(1), 0).await.unwrap(), + PlayOutcome::Started + ); + assert!(playback.state.lock().await.file.is_active()); + } + + #[tokio::test] + async fn playback_auth_failure_does_not_advance_from_a_local_file() { + let playback = Playback::default(); + playback.set_local_requested(true); + playback.play(None, mixed_tracks(), 0).await.unwrap(); + + assert!(matches!( + playback + .next(Some(client_without_playback_credentials())) + .await + .unwrap(), + PlayOutcome::PlaybackAuthorizationRequired(PlaybackAuthorizationPrompt { + reason: PlaybackAuthorizationReason::Missing, + target_track_id: 2, + .. + }) + )); + let state = playback.state.lock().await; + assert_eq!(state.reducer.snapshot().unwrap().index, 0); + assert!(state.file.is_active()); + } + #[tokio::test] async fn manual_next_and_prev_do_not_complete_tracks() { let playback = Playback::default(); @@ -1650,7 +2105,11 @@ mod tests { )); assert_eq!( - playback.step_locked(&mut state, None, 1).await.unwrap_err(), + playback + .step_locked(&mut state, None, 1) + .await + .unwrap_err() + .into_string(), missing_spotify() ); assert_eq!(state.reducer.snapshot().unwrap().index, 1); diff --git a/apps/desktop/src-tauri/src/playback_commands.rs b/apps/desktop/src-tauri/src/playback_commands.rs index e3c92ff..546f314 100644 --- a/apps/desktop/src-tauri/src/playback_commands.rs +++ b/apps/desktop/src-tauri/src/playback_commands.rs @@ -1,14 +1,25 @@ use super::*; +use crate::playback::PlayOutcome; #[tauri::command(rename_all = "camelCase")] pub(super) async fn play_tracks( app: tauri::AppHandle, snapshot: Vec, start_index: usize, -) -> Result<(), String> { +) -> Result { let state = app.state::(); let client = provider_from(&state).ok(); - state.playback.play(client, snapshot, start_index).await + let outcome = state + .playback + .play(client.clone(), snapshot, start_index) + .await?; + if let PlayOutcome::PlaybackAuthorizationRequired(prompt) = &outcome { + state + .playback + .stop_for_authorization(&app, prompt.clone()) + .await; + } + Ok(outcome) } #[tauri::command(rename_all = "camelCase")] @@ -16,10 +27,12 @@ pub(super) async fn replace_queue( app: tauri::AppHandle, snapshot: Vec, current_index: usize, -) -> Result<(), String> { - app.state::() +) -> Result { + let state = app.state::(); + let client = provider_from(&state).ok(); + state .playback - .replace_queue(snapshot, current_index) + .replace_queue(client, snapshot, current_index) .await } @@ -34,14 +47,20 @@ pub(super) async fn player_toggle(app: tauri::AppHandle) -> Result<(), String> { pub(super) async fn player_next(app: tauri::AppHandle) -> Result<(), String> { let state = app.state::(); let client = provider_from(&state).ok(); - state.playback.next(client).await + if let PlayOutcome::PlaybackAuthorizationRequired(prompt) = state.playback.next(client).await? { + state.playback.stop_for_authorization(&app, prompt).await; + } + Ok(()) } #[tauri::command] pub(super) async fn player_prev(app: tauri::AppHandle) -> Result<(), String> { let state = app.state::(); let client = provider_from(&state).ok(); - state.playback.prev(client).await + if let PlayOutcome::PlaybackAuthorizationRequired(prompt) = state.playback.prev(client).await? { + state.playback.stop_for_authorization(&app, prompt).await; + } + Ok(()) } #[tauri::command] diff --git a/apps/desktop/src-tauri/src/playlists.rs b/apps/desktop/src-tauri/src/playlists.rs index a40acc3..cc35307 100644 --- a/apps/desktop/src-tauri/src/playlists.rs +++ b/apps/desktop/src-tauri/src/playlists.rs @@ -1210,6 +1210,7 @@ mod tests { refresh: String::new(), expires_at: 0, scopes: "user-library-read".into(), + playback_credentials: None, }; let error = Error::Http { endpoint: "/playlists/id/tracks".into(), diff --git a/apps/desktop/src-tauri/src/spotify_commands.rs b/apps/desktop/src-tauri/src/spotify_commands.rs index 8d60f1a..80265ae 100644 --- a/apps/desktop/src-tauri/src/spotify_commands.rs +++ b/apps/desktop/src-tauri/src/spotify_commands.rs @@ -1,4 +1,6 @@ use super::*; +use librespot_core::{authentication::Credentials, config::SessionConfig, session::Session}; +use retune_spotify::tokens::PlaybackCredentials; #[tauri::command] pub(super) fn connection_state( @@ -53,6 +55,11 @@ pub(super) async fn connect_spotify(app: tauri::AppHandle) -> Result<(), String> let now = unix_now(); let state = app.state::(); let granted_scopes = token.scope.unwrap_or_else(|| auth::SCOPES.clone()); + let playback_credentials = state + .token_store + .load() + .map_err(|error| error.to_string())? + .and_then(|tokens| tokens.playback_credentials); state .token_store .save(&Tokens { @@ -60,6 +67,7 @@ pub(super) async fn connect_spotify(app: tauri::AppHandle) -> Result<(), String> refresh, expires_at: now.saturating_add(token.expires_in), scopes: granted_scopes, + playback_credentials, }) .map_err(|error| error.to_string())?; *state.spotify.lock().expect("spotify mutex poisoned") = @@ -69,6 +77,81 @@ pub(super) async fn connect_spotify(app: tauri::AppHandle) -> Result<(), String> sync_spotify(&app).await } +#[tauri::command] +pub(super) async fn authorize_spotify_playback(app: tauri::AppHandle) -> Result<(), String> { + let client_id = SessionConfig::default().client_id; + let listener = LoopbackListener::bind_on(8898).map_err(|error| error.to_string())?; + let redirect_uri = listener + .redirect_uri_for("/login") + .map_err(|error| error.to_string())?; + let state = auth::random_state(); + let pkce = Pkce::generate(); + let url = auth::authorize_url_with_scopes( + &client_id, + &redirect_uri, + &state, + &pkce.challenge, + auth::PLAYBACK_SCOPE, + ) + .map_err(|error| error.to_string())?; + app.opener() + .open_url(url.to_string(), None::) + .map_err(|error| error.to_string())?; + let callback = tauri::async_runtime::spawn_blocking(move || { + listener.accept_path(&state, "/login", Duration::from_secs(180)) + }) + .await + .map_err(|error| error.to_string())? + .map_err(|error| error.to_string())?; + let token = auth::exchange_code( + &reqwest::Client::new(), + &client_id, + &callback.code, + &redirect_uri, + &pkce.verifier, + ) + .await + .map_err(|error| error.to_string())?; + + let session = Session::new(SessionConfig::default(), None); + session + .connect(Credentials::with_access_token(token.access_token), false) + .await + .map_err(|error| error.to_string())?; + if let Err(error) = session.login5().auth_token().await { + session.shutdown(); + return Err(error.to_string()); + } + let playback_credentials = playback_credentials(session.username(), session.auth_data())?; + session.shutdown(); + + let state = app.state::(); + let mut tokens = state + .token_store + .load() + .map_err(|error| error.to_string())? + .ok_or_else(|| "Connect to Spotify before authorizing playback.".to_string())?; + tokens.playback_credentials = Some(playback_credentials); + state + .token_store + .save(&tokens) + .map_err(|error| error.to_string())?; + emit_connection_state(&app) +} + +fn playback_credentials( + username: String, + auth_data: Vec, +) -> Result { + if username.is_empty() || auth_data.is_empty() { + return Err("Spotify did not return reusable playback credentials.".into()); + } + Ok(PlaybackCredentials { + username, + auth_data, + }) +} + #[tauri::command] pub(super) async fn disconnect_spotify(app: tauri::AppHandle) -> Result<(), String> { let state = app.state::(); @@ -364,3 +447,20 @@ pub(super) async fn remove_spotify_track(app: tauri::AppHandle, uri: String) -> app.emit("library-changed", ()) .map_err(|error| error.to_string()) } + +#[cfg(test)] +mod tests { + use super::playback_credentials; + + #[test] + fn playback_credentials_require_both_session_parts() { + assert!(playback_credentials(String::new(), vec![1]).is_err()); + assert!(playback_credentials("user".into(), vec![]).is_err()); + assert_eq!( + playback_credentials("user".into(), vec![1, 2, 3]) + .unwrap() + .auth_data, + [1, 2, 3] + ); + } +} diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs index edb9949..9fa3432 100644 --- a/apps/desktop/src-tauri/src/store.rs +++ b/apps/desktop/src-tauri/src/store.rs @@ -585,6 +585,7 @@ mod tests { refresh: "refresh".into(), expires_at: 42, scopes: "streaming".into(), + playback_credentials: None, }; assert!(store.load().unwrap().is_none()); diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index 0b5f83d..537288c 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -231,6 +231,7 @@ button { color: inherit; } .sync-status { display: flex; align-items: center; justify-content: center; gap: 8px; } .sync-meter { width: 120px; height: 5px; accent-color: var(--accent); } .error-banner { position: absolute; right: 10px; bottom: 30px; max-width: 430px; padding: 5px 8px; border: 1px solid #b33; color: #fff; background: #8d2d2d; } +.dialog-error { color: var(--danger); } .startup-notice { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--notice-border); color: var(--notice-text); background: var(--notice-bg); } .startup-notice button { border: 0; color: inherit; background: transparent; font-size: 16px; } .reauth-notice button { padding: 3px 8px; border: 1px solid currentColor; border-radius: 3px; font-size: inherit; } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f79eeb7..3fbabe8 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -3,11 +3,11 @@ import { listen } from '@tauri-apps/api/event' import { getCurrentWindow } from '@tauri-apps/api/window' import { Fragment, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import './App.css' -import { browseRequestKey, browseViewForRequest, COLUMN_SPECS, compareTracks, contiguousRange, DRAG_LOCAL_TYPE, DRAG_TYPE, facetLabel, formatTime, hasLocalTracks, insertionIndexAtY, isCurrentTrack, labels, moveToIndex, nextNativeDragActive, normalizeZoom, playbackOriginAction, playbackQueue, playlistRows, replacementQueue, selectionAfterFacet, SYNTHETIC_BASE, trackColumnHeadings, trackGridColumns } from './ui.ts' -import { GetInfo, MultipleItemInformation, Preferences, SetupLibrary } from './dialogViews.tsx' +import { browseRequestKey, browseViewForRequest, COLUMN_SPECS, compareTracks, contiguousRange, DRAG_LOCAL_TYPE, DRAG_TYPE, facetLabel, formatTime, hasLocalTracks, insertionIndexAtY, isCurrentTrack, labels, moveToIndex, nextNativeDragActive, normalizeZoom, pendingPlaybackTarget, playbackAuthorizationPrompt, playbackOriginAction, playbackQueue, playbackRetryReady, playbackStartAction, playlistRows, replacementQueue, selectionAfterFacet, SYNTHETIC_BASE, trackColumnHeadings, trackGridColumns } from './ui.ts' +import { GetInfo, MultipleItemInformation, PlaybackAuthorization, Preferences, SetupLibrary } from './dialogViews.tsx' import { AlbumRatingStrip, BrowserPane, TrackCell, TrackList } from './libraryViews.tsx' import { SpotifySearch } from './spotifyViews.tsx' -import type { ActivePane, BrowseView, BrowserPanes, ColumnKey, ConnectionState, ImportSummary, InfoDialog, PlaybackOrigin, PlaybackTrack, PlayerState, Playing, PlaylistListView, PlaylistSubject, PlaylistTrack, RepeatMode, Selection, Settings, Source, SpotifyNavEntry, SpotifyResults, Theme, Track, TrackInfo } from './types.ts' +import type { ActivePane, BrowseView, BrowserPanes, ColumnKey, ConnectionState, ImportSummary, InfoDialog, PlaybackAuthorizationPrompt, PlaybackOrigin, PlaybackTrack, PlayOutcome, PlayerState, Playing, PlaylistListView, PlaylistSubject, PlaylistTrack, RepeatMode, Selection, Settings, Source, SpotifyNavEntry, SpotifyResults, Theme, Track, TrackInfo } from './types.ts' import { CheckboxMenu, ContextMenu, ModalDialog } from './viewShared.tsx' const LOCAL_PLAYLIST_HINT = "Selection includes local files — Spotify playlists can't contain them." @@ -38,6 +38,7 @@ type State = { info?: InfoDialog preferences: boolean setup: boolean + playbackAuthorization: PlaybackAuthorizationPrompt | null connection: ConnectionState spotifyResults: SpotifyResults | null spotifySearching: boolean @@ -76,6 +77,7 @@ type Action = | { type: 'info'; info?: InfoDialog } | { type: 'preferences'; open: boolean } | { type: 'setup'; open: boolean } + | { type: 'playbackAuthorization'; prompt: PlaybackAuthorizationPrompt | null } | { type: 'connection'; connection: ConnectionState } | { type: 'spotifyResults'; results: SpotifyResults | null } | { type: 'spotifySearching'; searching: boolean } @@ -131,7 +133,8 @@ const initialState: State = { revision: 0, preferences: false, setup: false, - connection: { connected: false, needs_reauth: false }, + playbackAuthorization: null, + connection: { connected: false, needs_reauth: false, playback_authorized: false }, spotifyResults: null, spotifySearching: false, playlistRevision: 0, @@ -222,8 +225,12 @@ function reducer(state: State, action: Action): State { return { ...state, preferences: action.open, setup: false, info: undefined } case 'setup': return { ...state, setup: action.open, preferences: false, info: undefined } + case 'playbackAuthorization': + return action.prompt + ? { ...state, playbackAuthorization: action.prompt, info: undefined, preferences: false, setup: false } + : { ...state, playbackAuthorization: null } case 'connection': - return { ...state, connection: action.connection } + return { ...state, connection: action.connection, playbackAuthorization: action.connection.playback_authorized ? null : state.playbackAuthorization } case 'spotifyResults': return { ...state, spotifyResults: action.results, spotifySearching: false } case 'spotifySearching': @@ -257,10 +264,10 @@ function useTauriEvent(event: string, handler: (payload: T) => void }, [event]) } -function usePlayer(connected: boolean, playing: Playing | null, dispatch: React.Dispatch) { +function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Playing | null, dispatch: React.Dispatch) { const queue = useRef(emptyTracks) const origin = useRef(undefined) - const pendingPlay = useRef<{ id: number; tracks: readonly PlaybackTrack[]; origin?: PlaybackOrigin } | null>(null) + const pendingPlay = useRef<{ id: number; tracks: readonly PlaybackTrack[]; origin?: PlaybackOrigin; awaitingPlaybackAuthorization: boolean } | null>(null) const starting = useRef<{ id: number; uri: string } | null>(null) const playingRef = useRef(playing) const volumeTimer = useRef(undefined) @@ -272,6 +279,12 @@ function usePlayer(connected: boolean, playing: Playing | null, dispatch: React. dispatch({ type: 'playerState', player, queue: queue.current, origin: origin.current }) }) + useTauriEvent('playback-authorization-required', (prompt) => { + const id = pendingPlaybackTarget(prompt, queue.current) + if (id !== null) pendingPlay.current = { id, tracks: queue.current, origin: origin.current, awaitingPlaybackAuthorization: true } + dispatch({ type: 'playbackAuthorization', prompt }) + }) + const run = useCallback((command: string, args?: Record) => { invoke(command, args).catch((error) => dispatch({ type: 'error', error: String(error) })) }, [dispatch]) @@ -284,10 +297,10 @@ function usePlayer(connected: boolean, playing: Playing | null, dispatch: React. const start = useCallback((id: number, tracks: readonly PlaybackTrack[], launchOrigin?: PlaybackOrigin) => { const playable = playbackQueue(tracks, id) const target = playable.find((track) => track.id === id) - if (target?.uri.startsWith('spotify:') && !connected) { + if (playbackStartAction(target?.uri, connected) === 'connect') { // Kick off the OAuth flow instead of erroring; the pending play fires // once connection-changed reports connected. - pendingPlay.current = { id, tracks: playable, origin: launchOrigin } + pendingPlay.current = { id, tracks: playable, origin: launchOrigin, awaitingPlaybackAuthorization: false } run('connect_spotify') return } @@ -295,7 +308,14 @@ function usePlayer(connected: boolean, playing: Playing | null, dispatch: React. queue.current = playable origin.current = launchOrigin starting.current = { id, uri: target.uri } - invoke('play_tracks', { snapshot: playable, startIndex: playable.findIndex((track) => track.id === id) }) + invoke('play_tracks', { snapshot: playable, startIndex: playable.findIndex((track) => track.id === id) }) + .then((outcome) => { + const prompt = playbackAuthorizationPrompt(outcome) + if (!prompt) return + if (starting.current?.id === id && starting.current.uri === target.uri) starting.current = null + pendingPlay.current = { id, tracks: playable, origin: launchOrigin, awaitingPlaybackAuthorization: true } + dispatch({ type: 'playbackAuthorization', prompt }) + }) .catch((error) => { if (starting.current?.id === id && starting.current.uri === target.uri) starting.current = null dispatch({ type: 'error', error: String(error) }) @@ -313,8 +333,9 @@ function usePlayer(connected: boolean, playing: Playing | null, dispatch: React. dispatch({ type: 'queue', queue: replacement.queue, origin: nextOrigin }) return } - invoke('replace_queue', { snapshot: replacement.queue, currentIndex: replacement.index }) - .then(() => { + invoke('replace_queue', { snapshot: replacement.queue, currentIndex: replacement.index }) + .then((outcome) => { + if (playbackAuthorizationPrompt(outcome)) return queue.current = replacement.queue origin.current = nextOrigin dispatch({ type: 'queue', queue: replacement.queue, origin: nextOrigin }) @@ -324,10 +345,16 @@ function usePlayer(connected: boolean, playing: Playing | null, dispatch: React. useEffect(() => { if (!connected || !pendingPlay.current) return + if (!playbackRetryReady(connected, playbackAuthorized, pendingPlay.current.awaitingPlaybackAuthorization)) return const { id, tracks, origin: launchOrigin } = pendingPlay.current pendingPlay.current = null start(id, tracks, launchOrigin) - }, [connected, start]) + }, [connected, playbackAuthorized, start]) + + const cancelPending = useCallback(() => { + pendingPlay.current = null + dispatch({ type: 'playbackAuthorization', prompt: null }) + }, [dispatch]) const toggle = useCallback(() => { if (liveBackend()) { @@ -364,7 +391,7 @@ function usePlayer(connected: boolean, playing: Playing | null, dispatch: React. useEffect(() => () => window.clearTimeout(volumeTimer.current), []) - return useMemo(() => ({ start, replace, toggle, step, setVolume, seek }), [replace, seek, setVolume, start, step, toggle]) + return useMemo(() => ({ start, replace, toggle, step, setVolume, seek, cancelPending }), [cancelPending, replace, seek, setVolume, start, step, toggle]) } function App() { @@ -392,7 +419,7 @@ function App() { const tracklistVisible = !spotifySearchActive && !state.selectedPlaylist const libraryEmpty = view?.counts.perSource[state.source] === 0 && !state.syncPhase && !state.syncProgress const playbackTracks = state.playing?.queue ?? emptyTracks - const player = usePlayer(state.connection.connected, state.playing, dispatch) + const player = usePlayer(state.connection.connected, state.connection.playback_authorized, state.playing, dispatch) const addToPlaylist = useCallback((id: string, subject: PlaylistSubject) => subject.kind === 'album' ? invoke('playlist_add_album', { id, albumUri: subject.albumUri, albumLabel: subject.label }) : invoke('playlist_add', { id, uris: subject.uris }), []) @@ -633,12 +660,13 @@ function App() { const onKeyDown = useRef<(event: KeyboardEvent) => void>(() => {}) onKeyDown.current = (event: KeyboardEvent) => { - const modalOpen = Boolean(state.info || state.preferences || state.setup || playlistSubject) + const modalOpen = Boolean(state.info || state.preferences || state.setup || state.playbackAuthorization || playlistSubject) if (event.key === 'Escape' && modalOpen) { event.preventDefault() if (state.info) dispatch({ type: 'info' }) else if (state.preferences) cancelPreferences() else if (state.setup) dispatch({ type: 'setup', open: false }) + else if (state.playbackAuthorization) player.cancelPending() else setPlaylistSubject(undefined) return } @@ -720,13 +748,13 @@ function App() { useEffect(() => { const onWheel = (event: WheelEvent) => { - if (!(event.metaKey || event.ctrlKey) || state.info || state.preferences || state.setup || playlistSubject) return + if (!(event.metaKey || event.ctrlKey) || state.info || state.preferences || state.setup || state.playbackAuthorization || playlistSubject) return event.preventDefault() setZoom(state.settings.zoom + (event.deltaY < 0 ? 0.1 : -0.1)) } window.addEventListener('wheel', onWheel, { passive: false }) return () => window.removeEventListener('wheel', onWheel) - }, [playlistSubject, state.info, state.preferences, state.setup, state.settings.zoom]) + }, [playlistSubject, state.info, state.playbackAuthorization, state.preferences, state.setup, state.settings.zoom]) const selectedPlaylist = playlists?.find((playlist) => playlist.id === state.selectedPlaylist) const playlistHiddenColumns = selectedPlaylist @@ -912,6 +940,7 @@ function App() { .catch(fail) dispatch({ type: 'preferences', open: false }) }} />} + {state.playbackAuthorization && invoke('authorize_spotify_playback')} />} {playlistSubject && setPlaylistSubject(undefined)} onError={(error) => dispatch({ type: 'error', error })} />} {nativeDragActive &&
Drop to add to LibraryAudio files and folders
} diff --git a/apps/desktop/src/dialogViews.tsx b/apps/desktop/src/dialogViews.tsx index 117521e..2d7370d 100644 --- a/apps/desktop/src/dialogViews.tsx +++ b/apps/desktop/src/dialogViews.tsx @@ -1,6 +1,6 @@ import { invoke } from '@tauri-apps/api/core' import { useEffect, useMemo, useState } from 'react' -import type { MetadataValues, PlayThresholdPercent, PlaylistTrack, Settings, Theme, TrackInfo } from './types.ts' +import type { MetadataValues, PlaybackAuthorizationPrompt, PlayThresholdPercent, PlaylistTrack, Settings, Theme, TrackInfo } from './types.ts' import { clearedTrackRating, overlayEditTargets } from './ui.ts' import { ModalDialog, RatingStars } from './viewShared.tsx' @@ -144,6 +144,33 @@ export function SetupLibrary({ settings, connected, onCancel, onConnect, onSync } +export function PlaybackAuthorization({ prompt, onCancel, onAuthorize }: { + prompt: PlaybackAuthorizationPrompt + onCancel: () => void + onAuthorize: () => void | Promise +}) { + const [authorizing, setAuthorizing] = useState(false) + const [error, setError] = useState() + const authorize = async () => { + setError(undefined) + setAuthorizing(true) + try { + await onAuthorize() + } catch (error) { + setError(String(error)) + } finally { + setAuthorizing(false) + } + } + return +

Authorize Spotify playback

+

{prompt.message}

+

Your Spotify library access remains connected.

+ {error &&

{error}

} +
+
+} + export function Preferences({ settings, onZoom, onCancel, onSave }: { settings: Settings onZoom: (zoom: number) => void diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index 49c54f2..8f8c4b9 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -35,7 +35,13 @@ export type Settings = { playThresholdPercent: PlayThresholdPercent } -export type ConnectionState = { connected: boolean; needs_reauth: boolean } +export type ConnectionState = { connected: boolean; needs_reauth: boolean; playback_authorized: boolean } +export type PlaybackAuthorizationPrompt = { + reason: 'missing' | 'rejected' + message: string + targetTrackId: number +} +export type PlayOutcome = 'started' | { playbackAuthorizationRequired: PlaybackAuthorizationPrompt } export type ImportSummary = { imported: number; duplicates: number; failed: { path: string; reason: string }[] } export type PlaylistListView = { id: string diff --git a/apps/desktop/src/ui.ts b/apps/desktop/src/ui.ts index 33f1273..3871ea4 100644 --- a/apps/desktop/src/ui.ts +++ b/apps/desktop/src/ui.ts @@ -1,4 +1,4 @@ -import type { ColumnKey, PlaybackOrigin, PlaybackTrack, Playing, PlaylistSubject, Selection, Source, Track } from './types.ts' +import type { ColumnKey, PlaybackAuthorizationPrompt, PlaybackOrigin, PlaybackTrack, PlayOutcome, Playing, PlaylistSubject, Selection, Source, Track } from './types.ts' export type NativeDragEvent = { type: 'enter'; paths: string[] } | { type: 'over' } | { type: 'drop' } | { type: 'leave' } @@ -33,6 +33,18 @@ export const clearedTrackRating = (inherited: number | null) => export const playbackQueue = (tracks: readonly PlaybackTrack[], requestedId: number) => tracks.filter((track) => track.enabled || track.id === requestedId) +export const playbackStartAction = (uri: string | undefined, connected: boolean) => + uri?.startsWith('spotify:') && !connected ? 'connect' as const : 'play' as const + +export const playbackAuthorizationPrompt = (outcome: PlayOutcome | undefined) => + typeof outcome === 'object' ? outcome.playbackAuthorizationRequired : null + +export const pendingPlaybackTarget = (prompt: PlaybackAuthorizationPrompt, tracks: readonly PlaybackTrack[]) => + tracks.some((track) => track.id === prompt.targetTrackId) ? prompt.targetTrackId : null + +export const playbackRetryReady = (connected: boolean, playbackAuthorized: boolean, awaitingAuthorization: boolean) => + connected && (!awaitingAuthorization || playbackAuthorized) + export const replacementQueue = (tracks: readonly PlaybackTrack[], playing: Playing | null) => { if (!playing || playing.external || playing.trackId === null) return null const queue = playbackQueue(tracks, playing.trackId) diff --git a/apps/desktop/test/ui.test.ts b/apps/desktop/test/ui.test.ts index 7014e0e..24a87b4 100644 --- a/apps/desktop/test/ui.test.ts +++ b/apps/desktop/test/ui.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import type { PlaybackTrack, Playing } from '../src/types.ts' -import { browseRequestKey, browseViewForRequest, clearedTrackRating, compareTracks, contiguousRange, dialogTabTarget, facetLabel, insertionIndexAtY, isCurrentTrack, menuPosition, mergeByUri, moveBefore, moveToIndex, nextNativeDragActive, normalizeZoom, overlayEditTargets, playbackOriginAction, playbackQueue, playlistRows, replacementQueue, resizedColumnWidth, resizedPaneHeight, selectionAfterFacet, SYNTHETIC_BASE } from '../src/ui.ts' +import { browseRequestKey, browseViewForRequest, clearedTrackRating, compareTracks, contiguousRange, dialogTabTarget, facetLabel, insertionIndexAtY, isCurrentTrack, menuPosition, mergeByUri, moveBefore, moveToIndex, nextNativeDragActive, normalizeZoom, overlayEditTargets, pendingPlaybackTarget, playbackAuthorizationPrompt, playbackOriginAction, playbackQueue, playbackRetryReady, playbackStartAction, playlistRows, replacementQueue, resizedColumnWidth, resizedPaneHeight, selectionAfterFacet, SYNTHETIC_BASE } from '../src/ui.ts' test('pending navigation cannot use prior tracks, while a data refresh keeps them visible', () => { const broadQueue: PlaybackTrack[] = [ @@ -132,6 +132,21 @@ test('playback origins return to the launching library or playlist', () => { assert.deepEqual(playbackOriginAction({ kind: 'playlist', id: 'road-trip' }), { type: 'playlist', id: 'road-trip' }) }) +test('playback start and result decisions keep OAuth outside the controller', () => { + assert.equal(playbackStartAction('spotify:track:one', false), 'connect') + assert.equal(playbackStartAction('spotify:track:one', true), 'play') + assert.equal(playbackStartAction('file:///tmp/one.mp3', false), 'play') + const prompt = { reason: 'missing' as const, message: 'Authorize playback.', targetTrackId: 2 } + assert.deepEqual(playbackAuthorizationPrompt({ playbackAuthorizationRequired: prompt }), prompt) + assert.equal(playbackAuthorizationPrompt('started'), null) + assert.equal(pendingPlaybackTarget(prompt, [{ id: 2, uri: 'spotify:track:two', enabled: true }]), 2) + assert.equal(pendingPlaybackTarget(prompt, []), null) + assert.equal(playbackRetryReady(false, false, true), false) + assert.equal(playbackRetryReady(true, false, true), false) + assert.equal(playbackRetryReady(true, true, true), true) + assert.equal(playbackRetryReady(true, false, false), true) +}) + test('track and disc sorts keep multi-disc albums in playback order', () => { const track = (discNo: number | null, trackNo: number) => ({ discNo, trackNo } as never) const tracks = [track(2, 1), track(1, 2), track(null, 1), track(1, 3)] diff --git a/crates/retune-spotify/src/auth.rs b/crates/retune-spotify/src/auth.rs index 243c8f5..27de08e 100644 --- a/crates/retune-spotify/src/auth.rs +++ b/crates/retune-spotify/src/auth.rs @@ -12,12 +12,11 @@ use url::Url; use crate::{Error, Result}; -pub const REQUIRED_SCOPES: [&str; 12] = [ +pub const REQUIRED_SCOPES: [&str; 11] = [ "user-library-read", "user-library-modify", "user-read-playback-state", "user-modify-playback-state", - "streaming", "user-read-private", "playlist-read-private", "playlist-read-collaborative", @@ -27,6 +26,7 @@ pub const REQUIRED_SCOPES: [&str; 12] = [ "user-follow-modify", ]; pub static SCOPES: LazyLock = LazyLock::new(|| REQUIRED_SCOPES.join(" ")); +pub const PLAYBACK_SCOPE: &str = "streaming"; const AUTHORIZE_URL: &str = "https://accounts.spotify.com/authorize"; const TOKEN_URL: &str = "https://accounts.spotify.com/api/token"; @@ -64,13 +64,23 @@ pub fn authorize_url( redirect_uri: &str, state: &str, challenge: &str, +) -> Result { + authorize_url_with_scopes(client_id, redirect_uri, state, challenge, &SCOPES) +} + +pub fn authorize_url_with_scopes( + client_id: &str, + redirect_uri: &str, + state: &str, + challenge: &str, + scopes: &str, ) -> Result { let mut url = Url::parse(AUTHORIZE_URL).expect("Spotify authorize URL is constant"); url.query_pairs_mut() .append_pair("client_id", client_id) .append_pair("response_type", "code") .append_pair("redirect_uri", redirect_uri) - .append_pair("scope", &SCOPES) + .append_pair("scope", scopes) .append_pair("state", state) .append_pair("code_challenge_method", "S256") .append_pair("code_challenge", challenge); @@ -156,13 +166,29 @@ impl LoopbackListener { } pub fn redirect_uri(&self) -> Result { + self.redirect_uri_for("/callback") + } + + pub fn redirect_uri_for(&self, path: &str) -> Result { + if !path.starts_with('/') { + return Err(Error::Callback("redirect path must start with '/'".into())); + } self.listener .local_addr() - .map(|address| format!("http://127.0.0.1:{}/callback", address.port())) + .map(|address| format!("http://127.0.0.1:{}{path}", address.port())) .map_err(|error| Error::Callback(error.to_string())) } pub fn accept(self, expected_state: &str, timeout: Duration) -> Result { + self.accept_path(expected_state, "/callback", timeout) + } + + pub fn accept_path( + self, + expected_state: &str, + expected_path: &str, + timeout: Duration, + ) -> Result { self.listener .set_nonblocking(true) .map_err(|error| Error::Callback(error.to_string()))?; @@ -217,7 +243,7 @@ impl LoopbackListener { ); continue; }; - if url.path() != "/callback" { + if url.path() != expected_path { let _ = respond(&mut stream, "404 Not Found", "Not found."); continue; } @@ -308,6 +334,31 @@ mod tests { ); } + #[test] + fn playback_authorize_url_requests_only_streaming() { + let url = authorize_url_with_scopes( + "client", + "http://127.0.0.1:8898/login", + "state", + "challenge", + PLAYBACK_SCOPE, + ) + .unwrap(); + + assert_eq!(url.path(), "/authorize"); + assert_eq!( + url.query_pairs().find(|(key, _)| key == "scope").unwrap().1, + PLAYBACK_SCOPE + ); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "redirect_uri") + .unwrap() + .1, + "http://127.0.0.1:8898/login" + ); + } + #[test] fn callback_uses_a_real_loopback_request_and_checks_state() { let listener = LoopbackListener::bind().unwrap(); @@ -395,6 +446,23 @@ mod tests { )); } + #[test] + fn callback_accepts_the_playback_login_path() { + let listener = LoopbackListener::bind().unwrap(); + let redirect = listener.redirect_uri_for("/login").unwrap(); + let handle = + thread::spawn(move || listener.accept_path("right", "/login", Duration::from_secs(1))); + let url = Url::parse(&redirect).unwrap(); + let mut stream = TcpStream::connect(("127.0.0.1", url.port().unwrap())).unwrap(); + write!( + stream, + "GET /login?code=ok&state=right HTTP/1.1\r\nHost: localhost\r\n\r\n" + ) + .unwrap(); + stream.read_to_string(&mut String::new()).unwrap(); + assert_eq!(handle.join().unwrap().unwrap().code, "ok"); + } + #[test] fn callback_times_out() { let listener = LoopbackListener::bind().unwrap(); diff --git a/crates/retune-spotify/src/client.rs b/crates/retune-spotify/src/client.rs index 48c3eda..318e6b3 100644 --- a/crates/retune-spotify/src/client.rs +++ b/crates/retune-spotify/src/client.rs @@ -203,6 +203,7 @@ pub fn fake_client( refresh: "refresh".into(), expires_at: u64::MAX, scopes: scopes.into(), + playback_credentials: None, })), ) } @@ -234,6 +235,10 @@ impl SpotifyClient { &self.transport } + pub fn token_store(&self) -> &S { + &self.tokens + } + pub fn reset_request_counts(&self) { self.request_counts .lock() @@ -841,6 +846,7 @@ impl SpotifyClient { refresh: token.refresh_token.unwrap_or(stored.refresh), expires_at: unix_now().saturating_add(token.expires_in), scopes: stored.scopes, + playback_credentials: stored.playback_credentials, })?; log::info!("Refreshed Spotify access token"); Ok(()) @@ -1324,6 +1330,7 @@ mod tests { refresh: "refresh".into(), expires_at: 0, scopes: "streaming user-read-private".into(), + playback_credentials: None, })) } @@ -1362,7 +1369,17 @@ mod tests { ), Response::json(200, serde_json::json!({"items": [], "next": null})), ]); - let client = SpotifyClient::new("client", transport, tokens()); + let store = tokens(); + store + .save(&Tokens { + playback_credentials: Some(crate::tokens::PlaybackCredentials { + username: "user".into(), + auth_data: vec![1, 2, 3], + }), + ..store.load().unwrap().unwrap() + }) + .unwrap(); + let client = SpotifyClient::new("client", transport, store); client.saved_tracks(0, 50).await.unwrap(); let requests = client.transport().requests(); assert_eq!(requests.len(), 3); @@ -1372,6 +1389,17 @@ mod tests { client.tokens.load().unwrap().unwrap().scopes, "streaming user-read-private" ); + assert_eq!( + client + .tokens + .load() + .unwrap() + .unwrap() + .playback_credentials + .unwrap() + .auth_data, + vec![1, 2, 3] + ); } #[tokio::test] diff --git a/crates/retune-spotify/src/tokens.rs b/crates/retune-spotify/src/tokens.rs index 5dc8232..31aba6a 100644 --- a/crates/retune-spotify/src/tokens.rs +++ b/crates/retune-spotify/src/tokens.rs @@ -20,6 +20,34 @@ const SERVICE: &str = "com.rianjs.retune"; const KEY_ACCOUNT: &str = "token-file-key"; const NONCE_LEN: usize = 12; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlaybackCredentials { + pub username: String, + #[serde(with = "base64_bytes")] + pub auth_data: Vec, +} + +mod base64_bytes { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use serde::{Deserialize, Deserializer, Serializer, de::Error}; + + pub fn serialize(bytes: &[u8], serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&STANDARD.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + STANDARD + .decode(String::deserialize(deserializer)?) + .map_err(D::Error::custom) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Tokens { pub access: String, @@ -28,6 +56,8 @@ pub struct Tokens { pub expires_at: u64, #[serde(default)] pub scopes: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub playback_credentials: Option, } impl Tokens { @@ -339,6 +369,7 @@ mod tests { refresh: "refresh".into(), expires_at: 42, scopes: "streaming".into(), + playback_credentials: None, } } @@ -350,6 +381,7 @@ mod tests { refresh: "refresh".into(), expires_at: 42, scopes: "streaming".into(), + playback_credentials: None, }; store.save(&tokens).unwrap(); assert_eq!(store.load().unwrap(), Some(tokens)); @@ -403,6 +435,22 @@ mod tests { .unwrap(); assert!(tokens.scopes.is_empty()); + assert!(tokens.playback_credentials.is_none()); + } + + #[test] + fn playback_credentials_round_trip_as_base64() { + let tokens = Tokens { + playback_credentials: Some(PlaybackCredentials { + username: "user".into(), + auth_data: vec![0, 1, 2, 254, 255], + }), + ..tokens("access") + }; + + let serialized = serde_json::to_string(&tokens).unwrap(); + assert!(serialized.contains("AAEC/v8=")); + assert_eq!(serde_json::from_str::(&serialized).unwrap(), tokens); } #[test] @@ -468,8 +516,15 @@ mod tests { store.clear().unwrap(); assert_eq!(loads.load(Ordering::Relaxed), 0); assert_eq!(store.load().unwrap(), None); - store.save(&tokens("first")).unwrap(); - assert_eq!(store.load().unwrap(), Some(tokens("first"))); + let first = Tokens { + playback_credentials: Some(PlaybackCredentials { + username: "user".into(), + auth_data: vec![1, 2, 3], + }), + ..tokens("first") + }; + store.save(&first).unwrap(); + assert_eq!(store.load().unwrap(), Some(first)); store.save(&tokens("second")).unwrap(); assert_eq!(store.load().unwrap(), Some(tokens("second"))); assert_eq!(loads.load(Ordering::Relaxed), 1); diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md index 9b83121..7cab2fd 100644 --- a/docs/architecture/persistence.md +++ b/docs/architecture/persistence.md @@ -15,6 +15,14 @@ All JSON state writes use a temporary file followed by atomic rename. | `tokens.enc` | Encrypted release OAuth token state | | `dev-tokens.json` | Development token state, mode 0600 | +The token record has an optional reusable built-in playback credential containing +the librespot username and AP authentication bytes. Its absence is the default, +so older token files remain readable. Release builds keep it inside encrypted +`tokens.enc`; development token files retain the existing mode-0600 boundary. +Refreshing the Web API token preserves the playback credential. Playback +rejection removes only this field, while explicit Spotify disconnect removes the +whole token record. It is machine-specific and never belongs in backup/export. + Built-in Spotify playback also maintains an `audio-cache` directory. Cache data is disposable; library and settings files are not. diff --git a/docs/architecture/playback.md b/docs/architecture/playback.md index 4f94eaf..658bf7b 100644 --- a/docs/architecture/playback.md +++ b/docs/architecture/playback.md @@ -27,10 +27,10 @@ active queue unchanged. ## Backends -- Built-in Spotify uses librespot with the current OAuth token, a soft mixer, - normalization, gapless playback, compressed-audio read-ahead, and an app-data - audio cache. Its quality tiers are Normal (96 kbps), High (160 kbps), and Very - High (320 kbps). +- Built-in Spotify uses librespot with the stored reusable playback credential, + a login5 preflight, a soft mixer, normalization, gapless playback, + compressed-audio read-ahead, and an app-data audio cache. Its quality tiers + are Normal (96 kbps), High (160 kbps), and Very High (320 kbps). - Spotify Connect controls the active Spotify device through the Web API and polls its state. It distinguishes natural completion, external takeover, and device disappearance. Spotify owns audio download, buffering, quality, and @@ -45,6 +45,13 @@ audible; transitions pause or stop the counterpart before starting the next. System Play and Pause commands set an explicit state; only Toggle inverts the current state. +When built-in playback is selected, missing or rejected playback authorization +returns a typed outcome before a new queue is committed or the reducer advances +to the next track. It never falls through to Connect. The shell keeps the +requested selection outside the controller while it offers separate Spotify +authorization; Cancel leaves playback stopped. File URIs continue through the +local-file engine without Spotify playback authorization. + ## Built-in Spotify data path The Spotify access-point session supplies track metadata and the audio key. The diff --git a/docs/architecture/spotify.md b/docs/architecture/spotify.md index 893fba7..14a669b 100644 --- a/docs/architecture/spotify.md +++ b/docs/architecture/spotify.md @@ -6,10 +6,18 @@ library membership, playlists, and playback activation. ## Authentication and tokens -Authentication uses Authorization Code with PKCE (S256), a loopback redirect, -state validation, and a bounded callback wait. Retune requests the library, -playback, streaming, playlist, and follow scopes needed by its current features. -If an existing grant lacks required scopes, the UI asks the user to reconnect. +Web API authentication uses Authorization Code with PKCE (S256), a loopback +redirect, state validation, and a bounded callback wait. Its grant covers the +library, playlist, and follow scopes needed by sync and browsing; `streaming` is +not a Web API requirement. + +Built-in playback has a separate OAuth flow. It uses the current librespot +`SessionConfig` client ID, requests only `streaming`, and returns through the +same loopback listener at `/login`. The one-time access token is used to create +and verify a reusable librespot AP credential through login5, then only the +reusable credential is stored alongside the Web API token state. Web-token +refresh preserves it. A playback rejection clears only that credential; an +explicit Spotify disconnect clears the whole token record. There is one shared `SpotifyClient`. Access-token refresh is coalesced behind a refresh lock; a request that receives 401 refreshes once and retries once. Token @@ -66,3 +74,36 @@ Spotify endpoints, scopes, quotas, and eligibility rules are external contracts. Before changing them, verify current official Spotify documentation and cover the transport policy with fake-response tests. Do not bypass the shared client for a one-off endpoint. + +## Compatibility/research record — 2026-08-11 + +This is an upstream compatibility change, not a correction to Retune's Web API +OAuth flow. Retune previously passed its developer-app access token directly to +a librespot session presenting Spotify's built-in client identity. That shortcut +was less idiomatic than persisting the reusable AP credential, but it worked on +2026-08-08. On 2026-08-10 login5 +started returning `FaultyRequest(INVALID_CREDENTIALS)` while the same account +could still sync, browse, and search through the Web API. Music Assistant +reported the same ecosystem-wide [incident][ma-incident] and shipped [its fix][ma-fix] +that day: +Spotify now rejects a playback credential minted under an application's own +client ID when librespot presents Spotify's built-in client identity. + +No corresponding Spotify announcement was found. The conclusion therefore +rests on Retune's logs, the independent Music Assistant incident and fix, and +the current librespot 0.8 authentication path. The remedy is to authorize +playback separately with librespot's current `SessionConfig` client ID, verify +the resulting reusable credential through login5, and retain Web API tokens +unchanged. This flow was also checked against Spotify's current [PKCE][spotify-pkce], +[scope][spotify-scopes], and [loopback redirect][spotify-redirect] guidance. +Login5 is an undocumented private protocol; its [librespot authentication +history][librespot-auth] is evidence, not an official contract. This boundary must be verified +again if Spotify or librespot changes it; do not collapse playback authorization +back into the Web API grant. No librespot version or fork change is required. + +[spotify-pkce]: https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow +[spotify-scopes]: https://developer.spotify.com/documentation/web-api/concepts/scopes +[spotify-redirect]: https://developer.spotify.com/documentation/web-api/concepts/redirect_uri +[ma-incident]: https://github.com/music-assistant/support/issues/6043 +[ma-fix]: https://github.com/music-assistant/server/pull/5568 +[librespot-auth]: https://github.com/librespot-org/librespot/pull/1309