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