Skip to main content

tidal_player_api/
metadata.rs

1//! Track metadata and suggestions (spec 0004 "Filling the queue",
2//! "Autoplay"): single tracks, album and playlist contents, track radio.
3
4use std::sync::Arc;
5use std::time::Duration;
6
7use serde::Deserialize;
8use serde::de::DeserializeOwned;
9
10use tidal_player_core::{AlbumRef, ArtistRef, Item, Track, TrackId};
11
12use crate::auth::{AuthError, Authenticator};
13
14/// Why metadata could not be fetched.
15#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
16pub enum MetadataError {
17    /// `404` with `subStatus` `2001`: no such track, album or playlist.
18    #[error("{0} was not found")]
19    NotFound(Item),
20    /// A `2xx` body that could not be read; never quotes the body.
21    #[error("malformed {0} response from Tidal")]
22    Malformed(&'static str),
23    /// From the [`Authenticator`], unchanged: `LoginRequired`, transport
24    /// errors, unexpected statuses (`5xx`, `429`, ...).
25    #[error(transparent)]
26    Auth(#[from] AuthError),
27}
28
29/// How long one metadata request may take before it fails with
30/// [`AuthError::Transport`].
31pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
32
33/// Tidal's `subStatus` for an unknown track, album, playlist or radio seed
34/// (on a `404`).
35const SUB_STATUS_NOT_FOUND: u64 = 2001;
36
37/// The largest page the playlist endpoint accepts (`limit=1000` is a `400`).
38const PAGE_SIZE: u64 = 100;
39
40/// Fetches metadata through the [`Authenticator`].
41#[derive(Debug, Clone)]
42pub struct MetadataClient {
43    auth: Arc<Authenticator>,
44}
45
46impl MetadataClient {
47    pub fn new(auth: Arc<Authenticator>) -> Self {
48        Self { auth }
49    }
50
51    /// `GET /tracks/{id}` (AC17).
52    pub async fn get_track(&self, id: TrackId) -> Result<Track, MetadataError> {
53        let path = format!("tracks/{id}");
54        let dto: TrackDto = self
55            .fetch(&path, &[], "track")
56            .await?
57            .ok_or(MetadataError::NotFound(Item::Track(id)))?;
58        Ok(dto.into())
59    }
60
61    /// Every track of an album, in album order (AC17).
62    pub async fn get_album_tracks(&self, album: u64) -> Result<Vec<Track>, MetadataError> {
63        let path = format!("albums/{album}/tracks");
64        let pages: Vec<TrackDto> = self
65            .walk(&path, "album", || {
66                MetadataError::NotFound(Item::Album(album))
67            })
68            .await?;
69        Ok(pages.into_iter().map(Track::from).collect())
70    }
71
72    /// Every track of a playlist, in playlist order. Items that are not
73    /// tracks (videos) are left out (AC17).
74    pub async fn get_playlist_tracks(&self, uuid: &str) -> Result<Vec<Track>, MetadataError> {
75        let path = format!("playlists/{uuid}/items");
76        let entries: Vec<PlaylistEntry> = self
77            .walk(&path, "playlist", || {
78                MetadataError::NotFound(Item::Playlist(uuid.to_owned()))
79            })
80            .await?;
81        let mut tracks = Vec::new();
82        for entry in entries.into_iter().filter(|e| e.kind == "track") {
83            let dto = serde_json::from_value::<TrackDto>(entry.item)
84                .map_err(|_| MetadataError::Malformed("playlist"))?;
85            tracks.push(dto.into());
86        }
87        Ok(tracks)
88    }
89
90    /// Tidal's suggestions for `seed`, in order, the seed included (the
91    /// radio starts with it): one request. An unknown seed has none (AC27).
92    pub async fn get_suggestions(&self, seed: TrackId) -> Result<Vec<Track>, MetadataError> {
93        let path = format!("tracks/{seed}/radio");
94        let limit = PAGE_SIZE.to_string();
95        let page: Option<Page<TrackDto>> = self
96            .fetch(&path, &[("limit", limit.as_str())], "suggestions")
97            .await?;
98        Ok(page
99            .map(|p| p.items.into_iter().map(Track::from).collect())
100            .unwrap_or_default())
101    }
102
103    /// Every item of a paged endpoint: pages of [`PAGE_SIZE`], `offset`
104    /// advanced by the number of items received, until `offset` reaches the
105    /// total or a page comes back empty.
106    async fn walk<T: DeserializeOwned>(
107        &self,
108        path: &str,
109        what: &'static str,
110        not_found: impl Fn() -> MetadataError,
111    ) -> Result<Vec<T>, MetadataError> {
112        let limit = PAGE_SIZE.to_string();
113        let mut all = Vec::new();
114        let mut offset: u64 = 0;
115        loop {
116            let offset_text = offset.to_string();
117            let query = [("limit", limit.as_str()), ("offset", offset_text.as_str())];
118            let page: Page<T> = self
119                .fetch(path, &query, what)
120                .await?
121                .ok_or_else(&not_found)?;
122            let received = page.items.len() as u64;
123            all.extend(page.items);
124            offset += received;
125            if received == 0 || offset >= page.total {
126                return Ok(all);
127            }
128        }
129    }
130
131    /// One `GET`, with `countryCode` from the session. `Ok(None)` is the
132    /// `404`/`2001` of an unknown ID; any other failure is an error.
133    async fn fetch<T: DeserializeOwned>(
134        &self,
135        path: &str,
136        extra: &[(&str, &str)],
137        what: &'static str,
138    ) -> Result<Option<T>, MetadataError> {
139        let (_, country) = self.auth.account().await;
140        let mut query = vec![("countryCode", country.as_str())];
141        query.extend_from_slice(extra);
142        let response = self.auth.get(path, &query, Some(REQUEST_TIMEOUT)).await?;
143        let status = response.status();
144        if status.is_success() {
145            return response
146                .json()
147                .map(Some)
148                .map_err(|_| MetadataError::Malformed(what));
149        }
150        if status.as_u16() == 404 && response.sub_status() == Some(SUB_STATUS_NOT_FOUND) {
151            return Ok(None);
152        }
153        Err(AuthError::Http {
154            status: status.as_u16(),
155            code: None,
156        }
157        .into())
158    }
159}
160
161#[derive(Deserialize)]
162struct Page<T> {
163    #[serde(rename = "totalNumberOfItems")]
164    total: u64,
165    items: Vec<T>,
166}
167
168#[derive(Deserialize)]
169struct PlaylistEntry {
170    #[serde(rename = "type")]
171    kind: String,
172    item: serde_json::Value,
173}
174
175#[derive(Deserialize)]
176pub(crate) struct ArtistDto {
177    pub(crate) id: u64,
178    pub(crate) name: String,
179}
180
181#[derive(Deserialize)]
182struct AlbumDto {
183    id: u64,
184    title: String,
185    /// The cover image ID; `null` or absent for albums without one.
186    #[serde(default)]
187    cover: Option<String>,
188}
189
190impl From<ArtistDto> for ArtistRef {
191    fn from(dto: ArtistDto) -> Self {
192        ArtistRef {
193            id: dto.id,
194            name: dto.name,
195        }
196    }
197}
198
199/// The keys of a track response this player reads; the rest is ignored.
200#[derive(Deserialize)]
201pub(crate) struct TrackDto {
202    id: u64,
203    title: String,
204    /// `Instrumental`, `Live`, ...; `null` for most tracks.
205    version: Option<String>,
206    /// Whole seconds.
207    duration: Option<u64>,
208    #[serde(default, rename = "allowStreaming")]
209    allow_streaming: bool,
210    #[serde(default, rename = "streamReady")]
211    stream_ready: bool,
212    #[serde(default)]
213    artists: Vec<ArtistDto>,
214    artist: Option<ArtistDto>,
215    album: Option<AlbumDto>,
216    /// `STEREO`, `DOLBY_ATMOS`: read by the artist's *All tracks* (spec
217    /// 0006 AC6).
218    #[serde(default, rename = "audioModes")]
219    pub(crate) audio_modes: Vec<String>,
220}
221
222impl From<TrackDto> for Track {
223    fn from(dto: TrackDto) -> Self {
224        let artists: Vec<ArtistRef> = if dto.artists.is_empty() {
225            dto.artist.into_iter().map(ArtistRef::from).collect()
226        } else {
227            dto.artists.into_iter().map(ArtistRef::from).collect()
228        };
229        Track {
230            id: TrackId(dto.id),
231            title: dto.title,
232            version: dto.version,
233            artists,
234            album: dto.album.map(|a| AlbumRef {
235                id: a.id,
236                title: a.title,
237                cover: a.cover,
238            }),
239            duration: dto.duration.map(Duration::from_secs),
240            streamable: dto.allow_streaming && dto.stream_ready,
241        }
242    }
243}