refactor: update playlist model

This commit is contained in:
Theta-Dev 2022-10-17 22:00:33 +02:00
parent 71c77fcfb6
commit 4f48ad59bd
5 changed files with 189 additions and 788 deletions

View file

@ -27,22 +27,15 @@ pub mod channel_rss;
#[cfg(feature = "rss")]
pub use channel_rss::ChannelRss;
use chrono::TimeZone;
use serde::Deserialize;
use serde_with::{json::JsonString, serde_as, DefaultOnError, VecSkipError};
use crate::error::ExtractionError;
use crate::model;
use crate::param::Language;
use crate::serializer::MapResult;
use crate::serializer::{
ignore_any,
text::{Text, TextComponent},
VecLogError,
};
use crate::timeago;
use crate::util::MappingError;
use crate::util::{self, TryRemove};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -81,235 +74,6 @@ pub struct Thumbnail {
pub height: u32,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum VideoListItem {
/// Video on channel page
GridVideoRenderer(GridVideoRenderer),
/// Video in recommendations
CompactVideoRenderer(CompactVideoRenderer),
/// Video in playlist
PlaylistVideoRenderer(PlaylistVideoRenderer),
/// Playlist on channel page
GridPlaylistRenderer(GridPlaylistRenderer),
/// Video on startpage
///
/// Seems to be currently A/B tested on the channel page,
/// as of 11.10.2022
RichItemRenderer { content: RichItem },
/// Seems to be currently A/B tested on the video details page,
/// as of 11.10.2022
ItemSectionRenderer {
#[serde_as(as = "VecLogError<_>")]
contents: MapResult<Vec<VideoListItem>>,
},
/// Continauation items are located at the end of a list
/// and contain the continuation token for progressive loading
#[serde(rename_all = "camelCase")]
ContinuationItemRenderer {
continuation_endpoint: ContinuationEndpoint,
},
/// No video list item (e.g. ad) or unimplemented item
///
/// Unimplemented:
/// - compactPlaylistRenderer (recommended playlists)
/// - compactRadioRenderer (recommended mix)
#[serde(other, deserialize_with = "ignore_any")]
None,
}
/// Video displayed on a channel page
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GridVideoRenderer {
pub video_id: String,
pub thumbnail: Thumbnails,
#[serde_as(as = "Text")]
pub title: String,
#[serde_as(as = "Option<Text>")]
pub published_time_text: Option<String>,
/// Contains `No views` if the view count is zero
#[serde_as(as = "Option<Text>")]
pub view_count_text: Option<String>,
/// Contains video length and Short/Live tag
#[serde_as(as = "VecSkipError<_>")]
pub thumbnail_overlays: Vec<TimeOverlay>,
/// Release date for upcoming videos
pub upcoming_event_data: Option<UpcomingEventData>,
}
/// Video displayed in recommendations
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompactVideoRenderer {
pub video_id: String,
pub thumbnail: Thumbnails,
#[serde_as(as = "Text")]
pub title: String,
#[serde(rename = "shortBylineText")]
pub channel: TextComponent,
pub channel_thumbnail: Thumbnails,
/// Channel verification badge
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub owner_badges: Vec<ChannelBadge>,
#[serde_as(as = "Option<Text>")]
pub length_text: Option<String>,
/// (e.g. `11 months ago`)
#[serde_as(as = "Option<Text>")]
pub published_time_text: Option<String>,
/// Contains `No views` if the view count is zero
#[serde_as(as = "Option<Text>")]
pub view_count_text: Option<String>,
/// Badges are displayed on the video thumbnail and
/// show certain video properties (e.g. active livestream)
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub badges: Vec<VideoBadge>,
/// Contains Short/Live tag
#[serde_as(as = "VecSkipError<_>")]
pub thumbnail_overlays: Vec<TimeOverlay>,
}
/// Video displayed in search results
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoRenderer {
pub video_id: String,
pub thumbnail: Thumbnails,
#[serde_as(as = "Text")]
pub title: String,
#[serde(rename = "shortBylineText")]
pub channel: Option<TextComponent>,
pub channel_thumbnail_supported_renderers: Option<ChannelThumbnailSupportedRenderers>,
#[serde_as(as = "Option<Text>")]
pub published_time_text: Option<String>,
#[serde_as(as = "Option<Text>")]
pub length_text: Option<String>,
/// Contains `No views` if the view count is zero
#[serde_as(as = "Option<Text>")]
pub view_count_text: Option<String>,
/// Channel verification badge
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub owner_badges: Vec<ChannelBadge>,
/// Contains Short/Live tag
#[serde_as(as = "VecSkipError<_>")]
pub thumbnail_overlays: Vec<TimeOverlay>,
/// Abbreviated video description (on startpage)
#[serde_as(as = "Option<Text>")]
pub description_snippet: Option<String>,
/// Contains abbreviated video description (on search page)
#[serde_as(as = "Option<VecSkipError<_>>")]
pub detailed_metadata_snippets: Option<Vec<DetailedMetadataSnippet>>,
/// Release date for upcoming videos
pub upcoming_event_data: Option<UpcomingEventData>,
}
/// Playlist displayed in search results
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistRenderer {
pub playlist_id: String,
#[serde_as(as = "Text")]
pub title: String,
/// The first item of this list contains the playlist thumbnail,
/// subsequent items contain very small thumbnails of the next playlist videos
pub thumbnails: Vec<Thumbnails>,
#[serde_as(as = "JsonString")]
pub video_count: u64,
#[serde(rename = "shortBylineText")]
pub channel: TextComponent,
/// Channel verification badge
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub owner_badges: Vec<ChannelBadge>,
/// First 2 videos
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub videos: Vec<ChildVideoRendererWrap>,
}
/// Video displayed in a playlist
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistVideoRenderer {
pub video_id: String,
pub thumbnail: Thumbnails,
#[serde_as(as = "Text")]
pub title: String,
#[serde(rename = "shortBylineText")]
pub channel: TextComponent,
#[serde_as(as = "JsonString")]
pub length_seconds: u32,
}
/// Channel displayed in search results
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelRenderer {
pub channel_id: String,
#[serde_as(as = "Text")]
pub title: String,
pub thumbnail: Thumbnails,
/// Abbreviated channel description
///
/// Not present if the channel has no description
#[serde(default)]
#[serde_as(as = "Text")]
pub description_snippet: String,
/// Not present if the channel has no videos
#[serde_as(as = "Option<Text>")]
pub video_count_text: Option<String>,
#[serde_as(as = "Option<Text>")]
pub subscriber_count_text: Option<String>,
/// Channel verification badge
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub owner_badges: Vec<ChannelBadge>,
}
/// Playlist displayed on a channel page
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GridPlaylistRenderer {
pub playlist_id: String,
#[serde_as(as = "Text")]
pub title: String,
pub thumbnail: Thumbnails,
#[serde_as(as = "Text")]
pub video_count_short_text: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(clippy::large_enum_variant)]
pub enum RichItem {
VideoRenderer(VideoRenderer),
PlaylistRenderer(GridPlaylistRenderer),
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpcomingEventData {
/// Unixtime in seconds
#[serde_as(as = "JsonString")]
pub start_time: i64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContinuationItemRenderer {
@ -384,58 +148,6 @@ pub enum ChannelBadgeStyle {
BadgeStyleTypeVerifiedArtist,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TimeOverlay {
pub thumbnail_overlay_time_status_renderer: TimeOverlayRenderer,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TimeOverlayRenderer {
/// `29:54`
///
/// Is `LIVE` in case of a livestream and `SHORTS` in case of a short video
#[serde_as(as = "Text")]
pub text: String,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub style: TimeOverlayStyle,
}
#[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum TimeOverlayStyle {
#[default]
Default,
Live,
Shorts,
}
/// Badges are displayed on the video thumbnail and
/// show certain video properties (e.g. active livestream)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoBadge {
pub metadata_badge_renderer: VideoBadgeRenderer,
}
/// Badges are displayed on the video thumbnail and
/// show certain video properties (e.g. active livestream)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoBadgeRenderer {
pub style: VideoBadgeStyle,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum VideoBadgeStyle {
/// Active livestream
BadgeStyleTypeLiveNow,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Alert {
@ -450,43 +162,6 @@ pub struct AlertRenderer {
pub text: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelThumbnailSupportedRenderers {
pub channel_thumbnail_with_link_renderer: ChannelThumbnailWithLinkRenderer,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelThumbnailWithLinkRenderer {
pub thumbnail: Thumbnails,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DetailedMetadataSnippet {
#[serde_as(as = "Text")]
pub snippet_text: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChildVideoRendererWrap {
pub child_video_renderer: ChildVideoRenderer,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChildVideoRenderer {
pub video_id: String,
#[serde_as(as = "Text")]
pub title: String,
#[serde_as(as = "Option<Text>")]
pub length_text: Option<String>,
}
// CONTINUATION
#[serde_as]
@ -631,38 +306,6 @@ impl From<Icon> for crate::model::Verification {
}
}
pub trait IsLive {
fn is_live(&self) -> bool;
}
pub trait IsShort {
fn is_short(&self) -> bool;
}
impl IsLive for Vec<VideoBadge> {
fn is_live(&self) -> bool {
self.iter().any(|badge| {
badge.metadata_badge_renderer.style == VideoBadgeStyle::BadgeStyleTypeLiveNow
})
}
}
impl IsLive for Vec<TimeOverlay> {
fn is_live(&self) -> bool {
self.iter().any(|overlay| {
overlay.thumbnail_overlay_time_status_renderer.style == TimeOverlayStyle::Live
})
}
}
impl IsShort for Vec<TimeOverlay> {
fn is_short(&self) -> bool {
self.iter().any(|overlay| {
overlay.thumbnail_overlay_time_status_renderer.style == TimeOverlayStyle::Shorts
})
}
}
pub fn alerts_to_err(alerts: Option<Vec<Alert>>) -> ExtractionError {
match alerts {
Some(alerts) => ExtractionError::ContentUnavailable(
@ -676,234 +319,3 @@ pub fn alerts_to_err(alerts: Option<Vec<Alert>>) -> ExtractionError {
None => ExtractionError::ContentUnavailable("content not found".into()),
}
}
pub trait FromWLang<T> {
fn from_w_lang(from: T, lang: Language) -> Self;
}
pub trait TryFromWLang<T>: Sized {
fn from_w_lang(from: T, lang: Language) -> Result<Self, util::MappingError>;
}
impl FromWLang<GridVideoRenderer> for model::ChannelVideo {
fn from_w_lang(video: GridVideoRenderer, lang: Language) -> Self {
let mut toverlays = video.thumbnail_overlays;
let is_live = toverlays.is_live();
let is_short = toverlays.is_short();
let to = toverlays.try_swap_remove(0);
Self {
id: video.video_id,
title: video.title,
// Time text is `LIVE` for livestreams, so we ignore parse errors
length: to.and_then(|to| {
util::parse_video_length(&to.thumbnail_overlay_time_status_renderer.text)
}),
thumbnail: video.thumbnail.into(),
publish_date: video
.upcoming_event_data
.as_ref()
.map(|upc| {
chrono::Local.from_utc_datetime(&chrono::NaiveDateTime::from_timestamp(
upc.start_time,
0,
))
})
.or_else(|| {
video
.published_time_text
.as_ref()
.and_then(|txt| timeago::parse_timeago_to_dt(lang, txt))
}),
publish_date_txt: video.published_time_text,
view_count: video
.view_count_text
.and_then(|txt| util::parse_numeric(&txt).ok())
.unwrap_or_default(),
is_live,
is_short,
is_upcoming: video.upcoming_event_data.is_some(),
}
}
}
impl FromWLang<VideoRenderer> for model::ChannelVideo {
fn from_w_lang(video: VideoRenderer, lang: Language) -> Self {
let mut toverlays = video.thumbnail_overlays;
let is_live = toverlays.is_live();
let is_short = toverlays.is_short();
let to = toverlays.try_swap_remove(0);
Self {
id: video.video_id,
title: video.title,
// Time text is `LIVE` for livestreams, so we ignore parse errors
length: to.and_then(|to| {
util::parse_video_length(&to.thumbnail_overlay_time_status_renderer.text)
}),
thumbnail: video.thumbnail.into(),
publish_date: video
.upcoming_event_data
.as_ref()
.map(|upc| {
chrono::Local.from_utc_datetime(&chrono::NaiveDateTime::from_timestamp(
upc.start_time,
0,
))
})
.or_else(|| {
video
.published_time_text
.as_ref()
.and_then(|txt| timeago::parse_timeago_to_dt(lang, txt))
}),
publish_date_txt: video.published_time_text,
view_count: video
.view_count_text
.and_then(|txt| util::parse_numeric(&txt).ok())
.unwrap_or_default(),
is_live,
is_short,
is_upcoming: video.upcoming_event_data.is_some(),
}
}
}
impl From<GridPlaylistRenderer> for model::ChannelPlaylist {
fn from(playlist: GridPlaylistRenderer) -> Self {
Self {
id: playlist.playlist_id,
name: playlist.title,
thumbnail: playlist.thumbnail.into(),
video_count: util::parse_numeric(&playlist.video_count_short_text).ok(),
}
}
}
impl TryFromWLang<CompactVideoRenderer> for model::RecommendedVideo {
fn from_w_lang(
video: CompactVideoRenderer,
lang: Language,
) -> Result<Self, util::MappingError> {
let channel = model::ChannelId::try_from(video.channel)?;
Ok(Self {
id: video.video_id,
title: video.title,
length: video
.length_text
.and_then(|txt| util::parse_video_length(&txt)),
thumbnail: video.thumbnail.into(),
channel: model::ChannelTag {
id: channel.id,
name: channel.name,
avatar: video.channel_thumbnail.into(),
verification: video.owner_badges.into(),
subscriber_count: None,
},
publish_date: video
.published_time_text
.as_ref()
.and_then(|txt| timeago::parse_timeago_to_dt(lang, txt)),
publish_date_txt: video.published_time_text,
view_count: video
.view_count_text
.and_then(|txt| util::parse_numeric(&txt).ok())
.unwrap_or_default(),
is_live: video.badges.is_live(),
is_short: video.thumbnail_overlays.is_short(),
})
}
}
impl TryFromWLang<VideoRenderer> for model::SearchVideo {
fn from_w_lang(video: VideoRenderer, lang: Language) -> Result<Self, util::MappingError> {
let channel = model::ChannelId::try_from(
video
.channel
.ok_or_else(|| MappingError("no video channel".into()))?,
)?;
let channel_thumbnail = video
.channel_thumbnail_supported_renderers
.ok_or_else(|| MappingError("no video channel thumbnail".into()))?;
Ok(Self {
id: video.video_id,
title: video.title,
length: video
.length_text
.and_then(|txt| util::parse_video_length(&txt)),
thumbnail: video.thumbnail.into(),
channel: model::ChannelTag {
id: channel.id,
name: channel.name,
avatar: channel_thumbnail
.channel_thumbnail_with_link_renderer
.thumbnail
.into(),
verification: video.owner_badges.into(),
subscriber_count: None,
},
publish_date: video
.published_time_text
.as_ref()
.and_then(|txt| timeago::parse_timeago_to_dt(lang, txt)),
publish_date_txt: video.published_time_text,
view_count: video
.view_count_text
.and_then(|txt| util::parse_numeric(&txt).ok())
.unwrap_or_default(),
is_live: video.thumbnail_overlays.is_live(),
is_short: video.thumbnail_overlays.is_short(),
short_description: video
.detailed_metadata_snippets
.and_then(|mut snippets| snippets.try_swap_remove(0).map(|s| s.snippet_text))
.or(video.description_snippet)
.unwrap_or_default(),
})
}
}
impl From<PlaylistRenderer> for model::SearchPlaylist {
fn from(playlist: PlaylistRenderer) -> Self {
let mut thumbnails = playlist.thumbnails;
Self {
id: playlist.playlist_id,
name: playlist.title,
thumbnail: thumbnails.try_swap_remove(0).unwrap_or_default().into(),
video_count: playlist.video_count,
first_videos: playlist
.videos
.into_iter()
.map(|v| model::SearchPlaylistVideo {
id: v.child_video_renderer.video_id,
title: v.child_video_renderer.title,
length: v
.child_video_renderer
.length_text
.and_then(|txt| util::parse_video_length(&txt)),
})
.collect(),
}
}
}
impl From<ChannelRenderer> for model::SearchChannel {
fn from(channel: ChannelRenderer) -> Self {
Self {
id: channel.channel_id,
name: channel.title,
avatar: channel.thumbnail.into(),
verification: channel.owner_badges.into(),
subscriber_count: channel
.subscriber_count_text
.and_then(|txt| util::parse_numeric(&txt).ok()),
video_count: channel
.video_count_text
.and_then(|txt| util::parse_numeric(&txt).ok())
.unwrap_or_default(),
short_description: channel.description_snippet,
}
}
}