From cfde29f07687cdd8155716f38b743250577ec353 Mon Sep 17 00:00:00 2001 From: Thang Pham Date: Mon, 28 Oct 2024 14:21:04 -0400 Subject: [PATCH] handling `client_id` scoping for Spotify Connect feature with `librespot 0.5.0` (#584) - update `token::get_token` to support user-provided `client_id` (required for Spotify Connect feature) - refactor playback initialization logic upon startup or new session --- spotify_player/src/auth.rs | 8 ++- spotify_player/src/client/mod.rs | 102 +++++++++++++-------------- spotify_player/src/client/spotify.rs | 11 ++- spotify_player/src/config/mod.rs | 2 - spotify_player/src/main.rs | 5 +- spotify_player/src/token.rs | 50 +++++++++++-- 6 files changed, 108 insertions(+), 70 deletions(-) diff --git a/spotify_player/src/auth.rs b/spotify_player/src/auth.rs index 00a9abb2..53c7c5a4 100644 --- a/spotify_player/src/auth.rs +++ b/spotify_player/src/auth.rs @@ -5,8 +5,10 @@ use librespot_oauth::get_access_token; use crate::config; pub const SPOTIFY_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; -pub const CLIENT_REDIRECT_URI: &str = "http://127.0.0.1:8989/login"; -pub const OAUTH_SCOPES: &[&str] = &[ +const CLIENT_REDIRECT_URI: &str = "http://127.0.0.1:8989/login"; +// based on https://github.com/librespot-org/librespot/blob/f96f36c064795011f9fee912291eecb1aa46fff6/src/main.rs#L173 +const OAUTH_SCOPES: &[&str] = &[ + "app-remote-control", "playlist-modify", "playlist-modify-private", "playlist-modify-public", @@ -14,6 +16,7 @@ pub const OAUTH_SCOPES: &[&str] = &[ "playlist-read-collaborative", "playlist-read-private", "streaming", + "ugc-image-upload", "user-follow-modify", "user-follow-read", "user-library-modify", @@ -22,6 +25,7 @@ pub const OAUTH_SCOPES: &[&str] = &[ "user-modify-playback-state", "user-modify-private", "user-personalized", + "user-read-birthdate", "user-read-currently-playing", "user-read-email", "user-read-play-history", diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs index 9ff987c8..1e5e28dd 100644 --- a/spotify_player/src/client/mod.rs +++ b/spotify_player/src/client/mod.rs @@ -60,22 +60,55 @@ impl Client { } /// Initialize the application's playback upon creating a new session or during startup - pub async fn initialize_playback(&self, state: &SharedState) -> Result<()> { - self.retrieve_current_playback(state, false).await?; - - if state.player.read().playback.is_none() { - tracing::info!("No playback found, trying to connect to an available device..."); - // // handle `connect_device` task separately as we don't want to block here - tokio::task::spawn({ - let client = self.clone(); - let state = state.clone(); - async move { - client.connect_device(&state).await; - } - }); - } + pub fn initialize_playback(&self, state: &SharedState) { + tokio::task::spawn({ + let client = self.clone(); + let state = state.clone(); + async move { + // The main playback initialization logic is simple: + // if there is no playback, connect to an available device + // + // However, because it takes time for Spotify server to show up new changes, + // a retry logic is implemented to ensure the application's state is properly initialized + let delay = std::time::Duration::from_secs(1); + + for _ in 0..5 { + tokio::time::sleep(delay).await; + + if let Err(err) = client.retrieve_current_playback(&state, false).await { + tracing::error!("Failed to retrieve current playback: {err:#}"); + return; + } - Ok(()) + // if playback exists, don't connect to a new device + if state.player.read().playback.is_some() { + continue; + } + + let id = match client.find_available_device().await { + Ok(Some(id)) => Some(Cow::Owned(id)), + Ok(None) => None, + Err(err) => { + tracing::error!("Failed to find an available device: {err:#}"); + None + } + }; + + if let Some(id) = id { + tracing::info!("Trying to connect to device (id={id})"); + if let Err(err) = client.transfer_playback(&id, Some(false)).await { + tracing::warn!("Connection failed (device_id={id}): {err:#}"); + } else { + tracing::info!("Connection succeeded (device_id={id})!"); + // upon new connection, reset the buffered playback + state.player.write().buffered_playback = None; + client.update_playback(&state); + break; + } + } + } + } + }); } /// Create a new client session @@ -114,10 +147,7 @@ impl Client { if let Some(state) = state { // reset the application's caches state.data.write().caches = MemoryCaches::new(); - - self.initialize_playback(state) - .await - .context("initialize playback")?; + self.initialize_playback(state); } Ok(()) @@ -554,40 +584,6 @@ impl Client { Ok(()) } - /// Connect to a Spotify device - async fn connect_device(&self, state: &SharedState) { - // Device connection can fail when the specified device hasn't shown up - // in the Spotify's server, resulting in a failed `TransferPlayback` API request. - // This is why a retry mechanism is needed to ensure a successful connection. - let delay = std::time::Duration::from_secs(1); - - for _ in 0..10 { - tokio::time::sleep(delay).await; - - let id = match self.find_available_device().await { - Ok(Some(id)) => Some(Cow::Owned(id)), - Ok(None) => None, - Err(err) => { - tracing::error!("Failed to find an available device: {err:#}"); - None - } - }; - - if let Some(id) = id { - tracing::info!("Trying to connect to device (id={id})"); - if let Err(err) = self.transfer_playback(&id, Some(false)).await { - tracing::warn!("Connection failed (device_id={id}): {err:#}"); - } else { - tracing::info!("Connection succeeded (device_id={id})!"); - // upon new connection, reset the buffered playback - state.player.write().buffered_playback = None; - self.update_playback(state); - break; - } - } - } - } - pub fn update_playback(&self, state: &SharedState) { // After handling a request changing the player's playback, // update the playback state by making multiple get-playback requests. diff --git a/spotify_player/src/client/spotify.rs b/spotify_player/src/client/spotify.rs index 33ffb103..8b0f5e47 100644 --- a/spotify_player/src/client/spotify.rs +++ b/spotify_player/src/client/spotify.rs @@ -9,7 +9,7 @@ use rspotify::{ }; use std::{fmt, sync::Arc}; -use crate::token; +use crate::{config, token}; #[derive(Clone, Default)] /// A Spotify client to interact with Spotify API server @@ -19,6 +19,7 @@ pub struct Spotify { config: Config, token: Arc>>, http: HttpClient, + client_id: String, pub(crate) session: Arc>>, } @@ -45,6 +46,12 @@ impl Spotify { }, token: Arc::new(Mutex::new(None)), http: HttpClient::default(), + // Spotify client uses different `client_id` from Spotify session (`auth::SPOTIFY_CLIENT_ID`) + // to support user-provided `client_id`, which is required for Spotify Connect feature + client_id: config::get_config() + .app_config + .get_client_id() + .expect("get client_id"), session: Arc::new(tokio::sync::Mutex::new(None)), } } @@ -108,7 +115,7 @@ impl BaseClient for Spotify { return Ok(old_token); } - match token::get_token(&session).await { + match token::get_token(&session, &self.client_id).await { Ok(token) => Ok(Some(token)), Err(err) => { tracing::error!("Failed to get a new token: {err:#}"); diff --git a/spotify_player/src/config/mod.rs b/spotify_player/src/config/mod.rs index dfd34650..e2e98750 100644 --- a/spotify_player/src/config/mod.rs +++ b/spotify_player/src/config/mod.rs @@ -413,8 +413,6 @@ impl AppConfig { } /// Returns stdout of `client_id_command` if set, otherwise it returns the the value of `client_id` - // TODO: figure out how to use user-provided client_id for Spotify Connect integration - #[allow(dead_code)] pub fn get_client_id(&self) -> Result { match self.client_id_command { Some(ref cmd) => cmd.execute(None), diff --git a/spotify_player/src/main.rs b/spotify_player/src/main.rs index 5bf1e9be..4d00e991 100644 --- a/spotify_player/src/main.rs +++ b/spotify_player/src/main.rs @@ -23,10 +23,7 @@ async fn init_spotify( client: &client::Client, state: &state::SharedState, ) -> Result<()> { - client - .initialize_playback(state) - .await - .context("initialize playback")?; + client.initialize_playback(state); // request user data client_pub.send(client::ClientRequest::GetCurrentUser)?; diff --git a/spotify_player/src/token.rs b/spotify_player/src/token.rs index 5e1412a7..f754a096 100644 --- a/spotify_player/src/token.rs +++ b/spotify_player/src/token.rs @@ -3,17 +3,53 @@ use std::collections::HashSet; use anyhow::Result; use chrono::{Duration, Utc}; use librespot_core::session::Session; -use rspotify::Token; - -use crate::auth::OAUTH_SCOPES; const TIMEOUT_IN_SECS: u64 = 5; -pub async fn get_token(session: &Session) -> Result { +/// The application authentication token's permission scopes +const SCOPES: [&str; 15] = [ + "user-read-recently-played", + "user-top-read", + "user-read-playback-position", + "user-read-playback-state", + "user-modify-playback-state", + "user-read-currently-playing", + "streaming", + "playlist-read-private", + "playlist-modify-private", + "playlist-modify-public", + "playlist-read-collaborative", + "user-follow-read", + "user-follow-modify", + "user-library-read", + "user-library-modify", +]; + +async fn retrieve_token( + session: &Session, + client_id: &str, +) -> Result { + let query_uri = format!( + "hm://keymaster/token/authenticated?scope={}&client_id={}&device_id={}", + SCOPES.join(","), + client_id, + session.device_id(), + ); + let request = session.mercury().get(query_uri)?; + let response = request.await?; + let data = response + .payload + .first() + .ok_or(librespot_core::token::TokenError::Empty)? + .to_vec(); + let token = librespot_core::token::Token::from_json(String::from_utf8(data)?)?; + Ok(token) +} + +pub async fn get_token(session: &Session, client_id: &str) -> Result { tracing::info!("Getting a new authentication token..."); - let scopes = OAUTH_SCOPES.join(","); - let fut = session.token_provider().get_token(&scopes); + let fut = retrieve_token(session, client_id); let token = match tokio::time::timeout(std::time::Duration::from_secs(TIMEOUT_IN_SECS), fut).await { Ok(Ok(token)) => token, @@ -34,7 +70,7 @@ pub async fn get_token(session: &Session) -> Result { // let expires_in = Duration::from_std(std::time::Duration::from_secs(5))?; let expires_at = Utc::now() + expires_in; - let token = Token { + let token = rspotify::Token { access_token: token.access_token, expires_in, expires_at: Some(expires_at),