Skip to main content

tidal_player_api/
library.rs

1//! The library: lists, pages and writes (spec 0006). Requests are answered
2//! through the [`Authenticator`]; every list and page has a pure DTO to core
3//! mapping behind it.
4//!
5//! Choices the spec leaves open:
6//!
7//! - **The artist's albums** are the plain list, then EPs and singles. Both
8//!   are paged by the page size asked: the plain list takes the offsets
9//!   below `ceil(total / limit) * limit`, the EPs and singles the offsets
10//!   after, counted from 0. A plain page costs a second request, for the
11//!   EP total only (`limit=1`), so `total` is always the sum.
12//! - **The credits' `dataApiPath`** is read from `/pages/contributor` on
13//!   `offset` 0 and kept per artist in the client; later pages use it, and
14//!   read it again once when it fails with a `404`.
15//! - **A credits page smaller than Tidal's 50** keeps the first rows of
16//!   what Tidal sent.
17//! - **A playlist's `own`** in its header is `creator.id == user_id`.
18//! - **A playlist edit** that answers `412` is retried once per request to
19//!   Tidal's `items` endpoint (one per chunk of 100 tracks).
20//! - **A search's top hit** whose `value` cannot be read is no top hit:
21//!   the lists still show (spec 0007).
22//! - **A search's Atmos-only tracks** are dropped without counting them as
23//!   `hidden`: the window's title shows Tidal's total, as 0006's favorite
24//!   tracks do (spec 0007 "Edge cases").
25//! - **A search `400` without a `userMessage`** reads as any other status
26//!   (`Tidal answered 400`).
27
28use std::collections::HashMap;
29use std::sync::{Arc, Mutex};
30use std::time::Duration;
31
32use reqwest::Method;
33use serde::Deserialize;
34use serde::de::DeserializeOwned;
35use tidal_player_core::library::{
36    AlbumKind, AlbumSummary, CreditedTrack, FavoriteKind, LibraryRequest, LibraryResponse,
37    ListItems, ListPage, ListRef, MixSummary, PageData, PageRequest, PlaylistSummary, RadioSeed,
38    RoleCategory, TopHit, hidden_version,
39};
40use tidal_player_core::lyrics::{Lyrics, parse_lrc};
41use tidal_player_core::{ArtistRef, Item, Track, TrackId};
42
43use crate::ApiResponse;
44use crate::auth::{AuthError, Authenticator};
45use crate::metadata::{ArtistDto, REQUEST_TIMEOUT, TrackDto};
46
47#[path = "browse.rs"]
48mod browse;
49
50/// Tidal's `subStatus` for an unknown item (on a `404`).
51const SUB_STATUS_NOT_FOUND: u64 = 2001;
52
53/// The largest page of the playlists endpoint and of the credits.
54const LARGEST_SMALL: u32 = 50;
55/// The largest page of a playlist's items.
56const LARGEST_ITEMS: u32 = 100;
57/// The largest page of every other list.
58const LARGEST: u32 = 1000;
59/// Tracks per request to a playlist's `items` (Tidal's limit).
60const ADD_CHUNK: usize = 100;
61/// The kinds a search asks for: always sent, or `videos` fill (spec 0007).
62const SEARCH_TYPES: &str = "TRACKS,ALBUMS,ARTISTS,PLAYLISTS";
63
64/// The page size of a radio: the largest an artist radio takes (`400`
65/// above).
66const RADIO_LIMIT: &str = "100";
67/// What the page endpoints (`/pages/...`) are asked.
68const PAGES_QUERY: [(&str, &str); 2] = [("deviceType", "BROWSER"), ("locale", "en_US")];
69
70/// What was not found.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum Subject {
73    Item(Item),
74    Artist(u64),
75    /// A mix, by Tidal's mix ID (spec 0011).
76    Mix(String),
77    /// A radio Tidal cannot generate (spec 0011): an empty list, never
78    /// shown as an error.
79    Radio,
80}
81
82impl std::fmt::Display for Subject {
83    /// `Album 1`, `Playlist <uuid>`, `Track 1`, `Artist 1`.
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        match self {
86            Self::Item(item) => write!(f, "{item}"),
87            Self::Artist(id) => write!(f, "Artist {id}"),
88            Self::Mix(id) => write!(f, "Mix {id}"),
89            Self::Radio => f.write_str("Radio"),
90        }
91    }
92}
93
94/// Why a library request failed. `Display` is the message a user sees.
95#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
96pub enum LibraryError {
97    /// `404` with `subStatus` `2001` on the request's own resource.
98    #[error("{0} was not found")]
99    NotFound(Subject),
100    /// A `2xx` body that could not be read; never quotes the body.
101    #[error("malformed {0} response from Tidal")]
102    Malformed(&'static str),
103    /// `412` on a playlist edit (a second one, for an add).
104    #[error("The playlist changed: nothing was changed, try again")]
105    PlaylistChanged,
106    /// A track, album or artist ID that is not a number.
107    #[error("Not a valid ID: {0}")]
108    InvalidId(String),
109    /// `400` on a search, with Tidal's `userMessage` (spec 0007).
110    #[error("Tidal refused the search: {0}")]
111    SearchRefused(String),
112    /// A browse path of neither known form (spec 0024 AC8); nothing was sent.
113    #[error("Not a Tidal page: {0}")]
114    NotAPage(String),
115    /// `404` on a browse page or list (spec 0024 AC12).
116    #[error("Page {0} was not found")]
117    PageNotFound(String),
118    /// The v2 home feed answered with this status (spec 0024 AC12).
119    #[error("Home is not available: Tidal answered {0}")]
120    HomeUnavailable(u16),
121    /// From the [`Authenticator`], unchanged: `LoginRequired`, transport
122    /// errors, unexpected statuses (`5xx`, `429`, ...).
123    #[error("{}", auth_message(.0))]
124    Auth(AuthError),
125}
126
127/// The user-facing wording of an [`AuthError`] met while browsing.
128fn auth_message(error: &AuthError) -> String {
129    match error {
130        AuthError::Http { status: 429, .. } => {
131            "Tidal answered 429: try again in a moment".to_owned()
132        }
133        AuthError::Http { status, .. } => format!("Tidal answered {status}"),
134        AuthError::Transport(message) => format!("Could not reach Tidal: {message}"),
135        other => other.to_string(),
136    }
137}
138
139impl LibraryError {
140    /// The underlying [`AuthError`]: `LoginRequired` and the transient
141    /// errors (see [`AuthError::is_transient`]) are told apart through it.
142    pub fn auth(&self) -> Option<&AuthError> {
143        match self {
144            Self::Auth(error) => Some(error),
145            _ => None,
146        }
147    }
148}
149
150impl From<AuthError> for LibraryError {
151    fn from(error: AuthError) -> Self {
152        Self::Auth(error)
153    }
154}
155
156type Result<T> = std::result::Result<T, LibraryError>;
157
158/// Reads and writes the library through the [`Authenticator`].
159#[derive(Debug, Clone)]
160pub struct LibraryClient {
161    auth: Arc<Authenticator>,
162    /// The credits' `dataApiPath` per artist ID.
163    credit_paths: Arc<Mutex<HashMap<u64, String>>>,
164}
165
166impl LibraryClient {
167    pub fn new(auth: Arc<Authenticator>) -> Self {
168        Self {
169            auth,
170            credit_paths: Arc::default(),
171        }
172    }
173
174    /// Answers one request. `page_size` (1-10 000) is the size of the first
175    /// page of each list in a `Page` request; `More` carries its own
176    /// `limit`. Each request is clamped to the endpoint's largest page.
177    /// `hidden_words` is the hidden-version word list of the credits.
178    pub async fn request(
179        &self,
180        request: LibraryRequest,
181        page_size: u32,
182        hidden_words: &[String],
183    ) -> Result<LibraryResponse> {
184        match request {
185            LibraryRequest::Page(page) => {
186                self.page(page, page_size).await.map(LibraryResponse::Page)
187            }
188            LibraryRequest::More {
189                list,
190                offset,
191                limit,
192            } => self
193                .list(&list, offset, limit, hidden_words)
194                .await
195                .map(LibraryResponse::Items),
196            LibraryRequest::IsFavorite(kind, id) => self
197                .is_favorite(kind, &id)
198                .await
199                .map(LibraryResponse::Favorite),
200            LibraryRequest::AddFavorite(kind, id) => {
201                self.add_favorite(kind, &id).await?;
202                Ok(LibraryResponse::Done)
203            }
204            LibraryRequest::RemoveFavorite(kind, id) => {
205                self.remove_favorite(kind, &id).await?;
206                Ok(LibraryResponse::Done)
207            }
208            LibraryRequest::AddToPlaylist {
209                uuid,
210                tracks,
211                allow_duplicates,
212            } => {
213                self.add_to_playlist(&uuid, &tracks, allow_duplicates)
214                    .await?;
215                Ok(LibraryResponse::Done)
216            }
217            LibraryRequest::RemoveFromPlaylist {
218                uuid,
219                indices,
220                etag,
221            } => {
222                self.remove_from_playlist(&uuid, &indices, &etag).await?;
223                Ok(LibraryResponse::Done)
224            }
225            LibraryRequest::CreatePlaylist { title, description } => self
226                .create_playlist(&title, &description)
227                .await
228                .map(LibraryResponse::Created),
229            LibraryRequest::DeletePlaylist { uuid } => {
230                self.delete_playlist(&uuid).await?;
231                Ok(LibraryResponse::Done)
232            }
233            LibraryRequest::MovePlaylistItem {
234                uuid,
235                from,
236                to,
237                etag,
238            } => {
239                let etag = self.move_playlist_item(&uuid, from, to, &etag).await?;
240                Ok(LibraryResponse::Moved { etag })
241            }
242            LibraryRequest::EditPlaylist {
243                uuid,
244                title,
245                description,
246            } => {
247                self.edit_playlist(&uuid, title.as_deref(), description.as_deref())
248                    .await?;
249                Ok(LibraryResponse::Done)
250            }
251            LibraryRequest::SetPlaylistPublic { uuid, public } => {
252                self.set_playlist_public(&uuid, public).await?;
253                Ok(LibraryResponse::Done)
254            }
255        }
256    }
257
258    // Pages -----------------------------------------------------------
259
260    /// The header and the first page of each list, from parallel requests;
261    /// any failing call fails the page.
262    async fn page(&self, request: PageRequest, size: u32) -> Result<PageData> {
263        match request {
264            PageRequest::Library => {
265                let (playlists, albums, artists) = tokio::try_join!(
266                    self.playlists(0, size),
267                    self.favorite_albums(0, size),
268                    self.favorite_artists(0, size),
269                )?;
270                Ok(PageData::Library {
271                    playlists,
272                    albums,
273                    artists,
274                })
275            }
276            PageRequest::FavoriteTracks => Ok(PageData::FavoriteTracks {
277                tracks: self.favorite_tracks(0, size).await?,
278            }),
279            PageRequest::Album(id) => {
280                let (album, tracks) =
281                    tokio::try_join!(self.album_header(id), self.album_tracks(id, 0, size))?;
282                Ok(PageData::Album { album, tracks })
283            }
284            PageRequest::Playlist(uuid) => {
285                let ((playlist, etag), tracks) = tokio::try_join!(
286                    self.playlist_header(&uuid),
287                    self.playlist_tracks(&uuid, 0, size)
288                )?;
289                Ok(PageData::Playlist {
290                    playlist,
291                    etag,
292                    tracks,
293                })
294            }
295            PageRequest::Artist(id) => {
296                let (artist, top_tracks, albums, appears_on) = tokio::try_join!(
297                    self.artist_header(id),
298                    self.top_tracks(id, 0, size),
299                    self.artist_albums(id, 0, size),
300                    self.appears_on(id, 0, size),
301                )?;
302                Ok(PageData::Artist {
303                    artist,
304                    top_tracks,
305                    albums,
306                    appears_on,
307                })
308            }
309            PageRequest::Search(query) => self.search(&query, size).await,
310            PageRequest::Mixes => Ok(PageData::Mixes {
311                mixes: self.mixes().await?,
312            }),
313            PageRequest::Mix(id) => {
314                let (mix, tracks) = tokio::try_join!(self.mix_header(&id), self.mix_items(&id))?;
315                Ok(PageData::Mix { mix, tracks })
316            }
317            PageRequest::TrackRadio(id) => {
318                let path = format!("tracks/{id}/radio");
319                let (track, tracks) = tokio::try_join!(self.track_header(id), self.radio(&path))?;
320                Ok(PageData::Radio {
321                    seed: RadioSeed::Track(track),
322                    tracks,
323                })
324            }
325            PageRequest::ArtistRadio(id) => {
326                let path = format!("artists/{id}/radio");
327                let (artist, tracks) = tokio::try_join!(self.artist_header(id), self.radio(&path))?;
328                Ok(PageData::Radio {
329                    seed: RadioSeed::Artist(artist),
330                    tracks,
331                })
332            }
333            PageRequest::Lyrics(id) => Ok(PageData::Lyrics {
334                track: id,
335                lyrics: self.lyrics(id).await?,
336            }),
337            PageRequest::Browse(target) => self.browse_page(&target).await,
338        }
339    }
340
341    /// The track's lyrics (spec 0021); `None` for any `404`: Tidal's body
342    /// for a track without lyrics is not its usual `subStatus` one.
343    async fn lyrics(&self, id: u64) -> Result<Option<Lyrics>> {
344        let response = self.fetch(&format!("tracks/{id}/lyrics"), &[]).await?;
345        if response.status().as_u16() == 404 {
346            return Ok(None);
347        }
348        let dto: LyricsDto = parse(&check(&response, None)?, "lyrics")?;
349        Ok(Some(dto.into()))
350    }
351
352    async fn album_header(&self, id: u64) -> Result<AlbumSummary> {
353        let dto: AlbumDto = self
354            .read(
355                &format!("albums/{id}"),
356                &[],
357                Some(Subject::Item(Item::Album(id))),
358                "album",
359            )
360            .await?;
361        Ok(dto.into())
362    }
363
364    /// The playlist, and its `ETag` header.
365    async fn playlist_header(&self, uuid: &str) -> Result<(PlaylistSummary, Option<String>)> {
366        let (user, _) = self.auth.account().await;
367        let response = self
368            .get(
369                &format!("playlists/{uuid}"),
370                &[],
371                Some(Subject::Item(Item::Playlist(uuid.to_owned()))),
372            )
373            .await?;
374        let etag = response.header("etag").map(str::to_owned);
375        let dto: PlaylistDto = parse(&response, "playlist")?;
376        let own = dto.creator.as_ref().is_some_and(|c| c.id == Some(user));
377        Ok((dto.into_summary(own), etag))
378    }
379
380    async fn artist_header(&self, id: u64) -> Result<ArtistRef> {
381        let dto: ArtistDto = self
382            .read(
383                &format!("artists/{id}"),
384                &[],
385                Some(Subject::Artist(id)),
386                "artist",
387            )
388            .await?;
389        Ok(ArtistRef {
390            id: dto.id,
391            name: dto.name,
392        })
393    }
394
395    async fn track_header(&self, id: u64) -> Result<Track> {
396        let dto: TrackDto = self
397            .read(
398                &format!("tracks/{id}"),
399                &[],
400                Some(Subject::Item(Item::Track(TrackId(id)))),
401                "track",
402            )
403            .await?;
404        Ok(dto.into())
405    }
406
407    // Mixes and radio (spec 0011) ---------------------------------------
408
409    /// `GET /pages/my_collection_my_mixes`: every `MIX_LIST` module's
410    /// mixes, the rest of a module that holds fewer than its total read
411    /// from its `dataApiPath` with our own offset (the answers report
412    /// `offset: 0`). Video mixes, repeated and empty IDs are dropped.
413    async fn mixes(&self) -> Result<ListPage<MixSummary>> {
414        let page: MixesPage = self
415            .read("pages/my_collection_my_mixes", &PAGES_QUERY, None, "mixes")
416            .await?;
417        let mut entries = Vec::new();
418        for module in page
419            .rows
420            .into_iter()
421            .flat_map(|row| row.modules)
422            .filter(|m| m.kind == "MIX_LIST")
423        {
424            let Some(list) = module.paged_list else {
425                continue;
426            };
427            let step = if list.limit > 0 {
428                list.limit
429            } else {
430                clamp_total(list.items.len() as u64)
431            };
432            let mut held = list.items.len() as u64;
433            entries.extend(list.items);
434            while held < list.total && step > 0 {
435                let raw: RawPage<MixDto> = self
436                    .page_of(
437                        &list.data_api_path,
438                        &PAGES_QUERY,
439                        clamp_total(held),
440                        step,
441                        None,
442                        "mixes",
443                    )
444                    .await?;
445                if raw.items.is_empty() {
446                    break;
447                }
448                held += raw.items.len() as u64;
449                entries.extend(raw.items);
450            }
451        }
452        let mut seen = std::collections::HashSet::new();
453        let items: Vec<MixSummary> = entries
454            .into_iter()
455            .filter(|m| !m.mix_type.contains("VIDEO") && !m.id.is_empty())
456            .filter(|m| seen.insert(m.id.clone()))
457            .map(MixSummary::from)
458            .collect();
459        Ok(whole(items))
460    }
461
462    /// `GET /pages/mix`: the `MIX_HEADER` module's mix.
463    async fn mix_header(&self, id: &str) -> Result<MixSummary> {
464        let mut query = PAGES_QUERY.to_vec();
465        query.push(("mixId", id));
466        let page: MixHeaderPage = self
467            .read(
468                "pages/mix",
469                &query,
470                Some(Subject::Mix(id.to_owned())),
471                "mix",
472            )
473            .await?;
474        page.rows
475            .into_iter()
476            .flat_map(|row| row.modules)
477            .find_map(|m| m.mix)
478            .map(MixSummary::from)
479            .ok_or(LibraryError::Malformed("mix"))
480    }
481
482    /// `GET /mixes/{id}/items`, once: Tidal ignores `limit` and `offset`
483    /// and answers the whole mix. Only `track` items, with a stereo mode.
484    async fn mix_items(&self, id: &str) -> Result<ListPage<Track>> {
485        let raw: RawPage<TypedEntry> = self
486            .read(
487                &format!("mixes/{id}/items"),
488                &[],
489                Some(Subject::Mix(id.to_owned())),
490                "mix",
491            )
492            .await?;
493        Ok(whole(
494            raw.items
495                .into_iter()
496                .filter(|entry| entry.kind == "track")
497                .filter_map(|entry| serde_json::from_value::<TrackDto>(entry.item).ok())
498                .filter_map(stereo_track)
499                .collect(),
500        ))
501    }
502
503    /// `GET {path}?limit=100`, bare tracks; a `404`/`2001` (Tidal cannot
504    /// generate that radio) is an empty list.
505    async fn radio(&self, path: &str) -> Result<ListPage<Track>> {
506        let result = self
507            .read::<RawPage<TrackDto>>(
508                path,
509                &[("limit", RADIO_LIMIT)],
510                Some(Subject::Radio),
511                "radio",
512            )
513            .await;
514        match result {
515            Ok(raw) => Ok(whole(
516                raw.items.into_iter().filter_map(stereo_track).collect(),
517            )),
518            Err(LibraryError::NotFound(Subject::Radio)) => Ok(whole(Vec::new())),
519            Err(other) => Err(other),
520        }
521    }
522
523    // Lists -----------------------------------------------------------
524
525    async fn list(
526        &self,
527        list: &ListRef,
528        offset: u32,
529        limit: u32,
530        hidden_words: &[String],
531    ) -> Result<ListItems> {
532        Ok(match list {
533            ListRef::FavoriteTracks => {
534                ListItems::Tracks(self.favorite_tracks(offset, limit).await?)
535            }
536            ListRef::Playlists => ListItems::Playlists(self.playlists(offset, limit).await?),
537            ListRef::FavoriteAlbums => {
538                ListItems::Albums(self.favorite_albums(offset, limit).await?)
539            }
540            ListRef::FavoriteArtists => {
541                ListItems::Artists(self.favorite_artists(offset, limit).await?)
542            }
543            ListRef::AlbumTracks(id) => {
544                ListItems::Tracks(self.album_tracks(*id, offset, limit).await?)
545            }
546            ListRef::PlaylistTracks(uuid) => {
547                ListItems::Tracks(self.playlist_tracks(uuid, offset, limit).await?)
548            }
549            ListRef::TopTracks(id) => ListItems::Tracks(self.top_tracks(*id, offset, limit).await?),
550            ListRef::ArtistAlbums(id) => {
551                ListItems::Albums(self.artist_albums(*id, offset, limit).await?)
552            }
553            ListRef::ArtistAppearsOn(id) => {
554                ListItems::Albums(self.appears_on(*id, offset, limit).await?)
555            }
556            ListRef::Credits(id) => {
557                ListItems::Credits(self.credits(*id, offset, limit, hidden_words).await?)
558            }
559            ListRef::SearchTracks(query) => {
560                let page: RawPage<TrackDto> =
561                    self.search_list("tracks", query, offset, limit).await?;
562                ListItems::Tracks(page.map(stereo_track, offset))
563            }
564            ListRef::SearchAlbums(query) => {
565                let page: RawPage<AlbumDto> =
566                    self.search_list("albums", query, offset, limit).await?;
567                ListItems::Albums(page.map(|a| Some(a.into()), offset))
568            }
569            ListRef::SearchArtists(query) => {
570                let page: RawPage<ArtistDto> =
571                    self.search_list("artists", query, offset, limit).await?;
572                ListItems::Artists(page.map(|a| Some(ArtistRef::from(a)), offset))
573            }
574            ListRef::SearchPlaylists(query) => {
575                let page: RawPage<PlaylistDto> =
576                    self.search_list("playlists", query, offset, limit).await?;
577                ListItems::Playlists(page.map(|p| Some(p.into_summary(false)), offset))
578            }
579            ListRef::BrowseData(data) => self.browse_data(data, offset, limit).await?,
580            ListRef::BrowseViewAll(view_all) => self.browse_view_all(view_all).await?,
581        })
582    }
583
584    async fn favorite_tracks(&self, offset: u32, limit: u32) -> Result<ListPage<Track>> {
585        let (user, _) = self.auth.account().await;
586        let page: RawPage<Entry<TrackDto>> = self
587            .page_of(
588                &format!("users/{user}/favorites/tracks"),
589                &[("order", "DATE"), ("orderDirection", "DESC")],
590                offset,
591                limit.clamp(1, LARGEST),
592                None,
593                "favorite tracks",
594            )
595            .await?;
596        Ok(page.map(|e| Some(Track::from(e.item)), offset))
597    }
598
599    async fn playlists(&self, offset: u32, limit: u32) -> Result<ListPage<PlaylistSummary>> {
600        let (user, _) = self.auth.account().await;
601        let page: RawPage<PlaylistEntry> = self
602            .page_of(
603                &format!("users/{user}/playlistsAndFavoritePlaylists"),
604                &[("order", "DATE"), ("orderDirection", "DESC")],
605                offset,
606                limit.clamp(1, LARGEST_SMALL),
607                None,
608                "playlists",
609            )
610            .await?;
611        Ok(page.map(
612            |e| {
613                let own = e.kind.as_deref() == Some("USER_CREATED");
614                Some(e.playlist.into_summary(own))
615            },
616            offset,
617        ))
618    }
619
620    async fn favorite_albums(&self, offset: u32, limit: u32) -> Result<ListPage<AlbumSummary>> {
621        let (user, _) = self.auth.account().await;
622        let page: RawPage<Entry<AlbumDto>> = self
623            .page_of(
624                &format!("users/{user}/favorites/albums"),
625                &[("order", "DATE"), ("orderDirection", "DESC")],
626                offset,
627                limit.clamp(1, LARGEST),
628                None,
629                "favorite albums",
630            )
631            .await?;
632        Ok(page.map(|e| Some(e.item.into()), offset))
633    }
634
635    async fn favorite_artists(&self, offset: u32, limit: u32) -> Result<ListPage<ArtistRef>> {
636        let (user, _) = self.auth.account().await;
637        let page: RawPage<Entry<ArtistDto>> = self
638            .page_of(
639                &format!("users/{user}/favorites/artists"),
640                &[("order", "DATE"), ("orderDirection", "DESC")],
641                offset,
642                limit.clamp(1, LARGEST),
643                None,
644                "favorite artists",
645            )
646            .await?;
647        Ok(page.map(
648            |e| {
649                Some(ArtistRef {
650                    id: e.item.id,
651                    name: e.item.name,
652                })
653            },
654            offset,
655        ))
656    }
657
658    async fn album_tracks(&self, id: u64, offset: u32, limit: u32) -> Result<ListPage<Track>> {
659        let page: RawPage<TrackDto> = self
660            .page_of(
661                &format!("albums/{id}/tracks"),
662                &[],
663                offset,
664                limit.clamp(1, LARGEST),
665                Some(Subject::Item(Item::Album(id))),
666                "album",
667            )
668            .await?;
669        Ok(page.map(|t| Some(Track::from(t)), offset))
670    }
671
672    /// Items that are not tracks (videos) are left out, and the page names
673    /// the positions of the rest (spec 0006 AC21).
674    async fn playlist_tracks(
675        &self,
676        uuid: &str,
677        offset: u32,
678        limit: u32,
679    ) -> Result<ListPage<Track>> {
680        let page: RawPage<TypedEntry> = self
681            .page_of(
682                &format!("playlists/{uuid}/items"),
683                &[],
684                offset,
685                limit.clamp(1, LARGEST_ITEMS),
686                Some(Subject::Item(Item::Playlist(uuid.to_owned()))),
687                "playlist",
688            )
689            .await?;
690        let listed = page.items.len();
691        let mut malformed = false;
692        let mut next = offset;
693        let mut positions = Vec::with_capacity(listed);
694        let mut page = page.map(
695            |e| {
696                let position = next;
697                next += 1;
698                if e.kind != "track" {
699                    return None;
700                }
701                match serde_json::from_value::<TrackDto>(e.item) {
702                    Ok(dto) => {
703                        positions.push(position);
704                        Some(Track::from(dto))
705                    }
706                    Err(_) => {
707                        malformed = true;
708                        None
709                    }
710                }
711            },
712            offset,
713        );
714        if malformed {
715            return Err(LibraryError::Malformed("playlist"));
716        }
717        if page.items.len() < listed {
718            page.positions = positions;
719        }
720        Ok(page)
721    }
722
723    async fn top_tracks(&self, id: u64, offset: u32, limit: u32) -> Result<ListPage<Track>> {
724        let page: RawPage<TrackDto> = self
725            .page_of(
726                &format!("artists/{id}/toptracks"),
727                &[],
728                offset,
729                limit.clamp(1, LARGEST),
730                Some(Subject::Artist(id)),
731                "top tracks",
732            )
733            .await?;
734        Ok(page.map(|t| Some(Track::from(t)), offset))
735    }
736
737    async fn appears_on(&self, id: u64, offset: u32, limit: u32) -> Result<ListPage<AlbumSummary>> {
738        let page: RawPage<AlbumDto> = self
739            .page_of(
740                &format!("artists/{id}/albums"),
741                &[("filter", "COMPILATIONS")],
742                offset,
743                limit.clamp(1, LARGEST),
744                Some(Subject::Artist(id)),
745                "albums",
746            )
747            .await?;
748        Ok(page.map(|a| Some(a.into()), offset))
749    }
750
751    /// The plain album list, then EPs and singles (see the module docs for
752    /// how `offset` maps across the two).
753    async fn artist_albums(
754        &self,
755        id: u64,
756        offset: u32,
757        limit: u32,
758    ) -> Result<ListPage<AlbumSummary>> {
759        let limit = limit.clamp(1, LARGEST);
760        let path = format!("artists/{id}/albums");
761        let subject = Some(Subject::Artist(id));
762        let plain: RawPage<AlbumDto> = self
763            .page_of(&path, &[], offset, limit, subject.clone(), "albums")
764            .await?;
765        let plain_total = plain.total;
766        let slots = plain_total.div_ceil(u64::from(limit)) * u64::from(limit);
767        let filter = [("filter", "EPSANDSINGLES")];
768        let (items, eps_total) = if u64::from(offset) < slots {
769            let eps: RawPage<AlbumDto> = self
770                .page_of(&path, &filter, 0, 1, subject, "albums")
771                .await?;
772            (plain.items, eps.total)
773        } else {
774            let eps_offset = u32::try_from(u64::from(offset) - slots).unwrap_or(u32::MAX);
775            let eps: RawPage<AlbumDto> = self
776                .page_of(&path, &filter, eps_offset, limit, subject, "albums")
777                .await?;
778            (eps.items, eps.total)
779        };
780        Ok(ListPage {
781            items: items.into_iter().map(Into::into).collect(),
782            offset,
783            total: clamp_total(plain_total + eps_total),
784            hidden: 0,
785            positions: Vec::new(),
786        })
787    }
788
789    /// The artist's *All tracks* (see the module docs).
790    async fn credits(
791        &self,
792        artist: u64,
793        offset: u32,
794        limit: u32,
795        hidden_words: &[String],
796    ) -> Result<ListPage<CreditedTrack>> {
797        let limit = limit.clamp(1, LARGEST_SMALL);
798        let cached = self.cached_credit_path(artist);
799        let raw = if offset == 0 || cached.is_none() {
800            let Some(list) = self.contributor(artist).await? else {
801                return Ok(ListPage {
802                    items: vec![],
803                    offset,
804                    total: 0,
805                    hidden: 0,
806                    positions: Vec::new(),
807                });
808            };
809            if offset == 0 {
810                let mut list = list;
811                list.items.truncate(limit as usize);
812                list.into_page()
813            } else {
814                self.credit_data(artist, &list.data_api_path, offset, limit)
815                    .await?
816            }
817        } else {
818            let path = cached.unwrap_or_default();
819            match self.credit_data(artist, &path, offset, limit).await {
820                Err(LibraryError::NotFound(_)) => {
821                    let Some(list) = self.contributor(artist).await? else {
822                        return Ok(ListPage {
823                            items: vec![],
824                            offset,
825                            total: 0,
826                            hidden: 0,
827                            positions: Vec::new(),
828                        });
829                    };
830                    self.credit_data(artist, &list.data_api_path, offset, limit)
831                        .await?
832                }
833                other => other?,
834            }
835        };
836        let (items, hidden) = credited(raw.items, hidden_words)?;
837        Ok(ListPage {
838            items,
839            offset,
840            total: clamp_total(raw.total),
841            hidden,
842            positions: Vec::new(),
843        })
844    }
845
846    fn cached_credit_path(&self, artist: u64) -> Option<String> {
847        self.credit_paths
848            .lock()
849            .expect("poisoned")
850            .get(&artist)
851            .cloned()
852    }
853
854    /// `GET /pages/contributor`: the `ITEM_LIST_WITH_ROLES` module's
855    /// list, `None` when the page has no such module. Keeps its
856    /// `dataApiPath`.
857    async fn contributor(&self, artist: u64) -> Result<Option<PagedList>> {
858        let id = artist.to_string();
859        let page: ContributorPage = self
860            .read(
861                "pages/contributor",
862                &[
863                    ("artistId", id.as_str()),
864                    ("deviceType", "BROWSER"),
865                    ("locale", "en_US"),
866                ],
867                Some(Subject::Artist(artist)),
868                "credits",
869            )
870            .await?;
871        let list = page
872            .rows
873            .into_iter()
874            .flat_map(|row| row.modules)
875            .filter(|m| m.kind == "ITEM_LIST_WITH_ROLES")
876            .find_map(|m| m.paged_list);
877        if let Some(list) = &list {
878            self.credit_paths
879                .lock()
880                .expect("poisoned")
881                .insert(artist, list.data_api_path.clone());
882        }
883        Ok(list)
884    }
885
886    /// One page from a `dataApiPath`.
887    async fn credit_data(
888        &self,
889        artist: u64,
890        data_api_path: &str,
891        offset: u32,
892        limit: u32,
893    ) -> Result<RawPage<CreditEntry>> {
894        self.page_of(
895            data_api_path,
896            &[("deviceType", "BROWSER"), ("locale", "en_US")],
897            offset,
898            limit,
899            Some(Subject::Artist(artist)),
900            "credits",
901        )
902        .await
903    }
904
905    /// One page of a paged endpoint: `countryCode`, `extra`, `limit`,
906    /// `offset`.
907    async fn page_of<T: DeserializeOwned>(
908        &self,
909        path: &str,
910        extra: &[(&str, &str)],
911        offset: u32,
912        limit: u32,
913        subject: Option<Subject>,
914        what: &'static str,
915    ) -> Result<RawPage<T>> {
916        let limit = limit.to_string();
917        let offset = offset.to_string();
918        let mut query = extra.to_vec();
919        query.push(("limit", limit.as_str()));
920        query.push(("offset", offset.as_str()));
921        self.read(path, &query, subject, what).await
922    }
923
924    // Search ----------------------------------------------------------
925
926    /// `GET /search`: the four lists' first pages and the top hit.
927    async fn search(&self, query: &str, size: u32) -> Result<PageData> {
928        let dto: SearchDto = self
929            .search_read(
930                "search",
931                &[("query", query), ("types", SEARCH_TYPES)],
932                0,
933                size,
934            )
935            .await?;
936        Ok(PageData::Search {
937            top_hit: dto.top_hit.and_then(top_hit).map(Box::new),
938            tracks: dto.tracks.map(stereo_track, 0),
939            albums: dto.albums.map(|a| Some(a.into()), 0),
940            artists: dto.artists.map(|a| Some(ArtistRef::from(a)), 0),
941            playlists: dto.playlists.map(|p| Some(p.into_summary(false)), 0),
942        })
943    }
944
945    /// `GET /search/{kind}`: one page of one list, bare items.
946    async fn search_list<T: DeserializeOwned>(
947        &self,
948        kind: &str,
949        query: &str,
950        offset: u32,
951        limit: u32,
952    ) -> Result<RawPage<T>> {
953        self.search_read(
954            &format!("search/{kind}"),
955            &[("query", query)],
956            offset,
957            limit,
958        )
959        .await
960    }
961
962    /// A search call: `extra`, `limit` (clamped) and `offset`; a `400` with
963    /// a `userMessage` is [`LibraryError::SearchRefused`].
964    async fn search_read<T: DeserializeOwned>(
965        &self,
966        path: &str,
967        extra: &[(&str, &str)],
968        offset: u32,
969        limit: u32,
970    ) -> Result<T> {
971        let limit = limit.clamp(1, LARGEST).to_string();
972        let offset = offset.to_string();
973        let mut query = extra.to_vec();
974        query.push(("limit", limit.as_str()));
975        query.push(("offset", offset.as_str()));
976        let response = self.fetch(path, &query).await?;
977        if response.status().as_u16() == 400
978            && let Some(message) = user_message(&response)
979        {
980            return Err(LibraryError::SearchRefused(message));
981        }
982        parse(&check(&response, None)?, "search")
983    }
984
985    // Favorites -------------------------------------------------------
986
987    async fn is_favorite(&self, kind: FavoriteKind, id: &str) -> Result<bool> {
988        let (user, _) = self.auth.account().await;
989        let lists: HashMap<String, Vec<serde_json::Value>> = self
990            .read(
991                &format!("users/{user}/favorites/ids"),
992                &[],
993                None,
994                "favorites",
995            )
996            .await?;
997        let key = match kind {
998            FavoriteKind::Track => "TRACK",
999            FavoriteKind::Album => "ALBUM",
1000            FavoriteKind::Artist => "ARTIST",
1001            FavoriteKind::Playlist => "PLAYLIST",
1002        };
1003        Ok(lists.get(key).is_some_and(|ids| {
1004            ids.iter().any(|v| match v {
1005                serde_json::Value::String(s) => s == id,
1006                serde_json::Value::Number(n) => n.to_string() == id,
1007                _ => false,
1008            })
1009        }))
1010    }
1011
1012    async fn add_favorite(&self, kind: FavoriteKind, id: &str) -> Result<()> {
1013        let (user, _) = self.auth.account().await;
1014        let (segment, field, subject) = favorite_target(kind, id)?;
1015        let response = self
1016            .write(
1017                Method::POST,
1018                &format!("users/{user}/favorites/{segment}"),
1019                Some(&[(field, id)]),
1020                &[],
1021            )
1022            .await?;
1023        check(&response, Some(subject)).map(drop)
1024    }
1025
1026    async fn remove_favorite(&self, kind: FavoriteKind, id: &str) -> Result<()> {
1027        let (user, _) = self.auth.account().await;
1028        let (segment, _, subject) = favorite_target(kind, id)?;
1029        let response = self
1030            .write(
1031                Method::DELETE,
1032                &format!("users/{user}/favorites/{segment}/{id}"),
1033                None,
1034                &[],
1035            )
1036            .await?;
1037        check(&response, Some(subject)).map(drop)
1038    }
1039
1040    // Playlists -------------------------------------------------------
1041
1042    async fn create_playlist(&self, title: &str, description: &str) -> Result<PlaylistSummary> {
1043        let (user, _) = self.auth.account().await;
1044        let response = self
1045            .write(
1046                Method::POST,
1047                &format!("users/{user}/playlists"),
1048                Some(&[("title", title), ("description", description)]),
1049                &[],
1050            )
1051            .await?;
1052        let dto: PlaylistDto = parse(&check(&response, None)?, "playlist")?;
1053        Ok(dto.into_summary(true))
1054    }
1055
1056    /// The playlist's current `ETag`.
1057    async fn playlist_etag(&self, uuid: &str) -> Result<String> {
1058        let response = self
1059            .get(
1060                &format!("playlists/{uuid}"),
1061                &[],
1062                Some(Subject::Item(Item::Playlist(uuid.to_owned()))),
1063            )
1064            .await?;
1065        response
1066            .header("etag")
1067            .map(str::to_owned)
1068            .ok_or(LibraryError::Malformed("playlist"))
1069    }
1070
1071    async fn add_to_playlist(
1072        &self,
1073        uuid: &str,
1074        tracks: &[TrackId],
1075        allow_duplicates: bool,
1076    ) -> Result<()> {
1077        if tracks.is_empty() {
1078            return Ok(());
1079        }
1080        let subject = Subject::Item(Item::Playlist(uuid.to_owned()));
1081        let path = format!("playlists/{uuid}/items");
1082        let mut etag = self.playlist_etag(uuid).await?;
1083        for chunk in tracks.chunks(ADD_CHUNK) {
1084            let ids = chunk
1085                .iter()
1086                .map(ToString::to_string)
1087                .collect::<Vec<_>>()
1088                .join(",");
1089            let mut form = vec![("trackIds", ids.as_str())];
1090            if allow_duplicates {
1091                form.push(("onDupes", "ADD"));
1092            }
1093            let mut retried = false;
1094            loop {
1095                let response = self
1096                    .write(
1097                        Method::POST,
1098                        &path,
1099                        Some(&form),
1100                        &[("if-none-match", etag.as_str())],
1101                    )
1102                    .await?;
1103                if response.status().as_u16() == 412 {
1104                    if retried {
1105                        return Err(LibraryError::PlaylistChanged);
1106                    }
1107                    retried = true;
1108                    etag = self.playlist_etag(uuid).await?;
1109                    continue;
1110                }
1111                check(&response, Some(subject.clone()))?;
1112                etag = match response.header("etag") {
1113                    Some(next) => next.to_owned(),
1114                    None => self.playlist_etag(uuid).await?,
1115                };
1116                break;
1117            }
1118        }
1119        Ok(())
1120    }
1121
1122    /// One write for all positions, comma-joined in the order given (none:
1123    /// nothing is sent). Never retried: a `412` means positions may have
1124    /// moved.
1125    async fn remove_from_playlist(&self, uuid: &str, indices: &[u32], etag: &str) -> Result<()> {
1126        if indices.is_empty() {
1127            return Ok(());
1128        }
1129        let positions = indices
1130            .iter()
1131            .map(u32::to_string)
1132            .collect::<Vec<_>>()
1133            .join(",");
1134        let response = self
1135            .write(
1136                Method::DELETE,
1137                &format!("playlists/{uuid}/items/{positions}"),
1138                None,
1139                &[("if-none-match", etag)],
1140            )
1141            .await?;
1142        check_edit(&response, uuid).map(drop)
1143    }
1144
1145    /// Moves the item at `from` to the final position `to`, and returns
1146    /// the new `ETag`. Never retried, as a removal (spec 0022).
1147    async fn move_playlist_item(
1148        &self,
1149        uuid: &str,
1150        from: u32,
1151        to: u32,
1152        etag: &str,
1153    ) -> Result<Option<String>> {
1154        let to = to.to_string();
1155        let response = self
1156            .write(
1157                Method::POST,
1158                &format!("playlists/{uuid}/items/{from}"),
1159                Some(&[("toIndex", &to)]),
1160                &[("if-none-match", etag)],
1161            )
1162            .await?;
1163        let response = check_edit(&response, uuid)?;
1164        Ok(response.header("etag").map(str::to_owned))
1165    }
1166
1167    /// Tidal checks no `ETag` here: the last write wins (spec 0022).
1168    async fn edit_playlist(
1169        &self,
1170        uuid: &str,
1171        title: Option<&str>,
1172        description: Option<&str>,
1173    ) -> Result<()> {
1174        let form: Vec<(&str, &str)> = [("title", title), ("description", description)]
1175            .into_iter()
1176            .filter_map(|(field, value)| value.map(|value| (field, value)))
1177            .collect();
1178        let response = self
1179            .write(Method::POST, &format!("playlists/{uuid}"), Some(&form), &[])
1180            .await?;
1181        check_edit(&response, uuid).map(drop)
1182    }
1183
1184    /// On the v2 API, reached from the v1 base: `Url` resolves the `..`.
1185    async fn set_playlist_public(&self, uuid: &str, public: bool) -> Result<()> {
1186        let verb = if public { "set-public" } else { "set-private" };
1187        let response = self
1188            .write(
1189                Method::PUT,
1190                &format!("../v2/playlists/{uuid}/{verb}"),
1191                None,
1192                &[],
1193            )
1194            .await?;
1195        check_edit(&response, uuid).map(drop)
1196    }
1197
1198    async fn delete_playlist(&self, uuid: &str) -> Result<()> {
1199        let etag = self.playlist_etag(uuid).await?;
1200        let response = self
1201            .write(
1202                Method::DELETE,
1203                &format!("playlists/{uuid}"),
1204                None,
1205                &[("if-none-match", etag.as_str())],
1206            )
1207            .await?;
1208        check_edit(&response, uuid).map(drop)
1209    }
1210
1211    // HTTP ------------------------------------------------------------
1212
1213    /// `GET` with `countryCode`; a `404`/`2001` is `subject`'s not-found.
1214    async fn get(
1215        &self,
1216        path: &str,
1217        extra: &[(&str, &str)],
1218        subject: Option<Subject>,
1219    ) -> Result<ApiResponse> {
1220        check(&self.fetch(path, extra).await?, subject)
1221    }
1222
1223    /// `GET` with `countryCode`; the response whatever its status.
1224    async fn fetch(&self, path: &str, extra: &[(&str, &str)]) -> Result<ApiResponse> {
1225        let (_, country) = self.auth.account().await;
1226        let mut query = vec![("countryCode", country.as_str())];
1227        query.extend_from_slice(extra);
1228        Ok(self.auth.get(path, &query, Some(REQUEST_TIMEOUT)).await?)
1229    }
1230
1231    /// [`Self::get`], and the body decoded.
1232    async fn read<T: DeserializeOwned>(
1233        &self,
1234        path: &str,
1235        extra: &[(&str, &str)],
1236        subject: Option<Subject>,
1237        what: &'static str,
1238    ) -> Result<T> {
1239        parse(&self.get(path, extra, subject).await?, what)
1240    }
1241
1242    /// A write with `countryCode`; the response is returned whatever its
1243    /// status.
1244    async fn write(
1245        &self,
1246        method: Method,
1247        path: &str,
1248        form: Option<&[(&str, &str)]>,
1249        headers: &[(&str, &str)],
1250    ) -> Result<ApiResponse> {
1251        let (_, country) = self.auth.account().await;
1252        let query = [("countryCode", country.as_str())];
1253        Ok(self
1254            .auth
1255            .send(method, path, &query, form, headers, Some(REQUEST_TIMEOUT))
1256            .await?)
1257    }
1258}
1259
1260/// The path segment, form field and not-found subject of a favorite.
1261fn favorite_target(kind: FavoriteKind, id: &str) -> Result<(&'static str, &'static str, Subject)> {
1262    let number = || {
1263        id.parse::<u64>()
1264            .map_err(|_| LibraryError::InvalidId(id.to_owned()))
1265    };
1266    Ok(match kind {
1267        FavoriteKind::Track => (
1268            "tracks",
1269            "trackIds",
1270            Subject::Item(Item::Track(TrackId(number()?))),
1271        ),
1272        FavoriteKind::Album => ("albums", "albumIds", Subject::Item(Item::Album(number()?))),
1273        FavoriteKind::Artist => ("artists", "artistIds", Subject::Artist(number()?)),
1274        FavoriteKind::Playlist => (
1275            "playlists",
1276            "uuids",
1277            Subject::Item(Item::Playlist(id.to_owned())),
1278        ),
1279    })
1280}
1281
1282/// A success is returned as is; a `404`/`2001` is `subject`'s not-found; any
1283/// other status is an [`AuthError::Http`].
1284fn check(response: &ApiResponse, subject: Option<Subject>) -> Result<ApiResponse> {
1285    let status = response.status();
1286    if status.is_success() {
1287        return Ok(response.clone());
1288    }
1289    if status.as_u16() == 404
1290        && response.sub_status() == Some(SUB_STATUS_NOT_FOUND)
1291        && let Some(subject) = subject
1292    {
1293        return Err(LibraryError::NotFound(subject));
1294    }
1295    Err(AuthError::Http {
1296        status: status.as_u16(),
1297        code: None,
1298    }
1299    .into())
1300}
1301
1302/// [`check`] for an edit of playlist `uuid`: `412` is the changed message.
1303fn check_edit(response: &ApiResponse, uuid: &str) -> Result<ApiResponse> {
1304    if response.status().as_u16() == 412 {
1305        return Err(LibraryError::PlaylistChanged);
1306    }
1307    check(
1308        response,
1309        Some(Subject::Item(Item::Playlist(uuid.to_owned()))),
1310    )
1311}
1312
1313fn parse<T: DeserializeOwned>(response: &ApiResponse, what: &'static str) -> Result<T> {
1314    response.json().map_err(|_| LibraryError::Malformed(what))
1315}
1316
1317/// Tidal's `userMessage` from an error body, when it has a non-empty one.
1318fn user_message(response: &ApiResponse) -> Option<String> {
1319    #[derive(Deserialize)]
1320    struct ErrorBody {
1321        #[serde(rename = "userMessage")]
1322        user_message: Option<String>,
1323    }
1324    let message = response.json::<ErrorBody>().ok()?.user_message?;
1325    (!message.trim().is_empty()).then_some(message)
1326}
1327
1328/// The track, unless it has no stereo mode (Dolby-Atmos-only).
1329fn stereo_track(dto: TrackDto) -> Option<Track> {
1330    dto.audio_modes
1331        .iter()
1332        .any(|m| m == "STEREO")
1333        .then(|| Track::from(dto))
1334}
1335
1336/// A search's `topHit` (`{type, value}`) by its `type`; any other type, or
1337/// a `value` that cannot be read, is none.
1338fn top_hit(hit: serde_json::Value) -> Option<TopHit> {
1339    let kind = hit.get("type")?.as_str()?.to_owned();
1340    let value = hit.get("value")?.clone();
1341    match kind.as_str() {
1342        "TRACKS" => stereo_track(serde_json::from_value(value).ok()?).map(TopHit::Track),
1343        "ALBUMS" => Some(TopHit::Album(
1344            serde_json::from_value::<AlbumDto>(value).ok()?.into(),
1345        )),
1346        "ARTISTS" => Some(TopHit::Artist(
1347            serde_json::from_value::<ArtistDto>(value).ok()?.into(),
1348        )),
1349        "PLAYLISTS" => Some(TopHit::Playlist(
1350            serde_json::from_value::<PlaylistDto>(value)
1351                .ok()?
1352                .into_summary(false),
1353        )),
1354        _ => None,
1355    }
1356}
1357
1358/// A list that arrives whole: `total` is the rows kept (spec 0011).
1359fn whole<T>(items: Vec<T>) -> ListPage<T> {
1360    let total = clamp_total(items.len() as u64);
1361    ListPage {
1362        items,
1363        offset: 0,
1364        total,
1365        hidden: 0,
1366        positions: Vec::new(),
1367    }
1368}
1369
1370fn clamp_total(total: u64) -> u32 {
1371    u32::try_from(total).unwrap_or(u32::MAX)
1372}
1373
1374/// The credits' rows with their role categories, minus the Atmos-only
1375/// copies and the alternate versions; and how many were left out.
1376fn credited(entries: Vec<CreditEntry>, words: &[String]) -> Result<(Vec<CreditedTrack>, u32)> {
1377    let mut items = Vec::new();
1378    let mut hidden = 0;
1379    for entry in entries {
1380        if entry.kind.as_deref().is_some_and(|k| k != "track") {
1381            continue;
1382        }
1383        let dto: TrackDto =
1384            serde_json::from_value(entry.item).map_err(|_| LibraryError::Malformed("credits"))?;
1385        let stereo = dto.audio_modes.iter().any(|m| m == "STEREO");
1386        let track = Track::from(dto);
1387        if !stereo || hidden_version(&track, words) {
1388            hidden += 1;
1389            continue;
1390        }
1391        let mut roles: Vec<RoleCategory> = Vec::new();
1392        for role in entry.roles {
1393            if let Some(category) = role_category(role.category_id)
1394                && !roles.contains(&category)
1395            {
1396                roles.push(category);
1397            }
1398        }
1399        items.push(CreditedTrack { track, roles });
1400    }
1401    Ok((items, hidden))
1402}
1403
1404/// Tidal's `categoryId` of a role category.
1405fn role_category(id: i64) -> Option<RoleCategory> {
1406    match id {
1407        11 => Some(RoleCategory::Performer),
1408        2 => Some(RoleCategory::Songwriter),
1409        1 => Some(RoleCategory::Producer),
1410        3 => Some(RoleCategory::Engineer),
1411        _ => None,
1412    }
1413}
1414
1415// DTOs ------------------------------------------------------------------
1416
1417/// A paged response: the items are the endpoint's own envelope.
1418#[derive(Deserialize)]
1419struct RawPage<T> {
1420    #[serde(default, rename = "totalNumberOfItems")]
1421    total: u64,
1422    items: Vec<T>,
1423}
1424
1425impl<T> RawPage<T> {
1426    /// Maps each item (`None` drops it) into a core page asked at `offset`.
1427    fn map<U>(self, mut f: impl FnMut(T) -> Option<U>, offset: u32) -> ListPage<U> {
1428        ListPage {
1429            items: self.items.into_iter().filter_map(&mut f).collect(),
1430            offset,
1431            total: clamp_total(self.total),
1432            hidden: 0,
1433            positions: Vec::new(),
1434        }
1435    }
1436}
1437
1438/// `GET /search`: the four lists asked for (`videos` ignored) and the top
1439/// hit, read by [`top_hit`].
1440#[derive(Deserialize)]
1441struct SearchDto {
1442    tracks: RawPage<TrackDto>,
1443    albums: RawPage<AlbumDto>,
1444    artists: RawPage<ArtistDto>,
1445    playlists: RawPage<PlaylistDto>,
1446    #[serde(default, rename = "topHit")]
1447    top_hit: Option<serde_json::Value>,
1448}
1449
1450/// `{created, item}`.
1451#[derive(Deserialize)]
1452struct Entry<T> {
1453    item: T,
1454}
1455
1456/// `{type, item}` of a playlist's items.
1457#[derive(Deserialize)]
1458struct TypedEntry {
1459    #[serde(rename = "type")]
1460    kind: String,
1461    item: serde_json::Value,
1462}
1463
1464/// `{type: USER_CREATED | USER_FAVORITE, playlist}`.
1465#[derive(Deserialize)]
1466struct PlaylistEntry {
1467    #[serde(rename = "type")]
1468    kind: Option<String>,
1469    playlist: PlaylistDto,
1470}
1471
1472#[derive(Deserialize)]
1473struct Creator {
1474    id: Option<u64>,
1475}
1476
1477#[derive(Deserialize)]
1478struct PlaylistDto {
1479    uuid: String,
1480    title: String,
1481    #[serde(rename = "numberOfTracks")]
1482    tracks: Option<u32>,
1483    /// Whole seconds.
1484    duration: Option<u64>,
1485    creator: Option<Creator>,
1486    #[serde(default)]
1487    description: Option<String>,
1488    #[serde(default, rename = "publicPlaylist")]
1489    public: bool,
1490}
1491
1492impl PlaylistDto {
1493    fn into_summary(self, own: bool) -> PlaylistSummary {
1494        PlaylistSummary {
1495            uuid: self.uuid,
1496            title: self.title,
1497            tracks: self.tracks,
1498            duration: self.duration.map(Duration::from_secs),
1499            own,
1500            description: self.description.filter(|d| !d.is_empty()),
1501            public: self.public,
1502        }
1503    }
1504}
1505
1506#[derive(Deserialize)]
1507struct AlbumDto {
1508    id: u64,
1509    title: String,
1510    #[serde(default)]
1511    artists: Vec<ArtistDto>,
1512    artist: Option<ArtistDto>,
1513    /// `YYYY-MM-DD`.
1514    #[serde(rename = "releaseDate")]
1515    release_date: Option<String>,
1516    /// `ALBUM`, `EP`, `SINGLE`, ...
1517    #[serde(rename = "type")]
1518    kind: Option<String>,
1519    #[serde(rename = "numberOfTracks")]
1520    tracks: Option<u32>,
1521    /// Whole seconds.
1522    duration: Option<u64>,
1523}
1524
1525impl From<AlbumDto> for AlbumSummary {
1526    fn from(dto: AlbumDto) -> Self {
1527        let artists: Vec<ArtistDto> = if dto.artists.is_empty() {
1528            dto.artist.into_iter().collect()
1529        } else {
1530            dto.artists
1531        };
1532        AlbumSummary {
1533            id: dto.id,
1534            title: dto.title,
1535            artists: artists
1536                .into_iter()
1537                .map(|a| ArtistRef {
1538                    id: a.id,
1539                    name: a.name,
1540                })
1541                .collect(),
1542            year: dto
1543                .release_date
1544                .as_deref()
1545                .and_then(|d| d.get(..4))
1546                .and_then(|y| y.parse().ok()),
1547            kind: match dto.kind.as_deref() {
1548                Some("EP") => AlbumKind::Ep,
1549                Some("SINGLE") => AlbumKind::Single,
1550                _ => AlbumKind::Album,
1551            },
1552            tracks: dto.tracks,
1553            duration: dto.duration.map(Duration::from_secs),
1554        }
1555    }
1556}
1557
1558#[derive(Deserialize)]
1559struct ContributorPage {
1560    #[serde(default)]
1561    rows: Vec<ContributorRow>,
1562}
1563
1564#[derive(Deserialize)]
1565struct ContributorRow {
1566    #[serde(default)]
1567    modules: Vec<ContributorModule>,
1568}
1569
1570#[derive(Deserialize)]
1571struct ContributorModule {
1572    #[serde(default, rename = "type")]
1573    kind: String,
1574    #[serde(rename = "pagedList")]
1575    paged_list: Option<PagedList>,
1576}
1577
1578#[derive(Deserialize)]
1579struct PagedList {
1580    #[serde(rename = "dataApiPath")]
1581    data_api_path: String,
1582    #[serde(default, rename = "totalNumberOfItems")]
1583    total: u64,
1584    #[serde(default)]
1585    items: Vec<CreditEntry>,
1586}
1587
1588impl PagedList {
1589    fn into_page(self) -> RawPage<CreditEntry> {
1590        RawPage {
1591            total: self.total,
1592            items: self.items,
1593        }
1594    }
1595}
1596
1597#[derive(Deserialize)]
1598struct CreditEntry {
1599    #[serde(default, rename = "type")]
1600    kind: Option<String>,
1601    item: serde_json::Value,
1602    #[serde(default)]
1603    roles: Vec<RoleDto>,
1604}
1605
1606#[derive(Deserialize)]
1607struct RoleDto {
1608    #[serde(rename = "categoryId")]
1609    category_id: i64,
1610}
1611
1612/// `GET /pages/my_collection_my_mixes`.
1613#[derive(Deserialize)]
1614struct MixesPage {
1615    #[serde(default)]
1616    rows: Vec<MixesRow>,
1617}
1618
1619#[derive(Deserialize)]
1620struct MixesRow {
1621    #[serde(default)]
1622    modules: Vec<MixesModule>,
1623}
1624
1625#[derive(Deserialize)]
1626struct MixesModule {
1627    #[serde(default, rename = "type")]
1628    kind: String,
1629    #[serde(rename = "pagedList")]
1630    paged_list: Option<MixList>,
1631}
1632
1633#[derive(Deserialize)]
1634struct MixList {
1635    #[serde(default, rename = "dataApiPath")]
1636    data_api_path: String,
1637    #[serde(default)]
1638    limit: u32,
1639    #[serde(default, rename = "totalNumberOfItems")]
1640    total: u64,
1641    #[serde(default)]
1642    items: Vec<MixDto>,
1643}
1644
1645/// `GET /pages/mix`.
1646#[derive(Deserialize)]
1647struct MixHeaderPage {
1648    #[serde(default)]
1649    rows: Vec<MixHeaderRow>,
1650}
1651
1652#[derive(Deserialize)]
1653struct MixHeaderRow {
1654    #[serde(default)]
1655    modules: Vec<MixHeaderModule>,
1656}
1657
1658#[derive(Deserialize)]
1659struct MixHeaderModule {
1660    mix: Option<MixDto>,
1661}
1662
1663#[derive(Deserialize)]
1664struct MixDto {
1665    #[serde(default)]
1666    id: String,
1667    #[serde(default)]
1668    title: String,
1669    #[serde(default, rename = "subTitle")]
1670    subtitle: Option<String>,
1671    #[serde(default, rename = "mixType")]
1672    mix_type: String,
1673}
1674
1675impl From<MixDto> for MixSummary {
1676    fn from(dto: MixDto) -> Self {
1677        MixSummary {
1678            id: dto.id,
1679            title: dto.title,
1680            subtitle: dto.subtitle.filter(|s| !s.trim().is_empty()),
1681        }
1682    }
1683}
1684
1685/// `GET /tracks/{id}/lyrics` (spec 0021 "Facts").
1686#[derive(Deserialize)]
1687struct LyricsDto {
1688    #[serde(default)]
1689    lyrics: Option<String>,
1690    #[serde(default)]
1691    subtitles: Option<String>,
1692    #[serde(default, rename = "isRightToLeft")]
1693    right_to_left: Option<bool>,
1694}
1695
1696impl From<LyricsDto> for Lyrics {
1697    fn from(dto: LyricsDto) -> Self {
1698        let text = dto.lyrics.unwrap_or_default().replace("\r\n", "\n");
1699        let mut lines: Vec<&str> = text.split('\n').collect();
1700        while lines.last().is_some_and(|l| l.trim().is_empty()) {
1701            lines.pop();
1702        }
1703        Self {
1704            synced: parse_lrc(&dto.subtitles.unwrap_or_default()),
1705            plain: lines.join("\n"),
1706            right_to_left: dto.right_to_left.unwrap_or(false),
1707        }
1708    }
1709}