Skip to main content

tidal_player_api/
lib.rs

1//! Tidal HTTP API client. See docs/specs/0001-architecture.md.
2//!
3//! Base URLs are always injected by the caller. The production URLs are named
4//! constants in [`auth`] (spec 0002); authenticated requests go through
5//! [`auth::Authenticator`].
6
7pub mod auth;
8pub mod library;
9pub mod metadata;
10pub mod stream;
11
12use std::fmt;
13
14use reqwest::StatusCode;
15use reqwest::header::HeaderMap;
16use serde::de::DeserializeOwned;
17
18/// Tidal's `subStatus` for "Asset is not ready for playback": a `401` that
19/// says the track (or quality) is not available to the account, not that
20/// the token is bad (spec 0002 AC18).
21pub const SUB_STATUS_NOT_AVAILABLE: u64 = 4005;
22
23/// A response from [`auth::Authenticator::get`] and its siblings: the status,
24/// the headers and the whole body, as the server sent them.
25#[derive(Clone, PartialEq, Eq)]
26pub struct ApiResponse {
27    status: StatusCode,
28    headers: HeaderMap,
29    body: Vec<u8>,
30}
31
32impl ApiResponse {
33    pub(crate) fn new(status: StatusCode, headers: HeaderMap, body: Vec<u8>) -> Self {
34        Self {
35            status,
36            headers,
37            body,
38        }
39    }
40
41    /// A response header's value (`ETag`, ...), when present and text.
42    pub fn header(&self, name: &str) -> Option<&str> {
43        self.headers.get(name)?.to_str().ok()
44    }
45
46    /// The HTTP status.
47    pub fn status(&self) -> StatusCode {
48        self.status
49    }
50
51    /// The raw body.
52    pub fn body(&self) -> &[u8] {
53        &self.body
54    }
55
56    /// The body decoded as JSON.
57    pub fn json<T: DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
58        serde_json::from_slice(&self.body)
59    }
60
61    /// Tidal's `subStatus` from an error body, when it has one.
62    pub fn sub_status(&self) -> Option<u64> {
63        #[derive(serde::Deserialize)]
64        struct ErrorBody {
65            #[serde(rename = "subStatus")]
66            sub_status: Option<u64>,
67        }
68        self.json::<ErrorBody>().ok()?.sub_status
69    }
70}
71
72/// Shows the status and body length only: a body may echo a token.
73impl fmt::Debug for ApiResponse {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        f.debug_struct("ApiResponse")
76            .field("status", &self.status)
77            .field("body_len", &self.body.len())
78            .finish()
79    }
80}
81
82/// Errors returned by [`Client`].
83#[derive(Debug, thiserror::Error)]
84pub enum ApiError {
85    /// The request could not be sent or the response could not be read or decoded.
86    #[error("transport error: {0}")]
87    Transport(#[from] reqwest::Error),
88    /// The server answered with a non-2xx status.
89    #[error("unexpected HTTP status {0}")]
90    Status(u16),
91}
92
93/// HTTP client bound to an injectable base URL.
94#[derive(Debug, Clone)]
95pub struct Client {
96    base_url: String,
97    http: reqwest::Client,
98}
99
100impl Client {
101    /// Creates a client that resolves request paths against `base_url`.
102    pub fn new(base_url: impl Into<String>) -> Result<Self, ApiError> {
103        Ok(Self {
104            base_url: base_url.into(),
105            http: reqwest::Client::builder().build()?,
106        })
107    }
108
109    /// Joins the base URL and `path` with exactly one slash between them.
110    fn url(&self, path: &str) -> String {
111        format!(
112            "{}/{}",
113            self.base_url.trim_end_matches('/'),
114            path.trim_start_matches('/')
115        )
116    }
117
118    /// Sends `GET {base_url}{path}` and decodes the JSON body as `T`.
119    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
120        let response = self.http.get(self.url(path)).send().await?;
121        let status = response.status();
122        if !status.is_success() {
123            return Err(ApiError::Status(status.as_u16()));
124        }
125        Ok(response.json().await?)
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn url_has_exactly_one_slash() {
135        for base in ["http://h:1", "http://h:1/"] {
136            for path in ["/v1/echo", "v1/echo"] {
137                let c = Client::new(base).unwrap();
138                assert_eq!(c.url(path), "http://h:1/v1/echo", "{base} + {path}");
139            }
140        }
141    }
142}