fix: add support for channel RichGridRenderer
This commit is contained in:
parent
79b7fcf92c
commit
c65af48a4b
5 changed files with 13640 additions and 77 deletions
|
|
@ -1,4 +1,3 @@
|
||||||
use chrono::TimeZone;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
|
@ -12,7 +11,7 @@ use crate::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
response::{self, IsLive, IsShort},
|
response::{self, FromWLang},
|
||||||
ClientType, MapResponse, QContinuation, RustyPipeQuery, YTContext,
|
ClientType, MapResponse, QContinuation, RustyPipeQuery, YTContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -313,51 +312,17 @@ fn map_videos(
|
||||||
res: MapResult<Vec<response::VideoListItem>>,
|
res: MapResult<Vec<response::VideoListItem>>,
|
||||||
lang: Language,
|
lang: Language,
|
||||||
) -> MapResult<Paginator<ChannelVideo>> {
|
) -> MapResult<Paginator<ChannelVideo>> {
|
||||||
let mut warnings = res.warnings;
|
|
||||||
|
|
||||||
let mut ctoken = None;
|
let mut ctoken = None;
|
||||||
let videos = res
|
let videos = res
|
||||||
.c
|
.c
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|item| match item {
|
.filter_map(|item| match item {
|
||||||
response::VideoListItem::GridVideoRenderer(video) => {
|
response::VideoListItem::GridVideoRenderer(video) => {
|
||||||
let mut toverlays = video.thumbnail_overlays;
|
Some(ChannelVideo::from_w_lang(video, lang))
|
||||||
let is_live = toverlays.is_live();
|
|
||||||
let is_short = toverlays.is_short();
|
|
||||||
let to = toverlays.try_swap_remove(0);
|
|
||||||
|
|
||||||
Some(ChannelVideo {
|
|
||||||
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_or_warn(lang, txt, &mut warnings)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
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(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
response::VideoListItem::RichItemRenderer {
|
||||||
|
content: response::RichItem::VideoRenderer(video),
|
||||||
|
} => Some(ChannelVideo::from_w_lang(video, lang)),
|
||||||
response::VideoListItem::ContinuationItemRenderer {
|
response::VideoListItem::ContinuationItemRenderer {
|
||||||
continuation_endpoint,
|
continuation_endpoint,
|
||||||
} => {
|
} => {
|
||||||
|
|
@ -370,7 +335,7 @@ fn map_videos(
|
||||||
|
|
||||||
MapResult {
|
MapResult {
|
||||||
c: Paginator::new(None, videos, ctoken),
|
c: Paginator::new(None, videos, ctoken),
|
||||||
warnings,
|
warnings: res.warnings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -382,12 +347,10 @@ fn map_playlists(
|
||||||
.c
|
.c
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|item| match item {
|
.filter_map(|item| match item {
|
||||||
response::VideoListItem::GridPlaylistRenderer(playlist) => Some(ChannelPlaylist {
|
response::VideoListItem::GridPlaylistRenderer(playlist) => Some(playlist.into()),
|
||||||
id: playlist.playlist_id,
|
response::VideoListItem::RichItemRenderer {
|
||||||
name: playlist.title,
|
content: response::RichItem::PlaylistRenderer(playlist),
|
||||||
thumbnail: playlist.thumbnail.into(),
|
} => Some(playlist.into()),
|
||||||
video_count: util::parse_numeric(&playlist.video_count_short_text).ok(),
|
|
||||||
}),
|
|
||||||
response::VideoListItem::ContinuationItemRenderer {
|
response::VideoListItem::ContinuationItemRenderer {
|
||||||
continuation_endpoint,
|
continuation_endpoint,
|
||||||
} => {
|
} => {
|
||||||
|
|
@ -503,15 +466,41 @@ fn map_channel_content(
|
||||||
match contents {
|
match contents {
|
||||||
Some(contents) => {
|
Some(contents) => {
|
||||||
let mut tabs = contents.two_column_browse_results_renderer.tabs;
|
let mut tabs = contents.two_column_browse_results_renderer.tabs;
|
||||||
let mut sectionlist = some_or_bail!(
|
let content = some_or_bail!(
|
||||||
tabs.try_swap_remove(0),
|
tabs.try_swap_remove(0),
|
||||||
Ok(MapResult::error("no tab".to_owned()))
|
Ok(MapResult::error("no tab".to_owned()))
|
||||||
)
|
)
|
||||||
.tab_renderer
|
.tab_renderer
|
||||||
.content
|
.content;
|
||||||
.section_list_renderer;
|
|
||||||
|
|
||||||
if let Some(target_id) = sectionlist.target_id {
|
let (channel_content, target_id) = match content {
|
||||||
|
response::channel::TabContent::SectionListRenderer {
|
||||||
|
mut contents,
|
||||||
|
target_id,
|
||||||
|
} => {
|
||||||
|
let mut itemsection = some_or_bail!(
|
||||||
|
contents.try_swap_remove(0),
|
||||||
|
Ok(MapResult::error("no sectionlist".to_owned()))
|
||||||
|
)
|
||||||
|
.item_section_renderer
|
||||||
|
.contents;
|
||||||
|
|
||||||
|
let content = some_or_bail!(
|
||||||
|
itemsection.try_swap_remove(0),
|
||||||
|
Ok(MapResult::error("no channel content".to_owned()))
|
||||||
|
);
|
||||||
|
(content, target_id)
|
||||||
|
}
|
||||||
|
response::channel::TabContent::RichGridRenderer {
|
||||||
|
contents,
|
||||||
|
target_id,
|
||||||
|
} => (
|
||||||
|
response::channel::ChannelContent::GridRenderer { items: contents },
|
||||||
|
target_id,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(target_id) = target_id {
|
||||||
// YouTube falls back to the featured page if the channel does not have a "videos" tab.
|
// YouTube falls back to the featured page if the channel does not have a "videos" tab.
|
||||||
// This is the case for YouTube Music channels.
|
// This is the case for YouTube Music channels.
|
||||||
if target_id.starts_with(&format!("browse-feed{}featured", id)) {
|
if target_id.starts_with(&format!("browse-feed{}featured", id)) {
|
||||||
|
|
@ -519,19 +508,7 @@ fn map_channel_content(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut itemsection = some_or_bail!(
|
Ok(MapResult::ok(channel_content))
|
||||||
sectionlist.contents.try_swap_remove(0),
|
|
||||||
Ok(MapResult::error("no sectionlist".to_owned()))
|
|
||||||
)
|
|
||||||
.item_section_renderer
|
|
||||||
.contents;
|
|
||||||
|
|
||||||
let content = some_or_bail!(
|
|
||||||
itemsection.try_swap_remove(0),
|
|
||||||
Ok(MapResult::error("no channel content".to_owned()))
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(MapResult::ok(content))
|
|
||||||
}
|
}
|
||||||
None => Err(response::alerts_to_err(alerts)),
|
None => Err(response::alerts_to_err(alerts)),
|
||||||
}
|
}
|
||||||
|
|
@ -557,6 +534,7 @@ mod tests {
|
||||||
#[case::live("live", "UChs0pSaEoNLV4mevBFGaoKA")]
|
#[case::live("live", "UChs0pSaEoNLV4mevBFGaoKA")]
|
||||||
#[case::empty("empty", "UCxBa895m48H5idw5li7h-0g")]
|
#[case::empty("empty", "UCxBa895m48H5idw5li7h-0g")]
|
||||||
#[case::upcoming("upcoming", "UCcvfHa-GHSOHFAjU0-Ie57A")]
|
#[case::upcoming("upcoming", "UCcvfHa-GHSOHFAjU0-Ie57A")]
|
||||||
|
#[case::shorts("20221011_richgrid", "UCh8gHdtzO2tXd593_bjErWg")]
|
||||||
fn map_channel_videos(#[case] name: &str, #[case] id: &str) {
|
fn map_channel_videos(#[case] name: &str, #[case] id: &str) {
|
||||||
let filename = format!("testfiles/channel/channel_videos_{}.json", name);
|
let filename = format!("testfiles/channel/channel_videos_{}.json", name);
|
||||||
let json_path = Path::new(&filename);
|
let json_path = Path::new(&filename);
|
||||||
|
|
|
||||||
|
|
@ -49,23 +49,31 @@ pub struct TabsRenderer {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct TabRendererWrap {
|
pub struct TabRendererWrap {
|
||||||
pub tab_renderer: ContentRenderer<SectionListRendererWrap>,
|
pub tab_renderer: ContentRenderer<TabContent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SectionListRendererWrap {
|
pub enum TabContent {
|
||||||
pub section_list_renderer: SectionListRenderer,
|
#[serde(rename_all = "camelCase")]
|
||||||
}
|
SectionListRenderer {
|
||||||
|
contents: Vec<ItemSectionRendererWrap>,
|
||||||
#[derive(Debug, Deserialize)]
|
/// - **Videos**: browse-feedUC2DjFE7Xf11URZqWBigcVOQvideos (...)
|
||||||
#[serde(rename_all = "camelCase")]
|
/// - **Playlists**: browse-feedUC2DjFE7Xf11URZqWBigcVOQplaylists104 (...)
|
||||||
pub struct SectionListRenderer {
|
/// - **Info**: None
|
||||||
pub contents: Vec<ItemSectionRendererWrap>,
|
target_id: Option<String>,
|
||||||
/// - **Videos**: browse-feedUC2DjFE7Xf11URZqWBigcVOQvideos (...)
|
},
|
||||||
/// - **Playlists**: browse-feedUC2DjFE7Xf11URZqWBigcVOQplaylists104 (...)
|
/// Seems to be currently A/B tested, as of 11.10.2022
|
||||||
/// - **Info**: None
|
#[serde(rename_all = "camelCase")]
|
||||||
pub target_id: Option<String>,
|
RichGridRenderer {
|
||||||
|
#[serde_as(as = "VecLogError<_>")]
|
||||||
|
contents: MapResult<Vec<VideoListItem>>,
|
||||||
|
/// - **Videos**: browse-feedUC2DjFE7Xf11URZqWBigcVOQvideos (...)
|
||||||
|
/// - **Playlists**: browse-feedUC2DjFE7Xf11URZqWBigcVOQplaylists104 (...)
|
||||||
|
/// - **Info**: None
|
||||||
|
target_id: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ pub mod video_details;
|
||||||
|
|
||||||
pub use channel::Channel;
|
pub use channel::Channel;
|
||||||
pub use channel::ChannelCont;
|
pub use channel::ChannelCont;
|
||||||
|
use chrono::TimeZone;
|
||||||
pub use player::Player;
|
pub use player::Player;
|
||||||
pub use playlist::Playlist;
|
pub use playlist::Playlist;
|
||||||
pub use playlist::PlaylistCont;
|
pub use playlist::PlaylistCont;
|
||||||
|
|
@ -26,10 +27,15 @@ use serde::Deserialize;
|
||||||
use serde_with::{json::JsonString, serde_as, DefaultOnError, VecSkipError};
|
use serde_with::{json::JsonString, serde_as, DefaultOnError, VecSkipError};
|
||||||
|
|
||||||
use crate::error::ExtractionError;
|
use crate::error::ExtractionError;
|
||||||
|
use crate::model;
|
||||||
|
use crate::param::Language;
|
||||||
use crate::serializer::{
|
use crate::serializer::{
|
||||||
ignore_any,
|
ignore_any,
|
||||||
text::{Text, TextComponent},
|
text::{Text, TextComponent},
|
||||||
};
|
};
|
||||||
|
use crate::timeago;
|
||||||
|
use crate::util;
|
||||||
|
use crate::util::TryRemove;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
@ -81,6 +87,10 @@ pub enum VideoListItem {
|
||||||
/// Playlist on channel page
|
/// Playlist on channel page
|
||||||
GridPlaylistRenderer(GridPlaylistRenderer),
|
GridPlaylistRenderer(GridPlaylistRenderer),
|
||||||
|
|
||||||
|
/// Seems to be currently A/B tested on the channel page,
|
||||||
|
/// as of 11.10.2022
|
||||||
|
RichItemRenderer { content: RichItem },
|
||||||
|
|
||||||
/// Continauation items are located at the end of a list
|
/// Continauation items are located at the end of a list
|
||||||
/// and contain the continuation token for progressive loading
|
/// and contain the continuation token for progressive loading
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
@ -179,6 +189,13 @@ pub struct GridPlaylistRenderer {
|
||||||
pub video_count_short_text: String,
|
pub video_count_short_text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum RichItem {
|
||||||
|
VideoRenderer(GridVideoRenderer),
|
||||||
|
PlaylistRenderer(GridPlaylistRenderer),
|
||||||
|
}
|
||||||
|
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
@ -485,3 +502,60 @@ pub fn alerts_to_err(alerts: Option<Vec<Alert>>) -> ExtractionError {
|
||||||
None => ExtractionError::InvalidData("no contents".into()),
|
None => ExtractionError::InvalidData("no contents".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait FromWLang<T> {
|
||||||
|
fn from_w_lang(from: T, lang: Language) -> Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
12392
testfiles/channel/channel_videos_20221011_richgrid.json
Normal file
12392
testfiles/channel/channel_videos_20221011_richgrid.json
Normal file
File diff suppressed because it is too large
Load diff
Reference in a new issue