feat!: add rich text description to playlists and albums
fix: panic when parsing new music album/playlist layout
This commit is contained in:
parent
ff68cfb4e1
commit
95ab7c91c6
12 changed files with 146 additions and 42 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
Reference in a new issue