refactor: update playlist model
This commit is contained in:
parent
71c77fcfb6
commit
4f48ad59bd
5 changed files with 189 additions and 788 deletions
|
|
@ -200,30 +200,23 @@ impl MapResponse<Paginator<PlaylistVideo>> for response::PlaylistCont {
|
|||
}
|
||||
}
|
||||
|
||||
fn map_playlist_items(items: Vec<response::VideoListItem>) -> (Vec<PlaylistVideo>, Option<String>) {
|
||||
fn map_playlist_items(
|
||||
items: Vec<response::playlist::PlaylistItem>,
|
||||
) -> (Vec<PlaylistVideo>, Option<String>) {
|
||||
let mut ctoken: Option<String> = None;
|
||||
let videos = items
|
||||
.into_iter()
|
||||
.filter_map(|it| match it {
|
||||
response::VideoListItem::PlaylistVideoRenderer(video) => {
|
||||
match ChannelId::try_from(video.channel) {
|
||||
Ok(channel) => Some(PlaylistVideo {
|
||||
id: video.video_id,
|
||||
title: video.title,
|
||||
length: video.length_seconds,
|
||||
thumbnail: video.thumbnail.into(),
|
||||
channel,
|
||||
}),
|
||||
Err(_) => None,
|
||||
}
|
||||
response::playlist::PlaylistItem::PlaylistVideoRenderer(video) => {
|
||||
PlaylistVideo::try_from(video).ok()
|
||||
}
|
||||
response::VideoListItem::ContinuationItemRenderer {
|
||||
response::playlist::PlaylistItem::ContinuationItemRenderer {
|
||||
continuation_endpoint,
|
||||
} => {
|
||||
ctoken = Some(continuation_endpoint.continuation_command.token);
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
response::playlist::PlaylistItem::None => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(videos, ctoken)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
use serde::Deserialize;
|
||||
use serde_with::serde_as;
|
||||
use serde_with::{DefaultOnError, VecSkipError};
|
||||
use serde_with::{json::JsonString, serde_as, DefaultOnError, VecSkipError};
|
||||
|
||||
use crate::serializer::text::{Text, TextComponent};
|
||||
use crate::serializer::{MapResult, VecLogError};
|
||||
use crate::serializer::{ignore_any, MapResult, VecLogError};
|
||||
use crate::util::MappingError;
|
||||
|
||||
use super::{Alert, ContentRenderer, ContentsRenderer, ThumbnailsWrap, VideoListItem};
|
||||
use super::{
|
||||
Alert, ContentRenderer, ContentsRenderer, ContinuationEndpoint, Thumbnails, ThumbnailsWrap,
|
||||
};
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -62,7 +64,7 @@ pub struct PlaylistVideoListRenderer {
|
|||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaylistVideoList {
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
pub contents: MapResult<Vec<VideoListItem>>,
|
||||
pub contents: MapResult<Vec<PlaylistItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -151,6 +153,54 @@ pub struct PlaylistThumbnailRenderer {
|
|||
pub playlist_video_thumbnail_renderer: ThumbnailsWrap,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PlaylistItem {
|
||||
/// Video in playlist
|
||||
PlaylistVideoRenderer(PlaylistVideoRenderer),
|
||||
/// 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
|
||||
#[serde(other, deserialize_with = "ignore_any")]
|
||||
None,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl TryFrom<PlaylistVideoRenderer> for crate::model::PlaylistVideo {
|
||||
type Error = MappingError;
|
||||
|
||||
fn try_from(video: PlaylistVideoRenderer) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
id: video.video_id,
|
||||
title: video.title,
|
||||
length: video.length_seconds,
|
||||
thumbnail: video.thumbnail.into(),
|
||||
channel: crate::model::ChannelId::try_from(video.channel)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Continuation
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OnResponseReceivedAction {
|
||||
|
|
@ -162,5 +212,5 @@ pub struct OnResponseReceivedAction {
|
|||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppendAction {
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
pub continuation_items: MapResult<Vec<VideoListItem>>,
|
||||
pub continuation_items: MapResult<Vec<PlaylistItem>>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
use chrono::TimeZone;
|
||||
use serde::Deserialize;
|
||||
use serde_with::{json::JsonString, serde_as, VecSkipError};
|
||||
use serde_with::{json::JsonString, serde_as, DefaultOnError, VecSkipError};
|
||||
|
||||
use super::{
|
||||
ChannelBadge, ChannelThumbnailSupportedRenderers, ContinuationEndpoint,
|
||||
DetailedMetadataSnippet, IsLive, IsShort, Thumbnails, TimeOverlay, UpcomingEventData,
|
||||
VideoBadge,
|
||||
};
|
||||
use super::{ChannelBadge, ContinuationEndpoint, Thumbnails};
|
||||
use crate::{
|
||||
model::{ChannelId, ChannelItem, ChannelTag, PlaylistItem, VideoItem, YouTubeItem},
|
||||
param::Language,
|
||||
|
|
@ -181,6 +177,119 @@ pub struct YouTubeListRenderer {
|
|||
pub contents: MapResult<Vec<YouTubeListItem>>,
|
||||
}
|
||||
|
||||
#[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 TimeOverlay {
|
||||
pub thumbnail_overlay_time_status_renderer: TimeOverlayRenderer,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[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 ChannelThumbnailSupportedRenderers {
|
||||
pub channel_thumbnail_with_link_renderer: ChannelThumbnailWithLinkRenderer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChannelThumbnailWithLinkRenderer {
|
||||
pub thumbnail: Thumbnails,
|
||||
}
|
||||
|
||||
trait IsLive {
|
||||
fn is_live(&self) -> bool;
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of mapping a list of different YouTube enities
|
||||
/// (videos, channels, playlists)
|
||||
#[derive(Debug)]
|
||||
|
|
|
|||
185
src/model/mod.rs
185
src/model/mod.rs
|
|
@ -557,42 +557,6 @@ pub struct Chapter {
|
|||
pub thumbnail: Vec<Thumbnail>,
|
||||
}
|
||||
|
||||
/*
|
||||
@RECOMMENDATIONS
|
||||
*/
|
||||
|
||||
/// YouTube video fetched from the recommendations next to a video
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct RecommendedVideo {
|
||||
/// Unique YouTube video ID
|
||||
pub id: String,
|
||||
/// Video title
|
||||
pub title: String,
|
||||
/// Video length in seconds.
|
||||
///
|
||||
/// Is [`None`] for livestreams.
|
||||
pub length: Option<u32>,
|
||||
/// Video thumbnail
|
||||
pub thumbnail: Vec<Thumbnail>,
|
||||
/// Channel of the video
|
||||
pub channel: ChannelTag,
|
||||
/// Video publishing date.
|
||||
///
|
||||
/// [`None`] if the date could not be parsed.
|
||||
pub publish_date: Option<DateTime<Local>>,
|
||||
/// Textual video publish date (e.g. `11 months ago`, depends on language)
|
||||
///
|
||||
/// Is [`None`] for livestreams.
|
||||
pub publish_date_txt: Option<String>,
|
||||
/// View count
|
||||
pub view_count: u64,
|
||||
/// Is the video an active livestream?
|
||||
pub is_live: bool,
|
||||
/// Is the video a YouTube Short video (vertical and <60s)?
|
||||
pub is_short: bool,
|
||||
}
|
||||
|
||||
/// Channel information attached to a video or comment
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
|
|
@ -710,55 +674,6 @@ pub struct Channel<T> {
|
|||
pub content: T,
|
||||
}
|
||||
|
||||
/// Video fetched from a YouTube channel
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct ChannelVideo {
|
||||
/// Unique YouTube video ID
|
||||
pub id: String,
|
||||
/// Video title
|
||||
pub title: String,
|
||||
/// Video length in seconds.
|
||||
///
|
||||
/// Is [`None`] for livestreams.
|
||||
pub length: Option<u32>,
|
||||
/// Video thumbnail
|
||||
pub thumbnail: Vec<Thumbnail>,
|
||||
/// Video publishing date.
|
||||
///
|
||||
/// [`None`] if the date could not be parsed.
|
||||
/// May be in the future for upcoming videos
|
||||
pub publish_date: Option<DateTime<Local>>,
|
||||
/// Textual video publish date (e.g. `11 months ago`, depends on language)
|
||||
///
|
||||
/// Is [`None`] for livestreams and upcoming videos.
|
||||
pub publish_date_txt: Option<String>,
|
||||
/// Number of views / current viewers in case of a livestream.
|
||||
///
|
||||
/// [`None`] if it could not be extracted.
|
||||
pub view_count: u64,
|
||||
/// Is the video an active livestream?
|
||||
pub is_live: bool,
|
||||
/// Is the video a YouTube Short video (vertical and <60s)?
|
||||
pub is_short: bool,
|
||||
/// Is the video announced, but not released yet (YouTube Premiere)?
|
||||
pub is_upcoming: bool,
|
||||
}
|
||||
|
||||
/// Playlist fetched from a YouTube channel
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct ChannelPlaylist {
|
||||
/// Unique YouTube Playlist-ID (e.g. `PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ`)
|
||||
pub id: String,
|
||||
/// Playlist name
|
||||
pub name: String,
|
||||
/// Playlist thumbnail
|
||||
pub thumbnail: Vec<Thumbnail>,
|
||||
/// Number of playlist videos
|
||||
pub video_count: Option<u64>,
|
||||
}
|
||||
|
||||
/// Additional channel metadata fetched from the "About" tab.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
|
|
@ -809,105 +724,27 @@ pub struct ChannelRssVideo {
|
|||
pub like_count: u64,
|
||||
}
|
||||
|
||||
/// YouTube search result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SearchResult {
|
||||
/// Search result items
|
||||
pub items: Paginator<YouTubeItem>,
|
||||
/// Corrected search query
|
||||
///
|
||||
/// If the search term containes a typo, YouTube instead searches
|
||||
/// for the corrected search term and displays it on top of the
|
||||
/// search results page.
|
||||
pub corrected_query: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum SearchItem {
|
||||
Video(SearchVideo),
|
||||
Playlist(SearchPlaylist),
|
||||
Channel(SearchChannel),
|
||||
}
|
||||
|
||||
/// YouTube video from the search results
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct SearchVideo {
|
||||
/// Unique YouTube video ID
|
||||
pub id: String,
|
||||
/// Video title
|
||||
pub title: String,
|
||||
/// Video length in seconds.
|
||||
///
|
||||
/// Is [`None`] for livestreams.
|
||||
pub length: Option<u32>,
|
||||
/// Video thumbnail
|
||||
pub thumbnail: Vec<Thumbnail>,
|
||||
/// Channel of the video
|
||||
pub channel: ChannelTag,
|
||||
/// Video publishing date.
|
||||
///
|
||||
/// [`None`] if the date could not be parsed.
|
||||
pub publish_date: Option<DateTime<Local>>,
|
||||
/// Textual video publish date (e.g. `11 months ago`, depends on language)
|
||||
///
|
||||
/// Is [`None`] for livestreams.
|
||||
pub publish_date_txt: Option<String>,
|
||||
/// View count
|
||||
///
|
||||
/// [`None`] if it could not be extracted.
|
||||
pub view_count: u64,
|
||||
/// Is the video an active livestream?
|
||||
pub is_live: bool,
|
||||
/// Is the video a YouTube Short video (vertical and <60s)?
|
||||
pub is_short: bool,
|
||||
/// Abbreviated video description
|
||||
pub short_description: String,
|
||||
}
|
||||
|
||||
/// Playlist from the search results
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct SearchPlaylist {
|
||||
/// Unique YouTube Playlist-ID (e.g. `PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ`)
|
||||
pub id: String,
|
||||
/// Playlist name
|
||||
pub name: String,
|
||||
/// Playlist thumbnail
|
||||
pub thumbnail: Vec<Thumbnail>,
|
||||
/// Number of playlist videos
|
||||
pub video_count: u64,
|
||||
/// First 2 videos
|
||||
pub first_videos: Vec<SearchPlaylistVideo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct SearchPlaylistVideo {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub length: Option<u32>,
|
||||
}
|
||||
|
||||
/// Channel from the search results
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct SearchChannel {
|
||||
/// Unique YouTube channel ID
|
||||
pub id: String,
|
||||
/// Channel name
|
||||
pub name: String,
|
||||
/// Channel avatar/profile picture
|
||||
pub avatar: Vec<Thumbnail>,
|
||||
/// Channel verification mark
|
||||
pub verification: Verification,
|
||||
/// Approximate number of subscribers
|
||||
///
|
||||
/// [`None`] if hidden by the owner or not present.
|
||||
pub subscriber_count: Option<u64>,
|
||||
/// Number of videos from the channel
|
||||
pub video_count: u64,
|
||||
/// Abbreviated channel description
|
||||
pub short_description: String,
|
||||
}
|
||||
|
||||
/// YouTube item (Video/Channel/Playlist)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum YouTubeItem {
|
||||
/// YouTube video item
|
||||
Video(VideoItem),
|
||||
/// YouTube playlist item
|
||||
Playlist(PlaylistItem),
|
||||
/// YouTube channel item
|
||||
Channel(ChannelItem),
|
||||
}
|
||||
|
||||
|
|
|
|||
Reference in a new issue