/// Lots of stuff and especially the auth handling is shamelessly copied from /// https://github.com/MinisculeGirraffe/tdl use reqwest::Client as HttpClient; use serde::de::DeserializeOwned; use tokio::time::{sleep, Duration, Instant}; use tracing::{debug, error, info, instrument, trace, warn}; pub mod config; pub mod models; use async_trait::async_trait; pub use models::*; #[derive(Debug)] pub struct Client { http_client: HttpClient, settings: config::Settings, } #[async_trait] impl crabidy_core::ProviderClient for Client { #[instrument(skip(raw_toml_settings))] async fn init(raw_toml_settings: &str) -> Result { let settings: config::Settings = if let Ok(settings) = toml::from_str(raw_toml_settings) { settings } else { warn!("could not parse toml settings, using defaults"); config::Settings::default() }; let mut client = Self::new(settings)?; if let Ok(_) = client.login_config().await { return Ok(client); } if let Ok(_) = client.login_web().await { return Ok(client); } Err(crabidy_core::ProviderError::CouldNotLogin) } #[instrument(skip(self))] fn settings(&self) -> String { toml::to_string_pretty(&self.settings).unwrap_or_default() } fn is_track_path(&self, path: &str) -> bool { matches!( parse_path(path), Ok(TidalPath::PlaylistTrack { .. } | TidalPath::AlbumTrack { .. }) ) } #[instrument(skip(self))] async fn get_urls_for_track( &self, track_path: &str, ) -> Result, crabidy_core::ProviderError> { let track_id = track_id_from_path(track_path)?; let playback = self.get_track_playback(track_id).await.map_err(|err| { warn!(track = track_id, "failed to fetch playback info: {err}"); crabidy_core::ProviderError::FetchError })?; trace!(?playback, "got playback info"); let manifest = playback.get_manifest().map_err(|err| { warn!(track = track_id, "failed to decode manifest: {err}"); crabidy_core::ProviderError::FetchError })?; debug!( track = track_id, urls = manifest.urls.len(), "resolved stream urls" ); Ok(manifest.urls) } #[instrument(skip(self))] async fn get_metadata_for_track( &self, track_path: &str, ) -> Result { let track_id = track_id_from_path(track_path)?; let track = self.get_track(track_id).await.map_err(|err| { warn!(track = track_id, "failed to fetch track metadata: {err}"); crabidy_core::ProviderError::FetchError })?; let parent = crabidy_core::parent_path(track_path) .unwrap_or(PROVIDER_ROOT) .to_string(); Ok(track.to_proto(&parent)) } #[instrument(skip(self))] fn get_lib_root(&self) -> crabidy_core::proto::crabidy::LibraryNode { crabidy_core::proto::crabidy::LibraryNode { path: PROVIDER_ROOT.to_string(), title: "tidal".to_string(), parent: Some(crabidy_core::ROOT_PATH.to_string()), tracks: Vec::new(), children: vec![ crabidy_core::proto::crabidy::LibraryNodeChild::new( format!("{PROVIDER_ROOT}/playlists"), "playlists".to_string(), false, ), crabidy_core::proto::crabidy::LibraryNodeChild::new( format!("{PROVIDER_ROOT}/artists"), "artists".to_string(), false, ), ], is_queable: false, } } #[instrument(skip(self))] async fn get_lib_node( &self, path: &str, ) -> Result { let Some(user_id) = self.settings.login.user_id.clone() else { return Err(crabidy_core::ProviderError::UnknownUser); }; let parsed = parse_path(path)?; debug!(?parsed, "resolving library node"); let parent = crabidy_core::parent_path(path) .unwrap_or(crabidy_core::ROOT_PATH) .to_string(); let node = match parsed { TidalPath::Root => self.get_lib_root(), TidalPath::Playlists => { let mut node = crabidy_core::proto::crabidy::LibraryNode { path: path.to_string(), title: "playlists".to_string(), parent: Some(parent), tracks: Vec::new(), children: Vec::new(), is_queable: false, }; for playlist in self .get_users_playlists_and_favorite_playlists(&user_id) .await? { node.children .push(crabidy_core::proto::crabidy::LibraryNodeChild::new( crabidy_core::join_path(path, &playlist.playlist.uuid), playlist.playlist.title, true, )); } node } TidalPath::Playlist(playlist_id) => { let playlist = self.get_playlist(playlist_id).await?; let tracks = self .get_playlist_tracks(playlist_id) .await? .iter() .map(|t| t.to_proto(path)) .collect(); crabidy_core::proto::crabidy::LibraryNode { path: path.to_string(), title: playlist.title, parent: Some(parent), tracks, children: Vec::new(), is_queable: true, } } TidalPath::Artists => { let mut node = crabidy_core::proto::crabidy::LibraryNode { path: path.to_string(), title: "artists".to_string(), parent: Some(parent), tracks: Vec::new(), children: Vec::new(), is_queable: false, }; for artist in self.get_users_artists(&user_id).await? { node.children .push(crabidy_core::proto::crabidy::LibraryNodeChild::new( crabidy_core::join_path(path, &artist.item.id.to_string()), artist.item.name, true, )); } node } TidalPath::Artist(artist_id) => { let artist = self.get_artist(artist_id).await?; let children = self .get_artist_albums(artist_id) .await? .iter() .map(|album| { crabidy_core::proto::crabidy::LibraryNodeChild::new( crabidy_core::join_path(path, &album.id.to_string()), album.title.clone(), true, ) }) .collect(); crabidy_core::proto::crabidy::LibraryNode { path: path.to_string(), title: artist.name, parent: Some(parent), tracks: Vec::new(), children, is_queable: true, } } TidalPath::Album { album, .. } => { let album_data = self.get_album(album).await?; let tracks = self .get_album_tracks(album) .await? .iter() .map(|t| t.to_proto(path)) .collect(); crabidy_core::proto::crabidy::LibraryNode { path: path.to_string(), title: album_data.title, parent: Some(parent), tracks, children: Vec::new(), is_queable: true, } } TidalPath::PlaylistTrack { .. } | TidalPath::AlbumTrack { .. } => { warn!(path, "get_lib_node called with a track path"); return Err(crabidy_core::ProviderError::MalformedPath); } }; Ok(node) } } /// The root of this provider in the global library tree. pub const PROVIDER_ROOT: &str = "/tidal"; /// A parsed tidal library path. The position in the tree is fully encoded /// in the path itself. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TidalPath<'a> { Root, Playlists, Playlist(&'a str), PlaylistTrack { playlist: &'a str, track: &'a str, }, Artists, Artist(&'a str), Album { artist: &'a str, album: &'a str, }, AlbumTrack { artist: &'a str, album: &'a str, track: &'a str, }, } pub fn parse_path(path: &str) -> Result, crabidy_core::ProviderError> { let segments = crabidy_core::path_segments(path); match segments.as_slice() { ["tidal"] => Ok(TidalPath::Root), ["tidal", "playlists"] => Ok(TidalPath::Playlists), ["tidal", "playlists", playlist] => Ok(TidalPath::Playlist(playlist)), ["tidal", "playlists", playlist, track] => Ok(TidalPath::PlaylistTrack { playlist, track }), ["tidal", "artists"] => Ok(TidalPath::Artists), ["tidal", "artists", artist] => Ok(TidalPath::Artist(artist)), ["tidal", "artists", artist, album] => Ok(TidalPath::Album { artist, album }), ["tidal", "artists", artist, album, track] => Ok(TidalPath::AlbumTrack { artist, album, track, }), _ => { warn!(path, "malformed tidal path"); Err(crabidy_core::ProviderError::MalformedPath) } } } fn track_id_from_path(path: &str) -> Result<&str, crabidy_core::ProviderError> { match parse_path(path)? { TidalPath::PlaylistTrack { track, .. } | TidalPath::AlbumTrack { track, .. } => Ok(track), _ => { warn!(path, "expected a track path"); Err(crabidy_core::ProviderError::MalformedPath) } } } impl Client { pub fn new(settings: config::Settings) -> Result { let http_client = HttpClient::builder() .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59") .build()?; Ok(Self { http_client, settings, }) } #[instrument(skip(self))] pub fn get_user_id(&self) -> Option { self.settings.login.user_id.clone() } #[instrument(skip(self))] pub async fn make_request( &self, uri: &str, query: Option<&[(&str, String)]>, ) -> Result { debug!("make_request {}", uri); let Some(ref access_token) = self.settings.login.access_token.clone() else { return Err(ClientError::AuthError("No access token found".to_string())); }; let Some(country_code) = self.settings.login.country_code.clone() else { return Err(ClientError::AuthError("No country code found".to_string())); }; let country_param = ("countryCode", country_code); let mut params: Vec<&(&str, String)> = vec![&country_param]; if let Some(query) = query { params.extend(query); } let response: T = self .http_client .get(format!("{}/{}", self.settings.hifi_url, uri)) .bearer_auth(access_token) .query(¶ms) .send() .await .map_err(|e| { error!("{:?}", e); e })? .json() .await .map_err(|e| { error!("{:?}", e); e })?; Ok(response) } #[instrument(skip(self))] pub async fn make_paginated_request( &self, uri: &str, query: Option<&[(&str, String)]>, ) -> Result, ClientError> { debug!("make_paginated_request {}", uri); let Some(ref access_token) = self.settings.login.access_token.clone() else { return Err(ClientError::AuthError("No access token found".to_string())); }; let Some(country_code) = self.settings.login.country_code.clone() else { return Err(ClientError::AuthError("No country code found".to_string())); }; let country_param = ("countryCode", country_code); let limit = 50; let mut offset = 0; let limit_param = ("limit", limit.to_string()); let mut params: Vec<&(&str, String)> = vec![&country_param, &limit_param]; if let Some(query) = query { params.extend(query); } let mut response: Page = self .http_client .get(format!("{}/{}", self.settings.hifi_url, uri)) .bearer_auth(access_token) .query(¶ms) .send() .await .map_err(|e| { error!("{:?}", e); e })? .json() .await .map_err(|e| { error!("{:?}", e); e })?; let mut items = Vec::with_capacity(response.total_number_of_items); items.extend(response.items); while response.offset + limit < response.total_number_of_items { offset += limit; let offset_param = ("offset", offset.to_string()); let mut params: Vec<&(&str, String)> = vec![&country_param, &limit_param, &offset_param]; if let Some(query) = query { params.extend(query); } response = self .http_client .get(format!("{}/{}", self.settings.hifi_url, uri)) .bearer_auth(access_token) .query(¶ms) .send() .await .map_err(|e| { error!("{:?}", e); e })? .json() .await .map_err(|e| { error!("{:?}", e); e })?; items.extend(response.items); } Ok(items) } #[instrument(skip(self))] pub async fn make_explorer_request( &self, uri: &str, query: Option<&[(&str, String)]>, ) -> Result<(), ClientError> { let Some(ref access_token) = self.settings.login.access_token.clone() else { return Err(ClientError::AuthError("No access token found".to_string())); }; let Some(country_code) = self.settings.login.country_code.clone() else { return Err(ClientError::AuthError("No country code found".to_string())); }; let country_param = ("countryCode", country_code); let mut params: Vec<&(&str, String)> = vec![&country_param]; if let Some(query) = query { params.extend(query); } let response = self .http_client .get(format!("{}/{}", self.settings.hifi_url, uri)) .bearer_auth(access_token) .query(¶ms) .send() .await .map_err(|e| { error!("{:?}", e); e })? .text() .await .map_err(|e| { error!("{:?}", e); e })?; debug!(?response, "explorer response"); Ok(()) } #[instrument(skip(self))] pub async fn search(&self, query: &str) -> Result<(), ClientError> { let query = vec![("query", query.to_string())]; self.make_explorer_request(&format!("search/artists"), Some(&query)) .await?; Ok(()) } #[instrument(skip(self))] pub async fn get_playlist_tracks( &self, playlist_uuid: &str, ) -> Result, ClientError> { Ok(self .make_paginated_request(&format!("playlists/{}/tracks", playlist_uuid), None) .await?) } #[instrument(skip(self))] pub async fn get_playlist(&self, playlist_uuid: &str) -> Result { Ok(self .make_request(&format!("playlists/{}", playlist_uuid), None) .await?) } #[instrument(skip(self))] pub async fn get_artist(&self, artist_uuid: &str) -> Result { Ok(self .make_request(&format!("artists/{}", artist_uuid), None) .await?) } #[instrument(skip(self))] pub async fn get_artist_albums(&self, artist_uuid: &str) -> Result, ClientError> { Ok(self .make_paginated_request(&format!("artists/{}/albums", artist_uuid), None) .await?) } #[instrument(skip(self))] pub async fn get_users_playlists(&self, user_id: u64) -> Result, ClientError> { Ok(self .make_paginated_request(&format!("users/{}/playlists", user_id), None) .await?) } #[instrument(skip(self))] pub async fn get_users_playlists_and_favorite_playlists( &self, user_id: &str, ) -> Result, ClientError> { Ok(self .make_paginated_request( &format!("users/{}/playlistsAndFavoritePlaylists", user_id), None, ) .await?) } #[instrument(skip(self))] pub async fn get_users_favorites(&self, user_id: u64) -> Result<(), ClientError> { self.make_explorer_request( &format!("users/{}/favorites", user_id), None, // Some(&query), ) .await?; Ok(()) } #[instrument(skip(self))] pub async fn get_users_artists(&self, user_id: &str) -> Result, ClientError> { Ok(self .make_paginated_request( &format!("users/{}/favorites/artists", user_id), None, // Some(&query), ) .await?) } #[instrument(skip(self))] pub async fn get_user(&self, user_id: u64) -> Result<(), ClientError> { self.make_explorer_request( &format!("users/{}", user_id), None, // Some(&query), ) .await?; Ok(()) } #[instrument(skip(self))] pub async fn get_album(&self, album_id: &str) -> Result { self.make_request(&format!("albums/{}/", album_id), None) .await } #[instrument(skip(self))] pub async fn get_album_tracks(&self, album_id: &str) -> Result, ClientError> { self.make_paginated_request(&format!("albums/{}/tracks", album_id), None) .await } #[instrument(skip(self))] pub async fn get_track_playback(&self, track_id: &str) -> Result { let query = vec![ ("audioquality", "LOSSLESS".to_string()), ("playbackmode", "STREAM".to_string()), ("assetpresentation", "FULL".to_string()), ]; self.make_request( &format!("tracks/{}/playbackinfopostpaywall", track_id), Some(&query), ) .await } #[instrument(skip(self))] pub async fn get_track(&self, track_id: &str) -> Result { self.make_request(&format!("tracks/{}", track_id), None) .await } #[instrument(skip(self))] pub async fn login_web(&mut self) -> Result<(), ClientError> { let code_response = self.get_device_code().await?; let now = Instant::now(); // The verification link must reach the user even without a log // subscriber configured. println!("https://{}", code_response.verification_uri_complete); info!( "waiting for device login at https://{}", code_response.verification_uri_complete ); while now.elapsed().as_secs() <= code_response.expires_in { let login = self.check_auth_status(&code_response.device_code).await; if login.is_err() { sleep(Duration::from_secs(code_response.interval)).await; continue; } let timestamp = chrono::Utc::now().timestamp() as u64; let login_results = login?; self.settings.login.device_code = Some(code_response.device_code); self.settings.login.access_token = Some(login_results.access_token); self.settings.login.refresh_token = login_results.refresh_token; self.settings.login.expires_after = Some(login_results.expires_in + timestamp); self.settings.login.user_id = Some(login_results.user.user_id.to_string()); self.settings.login.country_code = Some(login_results.user.country_code); info!("device login succeeded"); return Ok(()); } warn!("device login attempt expired"); Err(ClientError::ConnectionError) } #[instrument(skip(self))] pub async fn login_config(&mut self) -> Result<(), ClientError> { let Some(access_token) = self.settings.login.access_token.clone() else { return Err(ClientError::AuthError("No access token found".to_string())); }; //return if our session is still valid if self .http_client .get(format!("{}/sessions", self.settings.base_url)) .bearer_auth(access_token) .send() .await .map_err(|e| { error!("{:?}", e); e })? .status() .is_success() { return Ok(()); } //otherwise refresh our token let refresh = self.refresh_access_token().await?; let now = chrono::Utc::now().timestamp() as u64; self.settings.login.expires_after = Some(refresh.expires_in + now); self.settings.login.access_token = Some(refresh.access_token); Ok(()) } #[instrument(skip(self))] pub async fn refresh_access_token(&self) -> Result { let Some(refresh_token) = self.settings.login.refresh_token.clone() else { return Err(ClientError::AuthError("No refresh token found".to_string())); }; let data = DeviceAuthRequest { client_id: self.settings.oauth.client_id.clone(), client_secret: Some(self.settings.oauth.client_secret.clone()), refresh_token: Some(refresh_token.to_string()), grant_type: Some("refresh_token".to_string()), ..Default::default() }; let body = serde_urlencoded::to_string(&data)?; let req = self .http_client .post("https://auth.tidal.com/v1/oauth2/token") .body(body) .basic_auth( self.settings.oauth.client_id.clone(), Some(self.settings.oauth.client_secret.clone()), ) .header("Content-Type", "application/x-www-form-urlencoded") .send() .await .map_err(|e| { error!("{:?}", e); e })?; if req.status().is_success() { let res = req.json::().await?; Ok(res) } else { Err(ClientError::AuthError( "Failed to refresh access token".to_string(), )) } } #[instrument(skip(self))] async fn get_device_code(&self) -> Result { let req = DeviceAuthRequest { client_id: self.settings.oauth.client_id.clone(), scope: Some("r_usr+w_usr+w_sub".to_string()), ..Default::default() }; let payload = serde_urlencoded::to_string(&req)?; let res = self .http_client .post(format!( "{}/device_authorization", &self.settings.oauth.base_url )) .header("Content-Type", "application/x-www-form-urlencoded") .body(payload) .send() .await .map_err(|e| { error!("{:?}", e); e })?; if !res.status().is_success() { return Err(ClientError::AuthError(res.status().to_string())); } let code: DeviceAuthResponse = res.json().await?; Ok(code) } #[instrument(skip(self))] pub async fn check_auth_status( &self, device_code: &str, ) -> Result { let req = DeviceAuthRequest { client_id: self.settings.oauth.client_id.clone(), device_code: Some(device_code.to_string()), scope: Some("r_usr+w_usr+w_sub".to_string()), grant_type: Some("urn:ietf:params:oauth:grant-type:device_code".to_string()), ..Default::default() }; let payload = serde_urlencoded::to_string(&req)?; let res = self .http_client .post(format!("{}/token", self.settings.oauth.base_url)) .basic_auth( self.settings.oauth.client_id.clone(), Some(self.settings.oauth.client_secret.clone()), ) .body(payload) .header("Content-Type", "application/x-www-form-urlencoded") .send() .await .map_err(|e| { error!("{:?}", e); e })?; if !res.status().is_success() { if res.status().is_client_error() { return Err(ClientError::AuthError(format!( "Failed to check auth status: {}", res.status().canonical_reason().unwrap_or("") ))); } else { return Err(ClientError::AuthError( "Failed to check auth status".to_string(), )); } } let refresh = res.json::().await?; Ok(refresh) } } #[cfg(test)] mod tests { use crabidy_core::ProviderClient; use super::*; async fn setup() -> Client { let raw_toml_settings = std::fs::read_to_string("/home/hans/.config/crabidy/tidaldy.toml").unwrap(); Client::init(&raw_toml_settings).await.unwrap() } #[tokio::test] #[ignore = "requires a local tidal config and network access"] async fn test() { let client = setup().await; let user = client.settings.login.user_id.clone().unwrap(); let result = client.get_users_artists(&user).await.unwrap(); println!("{:?}", result); let result = client.get_artist("5293333").await.unwrap(); println!("{:?}", result); let result = client.get_album("244167550").await.unwrap(); println!("{:?}", result); assert!(false); } }