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, and the page names
636    /// the positions of the rest (spec 0006 AC21).
637    async fn playlist_tracks(
638        &self,
639        uuid: &str,
640        offset: u32,
641        limit: u32,
642    ) -> Result<ListPage<Track>> {
643        let page: RawPage<TypedEntry> = self
644            .page_of(
645                &format!("playlists/{uuid}/items"),
646                &[],
647                offset,
648                limit.clamp(1, LARGEST_ITEMS),
649                Some(Subject::Item(Item::Playlist(uuid.to_owned()))),
650                "playlist",
651            )
652            .await?;
653        let listed = page.items.len();
654        let mut malformed = false;
655        let mut next = offset;
656        let mut positions = Vec::with_capacity(listed);
657        let mut page = page.map(
658            |e| {
659                let position = next;
660                next += 1;
661                if e.kind != "track" {
662                    return None;
663                }
664                match serde_json::from_value::<TrackDto>(e.item) {
665                    Ok(dto) => {
666                        positions.push(position);
667                        Some(Track::from(dto))
668                    }
669                    Err(_) => {
670                        malformed = true;
671                        None
672                    }
673                }
674            },
675            offset,
676        );
677        if malformed {
678            return Err(LibraryError::Malformed("playlist"));
679        }
680        if page.items.len() < listed {
681            page.positions = positions;
682        }
683        Ok(page)
684    }
685
686    async fn top_tracks(&self, id: u64, offset: u32, limit: u32) -> Result<ListPage<Track>> {
687        let page: RawPage<TrackDto> = self
688            .page_of(
689                &format!("artists/{id}/toptracks"),
690                &[],
691                offset,
692                limit.clamp(1, LARGEST),
693                Some(Subject::Artist(id)),
694                "top tracks",
695            )
696            .await?;
697        Ok(page.map(|t| Some(Track::from(t)), offset))
698    }
699
700    async fn appears_on(&self, id: u64, offset: u32, limit: u32) -> Result<ListPage<AlbumSummary>> {
701        let page: RawPage<AlbumDto> = self
702            .page_of(
703                &format!("artists/{id}/albums"),
704                &[("filter", "COMPILATIONS")],
705                offset,
706                limit.clamp(1, LARGEST),
707                Some(Subject::Artist(id)),
708                "albums",
709            )
710            .await?;
711        Ok(page.map(|a| Some(a.into()), offset))
712    }
713
714    /// The plain album list, then EPs and singles (see the module docs for
715    /// how `offset` maps across the two).
716    async fn artist_albums(
717        &self,
718        id: u64,
719        offset: u32,
720        limit: u32,
721    ) -> Result<ListPage<AlbumSummary>> {
722        let limit = limit.clamp(1, LARGEST);
723        let path = format!("artists/{id}/albums");
724        let subject = Some(Subject::Artist(id));
725        let plain: RawPage<AlbumDto> = self
726            .page_of(&path, &[], offset, limit, subject.clone(), "albums")
727            .await?;
728        let plain_total = plain.total;
729        let slots = plain_total.div_ceil(u64::from(limit)) * u64::from(limit);
730        let filter = [("filter", "EPSANDSINGLES")];
731        let (items, eps_total) = if u64::from(offset) < slots {
732            let eps: RawPage<AlbumDto> = self
733                .page_of(&path, &filter, 0, 1, subject, "albums")
734                .await?;
735            (plain.items, eps.total)
736        } else {
737            let eps_offset = u32::try_from(u64::from(offset) - slots).unwrap_or(u32::MAX);
738            let eps: RawPage<AlbumDto> = self
739                .page_of(&path, &filter, eps_offset, limit, subject, "albums")
740                .await?;
741            (eps.items, eps.total)
742        };
743        Ok(ListPage {
744            items: items.into_iter().map(Into::into).collect(),
745            offset,
746            total: clamp_total(plain_total + eps_total),
747            hidden: 0,
748            positions: Vec::new(),
749        })
750    }
751
752    /// The artist's *All tracks* (see the module docs).
753    async fn credits(
754        &self,
755        artist: u64,
756        offset: u32,
757        limit: u32,
758        hidden_words: &[String],
759    ) -> Result<ListPage<CreditedTrack>> {
760        let limit = limit.clamp(1, LARGEST_SMALL);
761        let cached = self.cached_credit_path(artist);
762        let raw = if offset == 0 || cached.is_none() {
763            let Some(list) = self.contributor(artist).await? else {
764                return Ok(ListPage {
765                    items: vec![],
766                    offset,
767                    total: 0,
768                    hidden: 0,
769                    positions: Vec::new(),
770                });
771            };
772            if offset == 0 {
773                let mut list = list;
774                list.items.truncate(limit as usize);
775                list.into_page()
776            } else {
777                self.credit_data(artist, &list.data_api_path, offset, limit)
778                    .await?
779            }
780        } else {
781            let path = cached.unwrap_or_default();
782            match self.credit_data(artist, &path, offset, limit).await {
783                Err(LibraryError::NotFound(_)) => {
784                    let Some(list) = self.contributor(artist).await? else {
785                        return Ok(ListPage {
786                            items: vec![],
787                            offset,
788                            total: 0,
789                            hidden: 0,
790                            positions: Vec::new(),
791                        });
792                    };
793                    self.credit_data(artist, &list.data_api_path, offset, limit)
794                        .await?
795                }
796                other => other?,
797            }
798        };
799        let (items, hidden) = credited(raw.items, hidden_words)?;
800        Ok(ListPage {
801            items,
802            offset,
803            total: clamp_total(raw.total),
804            hidden,
805            positions: Vec::new(),
806        })
807    }
808
809    fn cached_credit_path(&self, artist: u64) -> Option<String> {
810        self.credit_paths
811            .lock()
812            .expect("poisoned")
813            .get(&artist)
814            .cloned()
815    }
816
817    /// `GET /pages/contributor`: the `ITEM_LIST_WITH_ROLES` module's
818    /// list, `None` when the page has no such module. Keeps its
819    /// `dataApiPath`.
820    async fn contributor(&self, artist: u64) -> Result<Option<PagedList>> {
821        let id = artist.to_string();
822        let page: ContributorPage = self
823            .read(
824                "pages/contributor",
825                &[
826                    ("artistId", id.as_str()),
827                    ("deviceType", "BROWSER"),
828                    ("locale", "en_US"),
829                ],
830                Some(Subject::Artist(artist)),
831                "credits",
832            )
833            .await?;
834        let list = page
835            .rows
836            .into_iter()
837            .flat_map(|row| row.modules)
838            .filter(|m| m.kind == "ITEM_LIST_WITH_ROLES")
839            .find_map(|m| m.paged_list);
840        if let Some(list) = &list {
841            self.credit_paths
842                .lock()
843                .expect("poisoned")
844                .insert(artist, list.data_api_path.clone());
845        }
846        Ok(list)
847    }
848
849    /// One page from a `dataApiPath`.
850    async fn credit_data(
851        &self,
852        artist: u64,
853        data_api_path: &str,
854        offset: u32,
855        limit: u32,
856    ) -> Result<RawPage<CreditEntry>> {
857        self.page_of(
858            data_api_path,
859            &[("deviceType", "BROWSER"), ("locale", "en_US")],
860            offset,
861            limit,
862            Some(Subject::Artist(artist)),
863            "credits",
864        )
865        .await
866    }
867
868    /// One page of a paged endpoint: `countryCode`, `extra`, `limit`,
869    /// `offset`.
870    async fn page_of<T: DeserializeOwned>(
871        &self,
872        path: &str,
873        extra: &[(&str, &str)],
874        offset: u32,
875        limit: u32,
876        subject: Option<Subject>,
877        what: &'static str,
878    ) -> Result<RawPage<T>> {
879        let limit = limit.to_string();
880        let offset = offset.to_string();
881        let mut query = extra.to_vec();
882        query.push(("limit", limit.as_str()));
883        query.push(("offset", offset.as_str()));
884        self.read(path, &query, subject, what).await
885    }
886
887    // Search ----------------------------------------------------------
888
889    /// `GET /search`: the four lists' first pages and the top hit.
890    async fn search(&self, query: &str, size: u32) -> Result<PageData> {
891        let dto: SearchDto = self
892            .search_read(
893                "search",
894                &[("query", query), ("types", SEARCH_TYPES)],
895                0,
896                size,
897            )
898            .await?;
899        Ok(PageData::Search {
900            top_hit: dto.top_hit.and_then(top_hit).map(Box::new),
901            tracks: dto.tracks.map(stereo_track, 0),
902            albums: dto.albums.map(|a| Some(a.into()), 0),
903            artists: dto.artists.map(|a| Some(ArtistRef::from(a)), 0),
904            playlists: dto.playlists.map(|p| Some(p.into_summary(false)), 0),
905        })
906    }
907
908    /// `GET /search/{kind}`: one page of one list, bare items.
909    async fn search_list<T: DeserializeOwned>(
910        &self,
911        kind: &str,
912        query: &str,
913        offset: u32,
914        limit: u32,
915    ) -> Result<RawPage<T>> {
916        self.search_read(
917            &format!("search/{kind}"),
918            &[("query", query)],
919            offset,
920            limit,
921        )
922        .await
923    }
924
925    /// A search call: `extra`, `limit` (clamped) and `offset`; a `400` with
926    /// a `userMessage` is [`LibraryError::SearchRefused`].
927    async fn search_read<T: DeserializeOwned>(
928        &self,
929        path: &str,
930        extra: &[(&str, &str)],
931        offset: u32,
932        limit: u32,
933    ) -> Result<T> {
934        let limit = limit.clamp(1, LARGEST).to_string();
935        let offset = offset.to_string();
936        let mut query = extra.to_vec();
937        query.push(("limit", limit.as_str()));
938        query.push(("offset", offset.as_str()));
939        let response = self.fetch(path, &query).await?;
940        if response.status().as_u16() == 400
941            && let Some(message) = user_message(&response)
942        {
943            return Err(LibraryError::SearchRefused(message));
944        }
945        parse(&check(&response, None)?, "search")
946    }
947
948    // Favorites -------------------------------------------------------
949
950    async fn is_favorite(&self, kind: FavoriteKind, id: &str) -> Result<bool> {
951        let (user, _) = self.auth.account().await;
952        let lists: HashMap<String, Vec<serde_json::Value>> = self
953            .read(
954                &format!("users/{user}/favorites/ids"),
955                &[],
956                None,
957                "favorites",
958            )
959            .await?;
960        let key = match kind {
961            FavoriteKind::Track => "TRACK",
962            FavoriteKind::Album => "ALBUM",
963            FavoriteKind::Artist => "ARTIST",
964            FavoriteKind::Playlist => "PLAYLIST",
965        };
966        Ok(lists.get(key).is_some_and(|ids| {
967            ids.iter().any(|v| match v {
968                serde_json::Value::String(s) => s == id,
969                serde_json::Value::Number(n) => n.to_string() == id,
970                _ => false,
971            })
972        }))
973    }
974
975    async fn add_favorite(&self, kind: FavoriteKind, id: &str) -> Result<()> {
976        let (user, _) = self.auth.account().await;
977        let (segment, field, subject) = favorite_target(kind, id)?;
978        let response = self
979            .write(
980                Method::POST,
981                &format!("users/{user}/favorites/{segment}"),
982                Some(&[(field, id)]),
983                &[],
984            )
985            .await?;
986        check(&response, Some(subject)).map(drop)
987    }
988
989    async fn remove_favorite(&self, kind: FavoriteKind, id: &str) -> Result<()> {
990        let (user, _) = self.auth.account().await;
991        let (segment, _, subject) = favorite_target(kind, id)?;
992        let response = self
993            .write(
994                Method::DELETE,
995                &format!("users/{user}/favorites/{segment}/{id}"),
996                None,
997                &[],
998            )
999            .await?;
1000        check(&response, Some(subject)).map(drop)
1001    }
1002
1003    // Playlists -------------------------------------------------------
1004
1005    async fn create_playlist(&self, title: &str) -> Result<PlaylistSummary> {
1006        let (user, _) = self.auth.account().await;
1007        let response = self
1008            .write(
1009                Method::POST,
1010                &format!("users/{user}/playlists"),
1011                Some(&[("title", title), ("description", "")]),
1012                &[],
1013            )
1014            .await?;
1015        let dto: PlaylistDto = parse(&check(&response, None)?, "playlist")?;
1016        Ok(dto.into_summary(true))
1017    }
1018
1019    /// The playlist's current `ETag`.
1020    async fn playlist_etag(&self, uuid: &str) -> Result<String> {
1021        let response = self
1022            .get(
1023                &format!("playlists/{uuid}"),
1024                &[],
1025                Some(Subject::Item(Item::Playlist(uuid.to_owned()))),
1026            )
1027            .await?;
1028        response
1029            .header("etag")
1030            .map(str::to_owned)
1031            .ok_or(LibraryError::Malformed("playlist"))
1032    }
1033
1034    async fn add_to_playlist(
1035        &self,
1036        uuid: &str,
1037        tracks: &[TrackId],
1038        allow_duplicates: bool,
1039    ) -> Result<()> {
1040        if tracks.is_empty() {
1041            return Ok(());
1042        }
1043        let subject = Subject::Item(Item::Playlist(uuid.to_owned()));
1044        let path = format!("playlists/{uuid}/items");
1045        let mut etag = self.playlist_etag(uuid).await?;
1046        for chunk in tracks.chunks(ADD_CHUNK) {
1047            let ids = chunk
1048                .iter()
1049                .map(ToString::to_string)
1050                .collect::<Vec<_>>()
1051                .join(",");
1052            let mut form = vec![("trackIds", ids.as_str())];
1053            if allow_duplicates {
1054                form.push(("onDupes", "ADD"));
1055            }
1056            let mut retried = false;
1057            loop {
1058                let response = self
1059                    .write(
1060                        Method::POST,
1061                        &path,
1062                        Some(&form),
1063                        &[("if-none-match", etag.as_str())],
1064                    )
1065                    .await?;
1066                if response.status().as_u16() == 412 {
1067                    if retried {
1068                        return Err(LibraryError::PlaylistChanged);
1069                    }
1070                    retried = true;
1071                    etag = self.playlist_etag(uuid).await?;
1072                    continue;
1073                }
1074                check(&response, Some(subject.clone()))?;
1075                etag = match response.header("etag") {
1076                    Some(next) => next.to_owned(),
1077                    None => self.playlist_etag(uuid).await?,
1078                };
1079                break;
1080            }
1081        }
1082        Ok(())
1083    }
1084
1085    /// One write for all positions, comma-joined in the order given (none:
1086    /// nothing is sent). Never retried: a `412` means positions may have
1087    /// moved.
1088    async fn remove_from_playlist(&self, uuid: &str, indices: &[u32], etag: &str) -> Result<()> {
1089        if indices.is_empty() {
1090            return Ok(());
1091        }
1092        let positions = indices
1093            .iter()
1094            .map(u32::to_string)
1095            .collect::<Vec<_>>()
1096            .join(",");
1097        let response = self
1098            .write(
1099                Method::DELETE,
1100                &format!("playlists/{uuid}/items/{positions}"),
1101                None,
1102                &[("if-none-match", etag)],
1103            )
1104            .await?;
1105        check_edit(&response, uuid).map(drop)
1106    }
1107
1108    async fn delete_playlist(&self, uuid: &str) -> Result<()> {
1109        let etag = self.playlist_etag(uuid).await?;
1110        let response = self
1111            .write(
1112                Method::DELETE,
1113                &format!("playlists/{uuid}"),
1114                None,
1115                &[("if-none-match", etag.as_str())],
1116            )
1117            .await?;
1118        check_edit(&response, uuid).map(drop)
1119    }
1120
1121    // HTTP ------------------------------------------------------------
1122
1123    /// `GET` with `countryCode`; a `404`/`2001` is `subject`'s not-found.
1124    async fn get(
1125        &self,
1126        path: &str,
1127        extra: &[(&str, &str)],
1128        subject: Option<Subject>,
1129    ) -> Result<ApiResponse> {
1130        check(&self.fetch(path, extra).await?, subject)
1131    }
1132
1133    /// `GET` with `countryCode`; the response whatever its status.
1134    async fn fetch(&self, path: &str, extra: &[(&str, &str)]) -> Result<ApiResponse> {
1135        let (_, country) = self.auth.account().await;
1136        let mut query = vec![("countryCode", country.as_str())];
1137        query.extend_from_slice(extra);
1138        Ok(self.auth.get(path, &query, Some(REQUEST_TIMEOUT)).await?)
1139    }
1140
1141    /// [`Self::get`], and the body decoded.
1142    async fn read<T: DeserializeOwned>(
1143        &self,
1144        path: &str,
1145        extra: &[(&str, &str)],
1146        subject: Option<Subject>,
1147        what: &'static str,
1148    ) -> Result<T> {
1149        parse(&self.get(path, extra, subject).await?, what)
1150    }
1151
1152    /// A write with `countryCode`; the response is returned whatever its
1153    /// status.
1154    async fn write(
1155        &self,
1156        method: Method,
1157        path: &str,
1158        form: Option<&[(&str, &str)]>,
1159        headers: &[(&str, &str)],
1160    ) -> Result<ApiResponse> {
1161        let (_, country) = self.auth.account().await;
1162        let query = [("countryCode", country.as_str())];
1163        Ok(self
1164            .auth
1165            .send(method, path, &query, form, headers, Some(REQUEST_TIMEOUT))
1166            .await?)
1167    }
1168}
1169
1170/// The path segment, form field and not-found subject of a favorite.
1171fn favorite_target(kind: FavoriteKind, id: &str) -> Result<(&'static str, &'static str, Subject)> {
1172    let number = || {
1173        id.parse::<u64>()
1174            .map_err(|_| LibraryError::InvalidId(id.to_owned()))
1175    };
1176    Ok(match kind {
1177        FavoriteKind::Track => (
1178            "tracks",
1179            "trackIds",
1180            Subject::Item(Item::Track(TrackId(number()?))),
1181        ),
1182        FavoriteKind::Album => ("albums", "albumIds", Subject::Item(Item::Album(number()?))),
1183        FavoriteKind::Artist => ("artists", "artistIds", Subject::Artist(number()?)),
1184        FavoriteKind::Playlist => (
1185            "playlists",
1186            "uuids",
1187            Subject::Item(Item::Playlist(id.to_owned())),
1188        ),
1189    })
1190}
1191
1192/// A success is returned as is; a `404`/`2001` is `subject`'s not-found; any
1193/// other status is an [`AuthError::Http`].
1194fn check(response: &ApiResponse, subject: Option<Subject>) -> Result<ApiResponse> {
1195    let status = response.status();
1196    if status.is_success() {
1197        return Ok(response.clone());
1198    }
1199    if status.as_u16() == 404
1200        && response.sub_status() == Some(SUB_STATUS_NOT_FOUND)
1201        && let Some(subject) = subject
1202    {
1203        return Err(LibraryError::NotFound(subject));
1204    }
1205    Err(AuthError::Http {
1206        status: status.as_u16(),
1207        code: None,
1208    }
1209    .into())
1210}
1211
1212/// [`check`] for an edit of playlist `uuid`: `412` is the changed message.
1213fn check_edit(response: &ApiResponse, uuid: &str) -> Result<ApiResponse> {
1214    if response.status().as_u16() == 412 {
1215        return Err(LibraryError::PlaylistChanged);
1216    }
1217    check(
1218        response,
1219        Some(Subject::Item(Item::Playlist(uuid.to_owned()))),
1220    )
1221}
1222
1223fn parse<T: DeserializeOwned>(response: &ApiResponse, what: &'static str) -> Result<T> {
1224    response.json().map_err(|_| LibraryError::Malformed(what))
1225}
1226
1227/// Tidal's `userMessage` from an error body, when it has a non-empty one.
1228fn user_message(response: &ApiResponse) -> Option<String> {
1229    #[derive(Deserialize)]
1230    struct ErrorBody {
1231        #[serde(rename = "userMessage")]
1232        user_message: Option<String>,
1233    }
1234    let message = response.json::<ErrorBody>().ok()?.user_message?;
1235    (!message.trim().is_empty()).then_some(message)
1236}
1237
1238/// The track, unless it has no stereo mode (Dolby-Atmos-only).
1239fn stereo_track(dto: TrackDto) -> Option<Track> {
1240    dto.audio_modes
1241        .iter()
1242        .any(|m| m == "STEREO")
1243        .then(|| Track::from(dto))
1244}
1245
1246/// A search's `topHit` (`{type, value}`) by its `type`; any other type, or
1247/// a `value` that cannot be read, is none.
1248fn top_hit(hit: serde_json::Value) -> Option<TopHit> {
1249    let kind = hit.get("type")?.as_str()?.to_owned();
1250    let value = hit.get("value")?.clone();
1251    match kind.as_str() {
1252        "TRACKS" => stereo_track(serde_json::from_value(value).ok()?).map(TopHit::Track),
1253        "ALBUMS" => Some(TopHit::Album(
1254            serde_json::from_value::<AlbumDto>(value).ok()?.into(),
1255        )),
1256        "ARTISTS" => Some(TopHit::Artist(
1257            serde_json::from_value::<ArtistDto>(value).ok()?.into(),
1258        )),
1259        "PLAYLISTS" => Some(TopHit::Playlist(
1260            serde_json::from_value::<PlaylistDto>(value)
1261                .ok()?
1262                .into_summary(false),
1263        )),
1264        _ => None,
1265    }
1266}
1267
1268/// A list that arrives whole: `total` is the rows kept (spec 0011).
1269fn whole<T>(items: Vec<T>) -> ListPage<T> {
1270    let total = clamp_total(items.len() as u64);
1271    ListPage {
1272        items,
1273        offset: 0,
1274        total,
1275        hidden: 0,
1276        positions: Vec::new(),
1277    }
1278}
1279
1280fn clamp_total(total: u64) -> u32 {
1281    u32::try_from(total).unwrap_or(u32::MAX)
1282}
1283
1284/// The credits' rows with their role categories, minus the Atmos-only
1285/// copies and the alternate versions; and how many were left out.
1286fn credited(entries: Vec<CreditEntry>, words: &[String]) -> Result<(Vec<CreditedTrack>, u32)> {
1287    let mut items = Vec::new();
1288    let mut hidden = 0;
1289    for entry in entries {
1290        if entry.kind.as_deref().is_some_and(|k| k != "track") {
1291            continue;
1292        }
1293        let dto: TrackDto =
1294            serde_json::from_value(entry.item).map_err(|_| LibraryError::Malformed("credits"))?;
1295        let stereo = dto.audio_modes.iter().any(|m| m == "STEREO");
1296        let track = Track::from(dto);
1297        if !stereo || hidden_version(&track, words) {
1298            hidden += 1;
1299            continue;
1300        }
1301        let mut roles: Vec<RoleCategory> = Vec::new();
1302        for role in entry.roles {
1303            if let Some(category) = role_category(role.category_id)
1304                && !roles.contains(&category)
1305            {
1306                roles.push(category);
1307            }
1308        }
1309        items.push(CreditedTrack { track, roles });
1310    }
1311    Ok((items, hidden))
1312}
1313
1314/// Tidal's `categoryId` of a role category.
1315fn role_category(id: i64) -> Option<RoleCategory> {
1316    match id {
1317        11 => Some(RoleCategory::Performer),
1318        2 => Some(RoleCategory::Songwriter),
1319        1 => Some(RoleCategory::Producer),
1320        3 => Some(RoleCategory::Engineer),
1321        _ => None,
1322    }
1323}
1324
1325// DTOs ------------------------------------------------------------------
1326
1327/// A paged response: the items are the endpoint's own envelope.
1328#[derive(Deserialize)]
1329struct RawPage<T> {
1330    #[serde(default, rename = "totalNumberOfItems")]
1331    total: u64,
1332    items: Vec<T>,
1333}
1334
1335impl<T> RawPage<T> {
1336    /// Maps each item (`None` drops it) into a core page asked at `offset`.
1337    fn map<U>(self, mut f: impl FnMut(T) -> Option<U>, offset: u32) -> ListPage<U> {
1338        ListPage {
1339            items: self.items.into_iter().filter_map(&mut f).collect(),
1340            offset,
1341            total: clamp_total(self.total),
1342            hidden: 0,
1343            positions: Vec::new(),
1344        }
1345    }
1346}
1347
1348/// `GET /search`: the four lists asked for (`videos` ignored) and the top
1349/// hit, read by [`top_hit`].
1350#[derive(Deserialize)]
1351struct SearchDto {
1352    tracks: RawPage<TrackDto>,
1353    albums: RawPage<AlbumDto>,
1354    artists: RawPage<ArtistDto>,
1355    playlists: RawPage<PlaylistDto>,
1356    #[serde(default, rename = "topHit")]
1357    top_hit: Option<serde_json::Value>,
1358}
1359
1360/// `{created, item}`.
1361#[derive(Deserialize)]
1362struct Entry<T> {
1363    item: T,
1364}
1365
1366/// `{type, item}` of a playlist's items.
1367#[derive(Deserialize)]
1368struct TypedEntry {
1369    #[serde(rename = "type")]
1370    kind: String,
1371    item: serde_json::Value,
1372}
1373
1374/// `{type: USER_CREATED | USER_FAVORITE, playlist}`.
1375#[derive(Deserialize)]
1376struct PlaylistEntry {
1377    #[serde(rename = "type")]
1378    kind: Option<String>,
1379    playlist: PlaylistDto,
1380}
1381
1382#[derive(Deserialize)]
1383struct Creator {
1384    id: Option<u64>,
1385}
1386
1387#[derive(Deserialize)]
1388struct PlaylistDto {
1389    uuid: String,
1390    title: String,
1391    #[serde(rename = "numberOfTracks")]
1392    tracks: Option<u32>,
1393    /// Whole seconds.
1394    duration: Option<u64>,
1395    creator: Option<Creator>,
1396}
1397
1398impl PlaylistDto {
1399    fn into_summary(self, own: bool) -> PlaylistSummary {
1400        PlaylistSummary {
1401            uuid: self.uuid,
1402            title: self.title,
1403            tracks: self.tracks,
1404            duration: self.duration.map(Duration::from_secs),
1405            own,
1406        }
1407    }
1408}
1409
1410#[derive(Deserialize)]
1411struct AlbumDto {
1412    id: u64,
1413    title: String,
1414    #[serde(default)]
1415    artists: Vec<ArtistDto>,
1416    artist: Option<ArtistDto>,
1417    /// `YYYY-MM-DD`.
1418    #[serde(rename = "releaseDate")]
1419    release_date: Option<String>,
1420    /// `ALBUM`, `EP`, `SINGLE`, ...
1421    #[serde(rename = "type")]
1422    kind: Option<String>,
1423    #[serde(rename = "numberOfTracks")]
1424    tracks: Option<u32>,
1425    /// Whole seconds.
1426    duration: Option<u64>,
1427}
1428
1429impl From<AlbumDto> for AlbumSummary {
1430    fn from(dto: AlbumDto) -> Self {
1431        let artists: Vec<ArtistDto> = if dto.artists.is_empty() {
1432            dto.artist.into_iter().collect()
1433        } else {
1434            dto.artists
1435        };
1436        AlbumSummary {
1437            id: dto.id,
1438            title: dto.title,
1439            artists: artists
1440                .into_iter()
1441                .map(|a| ArtistRef {
1442                    id: a.id,
1443                    name: a.name,
1444                })
1445                .collect(),
1446            year: dto
1447                .release_date
1448                .as_deref()
1449                .and_then(|d| d.get(..4))
1450                .and_then(|y| y.parse().ok()),
1451            kind: match dto.kind.as_deref() {
1452                Some("EP") => AlbumKind::Ep,
1453                Some("SINGLE") => AlbumKind::Single,
1454                _ => AlbumKind::Album,
1455            },
1456            tracks: dto.tracks,
1457            duration: dto.duration.map(Duration::from_secs),
1458        }
1459    }
1460}
1461
1462#[derive(Deserialize)]
1463struct ContributorPage {
1464    #[serde(default)]
1465    rows: Vec<ContributorRow>,
1466}
1467
1468#[derive(Deserialize)]
1469struct ContributorRow {
1470    #[serde(default)]
1471    modules: Vec<ContributorModule>,
1472}
1473
1474#[derive(Deserialize)]
1475struct ContributorModule {
1476    #[serde(default, rename = "type")]
1477    kind: String,
1478    #[serde(rename = "pagedList")]
1479    paged_list: Option<PagedList>,
1480}
1481
1482#[derive(Deserialize)]
1483struct PagedList {
1484    #[serde(rename = "dataApiPath")]
1485    data_api_path: String,
1486    #[serde(default, rename = "totalNumberOfItems")]
1487    total: u64,
1488    #[serde(default)]
1489    items: Vec<CreditEntry>,
1490}
1491
1492impl PagedList {
1493    fn into_page(self) -> RawPage<CreditEntry> {
1494        RawPage {
1495            total: self.total,
1496            items: self.items,
1497        }
1498    }
1499}
1500
1501#[derive(Deserialize)]
1502struct CreditEntry {
1503    #[serde(default, rename = "type")]
1504    kind: Option<String>,
1505    item: serde_json::Value,
1506    #[serde(default)]
1507    roles: Vec<RoleDto>,
1508}
1509
1510#[derive(Deserialize)]
1511struct RoleDto {
1512    #[serde(rename = "categoryId")]
1513    category_id: i64,
1514}
1515
1516/// `GET /pages/my_collection_my_mixes`.
1517#[derive(Deserialize)]
1518struct MixesPage {
1519    #[serde(default)]
1520    rows: Vec<MixesRow>,
1521}
1522
1523#[derive(Deserialize)]
1524struct MixesRow {
1525    #[serde(default)]
1526    modules: Vec<MixesModule>,
1527}
1528
1529#[derive(Deserialize)]
1530struct MixesModule {
1531    #[serde(default, rename = "type")]
1532    kind: String,
1533    #[serde(rename = "pagedList")]
1534    paged_list: Option<MixList>,
1535}
1536
1537#[derive(Deserialize)]
1538struct MixList {
1539    #[serde(default, rename = "dataApiPath")]
1540    data_api_path: String,
1541    #[serde(default)]
1542    limit: u32,
1543    #[serde(default, rename = "totalNumberOfItems")]
1544    total: u64,
1545    #[serde(default)]
1546    items: Vec<MixDto>,
1547}
1548
1549/// `GET /pages/mix`.
1550#[derive(Deserialize)]
1551struct MixHeaderPage {
1552    #[serde(default)]
1553    rows: Vec<MixHeaderRow>,
1554}
1555
1556#[derive(Deserialize)]
1557struct MixHeaderRow {
1558    #[serde(default)]
1559    modules: Vec<MixHeaderModule>,
1560}
1561
1562#[derive(Deserialize)]
1563struct MixHeaderModule {
1564    mix: Option<MixDto>,
1565}
1566
1567#[derive(Deserialize)]
1568struct MixDto {
1569    #[serde(default)]
1570    id: String,
1571    #[serde(default)]
1572    title: String,
1573    #[serde(default, rename = "subTitle")]
1574    subtitle: Option<String>,
1575    #[serde(default, rename = "mixType")]
1576    mix_type: String,
1577}
1578
1579impl From<MixDto> for MixSummary {
1580    fn from(dto: MixDto) -> Self {
1581        MixSummary {
1582            id: dto.id,
1583            title: dto.title,
1584            subtitle: dto.subtitle.filter(|s| !s.trim().is_empty()),
1585        }
1586    }
1587}
1588
1589/// `GET /tracks/{id}/lyrics` (spec 0021 "Facts").
1590#[derive(Deserialize)]
1591struct LyricsDto {
1592    #[serde(default)]
1593    lyrics: Option<String>,
1594    #[serde(default)]
1595    subtitles: Option<String>,
1596    #[serde(default, rename = "isRightToLeft")]
1597    right_to_left: Option<bool>,
1598}
1599
1600impl From<LyricsDto> for Lyrics {
1601    fn from(dto: LyricsDto) -> Self {
1602        let text = dto.lyrics.unwrap_or_default().replace("\r\n", "\n");
1603        let mut lines: Vec<&str> = text.split('\n').collect();
1604        while lines.last().is_some_and(|l| l.trim().is_empty()) {
1605            lines.pop();
1606        }
1607        Self {
1608            synced: parse_lrc(&dto.subtitles.unwrap_or_default()),
1609            plain: lines.join("\n"),
1610            right_to_left: dto.right_to_left.unwrap_or(false),
1611        }
1612    }
1613}