diff --git a/src/client/music_details.rs b/src/client/music_details.rs index 1a48e67..cd4717c 100644 --- a/src/client/music_details.rs +++ b/src/client/music_details.rs @@ -99,7 +99,10 @@ impl RustyPipeQuery { radio_id: S, ) -> Result, Error> { let radio_id = radio_id.as_ref(); - let context = self.get_context(ClientType::DesktopMusic, true, None).await; + let visitor_data = self.get_ytm_visitor_data().await?; + let context = self + .get_context(ClientType::DesktopMusic, true, Some(&visitor_data)) + .await; let request_body = QRadio { context, playlist_id: radio_id, diff --git a/src/client/music_playlist.rs b/src/client/music_playlist.rs index 6db4c7e..6fdd6ef 100644 --- a/src/client/music_playlist.rs +++ b/src/client/music_playlist.rs @@ -45,14 +45,55 @@ impl RustyPipeQuery { browse_id: album_id, }; - self.execute_request::( - ClientType::DesktopMusic, - "music_album", - album_id, - "browse", - &request_body, - ) - .await + let mut album = self + .execute_request::( + ClientType::DesktopMusic, + "music_album", + album_id, + "browse", + &request_body, + ) + .await?; + + // 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.title.to_owned())) + } else { + None + } + }) + .collect::>(); + + if !to_replace.is_empty() { + let playlist = self.music_playlist(playlist_id).await?; + + for (i, title) in to_replace { + let found_track = playlist.tracks.items.iter().find_map(|track| { + if track.title == title && !track.is_video { + Some((track.id.to_owned(), 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) } } diff --git a/src/client/response/music_item.rs b/src/client/response/music_item.rs index c505b02..d29ebd2 100644 --- a/src/client/response/music_item.rs +++ b/src/client/response/music_item.rs @@ -418,10 +418,12 @@ impl MusicListMapper { // List item MusicResponseItem::MusicResponsiveListItemRenderer(item) => { let mut columns = item.flex_columns.into_iter(); - let title = columns.next().map(|col| col.renderer.text.to_string()); + let c1 = columns.next(); let c2 = columns.next(); let c3 = columns.next(); + let title = c1.as_ref().map(|col| col.renderer.text.to_string()); + let first_tn = item .thumbnail .music_thumbnail_renderer @@ -433,27 +435,54 @@ impl MusicListMapper { .navigation_endpoint .and_then(|ne| ne.music_page()) .or_else(|| { - item.playlist_item_data - .map(|d| (MusicPageType::Track, d.video_id)) + c1.and_then(|c1| { + c1.renderer.text.0.into_iter().next().and_then(|t| match t { + crate::serializer::text::TextComponent::Video { + video_id, + is_video, + .. + } => Some((MusicPageType::Track { is_video }, video_id)), + crate::serializer::text::TextComponent::Browse { + page_type, + browse_id, + .. + } => Some((page_type.into(), browse_id)), + _ => None, + }) + }) + }) + .or_else(|| { + item.playlist_item_data.map(|d| { + ( + MusicPageType::Track { + is_video: self.album.is_none() + && !first_tn + .map(|tn| tn.height == tn.width) + .unwrap_or_default(), + }, + d.video_id, + ) + }) }) .or_else(|| { first_tn.and_then(|tn| { - util::video_id_from_thumbnail_url(&tn.url) - .map(|id| (MusicPageType::Track, id)) + util::video_id_from_thumbnail_url(&tn.url).map(|id| { + ( + MusicPageType::Track { + is_video: self.album.is_none() && tn.width != tn.height, + }, + id, + ) + }) }) }); match pt_id { // Track - Some((MusicPageType::Track, id)) => { + Some((MusicPageType::Track { is_video }, id)) => { let title = title.ok_or_else(|| format!("track {}: could not get title", id))?; - // Videos have rectangular thumbnails, YTM tracks have square covers - // Exception: there are no thumbnails on album items - let is_video = self.album.is_none() - && !first_tn.map(|tn| tn.height == tn.width).unwrap_or_default(); - let (artists_p, album_p, duration_p) = match item.flex_column_display_style { // Search result @@ -519,15 +548,14 @@ impl MusicListMapper { }), ), (_, false) => ( - album_p - .and_then(|p| { - p.0.into_iter().find_map(|c| AlbumId::try_from(c).ok()) - }) - .or_else(|| self.album.clone()), + album_p.and_then(|p| { + p.0.into_iter().find_map(|c| AlbumId::try_from(c).ok()) + }), None, ), (FlexColumnDisplayStyle::Default, true) => (None, None), }; + let album = album.or_else(|| self.album.clone()); let (mut artists, _) = map_artists(artists_p); @@ -640,7 +668,8 @@ impl MusicListMapper { // There may be broken YT channels from the artist search. They can be skipped. Ok(None) } - MusicPageType::Track => unreachable!(), + // Tracks were already handled above + MusicPageType::Track { .. } => unreachable!(), } } None => Err("could not determine item type".to_owned()), @@ -655,7 +684,7 @@ impl MusicListMapper { match item.navigation_endpoint.music_page() { Some((page_type, id)) => match page_type { - MusicPageType::Track => { + MusicPageType::Track { is_video } => { let artists = map_artists(subtitle_p1).0; self.items.push(MusicItem::Track(TrackItem { @@ -669,7 +698,7 @@ impl MusicListMapper { view_count: subtitle_p2.and_then(|c| { util::parse_large_numstr(c.first_str(), self.lang) }), - is_video: true, + is_video, track_nr: None, })); Ok(Some(MusicEntityType::Track)) diff --git a/src/client/response/url_endpoint.rs b/src/client/response/url_endpoint.rs index 09713e1..03d9043 100644 --- a/src/client/response/url_endpoint.rs +++ b/src/client/response/url_endpoint.rs @@ -35,6 +35,8 @@ pub(crate) struct WatchEndpoint { pub playlist_id: Option, #[serde(default)] pub start_time_seconds: u32, + #[serde(default)] + pub watch_endpoint_music_supported_configs: WatchEndpointConfigWrap, } #[derive(Debug)] @@ -118,6 +120,30 @@ pub(crate) struct WebCommandMetadata { pub web_page_type: PageType, } +#[derive(Default, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WatchEndpointConfigWrap { + pub watch_endpoint_music_config: WatchEndpointConfig, +} + +#[serde_as] +#[derive(Default, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WatchEndpointConfig { + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnError")] + pub music_video_type: MusicVideoType, +} + +#[derive(Default, Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +pub(crate) enum MusicVideoType { + #[default] + #[serde(rename = "MUSIC_VIDEO_TYPE_OMV")] + Video, + #[serde(rename = "MUSIC_VIDEO_TYPE_ATV")] + Track, +} + #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] pub(crate) enum PageType { #[serde( @@ -152,7 +178,7 @@ pub(crate) enum MusicPageType { Artist, Album, Playlist, - Track, + Track { is_video: bool }, None, } @@ -189,7 +215,16 @@ impl NavigationEndpoint { // Genre radios (e.g. "pop radio") will be skipped (MusicPageType::None, watch.video_id) } else { - (MusicPageType::Track, watch.video_id) + ( + MusicPageType::Track { + is_video: watch + .watch_endpoint_music_supported_configs + .watch_endpoint_music_config + .music_video_type + == MusicVideoType::Video, + }, + watch.video_id, + ) } }) }) diff --git a/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_description.snap b/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_description.snap index 8f2bb6c..19a4cd1 100644 --- a/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_description.snap +++ b/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_description.snap @@ -56,7 +56,7 @@ MusicAlbum( name: "25", )), view_count: None, - is_video: false, + is_video: true, track_nr: Some(1), ), TrackItem( @@ -76,7 +76,7 @@ MusicAlbum( name: "25", )), view_count: None, - is_video: false, + is_video: true, track_nr: Some(2), ), TrackItem( diff --git a/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_single.snap b/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_single.snap index fa429ce..3e86ad8 100644 --- a/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_single.snap +++ b/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_single.snap @@ -64,7 +64,7 @@ MusicAlbum( name: "Der Himmel reißt auf", )), view_count: None, - is_video: false, + is_video: true, track_nr: Some(1), ), ], diff --git a/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_various_artists.snap b/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_various_artists.snap index 83f0652..78bfbc7 100644 --- a/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_various_artists.snap +++ b/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_various_artists.snap @@ -51,7 +51,7 @@ MusicAlbum( name: "<Queendom2> FINAL", )), view_count: None, - is_video: false, + is_video: true, track_nr: Some(1), ), TrackItem( diff --git a/src/model/richtext.rs b/src/model/richtext.rs index c4609be..1947e48 100644 --- a/src/model/richtext.rs +++ b/src/model/richtext.rs @@ -122,7 +122,7 @@ mod tests { text::TextComponent::Text { text: "🎧Listen and download aespa's debut single \"Black Mamba\": ".to_owned() }, text::TextComponent::Web { text: "https://smarturl.it/aespa_BlackMamba".to_owned(), url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbFY1QmpQamJPSms0Z1FnVTlQUS00ZFhBZnBJZ3xBQ3Jtc0tuRGJBanludGoyRnphb2dZWVd3cUNnS3dEd0FnNHFOZEY1NHBJaHFmLXpaWUJwX3ZucDZxVnpGeHNGX1FpMzFkZW9jQkI2Mi1wNGJ1UVFNN3h1MnN3R3JLMzdxU01nZ01POHBGcmxHU2puSUk1WHRzQQ&q=https%3A%2F%2Fsmarturl.it%2Faespa_BlackMamba&v=ZeerrnuLi5E".to_owned() }, text::TextComponent::Text { text: "\n🐍The Debut Stage ".to_owned() }, - text::TextComponent::Video { text: "https://youtu.be/Ky5RT5oGg0w".to_owned(), video_id: "Ky5RT5oGg0w".to_owned(), start_time: 0 }, + text::TextComponent::Video { text: "https://youtu.be/Ky5RT5oGg0w".to_owned(), video_id: "Ky5RT5oGg0w".to_owned(), start_time: 0, is_video: true }, text::TextComponent::Text { text: "\n\n🎟️ aespa Showcase SYNK in LA! Tickets now on sale: ".to_owned() }, text::TextComponent::Web { text: "https://www.ticketmaster.com/event/0A...".to_owned(), url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbFpUMEZiaXJWWkszaVZXaEM0emxWU1JQV3NoQXxBQ3Jtc0tuU2g4VWNPNE5UY3hoSWYtamFzX0h4bUVQLVJiRy1ubDZrTnh3MUpGdDNSaUo0ZlMyT3lUM28ycUVBdHJLMndGcDhla3BkOFpxSVFfOS1QdVJPVHBUTEV1LXpOV0J2QXdhV05lV210cEJtZUJMeHdaTQ&q=https%3A%2F%2Fwww.ticketmaster.com%2Fevent%2F0A005CCD9E871F6E&v=ZeerrnuLi5E".to_owned() }, text::TextComponent::Text { text: "\n\nSubscribe to aespa Official YouTube Channel!\n".to_owned() }, diff --git a/src/serializer/snapshots/rustypipe__serializer__text__tests__t_attributed_description.snap b/src/serializer/snapshots/rustypipe__serializer__text__tests__t_attributed_description.snap index 8ba3dfa..e937bcc 100644 --- a/src/serializer/snapshots/rustypipe__serializer__text__tests__t_attributed_description.snap +++ b/src/serializer/snapshots/rustypipe__serializer__text__tests__t_attributed_description.snap @@ -19,6 +19,7 @@ SAttributed { text: "aespa 에스파 'Black ...", video_id: "Ky5RT5oGg0w", start_time: 0, + is_video: true, }, Text { text: "\n\n🎟\u{fe0f} aespa Showcase SYNK in LA! Tickets now on sale: ", diff --git a/src/serializer/text.rs b/src/serializer/text.rs index 587daf0..abe8c80 100644 --- a/src/serializer/text.rs +++ b/src/serializer/text.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Deserializer}; use serde_with::{serde_as, DeserializeAs}; use crate::{ - client::response::url_endpoint::{NavigationEndpoint, PageType}, + client::response::url_endpoint::{MusicVideoType, NavigationEndpoint, PageType}, model::UrlTarget, util, }; @@ -94,6 +94,8 @@ pub(crate) enum TextComponent { text: String, video_id: String, start_time: u32, + /// True if the item is a video, false if it is a YTM track + is_video: bool, }, Browse { text: String, @@ -164,6 +166,11 @@ fn map_text_component(text: String, nav: NavigationEndpoint) -> TextComponent { text, video_id: w.video_id, start_time: w.start_time_seconds, + is_video: w + .watch_endpoint_music_supported_configs + .watch_endpoint_music_config + .music_video_type + == MusicVideoType::Video, }, None => match nav.browse_endpoint { Some(b) => TextComponent::Browse { @@ -365,6 +372,7 @@ impl From for crate::model::richtext::TextComponent { text, video_id, start_time, + .. } => Self::YouTube { text, target: UrlTarget::Video { @@ -581,6 +589,7 @@ mod tests { text: "DEEP", video_id: "wZIoIgz5mbs", start_time: 0, + is_video: true, }, } "###); diff --git a/tests/snapshots/youtube__music_album_ep.snap b/tests/snapshots/youtube__music_album_ep.snap index dd465da..26256f6 100644 --- a/tests/snapshots/youtube__music_album_ep.snap +++ b/tests/snapshots/youtube__music_album_ep.snap @@ -39,7 +39,7 @@ MusicAlbum( track_nr: Some(1), ), TrackItem( - id: "lhPOMUjV4rE", + id: "Jz-26iiDuYs", title: "Waldbrand", duration: Some(208), cover: [], diff --git a/tests/snapshots/youtube__music_album_single.snap b/tests/snapshots/youtube__music_album_single.snap index befe8c2..aeba890 100644 --- a/tests/snapshots/youtube__music_album_single.snap +++ b/tests/snapshots/youtube__music_album_single.snap @@ -23,7 +23,7 @@ MusicAlbum( by_va: false, tracks: [ TrackItem( - id: "XX0epju-YvY", + id: "VU6lEv0PKAo", title: "Der Himmel reißt auf", duration: Some(183), cover: [], diff --git a/tests/snapshots/youtube__music_album_various_artists.snap b/tests/snapshots/youtube__music_album_various_artists.snap index 8da3db0..b04f5ea 100644 --- a/tests/snapshots/youtube__music_album_various_artists.snap +++ b/tests/snapshots/youtube__music_album_various_artists.snap @@ -14,7 +14,7 @@ MusicAlbum( by_va: true, tracks: [ TrackItem( - id: "8IqLxg0GqXc", + id: "Tzai7JXo45w", title: "Waka Boom (My Way) (feat. Lee Young Ji)", duration: Some(274), cover: [], diff --git a/tests/youtube.rs b/tests/youtube.rs index bd48b4a..4bf178f 100644 --- a/tests/youtube.rs +++ b/tests/youtube.rs @@ -1600,6 +1600,8 @@ async fn music_search_videos() { assert_next(res.items, rp.query(), 15, 2).await; } +/* +This podcast was removed from YouTube Music and I could not find another one #[tokio::test] async fn music_search_episode() { let rp = RustyPipe::builder().strict().build(); @@ -1624,6 +1626,7 @@ async fn music_search_episode() { ); assert!(!track.cover.is_empty(), "got no cover"); } +*/ #[rstest] #[case::single(