2468 lines
97 KiB
Rust
2468 lines
97 KiB
Rust
use std::{collections::HashMap, fmt::Write, ops::Not, sync::Arc, time::Instant};
|
||
|
||
use byteorder::{BigEndian, ByteOrder};
|
||
use bytes::Bytes;
|
||
use data_encoding::{HEXLOWER_PERMISSIVE, HEXUPPER_PERMISSIVE};
|
||
use parking_lot::Mutex;
|
||
use protobuf::{Enum, EnumOrUnknown, Message};
|
||
use reqwest::{header, Method, Response, StatusCode};
|
||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||
use sha1::{Digest, Sha1};
|
||
use time::{Duration, OffsetDateTime};
|
||
use tracing::{debug, error, info, trace, warn};
|
||
|
||
use spotifyio_protocol::{
|
||
canvaz::{entity_canvaz_response::Canvaz, EntityCanvazRequest, EntityCanvazResponse},
|
||
clienttoken_http::{
|
||
ChallengeAnswer, ChallengeType, ClientTokenRequest, ClientTokenRequestType,
|
||
ClientTokenResponse, ClientTokenResponseType,
|
||
},
|
||
credentials::StoredCredential,
|
||
extended_metadata::{
|
||
BatchedEntityRequest, BatchedExtensionResponse, EntityRequest, ExtensionQuery,
|
||
},
|
||
extension_kind::ExtensionKind,
|
||
login5::{LoginError, LoginRequest, LoginResponse},
|
||
metadata::{Album, Artist, Episode, Show, Track},
|
||
playlist4_external::SelectedListContent,
|
||
playplay::{PlayPlayLicenseRequest, PlayPlayLicenseResponse},
|
||
storage_resolve::StorageResolveResponse,
|
||
};
|
||
|
||
use crate::{
|
||
error::ErrorKind,
|
||
gql_model::{
|
||
ArtistGql, ArtistGqlWrap, Concert, ConcertGql, ConcertGqlWrap, ConcertOption, FollowerItem,
|
||
FollowerItemUserlike, GqlPlaylistItem, GqlSearchResult, GqlWrap, Lyrics, LyricsWrap,
|
||
PlaylistWrap, PrereleaseItem, PrereleaseLookup, SearchItemType, SearchResultWrap,
|
||
Seektable, TrackCredits, UserPlaylists, UserProfile, UserProfilesWrap,
|
||
},
|
||
model::{
|
||
AlbumId, AlbumType, ArtistId, AudioAnalysis, AudioFeatures, AudioFeaturesPayload, Category,
|
||
CategoryPlaylists, ConcertId, EpisodeId, EpisodesPayload, FeaturedPlaylists, FileId,
|
||
FullAlbum, FullAlbums, FullArtist, FullArtists, FullEpisode, FullPlaylist, FullShow,
|
||
FullTrack, FullTracks, Id, IdBase62, IdConstruct, IncludeExternal, Market, MetadataItemId,
|
||
Page, PageCategory, PageSimplifiedAlbums, PlaylistId, PlaylistItem, PrereleaseId,
|
||
PrivateUser, PublicUser, Recommendations, RecommendationsAttribute, SearchMultipleResult,
|
||
SearchResult, SearchType, ShowId, SimplifiedAlbum, SimplifiedEpisode, SimplifiedPlaylist,
|
||
SimplifiedShow, SimplifiedShows, SimplifiedTrack, SpotifyType, TrackId, UserId,
|
||
},
|
||
pagination::{paginate, paginate_with_ctx, Paginator},
|
||
session::SessionWeak,
|
||
util::{self, SocketAddress},
|
||
Error, Session,
|
||
};
|
||
|
||
#[derive(Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct TokenData {
|
||
access_token: String,
|
||
expires_in: u32,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||
pub struct Token {
|
||
pub access_token: String,
|
||
pub expires_in: u32,
|
||
#[serde(with = "time::serde::rfc3339")]
|
||
pub timestamp: OffsetDateTime,
|
||
}
|
||
|
||
impl Token {
|
||
const EXPIRY_THRESHOLD: Duration = Duration::seconds(10);
|
||
|
||
pub fn from_json(body: String) -> Result<Self, Error> {
|
||
let data: TokenData = serde_json::from_slice(body.as_ref())?;
|
||
Ok(Self {
|
||
access_token: data.access_token,
|
||
expires_in: data.expires_in,
|
||
timestamp: OffsetDateTime::now_utc(),
|
||
})
|
||
}
|
||
|
||
pub fn is_expired(&self) -> bool {
|
||
self.timestamp
|
||
+ (Duration::seconds(self.expires_in.into()).saturating_sub(Self::EXPIRY_THRESHOLD))
|
||
< OffsetDateTime::now_utc()
|
||
}
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
pub struct SpClient {
|
||
i: Arc<SpClientInner>,
|
||
}
|
||
|
||
struct SpClientInner {
|
||
session: SessionWeak,
|
||
data: Mutex<SpClientData>,
|
||
lock_client_token: tokio::sync::Mutex<()>,
|
||
lock_auth_token: tokio::sync::Mutex<()>,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct SpClientData {
|
||
accesspoint: Option<SocketAddress>,
|
||
}
|
||
|
||
#[derive(Copy, Clone, Debug)]
|
||
pub enum RequestStrategy {
|
||
TryTimes(usize),
|
||
Infinitely,
|
||
}
|
||
|
||
impl Default for RequestStrategy {
|
||
fn default() -> Self {
|
||
RequestStrategy::TryTimes(10)
|
||
}
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct RequestParams<'a> {
|
||
method: Method,
|
||
endpoint: &'a str,
|
||
params: Option<&'a Query<'a>>,
|
||
public_api: bool,
|
||
content_type: Option<ContentType>,
|
||
body: Vec<u8>,
|
||
if_none_match: Option<&'a str>,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub struct EtagResponse<T> {
|
||
pub data: T,
|
||
pub etag: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy)]
|
||
enum ContentType {
|
||
Protobuf,
|
||
Json,
|
||
}
|
||
|
||
impl ContentType {
|
||
fn mime(self) -> &'static str {
|
||
match self {
|
||
ContentType::Protobuf => "application/x-protobuf",
|
||
ContentType::Json => "application/json",
|
||
}
|
||
}
|
||
}
|
||
|
||
type Query<'a> = HashMap<&'a str, &'a str>;
|
||
|
||
impl SpClient {
|
||
pub(crate) fn new(session: SessionWeak) -> Self {
|
||
Self {
|
||
i: SpClientInner {
|
||
session,
|
||
data: Mutex::default(),
|
||
lock_client_token: tokio::sync::Mutex::default(),
|
||
lock_auth_token: tokio::sync::Mutex::default(),
|
||
}
|
||
.into(),
|
||
}
|
||
}
|
||
|
||
fn session(&self) -> Session {
|
||
self.i.session.upgrade()
|
||
}
|
||
|
||
pub async fn flush_accesspoint(&self) {
|
||
let mut data = self.i.data.lock();
|
||
data.accesspoint = None;
|
||
}
|
||
|
||
pub async fn get_accesspoint(&self) -> Result<SocketAddress, Error> {
|
||
// Memoize the current access point.
|
||
let ap = {
|
||
let data = self.i.data.lock();
|
||
data.accesspoint.clone()
|
||
};
|
||
let tuple = match ap {
|
||
Some(tuple) => tuple,
|
||
None => {
|
||
let tuple = self.session().apresolver().resolve("spclient").await?;
|
||
{
|
||
let mut data = self.i.data.lock();
|
||
data.accesspoint = Some(tuple.clone());
|
||
}
|
||
info!(
|
||
"Resolved \"{}:{}\" as spclient access point",
|
||
tuple.0, tuple.1
|
||
);
|
||
tuple
|
||
}
|
||
};
|
||
Ok(tuple)
|
||
}
|
||
|
||
pub async fn base_url(&self) -> Result<String, Error> {
|
||
// let ap = self.get_accesspoint().await?;
|
||
// Ok(format!("https://{}:{}", ap.0, ap.1))
|
||
Ok("https://spclient.wg.spotify.com".to_owned())
|
||
}
|
||
|
||
pub async fn client_token(&self) -> Result<String, Error> {
|
||
let _lock = self.i.lock_client_token.lock().await;
|
||
let client_token = self.session().cache().read_client_token();
|
||
if let Some(client_token) = client_token {
|
||
return Ok(client_token.access_token);
|
||
}
|
||
|
||
debug!("Client token unavailable or expired, requesting new token.");
|
||
|
||
let mut request = ClientTokenRequest::new();
|
||
request.request_type = ClientTokenRequestType::REQUEST_CLIENT_DATA_REQUEST.into();
|
||
|
||
let client_data = request.mut_client_data();
|
||
|
||
client_data.client_version = util::SPOTIFY_VERSION.to_owned();
|
||
client_data.client_id = util::SPOTIFY_CLIENT_ID.to_owned();
|
||
|
||
let connectivity_data = client_data.mut_connectivity_sdk_data();
|
||
connectivity_data.device_id = self.session().device_id();
|
||
|
||
let platform_data = connectivity_data
|
||
.platform_specific_data
|
||
.mut_or_insert_default();
|
||
|
||
let linux_data = platform_data.mut_desktop_linux();
|
||
linux_data.system_name = "Linux".to_string();
|
||
linux_data.system_version = "6.1.0-25-amd64".to_owned();
|
||
linux_data.system_release =
|
||
"#1 SMP PREEMPT_DYNAMIC Debian 6.1.106-3 (2024-08-26)".to_owned();
|
||
linux_data.hardware = "x86_64".to_owned();
|
||
|
||
let mut response = self.client_token_request(&request).await?;
|
||
let mut count = 0;
|
||
const MAX_TRIES: u8 = 3;
|
||
|
||
let token_response = loop {
|
||
count += 1;
|
||
|
||
let message = ClientTokenResponse::parse_from_bytes(&response)?;
|
||
|
||
match ClientTokenResponseType::from_i32(message.response_type.value()) {
|
||
// depending on the platform, you're either given a token immediately
|
||
// or are presented a hash cash challenge to solve first
|
||
Some(ClientTokenResponseType::RESPONSE_GRANTED_TOKEN_RESPONSE) => {
|
||
debug!("Received a granted token");
|
||
break message;
|
||
}
|
||
Some(ClientTokenResponseType::RESPONSE_CHALLENGES_RESPONSE) => {
|
||
debug!("Received a hash cash challenge, solving...");
|
||
|
||
let challenges = message.challenges().clone();
|
||
let state = challenges.state;
|
||
if let Some(challenge) = challenges.challenges.first() {
|
||
let hash_cash_challenge = challenge.evaluate_hashcash_parameters();
|
||
|
||
let ctx = vec![];
|
||
let prefix = HEXUPPER_PERMISSIVE
|
||
.decode(hash_cash_challenge.prefix.as_bytes())
|
||
.map_err(|e| {
|
||
Error::failed_precondition(format!(
|
||
"Unable to decode hash cash challenge: {e}"
|
||
))
|
||
})?;
|
||
let length = hash_cash_challenge.length;
|
||
|
||
let mut suffix = [0u8; 0x10];
|
||
let answer = Self::solve_hash_cash(&ctx, &prefix, length, &mut suffix);
|
||
|
||
match answer {
|
||
Ok(_) => {
|
||
// the suffix must be in uppercase
|
||
let suffix = HEXUPPER_PERMISSIVE.encode(&suffix);
|
||
|
||
let mut answer_message = ClientTokenRequest::new();
|
||
answer_message.request_type =
|
||
ClientTokenRequestType::REQUEST_CHALLENGE_ANSWERS_REQUEST
|
||
.into();
|
||
|
||
let challenge_answers = answer_message.mut_challenge_answers();
|
||
|
||
let mut challenge_answer = ChallengeAnswer::new();
|
||
challenge_answer.mut_hash_cash().suffix = suffix;
|
||
challenge_answer.ChallengeType =
|
||
ChallengeType::CHALLENGE_HASH_CASH.into();
|
||
|
||
challenge_answers.state = state.to_string();
|
||
challenge_answers.answers.push(challenge_answer);
|
||
|
||
trace!("Answering hash cash challenge");
|
||
match self.client_token_request(&answer_message).await {
|
||
Ok(token) => {
|
||
response = token;
|
||
continue;
|
||
}
|
||
Err(e) => {
|
||
trace!(
|
||
"Answer not accepted {}/{}: {}",
|
||
count,
|
||
MAX_TRIES,
|
||
e
|
||
);
|
||
}
|
||
}
|
||
}
|
||
Err(e) => trace!(
|
||
"Unable to solve hash cash challenge {}/{}: {}",
|
||
count,
|
||
MAX_TRIES,
|
||
e
|
||
),
|
||
}
|
||
|
||
if count < MAX_TRIES {
|
||
response = self.client_token_request(&request).await?;
|
||
} else {
|
||
return Err(Error::failed_precondition(format!(
|
||
"Unable to solve any of {MAX_TRIES} hash cash challenges"
|
||
)));
|
||
}
|
||
} else {
|
||
return Err(Error::failed_precondition("No challenges found"));
|
||
}
|
||
}
|
||
|
||
Some(unknown) => {
|
||
return Err(Error::unimplemented(format!(
|
||
"Unknown client token response type: {unknown:?}"
|
||
)))
|
||
}
|
||
None => return Err(Error::failed_precondition("No client token response type")),
|
||
}
|
||
};
|
||
|
||
let granted_token = token_response.granted_token();
|
||
let access_token = granted_token.token.to_owned();
|
||
|
||
let client_token = Token {
|
||
access_token: access_token.clone(),
|
||
expires_in: granted_token
|
||
.refresh_after_seconds
|
||
.try_into()
|
||
.unwrap_or(7200),
|
||
timestamp: OffsetDateTime::now_utc(),
|
||
};
|
||
self.session().cache().write_client_token(client_token);
|
||
trace!("Got client token");
|
||
|
||
Ok(access_token)
|
||
}
|
||
|
||
async fn client_token_request<M: Message>(&self, message: &M) -> Result<Bytes, Error> {
|
||
let body = message.write_to_bytes()?;
|
||
let resp = self
|
||
.session()
|
||
.http_client()
|
||
.post("https://clienttoken.spotify.com/v1/clienttoken")
|
||
.header(header::ACCEPT, ContentType::Protobuf.mime())
|
||
.body(body)
|
||
.send()
|
||
.await?;
|
||
resp.bytes().await.map_err(Error::from)
|
||
}
|
||
|
||
fn solve_hash_cash(
|
||
ctx: &[u8],
|
||
prefix: &[u8],
|
||
length: i32,
|
||
dst: &mut [u8],
|
||
) -> Result<(), Error> {
|
||
// after a certain number of seconds, the challenge expires
|
||
const TIMEOUT: u64 = 5; // seconds
|
||
let now = Instant::now();
|
||
|
||
let md = Sha1::digest(ctx);
|
||
|
||
let mut counter: i64 = 0;
|
||
let target: i64 = BigEndian::read_i64(&md[12..20]);
|
||
|
||
let suffix = loop {
|
||
if now.elapsed().as_secs() >= TIMEOUT {
|
||
return Err(Error::deadline_exceeded(format!(
|
||
"{TIMEOUT} seconds expired"
|
||
)));
|
||
}
|
||
|
||
let suffix = [(target + counter).to_be_bytes(), counter.to_be_bytes()].concat();
|
||
|
||
let mut hasher = Sha1::new();
|
||
hasher.update(prefix);
|
||
hasher.update(&suffix);
|
||
let md = hasher.finalize();
|
||
|
||
if BigEndian::read_i64(&md[12..20]).trailing_zeros() >= (length as u32) {
|
||
break suffix;
|
||
}
|
||
|
||
counter += 1;
|
||
};
|
||
|
||
dst.copy_from_slice(&suffix);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn auth_token(&self) -> Result<String, Error> {
|
||
let _lock = self.i.lock_auth_token.lock().await;
|
||
let auth_token = self.session().cache().read_auth_token();
|
||
|
||
if let Some(auth_token) = auth_token {
|
||
return Ok(auth_token.access_token);
|
||
}
|
||
|
||
debug!("Auth token unavailable or expired, requesting new token.");
|
||
|
||
let client_token = self.client_token().await?;
|
||
|
||
let mut sc = StoredCredential::new();
|
||
sc.username = self.session().user_id();
|
||
sc.data = self.session().auth_data();
|
||
|
||
let mut request = LoginRequest::new();
|
||
request.set_stored_credential(sc);
|
||
let client_info = request.client_info.mut_or_insert_default();
|
||
client_info.client_id = self.session().client_id().to_owned();
|
||
client_info.device_id = self.session().device_id();
|
||
|
||
let response = self
|
||
.session()
|
||
.http_client()
|
||
.post("https://login5.spotify.com/v3/login")
|
||
.header(header::ACCEPT, ContentType::Protobuf.mime())
|
||
.header(header::CONTENT_TYPE, ContentType::Protobuf.mime())
|
||
.header("client-token", client_token)
|
||
.body(request.write_to_bytes()?)
|
||
.send()
|
||
.await?
|
||
.error_for_status()?
|
||
.bytes()
|
||
.await?;
|
||
|
||
let login_response = LoginResponse::parse_from_bytes(&response)?;
|
||
|
||
if login_response.has_ok() {
|
||
let ok = login_response.ok();
|
||
let auth_token = Token {
|
||
access_token: ok.access_token.clone(),
|
||
expires_in: ok.access_token_expires_in.try_into().unwrap_or(3600),
|
||
timestamp: OffsetDateTime::now_utc(),
|
||
};
|
||
self.session().cache().write_auth_token(auth_token);
|
||
self.session().reset_auth_errors();
|
||
|
||
return Ok(ok.access_token.clone());
|
||
}
|
||
|
||
if login_response.has_challenges() {
|
||
return Err(Error::unimplemented("login challenges not implemented"));
|
||
}
|
||
if login_response.has_error() {
|
||
match login_response.error() {
|
||
LoginError::UNKNOWN_ERROR => return Err(Error::unknown("login5: unknown error")),
|
||
LoginError::INVALID_CREDENTIALS => {
|
||
self.session().inc_auth_errors();
|
||
return Err(Error::unauthenticated("login5: invalid credentials"));
|
||
}
|
||
LoginError::BAD_REQUEST => {
|
||
self.session().inc_auth_errors();
|
||
return Err(Error::invalid_argument("login5: bad request"));
|
||
}
|
||
LoginError::UNSUPPORTED_LOGIN_PROTOCOL => {
|
||
self.session().inc_auth_errors();
|
||
return Err(Error::invalid_argument(
|
||
"login5: unsupported login protocol",
|
||
));
|
||
}
|
||
LoginError::TIMEOUT => return Err(Error::deadline_exceeded("login5: timeout")),
|
||
LoginError::UNKNOWN_IDENTIFIER => {
|
||
self.session().inc_auth_errors();
|
||
return Err(Error::invalid_argument("login5: unknown identifier"));
|
||
}
|
||
LoginError::TOO_MANY_ATTEMPTS => {
|
||
return Err(Error::resource_exhausted("login5: too many attempts"))
|
||
}
|
||
LoginError::INVALID_PHONENUMBER => {
|
||
self.session().inc_auth_errors();
|
||
return Err(Error::invalid_argument("login5: invalid phone number"));
|
||
}
|
||
LoginError::TRY_AGAIN_LATER => {
|
||
return Err(Error::unavailable("login5: try again later"))
|
||
}
|
||
}
|
||
}
|
||
|
||
Err(Error::unknown("login5: empty response"))
|
||
}
|
||
|
||
#[tracing::instrument("spclient", level = "error", skip_all, fields(usr = self.session().user_id()))]
|
||
async fn _request_generic(&self, p: RequestParams<'_>) -> Result<EtagResponse<Bytes>, Error> {
|
||
let mut tries: usize = 0;
|
||
let mut last_error: Error;
|
||
let mut retry_after: Option<u64> = None;
|
||
|
||
loop {
|
||
tries += 1;
|
||
|
||
let url = if p.endpoint.starts_with('/') {
|
||
if p.public_api {
|
||
format!("https://api.spotify.com/v1{}", p.endpoint)
|
||
} else {
|
||
// Reconnection logic: retrieve the endpoint every iteration, so we can try
|
||
// another access point when we are experiencing network issues (see below).
|
||
|
||
let mut url = self.base_url().await?;
|
||
url.push_str(p.endpoint);
|
||
url
|
||
}
|
||
} else {
|
||
p.endpoint.to_string()
|
||
};
|
||
|
||
// Reconnection logic: keep getting (cached) tokens because they might have expired.
|
||
let auth_token = self.auth_token().await?;
|
||
|
||
let mut request = self
|
||
.session()
|
||
.http_client()
|
||
.request(p.method.clone(), url)
|
||
.bearer_auth(auth_token)
|
||
// .bearer_auth("BQDdwSxvV2qf5GqQJY4t37YcRePJPkQ2hv_rwXJLsR6NCsCb0CFJaW0Ecs9paIikmbM0VG3k7E6K7ufkYaF_ut4VX0Q6QoC6SaU7YXqBCxn0ZGUG9iTLhjXqIw7n2iomrNb8r_3Vyk3xeYWqZmJymXokEjIjgr0FBKT69djnemIxAtm3YSK54fH1QPw1AwqvXooIqIUPMOwskz_a-gUE4n_YWzzSiHJQ38Kw8dzRLa7wMcoy8PO_tchWcofvdRhGw2IglFr4x2xjaFqozoPTaQsLCo1vdKYPUN8xr8xF7Ls76SU8YEFHP3krH0krNgpolyNbPR1fd4jJg3T7Mpfd08mtxVd4rMCNpa683HWJSHI4rtMJGaaSx2zx-4KrklA_8w")
|
||
.header(header::ACCEPT_LANGUAGE, &self.session().config().language);
|
||
|
||
if let Some(content_type) = p.content_type {
|
||
request = request.header(header::ACCEPT, content_type.mime());
|
||
}
|
||
|
||
if !p.body.is_empty() {
|
||
request = request.body(p.body.clone());
|
||
if let Some(content_type) = p.content_type {
|
||
request = request.header(header::CONTENT_TYPE, content_type.mime());
|
||
}
|
||
}
|
||
|
||
if !p.public_api {
|
||
match self.client_token().await {
|
||
Ok(client_token) => {
|
||
request = request.header("client-token", client_token);
|
||
}
|
||
Err(e) => {
|
||
// currently these endpoints seem to work fine without it
|
||
warn!("Unable to get client token: {e} Trying to continue without...")
|
||
}
|
||
}
|
||
}
|
||
|
||
if let Some(t) = p.if_none_match {
|
||
request = request.header(header::IF_NONE_MATCH, t);
|
||
}
|
||
|
||
if let Some(params) = p.params {
|
||
request = request.query(params);
|
||
}
|
||
|
||
let resp = request.send().await.map_err(Error::from);
|
||
|
||
last_error = match resp {
|
||
Ok(resp) => {
|
||
if matches!(resp.status(), StatusCode::NOT_MODIFIED) {
|
||
return Err(Error::not_modified(""));
|
||
}
|
||
|
||
let estatus = resp.error_for_status_ref().err();
|
||
let etag = resp
|
||
.headers()
|
||
.get(header::ETAG)
|
||
.and_then(|v| v.to_str().ok())
|
||
.map(str::to_owned);
|
||
retry_after = resp
|
||
.headers()
|
||
.get(header::RETRY_AFTER)
|
||
.and_then(|v| v.to_str().ok())
|
||
.and_then(|v| v.parse().ok());
|
||
|
||
match estatus {
|
||
Some(e) => {
|
||
// Protobuf API returns no textual error messages
|
||
if !matches!(p.content_type, Some(ContentType::Protobuf)) {
|
||
if let Ok(emsg) = resp.text().await {
|
||
debug!("error response:\n{emsg}");
|
||
}
|
||
}
|
||
e.into()
|
||
}
|
||
None => match resp.bytes().await {
|
||
Ok(data) => {
|
||
return Ok(EtagResponse { data, etag });
|
||
}
|
||
Err(e) => e.into(),
|
||
},
|
||
}
|
||
}
|
||
Err(e) => e,
|
||
};
|
||
|
||
// Break before the reconnection logic below, so that the current access point
|
||
// is retained when max_tries == 1. Leave it up to the caller when to flush.
|
||
if let RequestStrategy::TryTimes(max_tries) = self.session().config().request_strategy {
|
||
if tries >= max_tries {
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Reconnection logic: drop the current access point if we are experiencing issues.
|
||
// This will cause the next call to base_url() to resolve a new one.
|
||
match last_error.kind {
|
||
ErrorKind::Unavailable | ErrorKind::DeadlineExceeded => {
|
||
warn!("API error: {last_error} (retrying)");
|
||
// Keep trying the current access point three times before dropping it.
|
||
if tries.is_multiple_of(3) {
|
||
self.flush_accesspoint().await
|
||
}
|
||
}
|
||
ErrorKind::ResourceExhausted => {
|
||
let wait_time = retry_after.unwrap_or(15);
|
||
if wait_time > 30 {
|
||
error!("API error 429: need to wait {wait_time}s");
|
||
break;
|
||
} else {
|
||
warn!("API error 429: waiting for {wait_time}s");
|
||
tokio::time::sleep(std::time::Duration::from_secs(wait_time)).await;
|
||
}
|
||
}
|
||
_ => {
|
||
// if we can't build the request now, then we won't ever
|
||
error!("API error: {last_error}");
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
Err(last_error)
|
||
}
|
||
|
||
async fn request_pb<O: Message>(
|
||
&self,
|
||
method: Method,
|
||
endpoint: &str,
|
||
message: &impl Message,
|
||
) -> Result<O, Error> {
|
||
let res = self
|
||
._request_generic(RequestParams {
|
||
method,
|
||
endpoint,
|
||
content_type: Some(ContentType::Protobuf),
|
||
body: message.write_to_bytes()?,
|
||
..Default::default()
|
||
})
|
||
.await?;
|
||
Ok(O::parse_from_bytes(&res.data)?)
|
||
}
|
||
|
||
async fn request_get_pb<O: Message>(
|
||
&self,
|
||
endpoint: &str,
|
||
if_none_match: Option<&str>,
|
||
) -> Result<EtagResponse<O>, Error> {
|
||
let res = self
|
||
._request_generic(RequestParams {
|
||
endpoint,
|
||
content_type: Some(ContentType::Protobuf),
|
||
if_none_match,
|
||
..Default::default()
|
||
})
|
||
.await?;
|
||
Ok(EtagResponse {
|
||
data: O::parse_from_bytes(&res.data)?,
|
||
etag: res.etag,
|
||
})
|
||
}
|
||
|
||
async fn request_get_json<O: DeserializeOwned>(&self, endpoint: &str) -> Result<O, Error> {
|
||
let res = self
|
||
._request_generic(RequestParams {
|
||
endpoint,
|
||
content_type: Some(ContentType::Json),
|
||
..Default::default()
|
||
})
|
||
.await?;
|
||
match serde_json::from_slice(&res.data) {
|
||
Ok(res) => Ok(res),
|
||
Err(e) => {
|
||
debug!("JSON response:\n{}", String::from_utf8_lossy(&res.data));
|
||
Err(e.into())
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn public_get<O: DeserializeOwned>(
|
||
&self,
|
||
endpoint: &str,
|
||
params: Option<&Query<'_>>,
|
||
) -> Result<O, Error> {
|
||
let res = self
|
||
._request_generic(RequestParams {
|
||
endpoint,
|
||
params,
|
||
public_api: true,
|
||
content_type: Some(ContentType::Json),
|
||
..Default::default()
|
||
})
|
||
.await?;
|
||
Ok(serde_json::from_slice(&res.data)?)
|
||
}
|
||
|
||
async fn get_metadata<O: Message>(
|
||
&self,
|
||
id: MetadataItemId<'_>,
|
||
if_none_match: Option<&str>,
|
||
) -> Result<EtagResponse<O>, Error> {
|
||
debug!("getting metadata for {id}");
|
||
self.request_get_pb(
|
||
&format!("/metadata/4/{}/{}", id.spotify_type(), id.base16()),
|
||
if_none_match,
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub async fn pb_artist(
|
||
&self,
|
||
id: ArtistId<'_>,
|
||
if_none_match: Option<&str>,
|
||
) -> Result<EtagResponse<Artist>, Error> {
|
||
self.get_metadata(MetadataItemId::Artist(id), if_none_match)
|
||
.await
|
||
}
|
||
|
||
pub async fn pb_album(
|
||
&self,
|
||
id: AlbumId<'_>,
|
||
if_none_match: Option<&str>,
|
||
) -> Result<EtagResponse<Album>, Error> {
|
||
self.get_metadata(MetadataItemId::Album(id), if_none_match)
|
||
.await
|
||
}
|
||
|
||
pub async fn pb_track(
|
||
&self,
|
||
id: TrackId<'_>,
|
||
if_none_match: Option<&str>,
|
||
) -> Result<EtagResponse<Track>, Error> {
|
||
self.get_metadata(MetadataItemId::Track(id), if_none_match)
|
||
.await
|
||
}
|
||
|
||
pub async fn pb_show(
|
||
&self,
|
||
id: ShowId<'_>,
|
||
if_none_match: Option<&str>,
|
||
) -> Result<EtagResponse<Show>, Error> {
|
||
self.get_metadata(MetadataItemId::Show(id), if_none_match)
|
||
.await
|
||
}
|
||
|
||
pub async fn pb_episode(
|
||
&self,
|
||
id: EpisodeId<'_>,
|
||
if_none_match: Option<&str>,
|
||
) -> Result<EtagResponse<Episode>, Error> {
|
||
self.get_metadata(MetadataItemId::Episode(id), if_none_match)
|
||
.await
|
||
}
|
||
|
||
pub async fn pb_playlist(
|
||
&self,
|
||
id: PlaylistId<'_>,
|
||
if_none_match: Option<&str>,
|
||
) -> Result<EtagResponse<SelectedListContent>, Error> {
|
||
debug!("getting metadata for playlist {id}");
|
||
self.request_get_pb(&format!("/playlist/v2/playlist/{}", id.id()), if_none_match)
|
||
.await
|
||
}
|
||
|
||
pub async fn pb_playlist_diff(
|
||
&self,
|
||
id: PlaylistId<'_>,
|
||
revision: &[u8],
|
||
) -> Result<SelectedListContent, Error> {
|
||
debug!("getting diff for playlist {id}");
|
||
let endpoint = format!(
|
||
"/playlist/v2/playlist/{}/diff?revision={},{}",
|
||
id.id(),
|
||
BigEndian::read_u32(&revision[0..4]),
|
||
HEXLOWER_PERMISSIVE.encode(&revision[4..]),
|
||
);
|
||
Ok(self.request_get_pb(&endpoint, None).await?.data)
|
||
}
|
||
|
||
async fn pb_extended_metadata<O: Message, T: Id>(
|
||
&self,
|
||
ids: impl IntoIterator<Item = T> + Send,
|
||
) -> Result<Vec<O>, Error> {
|
||
let mut ids = ids.into_iter().peekable();
|
||
let item_type = if let Some(first) = ids.peek() {
|
||
first.spotify_type()
|
||
} else {
|
||
return Ok(Vec::new());
|
||
};
|
||
let kind = match item_type {
|
||
SpotifyType::Album => ExtensionKind::ALBUM_V4,
|
||
SpotifyType::Artist => ExtensionKind::ARTIST_V4,
|
||
SpotifyType::Episode => ExtensionKind::EPISODE_V4,
|
||
SpotifyType::Show => ExtensionKind::SHOW_V4,
|
||
SpotifyType::Track => ExtensionKind::TRACK_V4,
|
||
_ => return Err(Error::invalid_argument("unsupported item type")),
|
||
};
|
||
|
||
let mut request = BatchedEntityRequest::new();
|
||
let header = request.header.mut_or_insert_default();
|
||
header.catalogue = self.session().catalogue().to_owned();
|
||
header.country = self.session().country().unwrap_or_default();
|
||
request.entity_request = ids
|
||
.map(|id| {
|
||
let mut r = EntityRequest::new();
|
||
r.entity_uri = id.uri();
|
||
|
||
let mut rq1 = ExtensionQuery::new();
|
||
rq1.extension_kind = EnumOrUnknown::new(kind);
|
||
r.query.push(rq1);
|
||
Ok(r)
|
||
})
|
||
.collect::<Result<Vec<_>, Error>>()?;
|
||
|
||
debug!(
|
||
"getting metadata for {} {}s",
|
||
request.entity_request.len(),
|
||
item_type
|
||
);
|
||
|
||
let resp = self
|
||
.request_pb::<BatchedExtensionResponse>(
|
||
Method::POST,
|
||
"/extended-metadata/v0/extended-metadata",
|
||
&request,
|
||
)
|
||
.await?;
|
||
|
||
resp.extended_metadata
|
||
.into_iter()
|
||
.flat_map(|x| {
|
||
x.extension_data.into_iter().filter_map(|itm| {
|
||
if itm.extension_data.is_some() {
|
||
Some(O::parse_from_bytes(&itm.extension_data.value).map_err(Error::from))
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub async fn pb_artists(
|
||
&self,
|
||
ids: impl IntoIterator<Item = ArtistId<'_>> + Send,
|
||
) -> Result<Vec<Artist>, Error> {
|
||
self.pb_extended_metadata(ids).await
|
||
}
|
||
|
||
pub async fn pb_albums(
|
||
&self,
|
||
ids: impl IntoIterator<Item = AlbumId<'_>> + Send,
|
||
) -> Result<Vec<Album>, Error> {
|
||
self.pb_extended_metadata(ids).await
|
||
}
|
||
|
||
pub async fn pb_tracks(
|
||
&self,
|
||
ids: impl IntoIterator<Item = TrackId<'_>> + Send,
|
||
) -> Result<Vec<Track>, Error> {
|
||
self.pb_extended_metadata(ids).await
|
||
}
|
||
|
||
pub async fn pb_shows(
|
||
&self,
|
||
ids: impl IntoIterator<Item = ShowId<'_>> + Send,
|
||
) -> Result<Vec<Show>, Error> {
|
||
self.pb_extended_metadata(ids).await
|
||
}
|
||
|
||
pub async fn pb_episodes(
|
||
&self,
|
||
ids: impl IntoIterator<Item = EpisodeId<'_>> + Send,
|
||
) -> Result<Vec<Episode>, Error> {
|
||
self.pb_extended_metadata(ids).await
|
||
}
|
||
|
||
pub async fn pb_audio_storage(
|
||
&self,
|
||
file_id: &FileId,
|
||
) -> Result<StorageResolveResponse, Error> {
|
||
debug!("getting audio storage for {file_id}");
|
||
let endpoint = format!(
|
||
"/storage-resolve/files/audio/interactive/{}",
|
||
file_id.base16()
|
||
);
|
||
Ok(self.request_get_pb(&endpoint, None).await?.data)
|
||
}
|
||
|
||
pub async fn get_obfuscated_key(
|
||
&self,
|
||
file_id: &FileId,
|
||
token: &[u8; 16],
|
||
is_episode: bool,
|
||
) -> Result<[u8; 16], Error> {
|
||
debug!("getting obfuscated key for {file_id}");
|
||
|
||
let mut req = PlayPlayLicenseRequest::new();
|
||
req.set_version(2);
|
||
req.set_token(token.to_vec());
|
||
req.set_interactivity(spotifyio_protocol::playplay::Interactivity::INTERACTIVE);
|
||
if is_episode {
|
||
req.set_content_type(spotifyio_protocol::playplay::ContentType::AUDIO_EPISODE);
|
||
} else {
|
||
req.set_content_type(spotifyio_protocol::playplay::ContentType::AUDIO_TRACK);
|
||
}
|
||
req.set_timestamp(OffsetDateTime::now_utc().unix_timestamp());
|
||
|
||
let license = self
|
||
.request_pb::<PlayPlayLicenseResponse>(
|
||
Method::POST,
|
||
&format!("/playplay/v1/key/{}", file_id.base16()),
|
||
&req,
|
||
)
|
||
.await?;
|
||
license
|
||
.obfuscated_key()
|
||
.try_into()
|
||
.map_err(|_| Error::failed_precondition("could not parse obfuscated key"))
|
||
}
|
||
|
||
pub async fn get_image(&self, image_id: &FileId) -> Result<Response, Error> {
|
||
debug!("getting image {image_id}");
|
||
let url = self.session().image_url(image_id);
|
||
|
||
let resp = self
|
||
.session()
|
||
.http_client()
|
||
.get(url)
|
||
.send()
|
||
.await?
|
||
.error_for_status()?;
|
||
Ok(resp)
|
||
}
|
||
|
||
pub async fn get_audio_preview(&self, file_id: &FileId) -> Result<Response, Error> {
|
||
debug!("getting audio preview for file:{file_id}");
|
||
let mut url = self.session().audio_preview_url(file_id);
|
||
let separator = match url.find('?') {
|
||
Some(_) => "&",
|
||
None => "?",
|
||
};
|
||
let _ = write!(url, "{}cid={}", separator, self.session().client_id());
|
||
|
||
let resp = self
|
||
.session()
|
||
.http_client()
|
||
.get(url)
|
||
.send()
|
||
.await?
|
||
.error_for_status()?;
|
||
Ok(resp)
|
||
}
|
||
|
||
pub async fn get_head_file(&self, file_id: &FileId) -> Result<Response, Error> {
|
||
debug!("getting head file for file:{file_id}");
|
||
|
||
let resp = self
|
||
.session()
|
||
.http_client()
|
||
.get(self.session().head_file_url(file_id))
|
||
.send()
|
||
.await?
|
||
.error_for_status()?;
|
||
Ok(resp)
|
||
}
|
||
|
||
/// Get the seektable required for streaming AAC tracks
|
||
pub async fn get_seektable(&self, file_id: &FileId) -> Result<Seektable, Error> {
|
||
debug!("getting seektable for file:{file_id}");
|
||
let url = format!(
|
||
"https://seektables.scdn.co/seektable/{}.json",
|
||
file_id.base16()
|
||
);
|
||
Ok(self
|
||
.session()
|
||
.http_client()
|
||
.get(url)
|
||
.send()
|
||
.await?
|
||
.error_for_status()?
|
||
.json()
|
||
.await?)
|
||
}
|
||
|
||
pub async fn get_widevine_certificate(&self) -> Result<Bytes, Error> {
|
||
debug!("getting widevine certificate");
|
||
Ok(self
|
||
._request_generic(RequestParams {
|
||
endpoint: "/widevine-license/v1/application-certificate",
|
||
..Default::default()
|
||
})
|
||
.await?
|
||
.data)
|
||
}
|
||
|
||
pub async fn get_widevine_license(
|
||
&self,
|
||
challenge: Vec<u8>,
|
||
is_video: bool,
|
||
) -> Result<Bytes, Error> {
|
||
debug!("getting widevine license");
|
||
let media_type = if is_video { "video" } else { "audio" };
|
||
let url = format!("/widevine-license/v1/{media_type}/license");
|
||
|
||
Ok(self
|
||
._request_generic(RequestParams {
|
||
method: Method::POST,
|
||
endpoint: &url,
|
||
body: challenge,
|
||
..Default::default()
|
||
})
|
||
.await?
|
||
.data)
|
||
}
|
||
|
||
/// Get information about a track's artists, writers and producers
|
||
pub async fn get_track_credits(&self, track_id: TrackId<'_>) -> Result<TrackCredits, Error> {
|
||
debug!("getting track credits for {track_id}");
|
||
self.request_get_json(&format!(
|
||
"/track-credits-view/v0/experimental/{}/credits",
|
||
track_id.id()
|
||
))
|
||
.await
|
||
}
|
||
|
||
pub async fn pb_canvases(
|
||
&self,
|
||
ids: impl IntoIterator<Item = TrackId<'_>> + Send,
|
||
) -> Result<HashMap<TrackId<'static>, Canvaz>, Error> {
|
||
let mut request = EntityCanvazRequest::new();
|
||
request.entities = ids
|
||
.into_iter()
|
||
.map(|id| {
|
||
let mut entity = spotifyio_protocol::canvaz::entity_canvaz_request::Entity::new();
|
||
entity.entity_uri = id.uri();
|
||
entity
|
||
})
|
||
.collect();
|
||
debug!("getting canvases for {} tracks", request.entities.len());
|
||
|
||
let resp = self
|
||
.request_pb::<EntityCanvazResponse>(Method::POST, "/canvaz-cache/v0/canvases", &request)
|
||
.await?;
|
||
|
||
resp.canvases
|
||
.into_iter()
|
||
.map(|c| Ok((TrackId::from_uri(&c.entity_uri)?.into_static(), c)))
|
||
.collect::<Result<HashMap<_, _>, Error>>()
|
||
}
|
||
|
||
pub async fn pb_canvas(&self, id: TrackId<'_>) -> Result<Canvaz, Error> {
|
||
debug!("getting canvas for {id}");
|
||
let mut request = EntityCanvazRequest::new();
|
||
let mut entity = spotifyio_protocol::canvaz::entity_canvaz_request::Entity::new();
|
||
entity.entity_uri = id.uri();
|
||
request.entities.push(entity);
|
||
|
||
let resp = self
|
||
.request_pb::<EntityCanvazResponse>(Method::POST, "/canvaz-cache/v0/canvases", &request)
|
||
.await?;
|
||
resp.canvases
|
||
.into_iter()
|
||
.next()
|
||
.ok_or_else(|| Error::not_found(format!("canvas for {id}")))
|
||
}
|
||
|
||
pub async fn get_lyrics(&self, id: TrackId<'_>) -> Result<Lyrics, Error> {
|
||
debug!("getting lyrics for {id}");
|
||
let res = self
|
||
.request_get_json::<LyricsWrap>(&format!("/color-lyrics/v2/track/{}", id.id()))
|
||
.await?;
|
||
Ok(res.lyrics)
|
||
}
|
||
|
||
pub async fn gql_artist_overview(&self, id: ArtistId<'_>) -> Result<ArtistGql, Error> {
|
||
debug!("getting artist overview for {id}");
|
||
let url = format!(
|
||
"https://api-partner.spotify.com/pathfinder/v1/query?operationName=queryArtistOverview&variables=%7B%22uri%22%3A%22spotify%3Aartist%3A{}%22%2C%22locale%22%3A%22%22%2C%22includePrerelease%22%3Atrue%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22bc0107feab9595387a22ebed6c944c9cf72c81b2f72a3d26ac055e4465173a1f%22%7D%7D",
|
||
id.id()
|
||
);
|
||
let res = self
|
||
.request_get_json::<GqlWrap<ArtistGqlWrap>>(&url)
|
||
.await?;
|
||
Ok(res.data.artist_union)
|
||
}
|
||
|
||
pub async fn gql_artist_concerts(&self, id: ArtistId<'_>) -> Result<Vec<Concert>, Error> {
|
||
debug!("getting artist concerts for {id}");
|
||
let url = format!(
|
||
"https://api-partner.spotify.com/pathfinder/v1/query?operationName=artistConcerts&variables=%7B%22artistId%22%3A%22spotify%3Aartist%3A{}%22%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22ce78fdb28c5036de6d81bbc45571b62e2201a0c08eb068ab17dd3428396026f5%22%7D%7D",
|
||
id.id()
|
||
);
|
||
let res = self
|
||
.request_get_json::<GqlWrap<ArtistGqlWrap>>(&url)
|
||
.await?;
|
||
Ok(res.data.artist_union.goods.events.concerts.items)
|
||
}
|
||
|
||
pub async fn gql_concert(&self, id: ConcertId<'_>) -> Result<ConcertGql, Error> {
|
||
debug!("getting concert {id}");
|
||
let cid = id.id();
|
||
let url = format!(
|
||
"https://api-partner.spotify.com/pathfinder/v1/query?operationName=concert&variables=%7B%22uri%22%3A%22spotify%3Aconcert%3A{}%22%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%2252470cde70b165f7dbf74fa9bcdff4eeee7e7eddadfe12bde54a25ef844abb38%22%7D%7D",
|
||
cid
|
||
);
|
||
let res = self
|
||
.request_get_json::<GqlWrap<ConcertGqlWrap>>(&url)
|
||
.await?;
|
||
|
||
match res.data.concert {
|
||
ConcertOption::ConcertV2(concert) => Ok(concert),
|
||
ConcertOption::NotFound => Err(Error::not_found(format!("concert `{cid}`"))),
|
||
}
|
||
}
|
||
|
||
pub async fn gql_prerelease(&self, id: PrereleaseId<'_>) -> Result<PrereleaseItem, Error> {
|
||
debug!("getting prerelease {id}");
|
||
let url = format!(
|
||
"https://api-partner.spotify.com/pathfinder/v1/query?operationName=albumPreRelease&variables=%7B%22uri%22%3A%22spotify%3Aprerelease%3A{}%22%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22cb7e121ae0c2d105ea9a8a5c8a003e520f333e0e94073032dcdbd548dd205d66%22%7D%7D",
|
||
id.id()
|
||
);
|
||
let res = self
|
||
.request_get_json::<GqlWrap<PrereleaseLookup>>(&url)
|
||
.await?;
|
||
Ok(res
|
||
.data
|
||
.lookup
|
||
.into_iter()
|
||
.next()
|
||
.ok_or(Error::not_found("prerelease not found"))?
|
||
.data)
|
||
}
|
||
|
||
pub async fn gql_search(
|
||
&self,
|
||
query: &str,
|
||
offset: u32,
|
||
limit: u32,
|
||
typ: Option<SearchItemType>,
|
||
) -> Result<GqlSearchResult, Error> {
|
||
debug!("searching `{query}` (typ={typ:?},gql)");
|
||
let query = urlencoding::encode(query);
|
||
let url = match typ {
|
||
Some(SearchItemType::Artists) => format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=searchArtists&variables=%7B%22searchTerm%22%3A%22{query}%22%2C%22offset%22%3A{offset}%2C%22limit%22%3A{limit}%2C%22numberOfTopResults%22%3A20%2C%22includeAudiobooks%22%3Afalse%2C%22includePreReleases%22%3Afalse%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%220e6f9020a66fe15b93b3bb5c7e6484d1d8cb3775963996eaede72bac4d97e909%22%7D%7D"),
|
||
Some(SearchItemType::Albums) => format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=searchAlbums&variables=%7B%22searchTerm%22%3A%22{query}%22%2C%22offset%22%3A{offset}%2C%22limit%22%3A{limit}%2C%22numberOfTopResults%22%3A20%2C%22includeAudiobooks%22%3Afalse%2C%22includePreReleases%22%3Afalse%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22de1046fc459b96b661b2f4e4d821118a9fbe4b563084bf5994e89ce34acc10c0%22%7D%7D"),
|
||
Some(SearchItemType::Tracks) => format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=searchTracks&variables=%7B%22searchTerm%22%3A%22{query}%22%2C%22offset%22%3A{offset}%2C%22limit%22%3A{limit}%2C%22numberOfTopResults%22%3A20%2C%22includeAudiobooks%22%3Afalse%2C%22includePreReleases%22%3Afalse%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%225307479c18ff24aa1bd70691fdb0e77734bede8cce3bd7d43b6ff7314f52a6b8%22%7D%7D"),
|
||
Some(SearchItemType::Playlists) => format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=searchPlaylists&variables=%7B%22searchTerm%22%3A%22{query}%22%2C%22offset%22%3A{offset}%2C%22limit%22%3A{limit}%2C%22numberOfTopResults%22%3A20%2C%22includeAudiobooks%22%3Afalse%2C%22includePreReleases%22%3Afalse%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22fc3a690182167dbad20ac7a03f842b97be4e9737710600874cb903f30112ad58%22%7D%7D"),
|
||
Some(SearchItemType::Users) => format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=searchUsers&variables=%7B%22searchTerm%22%3A%22{query}%22%2C%22offset%22%3A{offset}%2C%22limit%22%3A{limit}%2C%22numberOfTopResults%22%3A20%2C%22includeAudiobooks%22%3Afalse%2C%22includePreReleases%22%3Afalse%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22d3f7547835dc86a4fdf3997e0f79314e7580eaf4aaf2f4cb1e71e189c5dfcb1f%22%7D%7D"),
|
||
None => format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=searchDesktop&variables=%7B%22searchTerm%22%3A%22{query}%22%2C%22offset%22%3A{offset}%2C%22limit%22%3A{limit}%2C%22numberOfTopResults%22%3A1%2C%22includeAudiobooks%22%3Afalse%2C%22includeArtistHasConcertsField%22%3Afalse%2C%22includePreReleases%22%3Afalse%2C%22includeLocalConcertsField%22%3Afalse%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22bd8eb4cb57ae6deeac1a7d2ebe8487b65d52e0b69387a2b51590c2471f5fd57e%22%7D%7D"),
|
||
};
|
||
let res = self
|
||
.request_get_json::<GqlWrap<SearchResultWrap>>(&url)
|
||
.await?;
|
||
Ok(res.data.search_v2)
|
||
}
|
||
|
||
pub async fn gql_playlist(&self, id: PlaylistId<'_>) -> Result<GqlPlaylistItem, Error> {
|
||
debug!("getting playlist {id} (gql)");
|
||
let url = format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=fetchPlaylist&variables=%7B%22uri%22%3A%22spotify%3Aplaylist%3A{}%22%2C%22offset%22%3A0%2C%22limit%22%3A0%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%2219ff1327c29e99c208c86d7a9d8f1929cfdf3d3202a0ff4253c821f1901aa94d%22%7D%7D", id.id());
|
||
let res = self.request_get_json::<GqlWrap<PlaylistWrap>>(&url).await?;
|
||
|
||
res.data
|
||
.playlist_v2
|
||
.into_option()
|
||
.ok_or_else(|| Error::not_found(format!("playlist {id}")))
|
||
}
|
||
|
||
pub async fn get_user_profile(
|
||
&self,
|
||
id: UserId<'_>,
|
||
playlist_limit: u32,
|
||
) -> Result<UserProfile, Error> {
|
||
debug!("getting user profile {id}");
|
||
self.request_get_json(&format!(
|
||
"/user-profile-view/v3/profile/{}?playlist_limit={playlist_limit}&market=from_token",
|
||
id.id()
|
||
))
|
||
.await
|
||
}
|
||
|
||
pub async fn get_user_playlists(
|
||
&self,
|
||
id: UserId<'_>,
|
||
offset: u32,
|
||
limit: u32,
|
||
) -> Result<UserPlaylists, Error> {
|
||
debug!("getting user playlists {id}");
|
||
self.request_get_json(&format!("/user-profile-view/v3/profile/{}/playlists?offset={offset}&limit={limit}&market=from_token", id.id())).await
|
||
}
|
||
|
||
pub async fn get_user_followers(&self, id: UserId<'_>) -> Result<Vec<FollowerItem>, Error> {
|
||
debug!("getting user followers {id}");
|
||
let res = self
|
||
.request_get_json::<UserProfilesWrap<FollowerItemUserlike>>(&format!(
|
||
"/user-profile-view/v3/profile/{}/followers?market=from_token",
|
||
id.id()
|
||
))
|
||
.await?;
|
||
Ok(res
|
||
.profiles
|
||
.into_iter()
|
||
.filter_map(|p| p.try_into().ok())
|
||
.collect())
|
||
}
|
||
|
||
pub async fn get_user_following(&self, id: UserId<'_>) -> Result<Vec<FollowerItem>, Error> {
|
||
debug!("getting user following {id}");
|
||
let res = self
|
||
.request_get_json::<UserProfilesWrap<FollowerItemUserlike>>(&format!(
|
||
"/user-profile-view/v3/profile/{}/following?market=from_token",
|
||
id.id()
|
||
))
|
||
.await?;
|
||
Ok(res
|
||
.profiles
|
||
.into_iter()
|
||
.filter_map(|p| p.try_into().ok())
|
||
.collect())
|
||
}
|
||
|
||
// PUBLIC SPOTIFY API - FROM RSPOTIFY
|
||
|
||
/// Returns a single track given the track's ID, URI or URL.
|
||
///
|
||
/// Parameters:
|
||
/// - track_id - a spotify URI, URL or ID
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-track)
|
||
pub async fn web_track(
|
||
&self,
|
||
id: TrackId<'_>,
|
||
market: Option<Market>,
|
||
) -> Result<FullTrack, Error> {
|
||
debug!("getting track {id} (web)");
|
||
let params = build_map([("market", market.map(Into::into))]);
|
||
|
||
let url = format!("/tracks/{}", id.id());
|
||
self.public_get(&url, Some(¶ms)).await
|
||
}
|
||
|
||
/// Returns a list of tracks given a list of track IDs, URIs, or URLs.
|
||
///
|
||
/// Parameters:
|
||
/// - track_ids - a list of spotify URIs, URLs or IDs
|
||
/// - market - an ISO 3166-1 alpha-2 country code or the string from_token.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-several-tracks)
|
||
pub async fn web_tracks(
|
||
&self,
|
||
track_ids: impl IntoIterator<Item = TrackId<'_>> + Send,
|
||
market: Option<Market>,
|
||
) -> Result<Vec<FullTrack>, Error> {
|
||
let ids = join_ids(track_ids);
|
||
debug!("getting tracks: {ids} (web)");
|
||
let params = build_map([("market", market.map(Into::into))]);
|
||
|
||
let url = format!("/tracks/?ids={ids}");
|
||
let result = self.public_get::<FullTracks>(&url, Some(¶ms)).await?;
|
||
Ok(result.tracks)
|
||
}
|
||
|
||
/// Returns a single artist given the artist's ID, URI or URL.
|
||
///
|
||
/// Parameters:
|
||
/// - artist_id - an artist ID, URI or URL
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artist)
|
||
pub async fn web_artist(&self, artist_id: ArtistId<'_>) -> Result<FullArtist, Error> {
|
||
debug!("getting artist {artist_id} (web)");
|
||
let url = format!("/artists/{}", artist_id.id());
|
||
self.public_get(&url, None).await
|
||
}
|
||
|
||
/// Returns a list of artists given the artist IDs, URIs, or URLs.
|
||
///
|
||
/// Parameters:
|
||
/// - artist_ids - a list of artist IDs, URIs or URLs
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-artists)
|
||
pub async fn web_artists(
|
||
&self,
|
||
artist_ids: impl IntoIterator<Item = ArtistId<'_>> + Send,
|
||
) -> Result<Vec<FullArtist>, Error> {
|
||
let ids = join_ids(artist_ids);
|
||
debug!("getting artists: {ids} (web)");
|
||
let url = format!("/artists/?ids={ids}");
|
||
let result = self.public_get::<FullArtists>(&url, None).await?;
|
||
Ok(result.artists)
|
||
}
|
||
|
||
/// Get Spotify catalog information about an artist's albums.
|
||
///
|
||
/// Parameters:
|
||
/// - artist_id - the artist ID, URI or URL
|
||
/// - include_groups - a list of album type like 'album', 'single' that will be used to filter response. if not supplied, all album types will be returned.
|
||
/// - market - limit the response to one particular country.
|
||
/// - limit - the number of albums to return
|
||
/// - offset - the index of the first album to return
|
||
///
|
||
/// See [`Self::artist_albums_manual`] for a manually paginated version of
|
||
/// this.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artists-albums)
|
||
pub fn web_artist_albums<'a>(
|
||
&'a self,
|
||
artist_id: ArtistId<'a>,
|
||
include_groups: impl IntoIterator<Item = AlbumType> + Send + Copy + 'a,
|
||
market: Option<Market>,
|
||
) -> Paginator<'a, Result<SimplifiedAlbum, Error>> {
|
||
paginate_with_ctx(
|
||
(self, artist_id),
|
||
move |(slf, artist_id), limit, offset| {
|
||
Box::pin(slf.web_artist_albums_manual(
|
||
artist_id.as_ref(),
|
||
include_groups,
|
||
market,
|
||
Some(limit),
|
||
Some(offset),
|
||
))
|
||
},
|
||
self.session().config().pagination_chunks,
|
||
)
|
||
}
|
||
|
||
/// The manually paginated version of [`Self::artist_albums`].
|
||
pub async fn web_artist_albums_manual(
|
||
&self,
|
||
artist_id: ArtistId<'_>,
|
||
include_groups: impl IntoIterator<Item = AlbumType> + Send,
|
||
market: Option<Market>,
|
||
limit: Option<u32>,
|
||
offset: Option<u32>,
|
||
) -> Result<Page<SimplifiedAlbum>, Error> {
|
||
debug!("getting albums of {artist_id} (web)");
|
||
let limit = limit.map(|x| x.to_string());
|
||
let offset = offset.map(|x| x.to_string());
|
||
let include_groups_vec = include_groups
|
||
.into_iter()
|
||
.map(|t| t.into())
|
||
.collect::<Vec<&'static str>>();
|
||
let include_groups_opt = include_groups_vec
|
||
.is_empty()
|
||
.not()
|
||
.then_some(include_groups_vec)
|
||
.map(|t| t.join(","));
|
||
|
||
let params = build_map([
|
||
("include_groups", include_groups_opt.as_deref()),
|
||
("market", market.map(Into::into)),
|
||
("limit", limit.as_deref()),
|
||
("offset", offset.as_deref()),
|
||
]);
|
||
|
||
let url = format!("/artists/{}/albums", artist_id.id());
|
||
self.public_get(&url, Some(¶ms)).await
|
||
}
|
||
|
||
/// Get Spotify catalog information about an artist's top 10 tracks by
|
||
/// country.
|
||
///
|
||
/// Parameters:
|
||
/// - artist_id - the artist ID, URI or URL
|
||
/// - market - limit the response to one particular country.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artists-top-tracks)
|
||
pub async fn web_artist_top_tracks(
|
||
&self,
|
||
artist_id: ArtistId<'_>,
|
||
market: Option<Market>,
|
||
) -> Result<Vec<FullTrack>, Error> {
|
||
debug!("getting top tracks of {artist_id} (web)");
|
||
let params = build_map([("market", market.map(Into::into))]);
|
||
|
||
let url = format!("/artists/{}/top-tracks", artist_id.id());
|
||
let result = self.public_get::<FullTracks>(&url, Some(¶ms)).await?;
|
||
Ok(result.tracks)
|
||
}
|
||
|
||
/// Get Spotify catalog information about artists similar to an identified
|
||
/// artist. Similarity is based on analysis of the Spotify community's
|
||
/// listening history.
|
||
///
|
||
/// Parameters:
|
||
/// - artist_id - the artist ID, URI or URL
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artists-related-artists)
|
||
pub async fn web_artist_related_artists(
|
||
&self,
|
||
artist_id: ArtistId<'_>,
|
||
) -> Result<Vec<FullArtist>, Error> {
|
||
debug!("getting related artists of {artist_id} (web)");
|
||
let url = format!("/artists/{}/related-artists", artist_id.id());
|
||
let result = self.public_get::<FullArtists>(&url, None).await?;
|
||
Ok(result.artists)
|
||
}
|
||
|
||
/// Returns a single album given the album's ID, URIs or URL.
|
||
///
|
||
/// Parameters:
|
||
/// - album_id - the album ID, URI or URL
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-album)
|
||
pub async fn web_album(
|
||
&self,
|
||
album_id: AlbumId<'_>,
|
||
market: Option<Market>,
|
||
) -> Result<FullAlbum, Error> {
|
||
debug!("getting album {album_id} (web)");
|
||
let params = build_map([("market", market.map(Into::into))]);
|
||
|
||
let url = format!("/albums/{}", album_id.id());
|
||
self.public_get(&url, Some(¶ms)).await
|
||
}
|
||
|
||
/// Returns a list of albums given the album IDs, URIs, or URLs.
|
||
///
|
||
/// Parameters:
|
||
/// - albums_ids - a list of album IDs, URIs or URLs
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-albums)
|
||
pub async fn web_albums(
|
||
&self,
|
||
album_ids: impl IntoIterator<Item = AlbumId<'_>> + Send,
|
||
market: Option<Market>,
|
||
) -> Result<Vec<FullAlbum>, Error> {
|
||
let params = build_map([("market", market.map(Into::into))]);
|
||
let ids = join_ids(album_ids);
|
||
debug!("getting albums: {ids} (web)");
|
||
|
||
let url = format!("/albums/?ids={ids}");
|
||
let result = self.public_get::<FullAlbums>(&url, Some(¶ms)).await?;
|
||
Ok(result.albums)
|
||
}
|
||
|
||
/// Search for an Item. Get Spotify catalog information about artists,
|
||
/// albums, tracks or playlists that match a keyword string.
|
||
///
|
||
/// According to Spotify's doc, if you don't specify a country in the
|
||
/// request and your spotify account doesn't set the country, the content
|
||
/// might be unavailable for you:
|
||
/// > If a valid user access token is specified in the request header,
|
||
/// > the country associated with the user account will take priority over this parameter.
|
||
/// > Note: If neither market or user country are provided, the content is considered unavailable for the client.
|
||
/// > Users can view the country that is associated with their account in the [account settings](https://developer.spotify.com/documentation/web-api/reference/search).
|
||
///
|
||
/// Parameters:
|
||
/// - q - the search query
|
||
/// - limit - the number of items to return
|
||
/// - offset - the index of the first item to return
|
||
/// - typ - the type of item to return. One of 'artist', 'album', 'track',
|
||
/// 'playlist', 'show' or 'episode'
|
||
/// - market - An ISO 3166-1 alpha-2 country code or the string from_token.
|
||
/// - include_external: Optional.Possible values: audio. If
|
||
/// include_external=audio is specified the response will include any
|
||
/// relevant audio content that is hosted externally.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/search)
|
||
pub async fn web_search(
|
||
&self,
|
||
q: &str,
|
||
typ: SearchType,
|
||
market: Option<Market>,
|
||
include_external: Option<IncludeExternal>,
|
||
limit: Option<u32>,
|
||
offset: Option<u32>,
|
||
) -> Result<SearchResult, Error> {
|
||
debug!("searching `{q}` (typ={typ:?}, web)");
|
||
let limit = limit.map(|s| s.to_string());
|
||
let offset = offset.map(|s| s.to_string());
|
||
let params = build_map([
|
||
("q", Some(q)),
|
||
("type", Some(typ.into())),
|
||
("market", market.map(Into::into)),
|
||
("include_external", include_external.map(Into::into)),
|
||
("limit", limit.as_deref()),
|
||
("offset", offset.as_deref()),
|
||
]);
|
||
|
||
self.public_get("/search", Some(¶ms)).await
|
||
}
|
||
|
||
/// Search for multiple an Item. Get Spotify catalog information about artists,
|
||
/// albums, tracks or playlists that match a keyword string.
|
||
///
|
||
/// According to Spotify's doc, if you don't specify a country in the
|
||
/// request and your spotify account doesn't set the country, the content
|
||
/// might be unavailable for you:
|
||
/// > If a valid user access token is specified in the request header,
|
||
/// > the country associated with the user account will take priority over this parameter.
|
||
/// > Note: If neither market or user country are provided, the content is considered unavailable for the client.
|
||
/// > Users can view the country that is associated with their account in the [account settings](https://developer.spotify.com/documentation/web-api/reference/search).
|
||
///
|
||
/// Parameters:
|
||
/// - q - the search query
|
||
/// - limit - the number of items to return
|
||
/// - offset - the index of the first item to return
|
||
/// - typ - the type of item to return. Multiple of 'artist', 'album', 'track',
|
||
/// 'playlist', 'show' or 'episode'
|
||
/// - market - An ISO 3166-1 alpha-2 country code or the string from_token.
|
||
/// - include_external: Optional.Possible values: audio. If
|
||
/// include_external=audio is specified the response will include any
|
||
/// relevant audio content that is hosted externally.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/search)
|
||
pub async fn web_search_multiple(
|
||
&self,
|
||
q: &str,
|
||
typ: impl IntoIterator<Item = SearchType> + Send,
|
||
market: Option<Market>,
|
||
include_external: Option<IncludeExternal>,
|
||
limit: Option<u32>,
|
||
offset: Option<u32>,
|
||
) -> Result<SearchMultipleResult, Error> {
|
||
debug!("searching `{q}` (web)");
|
||
let limit = limit.map(|s| s.to_string());
|
||
let offset = offset.map(|s| s.to_string());
|
||
let mut _type = typ
|
||
.into_iter()
|
||
.map(|x| Into::<&str>::into(x).to_string() + ",")
|
||
.collect::<String>();
|
||
let params = build_map([
|
||
("q", Some(q)),
|
||
("type", Some(_type.trim_end_matches(","))),
|
||
("market", market.map(Into::into)),
|
||
("include_external", include_external.map(Into::into)),
|
||
("limit", limit.as_deref()),
|
||
("offset", offset.as_deref()),
|
||
]);
|
||
|
||
self.public_get("/search", Some(¶ms)).await
|
||
}
|
||
|
||
/// Get Spotify catalog information about an album's tracks.
|
||
///
|
||
/// Parameters:
|
||
/// - album_id - the album ID, URI or URL
|
||
/// - limit - the number of items to return
|
||
/// - offset - the index of the first item to return
|
||
///
|
||
/// See [`Self::album_track_manual`] for a manually paginated version of
|
||
/// this.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-albums-tracks)
|
||
pub fn web_album_track<'a>(
|
||
&'a self,
|
||
album_id: AlbumId<'a>,
|
||
market: Option<Market>,
|
||
) -> Paginator<'a, Result<SimplifiedTrack, Error>> {
|
||
paginate_with_ctx(
|
||
(self, album_id),
|
||
move |(slf, album_id), limit, offset| {
|
||
Box::pin(slf.web_album_track_manual(
|
||
album_id.as_ref(),
|
||
market,
|
||
Some(limit),
|
||
Some(offset),
|
||
))
|
||
},
|
||
self.session().config().pagination_chunks,
|
||
)
|
||
}
|
||
|
||
/// The manually paginated version of [`Self::album_track`].
|
||
pub async fn web_album_track_manual(
|
||
&self,
|
||
album_id: AlbumId<'_>,
|
||
market: Option<Market>,
|
||
limit: Option<u32>,
|
||
offset: Option<u32>,
|
||
) -> Result<Page<SimplifiedTrack>, Error> {
|
||
debug!("getting album tracks of {album_id} (web)");
|
||
let limit = limit.map(|s| s.to_string());
|
||
let offset = offset.map(|s| s.to_string());
|
||
let params = build_map([
|
||
("limit", limit.as_deref()),
|
||
("offset", offset.as_deref()),
|
||
("market", market.map(Into::into)),
|
||
]);
|
||
|
||
let url = format!("/albums/{}/tracks", album_id.id());
|
||
self.public_get(&url, Some(¶ms)).await
|
||
}
|
||
|
||
/// Gets basic profile information about a Spotify User.
|
||
///
|
||
/// Parameters:
|
||
/// - user - the id of the usr
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-users-profile)
|
||
pub async fn web_user(&self, user_id: UserId<'_>) -> Result<PublicUser, Error> {
|
||
debug!("getting user {user_id} (web)");
|
||
let url = format!("/users/{}", user_id.id());
|
||
self.public_get(&url, None).await
|
||
}
|
||
|
||
/// Get full details about Spotify playlist.
|
||
///
|
||
/// Parameters:
|
||
/// - playlist_id - the id of the playlist
|
||
/// - market - an ISO 3166-1 alpha-2 country code or the string from_token.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-playlist)
|
||
pub async fn web_playlist(
|
||
&self,
|
||
playlist_id: PlaylistId<'_>,
|
||
fields: Option<&str>,
|
||
market: Option<Market>,
|
||
) -> Result<FullPlaylist, Error> {
|
||
debug!("getting playlist {playlist_id} (web)");
|
||
let params = build_map([("fields", fields), ("market", market.map(Into::into))]);
|
||
|
||
let url = format!("/playlists/{}", playlist_id.id());
|
||
self.public_get(&url, Some(¶ms)).await
|
||
}
|
||
|
||
/// Gets playlist of a user.
|
||
///
|
||
/// Parameters:
|
||
/// - user_id - the id of the user
|
||
/// - playlist_id - the id of the playlist (None for liked tracks)
|
||
/// - fields - which fields to return
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-list-users-playlists)
|
||
pub async fn web_user_playlist(
|
||
&self,
|
||
user_id: UserId<'_>,
|
||
playlist_id: Option<PlaylistId<'_>>,
|
||
fields: Option<&str>,
|
||
) -> Result<FullPlaylist, Error> {
|
||
debug!("getting user playlist from {user_id} ({playlist_id:?},web)");
|
||
let params = build_map([("fields", fields)]);
|
||
|
||
let url = match playlist_id {
|
||
Some(playlist_id) => format!("/users/{}/playlists/{}", user_id.id(), playlist_id.id()),
|
||
None => format!("/users/{}/starred", user_id.id()),
|
||
};
|
||
self.public_get(&url, Some(¶ms)).await
|
||
}
|
||
|
||
/// Check to see if the given users are following the given playlist.
|
||
///
|
||
/// Parameters:
|
||
/// - playlist_id - the id of the playlist
|
||
/// - user_ids - the ids of the users that you want to check to see if they
|
||
/// follow the playlist. Maximum: 5 ids.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/check-if-user-follows-playlist)
|
||
pub async fn web_playlist_check_follow(
|
||
&self,
|
||
playlist_id: PlaylistId<'_>,
|
||
user_ids: impl IntoIterator<Item = UserId<'_>> + Send,
|
||
) -> Result<Vec<bool>, Error> {
|
||
let ids = join_ids(user_ids);
|
||
debug!("checking followers of playlist {playlist_id}: {ids} (web)");
|
||
let url = format!(
|
||
"/playlists/{}/followers/contains?ids={}",
|
||
playlist_id.id(),
|
||
ids,
|
||
);
|
||
self.public_get(&url, None).await
|
||
}
|
||
|
||
/// Get Spotify catalog information for a single show identified by its unique Spotify ID.
|
||
///
|
||
/// Path Parameters:
|
||
/// - id: The Spotify ID for the show.
|
||
///
|
||
/// Query Parameters
|
||
/// - market(Optional): An ISO 3166-1 alpha-2 country code or the string from_token.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-a-show)
|
||
pub async fn web_get_a_show(
|
||
&self,
|
||
id: ShowId<'_>,
|
||
market: Option<Market>,
|
||
) -> Result<FullShow, Error> {
|
||
debug!("getting show {id} (web)");
|
||
let params = build_map([("market", market.map(Into::into))]);
|
||
|
||
let url = format!("/shows/{}", id.id());
|
||
self.public_get(&url, Some(¶ms)).await
|
||
}
|
||
|
||
/// Get Spotify catalog information for multiple shows based on their
|
||
/// Spotify IDs.
|
||
///
|
||
/// Query Parameters
|
||
/// - ids(Required) A comma-separated list of the Spotify IDs for the shows. Maximum: 50 IDs.
|
||
/// - market(Optional) An ISO 3166-1 alpha-2 country code or the string from_token.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-shows)
|
||
pub async fn web_get_several_shows(
|
||
&self,
|
||
ids: impl IntoIterator<Item = ShowId<'_>> + Send,
|
||
market: Option<Market>,
|
||
) -> Result<Vec<SimplifiedShow>, Error> {
|
||
let ids = join_ids(ids);
|
||
debug!("getting shows: {ids} (web)");
|
||
let params = build_map([("ids", Some(&ids)), ("market", market.map(Into::into))]);
|
||
|
||
let result = self
|
||
.public_get::<SimplifiedShows>("/shows", Some(¶ms))
|
||
.await?;
|
||
Ok(result.shows)
|
||
}
|
||
|
||
/// Get Spotify catalog information about an show’s episodes. Optional
|
||
/// parameters can be used to limit the number of episodes returned.
|
||
///
|
||
/// Path Parameters
|
||
/// - id: The Spotify ID for the show.
|
||
///
|
||
/// Query Parameters
|
||
/// - limit: Optional. The maximum number of episodes to return. Default: 20. Minimum: 1. Maximum: 50.
|
||
/// - offset: Optional. The index of the first episode to return. Default: 0 (the first object). Use with limit to get the next set of episodes.
|
||
/// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token.
|
||
///
|
||
/// See [`Self::get_shows_episodes_manual`] for a manually paginated version
|
||
/// of this.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-a-shows-episodes)
|
||
pub fn web_get_shows_episodes<'a>(
|
||
&'a self,
|
||
id: ShowId<'a>,
|
||
market: Option<Market>,
|
||
) -> Paginator<'a, Result<SimplifiedEpisode, Error>> {
|
||
paginate_with_ctx(
|
||
(self, id),
|
||
move |(slf, id), limit, offset| {
|
||
Box::pin(slf.web_get_shows_episodes_manual(
|
||
id.as_ref(),
|
||
market,
|
||
Some(limit),
|
||
Some(offset),
|
||
))
|
||
},
|
||
self.session().config().pagination_chunks,
|
||
)
|
||
}
|
||
|
||
/// The manually paginated version of [`Self::get_shows_episodes`].
|
||
pub async fn web_get_shows_episodes_manual(
|
||
&self,
|
||
id: ShowId<'_>,
|
||
market: Option<Market>,
|
||
limit: Option<u32>,
|
||
offset: Option<u32>,
|
||
) -> Result<Page<SimplifiedEpisode>, Error> {
|
||
debug!("getting episodes of show {id} (web)");
|
||
let limit = limit.map(|x| x.to_string());
|
||
let offset = offset.map(|x| x.to_string());
|
||
let params = build_map([
|
||
("market", market.map(Into::into)),
|
||
("limit", limit.as_deref()),
|
||
("offset", offset.as_deref()),
|
||
]);
|
||
|
||
let url = format!("/shows/{}/episodes", id.id());
|
||
self.public_get(&url, Some(¶ms)).await
|
||
}
|
||
|
||
/// Get Spotify catalog information for a single episode identified by its unique Spotify ID.
|
||
///
|
||
/// Path Parameters
|
||
/// - id: The Spotify ID for the episode.
|
||
///
|
||
/// Query Parameters
|
||
/// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-episode)
|
||
pub async fn web_get_an_episode(
|
||
&self,
|
||
id: EpisodeId<'_>,
|
||
market: Option<Market>,
|
||
) -> Result<FullEpisode, Error> {
|
||
debug!("getting episode {id} (web)");
|
||
let params = build_map([("market", market.map(Into::into))]);
|
||
let url = format!("/episodes/{}", id.id());
|
||
self.public_get(&url, Some(¶ms)).await
|
||
}
|
||
|
||
/// Get Spotify catalog information for multiple episodes based on their Spotify IDs.
|
||
///
|
||
/// Query Parameters
|
||
/// - ids: Required. A comma-separated list of the Spotify IDs for the episodes. Maximum: 50 IDs.
|
||
/// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-episodes)
|
||
pub async fn web_get_several_episodes(
|
||
&self,
|
||
ids: impl IntoIterator<Item = EpisodeId<'_>> + Send,
|
||
market: Option<Market>,
|
||
) -> Result<Vec<FullEpisode>, Error> {
|
||
let ids = join_ids(ids);
|
||
debug!("getting episodes: {ids} (web)");
|
||
let params = build_map([("ids", Some(&ids)), ("market", market.map(Into::into))]);
|
||
|
||
let result = self
|
||
.public_get::<EpisodesPayload>("/episodes", Some(¶ms))
|
||
.await?;
|
||
Ok(result.episodes)
|
||
}
|
||
|
||
/// Get audio features for a track
|
||
///
|
||
/// Parameters:
|
||
/// - track - track URI, URL or ID
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-audio-features)
|
||
pub async fn web_track_features(&self, track_id: TrackId<'_>) -> Result<AudioFeatures, Error> {
|
||
debug!("getting track features for {track_id} (web)");
|
||
let url = format!("/audio-features/{}", track_id.id());
|
||
self.public_get(&url, None).await
|
||
}
|
||
|
||
/// Get Audio Features for Several Tracks
|
||
///
|
||
/// Parameters:
|
||
/// - tracks a list of track URIs, URLs or IDs
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-several-audio-features)
|
||
pub async fn web_tracks_features(
|
||
&self,
|
||
track_ids: impl IntoIterator<Item = TrackId<'_>> + Send,
|
||
) -> Result<Vec<AudioFeatures>, Error> {
|
||
let ids = join_ids(track_ids);
|
||
debug!("getting track features for {ids} (web)");
|
||
let url = format!("/audio-features/?ids={ids}");
|
||
|
||
let result = self
|
||
.public_get::<Option<AudioFeaturesPayload>>(&url, None)
|
||
.await?;
|
||
if let Some(payload) = result {
|
||
Ok(payload.audio_features.into_iter().flatten().collect())
|
||
} else {
|
||
Ok(Vec::new())
|
||
}
|
||
}
|
||
|
||
/// Get Audio Analysis for a Track
|
||
///
|
||
/// Parameters:
|
||
/// - track_id - a track URI, URL or ID
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-audio-analysis)
|
||
pub async fn web_track_analysis(&self, track_id: TrackId<'_>) -> Result<AudioAnalysis, Error> {
|
||
debug!("getting audio analysis for {track_id} (web)");
|
||
let url = format!("/audio-analysis/{}", track_id.id());
|
||
self.public_get(&url, None).await
|
||
}
|
||
|
||
/// Get a list of new album releases featured in Spotify
|
||
///
|
||
/// Parameters:
|
||
/// - country - An ISO 3166-1 alpha-2 country code or string from_token.
|
||
/// - locale - The desired language, consisting of an ISO 639 language code
|
||
/// and an ISO 3166-1 alpha-2 country code, joined by an underscore.
|
||
/// - limit - The maximum number of items to return. Default: 20.
|
||
/// Minimum: 1. Maximum: 50
|
||
/// - offset - The index of the first item to return. Default: 0 (the first
|
||
/// object). Use with limit to get the next set of items.
|
||
///
|
||
/// See [`Self::categories_manual`] for a manually paginated version of
|
||
/// this.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-categories)
|
||
pub fn web_categories<'a>(
|
||
&'a self,
|
||
locale: Option<&'a str>,
|
||
country: Option<Market>,
|
||
) -> Paginator<'a, Result<Category, Error>> {
|
||
paginate(
|
||
move |limit, offset| {
|
||
self.web_categories_manual(locale, country, Some(limit), Some(offset))
|
||
},
|
||
self.session().config().pagination_chunks,
|
||
)
|
||
}
|
||
|
||
/// The manually paginated version of [`Self::categories`].
|
||
pub async fn web_categories_manual(
|
||
&self,
|
||
locale: Option<&str>,
|
||
country: Option<Market>,
|
||
limit: Option<u32>,
|
||
offset: Option<u32>,
|
||
) -> Result<Page<Category>, Error> {
|
||
debug!("getting categories (web)");
|
||
let limit = limit.map(|x| x.to_string());
|
||
let offset = offset.map(|x| x.to_string());
|
||
let params = build_map([
|
||
("locale", locale),
|
||
("country", country.map(Into::into)),
|
||
("limit", limit.as_deref()),
|
||
("offset", offset.as_deref()),
|
||
]);
|
||
let result = self
|
||
.public_get::<PageCategory>("/browse/categories", Some(¶ms))
|
||
.await?;
|
||
Ok(result.categories)
|
||
}
|
||
|
||
/// Get a list of playlists in a category in Spotify
|
||
///
|
||
/// Parameters:
|
||
/// - category_id - The category id to get playlists from.
|
||
/// - country - An ISO 3166-1 alpha-2 country code or the string from_token.
|
||
/// - limit - The maximum number of items to return. Default: 20.
|
||
/// Minimum: 1. Maximum: 50
|
||
/// - offset - The index of the first item to return. Default: 0 (the first
|
||
/// object). Use with limit to get the next set of items.
|
||
///
|
||
/// See [`Self::category_playlists_manual`] for a manually paginated version
|
||
/// of this.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-a-categories-playlists)
|
||
pub fn web_category_playlists<'a>(
|
||
&'a self,
|
||
category_id: &'a str,
|
||
country: Option<Market>,
|
||
) -> Paginator<'a, Result<SimplifiedPlaylist, Error>> {
|
||
paginate(
|
||
move |limit, offset| {
|
||
self.web_category_playlists_manual(category_id, country, Some(limit), Some(offset))
|
||
},
|
||
self.session().config().pagination_chunks,
|
||
)
|
||
}
|
||
|
||
/// The manually paginated version of [`Self::category_playlists`].
|
||
pub async fn web_category_playlists_manual(
|
||
&self,
|
||
category_id: &str,
|
||
country: Option<Market>,
|
||
limit: Option<u32>,
|
||
offset: Option<u32>,
|
||
) -> Result<Page<SimplifiedPlaylist>, Error> {
|
||
debug!("getting playlists of category {category_id} (web)");
|
||
let limit = limit.map(|x| x.to_string());
|
||
let offset = offset.map(|x| x.to_string());
|
||
let params = build_map([
|
||
("country", country.map(Into::into)),
|
||
("limit", limit.as_deref()),
|
||
("offset", offset.as_deref()),
|
||
]);
|
||
|
||
let url = format!("/browse/categories/{category_id}/playlists");
|
||
let result = self
|
||
.public_get::<CategoryPlaylists>(&url, Some(¶ms))
|
||
.await?;
|
||
Ok(result.playlists)
|
||
}
|
||
|
||
/// Get detailed profile information about the current user.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-current-users-profile)
|
||
pub async fn web_current_user(&self) -> Result<PrivateUser, Error> {
|
||
debug!("getting current user (web)");
|
||
self.public_get("/me", None).await
|
||
}
|
||
|
||
/// Get a list of Spotify featured playlists.
|
||
///
|
||
/// Parameters:
|
||
/// - locale - The desired language, consisting of a lowercase ISO 639
|
||
/// language code and an uppercase ISO 3166-1 alpha-2 country code,
|
||
/// joined by an underscore.
|
||
/// - country - An ISO 3166-1 alpha-2 country code or the string from_token.
|
||
/// - timestamp - A timestamp in ISO 8601 format: yyyy-MM-ddTHH:mm:ss. Use
|
||
/// this parameter to specify the user's local time to get results
|
||
/// tailored for that specific date and time in the day
|
||
/// - limit - The maximum number of items to return. Default: 20.
|
||
/// Minimum: 1. Maximum: 50
|
||
/// - offset - The index of the first item to return. Default: 0
|
||
/// (the first object). Use with limit to get the next set of
|
||
/// items.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-featured-playlists)
|
||
pub async fn web_featured_playlists(
|
||
&self,
|
||
locale: Option<&str>,
|
||
country: Option<Market>,
|
||
timestamp: Option<OffsetDateTime>,
|
||
limit: Option<u32>,
|
||
offset: Option<u32>,
|
||
) -> Result<FeaturedPlaylists, Error> {
|
||
debug!("getting featured playlists (web)");
|
||
let limit = limit.map(|x| x.to_string());
|
||
let offset = offset.map(|x| x.to_string());
|
||
let timestamp = timestamp.and_then(|x| {
|
||
x.format(&time::format_description::well_known::Iso8601::DEFAULT)
|
||
.ok()
|
||
});
|
||
let params = build_map([
|
||
("locale", locale),
|
||
("country", country.map(Into::into)),
|
||
("timestamp", timestamp.as_deref()),
|
||
("limit", limit.as_deref()),
|
||
("offset", offset.as_deref()),
|
||
]);
|
||
|
||
self.public_get("/browse/featured-playlists", Some(¶ms))
|
||
.await
|
||
}
|
||
|
||
/// Get a list of new album releases featured in Spotify.
|
||
///
|
||
/// Parameters:
|
||
/// - country - An ISO 3166-1 alpha-2 country code or string from_token.
|
||
/// - limit - The maximum number of items to return. Default: 20.
|
||
/// Minimum: 1. Maximum: 50
|
||
/// - offset - The index of the first item to return. Default: 0 (the first
|
||
/// object). Use with limit to get the next set of items.
|
||
///
|
||
/// See [`Self::new_releases_manual`] for a manually paginated version of
|
||
/// this.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-new-releases)
|
||
pub fn web_new_releases(
|
||
&self,
|
||
country: Option<Market>,
|
||
) -> Paginator<'_, Result<SimplifiedAlbum, Error>> {
|
||
paginate(
|
||
move |limit, offset| self.web_new_releases_manual(country, Some(limit), Some(offset)),
|
||
self.session().config().pagination_chunks,
|
||
)
|
||
}
|
||
|
||
/// The manually paginated version of [`Self::new_releases`].
|
||
pub async fn web_new_releases_manual(
|
||
&self,
|
||
country: Option<Market>,
|
||
limit: Option<u32>,
|
||
offset: Option<u32>,
|
||
) -> Result<Page<SimplifiedAlbum>, Error> {
|
||
debug!("getting new releases (web)");
|
||
let limit = limit.map(|x| x.to_string());
|
||
let offset = offset.map(|x| x.to_string());
|
||
let params = build_map([
|
||
("country", country.map(Into::into)),
|
||
("limit", limit.as_deref()),
|
||
("offset", offset.as_deref()),
|
||
]);
|
||
|
||
let result = self
|
||
.public_get::<PageSimplifiedAlbums>("/browse/new-releases", Some(¶ms))
|
||
.await?;
|
||
Ok(result.albums)
|
||
}
|
||
|
||
/// Get Recommendations Based on Seeds
|
||
///
|
||
/// Parameters:
|
||
/// - attributes - restrictions on attributes for the selected tracks, such
|
||
/// as `min_acousticness` or `target_duration_ms`.
|
||
/// - seed_artists - a list of artist IDs, URIs or URLs
|
||
/// - seed_tracks - a list of artist IDs, URIs or URLs
|
||
/// - seed_genres - a list of genre names. Available genres for
|
||
/// - market - An ISO 3166-1 alpha-2 country code or the string from_token.
|
||
/// If provided, all results will be playable in this country.
|
||
/// - limit - The maximum number of items to return. Default: 20.
|
||
/// Minimum: 1. Maximum: 100
|
||
/// - `min/max/target_<attribute>` - For the tuneable track attributes
|
||
/// listed in the documentation, these values provide filters and
|
||
/// targeting on results.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-recommendations)
|
||
pub async fn web_recommendations(
|
||
&self,
|
||
attributes: impl IntoIterator<Item = RecommendationsAttribute> + Send,
|
||
seed_artists: Option<impl IntoIterator<Item = ArtistId<'_>> + Send>,
|
||
seed_genres: Option<impl IntoIterator<Item = &str> + Send>,
|
||
seed_tracks: Option<impl IntoIterator<Item = TrackId<'_>> + Send>,
|
||
market: Option<Market>,
|
||
limit: Option<u32>,
|
||
) -> Result<Recommendations, Error> {
|
||
let seed_artists = seed_artists.map(|a| join_ids(a));
|
||
let seed_genres = seed_genres.map(|x| x.into_iter().collect::<Vec<_>>().join(","));
|
||
let seed_tracks = seed_tracks.map(|t| join_ids(t));
|
||
let limit = limit.map(|x| x.to_string());
|
||
let mut params = build_map([
|
||
("seed_artists", seed_artists.as_deref()),
|
||
("seed_genres", seed_genres.as_deref()),
|
||
("seed_tracks", seed_tracks.as_deref()),
|
||
("market", market.map(Into::into)),
|
||
("limit", limit.as_deref()),
|
||
]);
|
||
debug!("getting recommendations (artists={seed_artists:?},genres={seed_genres:?},tracks={seed_tracks:?},web)");
|
||
|
||
// First converting the attributes into owned `String`s
|
||
let owned_attributes = attributes
|
||
.into_iter()
|
||
.map(|attr| (<&str>::from(attr).to_owned(), attr.value_string()))
|
||
.collect::<HashMap<_, _>>();
|
||
// Afterwards converting the values into `&str`s; otherwise they
|
||
// wouldn't live long enough
|
||
let borrowed_attributes = owned_attributes
|
||
.iter()
|
||
.map(|(key, value)| (key.as_str(), value.as_str()));
|
||
// And finally adding all of them to the payload
|
||
params.extend(borrowed_attributes);
|
||
|
||
self.public_get("/recommendations", Some(¶ms)).await
|
||
}
|
||
|
||
/// Get full details of the items of a playlist owned by a user.
|
||
///
|
||
/// Parameters:
|
||
/// - playlist_id - the id of the playlist
|
||
/// - fields - which fields to return
|
||
/// - limit - the maximum number of tracks to return
|
||
/// - offset - the index of the first track to return
|
||
/// - market - an ISO 3166-1 alpha-2 country code or the string from_token.
|
||
///
|
||
/// See [`Self::playlist_items_manual`] for a manually paginated version of
|
||
/// this.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-playlists-tracks)
|
||
pub async fn web_playlist_items<'a>(
|
||
&'a self,
|
||
playlist_id: PlaylistId<'a>,
|
||
fields: Option<&'a str>,
|
||
market: Option<Market>,
|
||
) -> Paginator<'a, Result<PlaylistItem, Error>> {
|
||
paginate_with_ctx(
|
||
(self, playlist_id, fields),
|
||
move |(slf, playlist_id, fields), limit, offset| {
|
||
Box::pin(slf.web_playlist_items_manual(
|
||
playlist_id.as_ref(),
|
||
*fields,
|
||
market,
|
||
Some(limit),
|
||
Some(offset),
|
||
))
|
||
},
|
||
self.session().config().pagination_chunks,
|
||
)
|
||
}
|
||
|
||
/// The manually paginated version of [`Self::playlist_items`].
|
||
pub async fn web_playlist_items_manual(
|
||
&self,
|
||
playlist_id: PlaylistId<'_>,
|
||
fields: Option<&str>,
|
||
market: Option<Market>,
|
||
limit: Option<u32>,
|
||
offset: Option<u32>,
|
||
) -> Result<Page<PlaylistItem>, Error> {
|
||
debug!("getting items of playlist {playlist_id} (web)");
|
||
let limit = limit.map(|s| s.to_string());
|
||
let offset = offset.map(|s| s.to_string());
|
||
let params = build_map([
|
||
("fields", fields),
|
||
("market", market.map(Into::into)),
|
||
("limit", limit.as_deref()),
|
||
("offset", offset.as_deref()),
|
||
]);
|
||
|
||
let url = format!("/playlists/{}/tracks", playlist_id.id());
|
||
self.public_get(&url, Some(¶ms)).await
|
||
}
|
||
|
||
/// Gets playlists of a user.
|
||
///
|
||
/// Parameters:
|
||
/// - user_id - the id of the usr
|
||
/// - limit - the number of items to return
|
||
/// - offset - the index of the first item to return
|
||
///
|
||
/// See [`Self::user_playlists_manual`] for a manually paginated version of
|
||
/// this.
|
||
///
|
||
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-list-users-playlists)
|
||
pub fn web_user_playlists<'a>(
|
||
&'a self,
|
||
user_id: UserId<'a>,
|
||
) -> Paginator<'a, Result<SimplifiedPlaylist, Error>> {
|
||
paginate_with_ctx(
|
||
(self, user_id),
|
||
move |(slf, user_id), limit, offset| {
|
||
Box::pin(slf.web_user_playlists_manual(user_id.as_ref(), Some(limit), Some(offset)))
|
||
},
|
||
self.session().config().pagination_chunks,
|
||
)
|
||
}
|
||
|
||
/// The manually paginated version of [`Self::user_playlists`].
|
||
pub async fn web_user_playlists_manual(
|
||
&self,
|
||
user_id: UserId<'_>,
|
||
limit: Option<u32>,
|
||
offset: Option<u32>,
|
||
) -> Result<Page<SimplifiedPlaylist>, Error> {
|
||
debug!("getting playlists of user {user_id} (web)");
|
||
let limit = limit.map(|s| s.to_string());
|
||
let offset = offset.map(|s| s.to_string());
|
||
let params = build_map([("limit", limit.as_deref()), ("offset", offset.as_deref())]);
|
||
|
||
let url = format!("/users/{}/playlists", user_id.id());
|
||
self.public_get(&url, Some(¶ms)).await
|
||
}
|
||
}
|
||
|
||
fn build_map<'key, 'value, const N: usize>(
|
||
array: [(&'key str, Option<&'value str>); N],
|
||
) -> HashMap<&'key str, &'value str> {
|
||
// Use a manual for loop instead of iterators so we can call `with_capacity`
|
||
// and avoid reallocating.
|
||
let mut map = HashMap::with_capacity(N);
|
||
for (key, value) in array {
|
||
if let Some(value) = value {
|
||
map.insert(key, value);
|
||
}
|
||
}
|
||
map
|
||
}
|
||
|
||
fn join_ids<T: Id>(ids: impl IntoIterator<Item = T>) -> String {
|
||
ids.into_iter()
|
||
.map(|id| id.id().to_owned())
|
||
.collect::<Vec<_>>()
|
||
.join(",")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use futures_util::TryStreamExt;
|
||
use spotifyio_model::{
|
||
ArtistId, FileId, IdConstruct, PlaylistId, PrereleaseId, TrackId, UserId,
|
||
};
|
||
|
||
use crate::{cache::SessionCache, gql_model::SearchItemType, Session, SessionConfig};
|
||
|
||
async fn conn() -> Session {
|
||
let s = Session::new(SessionConfig::default(), SessionCache::testing());
|
||
s.connect_stored_creds().await.unwrap();
|
||
s
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn get_artist() {
|
||
let s = conn().await;
|
||
let artist = s
|
||
.spclient()
|
||
.pb_artist(
|
||
ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap(),
|
||
// Some("\"MC-EvTsdg==\""),
|
||
None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
dbg!(&artist);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn get_artists() {
|
||
let s = conn().await;
|
||
let artists = s
|
||
.spclient()
|
||
.pb_artists([
|
||
ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap(),
|
||
ArtistId::from_id("5RJFJWYgtgWktosLrUDzxx").unwrap(), // does not exist
|
||
ArtistId::from_id("2NpPlwwDVYR5dIj0F31EcC").unwrap(),
|
||
])
|
||
.await
|
||
.unwrap();
|
||
dbg!(&artists);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn get_artist_overview() {
|
||
let s = conn().await;
|
||
let artist = s
|
||
.spclient()
|
||
.gql_artist_overview(ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap())
|
||
.await
|
||
.unwrap();
|
||
dbg!(&artist);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn get_prerelease() {
|
||
let s = conn().await;
|
||
let search = s
|
||
.spclient()
|
||
.gql_prerelease(
|
||
PrereleaseId::from_uri("spotify:prerelease:44eAZjMWAxN7AaRHqLHWO5").unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
dbg!(&search);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[tracing_test::traced_test]
|
||
async fn search() {
|
||
let s = conn().await;
|
||
let search = s
|
||
.spclient()
|
||
.gql_search("this is me", 0, 10, None)
|
||
.await
|
||
.unwrap();
|
||
dbg!(&search);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn search_tracks() {
|
||
let s = conn().await;
|
||
let n = 10;
|
||
let search = s
|
||
.spclient()
|
||
.gql_search("this is me", 0, n, Some(SearchItemType::Tracks))
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(search.tracks_v2.items.len(), n as usize);
|
||
dbg!(&search);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn search_users() {
|
||
let s = conn().await;
|
||
let n = 10;
|
||
let search = s
|
||
.spclient()
|
||
.gql_search("test", 0, n, Some(SearchItemType::Users))
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(search.users.items.len(), n as usize);
|
||
dbg!(&search);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn user_profile() {
|
||
let s = conn().await;
|
||
let user = s
|
||
.spclient()
|
||
.get_user_profile(UserId::from_id("ustz0fgnbb2pjpjhu3num7b91").unwrap(), 20)
|
||
.await
|
||
.unwrap();
|
||
dbg!(&user);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn user_playlists() {
|
||
let s = conn().await;
|
||
let playlists = s
|
||
.spclient()
|
||
.get_user_playlists(
|
||
UserId::from_id("ustz0fgnbb2pjpjhu3num7b91").unwrap(),
|
||
0,
|
||
200,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
dbg!(&playlists);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn user_followers() {
|
||
let s = conn().await;
|
||
let followers = s
|
||
.spclient()
|
||
.get_user_followers(UserId::from_id("c4vns19o05omhfw0p4txxvya7").unwrap())
|
||
.await
|
||
.unwrap();
|
||
dbg!(&followers);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn user_following() {
|
||
let s = conn().await;
|
||
let followers = s
|
||
.spclient()
|
||
.get_user_following(UserId::from_id("c4vns19o05omhfw0p4txxvya7").unwrap())
|
||
.await
|
||
.unwrap();
|
||
dbg!(&followers);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn artist_albums() {
|
||
let s = conn().await;
|
||
let albums = s
|
||
.spclient()
|
||
.web_artist_albums(
|
||
ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap(),
|
||
None,
|
||
None,
|
||
)
|
||
.try_collect::<Vec<_>>()
|
||
.await
|
||
.unwrap();
|
||
dbg!(&albums);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn web_track() {
|
||
let s = conn().await;
|
||
let track = s
|
||
.spclient()
|
||
.web_track(
|
||
TrackId::from_id("4Lzbwc1IKHCDddL17xJjxV").unwrap(),
|
||
Some(spotifyio_model::Market::FromToken),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
dbg!(&track);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn seek_table() {
|
||
let s = conn().await;
|
||
let st = s
|
||
.spclient()
|
||
.get_seektable(
|
||
&FileId::from_base16("a150f39c5e694f6066db4f815a9544a9f5b1a057").unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
dbg!(&st);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn widevine_license() {
|
||
let challenge = hex_lit::hex!("080112b20f0abc0e080112810a0ac10208021220529d02e1d35661973882f274eabf9d38ae315809a3950d4bf59e82c4b660591f189bc89fb106228e023082010a0282010100d49d3b3b3567d35b7695f8149e069a913f9832afa7fe1f22de19b59905df198756b4db905748043436a509b27c26a8cb4614b7aaec742501c2846b2cafb0fd4edf2a3620c1574289d713e9da656b032da1e8db876a636d394cd28697c3f366a7a959daf070770397ca1c7039ee6a3b5b8f256b9d423e56ab733fb7638b5055e325cb95613447c16df9edccff41da8f5f86ae71f0c9d24d2b17774804a5f25a9a59c9e1789c1473d203b7d06b22654364f25df45c9a105c3fa2840e02f73a372c6fb40046a6ef1529c0686ea07d3368783554904e05fd1e4dadfcd89be3439732f09ac9802a3b860d880977a4b3bf9a8d58355e1a8073de7ecdeeddb2a20351a9020301000128f7e1014801128002b7cb9c3e820807b3250be8ae5735b22347916b413589925d1ec93114a5cc10c3114374afed0c793547ff0065e7827f2156b238f0926130445f6d4d4f7d2af32dfb2807718744a76c9864f863a120b81b696851fb56d35ce9df6a6acdfe2133372e84834613c8ed91b75821b871a9c041a3208e25a7042a19004de051aadc89ee78e7f7cef46b68ff1aa6e371dc7416ef0b4d4467d5eaecb41964fae0ff66b77e1543de2bcea82ad9053b994af927daa0b0a0e9aecb1df985cbfcc302622c116bb39c5814c573b9f745efe45d5daacea054d9d6babc81701ec4be38fbfc76438282eba75586eaf88ba383beed6c2c3473dd18a56fe10c9245617f157577e39c0c1ab7050ab102080112101e6c196a3a78ee65768ed08932af535918b8c9ec9e06228e023082010a0282010100cb6cafd5143b87feea5cfc22c740deb0f02efdabd38aa95ee1510f7a6728363704608e46a41df7d5702778b73a74eab4b98fca2a00f620f36b55457c8be4d9d224c5f1b4b9c2dc1688b0c58a1854fbe1eee828a1d423ffc89c26a6dc925dbe511c1c3e60c6e963ab46c5e1da21fc17e7d0aeb76c53bfa028f767704945c890ccb6b02cd6c6c8448f5cc3f1e6d2d2ee0de5eb0477c0f5a52e547e44c6d220f754d3e880c36fea8f5943b0a33210101c5d4f78d20e42348c75d137907123f138e86ef1b0297ab1088dd4ec324492d8ff8221bb6fdfac0bab50f8c9f81ee2b30b12dda04a2f3ac67b557aeb13f518d8a8f076e6949cc827044feacb03d1e503aa7f020301000128f7e101480112800330d07e22ca8b5a281f9147088fa5733026d2b5708f1779ba07dff88bf403b3414b9a04199cc02b401deb112bbad27cd5f1230605c4901778e05930eec00b55677663e6b3c1bfe10a33ab1a895c9da1bdb3b766c0509d84a5dc4121f86b32b3cd9ab2520d06d4f7e45a501a7bf7973833a3f7de98c75a93099b50de677484f99870f22c947ad7afe97887ab7acd5a8510d25a9dd0c1913fe0c0984c82e31d9172b38f9e806346b2d1df246ea88d6e74c84f7336b8b6e21e232e9d84febe9ee7b32df9c0160b3050129ffebddccaa369b6ebf0de0b55b5aeb212a76feffcc7071aec1fe6999131666c04c33ef695c17956634e3a1bca5edc74255b3ff160d06ce7d95aad3071c24c8fd1a63f23a594e3d2e7323055e863d7e0aebc98914e79a83d059986b1089da1fee45ba32aa9c312c65d758b303d1e1590c97d377eae1196db559fc54417a56cea43ddbd04a6cb38bc7a45eb3691279a872938d4ea8977d327cdf1ce16e18e69321dcfd514050b2a682c3d2a40bfdf801c167866f1cac3edc41a260a106170706c69636174696f6e5f6e616d651212636f6d2e616e64726f69642e6368726f6d651a2a0a066f726967696e122034383045394330364144333844463239464242444433314143334234464435441a4e0a1e7061636b6167655f63657274696669636174655f686173685f6279746573122c38503173573045504a63736c7737557a527369584c3634772b4f353045642b52424943746179316732344d3d1a170a0c636f6d70616e795f6e616d65120773616d73756e671a160a0a6d6f64656c5f6e616d651208534d2d41313337461a200a116172636869746563747572655f6e616d65120b61726d656162692d7637611a140a0b6465766963655f6e616d65120561313376651a1a0a0c70726f647563745f6e616d65120a61313376656e736565611a590a0a6275696c645f696e666f124b73616d73756e672f61313376656e736565612f61313376653a31342f555031412e3233313030352e3030372f413133374658585334445843313a757365722f72656c656173652d6b6579731a220a147769646576696e655f63646d5f76657273696f6e120a31362e312e31403030361a240a1f6f656d5f63727970746f5f73656375726974795f70617463685f6c6576656c1201301a500a1c6f656d5f63727970746f5f6275696c645f696e666f726d6174696f6e12304f454d43727970746f204c6576656c3320436f64652032383931392046656220203220323032332030323a35303a32373214080110012000281030004000480050015800600112610a5f0a3908011210d399ea1a03b4cabf075e4c55cb04e9281a0773706f746966792214d399ea1a03b4cabf075e4c55cb04e92818edbef348e3dc959b0610011a2034383639353344343030303030303030303130303030303030303030303030301801208f98fab906301538c1ddccc7061a8002498e55d1ed289f0ff30e552abb9de307146ba11852f930ca61371f9a28da18a1416714675fb6ddd1a158cf722284bf6fb6705d2d5c87c54b8fba8b4c082f7fd95ddfa4ef10f37be59467415cd1be4cd8d259f7812927c8cc422cdab3f3d7a695d3e49af179299429513e0f0655eccb928df20f78d583c2f01053c7cc68870fd7323e8504f91a2a88a19fbb9de656a06d51f25c43604b3a6edb244fee0008e03fb11832f540d01c9dea92e13e1c3feea2f58441c0af87bc07bdbf1778096dd4b9299e77853e80ab8041579b2d5784f3fe9dfafe97ec7ae6a3b9ba9bf157ff64828ec378758a4e7c5158e46d5ca6e70605b32185da9d3a08b10fec1426e72399d6");
|
||
|
||
let s = conn().await;
|
||
let license = s
|
||
.spclient()
|
||
.get_widevine_license(challenge.to_vec(), false)
|
||
.await
|
||
.unwrap();
|
||
let license_b64 = data_encoding::BASE64.encode(&license);
|
||
dbg!(&license_b64);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn web_playlist() {
|
||
let s = conn().await;
|
||
let pl = s
|
||
.spclient()
|
||
.gql_playlist(PlaylistId::from_id("1Th7TfDCbL8I9v6nRhWObF").unwrap())
|
||
.await
|
||
.unwrap();
|
||
dbg!(&pl);
|
||
}
|
||
}
|