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