feat: add music artists
This commit is contained in:
parent
d3aacc77aa
commit
6f07095757
49 changed files with 139065 additions and 821 deletions
|
|
@ -1,4 +1,5 @@
|
|||
pub(crate) mod channel;
|
||||
pub(crate) mod music_artist;
|
||||
pub(crate) mod music_item;
|
||||
pub(crate) mod music_playlist;
|
||||
pub(crate) mod music_search;
|
||||
|
|
@ -11,6 +12,8 @@ pub(crate) mod video_details;
|
|||
pub(crate) mod video_item;
|
||||
|
||||
pub(crate) use channel::Channel;
|
||||
pub(crate) use music_artist::MusicArtist;
|
||||
pub(crate) use music_artist::MusicArtistAlbums;
|
||||
pub(crate) use music_item::MusicContinuation;
|
||||
pub(crate) use music_playlist::MusicPlaylist;
|
||||
pub(crate) use music_search::MusicSearch;
|
||||
|
|
|
|||
143
src/client/response/music_artist.rs
Normal file
143
src/client/response/music_artist.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
use serde::Deserialize;
|
||||
use serde_with::{serde_as, DefaultOnError};
|
||||
|
||||
use crate::serializer::{ignore_any, text::Text, MapResult, VecLogError};
|
||||
|
||||
use super::{
|
||||
music_item::{MusicResponseItem, MusicShelf, MusicThumbnailRenderer},
|
||||
url_endpoint::NavigationEndpoint,
|
||||
ContentsRenderer, Tab,
|
||||
};
|
||||
|
||||
/// Response model for YouTube Music artists
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicArtist {
|
||||
pub contents: Contents<ItemSection>,
|
||||
pub header: Header,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Contents<T> {
|
||||
pub single_column_browse_results_renderer: ContentsRenderer<Tab<SectionList<T>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SectionList<T> {
|
||||
pub section_list_renderer: ContentsRenderer<T>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) enum ItemSection {
|
||||
MusicShelfRenderer(MusicShelf),
|
||||
MusicCarouselShelfRenderer {
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "DefaultOnError")]
|
||||
header: Option<MusicCarouselShelfHeader>,
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
contents: MapResult<Vec<MusicResponseItem>>,
|
||||
},
|
||||
#[serde(other, deserialize_with = "ignore_any")]
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Header {
|
||||
#[serde(alias = "musicVisualHeaderRenderer")]
|
||||
pub music_immersive_header_renderer: MusicHeaderRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicHeaderRenderer {
|
||||
#[serde_as(as = "Text")]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "DefaultOnError")]
|
||||
pub subscription_button: Option<SubscriptionButton>,
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "Text")]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub thumbnail: MusicThumbnailRenderer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SubscriptionButton {
|
||||
pub subscribe_button_renderer: SubscriptionButtonRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SubscriptionButtonRenderer {
|
||||
#[serde_as(as = "Text")]
|
||||
pub subscriber_count_text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicCarouselShelfHeader {
|
||||
pub music_carousel_shelf_basic_header_renderer: MusicCarouselShelfHeaderRenderer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicCarouselShelfHeaderRenderer {
|
||||
pub more_content_button: MoreContentButton,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MoreContentButton {
|
||||
pub button_renderer: ButtonRenderer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ButtonRenderer {
|
||||
pub navigation_endpoint: NavigationEndpoint,
|
||||
}
|
||||
|
||||
/// Response model for YouTube Music artist album page
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicArtistAlbums {
|
||||
pub header: SimpleHeader,
|
||||
pub contents: Contents<Grid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Grid {
|
||||
pub grid_renderer: GridRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GridRenderer {
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
pub items: MapResult<Vec<MusicResponseItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SimpleHeader {
|
||||
pub music_header_renderer: SimpleHeaderRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SimpleHeaderRenderer {
|
||||
#[serde_as(as = "Text")]
|
||||
pub title: String,
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ use crate::{
|
|||
};
|
||||
|
||||
use super::{
|
||||
url_endpoint::{NavigationEndpoint, PageType},
|
||||
url_endpoint::{BrowseEndpointWrap, NavigationEndpoint, PageType},
|
||||
MusicContinuationData, ThumbnailsWrap,
|
||||
};
|
||||
|
||||
|
|
@ -31,6 +31,10 @@ pub(crate) struct MusicShelf {
|
|||
#[serde(default)]
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub continuations: Vec<MusicContinuationData>,
|
||||
/// "More" button at the bottom (artist pages)
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "DefaultOnError")]
|
||||
pub bottom_endpoint: Option<BrowseEndpointWrap>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -222,23 +226,19 @@ impl MusicListMapper {
|
|||
}
|
||||
}
|
||||
|
||||
/*
|
||||
pub fn with_artists(
|
||||
lang: Language,
|
||||
artists: Vec<ArtistId>,
|
||||
by_va: bool,
|
||||
artist_page: bool,
|
||||
) -> Self {
|
||||
/// Create a new MusicListMapper for an artist page
|
||||
pub fn with_artist(lang: Language, artist: ArtistId) -> Self {
|
||||
Self {
|
||||
lang,
|
||||
artists: Some((artists, by_va)),
|
||||
artists: Some((vec![artist], false)),
|
||||
album: None,
|
||||
artist_page,
|
||||
artist_page: true,
|
||||
items: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
/// Create a new MusicListMapper for an album page
|
||||
pub fn with_album(lang: Language, artists: Vec<ArtistId>, by_va: bool, album: AlbumId) -> Self {
|
||||
Self {
|
||||
lang,
|
||||
|
|
@ -399,9 +399,8 @@ impl MusicListMapper {
|
|||
}
|
||||
};
|
||||
|
||||
let duration = duration_p
|
||||
.and_then(|p| util::parse_video_length(p.first_str()))
|
||||
.ok_or_else(|| format!("track {}: could not parse duration", id))?;
|
||||
let duration =
|
||||
duration_p.and_then(|p| util::parse_video_length(p.first_str()));
|
||||
|
||||
let (album, view_count) = match (item.flex_column_display_style, is_video) {
|
||||
// The album field contains the view count for search videos
|
||||
|
|
@ -455,87 +454,117 @@ impl MusicListMapper {
|
|||
let subtitle_p2 = subtitle_parts.next();
|
||||
let subtitle_p3 = subtitle_parts.next();
|
||||
|
||||
let (page_type, id) = item
|
||||
.navigation_endpoint
|
||||
.music_page()
|
||||
.ok_or_else(|| "could not get navigation endpoint".to_owned())?;
|
||||
|
||||
match page_type {
|
||||
PageType::Album => {
|
||||
let mut year = None;
|
||||
let mut album_type = AlbumType::Single;
|
||||
|
||||
let (artists, by_va) =
|
||||
match (subtitle_p1, subtitle_p2, &self.artists, self.artist_page) {
|
||||
// "2022" (Artist singles)
|
||||
(Some(year_txt), None, Some(artists), true) => {
|
||||
year = util::parse_numeric(year_txt.first_str()).ok();
|
||||
artists.clone()
|
||||
}
|
||||
// "Album", "2022" (Artist albums)
|
||||
(Some(atype_txt), Some(year_txt), Some(artists), true) => {
|
||||
year = util::parse_numeric(year_txt.first_str()).ok();
|
||||
album_type = map_album_type(atype_txt.first_str(), self.lang);
|
||||
artists.clone()
|
||||
}
|
||||
// "Album", <"Oonagh"> (Album variants, new releases)
|
||||
(Some(atype_txt), Some(p2), _, false) => {
|
||||
album_type = map_album_type(atype_txt.first_str(), self.lang);
|
||||
map_artists(Some(p2))
|
||||
}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"could not parse subtitle of album {}",
|
||||
id
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
self.items.push(MusicItem::Album(AlbumItem {
|
||||
id,
|
||||
name: item.title,
|
||||
match item.navigation_endpoint.watch_endpoint {
|
||||
// Music video
|
||||
Some(wep) => {
|
||||
self.items.push(MusicItem::Track(TrackItem {
|
||||
id: wep.video_id,
|
||||
title: item.title,
|
||||
duration: None,
|
||||
cover: item.thumbnail_renderer.into(),
|
||||
artists,
|
||||
album_type,
|
||||
year,
|
||||
by_va,
|
||||
artists: map_artists(subtitle_p1).0,
|
||||
album: None,
|
||||
view_count: subtitle_p2
|
||||
.and_then(|c| util::parse_large_numstr(c.first_str(), self.lang)),
|
||||
is_video: true,
|
||||
track_nr: None,
|
||||
}));
|
||||
Ok(MusicEntityType::Album)
|
||||
Ok(MusicEntityType::Track)
|
||||
}
|
||||
PageType::Playlist => {
|
||||
let from_ytm = subtitle_p2
|
||||
.as_ref()
|
||||
.map(|p| p.first_str() == util::YT_MUSIC_NAME)
|
||||
.unwrap_or_default();
|
||||
let channel = subtitle_p2.and_then(|p| {
|
||||
p.0.into_iter().find_map(|c| ChannelId::try_from(c).ok())
|
||||
});
|
||||
let track_count =
|
||||
subtitle_p3.and_then(|p| util::parse_numeric(p.first_str()).ok());
|
||||
// Artist / Album / Playlist
|
||||
None => {
|
||||
let (page_type, id) = item
|
||||
.navigation_endpoint
|
||||
.music_page()
|
||||
.ok_or_else(|| "could not get navigation endpoint".to_owned())?;
|
||||
|
||||
self.items.push(MusicItem::Playlist(MusicPlaylistItem {
|
||||
id,
|
||||
name: item.title,
|
||||
thumbnail: item.thumbnail_renderer.into(),
|
||||
channel,
|
||||
track_count,
|
||||
from_ytm,
|
||||
}));
|
||||
Ok(MusicEntityType::Playlist)
|
||||
}
|
||||
PageType::Artist => {
|
||||
let subscriber_count = subtitle_p1
|
||||
.and_then(|p| util::parse_large_numstr(p.first_str(), self.lang));
|
||||
match page_type {
|
||||
PageType::Album => {
|
||||
let mut year = None;
|
||||
let mut album_type = AlbumType::Single;
|
||||
|
||||
self.items.push(MusicItem::Artist(ArtistItem {
|
||||
id,
|
||||
name: item.title,
|
||||
avatar: item.thumbnail_renderer.into(),
|
||||
subscriber_count,
|
||||
}));
|
||||
Ok(MusicEntityType::Artist)
|
||||
let (artists, by_va) = match (
|
||||
subtitle_p1,
|
||||
subtitle_p2,
|
||||
&self.artists,
|
||||
self.artist_page,
|
||||
) {
|
||||
// "2022" (Artist singles)
|
||||
(Some(year_txt), None, Some(artists), true) => {
|
||||
year = util::parse_numeric(year_txt.first_str()).ok();
|
||||
artists.clone()
|
||||
}
|
||||
// "Album", "2022" (Artist albums)
|
||||
(Some(atype_txt), Some(year_txt), Some(artists), true) => {
|
||||
year = util::parse_numeric(year_txt.first_str()).ok();
|
||||
album_type =
|
||||
map_album_type(atype_txt.first_str(), self.lang);
|
||||
artists.clone()
|
||||
}
|
||||
// "Album", <"Oonagh"> (Album variants, new releases)
|
||||
(Some(atype_txt), Some(p2), _, false) => {
|
||||
album_type =
|
||||
map_album_type(atype_txt.first_str(), self.lang);
|
||||
map_artists(Some(p2))
|
||||
}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"could not parse subtitle of album {}",
|
||||
id
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
self.items.push(MusicItem::Album(AlbumItem {
|
||||
id,
|
||||
name: item.title,
|
||||
cover: item.thumbnail_renderer.into(),
|
||||
artists,
|
||||
album_type,
|
||||
year,
|
||||
by_va,
|
||||
}));
|
||||
Ok(MusicEntityType::Album)
|
||||
}
|
||||
PageType::Playlist => {
|
||||
let from_ytm = subtitle_p2
|
||||
.as_ref()
|
||||
.map(|p| p.first_str() == util::YT_MUSIC_NAME)
|
||||
.unwrap_or_default();
|
||||
let channel = subtitle_p2.and_then(|p| {
|
||||
p.0.into_iter().find_map(|c| ChannelId::try_from(c).ok())
|
||||
});
|
||||
let track_count = subtitle_p3
|
||||
.and_then(|p| util::parse_numeric(p.first_str()).ok());
|
||||
|
||||
self.items.push(MusicItem::Playlist(MusicPlaylistItem {
|
||||
id,
|
||||
name: item.title,
|
||||
thumbnail: item.thumbnail_renderer.into(),
|
||||
channel,
|
||||
track_count,
|
||||
from_ytm,
|
||||
}));
|
||||
Ok(MusicEntityType::Playlist)
|
||||
}
|
||||
PageType::Artist => {
|
||||
let subscriber_count = subtitle_p1.and_then(|p| {
|
||||
util::parse_large_numstr(p.first_str(), self.lang)
|
||||
});
|
||||
|
||||
self.items.push(MusicItem::Artist(ArtistItem {
|
||||
id,
|
||||
name: item.title,
|
||||
avatar: item.thumbnail_renderer.into(),
|
||||
subscriber_count,
|
||||
}));
|
||||
Ok(MusicEntityType::Artist)
|
||||
}
|
||||
PageType::Channel => {
|
||||
Err(format!("channel items unsupported. id: {}", id))
|
||||
}
|
||||
}
|
||||
}
|
||||
PageType::Channel => Err(format!("channel items unsupported. id: {}", id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,9 +39,16 @@ pub(crate) struct WatchEndpoint {
|
|||
#[derive(Debug)]
|
||||
pub(crate) struct BrowseEndpoint {
|
||||
pub browse_id: String,
|
||||
pub params: String,
|
||||
pub browse_endpoint_context_supported_configs: Option<BrowseEndpointConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BrowseEndpointWrap {
|
||||
pub browse_endpoint: BrowseEndpoint,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for BrowseEndpoint {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
|
|
@ -51,6 +58,8 @@ impl<'de> Deserialize<'de> for BrowseEndpoint {
|
|||
#[serde(rename_all = "camelCase")]
|
||||
struct BEp {
|
||||
pub browse_id: String,
|
||||
#[serde(default)]
|
||||
pub params: String,
|
||||
pub browse_endpoint_context_supported_configs: Option<BrowseEndpointConfig>,
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +80,7 @@ impl<'de> Deserialize<'de> for BrowseEndpoint {
|
|||
|
||||
Ok(Self {
|
||||
browse_id,
|
||||
params: bep.params,
|
||||
browse_endpoint_context_supported_configs: bep
|
||||
.browse_endpoint_context_supported_configs,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use crate::serializer::{
|
|||
};
|
||||
|
||||
use super::{
|
||||
url_endpoint::BrowseEndpoint, ContinuationEndpoint, ContinuationItemRenderer, Icon,
|
||||
url_endpoint::BrowseEndpointWrap, ContinuationEndpoint, ContinuationItemRenderer, Icon,
|
||||
MusicContinuationData, Thumbnails,
|
||||
};
|
||||
use super::{ChannelBadge, ResponseContext, YouTubeListItem};
|
||||
|
|
@ -525,7 +525,7 @@ pub(crate) struct CommentRenderer {
|
|||
/// ID of the author's channel
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "DefaultOnError")]
|
||||
pub author_endpoint: Option<AuthorEndpoint>,
|
||||
pub author_endpoint: Option<BrowseEndpointWrap>,
|
||||
/// Comment text
|
||||
pub content_text: TextComponents,
|
||||
/// Textual publish date (e.g. `15 minutes ago`, `2 days ago`)
|
||||
|
|
@ -542,12 +542,6 @@ pub(crate) struct CommentRenderer {
|
|||
pub action_buttons: CommentActionButtons,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct AuthorEndpoint {
|
||||
pub browse_endpoint: BrowseEndpoint,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub(crate) enum CommentPriority {
|
||||
|
|
|
|||
Reference in a new issue