Skip to main content

tidal_player_api/
library.rs

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