crabidy/tidaldy/src/lib.rs

782 lines
28 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,
/// Login state changes at runtime when tokens are refreshed, while the
/// client is shared immutably, hence the lock. Never held across awaits.
login: std::sync::RwLock<config::LoginConfig>,
}
/// Refresh the access token this long before it actually expires.
const TOKEN_REFRESH_MARGIN_SECS: u64 = 300;
#[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 client.login_config().await.is_ok() {
return Ok(client);
}
if client.login_web().await.is_ok() {
return Ok(client);
}
Err(crabidy_core::ProviderError::CouldNotLogin)
}
#[instrument(skip(self))]
fn settings(&self) -> String {
let mut settings = self.settings.clone();
settings.login = self.login_snapshot();
toml::to_string_pretty(&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.get_user_id() 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")
.timeout(std::time::Duration::from_secs(30))
.build()?;
let login = std::sync::RwLock::new(settings.login.clone());
Ok(Self {
http_client,
settings,
login,
})
}
/// A consistent copy of the current login state.
fn login_snapshot(&self) -> config::LoginConfig {
match self.login.read() {
Ok(login) => login.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
fn store_refresh(&self, refresh: RefreshResponse) {
let now = chrono::Utc::now().timestamp() as u64;
let mut login = match self.login.write() {
Ok(login) => login,
Err(poisoned) => poisoned.into_inner(),
};
login.expires_after = Some(now + refresh.expires_in);
login.access_token = Some(refresh.access_token);
if let Some(refresh_token) = refresh.refresh_token {
login.refresh_token = Some(refresh_token);
}
}
/// Refreshes the access token unconditionally and stores the result.
async fn force_refresh_token(&self) -> Result<(), ClientError> {
let refresh = self.refresh_access_token().await?;
self.store_refresh(refresh);
info!("access token refreshed");
Ok(())
}
/// Refreshes the access token if it is expired or about to expire.
/// Without this, long-running sessions ended up with an expired token
/// and every track fetch failed, silently stopping playback.
async fn ensure_fresh_token(&self) -> Result<(), ClientError> {
let login = self.login_snapshot();
let Some(expires_after) = login.expires_after else {
return Ok(());
};
let now = chrono::Utc::now().timestamp() as u64;
if now + TOKEN_REFRESH_MARGIN_SECS < expires_after {
return Ok(());
}
info!("access token expired or expiring soon");
self.force_refresh_token().await
}
/// Performs an authenticated GET against the hifi API.
async fn authed_get(
&self,
uri: &str,
query: Option<&[(&str, String)]>,
) -> Result<reqwest::Response, ClientError> {
let login = self.login_snapshot();
let Some(access_token) = login.access_token else {
return Err(ClientError::AuthError("No access token found".to_string()));
};
let Some(country_code) = login.country_code else {
return Err(ClientError::AuthError("No country code found".to_string()));
};
let mut params: Vec<(&str, String)> = vec![("countryCode", country_code)];
if let Some(query) = query {
params.extend(query.iter().cloned());
}
self.http_client
.get(format!("{}/{}", self.settings.hifi_url, uri))
.bearer_auth(access_token)
.query(&params)
.send()
.await
.map_err(|e| {
warn!(uri, "tidal api request failed: {e}");
ClientError::from(e)
})
}
#[instrument(skip(self))]
pub fn get_user_id(&self) -> Option<String> {
self.login_snapshot().user_id
}
#[instrument(skip(self))]
pub async fn make_request<T: DeserializeOwned>(
&self,
uri: &str,
query: Option<&[(&str, String)]>,
) -> Result<T, ClientError> {
trace!(uri, "make_request");
self.ensure_fresh_token().await?;
let mut response = self.authed_get(uri, query).await?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
// The token may have been revoked or the clock may be off:
// refresh once and retry (GETs are idempotent).
info!(uri, "got 401, refreshing access token and retrying once");
self.force_refresh_token().await?;
response = self.authed_get(uri, query).await?;
}
if !response.status().is_success() {
warn!(uri, status = %response.status(), "tidal api request failed");
return Err(ClientError::ApiError(response.status().as_u16()));
}
response.json().await.map_err(|e| {
error!(uri, "failed to decode tidal api response: {e}");
ClientError::from(e)
})
}
#[instrument(skip(self))]
pub async fn make_paginated_request<T: DeserializeOwned>(
&self,
uri: &str,
query: Option<&[(&str, String)]>,
) -> Result<Vec<T>, ClientError> {
trace!(uri, "make_paginated_request");
let limit: usize = 50;
let mut offset: usize = 0;
let mut items = Vec::new();
loop {
let mut params: Vec<(&str, String)> =
vec![("limit", limit.to_string()), ("offset", offset.to_string())];
if let Some(query) = query {
params.extend(query.iter().cloned());
}
let page: Page<T> = self.make_request(uri, Some(&params)).await?;
let fetched = page.items.len();
items.extend(page.items);
offset += fetched;
if fetched == 0 || offset >= page.total_number_of_items {
break;
}
}
debug!(uri, count = items.len(), "fetched paginated collection");
Ok(items)
}
#[instrument(skip(self))]
pub async fn make_explorer_request(
&self,
uri: &str,
query: Option<&[(&str, String)]>,
) -> Result<(), ClientError> {
self.ensure_fresh_token().await?;
let response = self.authed_get(uri, query).await?.text().await?;
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("search/artists", Some(&query))
.await?;
Ok(())
}
#[instrument(skip(self))]
pub async fn get_playlist_tracks(
&self,
playlist_uuid: &str,
) -> Result<Vec<Track>, ClientError> {
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> {
self.make_request(&format!("playlists/{}", playlist_uuid), None)
.await
}
#[instrument(skip(self))]
pub async fn get_artist(&self, artist_uuid: &str) -> Result<Artist, ClientError> {
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> {
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> {
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> {
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> {
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?;
{
let mut login = match self.login.write() {
Ok(login) => login,
Err(poisoned) => poisoned.into_inner(),
};
login.device_code = Some(code_response.device_code);
login.access_token = Some(login_results.access_token);
login.refresh_token = login_results.refresh_token;
login.expires_after = Some(login_results.expires_in + timestamp);
login.user_id = Some(login_results.user.user_id.to_string());
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 login = self.login_snapshot();
let Some(access_token) = login.access_token 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| {
warn!("session check failed: {e}");
e
})?
.status()
.is_success()
{
debug!("existing session still valid");
return Ok(());
}
// Otherwise refresh our token.
self.force_refresh_token().await
}
#[instrument(skip(self))]
pub async fn refresh_access_token(&self) -> Result<RefreshResponse, ClientError> {
let Some(refresh_token) = self.login_snapshot().refresh_token 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);
}
}