crabidy/tidaldy/src/lib.rs

589 lines
20 KiB
Rust

/// 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, instrument};
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<Self, crabidy_core::ProviderError> {
let settings: config::Settings = if let Ok(settings) = toml::from_str(raw_toml_settings) {
settings
} else {
let settings = config::Settings::default();
println!(
"could not parse toml settings: {:#?} using default settings instead: {:#?}",
raw_toml_settings, settings
);
settings
};
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()
}
#[instrument(skip(self))]
async fn get_urls_for_track(
&self,
track_uuid: &str,
) -> Result<Vec<String>, crabidy_core::ProviderError> {
debug!("get_urls_for_track {}", track_uuid);
let (_, track_uuid, _) = split_uuid(track_uuid);
let Ok(playback) = self.get_track_playback(&track_uuid).await else {
return Err(crabidy_core::ProviderError::FetchError)
};
debug!("playback {:?}", playback);
let Ok(manifest) = playback.get_manifest() else {
return Err(crabidy_core::ProviderError::FetchError)
};
debug!("manifest {:?}", manifest);
Ok(manifest.urls)
}
#[instrument(skip(self))]
async fn get_metadata_for_track(
&self,
track_uuid: &str,
) -> Result<crabidy_core::proto::crabidy::Track, crabidy_core::ProviderError> {
debug!("get_metadata_for_track {}", track_uuid);
let Ok(track) = self.get_track(track_uuid).await else {
return Err(crabidy_core::ProviderError::FetchError)
};
Ok(track.into())
}
#[instrument(skip(self))]
fn get_lib_root(&self) -> crabidy_core::proto::crabidy::LibraryNode {
debug!("get_lib_root");
let global_root = crabidy_core::proto::crabidy::LibraryNode::new();
let children = vec![crabidy_core::proto::crabidy::LibraryNodeChild::new(
"node:userplaylists".to_string(),
"playlists".to_string(),
false,
)];
crabidy_core::proto::crabidy::LibraryNode {
uuid: "node:tidal".to_string(),
title: "tidal".to_string(),
parent: Some(format!("{}", global_root.uuid)),
tracks: Vec::new(),
children,
is_queable: false,
}
}
#[instrument(skip(self))]
async fn get_lib_node(
&self,
uuid: &str,
) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
let Some(user_id) = self.settings.login.user_id.clone() else {
return Err(crabidy_core::ProviderError::UnknownUser)
};
debug!("get_lib_node {}", uuid);
let (_kind, module, uuid) = split_uuid(uuid);
let node = match module.as_str() {
"userplaylists" => {
let mut node = crabidy_core::proto::crabidy::LibraryNode {
uuid: "node:userplaylists".to_string(),
title: "playlists".to_string(),
parent: Some("node:tidal".to_string()),
tracks: Vec::new(),
children: Vec::new(),
is_queable: false,
};
for playlist in self
.get_users_playlists_and_favorite_playlists(&user_id)
.await?
{
let child = crabidy_core::proto::crabidy::LibraryNodeChild::new(
format!("node:playlist:{}", playlist.playlist.uuid),
playlist.playlist.title,
true,
);
node.children.push(child);
}
node
}
"playlist" => {
let mut node: crabidy_core::proto::crabidy::LibraryNode =
self.get_playlist(&uuid).await?.into();
let tracks: Vec<crabidy_core::proto::crabidy::Track> = self
.get_playlist_tracks(&uuid)
.await?
.iter()
.map(|t| t.into())
.collect();
node.tracks = tracks;
node.parent = Some("node:userplaylists".to_string());
node
}
_ => return Err(crabidy_core::ProviderError::MalformedUuid),
};
Ok(node)
}
}
#[instrument]
fn split_uuid(uuid: &str) -> (String, String, String) {
let mut split = uuid.splitn(3, ':');
(
split.next().unwrap_or("").to_string(),
split.next().unwrap_or("").to_string(),
split.next().unwrap_or("").to_string(),
)
}
impl Client {
pub fn new(settings: config::Settings) -> Result<Self, ClientError> {
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]
pub fn get_user_id(&self) -> Option<String> {
self.settings.login.user_id.clone()
}
#[instrument]
pub async fn make_request<T: DeserializeOwned>(
&self,
uri: &str,
query: Option<&[(&str, String)]>,
) -> Result<T, 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: T = self
.http_client
.get(format!("{}/{}", self.settings.hifi_url, uri))
.bearer_auth(access_token)
.query(&params)
.send()
.await?
.json()
.await?;
Ok(response)
}
#[instrument]
pub async fn make_paginated_request<T: DeserializeOwned>(
&self,
uri: &str,
query: Option<&[(&str, String)]>,
) -> Result<Vec<T>, 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 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<T> = self
.http_client
.get(format!("{}/{}", self.settings.hifi_url, uri))
.bearer_auth(access_token)
.query(&params)
.send()
.await?
.json()
.await?;
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(&params)
.send()
.await?
.json()
.await?;
items.extend(response.items);
}
Ok(items)
}
#[instrument]
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(&params)
.send()
.await?
.text()
.await?;
println!("{:?}", response);
Ok(())
}
#[instrument]
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]
pub async fn get_playlist_tracks(
&self,
playlist_uuid: &str,
) -> Result<Vec<Track>, ClientError> {
Ok(self
.make_paginated_request(&format!("playlists/{}/tracks", playlist_uuid), None)
.await?)
}
#[instrument]
pub async fn get_playlist(&self, playlist_uuid: &str) -> Result<Playlist, ClientError> {
Ok(self
.make_request(&format!("playlists/{}", playlist_uuid), None)
.await?)
}
#[instrument]
pub async fn get_users_playlists(&self, user_id: u64) -> Result<Vec<Playlist>, ClientError> {
Ok(self
.make_paginated_request(&format!("users/{}/playlists", user_id), None)
.await?)
}
#[instrument]
pub async fn get_users_playlists_and_favorite_playlists(
&self,
user_id: &str,
) -> Result<Vec<PlaylistAndFavorite>, ClientError> {
Ok(self
.make_paginated_request(
&format!("users/{}/playlistsAndFavoritePlaylists", user_id),
None,
)
.await?)
}
#[instrument]
pub async fn explore_get_users_playlists_and_favorite_playlists(
&self,
user_id: u64,
) -> Result<(), ClientError> {
let limit = 50;
let offset = 0;
let limit_param = ("limit", limit.to_string());
let offset_param = ("offset", offset.to_string());
let params: Vec<(&str, String)> = vec![limit_param, offset_param];
self.make_explorer_request(
&format!("users/{}/playlistsAndFavoritePlaylists", user_id),
Some(&params[..]),
)
.await?;
Ok(())
}
#[instrument]
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]
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]
pub async fn get_track_playback(&self, track_id: &str) -> Result<TrackPlayback, ClientError> {
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]
pub async fn get_track(&self, track_id: &str) -> Result<Track, ClientError> {
let (_, track_id, _) = split_uuid(track_id);
self.make_request(&format!("tracks/{}", track_id), None)
.await
}
#[instrument]
pub async fn login_web(&mut self) -> Result<(), ClientError> {
let code_response = self.get_device_code().await?;
let now = Instant::now();
println!("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);
return Ok(());
}
println!("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?
.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]
pub async fn refresh_access_token(&self) -> Result<RefreshResponse, ClientError> {
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?;
if req.status().is_success() {
let res = req.json::<RefreshResponse>().await?;
Ok(res)
} else {
Err(ClientError::AuthError(
"Failed to refresh access token".to_string(),
))
}
}
#[instrument]
async fn get_device_code(&self) -> Result<DeviceAuthResponse, ClientError> {
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?;
if !res.status().is_success() {
return Err(ClientError::AuthError(res.status().to_string()));
}
let code: DeviceAuthResponse = res.json().await?;
Ok(code)
}
#[instrument]
pub async fn check_auth_status(
&self,
device_code: &str,
) -> Result<RefreshResponse, ClientError> {
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?;
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::<RefreshResponse>().await?;
Ok(refresh)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> Client {
let settings = crate::config::Settings::default();
Client::new(settings).expect("could not create tidaldy client")
}
#[tokio::test]
async fn test_get_device_code() {
let client = setup();
println!("{:#?}", client);
let response = client.get_device_code().await.unwrap();
assert!(!response.device_code.is_empty());
assert_eq!(response.device_code.len(), 36);
assert!(!response.user_code.is_empty());
assert_eq!(response.user_code.len(), 5);
assert!(!response.verification_uri.is_empty());
assert!(!response.verification_uri_complete.is_empty());
assert!(response.expires_in == 300);
assert!(response.interval != 0);
}
}