feat: add channel search
This commit is contained in:
parent
402f5834d2
commit
4c1876cb55
8 changed files with 151 additions and 171 deletions
|
|
@ -10,7 +10,7 @@ inspired by [NewPipe](https://github.com/TeamNewPipe/NewPipeExtractor).
|
||||||
- [X] **Player** (video/audio streams, subtitles)
|
- [X] **Player** (video/audio streams, subtitles)
|
||||||
- [X] **Playlist**
|
- [X] **Playlist**
|
||||||
- [X] **VideoDetails** (metadata, comments, recommended videos)
|
- [X] **VideoDetails** (metadata, comments, recommended videos)
|
||||||
- [X] **Channel** (videos, playlists, info)
|
- [X] **Channel** (videos, shorts, livestreams, playlists, info, search)
|
||||||
- [X] **ChannelRSS**
|
- [X] **ChannelRSS**
|
||||||
- [X] **Search** (with filters)
|
- [X] **Search** (with filters)
|
||||||
- [X] **Search suggestions**
|
- [X] **Search suggestions**
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,17 @@
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use time::OffsetDateTime;
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{Error, ExtractionError},
|
error::{Error, ExtractionError},
|
||||||
model::{Channel, ChannelInfo, Paginator, PlaylistItem, VideoItem},
|
model::{Channel, ChannelInfo, Paginator, PlaylistItem, VideoItem, YouTubeItem},
|
||||||
param::Language,
|
param::Language,
|
||||||
serializer::MapResult,
|
serializer::MapResult,
|
||||||
timeago,
|
util,
|
||||||
util::{self, TryRemove},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{response, ClientType, MapResponse, RustyPipeQuery, YTContext};
|
||||||
response::{self, channel::ChannelContent},
|
|
||||||
ClientType, MapResponse, RustyPipeQuery, YTContext,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
@ -24,6 +19,8 @@ struct QChannel<'a> {
|
||||||
context: YTContext<'a>,
|
context: YTContext<'a>,
|
||||||
browse_id: &'a str,
|
browse_id: &'a str,
|
||||||
params: Params,
|
params: Params,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
query: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|
@ -38,6 +35,8 @@ enum Params {
|
||||||
Playlists,
|
Playlists,
|
||||||
#[serde(rename = "EgVhYm91dPIGBAoCEgA%3D")]
|
#[serde(rename = "EgVhYm91dPIGBAoCEgA%3D")]
|
||||||
Info,
|
Info,
|
||||||
|
#[serde(rename = "EgZzZWFyY2jyBgQKAloA")]
|
||||||
|
Search,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RustyPipeQuery {
|
impl RustyPipeQuery {
|
||||||
|
|
@ -45,6 +44,7 @@ impl RustyPipeQuery {
|
||||||
&self,
|
&self,
|
||||||
channel_id: S,
|
channel_id: S,
|
||||||
params: Params,
|
params: Params,
|
||||||
|
query: Option<&str>,
|
||||||
operation: &str,
|
operation: &str,
|
||||||
) -> Result<Channel<Paginator<VideoItem>>, Error> {
|
) -> Result<Channel<Paginator<VideoItem>>, Error> {
|
||||||
let channel_id = channel_id.as_ref();
|
let channel_id = channel_id.as_ref();
|
||||||
|
|
@ -53,6 +53,7 @@ impl RustyPipeQuery {
|
||||||
context,
|
context,
|
||||||
browse_id: channel_id,
|
browse_id: channel_id,
|
||||||
params,
|
params,
|
||||||
|
query,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.execute_request::<response::Channel, _, _>(
|
self.execute_request::<response::Channel, _, _>(
|
||||||
|
|
@ -69,7 +70,7 @@ impl RustyPipeQuery {
|
||||||
&self,
|
&self,
|
||||||
channel_id: S,
|
channel_id: S,
|
||||||
) -> Result<Channel<Paginator<VideoItem>>, Error> {
|
) -> Result<Channel<Paginator<VideoItem>>, Error> {
|
||||||
self._channel_videos(channel_id, Params::Videos, "channel_videos")
|
self._channel_videos(channel_id, Params::Videos, None, "channel_videos")
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +78,7 @@ impl RustyPipeQuery {
|
||||||
&self,
|
&self,
|
||||||
channel_id: S,
|
channel_id: S,
|
||||||
) -> Result<Channel<Paginator<VideoItem>>, Error> {
|
) -> Result<Channel<Paginator<VideoItem>>, Error> {
|
||||||
self._channel_videos(channel_id, Params::Shorts, "channel_shorts")
|
self._channel_videos(channel_id, Params::Shorts, None, "channel_shorts")
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,10 +86,24 @@ impl RustyPipeQuery {
|
||||||
&self,
|
&self,
|
||||||
channel_id: S,
|
channel_id: S,
|
||||||
) -> Result<Channel<Paginator<VideoItem>>, Error> {
|
) -> Result<Channel<Paginator<VideoItem>>, Error> {
|
||||||
self._channel_videos(channel_id, Params::Live, "channel_livestreams")
|
self._channel_videos(channel_id, Params::Live, None, "channel_livestreams")
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn channel_search<S: AsRef<str>, S2: AsRef<str>>(
|
||||||
|
&self,
|
||||||
|
channel_id: S,
|
||||||
|
query: S2,
|
||||||
|
) -> Result<Channel<Paginator<VideoItem>>, Error> {
|
||||||
|
self._channel_videos(
|
||||||
|
channel_id,
|
||||||
|
Params::Search,
|
||||||
|
Some(query.as_ref()),
|
||||||
|
"channel_search",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn channel_playlists<S: AsRef<str>>(
|
pub async fn channel_playlists<S: AsRef<str>>(
|
||||||
&self,
|
&self,
|
||||||
channel_id: S,
|
channel_id: S,
|
||||||
|
|
@ -99,6 +114,7 @@ impl RustyPipeQuery {
|
||||||
context,
|
context,
|
||||||
browse_id: channel_id,
|
browse_id: channel_id,
|
||||||
params: Params::Playlists,
|
params: Params::Playlists,
|
||||||
|
query: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.execute_request::<response::Channel, _, _>(
|
self.execute_request::<response::Channel, _, _>(
|
||||||
|
|
@ -121,6 +137,7 @@ impl RustyPipeQuery {
|
||||||
context,
|
context,
|
||||||
browse_id: channel_id,
|
browse_id: channel_id,
|
||||||
params: Params::Info,
|
params: Params::Info,
|
||||||
|
query: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.execute_request::<response::Channel, _, _>(
|
self.execute_request::<response::Channel, _, _>(
|
||||||
|
|
@ -156,29 +173,20 @@ impl MapResponse<Channel<Paginator<VideoItem>>> for response::Channel {
|
||||||
lang,
|
lang,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let v_res = match content.content {
|
let mut mapper =
|
||||||
ChannelContent::GridRenderer { items } => {
|
response::YouTubeListMapper::<VideoItem>::with_channel(lang, &channel_data);
|
||||||
let mut mapper =
|
mapper.map_response(content.content);
|
||||||
response::YouTubeListMapper::<VideoItem>::with_channel(lang, &channel_data);
|
let p = Paginator::new_ext(
|
||||||
mapper.map_response(items);
|
None,
|
||||||
|
mapper.items,
|
||||||
MapResult {
|
mapper.ctoken,
|
||||||
c: Paginator::new_ext(
|
self.response_context.visitor_data,
|
||||||
None,
|
crate::param::ContinuationEndpoint::Browse,
|
||||||
mapper.items,
|
);
|
||||||
mapper.ctoken,
|
|
||||||
self.response_context.visitor_data,
|
|
||||||
crate::param::ContinuationEndpoint::Browse,
|
|
||||||
),
|
|
||||||
warnings: mapper.warnings,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => MapResult::default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(MapResult {
|
Ok(MapResult {
|
||||||
c: combine_channel_data(channel_data, v_res.c),
|
c: combine_channel_data(channel_data, p),
|
||||||
warnings: v_res.warnings,
|
warnings: mapper.warnings,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -205,23 +213,14 @@ impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel {
|
||||||
lang,
|
lang,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let p_res = match content.content {
|
let mut mapper =
|
||||||
ChannelContent::GridRenderer { items } => {
|
response::YouTubeListMapper::<PlaylistItem>::with_channel(lang, &channel_data);
|
||||||
let mut mapper =
|
mapper.map_response(content.content);
|
||||||
response::YouTubeListMapper::<PlaylistItem>::with_channel(lang, &channel_data);
|
let p = Paginator::new(None, mapper.items, mapper.ctoken);
|
||||||
mapper.map_response(items);
|
|
||||||
|
|
||||||
MapResult {
|
|
||||||
c: Paginator::new(None, mapper.items, mapper.ctoken),
|
|
||||||
warnings: mapper.warnings,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => MapResult::default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(MapResult {
|
Ok(MapResult {
|
||||||
c: combine_channel_data(channel_data, p_res.c),
|
c: combine_channel_data(channel_data, p),
|
||||||
warnings: p_res.warnings,
|
warnings: mapper.warnings,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -234,8 +233,6 @@ impl MapResponse<Channel<ChannelInfo>> for response::Channel {
|
||||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||||
) -> Result<MapResult<Channel<ChannelInfo>>, ExtractionError> {
|
) -> Result<MapResult<Channel<ChannelInfo>>, ExtractionError> {
|
||||||
let content = map_channel_content(self.contents, self.alerts)?;
|
let content = map_channel_content(self.contents, self.alerts)?;
|
||||||
let mut warnings = Vec::new();
|
|
||||||
|
|
||||||
let channel_data = map_channel(
|
let channel_data = map_channel(
|
||||||
MapChannelData {
|
MapChannelData {
|
||||||
header: self.header,
|
header: self.header,
|
||||||
|
|
@ -249,38 +246,18 @@ impl MapResponse<Channel<ChannelInfo>> for response::Channel {
|
||||||
lang,
|
lang,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let cinfo = match content.content {
|
let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(lang);
|
||||||
response::channel::ChannelContent::ChannelAboutFullMetadataRenderer(meta) => {
|
mapper.map_response(content.content);
|
||||||
ChannelInfo {
|
let mut warnings = mapper.warnings;
|
||||||
create_date: timeago::parse_textual_date_or_warn(
|
|
||||||
lang,
|
let cinfo = mapper.channel_info.unwrap_or_else(|| {
|
||||||
&meta.joined_date_text,
|
warnings.push("no aboutFullMetadata".to_owned());
|
||||||
&mut warnings,
|
ChannelInfo {
|
||||||
)
|
create_date: None,
|
||||||
.map(OffsetDateTime::date),
|
view_count: None,
|
||||||
view_count: meta
|
links: Vec::new(),
|
||||||
.view_count_text
|
|
||||||
.and_then(|txt| util::parse_numeric_or_warn(&txt, &mut warnings)),
|
|
||||||
links: meta
|
|
||||||
.primary_links
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|l| {
|
|
||||||
l.navigation_endpoint
|
|
||||||
.url_endpoint
|
|
||||||
.map(|url| (l.title, util::sanitize_yt_url(&url.url)))
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {
|
});
|
||||||
warnings.push("no aboutFullMetadata".to_owned());
|
|
||||||
ChannelInfo {
|
|
||||||
create_date: None,
|
|
||||||
view_count: None,
|
|
||||||
links: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(MapResult {
|
Ok(MapResult {
|
||||||
c: combine_channel_data(channel_data, cinfo),
|
c: combine_channel_data(channel_data, cinfo),
|
||||||
|
|
@ -406,7 +383,7 @@ fn map_channel(
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MappedChannelContent {
|
struct MappedChannelContent {
|
||||||
content: response::channel::ChannelContent,
|
content: MapResult<Vec<response::YouTubeListItem>>,
|
||||||
has_shorts: bool,
|
has_shorts: bool,
|
||||||
has_live: bool,
|
has_live: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -450,31 +427,19 @@ fn map_channel_content(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let channel_content = tabs
|
let channel_content = tabs.into_iter().find_map(|tab| {
|
||||||
.into_iter()
|
tab.tab_renderer
|
||||||
.filter_map(|tab| {
|
.content
|
||||||
let content = tab.tab_renderer.content;
|
.rich_grid_renderer
|
||||||
match (content.rich_grid_renderer, content.section_list_renderer) {
|
.or(tab.tab_renderer.content.section_list_renderer)
|
||||||
(Some(rich_grid), _) => Some(ChannelContent::GridRenderer {
|
});
|
||||||
items: rich_grid.contents,
|
|
||||||
}),
|
|
||||||
(None, Some(section_list)) => {
|
|
||||||
let mut contents = section_list.contents;
|
|
||||||
contents.try_swap_remove(0).and_then(|mut i| {
|
|
||||||
i.item_section_renderer.contents.try_swap_remove(0)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
(None, None) => None,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.next();
|
|
||||||
|
|
||||||
let content = match channel_content {
|
let content = match channel_content {
|
||||||
Some(content) => content,
|
Some(list) => list.contents,
|
||||||
None => {
|
None => {
|
||||||
// YouTube may show the "Featured" tab if the requested tab is empty/does not exist
|
// YouTube may show the "Featured" tab if the requested tab is empty/does not exist
|
||||||
if featured_tab {
|
if featured_tab {
|
||||||
response::channel::ChannelContent::None
|
MapResult::default()
|
||||||
} else {
|
} else {
|
||||||
return Err(ExtractionError::InvalidData(Cow::Borrowed(
|
return Err(ExtractionError::InvalidData(Cow::Borrowed(
|
||||||
"could not extract content",
|
"could not extract content",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError};
|
use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError};
|
||||||
|
|
||||||
use super::url_endpoint::NavigationEndpoint;
|
use super::{
|
||||||
use super::{Alert, ChannelBadge, ContentsRenderer, ResponseContext, Thumbnails, YouTubeListItem};
|
video_item::YouTubeListRenderer, Alert, ChannelBadge, ContentsRenderer, ResponseContext,
|
||||||
use crate::serializer::{text::Text, MapResult, VecLogError};
|
Thumbnails,
|
||||||
|
};
|
||||||
|
use crate::serializer::text::Text;
|
||||||
|
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -39,6 +41,7 @@ pub(crate) struct TabsRenderer {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct TabRendererWrap {
|
pub(crate) struct TabRendererWrap {
|
||||||
|
#[serde(alias = "expandableTabRenderer")]
|
||||||
pub tab_renderer: TabRenderer,
|
pub tab_renderer: TabRenderer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,11 +59,10 @@ pub(crate) struct TabRenderer {
|
||||||
pub(crate) struct TabContent {
|
pub(crate) struct TabContent {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[serde_as(as = "DefaultOnError")]
|
#[serde_as(as = "DefaultOnError")]
|
||||||
pub section_list_renderer: Option<ContentsRenderer<ItemSectionRendererWrap>>,
|
pub section_list_renderer: Option<YouTubeListRenderer>,
|
||||||
/// Seems to be currently A/B tested, as of 11.10.2022
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[serde_as(as = "DefaultOnError")]
|
#[serde_as(as = "DefaultOnError")]
|
||||||
pub rich_grid_renderer: Option<RichGridRenderer>,
|
pub rich_grid_renderer: Option<YouTubeListRenderer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -81,35 +83,6 @@ pub(crate) struct ChannelTabWebCommandMetadata {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seems to be currently A/B tested, as of 11.10.2022
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub(crate) struct RichGridRenderer {
|
|
||||||
#[serde_as(as = "VecLogError<_>")]
|
|
||||||
pub contents: MapResult<Vec<YouTubeListItem>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub(crate) struct ItemSectionRendererWrap {
|
|
||||||
pub item_section_renderer: ContentsRenderer<ChannelContent>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Default, Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub(crate) enum ChannelContent {
|
|
||||||
GridRenderer {
|
|
||||||
#[serde_as(as = "VecLogError<_>")]
|
|
||||||
items: MapResult<Vec<YouTubeListItem>>,
|
|
||||||
},
|
|
||||||
ChannelAboutFullMetadataRenderer(ChannelFullMetadata),
|
|
||||||
#[default]
|
|
||||||
#[serde(other, deserialize_with = "deserialize_ignore_any")]
|
|
||||||
None,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) enum Header {
|
pub(crate) enum Header {
|
||||||
|
|
@ -184,25 +157,3 @@ pub(crate) struct MicroformatDataRenderer {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub(crate) struct ChannelFullMetadata {
|
|
||||||
#[serde_as(as = "Text")]
|
|
||||||
pub joined_date_text: String,
|
|
||||||
#[serde_as(as = "Option<Text>")]
|
|
||||||
pub view_count_text: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
#[serde_as(as = "VecSkipError<_>")]
|
|
||||||
pub primary_links: Vec<PrimaryLink>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub(crate) struct PrimaryLink {
|
|
||||||
#[serde_as(as = "Text")]
|
|
||||||
pub title: String,
|
|
||||||
pub navigation_endpoint: NavigationEndpoint,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ use crate::error::ExtractionError;
|
||||||
use crate::serializer::MapResult;
|
use crate::serializer::MapResult;
|
||||||
use crate::serializer::{text::Text, VecLogError};
|
use crate::serializer::{text::Text, VecLogError};
|
||||||
|
|
||||||
|
use self::video_item::YouTubeListRenderer;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct ContentRenderer<T> {
|
pub(crate) struct ContentRenderer<T> {
|
||||||
|
|
@ -215,15 +217,7 @@ pub(crate) struct ContinuationAction {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct RichGridContinuationContents {
|
pub(crate) struct RichGridContinuationContents {
|
||||||
pub rich_grid_continuation: RichGridContinuation,
|
pub rich_grid_continuation: YouTubeListRenderer,
|
||||||
}
|
|
||||||
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub(crate) struct RichGridContinuation {
|
|
||||||
#[serde_as(as = "VecLogError<_>")]
|
|
||||||
pub contents: MapResult<Vec<YouTubeListItem>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,12 @@ use serde_with::{
|
||||||
};
|
};
|
||||||
use time::{Duration, OffsetDateTime};
|
use time::{Duration, OffsetDateTime};
|
||||||
|
|
||||||
use super::{ChannelBadge, ContinuationEndpoint, Thumbnails};
|
use super::{url_endpoint::NavigationEndpoint, ChannelBadge, ContinuationEndpoint, Thumbnails};
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{Channel, ChannelId, ChannelItem, ChannelTag, PlaylistItem, VideoItem, YouTubeItem},
|
model::{
|
||||||
|
Channel, ChannelId, ChannelInfo, ChannelItem, ChannelTag, PlaylistItem, VideoItem,
|
||||||
|
YouTubeItem,
|
||||||
|
},
|
||||||
param::Language,
|
param::Language,
|
||||||
serializer::{
|
serializer::{
|
||||||
text::{AccessibilityText, Text, TextComponent},
|
text::{AccessibilityText, Text, TextComponent},
|
||||||
|
|
@ -45,6 +48,9 @@ pub(crate) enum YouTubeListItem {
|
||||||
corrected_query: String,
|
corrected_query: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Channel metadata (about tab)
|
||||||
|
ChannelAboutFullMetadataRenderer(ChannelFullMetadata),
|
||||||
|
|
||||||
/// Contains video on startpage
|
/// Contains video on startpage
|
||||||
///
|
///
|
||||||
/// Seems to be currently A/B tested on the channel page,
|
/// Seems to be currently A/B tested on the channel page,
|
||||||
|
|
@ -58,7 +64,9 @@ pub(crate) enum YouTubeListItem {
|
||||||
///
|
///
|
||||||
/// Seems to be currently A/B tested on the video details page,
|
/// Seems to be currently A/B tested on the video details page,
|
||||||
/// as of 11.10.2022
|
/// as of 11.10.2022
|
||||||
#[serde(alias = "expandedShelfContentsRenderer")]
|
///
|
||||||
|
/// GridRenderer: contains videos on channel page
|
||||||
|
#[serde(alias = "expandedShelfContentsRenderer", alias = "gridRenderer")]
|
||||||
ItemSectionRenderer {
|
ItemSectionRenderer {
|
||||||
#[serde(alias = "items")]
|
#[serde(alias = "items")]
|
||||||
#[serde_as(as = "VecLogError<_>")]
|
#[serde_as(as = "VecLogError<_>")]
|
||||||
|
|
@ -326,6 +334,28 @@ pub(crate) struct ReelPlayerHeaderRenderer {
|
||||||
pub timestamp_text: String,
|
pub timestamp_text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct ChannelFullMetadata {
|
||||||
|
#[serde_as(as = "Text")]
|
||||||
|
pub joined_date_text: String,
|
||||||
|
#[serde_as(as = "Option<Text>")]
|
||||||
|
pub view_count_text: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
|
pub primary_links: Vec<PrimaryLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct PrimaryLink {
|
||||||
|
#[serde_as(as = "Text")]
|
||||||
|
pub title: String,
|
||||||
|
pub navigation_endpoint: NavigationEndpoint,
|
||||||
|
}
|
||||||
|
|
||||||
trait IsLive {
|
trait IsLive {
|
||||||
fn is_live(&self) -> bool;
|
fn is_live(&self) -> bool;
|
||||||
}
|
}
|
||||||
|
|
@ -369,6 +399,7 @@ pub(crate) struct YouTubeListMapper<T> {
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
pub ctoken: Option<String>,
|
pub ctoken: Option<String>,
|
||||||
pub corrected_query: Option<String>,
|
pub corrected_query: Option<String>,
|
||||||
|
pub channel_info: Option<ChannelInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> YouTubeListMapper<T> {
|
impl<T> YouTubeListMapper<T> {
|
||||||
|
|
@ -380,6 +411,7 @@ impl<T> YouTubeListMapper<T> {
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
ctoken: None,
|
ctoken: None,
|
||||||
corrected_query: None,
|
corrected_query: None,
|
||||||
|
channel_info: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -397,6 +429,7 @@ impl<T> YouTubeListMapper<T> {
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
ctoken: None,
|
ctoken: None,
|
||||||
corrected_query: None,
|
corrected_query: None,
|
||||||
|
channel_info: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -581,6 +614,28 @@ impl YouTubeListMapper<YouTubeItem> {
|
||||||
YouTubeListItem::ShowingResultsForRenderer { corrected_query } => {
|
YouTubeListItem::ShowingResultsForRenderer { corrected_query } => {
|
||||||
self.corrected_query = Some(corrected_query);
|
self.corrected_query = Some(corrected_query);
|
||||||
}
|
}
|
||||||
|
YouTubeListItem::ChannelAboutFullMetadataRenderer(meta) => {
|
||||||
|
self.channel_info = Some(ChannelInfo {
|
||||||
|
create_date: timeago::parse_textual_date_or_warn(
|
||||||
|
self.lang,
|
||||||
|
&meta.joined_date_text,
|
||||||
|
&mut self.warnings,
|
||||||
|
)
|
||||||
|
.map(OffsetDateTime::date),
|
||||||
|
view_count: meta
|
||||||
|
.view_count_text
|
||||||
|
.and_then(|txt| util::parse_numeric_or_warn(&txt, &mut self.warnings)),
|
||||||
|
links: meta
|
||||||
|
.primary_links
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|l| {
|
||||||
|
l.navigation_endpoint
|
||||||
|
.url_endpoint
|
||||||
|
.map(|url| (l.title, util::sanitize_yt_url(&url.url)))
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
YouTubeListItem::RichItemRenderer { content } => {
|
YouTubeListItem::RichItemRenderer { content } => {
|
||||||
self.map_item(*content);
|
self.map_item(*content);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ Channel(
|
||||||
count: Some(0),
|
count: Some(0),
|
||||||
items: [],
|
items: [],
|
||||||
ctoken: None,
|
ctoken: None,
|
||||||
|
visitor_data: Some("Cgtvc2s4UllvTGl6byigxseZBg%3D%3D"),
|
||||||
endpoint: browse,
|
endpoint: browse,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,7 @@ Channel(
|
||||||
count: Some(0),
|
count: Some(0),
|
||||||
items: [],
|
items: [],
|
||||||
ctoken: None,
|
ctoken: None,
|
||||||
|
visitor_data: Some("CgtCV1l2R2Rzb2ZSZyiu4a2ZBg%3D%3D"),
|
||||||
endpoint: browse,
|
endpoint: browse,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -948,6 +948,19 @@ async fn channel_info() {
|
||||||
"###);
|
"###);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn channel_search() {
|
||||||
|
let rp = RustyPipe::builder().strict().build();
|
||||||
|
let channel = rp
|
||||||
|
.query()
|
||||||
|
.channel_search("UC2DjFE7Xf11URZqWBigcVOQ", "test")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_channel_eevblog(&channel);
|
||||||
|
assert_next(channel.content, rp.query(), 20, 2).await;
|
||||||
|
}
|
||||||
|
|
||||||
fn assert_channel_eevblog<T>(channel: &Channel<T>) {
|
fn assert_channel_eevblog<T>(channel: &Channel<T>) {
|
||||||
assert_eq!(channel.id, "UC2DjFE7Xf11URZqWBigcVOQ");
|
assert_eq!(channel.id, "UC2DjFE7Xf11URZqWBigcVOQ");
|
||||||
assert_eq!(channel.name, "EEVblog");
|
assert_eq!(channel.name, "EEVblog");
|
||||||
|
|
|
||||||
Reference in a new issue