diff --git a/psst-core/src/cdn.rs b/psst-core/src/cdn.rs index 0d79a1cc..f27d5053 100644 --- a/psst-core/src/cdn.rs +++ b/psst-core/src/cdn.rs @@ -4,15 +4,21 @@ use std::{ time::{Duration, Instant}, }; -use serde::Deserialize; +use librespot_protocol::storage_resolve::StorageResolveResponse; +use parking_lot::Mutex; +use protobuf::Message; use crate::{ + connection::Transport, error::Error, item_id::FileId, - session::{SessionService}, + session::{ + client_token::{ClientTokenProvider, ClientTokenProviderHandle}, + login5::Login5, + SessionService, + }, util::default_ureq_agent_builder, }; -use crate::session::login5::Login5; pub type CdnHandle = Arc; @@ -20,50 +26,74 @@ pub struct Cdn { session: SessionService, agent: ureq::Agent, login5: Login5, + client_token_provider: ClientTokenProviderHandle, + spclient_base: Mutex>, + proxy_url: Option, } impl Cdn { pub fn new(session: SessionService, proxy_url: Option<&str>) -> Result { let agent = default_ureq_agent_builder(proxy_url).build(); + // Share a single ClientTokenProvider between Login5 and Cdn to avoid + // redundant round-trips to the client token API. + let client_token_provider = ClientTokenProvider::new_shared(proxy_url); Ok(Arc::new(Self { session, agent: agent.into(), - login5: Login5::new(None, proxy_url), + login5: Login5::new(Some(client_token_provider.clone()), proxy_url), + client_token_provider, + spclient_base: Mutex::new(None), + proxy_url: proxy_url.map(String::from), })) } + /// Resolve and cache the spclient base URL (e.g. "https://gew1-spclient.spotify.com:443"). + fn get_spclient_base(&self) -> Result { + let mut cached = self.spclient_base.lock(); + if let Some(ref url) = *cached { + return Ok(url.clone()); + } + let hosts = Transport::resolve_spclient(self.proxy_url.as_deref())?; + let host = hosts.first().ok_or(Error::UnexpectedResponse)?; + let base = format!("https://{host}"); + log::info!("using spclient base URL: {base}"); + *cached = Some(base.clone()); + Ok(base) + } + pub fn resolve_audio_file_url(&self, id: FileId) -> Result { + let spclient_base = self.get_spclient_base()?; + // The spclient endpoint returns protobuf natively and does not require + // the query parameters that the old api.spotify.com/v1/storage-resolve + // JSON endpoint needed (?alt=json&version=10000000&product=9&platform=39). + // This matches librespot's implementation. let locations_uri = format!( - "https://api.spotify.com/v1/storage-resolve/files/audio/interactive/{}", + "{spclient_base}/storage-resolve/files/audio/interactive/{}", id.to_base16() ); let access_token = self.login5.get_access_token(&self.session)?; - let response = self + let client_token = self.client_token_provider.get()?; + let mut response = self .agent .get(&locations_uri) - .query("version", "10000000") - .query("product", "9") - .query("platform", "39") - .query("alt", "json") - .header("Authorization", &format!("Bearer {}", access_token.access_token)) + .header( + "Authorization", + &format!("Bearer {}", access_token.access_token), + ) + .header("client-token", &client_token) .call()?; - #[derive(Deserialize)] - struct AudioFileLocations { - cdnurl: Vec, - } + // Parse the protobuf StorageResolveResponse. + let bytes = response.body_mut().read_to_vec()?; + let msg = StorageResolveResponse::parse_from_bytes(&bytes) + .map_err(|e| Error::AudioFetchingError(Box::new(e)))?; - // Deserialize the response and pick a file URL from the returned CDN list. - let locations: AudioFileLocations = response.into_body().read_json()?; - let file_uri = locations + // Pick a file URL from the returned CDN list. + let file_uri = msg .cdnurl .into_iter() - // TODO: - // Now, we always pick the first URL in the list, figure out a better strategy. - // Choosing by random seems wrong. .next() - // TODO: Avoid panicking here. - .expect("No file URI found"); + .ok_or(Error::UnexpectedResponse)?; let uri = CdnUrl::new(file_uri); Ok(uri) diff --git a/psst-core/src/connection/mod.rs b/psst-core/src/connection/mod.rs index 1c30af2d..3ed3d32d 100644 --- a/psst-core/src/connection/mod.rs +++ b/psst-core/src/connection/mod.rs @@ -133,6 +133,31 @@ impl Transport { } } + /// Resolve spclient access points from Spotify's AP resolver. + /// These are used for storage-resolve (audio file URL resolution) instead of + /// the rate-limited `api.spotify.com`. + pub fn resolve_spclient(proxy_url: Option<&str>) -> Result, Error> { + #[derive(Clone, Debug, Deserialize)] + struct SpClientResolveData { + spclient: Vec, + } + + let url = format!("{AP_RESOLVE_ENDPOINT}/?type=spclient"); + let agent: ureq::Agent = default_ureq_agent_builder(proxy_url).build().into(); + log::info!("requesting spclient list from {url}"); + let data: SpClientResolveData = agent.get(&url).call()?.into_body().read_json()?; + if data.spclient.is_empty() { + log::warn!("received empty spclient list from server"); + Err(Error::UnexpectedResponse) + } else { + log::info!( + "received {} spclient endpoints from server", + data.spclient.len() + ); + Ok(data.spclient) + } + } + pub fn connect(ap_list: &[String], proxy_url: Option<&str>) -> Result { log::info!( "attempting to connect using {} access points", diff --git a/psst-core/src/session/client_token.rs b/psst-core/src/session/client_token.rs index 5e9d440d..df6ef7e6 100644 --- a/psst-core/src/session/client_token.rs +++ b/psst-core/src/session/client_token.rs @@ -1,7 +1,8 @@ // Ported from librespot use crate::error::Error; -use crate::session::token::{Token}; +use crate::session::token::Token; +use crate::system_info::{CLIENT_ID, DEVICE_ID, OS, SPOTIFY_SEMANTIC_VERSION}; use crate::util::{default_ureq_agent_builder, solve_hash_cash}; use data_encoding::HEXUPPER_PERMISSIVE; use librespot_protocol::clienttoken_http::{ @@ -10,8 +11,10 @@ use librespot_protocol::clienttoken_http::{ }; use parking_lot::Mutex; use protobuf::{Enum, Message}; +use std::sync::Arc; use std::time::{Duration, Instant}; -use crate::system_info::{CLIENT_ID, DEVICE_ID, OS, SPOTIFY_SEMANTIC_VERSION}; + +pub type ClientTokenProviderHandle = Arc; pub struct ClientTokenProvider { token: Mutex>, @@ -26,6 +29,10 @@ impl ClientTokenProvider { } } + pub fn new_shared(proxy_url: Option<&str>) -> ClientTokenProviderHandle { + Arc::new(Self::new(proxy_url)) + } + fn request(&self, message: &M) -> Result, Error> { let body = message.write_to_bytes()?; diff --git a/psst-core/src/session/login5.rs b/psst-core/src/session/login5.rs index baf89634..3aac05d0 100644 --- a/psst-core/src/session/login5.rs +++ b/psst-core/src/session/login5.rs @@ -1,7 +1,7 @@ // Ported from librespot use crate::error::Error; -use crate::session::client_token::ClientTokenProvider; +use crate::session::client_token::{ClientTokenProvider, ClientTokenProviderHandle}; use crate::session::token::Token; use crate::session::SessionService; use crate::system_info::{CLIENT_ID, DEVICE_ID}; @@ -78,7 +78,7 @@ impl From for Error { pub struct Login5 { auth_token: Mutex>, - client_token_provider: ClientTokenProvider, + client_token_provider: ClientTokenProviderHandle, agent: ureq::Agent, } @@ -87,18 +87,18 @@ impl Login5 { /// /// # Arguments /// - /// * `client_token_provider`: Can be optionally injected to control which client-id is - /// used for it. + /// * `client_token_provider`: Can be optionally injected to share a `ClientTokenProvider` + /// instance with other components (e.g. `Cdn`), avoiding redundant round-trips. /// /// returns: Login5 pub fn new( - client_token_provider: Option, + client_token_provider: Option, proxy_url: Option<&str>, ) -> Self { Self { auth_token: Mutex::new(None), client_token_provider: client_token_provider - .unwrap_or_else(|| ClientTokenProvider::new(proxy_url)), + .unwrap_or_else(|| ClientTokenProvider::new_shared(proxy_url)), agent: default_ureq_agent_builder(proxy_url).build().into(), } }