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