#![doc = include_str!("../README.md")]
mod album;
mod artist;
mod playlist;
mod search;
mod track;
pub use album::*;
pub use artist::*;
pub use playlist::*;
pub use search::*;
pub use track::*;
use arc_swap::ArcSwapOption;
use async_recursion::async_recursion;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::fmt::Display;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use strum_macros::{AsRefStr, EnumString};
use tokio::sync::{Semaphore, SemaphorePermit};
use tokio::time::sleep;
pub(crate) static TIDAL_AUTH_API_BASE_URL: &str = "https://auth.tidal.com/v1";
pub(crate) static TIDAL_API_BASE_URL: &str = "https://api.tidal.com/v1";
const INITIAL_BACKOFF_MILLIS: u64 = 100;
const DEFAULT_MAX_BACKOFF_MILLIS: u64 = 5_000;
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DeviceAuthorizationResponse {
#[serde(rename = "verificationUriComplete")]
pub url: String,
pub device_code: String,
pub expires_in: u64,
pub user_code: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct User {
#[serde(rename = "acceptedEULA")]
pub accepted_eula: bool,
pub account_link_created: bool,
pub address: Option<String>,
pub apple_uid: Option<String>,
pub channel_id: u64,
pub city: Option<String>,
pub country_code: String,
pub created: u64,
pub email: String,
pub email_verified: bool,
pub facebook_uid: Option<u64>,
pub first_name: Option<String>,
pub full_name: Option<String>,
pub google_uid: Option<String>,
pub last_name: Option<String>,
pub new_user: bool,
pub nickname: Option<String>,
pub parent_id: u64,
pub phone_number: Option<String>,
pub postalcode: Option<String>,
pub updated: u64,
pub us_state: Option<String>,
pub user_id: u64,
pub username: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AuthzToken {
#[serde(rename = "access_token")]
pub access_token: String,
pub client_name: String,
#[serde(rename = "expires_in")]
pub expires_in: i64,
#[serde(rename = "refresh_token")]
pub refresh_token: Option<String>,
pub scope: String,
#[serde(rename = "token_type")]
pub token_type: String,
pub user: User,
#[serde(rename = "user_id")]
pub user_id: i64,
}
impl AuthzToken {
pub fn authz(&self) -> Option<Authz> {
if let Some(refresh_token) = self.refresh_token.clone() {
Some(Authz {
access_token: self.access_token.clone(),
refresh_token: refresh_token,
user_id: self.user_id as u64,
country_code: Some(self.user.country_code.clone()),
})
} else {
None
}
}
}
#[derive(Debug, Serialize, Clone)]
pub struct TidalApiError {
pub status: u16,
pub sub_status: u64,
pub user_message: String,
}
impl<'de> Deserialize<'de> for TidalApiError {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value: serde_json::Value = serde_json::Value::deserialize(deserializer)?;
let status = value
.get("status")
.and_then(|v| v.as_u64())
.ok_or_else(|| serde::de::Error::custom("Missing or invalid 'status' field"))?
as u16;
let sub_status = value
.get("sub_status")
.or_else(|| value.get("subStatus"))
.and_then(|v| v.as_u64())
.ok_or_else(|| {
serde::de::Error::custom("Missing or invalid 'sub_status'/'subStatus' field")
})?;
let user_message = value
.get("user_message")
.or_else(|| value.get("userMessage"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(TidalApiError {
status,
sub_status,
user_message,
})
}
}
impl Display for TidalApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Tidal API error: {} {} {}",
self.status, self.sub_status, self.user_message
)
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Http(#[from] reqwest::Error),
#[error("Tidal API error: {0}")]
TidalApiError(TidalApiError),
#[error("No authz token available to refresh client authorization")]
NoAuthzToken,
#[error(transparent)]
SerdeJson(#[from] serde_json::Error),
#[error("No primary streaming URL available")]
NoPrimaryUrl,
#[error("Stream initialization error: {0}")]
StreamInitializationError(String),
#[error("No access token available - have you authorized the client?")]
NoAccessTokenAvailable,
#[error("Track at this playback quality not available, try a lower quality")]
TrackQualityNotAvailable,
#[error("User authentication required - please login first")]
UserAuthenticationRequired,
#[error("Track {1} not found on playlist {0}")]
PlaylistTrackNotFound(String, u64),
#[error("Hit rate limit backoff ceiling of {0}ms without recovery")]
RateLimitBackoffExceeded(u64),
}
pub type AuthzCallback = Arc<dyn Fn(Authz) + Send + Sync>;
pub struct TidalClient {
pub client: reqwest::Client,
client_id: String,
authz: ArcSwapOption<Authz>,
authz_update_semaphore: Semaphore,
country_code: Option<String>,
locale: Option<String>,
device_type: Option<DeviceType>,
on_authz_refresh_callback: Option<AuthzCallback>,
backoff: Mutex<Option<u64>>,
max_backoff_millis: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Authz {
pub access_token: String,
pub refresh_token: String,
pub user_id: u64,
pub country_code: Option<String>,
}
impl Authz {
pub fn new(
access_token: String,
refresh_token: String,
user_id: u64,
country_code: Option<String>,
) -> Self {
Self {
access_token,
refresh_token,
user_id,
country_code,
}
}
}
impl TidalClient {
pub fn new(client_id: String) -> Self {
Self {
client: reqwest::Client::new(),
client_id,
authz: ArcSwapOption::from(None),
authz_update_semaphore: Semaphore::new(1),
country_code: None,
locale: None,
device_type: None,
on_authz_refresh_callback: None,
backoff: Mutex::new(None),
max_backoff_millis: None,
}
}
pub fn with_client(mut self, client: reqwest::Client) -> Self {
self.client = client;
self
}
pub fn with_authz(mut self, authz: Authz) -> Self {
self.authz = ArcSwapOption::from_pointee(authz);
self
}
pub fn with_locale(mut self, locale: String) -> Self {
self.locale = Some(locale);
self
}
pub fn with_device_type(mut self, device_type: DeviceType) -> Self {
self.device_type = Some(device_type);
self
}
pub fn with_country_code(mut self, country_code: String) -> Self {
self.country_code = Some(country_code);
self
}
pub fn with_authz_refresh_callback<F>(mut self, authz_refresh_callback: F) -> Self
where
F: Fn(Authz) + Send + Sync + 'static,
{
self.on_authz_refresh_callback = Some(Arc::new(authz_refresh_callback));
self
}
pub fn with_max_backoff_millis(mut self, max_backoff_millis: u64) -> Self {
self.max_backoff_millis = Some(max_backoff_millis);
self
}
pub fn get_country_code(&self) -> String {
match &self.country_code {
Some(country_code) => country_code.clone(),
None => match &self.get_authz() {
Some(authz) => authz.country_code.clone().unwrap_or_else(|| "US".into()),
None => "US".into(),
},
}
}
pub fn get_locale(&self) -> String {
self.locale.clone().unwrap_or_else(|| "en_US".into())
}
pub fn get_device_type(&self) -> DeviceType {
self.device_type.unwrap_or_else(|| DeviceType::Browser)
}
pub fn get_user_id(&self) -> Option<u64> {
self.get_authz().map(|authz| authz.user_id)
}
pub fn set_country_code(&mut self, country_code: String) {
self.country_code = Some(country_code);
}
pub fn set_locale(&mut self, locale: String) {
self.locale = Some(locale);
}
pub fn set_device_type(&mut self, device_type: DeviceType) {
self.device_type = Some(device_type);
}
pub fn set_max_backoff_millis(&mut self, max_backoff_millis: u64) {
self.max_backoff_millis = Some(max_backoff_millis);
}
pub fn get_max_backoff_millis(&self) -> u64 {
self.max_backoff_millis
.unwrap_or(DEFAULT_MAX_BACKOFF_MILLIS)
}
pub fn on_authz_refresh<F>(&mut self, f: F)
where
F: Fn(Authz) + Send + Sync + 'static,
{
self.on_authz_refresh_callback = Some(Arc::new(f));
}
pub fn get_authz(&self) -> Option<Arc<Authz>> {
self.authz.load_full()
}
#[async_recursion]
async fn refresh_authz(&self) -> Result<(), Error> {
let permit: Option<SemaphorePermit> = match self.authz_update_semaphore.try_acquire() {
Ok(p) => Some(p),
Err(_) => None,
};
match permit {
Some(permit) => {
let url = format!("{TIDAL_AUTH_API_BASE_URL}/oauth2/token");
let authz = self.get_authz().ok_or(Error::NoAuthzToken)?;
let params = serde_json::json!({
"client_id": &self.client_id,
"refresh_token": authz.refresh_token,
"grant_type": "refresh_token",
"scope": "r_usr w_usr",
});
let resp: AuthzToken = self
.do_request(reqwest::Method::POST, &url, Some(params), None)
.await?;
let new_authz = Authz {
access_token: resp.access_token,
refresh_token: resp
.refresh_token
.unwrap_or_else(|| authz.refresh_token.clone()),
user_id: resp.user.user_id,
country_code: match &authz.country_code {
Some(country_code) => Some(country_code.clone()),
None => Some(resp.user.country_code.clone()),
},
};
self.authz.store(Some(Arc::new(new_authz.clone())));
drop(permit);
if let Some(cb) = &self.on_authz_refresh_callback {
cb(new_authz);
}
Ok(())
}
None => {
let _ = self.authz_update_semaphore.acquire().await;
Ok(())
}
}
}
#[async_recursion]
pub(crate) async fn do_request<T: DeserializeOwned>(
&self,
method: reqwest::Method,
url: &str,
params: Option<serde_json::Value>,
etag: Option<&str>,
) -> Result<T, Error> {
self.await_rate_limit_backoff().await;
let mut req = match method {
reqwest::Method::GET => self.client.get(url),
reqwest::Method::DELETE => self.client.delete(url),
reqwest::Method::POST => self.client.post(url),
_ => panic!("Invalid method: {}", method),
};
if let Some(etag) = etag {
req = req.header(reqwest::header::IF_NONE_MATCH, etag);
}
if let Some(authz) = self.get_authz() {
req = req.header(
reqwest::header::AUTHORIZATION,
&format!("Bearer {}", authz.access_token),
);
}
req = req.header(reqwest::header::USER_AGENT, "Mozilla/5.0 (Linux; Android 12; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/91.0.4472.114 Safari/537.36");
if let Some(params) = params.as_ref() {
match method {
reqwest::Method::POST => req = req.form(params),
reqwest::Method::GET => req = req.query(params),
reqwest::Method::DELETE => req = req.query(params),
_ => panic!("Invalid method for params: {}", method),
}
}
let resp = req.send().await?;
let etag: Option<String> = resp.headers().get("ETag").map(|etag| {
let etag = etag.to_str().expect("Invalid ETag header").to_string();
match serde_json::from_str::<String>(&etag) {
Ok(etag) => etag,
Err(_) => etag,
}
});
let status = resp.status();
let body = resp.bytes().await?;
let mut value: serde_json::Value = if body.is_empty() {
serde_json::Value::Null
} else {
match serde_json::from_slice(&body) {
Ok(value) => value,
Err(e) => {
let error_message = String::from_utf8_lossy(&body);
if log::log_enabled!(log::Level::Warn) {
log::warn!("Requested URL: {}", url);
log::warn!("JSON deserialization error: {}", e);
log::warn!("Response: {}", error_message);
}
return Err(Error::TidalApiError(TidalApiError {
status: status.as_u16(),
sub_status: 0,
user_message: error_message.to_string(),
}));
}
}
};
log::trace!(
"Response from TIDAL: {}",
serde_json::to_string_pretty(&value).unwrap()
);
if status.is_success() {
self.reset_rate_limit_backoff();
if let Some(etag) = etag {
if value.get("etag").is_none() {
value["etag"] = serde_json::Value::String(etag);
}
}
let resp: T = match serde_json::from_value(value.clone()) {
Ok(t) => t,
Err(e) => {
if log::log_enabled!(log::Level::Warn) {
let problem_value_pretty = serde_json::to_string_pretty(&value).unwrap();
log::warn!("Requested URL: {}", url);
log::warn!("JSON deserialization error: {}", e);
log::warn!("Response: {}", problem_value_pretty);
}
return Err(Error::TidalApiError(TidalApiError {
status: status.as_u16(),
sub_status: 0,
user_message: e.to_string(),
}));
}
};
Ok(resp)
} else {
if status.as_u16() == 429 || status.as_u16() == 500 {
if self.get_max_backoff_millis() == 0 {
self.reset_rate_limit_backoff();
} else {
self.increase_rate_limit_backoff()?;
return self.do_request(method, url, params, etag.as_deref()).await;
}
} else {
self.reset_rate_limit_backoff();
}
let tidal_err = match serde_json::from_value::<TidalApiError>(value.clone()) {
Ok(e) => e,
Err(e) => {
if log::log_enabled!(log::Level::Warn) {
let problem_value_pretty = serde_json::to_string_pretty(&value).unwrap();
log::warn!("Requested URL: {}", url);
log::warn!("JSON deserialization error of TidalApiError: {}", e);
log::warn!("Response: {}", problem_value_pretty);
}
return Err(Error::TidalApiError(TidalApiError {
status: status.as_u16(),
sub_status: 0,
user_message: e.to_string(),
}));
}
};
if status.as_u16() == 401 && tidal_err.sub_status == 11003 {
self.refresh_authz().await?;
return self.do_request(method, url, params, etag.as_deref()).await;
}
if log::log_enabled!(log::Level::Warn) {
let pretty_err = serde_json::to_string_pretty(&tidal_err).unwrap();
log::warn!("Requested URL: {}", url);
log::warn!("TIDAL API Error: {}", pretty_err);
}
Err(Error::TidalApiError(tidal_err))
}
}
async fn await_rate_limit_backoff(&self) {
if self.get_max_backoff_millis() == 0 {
return;
}
let delay = {
let guard = self
.backoff
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
*guard
};
if let Some(ms) = delay {
if ms > 0 {
sleep(Duration::from_millis(ms)).await;
}
}
}
fn increase_rate_limit_backoff(&self) -> Result<(), Error> {
let max_backoff = self.get_max_backoff_millis();
if max_backoff == 0 {
return Ok(());
}
let mut guard = self
.backoff
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let next = match *guard {
Some(current) => current.saturating_mul(2),
None => INITIAL_BACKOFF_MILLIS,
};
if next >= max_backoff {
*guard = Some(max_backoff);
return Err(Error::RateLimitBackoffExceeded(max_backoff));
}
*guard = Some(next);
Ok(())
}
fn reset_rate_limit_backoff(&self) {
let mut guard = self
.backoff
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if guard.is_some() {
*guard = None;
}
}
pub async fn device_authorization(&self) -> Result<DeviceAuthorizationResponse, Error> {
let url = format!("{TIDAL_AUTH_API_BASE_URL}/oauth2/device_authorization");
let params = serde_json::json!({
"client_id": &self.client_id,
"scope": "r_usr w_usr w_sub",
});
let mut resp: DeviceAuthorizationResponse = self
.do_request(reqwest::Method::POST, &url, Some(params), None)
.await?;
resp.url = format!("https://{url}", url = resp.url);
Ok(resp)
}
pub async fn authorize(
&self,
device_code: &str,
client_secret: &str,
) -> Result<AuthzToken, Error> {
let url = format!("{TIDAL_AUTH_API_BASE_URL}/oauth2/token");
let params = serde_json::json!({
"client_id": &self.client_id,
"client_secret": client_secret,
"device_code": &device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"scope": "r_usr w_usr w_sub",
});
let resp: AuthzToken = self
.do_request(reqwest::Method::POST, &url, Some(params), None)
.await?;
let authz = Authz {
access_token: resp.access_token.clone(),
refresh_token: resp
.refresh_token
.clone()
.expect("No refresh token received from Tidal after authorization"),
user_id: resp.user.user_id,
country_code: match &self.country_code {
Some(country_code) => Some(country_code.clone()),
None => Some(resp.user.country_code.clone()),
},
};
self.authz.store(Some(Arc::new(authz)));
Ok(resp)
}
}
#[derive(
Debug, Serialize, Deserialize, Default, EnumString, AsRefStr, PartialEq, Eq, Clone, Copy,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum DeviceType {
#[default]
Browser,
}
#[derive(Debug, Serialize, Deserialize, EnumString, AsRefStr, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum AudioQuality {
Low,
High,
Lossless,
HiResLossless,
}
#[derive(Debug, Serialize, Deserialize, EnumString, AsRefStr, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum Order {
Date,
}
#[derive(Debug, Serialize, Deserialize, EnumString, AsRefStr, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum OrderDirection {
Asc,
Desc,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaMetadata {
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum ResourceType {
Artist,
Album,
Track,
Video,
Playlist,
UserProfile,
}
impl ResourceType {
pub fn as_str(&self) -> &str {
match self {
ResourceType::Artist => "ARTIST",
ResourceType::Album => "ALBUM",
ResourceType::Track => "TRACK",
ResourceType::Video => "VIDEO",
ResourceType::Playlist => "PLAYLIST",
ResourceType::UserProfile => "USER_PROFILE",
}
}
}
impl std::str::FromStr for ResourceType {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"ARTIST" => Ok(ResourceType::Artist),
"ARTISTS" => Ok(ResourceType::Artist),
"ALBUM" => Ok(ResourceType::Album),
"ALBUMS" => Ok(ResourceType::Album),
"TRACK" => Ok(ResourceType::Track),
"TRACKS" => Ok(ResourceType::Track),
"VIDEO" => Ok(ResourceType::Video),
"VIDEOS" => Ok(ResourceType::Video),
"PLAYLIST" => Ok(ResourceType::Playlist),
"PLAYLISTS" => Ok(ResourceType::Playlist),
"USER_PROFILE" => Ok(ResourceType::UserProfile),
"USER_PROFILES" => Ok(ResourceType::UserProfile),
_ => Err(()),
}
}
}
impl From<String> for ResourceType {
fn from(s: String) -> Self {
s.parse().unwrap()
}
}
impl From<&str> for ResourceType {
fn from(s: &str) -> Self {
s.parse().unwrap()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "value", rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Resource {
Artists(Artist),
Albums(Album),
Tracks(Track),
Playlists(Playlist),
Videos(serde_json::Value),
UserProfiles(serde_json::Value),
}
impl Resource {
pub fn id(&self) -> String {
match self {
Resource::Artists(artist) => artist.id.to_string(),
Resource::Albums(album) => album.id.to_string(),
Resource::Tracks(track) => track.id.to_string(),
Resource::Playlists(playlist) => playlist.uuid.to_string(),
Resource::Videos(video) => video
.get("id")
.unwrap_or(&serde_json::Value::Null)
.to_string(),
Resource::UserProfiles(user_profile) => user_profile
.get("id")
.unwrap_or(&serde_json::Value::Null)
.to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct List<T> {
pub items: Vec<T>,
pub offset: usize,
pub limit: usize,
#[serde(rename = "totalNumberOfItems")]
pub total: usize,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub etag: Option<String>,
}
impl<T> List<T> {
pub fn is_empty(&self) -> bool {
self.total == 0
}
pub fn num_left(&self) -> usize {
let current_batch_size = self.items.len();
self.total - self.offset - current_batch_size
}
}
impl<T> Default for List<T> {
fn default() -> Self {
Self {
items: Vec::new(),
offset: 0,
limit: 0,
total: 0,
etag: None,
}
}
}
pub(crate) fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: Default + serde::Deserialize<'de>,
{
Option::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
}