crabidy/tidaldy/src/lib.rs

785 lines
27 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, 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<Self, crabidy_core::ProviderError> {
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<Vec<String>, 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<crabidy_core::proto::crabidy::Track, crabidy_core::ProviderError> {
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<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);
};
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<TidalPath<'_>, 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<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(skip(self))]
pub fn get_user_id(&self) -> Option<String> {
self.settings.login.user_id.clone()
}
#[instrument(skip(self))]
pub async fn make_request<T: DeserializeOwned>(
&self,
uri: &str,
query: Option<&[(&str, String)]>,
) -> Result<T, ClientError> {
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(&params)
.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<T: DeserializeOwned>(
&self,
uri: &str,
query: Option<&[(&str, String)]>,
) -> Result<Vec<T>, 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<T> = self
.http_client
.get(format!("{}/{}", self.settings.hifi_url, uri))
.bearer_auth(access_token)
.query(&params)
.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(&params)
.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(&params)
.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<Vec<Track>, 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<Playlist, ClientError> {
Ok(self
.make_request(&format!("playlists/{}", playlist_uuid), None)
.await?)
}
#[instrument(skip(self))]
pub async fn get_artist(&self, artist_uuid: &str) -> Result<Artist, ClientError> {
Ok(self
.make_request(&format!("artists/{}", artist_uuid), None)
.await?)
}
#[instrument(skip(self))]
pub async fn get_artist_albums(&self, artist_uuid: &str) -> Result<Vec<Album>, 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<Vec<Playlist>, 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<Vec<PlaylistAndFavorite>, 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<Vec<ArtistItem>, 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<Album, ClientError> {
self.make_request(&format!("albums/{}/", album_id), None)
.await
}
#[instrument(skip(self))]
pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<Track>, 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<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(skip(self))]
pub async fn get_track(&self, track_id: &str) -> Result<Track, ClientError> {
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<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
.map_err(|e| {
error!("{:?}", e);
e
})?;
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(skip(self))]
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
.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<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
.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::<RefreshResponse>().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);
}
}