feat: add channel search

This commit is contained in:
ThetaDev 2022-12-08 21:53:44 +01:00
parent 402f5834d2
commit 4c1876cb55
8 changed files with 151 additions and 171 deletions

View file

@ -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**

View file

@ -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",

View file

@ -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,
}

View file

@ -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)]

View file

@ -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);
} }

View file

@ -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,
), ),
) )

View file

@ -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,
), ),
) )

View file

@ -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");