use std::{borrow::Cow, fmt::Debug}; use crate::{ client::response::url_endpoint::NavigationEndpoint, error::{Error, ExtractionError}, model::{ paginator::{ContinuationEndpoint, Paginator}, richtext::RichText, AlbumId, ChannelId, MusicAlbum, MusicPlaylist, TrackItem, }, serializer::{text::TextComponents, MapResult}, util::{self, TryRemove, DOT_SEPARATOR}, }; use super::{ response::{ self, music_item::{map_album_type, map_artist_id, map_artists, MusicListMapper}, }, ClientType, MapResponse, QBrowse, RustyPipeQuery, }; impl RustyPipeQuery { /// Get a playlist from YouTube Music #[tracing::instrument(skip(self))] pub async fn music_playlist + Debug>( &self, playlist_id: S, ) -> Result { let playlist_id = playlist_id.as_ref(); // YTM playlists require visitor data for continuations to work let visitor_data = if playlist_id.starts_with("RD") { Some(self.get_visitor_data().await?) } else { None }; let context = self .get_context(ClientType::DesktopMusic, true, visitor_data.as_deref()) .await; let request_body = QBrowse { context, browse_id: &format!("VL{playlist_id}"), }; self.execute_request_vdata::( ClientType::DesktopMusic, "music_playlist", playlist_id, "browse", &request_body, visitor_data.as_deref(), ) .await } /// Get an album from YouTube Music #[tracing::instrument(skip(self))] pub async fn music_album + Debug>( &self, album_id: S, ) -> Result { let album_id = album_id.as_ref(); let context = self.get_context(ClientType::DesktopMusic, true, None).await; let request_body = QBrowse { context, browse_id: album_id, }; let mut album = self .execute_request::( ClientType::DesktopMusic, "music_album", album_id, "browse", &request_body, ) .await?; // In rare cases, albums may have track numbers =0 (example: MPREb_RM0QfZ0eSKL) // They should be replaced with the track number derived from the previous track. let mut n_prev = 0; for track in &mut album.tracks { let tn = track.track_nr.unwrap_or_default(); if tn == 0 { n_prev += 1; track.track_nr = Some(n_prev); } else { n_prev = tn; } } // YouTube Music is replacing album tracks with their respective music videos. To get the original // tracks, we have to fetch the album as a playlist and replace the offending track ids. if let Some(playlist_id) = &album.playlist_id { // Get a list of music videos in the album let to_replace = album .tracks .iter() .enumerate() .filter_map(|(i, track)| { if track.is_video { Some((i, track.name.clone())) } else { None } }) .collect::>(); if !to_replace.is_empty() { let mut playlist = self.music_playlist(playlist_id).await?; playlist .tracks .extend_limit(&self, album.tracks.len()) .await?; for (i, title) in to_replace { let found_track = playlist.tracks.items.iter().find_map(|track| { if track.name == title && !track.is_video { Some((track.id.clone(), track.duration)) } else { None } }); if let Some((track_id, duration)) = found_track { album.tracks[i].id = track_id; if let Some(duration) = duration { album.tracks[i].duration = Some(duration); } album.tracks[i].is_video = false; } } } } Ok(album) } } impl MapResponse for response::MusicPlaylist { fn map_response( self, id: &str, lang: crate::param::Language, _deobf: Option<&crate::deobfuscate::DeobfData>, vdata: Option<&str>, ) -> Result, ExtractionError> { // dbg!(&self); let (header, music_contents) = match self.contents { response::music_playlist::Contents::SingleColumnBrowseResultsRenderer(c) => ( self.header, c.contents .into_iter() .next() .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))? .tab_renderer .content .section_list_renderer, ), response::music_playlist::Contents::TwoColumnBrowseResultsRenderer { secondary_contents, tabs, } => ( tabs.into_iter() .next() .and_then(|t| { t.tab_renderer .content .section_list_renderer .contents .into_iter() .next() }) .or(self.header), secondary_contents.section_list_renderer, ), }; let shelf = music_contents .contents .into_iter() .find_map(|section| match section { response::music_item::ItemSection::MusicShelfRenderer(shelf) => Some(shelf), _ => None, }) .ok_or(ExtractionError::InvalidData(Cow::Borrowed( "no sectionListRenderer content", )))?; if let Some(playlist_id) = shelf.playlist_id { if playlist_id != id { return Err(ExtractionError::WrongResult(format!( "got wrong playlist id {playlist_id}, expected {id}" ))); } } let mut mapper = MusicListMapper::new(lang); mapper.map_response(shelf.contents); let map_res = mapper.conv_items(); let ctoken = shelf .continuations .into_iter() .next() .map(|cont| cont.next_continuation_data.continuation); let track_count = if ctoken.is_some() { header.as_ref().and_then(|h| { let parts = h .music_detail_header_renderer .second_subtitle .split(|p| p == DOT_SEPARATOR) .collect::>(); parts .get(usize::from(parts.len() > 2)) .and_then(|txt| util::parse_numeric::(&txt[0]).ok()) }) } else { Some(map_res.c.len() as u64) }; let related_ctoken = music_contents .continuations .into_iter() .next() .map(|c| c.next_continuation_data.continuation); let (from_ytm, channel, name, thumbnail, description) = match header { Some(header) => { let h = header.music_detail_header_renderer; let st = match h.strapline_text_one { Some(s) => s, None => h.subtitle, }; let from_ytm = st.0.iter().any(util::is_ytm); let channel = st.0.into_iter().find_map(|c| ChannelId::try_from(c).ok()); ( from_ytm, channel, h.title, h.thumbnail.into(), h.description.map(TextComponents::from), ) } None => { // Album playlists fetched via the playlist method dont include a header let (album, cover) = map_res .c .first() .and_then(|t: &TrackItem| { t.album.as_ref().map(|a| (a.clone(), t.cover.clone())) }) .ok_or(ExtractionError::InvalidData(Cow::Borrowed( "playlist without header or album items", )))?; if !map_res.c.iter().all(|t| { t.album .as_ref() .map(|a| a.id == album.id) .unwrap_or_default() }) { return Err(ExtractionError::InvalidData(Cow::Borrowed( "album playlist containing items from different albums", ))); } (true, None, album.name, cover, None) } }; Ok(MapResult { c: MusicPlaylist { id: id.to_owned(), name, thumbnail, channel, description: description.map(RichText::from), track_count, from_ytm, tracks: Paginator::new_ext( track_count, map_res.c, ctoken, vdata.map(str::to_owned), ContinuationEndpoint::MusicBrowse, ), related_playlists: Paginator::new_ext( None, Vec::new(), related_ctoken, vdata.map(str::to_owned), ContinuationEndpoint::MusicBrowse, ), }, warnings: map_res.warnings, }) } } impl MapResponse for response::MusicPlaylist { fn map_response( self, id: &str, lang: crate::param::Language, _deobf: Option<&crate::deobfuscate::DeobfData>, _vdata: Option<&str>, ) -> Result, ExtractionError> { // dbg!(&self); let (header, sections) = match self.contents { response::music_playlist::Contents::SingleColumnBrowseResultsRenderer(c) => ( self.header, c.contents .into_iter() .next() .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))? .tab_renderer .content .section_list_renderer .contents, ), response::music_playlist::Contents::TwoColumnBrowseResultsRenderer { secondary_contents, tabs, } => ( tabs.into_iter() .next() .and_then(|t| { t.tab_renderer .content .section_list_renderer .contents .into_iter() .next() }) .or(self.header), secondary_contents.section_list_renderer.contents, ), }; let header = header .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no header")))? .music_detail_header_renderer; let mut shelf = None; let mut album_variants = None; for section in sections { match section { response::music_item::ItemSection::MusicShelfRenderer(sh) => shelf = Some(sh), response::music_item::ItemSection::MusicCarouselShelfRenderer(sh) => { album_variants = Some(sh.contents); } _ => (), } } let shelf = shelf.ok_or(ExtractionError::InvalidData(Cow::Borrowed( "no sectionListRenderer content", )))?; let mut subtitle_split = header.subtitle.split(util::DOT_SEPARATOR); let (year_txt, artists_p) = match header.strapline_text_one { // New (2column) album layout Some(sl) => { let year_txt = subtitle_split .try_swap_remove(1) .and_then(|t| t.0.first().map(|c| c.as_str().to_owned())); (year_txt, Some(sl)) } // Old album layout None => match subtitle_split.len() { 3.. => { let year_txt = subtitle_split .swap_remove(2) .0 .first() .map(|c| c.as_str().to_owned()); (year_txt, subtitle_split.try_swap_remove(1)) } 2 => { // The second part may either be the year or the artist let p2 = subtitle_split.swap_remove(1); let is_year = p2.0.len() == 1 && p2.0[0].as_str().chars().all(|c| c.is_ascii_digit()); if is_year { (Some(p2.0[0].as_str().to_owned()), None) } else { (None, Some(p2)) } } _ => (None, None), }, }; let (artists, by_va) = map_artists(artists_p); let album_type_txt = subtitle_split .into_iter() .next() .map(|part| part.to_string()) .unwrap_or_default(); let album_type = map_album_type(album_type_txt.as_str(), lang); let year = year_txt.and_then(|txt| util::parse_numeric(&txt).ok()); fn map_playlist_id(ep: &NavigationEndpoint) -> Option { if let NavigationEndpoint::WatchPlaylist { watch_playlist_endpoint, } = ep { Some(watch_playlist_endpoint.playlist_id.to_owned()) } else { None } } let playlist_id = self.microformat.and_then(|mf| { mf.microformat_data_renderer .url_canonical .strip_prefix("https://music.youtube.com/playlist?list=") .map(str::to_owned) }); let (playlist_id, artist_id) = header .menu .or_else(|| header.buttons.into_iter().next()) .map(|menu| { ( playlist_id.or_else(|| { menu.menu_renderer .top_level_buttons .iter() .find_map(|btn| { map_playlist_id(&btn.button_renderer.navigation_endpoint) }) .or_else(|| { menu.menu_renderer.items.iter().find_map(|itm| { map_playlist_id( &itm.menu_navigation_item_renderer.navigation_endpoint, ) }) }) }), map_artist_id(menu.menu_renderer.items), ) }) .unwrap_or_default(); let artist_id = artist_id.or_else(|| artists.first().and_then(|a| a.id.clone())); let mut mapper = MusicListMapper::with_album( lang, artists.clone(), by_va, AlbumId { id: id.to_owned(), name: header.title.clone(), }, ); mapper.map_response(shelf.contents); let tracks_res = mapper.conv_items(); let mut warnings = tracks_res.warnings; let mut variants_mapper = MusicListMapper::new(lang); if let Some(res) = album_variants { variants_mapper.map_response(res); } let mut variants_res = variants_mapper.conv_items(); warnings.append(&mut variants_res.warnings); Ok(MapResult { c: MusicAlbum { id: id.to_owned(), playlist_id, name: header.title, cover: header.thumbnail.into(), artists, artist_id, description: header .description .map(|t| RichText::from(TextComponents::from(t))), album_type, year, by_va, tracks: tracks_res.c, variants: variants_res.c, }, warnings, }) } } #[cfg(test)] mod tests { use std::{fs::File, io::BufReader}; use path_macro::path; use rstest::rstest; use super::*; use crate::{model, param::Language, util::tests::TESTFILES}; #[rstest] #[case::short("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk")] #[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")] #[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")] #[case::two_columns("20240228_twoColumns", "RDCLAK5uy_kb7EBi6y3GrtJri4_ZH56Ms786DFEimbM")] #[case::n_album("20240228_album", "OLAK5uy_kdSWBZ-9AZDkYkuy0QCc3p0KO9DEHVNH0")] fn map_music_playlist(#[case] name: &str, #[case] id: &str) { let json_path = path!(*TESTFILES / "music_playlist" / format!("playlist_{name}.json")); let json_file = File::open(json_path).unwrap(); let playlist: response::MusicPlaylist = serde_json::from_reader(BufReader::new(json_file)).unwrap(); let map_res: MapResult = playlist.map_response(id, Language::En, None, None).unwrap(); assert!( map_res.warnings.is_empty(), "deserialization/mapping warnings: {:?}", map_res.warnings ); insta::assert_ron_snapshot!(format!("map_music_playlist_{name}"), map_res.c, { ".last_update" => "[date]" }); } #[rstest] #[case::one_artist("one_artist", "MPREb_nlBWQROfvjo")] #[case::various_artists("various_artists", "MPREb_8QkDeEIawvX")] #[case::single("single", "MPREb_bHfHGoy7vuv")] #[case::description("description", "MPREb_PiyfuVl6aYd")] #[case::unavailable("unavailable", "MPREb_AzuWg8qAVVl")] #[case::unavailable("unavailable", "MPREb_AzuWg8qAVVl")] #[case::two_columns("20240228_twoColumns", "MPREb_bHfHGoy7vuv")] fn map_music_album(#[case] name: &str, #[case] id: &str) { let json_path = path!(*TESTFILES / "music_playlist" / format!("album_{name}.json")); let json_file = File::open(json_path).unwrap(); let playlist: response::MusicPlaylist = serde_json::from_reader(BufReader::new(json_file)).unwrap(); let map_res: MapResult = playlist.map_response(id, Language::En, None, None).unwrap(); assert!( map_res.warnings.is_empty(), "deserialization/mapping warnings: {:?}", map_res.warnings ); insta::assert_ron_snapshot!(format!("map_music_album_{name}"), map_res.c); } }