spotifyio/crates/spotifyio/src/spclient.rs

2468 lines
97 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(&params)).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(&params)).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(&params)).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(&params)).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(&params)).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(&params)).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(&params)).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(&params)).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(&params)).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(&params)).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(&params)).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(&params)).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(&params))
.await?;
Ok(result.shows)
}
/// Get Spotify catalog information about an shows 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(&params)).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(&params)).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(&params))
.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(&params))
.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(&params))
.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(&params))
.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(&params))
.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(&params)).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(&params)).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(&params)).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);
}
}