Compare commits

...

4 commits

14 changed files with 160 additions and 39 deletions

View file

@ -99,7 +99,10 @@ impl RustyPipeQuery {
radio_id: S,
) -> Result<Paginator<TrackItem>, 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,

View file

@ -45,14 +45,55 @@ impl RustyPipeQuery {
browse_id: album_id,
};
self.execute_request::<response::MusicPlaylist, _, _>(
ClientType::DesktopMusic,
"music_album",
album_id,
"browse",
&request_body,
)
.await
let mut album = self
.execute_request::<response::MusicPlaylist, MusicAlbum, _>(
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::<Vec<_>>();
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)
}
}

View file

@ -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))

View file

@ -35,6 +35,8 @@ pub(crate) struct WatchEndpoint {
pub playlist_id: Option<String>,
#[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,
)
}
})
})

View file

@ -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(

View file

@ -64,7 +64,7 @@ MusicAlbum(
name: "Der Himmel reißt auf",
)),
view_count: None,
is_video: false,
is_video: true,
track_nr: Some(1),
),
],

View file

@ -51,7 +51,7 @@ MusicAlbum(
name: "Queendom2 FINAL",
)),
view_count: None,
is_video: false,
is_video: true,
track_nr: Some(1),
),
TrackItem(

View file

@ -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() },

View file

@ -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: ",

View file

@ -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<TextComponent> 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,
},
}
"###);

View file

@ -39,7 +39,7 @@ MusicAlbum(
track_nr: Some(1),
),
TrackItem(
id: "lhPOMUjV4rE",
id: "Jz-26iiDuYs",
title: "Waldbrand",
duration: Some(208),
cover: [],

View file

@ -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: [],

View file

@ -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: [],

View file

@ -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(