diff --git a/codegen/src/abtest.rs b/codegen/src/abtest.rs index 8b2c9f0..f09a30d 100644 --- a/codegen/src/abtest.rs +++ b/codegen/src/abtest.rs @@ -30,9 +30,11 @@ pub enum ABTest { ChannelAboutModal = 10, LikeButtonViewmodel = 11, ChannelPageHeader = 12, + MusicPlaylistTwoColumn = 13, } -const TESTS_TO_RUN: [ABTest; 1] = [ABTest::ChannelPageHeader]; +/// List of active A/B tests that are run when none is manually specified +const TESTS_TO_RUN: [ABTest; 2] = [ABTest::ChannelPageHeader, ABTest::MusicPlaylistTwoColumn]; #[derive(Debug, Serialize, Deserialize)] pub struct ABTestRes { @@ -101,6 +103,7 @@ pub async fn run_test( ABTest::ChannelAboutModal => channel_about_modal(&query).await, ABTest::LikeButtonViewmodel => like_button_viewmodel(&query).await, ABTest::ChannelPageHeader => channel_page_header(&query).await, + ABTest::MusicPlaylistTwoColumn => music_playlist_two_column(&query).await, } .unwrap(); pb.inc(1); @@ -336,3 +339,20 @@ pub async fn channel_page_header(rp: &RustyPipeQuery) -> Result { .await?; Ok(channel.mobile_banner.is_empty() && channel.tv_banner.is_empty()) } + +pub async fn music_playlist_two_column(rp: &RustyPipeQuery) -> Result { + let id = "VLRDCLAK5uy_kb7EBi6y3GrtJri4_ZH56Ms786DFEimbM"; + let res = rp + .raw( + ClientType::DesktopMusic, + "browse", + &QBrowse { + context: rp.get_context(ClientType::DesktopMusic, true, None).await, + browse_id: id, + params: None, + }, + ) + .await + .unwrap(); + Ok(res.contains("\"musicResponsiveHeaderRenderer\"")) +} diff --git a/notes/AB_Tests.md b/notes/AB_Tests.md index fbe5d15..9f5457d 100644 --- a/notes/AB_Tests.md +++ b/notes/AB_Tests.md @@ -24,6 +24,13 @@ to the new feature. - 🔴 **High** Changes to the functionality of YouTube that will require API changes for alternative clients +**Status:** + +- Experimental (<3%) +- Common (>3%) +- Frequent (>40%) +- Stabilized (100%) + If you want to check how often these A/B tests occur, you can use the `codegen` tool with the following command: `rustypipe-codegen ab-test `. @@ -584,3 +591,16 @@ be accomodated. There are also no mobile/TV header images available any more. } } ``` + + +## [13] Music album/playlist 2-column layout + +- **Encountered on:** 29.02.2024 +- **Impact:** 🟢 Low +- **Endpoint:** browse +- **Status:** Common (6%) + +![A/B test 13 screenshot](./_img/ab_13.png) + +YouTube Music updated the layout of album and playlist pages. The new layout shows +the cover on the left side of the playlist content. diff --git a/notes/_img/ab_13.png b/notes/_img/ab_13.png new file mode 100644 index 0000000..a372da6 Binary files /dev/null and b/notes/_img/ab_13.png differ diff --git a/src/client/music_playlist.rs b/src/client/music_playlist.rs index d3fb2a9..e754e34 100644 --- a/src/client/music_playlist.rs +++ b/src/client/music_playlist.rs @@ -1,6 +1,7 @@ use std::{borrow::Cow, fmt::Debug}; use crate::{ + client::response::url_endpoint::NavigationEndpoint, error::{Error, ExtractionError}, model::{ paginator::{ContinuationEndpoint, Paginator}, @@ -143,16 +144,35 @@ impl MapResponse for response::MusicPlaylist { ) -> Result, ExtractionError> { // dbg!(&self); - let music_contents = self - .contents - .single_column_browse_results_renderer - .contents - .into_iter() - .next() - .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))? - .tab_renderer - .content - .section_list_renderer; + 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() @@ -183,7 +203,7 @@ impl MapResponse for response::MusicPlaylist { .map(|cont| cont.next_continuation_data.continuation); let track_count = if ctoken.is_some() { - self.header.as_ref().and_then(|h| { + header.as_ref().and_then(|h| { let parts = h .music_detail_header_renderer .second_subtitle @@ -203,23 +223,24 @@ impl MapResponse for response::MusicPlaylist { .next() .map(|c| c.next_continuation_data.continuation); - let (from_ytm, channel, name, thumbnail, description) = match self.header { + let (from_ytm, channel, name, thumbnail, description) = match header { Some(header) => { let h = header.music_detail_header_renderer; - let from_ytm = h.subtitle.0.iter().any(util::is_ytm); - let channel = h - .subtitle - .0 - .into_iter() - .find_map(|c| ChannelId::try_from(c).ok()); + 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, + h.description.map(String::from), ) } None => { @@ -288,23 +309,40 @@ impl MapResponse for response::MusicPlaylist { ) -> Result, ExtractionError> { // dbg!(&self); - let header = self - .header + 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 sections = self - .contents - .single_column_browse_results_renderer - .contents - .into_iter() - .next() - .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))? - .tab_renderer - .content - .section_list_renderer - .contents; - let mut shelf = None; let mut album_variants = None; for section in sections { @@ -322,27 +360,37 @@ impl MapResponse for response::MusicPlaylist { let mut subtitle_split = header.subtitle.split(util::DOT_SEPARATOR); - let (year_txt, artists_p) = match subtitle_split.len() { - 3.. => { + let (year_txt, artists_p) = match header.strapline_text_one { + Some(sl) => { let year_txt = subtitle_split - .swap_remove(2) + .swap_remove(1) .0 .first() .map(|c| c.as_str().to_owned()); - (year_txt, subtitle_split.try_swap_remove(1)) + (year_txt, Some(sl)) } - 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 => 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)) } - } - _ => (None, None), + 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); @@ -355,21 +403,34 @@ impl MapResponse for response::MusicPlaylist { let album_type = map_album_type(album_type_txt.as_str(), lang); let year = year_txt.and_then(|txt| util::parse_numeric(&txt).ok()); - let (artist_id, playlist_id) = header + 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, artist_id) = header .menu + .or_else(|| header.buttons.into_iter().next()) .map(|menu| { ( - map_artist_id(menu.menu_renderer.items), menu.menu_renderer .top_level_buttons - .into_iter() - .next() - .map(|btn| { - btn.button_renderer - .navigation_endpoint - .watch_playlist_endpoint - .playlist_id + .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(); @@ -403,7 +464,7 @@ impl MapResponse for response::MusicPlaylist { cover: header.thumbnail.into(), artists, artist_id, - description: header.description, + description: header.description.map(String::from), album_type, year, by_va, @@ -429,6 +490,8 @@ mod tests { #[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(); @@ -454,6 +517,8 @@ mod tests { #[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(); diff --git a/src/client/response/mod.rs b/src/client/response/mod.rs index 02c4075..3f5682d 100644 --- a/src/client/response/mod.rs +++ b/src/client/response/mod.rs @@ -67,6 +67,9 @@ pub(crate) struct ContentRenderer { pub content: T, } +/// Deserializes any object with an array field named `contents`, `tabs` or `items`. +/// +/// Invalid items are skipped #[derive(Debug)] pub(crate) struct ContentsRenderer { pub contents: Vec, diff --git a/src/client/response/music_playlist.rs b/src/client/response/music_playlist.rs index 8de1c91..734f6c4 100644 --- a/src/client/response/music_playlist.rs +++ b/src/client/response/music_playlist.rs @@ -5,23 +5,37 @@ use crate::serializer::text::{Text, TextComponents}; use super::{ music_item::{ - ItemSection, MusicContentsRenderer, MusicItemMenuEntry, MusicThumbnailRenderer, - SingleColumnBrowseResult, + Button, ItemSection, MusicContentsRenderer, MusicItemMenuEntry, MusicThumbnailRenderer, }, - Tab, + ContentsRenderer, SectionList, Tab, }; /// Response model for YouTube Music playlists and albums #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct MusicPlaylist { - pub contents: SingleColumnBrowseResult>, + pub contents: Contents, pub header: Option
, } +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum Contents { + SingleColumnBrowseResultsRenderer(ContentsRenderer>), + #[serde(rename_all = "camelCase")] + TwoColumnBrowseResultsRenderer { + /// List content + secondary_contents: PlSectionList, + /// Header + #[serde_as(as = "VecSkipError<_>")] + tabs: Vec>>, + }, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct SectionList { +pub(crate) struct PlSectionList { /// Includes a continuation token for fetching recommendations pub section_list_renderer: MusicContentsRenderer, } @@ -29,6 +43,7 @@ pub(crate) struct SectionList { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct Header { + #[serde(alias = "musicResponsiveHeaderRenderer")] pub music_detail_header_renderer: HeaderRenderer, } @@ -48,12 +63,13 @@ pub(crate) struct HeaderRenderer { pub subtitle: TextComponents, /// Playlist/album description. May contain hashtags which are /// displayed as search links on the YouTube website. - #[serde_as(as = "Option")] - pub description: Option, + pub description: Option, /// Playlist thumbnail / album cover. /// Missing on artist_tracks view. #[serde(default)] pub thumbnail: MusicThumbnailRenderer, + /// Channel (only on TwoColumnBrowseResultsRenderer) + pub strapline_text_one: Option, /// Number of tracks + playtime. /// Missing on artist_tracks view. /// @@ -66,6 +82,28 @@ pub(crate) struct HeaderRenderer { #[serde(default)] #[serde_as(as = "DefaultOnError")] pub menu: Option, + #[serde(default)] + #[serde_as(as = "VecSkipError<_>")] + pub buttons: Vec, +} + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub(crate) enum Description { + Text(#[serde_as(as = "Text")] String), + #[serde(rename_all = "camelCase")] + Shelf { + music_description_shelf_renderer: DescriptionShelf, + }, +} + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DescriptionShelf { + #[serde_as(as = "Text")] + pub description: String, } #[derive(Debug, Deserialize)] @@ -80,31 +118,18 @@ pub(crate) struct HeaderMenu { pub(crate) struct HeaderMenuRenderer { #[serde(default)] #[serde_as(as = "VecSkipError<_>")] - pub top_level_buttons: Vec, + pub top_level_buttons: Vec