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