feat!: add rich text description to playlists and albums

fix: panic when parsing new music album/playlist layout
This commit is contained in:
ThetaDev 2024-03-09 14:34:58 +01:00
parent ff68cfb4e1
commit 95ab7c91c6
No known key found for this signature in database
GPG key ID: E319D3C5148D65B6
12 changed files with 146 additions and 42 deletions

View file

@ -5,9 +5,10 @@ use crate::{
error::{Error, ExtractionError},
model::{
paginator::{ContinuationEndpoint, Paginator},
richtext::RichText,
AlbumId, ChannelId, MusicAlbum, MusicPlaylist, TrackItem,
},
serializer::MapResult,
serializer::{text::TextComponents, MapResult},
util::{self, TryRemove, DOT_SEPARATOR},
};
@ -240,7 +241,7 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
channel,
h.title,
h.thumbnail.into(),
h.description.map(String::from),
h.description.map(TextComponents::from),
)
}
None => {
@ -276,7 +277,7 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
name,
thumbnail,
channel,
description,
description: description.map(RichText::from),
track_count,
from_ytm,
tracks: Paginator::new_ext(
@ -361,14 +362,14 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
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
.swap_remove(1)
.0
.first()
.map(|c| c.as_str().to_owned());
.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
@ -414,22 +415,32 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
}
}
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| {
(
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,
)
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),
)
})
@ -464,7 +475,9 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
cover: header.thumbnail.into(),
artists,
artist_id,
description: header.description.map(String::from),
description: header
.description
.map(|t| RichText::from(TextComponents::from(t))),
album_type,
year,
by_va,

View file

@ -6,8 +6,10 @@ use crate::{
error::{Error, ExtractionError},
model::{
paginator::{ContinuationEndpoint, Paginator},
richtext::RichText,
ChannelId, Playlist, VideoItem,
},
serializer::text::{TextComponent, TextComponents},
util::{self, timeago, TryRemove},
};
@ -86,7 +88,7 @@ impl MapResponse<Playlist> for response::Playlist {
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(lang);
mapper.map_response(video_items);
let (thumbnails, last_update_txt) = match self.sidebar {
let (description, thumbnails, last_update_txt) = match self.sidebar {
Some(sidebar) => {
let sidebar_items = sidebar.playlist_sidebar_renderer.contents;
let mut primary =
@ -98,6 +100,10 @@ impl MapResponse<Playlist> for response::Playlist {
)))?;
(
primary
.playlist_sidebar_primary_info_renderer
.description
.filter(|d| !d.0.is_empty()),
primary
.playlist_sidebar_primary_info_renderer
.thumbnail_renderer
@ -123,6 +129,7 @@ impl MapResponse<Playlist> for response::Playlist {
.map(|b| b.playlist_byline_renderer.text);
(
None,
header_banner.hero_playlist_thumbnail_renderer.thumbnail,
last_update_txt,
)
@ -144,7 +151,14 @@ impl MapResponse<Playlist> for response::Playlist {
}
let name = header.playlist_header_renderer.title;
let description = header.playlist_header_renderer.description_text;
let description = description
.or_else(|| {
header
.playlist_header_renderer
.description_text
.map(|text| TextComponents(vec![TextComponent::Text { text }]))
})
.map(RichText::from);
let channel = header
.playlist_header_renderer
.owner_text

View file

@ -11,11 +11,15 @@ use super::{
};
/// Response model for YouTube Music playlists and albums
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicPlaylist {
pub contents: Contents,
pub header: Option<Header>,
#[serde(default)]
#[serde_as(as = "DefaultOnError")]
pub microformat: Option<Microformat>,
}
#[serde_as]
@ -87,23 +91,20 @@ pub(crate) struct HeaderRenderer {
pub buttons: Vec<HeaderMenu>,
}
#[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,
},
Text(TextComponents),
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DescriptionShelf {
#[serde_as(as = "Text")]
pub description: String,
pub description: TextComponents,
}
#[derive(Debug, Deserialize)]
@ -123,7 +124,7 @@ pub(crate) struct HeaderMenuRenderer {
pub items: Vec<MusicItemMenuEntry>,
}
impl From<Description> for String {
impl From<Description> for TextComponents {
fn from(value: Description) -> Self {
match value {
Description::Text(v) => v,
@ -133,3 +134,15 @@ impl From<Description> for String {
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Microformat {
pub microformat_data_renderer: MicroformatData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MicroformatData {
pub url_canonical: String,
}

View file

@ -1,7 +1,7 @@
use serde::Deserialize;
use serde_with::{serde_as, DefaultOnError};
use crate::serializer::text::{Text, TextComponent};
use crate::serializer::text::{Text, TextComponent, TextComponents};
use super::{
video_item::YouTubeListRenderer, Alert, ContentsRenderer, ResponseContext, SectionList, Tab,
@ -95,6 +95,7 @@ pub(crate) struct SidebarItemPrimary {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SidebarPrimaryInfoRenderer {
pub description: Option<TextComponents>,
pub thumbnail_renderer: PlaylistThumbnailRenderer,
/// - `"495", " videos"`
/// - `"3,310,996 views"`

View file

@ -35,7 +35,11 @@ MusicAlbum(
),
],
artist_id: Some("UCRw0x9_EfawqmgDI2IgQLLg"),
description: Some("25 is the third studio album by English singer-songwriter Adele, released on 20 November 2015 by XL Recordings and Columbia Records. The album is titled as a reflection of her life and frame of mind at 25 years old and is termed a \"make-up record\". Its lyrical content features themes of Adele \"yearning for her old self, her nostalgia\", and \"melancholia about the passage of time\" according to an interview with the singer by Rolling Stone, as well as themes of motherhood and regret. In contrast to Adele\'s previous works, the production of 25 incorporated the use of electronic elements and creative rhythmic patterns, with elements of 1980s R&B and organs. Like when recording 21, Adele worked with producer and songwriter Paul Epworth and Ryan Tedder, along with new collaborations with Max Martin and Shellback, Bruno Mars, Greg Kurstin, Danger Mouse, the Smeezingtons, Samuel Dixon, and Tobias Jesso Jr.\n25 received generally positive reviews from music critics, who commended its production and Adele\'s vocal performance.\n\nFrom Wikipedia (https://en.wikipedia.org/wiki/25_(Adele_album)) under Creative Commons Attribution CC-BY-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0/legalcode)"),
description: Some(RichText([
Text(
text: "25 is the third studio album by English singer-songwriter Adele, released on 20 November 2015 by XL Recordings and Columbia Records. The album is titled as a reflection of her life and frame of mind at 25 years old and is termed a \"make-up record\". Its lyrical content features themes of Adele \"yearning for her old self, her nostalgia\", and \"melancholia about the passage of time\" according to an interview with the singer by Rolling Stone, as well as themes of motherhood and regret. In contrast to Adele\'s previous works, the production of 25 incorporated the use of electronic elements and creative rhythmic patterns, with elements of 1980s R&B and organs. Like when recording 21, Adele worked with producer and songwriter Paul Epworth and Ryan Tedder, along with new collaborations with Max Martin and Shellback, Bruno Mars, Greg Kurstin, Danger Mouse, the Smeezingtons, Samuel Dixon, and Tobias Jesso Jr.\n25 received generally positive reviews from music critics, who commended its production and Adele\'s vocal performance.\n\nFrom Wikipedia (https://en.wikipedia.org/wiki/25_(Adele_album)) under Creative Commons Attribution CC-BY-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0/legalcode)",
),
])),
album_type: Album,
year: Some(2015),
by_va: false,

View file

@ -28,7 +28,26 @@ MusicPlaylist(
),
],
channel: None,
description: Some("Kick back and coast to these chillhop and lofi beats. #hiphop #chill #beats"),
description: Some(RichText([
Text(
text: "Kick back and coast to these chillhop and lofi beats. ",
),
Text(
text: "#hiphop",
),
Text(
text: " ",
),
Text(
text: "#chill",
),
Text(
text: " ",
),
Text(
text: "#beats",
),
])),
track_count: Some(127),
from_ytm: true,
tracks: Paginator(

View file

@ -26,7 +26,11 @@ MusicPlaylist(
id: "UCQM0bS4_04-Y4JuYrgmnpZQ",
name: "Chaosflo44",
)),
description: Some("SHINE - Survival Hardcore in New Environment: Auf einem Server machen sich tapfere Spieler auf, mystische Welten zu erkunden, magische Technologien zu erforschen und vorallem zu überleben..."),
description: Some(RichText([
Text(
text: "SHINE - Survival Hardcore in New Environment: Auf einem Server machen sich tapfere Spieler auf, mystische Welten zu erkunden, magische Technologien zu erforschen und vorallem zu überleben...",
),
])),
track_count: Some(66),
from_ytm: false,
tracks: Paginator(

View file

@ -28,7 +28,11 @@ MusicPlaylist(
),
],
channel: None,
description: Some("Stress-free tunes from classic rockers and newer artists."),
description: Some(RichText([
Text(
text: "Stress-free tunes from classic rockers and newer artists.",
),
])),
track_count: Some(87),
from_ytm: true,
tracks: Paginator(

View file

@ -2741,7 +2741,11 @@ Playlist(
height: 188,
),
],
description: Some("SHINE - Survival Hardcore in New Environment: Auf einem Server machen sich tapfere Spieler auf, mystische Welten zu erkunden, magische Technologien zu erforschen und vorallem zu überleben..."),
description: Some(RichText([
Text(
text: "SHINE - Survival Hardcore in New Environment: Auf einem Server machen sich tapfere Spieler auf, mystische Welten zu erkunden, magische Technologien zu erforschen und vorallem zu überleben...",
),
])),
channel: Some(ChannelId(
id: "UCQM0bS4_04-Y4JuYrgmnpZQ",
name: "Chaosflo44",

View file

@ -513,8 +513,8 @@ pub struct Playlist {
pub video_count: u64,
/// Playlist thumbnail
pub thumbnail: Vec<Thumbnail>,
/// Playlist description in plaintext format
pub description: Option<String>,
/// Playlist description in rich text format
pub description: Option<RichText>,
/// Channel of the playlist
pub channel: Option<ChannelId>,
/// Last update date
@ -1061,8 +1061,8 @@ pub struct MusicPlaylist {
pub thumbnail: Vec<Thumbnail>,
/// Channel of the playlist
pub channel: Option<ChannelId>,
/// Playlist description in plaintext format
pub description: Option<String>,
/// Playlist description in rich text format
pub description: Option<RichText>,
/// Number of tracks in the playlist
pub track_count: Option<u64>,
/// True if the playlist is from YouTube Music
@ -1089,8 +1089,8 @@ pub struct MusicAlbum {
pub artists: Vec<ArtistId>,
/// Primary artist ID
pub artist_id: Option<String>,
/// Album description in plaintext format
pub description: Option<String>,
/// Album description in rich text format
pub description: Option<RichText>,
/// Album type (Album/Single/EP)
pub album_type: AlbumType,
/// Release year

View file

@ -14,7 +14,25 @@ MusicAlbum(
),
],
artist_id: Some("UCwem2sj-QUJCiWiPAo9JuAw"),
description: Some("Unbroken is the third studio album by American singer Demi Lovato. It was released on September 20, 2011, by Hollywood Records. Primarily a pop record, Lovato described the album as \"more mature\" and with more R&B elements than her previous material, citing Rihanna as the major influence. While some of the album\'s lyrical content was heavily influenced by Lovato\'s personal struggles, it also deals with lighter subjects, such as love, self-empowerment, and having fun. Contributions to the album\'s production came from a wide range of producers, including Toby Gad, Ryan Tedder, Timbaland, Jim Beanz and Rock Mafia.\nLovato initially began recording her third studio album in 2010 before going on tour with the Jonas Brothers on their Live in Concert Tour. After withdrawing from the tour to seek treatment for physical and emotional issues, Lovato continued work on the album and described the recording process as therapeutic. She collaborated with artists such as Missy Elliott, Timbaland, Dev, Iyaz, and Jason Derulo on several tracks.\n\nFrom Wikipedia (https://en.wikipedia.org/wiki/Unbroken_(Demi_Lovato_album)) under Creative Commons Attribution CC-BY-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0/legalcode)"),
description: Some(RichText([
Text(
text: "Unbroken is the third studio album by American singer Demi Lovato. It was released on September 20, 2011, by Hollywood Records. Primarily a pop record, Lovato described the album as \"more mature\" and with more R&B elements than her previous material, citing Rihanna as the major influence. While some of the album\'s lyrical content was heavily influenced by Lovato\'s personal struggles, it also deals with lighter subjects, such as love, self-empowerment, and having fun. Contributions to the album\'s production came from a wide range of producers, including Toby Gad, Ryan Tedder, Timbaland, Jim Beanz and Rock Mafia.\nLovato initially began recording her third studio album in 2010 before going on tour with the Jonas Brothers on their Live in Concert Tour. After withdrawing from the tour to seek treatment for physical and emotional issues, Lovato continued work on the album and described the recording process as therapeutic. She collaborated with artists such as Missy Elliott, Timbaland, Dev, Iyaz, and Jason Derulo on several tracks.\n\nFrom Wikipedia (",
),
Web(
text: "https://en.wikipedia.org/wiki/Unbroke...",
url: "https://en.wikipedia.org/wiki/Unbroken_(Demi_Lovato_album)",
),
Text(
text: ") under Creative Commons Attribution CC-BY-SA 3.0 (",
),
Web(
text: "https://creativecommons.org/licenses/...",
url: "https://creativecommons.org/licenses/by-sa/3.0/legalcode",
),
Text(
text: ")",
),
])),
album_type: Album,
year: Some(2011),
by_va: false,

View file

@ -383,7 +383,7 @@ fn get_playlist(
if is_long { 100 } else { 10 },
"track count",
);
assert_eq!(playlist.description, description);
assert_eq!(playlist.description.map(|d| d.to_plaintext()), description);
if let Some(expect) = channel {
let c = playlist.channel.expect("channel");
@ -1338,6 +1338,7 @@ fn resolve_channel_not_found(rp: RustyPipe) {
//#TRENDS
#[rstest]
#[ignore]
fn startpage(rp: RustyPipe) {
let startpage = tokio_test::block_on(rp.query().startpage()).unwrap();
@ -1403,7 +1404,7 @@ fn music_playlist(
);
if unlocalized {
assert_eq!(playlist.name, name);
assert_eq!(playlist.description, description);
assert_eq!(playlist.description.map(|d| d.to_plaintext()), description);
}
if let Some(expect) = channel {
@ -1477,7 +1478,16 @@ fn music_playlist_not_found(rp: RustyPipe) {
#[case::version_no_artist("version_no_artist", "MPREb_h8ltx5oKvyY")]
#[case::no_artist("no_artist", "MPREb_bqWA6mAZFWS")]
fn music_album(#[case] name: &str, #[case] id: &str, rp: RustyPipe, unlocalized: bool) {
let album = tokio_test::block_on(rp.query().music_album(id)).unwrap();
// TODO: remove visitor data if A/B#13 is stabilized
let album = tokio_test::block_on(
rp.query()
.visitor_data_opt(
Some("Cgs1bHFWMlhmM1ZFNCi9jK6vBjIKCgJERRIEEgAgIw%3D%3D")
.filter(|_| name == "one_artist"),
)
.music_album(id),
)
.unwrap();
assert!(!album.cover.is_empty(), "got no cover");