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