785 lines
27 KiB
Rust
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(¶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<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(¶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<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);
|
|
}
|
|
}
|