crabidy/tidaldy/src/lib.rs

1526 lines
60 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>,
/// Search terms created under `/tidal/search`, in creation order,
/// deduplicated. In-memory only: terms die with the process, like the
/// queue. Same locking discipline as `login`: never held across awaits.
search_terms: std::sync::RwLock<Vec<String>>,
}
/// 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 { .. }
| TidalPath::SearchTrack { .. })
)
}
#[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,
),
crabidy_core::proto::crabidy::LibraryNodeChild {
is_creatable: true,
..crabidy_core::proto::crabidy::LibraryNodeChild::new(
format!("{PROVIDER_ROOT}/search"),
"search".to_string(),
false,
)
},
],
is_queable: false,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
#[instrument(skip(self))]
async fn get_lib_node(
&self,
path: &str,
) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
// The user id is only needed for the favorites listings; search must
// work even when it is missing, so the gate lives in those arms.
let user_id = self.get_user_id();
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,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
};
let user_id = user_id.ok_or(crabidy_core::ProviderError::UnknownUser)?;
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,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
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,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
};
let user_id = user_id.ok_or(crabidy_core::ProviderError::UnknownUser)?;
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,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
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,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
TidalPath::Search => crabidy_core::proto::crabidy::LibraryNode {
path: path.to_string(),
title: "search".to_string(),
parent: Some(parent),
tracks: Vec::new(),
children: self
.search_terms_snapshot()
.iter()
.map(|term| {
// Term nodes are the modifiable nodes: renamable
// (`e`) and deletable (`d`).
crabidy_core::proto::crabidy::LibraryNodeChild {
is_editable: true,
is_deletable: true,
..crabidy_core::proto::crabidy::LibraryNodeChild::new(
crabidy_core::join_path(path, &crabidy_core::encode_segment(term)),
term.clone(),
false,
)
}
})
.collect(),
is_queable: false,
is_creatable: true,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
},
TidalPath::SearchTerm(encoded) => {
let term = crabidy_core::decode_segment(encoded);
// Unknown terms (stale client cache, server restart) are
// recreated implicitly instead of erroring.
self.register_search_term(&term);
self.search_term_node(path, &term, parent).await?
}
TidalPath::PlaylistTrack { .. }
| TidalPath::AlbumTrack { .. }
| TidalPath::SearchTrack { .. } => {
warn!(path, "get_lib_node called with a track path");
return Err(crabidy_core::ProviderError::MalformedPath);
}
};
// Tidal implements download captures (architecture/captures.md D4):
// every queueable subtree may be captured with `W`, and every node
// that lists tracks blesses them for download (listed tracks
// inherit their node's flag — this covers search-term results,
// whose node is not queueable as a whole).
let mut node = node;
node.is_downloadable = node.is_queable || !node.tracks.is_empty();
for child in &mut node.children {
child.is_downloadable = child.is_queable;
}
Ok(node)
}
#[instrument(skip(self))]
async fn create_lib_node(
&self,
parent_path: &str,
title: &str,
) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
let term = title.trim();
if term.is_empty() {
return Err(crabidy_core::ProviderError::InvalidInput);
}
// Only /tidal/search is creatable.
if parse_path(parent_path)? != TidalPath::Search {
warn!(parent_path, "node creation not supported here");
return Err(crabidy_core::ProviderError::NotSupported);
}
self.register_search_term(term);
let term_path = crabidy_core::join_path(parent_path, &crabidy_core::encode_segment(term));
self.get_lib_node(&term_path).await
}
#[instrument(skip(self))]
async fn rename_lib_node(
&self,
path: &str,
new_title: &str,
) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
let new_term = new_title.trim();
if new_term.is_empty() {
return Err(crabidy_core::ProviderError::InvalidInput);
}
// Only search terms are editable.
let TidalPath::SearchTerm(encoded_old) = parse_path(path)? else {
warn!(path, "renaming not supported here");
return Err(crabidy_core::ProviderError::NotSupported);
};
let old_term = crabidy_core::decode_segment(encoded_old);
// In-place replace keeps the list position; renaming onto an
// existing term merges with it.
self.rename_search_term(&old_term, new_term);
// A SearchTerm path always has the search node as its parent.
let parent = crabidy_core::parent_path(path).unwrap_or(PROVIDER_ROOT);
let new_path = crabidy_core::join_path(parent, &crabidy_core::encode_segment(new_term));
self.get_lib_node(&new_path).await
}
#[instrument(skip(self))]
async fn delete_lib_node(
&self,
path: &str,
) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
// Only search terms are deletable.
let TidalPath::SearchTerm(encoded) = parse_path(path)? else {
warn!(path, "deleting not supported here");
return Err(crabidy_core::ProviderError::NotSupported);
};
// Idempotent: removing an already-gone term is a success.
self.remove_search_term(&crabidy_core::decode_segment(encoded));
// A SearchTerm path always has the search node as its parent.
let parent = crabidy_core::parent_path(path).unwrap_or(PROVIDER_ROOT);
self.get_lib_node(parent).await
}
/// Streams tracks chunk-wise (see the trait docs for the channel
/// contract). Overridden so that the paginated collections — playlists
/// and albums — emit one chunk per fetched page (50 tracks) instead of
/// paginating to exhaustion inside a single node fetch; a large playlist
/// starts playing after its first page. All other paths follow the
/// generic pre-order walk, in listing order.
#[instrument(skip(self, chunk_tx))]
async fn resolve_tracks_into(
&self,
path: &str,
chunk_tx: flume::Sender<Vec<crabidy_core::proto::crabidy::Track>>,
) -> Result<(), crabidy_core::ProviderError> {
// Validate before any I/O; foreign paths never touch the network.
if let TidalPath::PlaylistTrack { .. }
| TidalPath::AlbumTrack { .. }
| TidalPath::SearchTrack { .. } = parse_path(path)?
{
match self.get_metadata_for_track(path).await {
Ok(track) => {
let _ = chunk_tx.send_async(vec![track]).await;
}
Err(err) => warn!(path, "failed to resolve track: {err}"),
}
return Ok(());
}
// Depth-first pre-order (children pushed reversed, worklist pops
// from the back) so tracks arrive in listing order.
let mut nodes_to_go = vec![path.to_string()];
let mut at_root = true;
while let Some(node_path) = nodes_to_go.pop() {
let root = std::mem::take(&mut at_root);
// The track-bearing collections stream one chunk per fetched
// page instead of paginating to exhaustion first.
let paged_uri = match parse_path(&node_path) {
Ok(TidalPath::Playlist(playlist_id)) => {
Some(format!("playlists/{playlist_id}/tracks"))
}
Ok(TidalPath::Album { album, .. }) => Some(format!("albums/{album}/tracks")),
_ => None,
};
if let Some(uri) = paged_uri {
match self
.stream_track_pages_into(&uri, &node_path, &chunk_tx)
.await
{
Ok(true) => {}
// Receiver gone: the consumer cancelled, stop fetching.
Ok(false) => return Ok(()),
Err(err) if root => return Err(err.into()),
Err(err) => warn!(node = node_path, "skipping unreadable node: {err}"),
}
continue;
}
let node = match self.get_lib_node(&node_path).await {
Ok(node) => node,
Err(err) if root => return Err(err),
Err(err) => {
warn!(node = node_path, "skipping unreadable node: {err}");
continue;
}
};
if !node.is_queable {
continue;
}
if !node.tracks.is_empty() && chunk_tx.send_async(node.tracks).await.is_err() {
// Receiver gone: the consumer cancelled, stop fetching.
return Ok(());
}
nodes_to_go.extend(node.children.into_iter().rev().map(|c| c.path));
}
Ok(())
}
}
/// The root of this provider in the global library tree.
pub const PROVIDER_ROOT: &str = "/tidal";
/// Maximum results fetched per category (tracks/artists/albums) for one
/// search term. First page only — search is exploratory, not a collection.
pub const SEARCH_RESULT_LIMIT: usize = 20;
/// 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,
},
/// `/tidal/search` — the creatable node listing created search terms.
Search,
/// `/tidal/search/<term>` — one created term; `term` is the
/// percent-encoded segment, not the raw text.
SearchTerm(&'a str),
/// `/tidal/search/<term>/<track-id>` — a track search result. Artist and
/// album results are not nested here: their children carry canonical
/// `/tidal/artists/...` paths (see architecture/search.md).
SearchTrack {
term: &'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,
}),
["tidal", "search"] => Ok(TidalPath::Search),
["tidal", "search", term] => Ok(TidalPath::SearchTerm(term)),
["tidal", "search", term, track] => Ok(TidalPath::SearchTrack { term, 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, .. }
| TidalPath::SearchTrack { 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,
search_terms: std::sync::RwLock::new(Vec::new()),
})
}
/// 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.
///
/// A failed proactive refresh never fails the request: the current
/// access token may still be valid (stale `expires_after` metadata from
/// an old config, for example), and `make_request`'s 401-retry remains
/// the backstop for a token that is actually dead.
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");
match self.force_refresh_token().await {
Ok(()) => Ok(()),
Err(err @ ClientError::AuthError(_)) => {
// The refresh token is permanently unusable. Stop trying
// proactively so every request doesn't hammer the endpoint;
// a new device login (server restart) is the only cure once
// the access token dies.
warn!(
"stored refresh token was rejected and can never work; \
continuing with the current access token — restart the \
server to re-login once it expires: {err}"
);
let mut login = match self.login.write() {
Ok(login) => login,
Err(poisoned) => poisoned.into_inner(),
};
login.expires_after = None;
Ok(())
}
Err(err) => {
warn!("token refresh failed transiently, continuing with the current token: {err}");
Ok(())
}
}
}
/// 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)
}
/// Like [`make_paginated_request`](Self::make_paginated_request) for a
/// paginated track collection, but streams each fetched page into
/// `chunk_tx` (mapped to proto tracks under `parent_path`) as soon as
/// it arrives instead of collecting to exhaustion first. Returns
/// `Ok(false)` when the receiver disappeared before the collection was
/// exhausted — cancellation, not an error — and `Ok(true)` on normal
/// completion. Reuses [`make_request`](Self::make_request), so token
/// refresh and retry behave exactly like every other fetch.
async fn stream_track_pages_into(
&self,
uri: &str,
parent_path: &str,
chunk_tx: &flume::Sender<Vec<crabidy_core::proto::crabidy::Track>>,
) -> Result<bool, ClientError> {
let limit: usize = 50;
let mut offset: usize = 0;
loop {
let params: Vec<(&str, String)> =
vec![("limit", limit.to_string()), ("offset", offset.to_string())];
let page: Page<Track> = self.make_request(uri, Some(&params)).await?;
let fetched = page.items.len();
offset += fetched;
let exhausted = fetched == 0 || offset >= page.total_number_of_items;
let chunk: Vec<_> = page.items.iter().map(|t| t.to_proto(parent_path)).collect();
if !chunk.is_empty() && chunk_tx.send_async(chunk).await.is_err() {
debug!(uri, offset, "paginated fetch cancelled by its consumer");
return Ok(false);
}
if exhausted {
return Ok(true);
}
}
}
#[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(())
}
/// Explorer helper kept for probing the raw `search/*` payloads against
/// the live API (the typed methods below assume our models fit them).
#[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(())
}
/// One first-page search request. Search is exploratory: unlike the
/// library collections this deliberately does NOT paginate to
/// exhaustion.
async fn search_page<T: DeserializeOwned>(
&self,
category: &str,
query: &str,
) -> Result<Vec<T>, ClientError> {
let params = vec![
("query", query.to_string()),
("limit", SEARCH_RESULT_LIMIT.to_string()),
("offset", "0".to_string()),
];
let page: Page<T> = self
.make_request(&format!("search/{category}"), Some(&params))
.await?;
Ok(page.items)
}
/// First page of track results for a search term, at most
/// [`SEARCH_RESULT_LIMIT`].
#[instrument(skip(self))]
pub async fn search_tracks(&self, query: &str) -> Result<Vec<Track>, ClientError> {
self.search_page("tracks", query).await
}
/// First page of artist results, at most [`SEARCH_RESULT_LIMIT`].
#[instrument(skip(self))]
pub async fn search_artists(&self, query: &str) -> Result<Vec<Artist>, ClientError> {
self.search_page("artists", query).await
}
/// First page of album results, at most [`SEARCH_RESULT_LIMIT`].
#[instrument(skip(self))]
pub async fn search_albums(&self, query: &str) -> Result<Vec<Album>, ClientError> {
self.search_page("albums", query).await
}
/// A consistent copy of the created search terms.
fn search_terms_snapshot(&self) -> Vec<String> {
match self.search_terms.read() {
Ok(terms) => terms.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
/// Registers a term idempotently, preserving creation order. The lock is
/// released before any await point.
fn register_search_term(&self, term: &str) {
let mut terms = match self.search_terms.write() {
Ok(terms) => terms,
Err(poisoned) => poisoned.into_inner(),
};
if !terms.iter().any(|t| t == term) {
terms.push(term.to_string());
}
}
/// Renames `old` to `new` in place, keeping its position. If `new`
/// already exists elsewhere the entries merge (the `old` slot is
/// removed) — the list never holds duplicates. An unknown `old` behaves
/// like registration of `new` (stale-client forgiveness). The lock is
/// released before any await point.
fn rename_search_term(&self, old: &str, new: &str) {
let mut terms = match self.search_terms.write() {
Ok(terms) => terms,
Err(poisoned) => poisoned.into_inner(),
};
if terms.iter().any(|t| t == new) {
// Merge — but a self-rename must not delete the very term it
// is supposed to keep.
if old != new {
terms.retain(|t| t != old);
}
} else if let Some(slot) = terms.iter_mut().find(|t| *t == old) {
*slot = new.to_string();
} else {
terms.push(new.to_string());
}
}
/// Removes a term. Unknown terms are a no-op — delete is idempotent.
/// The lock is released before any await point.
fn remove_search_term(&self, term: &str) {
let mut terms = match self.search_terms.write() {
Ok(terms) => terms,
Err(poisoned) => poisoned.into_inner(),
};
terms.retain(|t| t != term);
}
/// Builds the node for one search term: its `tracks` are the track
/// results (queueable individually, paths under this node), its children
/// are artist and album results pointing at **canonical**
/// `/tidal/artists/...` paths. The node itself is not queueable — the
/// server's resolve sweep would otherwise pull every artist's full
/// discography into the queue (architecture/search.md).
///
/// The three categories are fetched concurrently. A failing category
/// degrades to empty with a warning; only all three failing is an error.
async fn search_term_node(
&self,
path: &str,
term: &str,
parent: String,
) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
let (tracks, artists, albums) = tokio::join!(
self.search_tracks(term),
self.search_artists(term),
self.search_albums(term),
);
if let (Err(t), Err(a), Err(al)) = (&tracks, &artists, &albums) {
warn!(term, "all search categories failed: {t}; {a}; {al}");
return Err(crabidy_core::ProviderError::FetchError);
}
fn or_empty<T>(term: &str, name: &str, r: Result<Vec<T>, ClientError>) -> Vec<T> {
match r {
Ok(items) => items,
Err(err) => {
warn!(term, category = name, "search category failed: {err}");
Vec::new()
}
}
}
let tracks = or_empty(term, "tracks", tracks);
let artists = or_empty(term, "artists", artists);
let albums = or_empty(term, "albums", albums);
let mut children = Vec::with_capacity(artists.len() + albums.len());
children.extend(artists.iter().map(|a| {
crabidy_core::proto::crabidy::LibraryNodeChild::new(
format!("{PROVIDER_ROOT}/artists/{}", a.id),
format!("Artist: {}", a.name),
true,
)
}));
children.extend(albums.iter().filter_map(|album| {
// The canonical album path needs the artist id; results without
// one (not observed in practice) are skipped, not guessed at.
let artist = album.artist.as_ref()?;
Some(crabidy_core::proto::crabidy::LibraryNodeChild::new(
format!("{PROVIDER_ROOT}/artists/{}/{}", artist.id, album.id),
format!("Album: {}", album.title),
true,
))
}));
Ok(crabidy_core::proto::crabidy::LibraryNode {
path: path.to_string(),
title: term.to_string(),
parent: Some(parent),
tracks: tracks.iter().map(|t| t.to_proto(path)).collect(),
children,
is_queable: false,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
})
}
#[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 started = Instant::now();
// The verification link must reach the user even without a log
// subscriber configured.
println!("https://{}", code_response.verification_uri_complete);
info!(
expires_in = code_response.expires_in,
interval = code_response.interval,
"waiting for device login at https://{}",
code_response.verification_uri_complete
);
// Poll no faster than the server asked for, and never busy-loop.
let mut interval = code_response.interval.max(1);
while started.elapsed().as_secs() <= code_response.expires_in {
match self.poll_device_token(&code_response.device_code).await {
Ok(Some(login_results)) => {
let timestamp = chrono::Utc::now().timestamp() as u64;
{
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(());
}
Ok(None) => {
debug!(interval, "authorization pending");
}
Err(PollError::SlowDown) => {
// Per RFC 8628 the client must back off by 5 seconds.
interval += 5;
debug!(interval, "server asked us to slow down");
}
Err(PollError::Transport(err)) => {
// Transient network problems shouldn't kill the login.
warn!("device token poll failed, retrying: {err}");
}
Err(PollError::Fatal(err)) => {
error!("device login failed: {err}");
return Err(err);
}
}
sleep(Duration::from_secs(interval)).await;
}
warn!("device login attempt expired before it was authorized");
Err(ClientError::AuthError("device login expired".to_string()))
}
#[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)?;
// Secret in the body, not Basic auth — see [`Self::poll_device_token`].
let req = self
.http_client
.post(format!("{}/token", self.settings.oauth.base_url))
.body(body)
.header("Content-Type", "application/x-www-form-urlencoded")
.send()
.await
.map_err(|e| {
error!("{:?}", e);
e
})?;
let status = req.status();
if status.is_success() {
let res = req.json::<RefreshResponse>().await?;
Ok(res)
} else if status.is_client_error() {
// A 4xx means the endpoint understood us and rejected the token
// (e.g. it was issued by a client that no longer exists). This
// cannot succeed on retry — only a new device login helps.
let body = req.text().await.unwrap_or_default();
let snippet: String = body.chars().take(300).collect();
Err(ClientError::AuthError(format!(
"token refresh returned {status}: {snippet}"
)))
} else {
let body = req.text().await.unwrap_or_default();
let snippet: String = body.chars().take(300).collect();
error!("token refresh returned {status}: {snippet}");
Err(ClientError::ApiError(status.as_u16()))
}
}
#[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)
}
/// One poll of the device-flow token endpoint.
///
/// `Ok(Some(_))` means the user authorized us, `Ok(None)` means the
/// authorization is still pending and the caller should keep polling.
#[instrument(skip(self, device_code))]
async fn poll_device_token(
&self,
device_code: &str,
) -> Result<Option<RefreshResponse>, PollError> {
let req = DeviceAuthRequest {
client_id: self.settings.oauth.client_id.clone(),
// The secret must travel in the body: Tidal's edge rejects HTTP
// Basic auth on this endpoint with an HTML 403.
client_secret: Some(self.settings.oauth.client_secret.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).map_err(|err| PollError::Fatal(err.into()))?;
let res = self
.http_client
.post(format!("{}/token", self.settings.oauth.base_url))
.body(payload)
.header("Content-Type", "application/x-www-form-urlencoded")
.send()
.await
.map_err(|err| PollError::Transport(err.into()))?;
let status = res.status();
let body = res
.text()
.await
.map_err(|err| PollError::Transport(err.into()))?;
if status.is_success() {
// NB: don't log the body here, it contains the tokens.
return match serde_json::from_str::<RefreshResponse>(&body) {
Ok(refresh) => Ok(Some(refresh)),
Err(err) => Err(PollError::Fatal(ClientError::AuthError(format!(
"could not decode token response: {err}"
)))),
};
}
// OAuth error responses carry the reason in an `error` field
// (RFC 8628 §3.5); Tidal additionally sets `sub_status`.
let oauth_error = serde_json::from_str::<OauthErrorBody>(&body).unwrap_or_default();
match oauth_error.error.as_deref() {
Some("authorization_pending") => Ok(None),
Some("slow_down") => Err(PollError::SlowDown),
_ if status.is_server_error() => {
Err(PollError::Transport(ClientError::ApiError(status.as_u16())))
}
_ => {
// Error bodies carry no secrets, so quoting them is safe
// and beats guessing why the flow died.
let body_snippet: String = body.chars().take(300).collect();
Err(PollError::Fatal(ClientError::AuthError(format!(
"token endpoint returned {status}: {body_snippet}"
))))
}
}
}
}
/// Outcome classification for one device-flow token poll.
#[derive(Debug)]
enum PollError {
/// The server asked us to poll less often (RFC 8628 `slow_down`).
SlowDown,
/// A transient failure; keep polling.
Transport(ClientError),
/// The flow cannot succeed anymore; stop polling.
Fatal(ClientError),
}
/// Lenient shape of an OAuth token-endpoint error body: only `error` is
/// used to classify the failure; the full body is logged separately for
/// anything not otherwise handled.
#[derive(Debug, Default, serde::Deserialize)]
struct OauthErrorBody {
error: Option<String>,
}
#[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()
}
/// A client with default settings and no login — enough for the pure
/// path/tree methods, no network involved.
fn offline_client() -> Client {
Client::new(config::Settings::default()).expect("offline client")
}
#[tokio::test]
async fn resolve_rejects_foreign_paths_before_any_network_call() {
use crabidy_core::ProviderError;
let client = offline_client();
for path in ["/spotify/x", "/nope", ""] {
let (chunk_tx, chunk_rx) = flume::bounded(1);
let result = client.resolve_tracks_into(path, chunk_tx).await;
assert_eq!(result, Err(ProviderError::MalformedPath), "path {path:?}");
assert!(chunk_rx.try_recv().is_err(), "no chunks for {path:?}");
}
}
/// Dumps the raw search payloads to re-verify the models if the API
/// drifts (last verified 2026-07-20, shapes matched `Page<T>` + the
/// existing Track/Artist/Album models).
#[tokio::test]
#[ignore = "requires local tidal config and network"]
async fn probe_search_shapes() {
let raw = std::fs::read_to_string("/home/hans/.config/crabidy/tidaly.toml").unwrap();
let settings: config::Settings = toml::from_str(&raw).unwrap();
let client = Client::new(settings).unwrap();
client.ensure_fresh_token().await.unwrap();
for cat in ["tracks", "artists", "albums"] {
let resp = client
.authed_get(
&format!("search/{cat}"),
Some(&[("query", "beatles".to_string()), ("limit", "2".to_string())]),
)
.await
.unwrap();
println!("=== {cat} status={}", resp.status());
println!("{}", resp.text().await.unwrap());
}
}
#[test]
fn parse_path_recognizes_search_paths() {
assert_eq!(parse_path("/tidal/search"), Ok(TidalPath::Search));
assert_eq!(
parse_path("/tidal/search/abba"),
Ok(TidalPath::SearchTerm("abba"))
);
assert_eq!(
parse_path("/tidal/search/abba/12345"),
Ok(TidalPath::SearchTrack {
term: "abba",
track: "12345"
})
);
assert!(parse_path("/tidal/search/a/b/c").is_err());
}
#[test]
fn search_track_paths_are_track_paths() {
let client = offline_client();
assert!(client.is_track_path("/tidal/search/abba/12345"));
assert!(!client.is_track_path("/tidal/search/abba"));
assert!(!client.is_track_path("/tidal/search"));
}
#[test]
fn track_id_is_extracted_from_search_track_paths() {
assert_eq!(track_id_from_path("/tidal/search/abba/12345"), Ok("12345"));
}
#[tokio::test]
async fn create_rejects_empty_titles_and_foreign_parents() {
use crabidy_core::ProviderError;
let client = offline_client();
// Validation happens before any network call, so this works offline.
assert_eq!(
client.create_lib_node("/tidal/search", " ").await,
Err(ProviderError::InvalidInput)
);
assert_eq!(
client.create_lib_node("/tidal/playlists", "abba").await,
Err(ProviderError::NotSupported)
);
assert_eq!(
client.create_lib_node("/nope", "abba").await,
Err(ProviderError::MalformedPath)
);
}
#[tokio::test]
async fn search_node_lists_created_terms() {
let client = offline_client();
client.register_search_term("AC/DC");
client.register_search_term("abba");
client.register_search_term("abba"); // idempotent
let node = client.get_lib_node("/tidal/search").await.expect("node");
assert!(node.is_creatable);
assert!(!node.is_queable);
let titles: Vec<_> = node.children.iter().map(|c| c.title.as_str()).collect();
assert_eq!(titles, vec!["AC/DC", "abba"]);
assert_eq!(node.children[0].path, "/tidal/search/AC%2FDC");
assert!(node.children.iter().all(|c| !c.is_queable));
// Term nodes are the modifiable nodes: renamable and deletable.
assert!(node
.children
.iter()
.all(|c| c.is_editable && c.is_deletable));
}
#[tokio::test]
async fn rename_rejects_empty_titles_and_foreign_paths() {
use crabidy_core::ProviderError;
let client = offline_client();
// Validation happens before any network call, so this works offline.
assert_eq!(
client.rename_lib_node("/tidal/search/abba", " ").await,
Err(ProviderError::InvalidInput)
);
assert_eq!(
client
.rename_lib_node("/tidal/playlists/xyz", "queen")
.await,
Err(ProviderError::NotSupported)
);
// The search node itself is creatable, not editable.
assert_eq!(
client.rename_lib_node("/tidal/search", "queen").await,
Err(ProviderError::NotSupported)
);
assert_eq!(
client.rename_lib_node("/nope", "queen").await,
Err(ProviderError::MalformedPath)
);
}
#[tokio::test]
async fn delete_rejects_foreign_paths() {
use crabidy_core::ProviderError;
let client = offline_client();
assert_eq!(
client.delete_lib_node("/tidal/playlists/xyz").await,
Err(ProviderError::NotSupported)
);
// The search node itself is not deletable, only its terms are.
assert_eq!(
client.delete_lib_node("/tidal/search").await,
Err(ProviderError::NotSupported)
);
assert_eq!(
client.delete_lib_node("/nope").await,
Err(ProviderError::MalformedPath)
);
}
#[tokio::test]
async fn delete_removes_terms_idempotently_and_returns_the_parent() {
let client = offline_client();
client.register_search_term("abba");
client.register_search_term("queen");
let parent = client
.delete_lib_node("/tidal/search/abba")
.await
.expect("refreshed parent");
assert_eq!(parent.path, "/tidal/search");
let titles: Vec<_> = parent.children.iter().map(|c| c.title.as_str()).collect();
assert_eq!(titles, vec!["queen"]);
// Deleting an already-gone term succeeds — delete is idempotent.
let parent = client
.delete_lib_node("/tidal/search/abba")
.await
.expect("idempotent delete");
assert_eq!(parent.children.len(), 1);
}
#[test]
fn rename_replaces_in_place_and_merges_duplicates() {
let client = offline_client();
client.register_search_term("abba");
client.register_search_term("queen");
client.register_search_term("kiss");
// In-place: the renamed term keeps its list position.
client.rename_search_term("queen", "wham");
assert_eq!(client.search_terms_snapshot(), vec!["abba", "wham", "kiss"]);
// Merge: renaming onto an existing term drops the old slot.
client.rename_search_term("abba", "kiss");
assert_eq!(client.search_terms_snapshot(), vec!["wham", "kiss"]);
// An unknown old term registers the new one (stale-client forgiveness).
client.rename_search_term("ghost", "toto");
assert_eq!(client.search_terms_snapshot(), vec!["wham", "kiss", "toto"]);
// Renaming a term to itself changes nothing.
client.rename_search_term("kiss", "kiss");
assert_eq!(client.search_terms_snapshot(), vec!["wham", "kiss", "toto"]);
}
#[test]
fn lib_root_offers_a_creatable_search_node() {
let client = offline_client();
let root = client.get_lib_root();
let search = root
.children
.iter()
.find(|c| c.path == format!("{PROVIDER_ROOT}/search"))
.expect("search child in provider root");
assert!(search.is_creatable);
assert!(!search.is_queable);
}
#[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);
}
}