Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 53 additions & 23 deletions psst-core/src/cdn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,66 +4,96 @@ 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<Cdn>;

pub struct Cdn {
session: SessionService,
agent: ureq::Agent,
login5: Login5,
client_token_provider: ClientTokenProviderHandle,
spclient_base: Mutex<Option<String>>,
proxy_url: Option<String>,
}

impl Cdn {
pub fn new(session: SessionService, proxy_url: Option<&str>) -> Result<CdnHandle, Error> {
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<String, Error> {
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<CdnUrl, Error> {
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<String>,
}
// 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)
Expand Down
25 changes: 25 additions & 0 deletions psst-core/src/connection/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>, Error> {
#[derive(Clone, Debug, Deserialize)]
struct SpClientResolveData {
spclient: Vec<String>,
}

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<Self, Error> {
log::info!(
"attempting to connect using {} access points",
Expand Down
11 changes: 9 additions & 2 deletions psst-core/src/session/client_token.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -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<ClientTokenProvider>;

pub struct ClientTokenProvider {
token: Mutex<Option<Token>>,
Expand All @@ -26,6 +29,10 @@ impl ClientTokenProvider {
}
}

pub fn new_shared(proxy_url: Option<&str>) -> ClientTokenProviderHandle {
Arc::new(Self::new(proxy_url))
}

fn request<M: Message>(&self, message: &M) -> Result<Vec<u8>, Error> {
let body = message.write_to_bytes()?;

Expand Down
12 changes: 6 additions & 6 deletions psst-core/src/session/login5.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -78,7 +78,7 @@ impl From<Login5Error> for Error {

pub struct Login5 {
auth_token: Mutex<Option<Token>>,
client_token_provider: ClientTokenProvider,
client_token_provider: ClientTokenProviderHandle,
agent: ureq::Agent,
}

Expand All @@ -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<ClientTokenProvider>,
client_token_provider: Option<ClientTokenProviderHandle>,
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(),
}
}
Expand Down