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