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