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