This repository has been archived on 2026-05-27. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
rustypipe/src/client/response/music_item.rs
2023-05-07 18:15:13 +02:00

1241 lines
46 KiB
Rust

use serde::Deserialize;
use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError};
use crate::{
model::{
self, traits::FromYtItem, AlbumId, AlbumItem, AlbumType, ArtistId, ArtistItem, ChannelId,
MusicItem, MusicItemType, MusicPlaylistItem, TrackItem,
},
param::Language,
serializer::{
text::{Text, TextComponents},
MapResult,
},
util::{self, dictionary},
};
use super::{
url_endpoint::{BrowseEndpointWrap, MusicPageType, NavigationEndpoint, PageType},
ContentsRenderer, MusicContinuationData, Thumbnails, ThumbnailsWrap,
};
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum ItemSection {
#[serde(alias = "musicPlaylistShelfRenderer")]
MusicShelfRenderer(MusicShelf),
MusicCarouselShelfRenderer(MusicCarouselShelf),
GridRenderer(GridRenderer),
#[serde(other, deserialize_with = "deserialize_ignore_any")]
None,
}
/// MusicShelf represents the standard, vertical list of music items
/// (used in search results, playlist, album).
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicShelf {
/// Playlist ID (only for playlists)
pub playlist_id: Option<String>,
pub contents: MapResult<Vec<MusicResponseItem>>,
/// Continuation token for fetching more (>100) playlist items
#[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>,
}
/// MusicCarouselShelf represents a horizontal list of music items displayed with
/// large covers.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicCarouselShelf {
pub header: Option<MusicCarouselShelfHeader>,
pub contents: MapResult<Vec<MusicResponseItem>>,
}
/// MusicCardShelf is used to display the top search result. It contains
/// one main item and optionally a list of sub-items (like an artist + top tracks).
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicCardShelf {
#[serde_as(as = "Text")]
pub title: String,
pub on_tap: NavigationEndpoint,
#[serde(default)]
pub subtitle: TextComponents,
#[serde(default)]
pub thumbnail: MusicThumbnailRenderer,
#[serde(default)]
pub contents: MapResult<Vec<MusicResponseItem>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum MusicResponseItem {
MusicResponsiveListItemRenderer(ListMusicItem),
MusicTwoRowItemRenderer(CoverMusicItem),
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ListMusicItem {
#[serde(default)]
pub thumbnail: MusicThumbnailRenderer,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub playlist_item_data: Option<PlaylistItemData>,
/// ### Playlist track
///
/// `[<"Das Beste">], [<"Silbermond">], [<"Laut Gedacht (Re-Edition)">]`
///
/// (title, artist, album)
///
/// ### Album track
///
/// `[<"Der Himmel reißt auf">]`
///
/// (title)
///
/// ### Search track
///
/// `[<"Girls">], ["Song", " • ", <"aespa">, " • ", <"Girls - The 2nd Mini Album">, " • ", "4:01"]`
///
/// (title, artist, album, duration)
///
/// Info: "Song" label is missing in the "Songs" tab
///
/// ### Search video
///
/// `[<"Black Mamba">], ["Video", " • ", <"aespa">, " • ", "235M views", " • ", "3:50"]`
///
/// (title, artist, view count, duration)
///
/// Info: "Video" label is missing in the "Videos" tab
///
/// ### Search podcast episode
///
/// `["Blond - Da muss man dabei..."], ["Episode", " • ", "Dec 24, 2020", " • ", <"BLOND_OFFICIAL">], ["Dec 24, 2020"]`
///
/// (title, date, artist, date again?)
///
/// Info: "Episode" label is missing in the "Videos" tab
///
/// ### Search album
///
/// `["Next Level"], ["Single", " • ", <"aespa">, " • ", "2021"]`
///
/// (title, type, artist, year)
///
/// ### Search artist
///
/// `["Test Shot Starfish"], ["Artist", " • ", "1660 subscribers"]`
///
/// (subscriber count)
///
/// ### Search playlist
///
/// `["aespa - All Songs & MV"], ["Playlist", " • ", <"Jerwen">, " • ", "49 songs"]`
///
/// (title, creator, track count)
///
/// Info: "Playlist" label is missing in the "Playlists" tab
pub flex_columns: Vec<MusicColumn>,
/// Track duration (playlist/album tracks)
///
/// `"3:32"`
#[serde(default)]
pub fixed_columns: Vec<MusicColumn>,
/// Content type + ID (for non-track search items)
pub navigation_endpoint: Option<NavigationEndpoint>,
#[serde(default)]
pub flex_column_display_style: FlexColumnDisplayStyle,
#[serde(default)]
pub item_height: ItemHeight,
#[serde(default)]
pub music_item_renderer_display_policy: DisplayPolicy,
/// Album track number
#[serde_as(as = "Option<Text>")]
pub index: Option<String>,
pub menu: Option<MusicItemMenu>,
}
#[derive(Default, Debug, Copy, Clone, Deserialize)]
pub(crate) enum FlexColumnDisplayStyle {
#[serde(rename = "MUSIC_RESPONSIVE_LIST_ITEM_FLEX_COLUMN_DISPLAY_STYLE_TWO_LINE_STACK")]
TwoLines,
#[default]
#[serde(other)]
Default,
}
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Deserialize)]
pub(crate) enum ItemHeight {
#[serde(rename = "MUSIC_RESPONSIVE_LIST_ITEM_HEIGHT_MEDIUM_COMPACT")]
Compact,
#[default]
#[serde(other)]
Default,
}
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Deserialize)]
pub(crate) enum DisplayPolicy {
#[serde(rename = "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")]
GreyOut,
#[default]
#[serde(other)]
Default,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CoverMusicItem {
#[serde_as(as = "Text")]
pub title: String,
/// Content type + Channel/Artist
///
/// `"Album", " • ", <"Oonagh">` Album variants, new releases
///
/// `"Album", " • ", "2022"` Artist albums
///
/// `"2022"` Artist singles
///
/// `"Playlist", " • ", <"ThetaDev"> " • ", "26 songs"`
///
/// `"Playlist", " • ", "YouTube Music" Featured on
#[serde(default)]
pub subtitle: TextComponents,
#[serde(default)]
pub thumbnail_renderer: MusicThumbnailRenderer,
/// Content type + ID
pub navigation_endpoint: NavigationEndpoint,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistPanelRenderer {
pub contents: MapResult<Vec<PlaylistPanelVideo>>,
/// Continuation token for fetching more radio items
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub continuations: Vec<MusicContinuationData>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum PlaylistPanelVideo {
PlaylistPanelVideoRenderer(QueueMusicItem),
#[serde(other, deserialize_with = "deserialize_ignore_any")]
None,
}
/// Music item from a playback queue (`playlistPanelVideoRenderer`)
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct QueueMusicItem {
pub video_id: String,
#[serde_as(as = "Text")]
pub title: String,
#[serde_as(as = "Option<Text>")]
pub length_text: Option<String>,
/// Artist + Album + Year (for tracks)
/// `<"IVE">, " • ", <"LOVE DIVE (LOVE DIVE)">, " • ", "2022"`
///
/// Artist + view count + like count (for videos)
/// `<"aespa">, " • ", "250M views", " • ", "3.6M likes"`
#[serde(default)]
pub long_byline_text: TextComponents,
#[serde(default)]
pub thumbnail: Thumbnails,
pub menu: Option<MusicItemMenu>,
}
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicThumbnailRenderer {
#[serde(alias = "croppedSquareThumbnailRenderer")]
pub music_thumbnail_renderer: ThumbnailsWrap,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistItemData {
pub video_id: String,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicContentsRenderer<T> {
pub contents: Vec<T>,
/// Continuation token for fetching recommended items
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub continuations: Vec<MusicContinuationData>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct MusicColumn {
#[serde(
rename = "musicResponsiveListItemFlexColumnRenderer",
alias = "musicResponsiveListItemFixedColumnRenderer"
)]
pub renderer: MusicColumnRenderer,
}
#[serde_as]
#[derive(Debug, Deserialize)]
pub(crate) struct MusicColumnRenderer {
pub text: TextComponents,
}
impl From<MusicColumn> for TextComponents {
fn from(col: MusicColumn) -> Self {
col.renderer.text
}
}
impl From<MusicThumbnailRenderer> for Vec<model::Thumbnail> {
fn from(tr: MusicThumbnailRenderer) -> Self {
tr.music_thumbnail_renderer.thumbnail.into()
}
}
/// Music list continuation response model
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicContinuation {
pub continuation_contents: Option<ContinuationContents>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(clippy::enum_variant_names)]
pub(crate) enum ContinuationContents {
#[serde(alias = "musicPlaylistShelfContinuation")]
MusicShelfContinuation(MusicShelf),
SectionListContinuation(ContentsRenderer<ItemSection>),
PlaylistPanelContinuation(PlaylistPanelRenderer),
}
#[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: Option<Button>,
#[serde(default)]
pub title: TextComponents,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Button {
pub button_renderer: ButtonRenderer,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ButtonRenderer {
pub navigation_endpoint: NavigationEndpoint,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicItemMenu {
pub menu_renderer: ContentsRenderer<MusicItemMenuEntry>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicItemMenuEntry {
pub menu_navigation_item_renderer: ButtonRenderer,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Grid {
pub grid_renderer: GridRenderer,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GridRenderer {
pub items: MapResult<Vec<MusicResponseItem>>,
pub header: Option<GridHeader>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GridHeader {
pub grid_header_renderer: GridHeaderRenderer,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GridHeaderRenderer {
#[serde_as(as = "Text")]
pub title: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SingleColumnBrowseResult<T> {
pub single_column_browse_results_renderer: ContentsRenderer<T>,
}
#[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,
}
/*
#MAPPER
*/
#[derive(Debug)]
pub(crate) struct MusicListMapper {
lang: Language,
/// Artists list + various artists flag
artists: Option<(Vec<ArtistId>, bool)>,
album: Option<AlbumId>,
artist_page: bool,
items: Vec<MusicItem>,
warnings: Vec<String>,
}
#[derive(Debug)]
pub(crate) struct GroupedMusicItems {
pub tracks: Vec<TrackItem>,
pub albums: Vec<AlbumItem>,
pub artists: Vec<ArtistItem>,
pub playlists: Vec<MusicPlaylistItem>,
}
impl MusicListMapper {
pub fn new(lang: Language) -> Self {
Self {
lang,
artists: None,
album: None,
artist_page: false,
items: Vec::new(),
warnings: Vec::new(),
}
}
/// Create a new MusicListMapper for an artist page
pub fn with_artist(lang: Language, artist: ArtistId) -> Self {
Self {
lang,
artists: Some((vec![artist], false)),
album: None,
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,
artists: Some((artists, by_va)),
album: Some(album),
artist_page: false,
items: Vec::new(),
warnings: Vec::new(),
}
}
fn map_item(&mut self, item: MusicResponseItem) -> Result<Option<MusicItemType>, String> {
match item {
// List item
MusicResponseItem::MusicResponsiveListItemRenderer(item) => {
let mut columns = item.flex_columns.into_iter();
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
.thumbnail
.thumbnails
.first();
let pt_id = item
.navigation_endpoint
.and_then(|ne| ne.music_page())
.or_else(|| {
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 {
is_video: self.album.is_none() && tn.width != tn.height,
},
id,
)
})
})
});
match pt_id {
// Track
Some((MusicPageType::Track { is_video }, id)) => {
let title =
title.ok_or_else(|| format!("track {id}: could not get title"))?;
let (artists_p, album_p, duration_p) = match item.flex_column_display_style
{
// Search result
FlexColumnDisplayStyle::TwoLines => {
// Is this a related track?
if !is_video && item.item_height == ItemHeight::Compact {
(
c2.map(TextComponents::from),
c3.map(TextComponents::from),
None,
)
} else {
let mut subtitle_parts = c2
.ok_or_else(|| {
format!("track {id}: could not get subtitle")
})?
.renderer
.text
.split(util::DOT_SEPARATOR)
.into_iter();
// Is this a related video?
if item.item_height == ItemHeight::Compact {
(subtitle_parts.next(), subtitle_parts.next(), None)
}
// Is it a podcast episode?
else if subtitle_parts.len() <= 3 && c3.is_some() {
(subtitle_parts.rev().next(), None, None)
} else {
// Skip first part (track type)
if subtitle_parts.len() > 3
|| (is_video && subtitle_parts.len() == 2)
{
subtitle_parts.next();
}
(
subtitle_parts.next(),
subtitle_parts.next(),
subtitle_parts.next(),
)
}
}
}
// Playlist item
FlexColumnDisplayStyle::Default => (
c2.map(TextComponents::from),
c3.map(TextComponents::from),
item.fixed_columns
.into_iter()
.next()
.map(TextComponents::from),
),
};
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
(FlexColumnDisplayStyle::TwoLines, true) => (
None,
album_p.and_then(|p| {
util::parse_large_numstr_or_warn(
p.first_str(),
self.lang,
&mut self.warnings,
)
}),
),
(_, false) => (
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, by_va) = map_artists(artists_p);
// Extract artist id from dropdown menu
let artist_id = map_artist_id_fallback(item.menu, artists.first());
// Fall back to the artist given when constructing the mapper.
// This is used for extracting artist pages.
// On some albums, the artist name of the tracks is not given but different
// from the album artist. In this case dont copy the album artist.
if let Some((fb_artists, _)) = &self.artists {
if artists.is_empty()
&& (self.artist_page
|| artist_id.is_none()
|| fb_artists.iter().any(|fb_id| {
fb_id
.id
.as_deref()
.map(|aid| artist_id.as_deref() == Some(aid))
.unwrap_or_default()
}))
{
artists = fb_artists.clone();
}
}
let track_nr = item.index.and_then(|txt| util::parse_numeric(&txt).ok());
self.items.push(MusicItem::Track(TrackItem {
id,
name: title,
duration,
cover: item.thumbnail.into(),
artists,
artist_id,
album,
view_count,
is_video,
track_nr,
by_va,
}));
Ok(Some(MusicItemType::Track))
}
// Artist / Album / Playlist
Some((page_type, id)) => {
let mut subtitle_parts = c2
.ok_or_else(|| "could not get subtitle".to_owned())?
.renderer
.text
.split(util::DOT_SEPARATOR)
.into_iter();
let title =
title.ok_or_else(|| format!("track {id}: could not get title"))?;
let subtitle_p1 = subtitle_parts.next();
let subtitle_p2 = subtitle_parts.next();
let subtitle_p3 = subtitle_parts.next();
match page_type {
MusicPageType::Artist => {
let subscriber_count = subtitle_p2.and_then(|p| {
util::parse_large_numstr_or_warn(
p.first_str(),
self.lang,
&mut self.warnings,
)
});
self.items.push(MusicItem::Artist(ArtistItem {
id,
name: title,
avatar: item.thumbnail.into(),
subscriber_count,
}));
Ok(Some(MusicItemType::Artist))
}
MusicPageType::Album => {
let album_type = subtitle_p1
.map(|st| map_album_type(st.first_str(), self.lang))
.unwrap_or_default();
let (artists, by_va) = map_artists(subtitle_p2);
let artist_id = map_artist_id_fallback(item.menu, artists.first());
let year = subtitle_p3
.and_then(|st| util::parse_numeric(st.first_str()).ok());
self.items.push(MusicItem::Album(AlbumItem {
id,
name: title,
cover: item.thumbnail.into(),
artists,
artist_id,
album_type,
year,
by_va,
}));
Ok(Some(MusicItemType::Album))
}
MusicPageType::Playlist => {
// Part 1 may be the "Playlist" label
let (channel_p, tcount_p) = match subtitle_p3 {
Some(_) => (subtitle_p2, subtitle_p3),
None => (subtitle_p1, subtitle_p2),
};
let from_ytm = channel_p
.as_ref()
.and_then(|p| p.0.first())
.map(util::is_ytm)
.unwrap_or_default();
let channel = channel_p.and_then(|p| {
p.0.into_iter().find_map(|c| ChannelId::try_from(c).ok())
});
let track_count =
tcount_p.and_then(|p| util::parse_numeric(p.first_str()).ok());
self.items.push(MusicItem::Playlist(MusicPlaylistItem {
id,
name: title,
thumbnail: item.thumbnail.into(),
channel,
track_count,
from_ytm,
}));
Ok(Some(MusicItemType::Playlist))
}
MusicPageType::None => {
// There may be broken YT channels from the artist search. They can be skipped.
Ok(None)
}
// Tracks were already handled above
MusicPageType::Track { .. } => unreachable!(),
}
}
None => {
if item.music_item_renderer_display_policy == DisplayPolicy::GreyOut {
Ok(None)
} else {
Err("could not determine item type".to_owned())
}
}
}
}
// Tile
MusicResponseItem::MusicTwoRowItemRenderer(item) => {
let mut subtitle_parts = item.subtitle.split(util::DOT_SEPARATOR).into_iter();
let subtitle_p1 = subtitle_parts.next();
let subtitle_p2 = subtitle_parts.next();
let subtitle_p3 = subtitle_parts.next();
match item.navigation_endpoint.music_page() {
Some((page_type, id)) => match page_type {
MusicPageType::Track { is_video } => {
let (artists, by_va) = map_artists(subtitle_p1);
self.items.push(MusicItem::Track(TrackItem {
id,
name: item.title,
duration: None,
cover: item.thumbnail_renderer.into(),
artist_id: artists.first().and_then(|a| a.id.to_owned()),
artists,
album: None,
view_count: subtitle_p2.and_then(|c| {
util::parse_large_numstr_or_warn(
c.first_str(),
self.lang,
&mut self.warnings,
)
}),
is_video,
track_nr: None,
by_va,
}));
Ok(Some(MusicItemType::Track))
}
MusicPageType::Artist => {
let subscriber_count = subtitle_p1.and_then(|p| {
util::parse_large_numstr_or_warn(
p.first_str(),
self.lang,
&mut self.warnings,
)
});
self.items.push(MusicItem::Artist(ArtistItem {
id,
name: item.title,
avatar: item.thumbnail_renderer.into(),
subscriber_count,
}));
Ok(Some(MusicItemType::Artist))
}
MusicPageType::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 on artist page with unknown year
(None, None, Some(artists), true) => 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))
}
// "Album" (Album variants, no artist)
(Some(atype_txt), None, _, false) => {
album_type =
map_album_type(atype_txt.first_str(), self.lang);
(Vec::new(), true)
}
_ => {
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(),
artist_id: artists.first().and_then(|a| a.id.to_owned()),
artists,
album_type,
year,
by_va,
}));
Ok(Some(MusicItemType::Album))
}
MusicPageType::Playlist => {
// When the playlist subtitle has only 1 part, it is a playlist from YT Music
// (featured on the startpage or in genres)
let from_ytm = subtitle_p2
.as_ref()
.and_then(|p| p.0.first())
.map(util::is_ytm)
.unwrap_or(true);
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(Some(MusicItemType::Playlist))
}
MusicPageType::None => Ok(None),
},
None => Err("could not determine item type".to_owned()),
}
}
}
}
pub fn map_response(
&mut self,
mut res: MapResult<Vec<MusicResponseItem>>,
) -> Option<MusicItemType> {
let mut etype = None;
self.warnings.append(&mut res.warnings);
res.c.into_iter().for_each(|item| {
if let Some(et) = self.add_response_item(item) {
if etype.is_none() {
etype = Some(et);
}
}
});
etype
}
pub fn map_card(&mut self, card: MusicCardShelf) -> Option<MusicItemType> {
/*
"Artist" " • " "<subscriber count>"
"Album" " • " "<artist>"
"Song" " • " "<artist>" " • " "<album>" " • " "<duration>"
"Video" " • " "<artist>" " • " "<view count>" " • " "<duration>"
"Playlist" " • " "<author>" " • " "<track count>" (guessed)
*/
let mut subtitle_parts = card.subtitle.split(util::DOT_SEPARATOR).into_iter();
let subtitle_p1 = subtitle_parts.next();
let subtitle_p2 = subtitle_parts.next();
let subtitle_p3 = subtitle_parts.next();
let subtitle_p4 = subtitle_parts.next();
let item_type = match card.on_tap.music_page() {
Some((page_type, id)) => match page_type {
MusicPageType::Artist => {
let subscriber_count = subtitle_p2.and_then(|p| {
util::parse_large_numstr_or_warn(
p.first_str(),
self.lang,
&mut self.warnings,
)
});
self.items.push(MusicItem::Artist(ArtistItem {
id,
name: card.title,
avatar: card.thumbnail.into(),
subscriber_count,
}));
Some(MusicItemType::Artist)
}
MusicPageType::Album => {
let (artists, by_va) = map_artists(subtitle_p2);
let album_type = subtitle_p1
.map(|p| map_album_type(p.first_str(), self.lang))
.unwrap_or_default();
self.items.push(MusicItem::Album(AlbumItem {
id,
name: card.title,
cover: card.thumbnail.into(),
artist_id: artists.first().and_then(|a| a.id.to_owned()),
artists,
album_type,
year: subtitle_p3.and_then(|y| util::parse_numeric(y.first_str()).ok()),
by_va,
}));
Some(MusicItemType::Album)
}
MusicPageType::Track { is_video } => {
let (artists, by_va) = map_artists(subtitle_p2);
let duration =
subtitle_p4.and_then(|p| util::parse_video_length(p.first_str()));
let (album, view_count) = if is_video {
(
None,
subtitle_p3.and_then(|p| {
util::parse_large_numstr_or_warn(
p.first_str(),
self.lang,
&mut self.warnings,
)
}),
)
} else {
(
subtitle_p3.and_then(|p| {
p.0.into_iter().find_map(|c| AlbumId::try_from(c).ok())
}),
None,
)
};
self.items.push(MusicItem::Track(TrackItem {
id,
name: card.title,
duration,
cover: card.thumbnail.into(),
artist_id: artists.first().and_then(|a| a.id.to_owned()),
artists,
album,
view_count,
is_video,
track_nr: None,
by_va,
}));
Some(MusicItemType::Track)
}
MusicPageType::Playlist => {
let from_ytm = subtitle_p2
.as_ref()
.and_then(|p| p.0.first())
.map(util::is_ytm)
.unwrap_or(true);
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: card.title,
thumbnail: card.thumbnail.into(),
channel,
track_count,
from_ytm,
}));
Some(MusicItemType::Playlist)
}
MusicPageType::None => None,
},
None => {
self.warnings
.push("could not determine item type".to_owned());
None
}
};
self.map_response(card.contents);
item_type
}
pub fn add_item(&mut self, item: MusicItem) {
self.items.push(item);
}
pub fn add_response_item(&mut self, item: MusicResponseItem) -> Option<MusicItemType> {
match self.map_item(item) {
Ok(et) => et,
Err(e) => {
self.warnings.push(e);
None
}
}
}
pub fn add_warnings(&mut self, warnings: &mut Vec<String>) {
self.warnings.append(warnings);
}
pub fn items(self) -> MapResult<Vec<MusicItem>> {
MapResult {
c: self.items,
warnings: self.warnings,
}
}
pub fn conv_items<T: FromYtItem>(self) -> MapResult<Vec<T>> {
MapResult {
c: self
.items
.into_iter()
.filter_map(T::from_ytm_item)
.collect(),
warnings: self.warnings,
}
}
pub fn group_items(self) -> MapResult<GroupedMusicItems> {
let mut tracks = Vec::new();
let mut albums = Vec::new();
let mut artists = Vec::new();
let mut playlists = Vec::new();
for item in self.items {
match item {
MusicItem::Track(track) => tracks.push(track),
MusicItem::Album(album) => albums.push(album),
MusicItem::Artist(artist) => artists.push(artist),
MusicItem::Playlist(playlist) => playlists.push(playlist),
}
}
MapResult {
c: GroupedMusicItems {
tracks,
albums,
artists,
playlists,
},
warnings: self.warnings,
}
}
}
/// Map TextComponents containing artist names to a list of artists and a 'Various Artists' flag
pub(crate) fn map_artists(artists_p: Option<TextComponents>) -> (Vec<ArtistId>, bool) {
let mut by_va = false;
let artists = artists_p
.map(|part| {
part.0
.into_iter()
.enumerate()
.filter_map(|(i, c)| {
let artist = ArtistId::from(c);
// Filter out text components with no links that are at
// odd positions (conjunctions)
if artist.id.is_none() && i % 2 == 1 {
None
} else if artist.id.is_none() && artist.name == util::VARIOUS_ARTISTS {
by_va = true;
None
} else {
Some(artist)
}
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
(artists, by_va)
}
fn map_artist_id_fallback(
menu: Option<MusicItemMenu>,
fallback_artist: Option<&ArtistId>,
) -> Option<String> {
menu.and_then(|m| map_artist_id(m.menu_renderer.contents))
.or_else(|| fallback_artist.and_then(|a| a.id.to_owned()))
}
pub(crate) fn map_artist_id(entries: Vec<MusicItemMenuEntry>) -> Option<String> {
entries.into_iter().find_map(|i| {
let ep = i
.menu_navigation_item_renderer
.navigation_endpoint
.browse_endpoint;
ep.and_then(|ep| {
ep.browse_endpoint_context_supported_configs
.and_then(|cfg| {
if cfg.browse_endpoint_context_music_config.page_type == PageType::Artist {
Some(ep.browse_id)
} else {
None
}
})
})
})
}
pub(crate) fn map_album_type(txt: &str, lang: Language) -> AlbumType {
dictionary::entry(lang)
.album_types
.get(txt.to_lowercase().trim())
.copied()
.unwrap_or_default()
}
pub(crate) fn map_queue_item(item: QueueMusicItem, lang: Language) -> MapResult<TrackItem> {
let mut warnings = Vec::new();
let mut subtitle_parts = item.long_byline_text.split(util::DOT_SEPARATOR).into_iter();
let is_video = !item
.thumbnail
.thumbnails
.first()
.map(|tn| tn.height == tn.width)
.unwrap_or_default();
let artist_p = subtitle_parts.next();
let (artists, by_va) = map_artists(artist_p);
let artist_id = map_artist_id_fallback(item.menu, artists.first());
let subtitle_p2 = subtitle_parts.next();
let (album, view_count) = if is_video {
(
None,
subtitle_p2
.and_then(|p| util::parse_large_numstr_or_warn(p.first_str(), lang, &mut warnings)),
)
} else {
(
subtitle_p2.and_then(|p| p.0.into_iter().find_map(|c| AlbumId::try_from(c).ok())),
None,
)
};
MapResult {
c: TrackItem {
id: item.video_id,
name: item.title,
duration: item
.length_text
.and_then(|txt| util::parse_video_length(&txt)),
cover: item.thumbnail.into(),
artists,
artist_id,
album,
view_count,
is_video,
track_nr: None,
by_va,
},
warnings,
}
}
#[cfg(test)]
mod tests {
use std::{collections::BTreeMap, fs::File, io::BufReader};
use path_macro::path;
use super::*;
use crate::util::tests::TESTFILES;
#[test]
fn map_album_type_samples() {
let json_path = path!(*TESTFILES / "dict" / "album_type_samples.json");
let json_file = File::open(json_path).unwrap();
let atype_samples: BTreeMap<Language, BTreeMap<AlbumType, String>> =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
atype_samples.iter().for_each(|(lang, entry)| {
entry.iter().for_each(|(album_type, txt)| {
let res = map_album_type(txt, *lang);
assert_eq!(res, *album_type, "lang: {lang}, txt: {txt}");
});
});
}
}