Compare commits

..

5 commits

16 changed files with 12527 additions and 169 deletions

View file

@ -160,7 +160,7 @@ impl MapResponse<Channel<Paginator<ChannelVideo>>> for response::Channel {
lang: Language, lang: Language,
_deobf: Option<&crate::deobfuscate::Deobfuscator>, _deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<Channel<Paginator<ChannelVideo>>>, ExtractionError> { ) -> Result<MapResult<Channel<Paginator<ChannelVideo>>>, ExtractionError> {
let content = map_channel_content(self.contents, id); let content = map_channel_content(self.contents, id, self.alerts)?;
let mut warnings = content.warnings; let mut warnings = content.warnings;
let grid = match content.c { let grid = match content.c {
response::channel::ChannelContent::GridRenderer { items } => Some(items), response::channel::ChannelContent::GridRenderer { items } => Some(items),
@ -191,7 +191,7 @@ impl MapResponse<Channel<Paginator<ChannelPlaylist>>> for response::Channel {
lang: Language, lang: Language,
_deobf: Option<&crate::deobfuscate::Deobfuscator>, _deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<Channel<Paginator<ChannelPlaylist>>>, ExtractionError> { ) -> Result<MapResult<Channel<Paginator<ChannelPlaylist>>>, ExtractionError> {
let content = map_channel_content(self.contents, id); let content = map_channel_content(self.contents, id, self.alerts)?;
let mut warnings = content.warnings; let mut warnings = content.warnings;
let grid = match content.c { let grid = match content.c {
response::channel::ChannelContent::GridRenderer { items } => Some(items), response::channel::ChannelContent::GridRenderer { items } => Some(items),
@ -222,7 +222,7 @@ impl MapResponse<Channel<ChannelInfo>> for response::Channel {
lang: Language, lang: Language,
_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, id); let content = map_channel_content(self.contents, id, self.alerts)?;
let mut warnings = content.warnings; let mut warnings = content.warnings;
let meta = match content.c { let meta = match content.c {
response::channel::ChannelContent::ChannelAboutFullMetadataRenderer(meta) => Some(meta), response::channel::ChannelContent::ChannelAboutFullMetadataRenderer(meta) => Some(meta),
@ -281,12 +281,11 @@ impl MapResponse<Paginator<ChannelVideo>> for response::ChannelCont {
_deobf: Option<&crate::deobfuscate::Deobfuscator>, _deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<Paginator<ChannelVideo>>, ExtractionError> { ) -> Result<MapResult<Paginator<ChannelVideo>>, ExtractionError> {
let mut actions = self.on_response_received_actions; let mut actions = self.on_response_received_actions;
let res = some_or_bail!( let res = actions
actions.try_swap_remove(0), .try_swap_remove(0)
Err(ExtractionError::InvalidData("no received action".into())) .ok_or(ExtractionError::Retry)?
) .append_continuation_items_action
.append_continuation_items_action .continuation_items;
.continuation_items;
Ok(map_videos(res, lang)) Ok(map_videos(res, lang))
} }
@ -300,12 +299,11 @@ impl MapResponse<Paginator<ChannelPlaylist>> for response::ChannelCont {
_deobf: Option<&crate::deobfuscate::Deobfuscator>, _deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<Paginator<ChannelPlaylist>>, ExtractionError> { ) -> Result<MapResult<Paginator<ChannelPlaylist>>, ExtractionError> {
let mut actions = self.on_response_received_actions; let mut actions = self.on_response_received_actions;
let res = some_or_bail!( let res = actions
actions.try_swap_remove(0), .try_swap_remove(0)
Err(ExtractionError::InvalidData("no received action".into())) .ok_or(ExtractionError::Retry)?
) .append_continuation_items_action
.append_continuation_items_action .continuation_items;
.continuation_items;
Ok(map_playlists(res)) Ok(map_playlists(res))
} }
@ -419,14 +417,18 @@ fn map_vanity_url(url: &str, id: &str) -> Option<String> {
} }
fn map_channel<T>( fn map_channel<T>(
header: response::channel::Header, header: Option<response::channel::Header>,
metadata: response::channel::Metadata, metadata: Option<response::channel::Metadata>,
microformat: response::channel::Microformat, microformat: Option<response::channel::Microformat>,
content: T, content: T,
id: &str, id: &str,
lang: Language, lang: Language,
) -> Result<Channel<T>, ExtractionError> { ) -> Result<Channel<T>, ExtractionError> {
let metadata = metadata.channel_metadata_renderer; let header = header.ok_or(ExtractionError::NoData)?;
let metadata = metadata
.ok_or(ExtractionError::NoData)?
.channel_metadata_renderer;
let microformat = microformat.ok_or(ExtractionError::NoData)?;
if metadata.external_id != id { if metadata.external_id != id {
return Err(ExtractionError::WrongResult(format!( return Err(ExtractionError::WrongResult(format!(
@ -494,39 +496,45 @@ fn map_channel<T>(
} }
fn map_channel_content( fn map_channel_content(
contents: response::channel::Contents, contents: Option<response::channel::Contents>,
id: &str, id: &str,
) -> MapResult<response::channel::ChannelContent> { alerts: Option<Vec<response::Alert>>,
let mut tabs = contents.two_column_browse_results_renderer.tabs; ) -> Result<MapResult<response::channel::ChannelContent>, ExtractionError> {
let mut sectionlist = some_or_bail!( match contents {
tabs.try_swap_remove(0), Some(contents) => {
MapResult::error("no tab".to_owned()) let mut tabs = contents.two_column_browse_results_renderer.tabs;
) let mut sectionlist = some_or_bail!(
.tab_renderer tabs.try_swap_remove(0),
.content Ok(MapResult::error("no tab".to_owned()))
.section_list_renderer; )
.tab_renderer
.content
.section_list_renderer;
if let Some(target_id) = sectionlist.target_id { if let Some(target_id) = sectionlist.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)) {
return MapResult::ok(response::channel::ChannelContent::None); return Ok(MapResult::ok(response::channel::ChannelContent::None));
}
}
let mut itemsection = some_or_bail!(
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)),
} }
let mut itemsection = some_or_bail!(
sectionlist.contents.try_swap_remove(0),
MapResult::error("no sectionlist".to_owned())
)
.item_section_renderer
.contents;
let content = some_or_bail!(
itemsection.try_swap_remove(0),
MapResult::error("no channel content".to_owned())
);
MapResult::ok(content)
} }
#[cfg(test)] #[cfg(test)]

View file

@ -169,7 +169,8 @@ struct RustyPipeRef {
http: Client, http: Client,
storage: Option<Box<dyn CacheStorage>>, storage: Option<Box<dyn CacheStorage>>,
reporter: Option<Box<dyn Reporter>>, reporter: Option<Box<dyn Reporter>>,
n_retries: u32, n_http_retries: u32,
n_query_retries: u32,
consent_cookie: String, consent_cookie: String,
cache: CacheHolder, cache: CacheHolder,
default_opts: RustyPipeOpts, default_opts: RustyPipeOpts,
@ -186,7 +187,8 @@ struct RustyPipeOpts {
pub struct RustyPipeBuilder { pub struct RustyPipeBuilder {
storage: Option<Box<dyn CacheStorage>>, storage: Option<Box<dyn CacheStorage>>,
reporter: Option<Box<dyn Reporter>>, reporter: Option<Box<dyn Reporter>>,
n_retries: u32, n_http_retries: u32,
n_query_retries: u32,
user_agent: String, user_agent: String,
default_opts: RustyPipeOpts, default_opts: RustyPipeOpts,
} }
@ -277,7 +279,8 @@ impl RustyPipeBuilder {
default_opts: RustyPipeOpts::default(), default_opts: RustyPipeOpts::default(),
storage: Some(Box::new(FileStorage::default())), storage: Some(Box::new(FileStorage::default())),
reporter: Some(Box::new(FileReporter::default())), reporter: Some(Box::new(FileReporter::default())),
n_retries: 3, n_http_retries: 3,
n_query_retries: 2,
user_agent: DEFAULT_UA.to_owned(), user_agent: DEFAULT_UA.to_owned(),
} }
} }
@ -312,7 +315,8 @@ impl RustyPipeBuilder {
http, http,
storage: self.storage, storage: self.storage,
reporter: self.reporter, reporter: self.reporter,
n_retries: self.n_retries, n_http_retries: self.n_http_retries,
n_query_retries: self.n_query_retries,
consent_cookie: format!( consent_cookie: format!(
"{}={}{}", "{}={}{}",
CONSENT_COOKIE, CONSENT_COOKIE,
@ -367,8 +371,18 @@ impl RustyPipeBuilder {
/// random jitter to be less predictable). /// random jitter to be less predictable).
/// ///
/// **Default value**: 3 /// **Default value**: 3
pub fn n_retries(mut self, n_retries: u32) -> Self { pub fn n_http_retries(mut self, n_retries: u32) -> Self {
self.n_retries = n_retries; self.n_http_retries = n_retries;
self
}
/// Set the number of retries for YouTube API queries.
///
/// If a YouTube API requests returns invalid data, the request is repeated.
///
/// **Default value**: 2
pub fn n_query_retries(mut self, n_retries: u32) -> Self {
self.n_http_retries = n_retries;
self self
} }
@ -458,7 +472,7 @@ impl RustyPipe {
request: Request, request: Request,
) -> core::result::Result<Response, reqwest::Error> { ) -> core::result::Result<Response, reqwest::Error> {
let mut last_res = None; let mut last_res = None;
for n in 0..self.inner.n_retries { for n in 0..self.inner.n_http_retries {
let res = self.inner.http.execute(request.try_clone().unwrap()).await; let res = self.inner.http.execute(request.try_clone().unwrap()).await;
let emsg = match &res { let emsg = match &res {
Ok(response) => { Ok(response) => {
@ -939,6 +953,56 @@ impl RustyPipeQuery {
endpoint: &str, endpoint: &str,
body: &B, body: &B,
deobf: Option<&Deobfuscator>, deobf: Option<&Deobfuscator>,
) -> Result<M> {
for n in 0..self.client.inner.n_query_retries.saturating_sub(1) {
let res = self
._try_execute_request_deobf::<R, M, B>(
ctype,
operation,
id,
endpoint,
body,
deobf,
n == 0,
)
.await;
let emsg = match res {
Ok(res) => return Ok(res),
Err(error) => match &error {
Error::Extraction(e) => match e {
ExtractionError::Deserialization(_)
| ExtractionError::InvalidData(_)
| ExtractionError::WrongResult(_)
| ExtractionError::Retry => e.to_string(),
_ => return Err(error),
},
_ => return Err(error),
},
};
warn!("{} retry attempt #{}. Error: {}.", operation, n, emsg);
}
self._try_execute_request_deobf::<R, M, B>(
ctype, operation, id, endpoint, body, deobf, false,
)
.await
}
/// Single try of `execute_request_deobf`
#[allow(clippy::too_many_arguments)]
async fn _try_execute_request_deobf<
R: DeserializeOwned + MapResponse<M> + Debug,
M,
B: Serialize + ?Sized,
>(
&self,
ctype: ClientType,
operation: &str,
id: &str,
endpoint: &str,
body: &B,
deobf: Option<&Deobfuscator>,
report: bool,
) -> Result<M> { ) -> Result<M> {
let request = self let request = self
.request_builder(ctype, endpoint) .request_builder(ctype, endpoint)
@ -949,36 +1013,38 @@ impl RustyPipeQuery {
let request_url = request.url().to_string(); let request_url = request.url().to_string();
let request_headers = request.headers().to_owned(); let request_headers = request.headers().to_owned();
let response = self.client.inner.http.execute(request).await?; let response = self.client.http_request(request).await?;
let status = response.status(); let status = response.status();
let resp_str = response.text().await?; let resp_str = response.text().await?;
let create_report = |level: Level, error: Option<String>, msgs: Vec<String>| { let create_report = |level: Level, error: Option<String>, msgs: Vec<String>| {
if let Some(reporter) = &self.client.inner.reporter { if report {
let report = Report { if let Some(reporter) = &self.client.inner.reporter {
info: Default::default(), let report = Report {
level, info: Default::default(),
operation: format!("{}({})", operation, id), level,
error, operation: format!("{}({})", operation, id),
msgs, error,
deobf_data: deobf.map(Deobfuscator::get_data), msgs,
http_request: crate::report::HTTPRequest { deobf_data: deobf.map(Deobfuscator::get_data),
url: request_url, http_request: crate::report::HTTPRequest {
method: "POST".to_string(), url: request_url,
req_header: request_headers method: "POST".to_string(),
.iter() req_header: request_headers
.map(|(k, v)| { .iter()
(k.to_string(), v.to_str().unwrap_or_default().to_owned()) .map(|(k, v)| {
}) (k.to_string(), v.to_str().unwrap_or_default().to_owned())
.collect(), })
req_body: serde_json::to_string(body).unwrap_or_default(), .collect(),
status: status.into(), req_body: serde_json::to_string(body).unwrap_or_default(),
resp_body: resp_str.to_owned(), status: status.into(),
}, resp_body: resp_str.to_owned(),
}; },
};
reporter.report(&report); reporter.report(&report);
}
} }
}; };
@ -1009,7 +1075,14 @@ impl RustyPipeQuery {
Ok(mapres.c) Ok(mapres.c)
} }
Err(e) => { Err(e) => {
create_report(Level::ERR, Some(e.to_string()), Vec::new()); match e {
ExtractionError::VideoUnavailable(_, _)
| ExtractionError::VideoAgeRestricted
| ExtractionError::ContentUnavailable(_)
| ExtractionError::NoData
| ExtractionError::Retry => (),
_ => create_report(Level::ERR, Some(e.to_string()), Vec::new()),
}
Err(e.into()) Err(e.into())
} }
}, },

View file

@ -68,8 +68,12 @@ impl MapResponse<Playlist> for response::Playlist {
lang: Language, lang: Language,
_deobf: Option<&Deobfuscator>, _deobf: Option<&Deobfuscator>,
) -> Result<MapResult<Playlist>, ExtractionError> { ) -> Result<MapResult<Playlist>, ExtractionError> {
// TODO: think about a deserializer that deserializes only first list item let (contents, header) = match (self.contents, self.header) {
let mut tcbr_contents = self.contents.two_column_browse_results_renderer.contents; (Some(contents), Some(header)) => (contents, header),
_ => return Err(response::alerts_to_err(self.alerts)),
};
let mut tcbr_contents = contents.two_column_browse_results_renderer.contents;
let video_items = some_or_bail!( let video_items = some_or_bail!(
some_or_bail!( some_or_bail!(
some_or_bail!( some_or_bail!(
@ -121,11 +125,11 @@ impl MapResponse<Playlist> for response::Playlist {
} }
None => { None => {
let header_banner = some_or_bail!( let header_banner = some_or_bail!(
self.header.playlist_header_renderer.playlist_header_banner, header.playlist_header_renderer.playlist_header_banner,
Err(ExtractionError::InvalidData("no thumbnail found".into())) Err(ExtractionError::InvalidData("no thumbnail found".into()))
); );
let mut byline = self.header.playlist_header_renderer.byline; let mut byline = header.playlist_header_renderer.byline;
let last_update_txt = byline let last_update_txt = byline
.try_swap_remove(1) .try_swap_remove(1)
.map(|b| b.playlist_byline_renderer.text); .map(|b| b.playlist_byline_renderer.text);
@ -140,14 +144,14 @@ impl MapResponse<Playlist> for response::Playlist {
let n_videos = match ctoken { let n_videos = match ctoken {
Some(_) => { Some(_) => {
ok_or_bail!( ok_or_bail!(
util::parse_numeric(&self.header.playlist_header_renderer.num_videos_text), util::parse_numeric(&header.playlist_header_renderer.num_videos_text),
Err(ExtractionError::InvalidData("no video count".into())) Err(ExtractionError::InvalidData("no video count".into()))
) )
} }
None => videos.len() as u64, None => videos.len() as u64,
}; };
let playlist_id = self.header.playlist_header_renderer.playlist_id; let playlist_id = header.playlist_header_renderer.playlist_id;
if playlist_id != id { if playlist_id != id {
return Err(ExtractionError::WrongResult(format!( return Err(ExtractionError::WrongResult(format!(
"got wrong playlist id {}, expected {}", "got wrong playlist id {}, expected {}",
@ -155,10 +159,9 @@ impl MapResponse<Playlist> for response::Playlist {
))); )));
} }
let name = self.header.playlist_header_renderer.title; let name = header.playlist_header_renderer.title;
let description = self.header.playlist_header_renderer.description_text; let description = header.playlist_header_renderer.description_text;
let channel = self let channel = header
.header
.playlist_header_renderer .playlist_header_renderer
.owner_text .owner_text
.and_then(|link| ChannelId::try_from(link).ok()); .and_then(|link| ChannelId::try_from(link).ok());
@ -193,12 +196,7 @@ impl MapResponse<Paginator<PlaylistVideo>> for response::PlaylistCont {
_deobf: Option<&Deobfuscator>, _deobf: Option<&Deobfuscator>,
) -> Result<MapResult<Paginator<PlaylistVideo>>, ExtractionError> { ) -> Result<MapResult<Paginator<PlaylistVideo>>, ExtractionError> {
let mut actions = self.on_response_received_actions; let mut actions = self.on_response_received_actions;
let action = some_or_bail!( let action = actions.try_swap_remove(0).ok_or(ExtractionError::Retry)?;
actions.try_swap_remove(0),
Err(ExtractionError::InvalidData(
"no continuation action".into()
))
);
let (items, ctoken) = let (items, ctoken) =
map_playlist_items(action.append_continuation_items_action.continuation_items.c); map_playlist_items(action.append_continuation_items_action.continuation_items.c);

View file

@ -1,9 +1,9 @@
use serde::Deserialize; use serde::Deserialize;
use serde_with::serde_as; use serde_with::serde_as;
use serde_with::VecSkipError; use serde_with::{DefaultOnError, VecSkipError};
use super::ChannelBadge;
use super::Thumbnails; use super::Thumbnails;
use super::{Alert, ChannelBadge};
use super::{ContentRenderer, ContentsRenderer, VideoListItem}; use super::{ContentRenderer, ContentsRenderer, VideoListItem};
use crate::serializer::ignore_any; use crate::serializer::ignore_any;
use crate::serializer::{text::Text, MapResult, VecLogError}; use crate::serializer::{text::Text, MapResult, VecLogError};
@ -12,16 +12,21 @@ use crate::serializer::{text::Text, MapResult, VecLogError};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Channel { pub struct Channel {
pub header: Header, #[serde_as(as = "DefaultOnError")]
pub contents: Contents, pub header: Option<Header>,
pub metadata: Metadata, pub contents: Option<Contents>,
pub microformat: Microformat, pub metadata: Option<Metadata>,
pub microformat: Option<Microformat>,
#[serde_as(as = "Option<DefaultOnError>")]
pub alerts: Option<Vec<Alert>>,
} }
#[serde_as] #[serde_as]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ChannelCont { pub struct ChannelCont {
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub on_response_received_actions: Vec<OnResponseReceivedAction>, pub on_response_received_actions: Vec<OnResponseReceivedAction>,
} }

View file

@ -25,6 +25,7 @@ pub use channel_rss::ChannelRss;
use serde::Deserialize; 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::serializer::{ use crate::serializer::{
ignore_any, ignore_any,
text::{Text, TextComponent}, text::{Text, TextComponent},
@ -313,6 +314,20 @@ pub enum VideoBadgeStyle {
BadgeStyleTypeLiveNow, BadgeStyleTypeLiveNow,
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Alert {
pub alert_renderer: AlertRenderer,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AlertRenderer {
#[serde_as(as = "Text")]
pub text: String,
}
// YouTube Music // YouTube Music
#[serde_as] #[serde_as]
@ -457,3 +472,16 @@ impl IsShort for Vec<TimeOverlay> {
}) })
} }
} }
pub fn alerts_to_err(alerts: Option<Vec<Alert>>) -> ExtractionError {
match alerts {
Some(alerts) => ExtractionError::ContentUnavailable(
alerts
.into_iter()
.map(|a| a.alert_renderer.text)
.collect::<Vec<_>>()
.join(" "),
),
None => ExtractionError::InvalidData("no contents".into()),
}
}

View file

@ -5,20 +5,24 @@ use serde_with::{DefaultOnError, VecSkipError};
use crate::serializer::text::{Text, TextComponent}; use crate::serializer::text::{Text, TextComponent};
use crate::serializer::{MapResult, VecLogError}; use crate::serializer::{MapResult, VecLogError};
use super::{ContentRenderer, ContentsRenderer, ThumbnailsWrap, VideoListItem}; use super::{Alert, ContentRenderer, ContentsRenderer, ThumbnailsWrap, VideoListItem};
#[serde_as]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Playlist { pub struct Playlist {
pub contents: Contents, pub contents: Option<Contents>,
pub header: Header, pub header: Option<Header>,
pub sidebar: Option<Sidebar>, pub sidebar: Option<Sidebar>,
#[serde_as(as = "Option<DefaultOnError>")]
pub alerts: Option<Vec<Alert>>,
} }
#[serde_as] #[serde_as]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PlaylistCont { pub struct PlaylistCont {
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")] #[serde_as(as = "VecSkipError<_>")]
pub on_response_received_actions: Vec<OnResponseReceivedAction>, pub on_response_received_actions: Vec<OnResponseReceivedAction>,
} }

View file

@ -25,6 +25,7 @@ pub struct Search {
pub struct SearchCont { pub struct SearchCont {
#[serde_as(as = "Option<JsonString>")] #[serde_as(as = "Option<JsonString>")]
pub estimated_results: Option<u64>, pub estimated_results: Option<u64>,
#[serde_as(as = "VecSkipError<_>")]
pub on_response_received_commands: Vec<SearchContCommand>, pub on_response_received_commands: Vec<SearchContCommand>,
} }

View file

@ -11,7 +11,8 @@ use crate::serializer::{
}; };
use super::{ use super::{
ContinuationEndpoint, ContinuationItemRenderer, Icon, Thumbnails, VideoListItem, VideoOwner, ContinuationEndpoint, ContinuationItemRenderer, Icon, MusicContinuation, Thumbnails,
VideoListItem, VideoOwner,
}; };
/* /*
@ -282,6 +283,8 @@ pub struct RecommendationResults {
/// Can be `None` for age-restricted videos /// Can be `None` for age-restricted videos
#[serde_as(as = "Option<VecLogError<_>>")] #[serde_as(as = "Option<VecLogError<_>>")]
pub results: Option<MapResult<Vec<VideoListItem>>>, pub results: Option<MapResult<Vec<VideoListItem>>>,
#[serde_as(as = "Option<VecSkipError<_>>")]
pub continuations: Option<Vec<MusicContinuation>>,
} }
/// The engagement panels are displayed below the video and contain chapter markers /// The engagement panels are displayed below the video and contain chapter markers
@ -418,9 +421,12 @@ pub struct CommentItemSectionHeaderMenuItem {
*/ */
/// Video recommendations continuation response /// Video recommendations continuation response
#[serde_as]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct VideoRecommendations { pub struct VideoRecommendations {
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub on_response_received_endpoints: Vec<RecommendationsContItem>, pub on_response_received_endpoints: Vec<RecommendationsContItem>,
} }
@ -459,8 +465,8 @@ pub struct VideoComments {
/// - Comment replies: appendContinuationItemsAction /// - Comment replies: appendContinuationItemsAction
/// - n*commentRenderer, continuationItemRenderer: /// - n*commentRenderer, continuationItemRenderer:
/// replies + continuation /// replies + continuation
#[serde_as(as = "VecLogError<_>")] #[serde_as(as = "Option<VecLogError<_>>")]
pub on_response_received_endpoints: MapResult<Vec<CommentsContItem>>, pub on_response_received_endpoints: Option<MapResult<Vec<CommentsContItem>>>,
} }
/// Video comments continuation /// Video comments continuation

View file

@ -0,0 +1,783 @@
---
source: src/client/video_details.rs
expression: map_res.c
---
VideoDetails(
id: "ZeerrnuLi5E",
title: "aespa 에스파 \'Black Mamba\' MV",
description: RichText([
Text("🎧Listen and download aespa\'s debut single \"Black Mamba\": "),
Web(
text: "https://smarturl.it/aespa_BlackMamba",
url: "https://smarturl.it/aespa_BlackMamba",
),
Text("\n🐍The Debut Stage "),
Video(
text: "https://youtu.be/Ky5RT5oGg0w",
id: "Ky5RT5oGg0w",
start_time: 0,
),
Text("\n\n🎟\u{fe0f} aespa Showcase SYNK in LA! Tickets now on sale: "),
Web(
text: "https://www.ticketmaster.com/event/0A...",
url: "https://www.ticketmaster.com/event/0A005CCD9E871F6E",
),
Text("\n\nSubscribe to aespa Official YouTube Channel!\n"),
Web(
text: "https://www.youtube.com/aespa?sub_con...",
url: "https://www.youtube.com/aespa?sub_confirmation=1",
),
Text("\n\naespa official\n"),
Web(
text: "https://www.youtube.com/c/aespa",
url: "https://www.youtube.com/c/aespa",
),
Text("\n"),
Web(
text: "https://www.instagram.com/aespa_official",
url: "https://www.instagram.com/aespa_official",
),
Text("\n"),
Web(
text: "https://www.tiktok.com/@aespa_official",
url: "https://www.tiktok.com/@aespa_official",
),
Text("\n"),
Web(
text: "https://twitter.com/aespa_Official",
url: "https://twitter.com/aespa_Official",
),
Text("\n"),
Web(
text: "https://www.facebook.com/aespa.official",
url: "https://www.facebook.com/aespa.official",
),
Text("\n"),
Web(
text: "https://weibo.com/aespa",
url: "https://weibo.com/aespa",
),
Text("\n\n"),
Text("#aespa"),
Text(" "),
Text("#æspa"),
Text(" "),
Text("#BlackMamba"),
Text(" "),
Text("#블랙맘바"),
Text(" "),
Text("#에스파"),
Text("\naespa 에스파 \'Black Mamba\' MV ℗ SM Entertainment"),
]),
channel: ChannelTag(
id: "UCEf_Bc-KVd7onSeifS3py9g",
name: "SMTOWN",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/_1Z4I2qpWaCN9g3BcDd3cVA9MDHOG43lE1YNWDNkKro49haGxkjwuFK-I8faWTKM6Jle9qb4ag=s48-c-k-c0x00ffffff-no-rj",
width: 48,
height: 48,
),
Thumbnail(
url: "https://yt3.ggpht.com/_1Z4I2qpWaCN9g3BcDd3cVA9MDHOG43lE1YNWDNkKro49haGxkjwuFK-I8faWTKM6Jle9qb4ag=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
Thumbnail(
url: "https://yt3.ggpht.com/_1Z4I2qpWaCN9g3BcDd3cVA9MDHOG43lE1YNWDNkKro49haGxkjwuFK-I8faWTKM6Jle9qb4ag=s176-c-k-c0x00ffffff-no-rj",
width: 176,
height: 176,
),
],
verification: verified,
subscriber_count: Some(31000000),
),
view_count: 234258725,
like_count: Some(4027586),
publish_date: "[date]",
publish_date_txt: "Nov 17, 2020",
is_live: false,
is_ccommons: false,
chapters: [],
recommended: Paginator(
count: None,
items: [
RecommendedVideo(
id: "WPdWvnAAurg",
title: "aespa 에스파 \'Savage\' MV",
length: Some(259),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/WPdWvnAAurg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDQGxlnDkAdMYRm2cdkDmiDbBDpYw",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/WPdWvnAAurg/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAIHFE0eH_r-HP7DRPv1QJJnRDzWw",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCEf_Bc-KVd7onSeifS3py9g",
name: "SMTOWN",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/_1Z4I2qpWaCN9g3BcDd3cVA9MDHOG43lE1YNWDNkKro49haGxkjwuFK-I8faWTKM6Jle9qb4ag=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("1 year ago"),
view_count: 218055265,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "4TWR90KJl84",
title: "aespa 에스파 \'Next Level\' MV",
length: Some(236),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/4TWR90KJl84/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBYCGc-AKsDC6UpJgIZw2_VsqjVWA",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/4TWR90KJl84/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDh-eDxZBmrNsHcb6pYX0Gyx6gJ8Q",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCEf_Bc-KVd7onSeifS3py9g",
name: "SMTOWN",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/_1Z4I2qpWaCN9g3BcDd3cVA9MDHOG43lE1YNWDNkKro49haGxkjwuFK-I8faWTKM6Jle9qb4ag=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("1 year ago"),
view_count: 248023999,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "uR8Mrt1IpXg",
title: "Red Velvet 레드벨벳 \'Psycho\' MV",
length: Some(216),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/uR8Mrt1IpXg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAnAsLcZaI1uWDB4nag1KnNotAUWw",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/uR8Mrt1IpXg/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBY8Von40LZlH0BIduElAOd7YQ3KQ",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCEf_Bc-KVd7onSeifS3py9g",
name: "SMTOWN",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/_1Z4I2qpWaCN9g3BcDd3cVA9MDHOG43lE1YNWDNkKro49haGxkjwuFK-I8faWTKM6Jle9qb4ag=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("2 years ago"),
view_count: 347102621,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "UUUWIGx3hDE",
title: "ITZY \"WANNABE\" Performance Video",
length: Some(198),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/UUUWIGx3hDE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAe05b8SVKrrSU0MSOcxluyp1R_aA",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/UUUWIGx3hDE/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC6B8WyE4aYQfJrjBMKxz0H-G23Og",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCDhM2k2Cua-JdobAh5moMFg",
name: "ITZY",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/jQq2YC6CxI82cb54SCLCVKgrL7AHhaccGr8JQcFMBagJ64URg5UNpYNmlIqQ7i7ODdSOUENjSg=s88-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: artist,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("2 years ago"),
view_count: 97453393,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "NoYKBAajoyo",
title: "EVERGLOW (에버글로우) - DUN DUN MV",
length: Some(209),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/NoYKBAajoyo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC3OhCUbjpIclmjfV8W8T98nVI5pA",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/NoYKBAajoyo/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA-CdJunWg1z_pnrT55qagTHnxkdQ",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UC_pwIXKXNm5KGhdEVzmY60A",
name: "Stone Music Entertainment",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/jv3r-jNHhG2jktdZcbxgdOUqdX6Yu-AbrpS6kYpYAeoAc0nZyMB5x7jjdjoDzxmHo2Q0LZQC=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("2 years ago"),
view_count: 266364690,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "32si5cfrCNc",
title: "BLACKPINK - \'How You Like That\' DANCE PERFORMANCE VIDEO",
length: Some(181),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/32si5cfrCNc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBjimPvMxDwTmPBlKX8Buo9EjMeOg",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/32si5cfrCNc/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDCsJMBcdZaForwAnhjYy3L1JT1hQ",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCOmHUn--16B90oW2L6FRR3A",
name: "BLACKPINK",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/hZDUwjoeQqigphL4A1tkg9c6hVp5yXmbboBR7PYFUSFj5PIJSA483NB5v7b0XVoTN9GCku3tqQ=s88-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: artist,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("2 years ago"),
view_count: 1254749733,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "CM4CkVFmTds",
title: "TWICE \"I CAN\'T STOP ME\" M/V",
length: Some(221),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/CM4CkVFmTds/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBfd7QADIduQSR2ESLIp1k5gxxNDg",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/CM4CkVFmTds/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDRn7hTXV_Ls30E6BQNZQtQjbuEpA",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCaO6TYtlC8U5ttz62hTrZgg",
name: "JYP Entertainment",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/fxlLUAZQPfYiK_6B-8ZQDbT1C_o-LkTTT75RO_JZ_78SbTSrNrRHB-X7nJkUJYKUb2XOos_Tnw=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("1 year ago"),
view_count: 459831562,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "UZPZyd5vE1c",
title: "Shut Down",
length: Some(176),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/UZPZyd5vE1c/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD0elXCfbeIuNyk1C4xJkfSUZrJPg",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/UZPZyd5vE1c/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDnA-7uKZLgLXvc4DbgvpRyODNPrg",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCOmHUn--16B90oW2L6FRR3A",
name: "BLACKPINK",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/hZDUwjoeQqigphL4A1tkg9c6hVp5yXmbboBR7PYFUSFj5PIJSA483NB5v7b0XVoTN9GCku3tqQ=s88-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: artist,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("3 weeks ago"),
view_count: 7118730,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "CKZvWhCqx1s",
title: "ROSÉ - \'On The Ground\' M/V",
length: Some(189),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/CKZvWhCqx1s/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC4uq8-ViYtFE0-2feawfW_IEADxg",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/CKZvWhCqx1s/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC4j67LyXvM7yBQrqhAQPrdOIExHg",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCOmHUn--16B90oW2L6FRR3A",
name: "BLACKPINK",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/hZDUwjoeQqigphL4A1tkg9c6hVp5yXmbboBR7PYFUSFj5PIJSA483NB5v7b0XVoTN9GCku3tqQ=s88-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: artist,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("1 year ago"),
view_count: 300492226,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "fE2h3lGlOsk",
title: "ITZY \"WANNABE\" M/V @ITZY",
length: Some(219),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/fE2h3lGlOsk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC44Q0lpu5a8rltgTMxi0X2QA6jnQ",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/fE2h3lGlOsk/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC6F85UnQjP3_9U0gehdYbbF6NTxw",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCaO6TYtlC8U5ttz62hTrZgg",
name: "JYP Entertainment",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/fxlLUAZQPfYiK_6B-8ZQDbT1C_o-LkTTT75RO_JZ_78SbTSrNrRHB-X7nJkUJYKUb2XOos_Tnw=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("2 years ago"),
view_count: 469178299,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "Y8JFxS1HlDo",
title: "IVE 아이브 \'LOVE DIVE\' MV",
length: Some(179),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/Y8JFxS1HlDo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDf8U7fRH0R-qXbbGwKwpKBCeOa4A",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/Y8JFxS1HlDo/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDOopxOvyhTYJ-zF5yqFpEl5_W_EQ",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCYDmx2Sfpnaxg488yBpZIGg",
name: "starshipTV",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AMLnZu_09DwCM_6aPAyhOP_HYK1v1Jm9YdYwW1zLtBkP3w=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("6 months ago"),
view_count: 161053206,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "dNCWe_6HAM8",
title: "LISA - \'MONEY\' EXCLUSIVE PERFORMANCE VIDEO",
length: Some(171),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/dNCWe_6HAM8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDdT1JD7bbEJ3z7fsQQ59tWeQUwkw",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/dNCWe_6HAM8/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBTecIbmrlrTBt4sMGNPVJkHpOGtA",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCOmHUn--16B90oW2L6FRR3A",
name: "BLACKPINK",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/hZDUwjoeQqigphL4A1tkg9c6hVp5yXmbboBR7PYFUSFj5PIJSA483NB5v7b0XVoTN9GCku3tqQ=s88-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: artist,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("1 year ago"),
view_count: 694135299,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "tyrVtwE8Gv0",
title: "NCT U 엔시티 유 \'Make A Wish (Birthday Song)\' MV",
length: Some(249),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/tyrVtwE8Gv0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDjumgWjrKFVPhKG0HyX9aEdP203g",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/tyrVtwE8Gv0/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAJDjnvc6ilNrdXRkFjThG28Dph3A",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCEf_Bc-KVd7onSeifS3py9g",
name: "SMTOWN",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/_1Z4I2qpWaCN9g3BcDd3cVA9MDHOG43lE1YNWDNkKro49haGxkjwuFK-I8faWTKM6Jle9qb4ag=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("1 year ago"),
view_count: 256797155,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "gU2HqP4NxUs",
title: "BLACKPINK - Pretty Savage 1011 SBS Inkigayo",
length: Some(208),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/gU2HqP4NxUs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD_x0P5jlgH-Xg013D6_0HCVjmpEQ",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/gU2HqP4NxUs/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDriklJAXGJ8a0wuSkNQI3gm_JzCQ",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCOmHUn--16B90oW2L6FRR3A",
name: "BLACKPINK",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/hZDUwjoeQqigphL4A1tkg9c6hVp5yXmbboBR7PYFUSFj5PIJSA483NB5v7b0XVoTN9GCku3tqQ=s88-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: artist,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("2 years ago"),
view_count: 285625201,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "Ujb-gvqsoi0",
title: "Red Velvet - IRENE & SEULGI \'Monster\' MV",
length: Some(182),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/Ujb-gvqsoi0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBrGO-Gkm-UqCln07oFNKfFgioXYQ",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/Ujb-gvqsoi0/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDeaEGoH8CCM5osz_jfzbKzkPKHuA",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCEf_Bc-KVd7onSeifS3py9g",
name: "SMTOWN",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/_1Z4I2qpWaCN9g3BcDd3cVA9MDHOG43lE1YNWDNkKro49haGxkjwuFK-I8faWTKM6Jle9qb4ag=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("2 years ago"),
view_count: 127297352,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "KhTeiaCezwM",
title: "[MV] MAMAMOO (마마무) - HIP",
length: Some(211),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/KhTeiaCezwM/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCMGgSAC2vrBvhW5_JvAG6-DmNv_Q",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/KhTeiaCezwM/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA_AtcABVzc3_EHCbI_4rX0p5TdPg",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCuhAUMLzJxlP1W7mEk0_6lA",
name: "MAMAMOO",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/FuZPj7lIW-I90PfZ3nij90uQCHy-KNdWr7BnDYE3F5Oh3d-2-fFeQYYzY2C3JQKSPUZNlLaTFGQ=s88-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: artist,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("2 years ago"),
view_count: 357346135,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "XJDPzNzQ3RE",
title: "Run BTS! 2022 Special Episode - Fly BTS Fly Part 1",
length: Some(2070),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/XJDPzNzQ3RE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDLdeTJMU0EXsKD20_m1oPEHNfJig",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/XJDPzNzQ3RE/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAZE_GkmGdfjdwu47uUcLusBwNuMA",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCLkAepWjdylmXSltofFvsYQ",
name: "BANGTANTV",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/NDWZM_aZQZJ81KRMyctZ5WYJbMIeDXLXBbAYfudK9idNpn7jIiamnj4-_3XIvCvKr1fEU7551A=s88-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: artist,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("2 hours ago"),
view_count: 748983,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "0lXwMdnpoFQ",
title: "aespa 에스파 \'도깨비불 (Illusion)\' Dance Practice",
length: Some(210),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/0lXwMdnpoFQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDvTjZu5GC9ZxiNY88whzTOHX-g1Q",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/0lXwMdnpoFQ/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAjumVxAE37gEGnP4ch7VW_V4lyeQ",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UC473RoZQE2gtgZJ61ZW0ZDQ",
name: "SMP FLOOR",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/wzxewsUVqXfk0SxKgC-opgrfigqvCXASyD1n_dj59GjYUPa5mgvgml3-dg8JXOfoI1ZZv7OO=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("3 months ago"),
view_count: 12347702,
is_live: false,
is_short: false,
),
RecommendedVideo(
id: "IHNzOHi8sJs",
title: "BLACKPINK - ‘뚜두뚜두 (DDU-DU DDU-DU) M/V",
length: Some(216),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/IHNzOHi8sJs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCzBqBp42z958fkbmx3yCOebx3aaA",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/IHNzOHi8sJs/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAP9l6y4EXVpwHC4vfYvI7hVJW9DQ",
width: 336,
height: 188,
),
],
channel: ChannelTag(
id: "UCOmHUn--16B90oW2L6FRR3A",
name: "BLACKPINK",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/hZDUwjoeQqigphL4A1tkg9c6hVp5yXmbboBR7PYFUSFj5PIJSA483NB5v7b0XVoTN9GCku3tqQ=s88-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: artist,
subscriber_count: None,
),
publish_date: "[date]",
publish_date_txt: Some("4 years ago"),
view_count: 1964840790,
is_live: false,
is_short: false,
),
],
ctoken: Some("CBQSExILWmVlcnJudUxpNUXAAQHIAQEYACqiDDJzNkw2d3lTQ1FxUENRb0Q4ajRBQ2czQ1Bnb0l1UFdDZ09mWDFmdFlDZ1B5UGdBS0RzSS1Dd2pPcjZhVTlMN2ttdUVCQ2dQeVBnQUtFdEktRHdvTlVrUmFaV1Z5Y201MVRHazFSUW9EOGo0QUNnN0NQZ3NJLU1xaTZ1MlZ3NC01QVFvRDhqNEFDZzNDUGdvSXNZamU0NGJFeGFKUkNnUHlQZ0FLRGNJLUNnaXF4bzYxd01DQ3d6WUtBX0ktQUFvT3dqNExDTmVSckxfYzNNaTEzd0VLQV9JLUFBb053ajRLQ051Ym1ZdVYwb0RuQ0FvRDhqNEFDZzNDUGdvSTE2YTg4NTI1OXNsUkNnUHlQZ0FLRGNJLUNnamJqcXVGb2V1YjB3Z0tBX0ktQUFvTndqNEtDTW4xbEkzbHUtaW1mQW9EOGo0QUNnM0NQZ29JdXFpZTZ0SzRrZUZqQ2dQeVBnQUtEY0ktQ2dqUGdaejB2OC1sNkhRS0FfSS1BQW9Pd2o0TENQMjE4SW53dHJXVnR3RUtBX0ktQUFvT3dqNExDTXVLdF9DUDllR21nUUVLQV9JLUFBb053ajRLQ0szRXN0V3YwTC1iVWdvRDhqNEFDZzNDUGdvSWc1NzdoSnJSdDRvcUNnUHlQZ0FLRGNJLUNnaVJ1c1BtemZtenlGd0tBX0ktQUFvT3dqNExDTlRBcHMtZGh2eXEwZ0VLQV9JLUFBb053ajRLQ0p2aDhzV0g1OXk1SUFvRDhqNEFDaF9TUGh3S0dsSkVRVTk2ZFZaM1JWbDNZMUZFTkhWMmNHZEJUbU5JU0ZWM0NoX1NQaHdLR2xKRVFVOWZjQzFWYmpCSGVUbHVXRlZ0Wm1kaE0xTlNYMXAzQ2hfU1Bod0tHbEpFUVU5cFEwaENhR3R1VTBSd09HcFZWekJPUzFoWU9FMVJDaF9TUGh3S0dsSkVRVTlYWjBsd1lVbDZha1p6UVhkeE5GOXhWR2hyTlROQkNoX1NQaHdLR2xKRVFVOXFNVkpuZEhkZmVtZHJibWxSWkdkTU5XTnlVRmxCQ2hfU1Bod0tHbEpFUVU4NWExbHRhMU5KVG5CVGFYVldTalEzUjNkT1RWSm5DaF9TUGh3S0dsSkVRVTlGYjNOTlVtbHlhM1ZKTjNvNE1tSmZia0oyUjNoQkNoX1NQaHdLR2xKRVFVOW1PRlExTURaUVZGcFVWRmxDWm01RVRVNURiR0ZSQ2hfU1Bod0tHbEpFUVU5UllqSlhRWGxLYTBwMlRURmhaMGRYZEhkRkxVOUJDaF9TUGh3S0dsSkVRVTlNWDFGNk1scFJRbUZNUkROTFExTnFWalpYZG5wM0NoX1NQaHdLR2xKRVFVOXpjeTFGWVdSRFpHZzBUVmxYV0hsMGFtWkpabFYzQ2hfU1Bod0tHbEpFUVU4MVRraFVXblJGV0ROSGJIWlhRMjgyYTJOdGFrdDNDaF9TUGh3S0dsSkVRVTlMWDBjMVRVZzFaM0ZJUTNRd1VXdENZVlZJTjJwUkNoX1NQaHdLR2xKRVFVOVpUWEZhWlV4U1RXMXhaRW8zZGs5b09UQXRhME5CQ2hfU1Bod0tHbEpFUVU4eVNGbEJhMFpIYzBGSmFWVmthRE5NVUhGRE5UZG5FaFVBQWdRR0NBb01EaEFTRkJZWUdod2VJQ0lrSmlnYUJBZ0FFQUVhQkFnQ0VBTWFCQWdFRUFVYUJBZ0dFQWNhQkFnSUVBa2FCQWdLRUFzYUJBZ01FQTBhQkFnT0VBOGFCQWdRRUJFYUJBZ1NFQk1hQkFnVUVCVWFCQWdXRUJjYUJBZ1lFQmthQkFnYUVCc2FCQWdjRUIwYUJBZ2VFQjhhQkFnZ0VDRWFCQWdpRUNNYUJBZ2tFQ1VhQkFnbUVDY2FCQWdvRUNrYUJBZ29FQ29hQkFnb0VDc2FCQWdvRUN3YUJBZ29FQzBhQkFnb0VDNGFCQWdvRUM4YUJBZ29FREFhQkFnb0VERWFCQWdvRURJYUJBZ29FRE1hQkFnb0VEUWFCQWdvRURVYUJBZ29FRFlhQkFnb0VEY3FGUUFDQkFZSUNnd09FQklVRmhnYUhCNGdJaVFtS0FqD3dhdGNoLW5leHQtZmVlZA%3D%3D"),
),
top_comments: Paginator(
count: Some(705000),
items: [],
ctoken: Some("Eg0SC1plZXJybnVMaTVFGAYyJSIRIgtaZWVycm51TGk1RTAAeAJCEGNvbW1lbnRzLXNlY3Rpb24%3D"),
),
latest_comments: Paginator(
count: Some(705000),
items: [],
ctoken: Some("Eg0SC1plZXJybnVMaTVFGAYyOCIRIgtaZWVycm51TGk1RTABeAIwAUIhZW5nYWdlbWVudC1wYW5lbC1jb21tZW50cy1zZWN0aW9u"),
),
)

View file

@ -251,7 +251,7 @@ impl MapResponse<VideoDetails> for response::VideoDetails {
.secondary_results .secondary_results
.and_then(|sr| { .and_then(|sr| {
sr.secondary_results.results.map(|r| { sr.secondary_results.results.map(|r| {
let mut res = map_recommendations(r, lang); let mut res = map_recommendations(r, sr.secondary_results.continuations, lang);
warnings.append(&mut res.warnings); warnings.append(&mut res.warnings);
res.c res.c
}) })
@ -342,15 +342,11 @@ impl MapResponse<Paginator<RecommendedVideo>> for response::VideoRecommendations
_deobf: Option<&crate::deobfuscate::Deobfuscator>, _deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<Paginator<RecommendedVideo>>, ExtractionError> { ) -> Result<MapResult<Paginator<RecommendedVideo>>, ExtractionError> {
let mut endpoints = self.on_response_received_endpoints; let mut endpoints = self.on_response_received_endpoints;
let cont = some_or_bail!( let cont = endpoints.try_swap_remove(0).ok_or(ExtractionError::Retry)?;
endpoints.try_swap_remove(0),
Err(ExtractionError::InvalidData(
"no continuation endpoint".into()
))
);
Ok(map_recommendations( Ok(map_recommendations(
cont.append_continuation_items_action.continuation_items, cont.append_continuation_items_action.continuation_items,
None,
lang, lang,
)) ))
} }
@ -363,57 +359,54 @@ impl MapResponse<Paginator<Comment>> for response::VideoComments {
lang: Language, lang: Language,
_deobf: Option<&crate::deobfuscate::Deobfuscator>, _deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<Paginator<Comment>>, ExtractionError> { ) -> Result<MapResult<Paginator<Comment>>, ExtractionError> {
let mut warnings = self.on_response_received_endpoints.warnings; let received_endpoints = self
.on_response_received_endpoints
.ok_or(ExtractionError::Retry)?;
let mut warnings = received_endpoints.warnings;
let mut comments = Vec::new(); let mut comments = Vec::new();
let mut comment_count = None; let mut comment_count = None;
let mut ctoken = None; let mut ctoken = None;
self.on_response_received_endpoints received_endpoints.c.into_iter().for_each(|citem| {
.c let mut items = citem.append_continuation_items_action.continuation_items;
.into_iter() warnings.append(&mut items.warnings);
.for_each(|citem| { items.c.into_iter().for_each(|item| match item {
let mut items = citem.append_continuation_items_action.continuation_items; response::video_details::CommentListItem::CommentThreadRenderer {
warnings.append(&mut items.warnings); comment,
items.c.into_iter().for_each(|item| match item { replies,
response::video_details::CommentListItem::CommentThreadRenderer { rendering_priority,
comment, } => {
replies, let mut res = map_comment(
comment.comment_renderer,
Some(replies),
rendering_priority, rendering_priority,
} => { lang,
let mut res = map_comment( );
comment.comment_renderer, comments.push(res.c);
Some(replies), warnings.append(&mut res.warnings)
rendering_priority, }
lang, response::video_details::CommentListItem::CommentRenderer(comment) => {
); let mut res = map_comment(
comments.push(res.c); comment,
warnings.append(&mut res.warnings) None,
} response::video_details::CommentPriority::RenderingPriorityUnknown,
response::video_details::CommentListItem::CommentRenderer(comment) => { lang,
let mut res = map_comment( );
comment, comments.push(res.c);
None, warnings.append(&mut res.warnings)
response::video_details::CommentPriority::RenderingPriorityUnknown, }
lang, response::video_details::CommentListItem::ContinuationItemRenderer {
); continuation_endpoint,
comments.push(res.c); } => {
warnings.append(&mut res.warnings) ctoken = Some(continuation_endpoint.continuation_command.token);
} }
response::video_details::CommentListItem::ContinuationItemRenderer { response::video_details::CommentListItem::CommentsHeaderRenderer { count_text } => {
continuation_endpoint, comment_count = count_text
} => { .and_then(|txt| util::parse_numeric_or_warn::<u64>(&txt, &mut warnings));
ctoken = Some(continuation_endpoint.continuation_command.token); }
}
response::video_details::CommentListItem::CommentsHeaderRenderer {
count_text,
} => {
comment_count = count_text.and_then(|txt| {
util::parse_numeric_or_warn::<u64>(&txt, &mut warnings)
});
}
});
}); });
});
Ok(MapResult { Ok(MapResult {
c: Paginator::new(comment_count, comments, ctoken), c: Paginator::new(comment_count, comments, ctoken),
@ -424,6 +417,7 @@ impl MapResponse<Paginator<Comment>> for response::VideoComments {
fn map_recommendations( fn map_recommendations(
r: MapResult<Vec<response::VideoListItem>>, r: MapResult<Vec<response::VideoListItem>>,
continuations: Option<Vec<response::MusicContinuation>>,
lang: Language, lang: Language,
) -> MapResult<Paginator<RecommendedVideo>> { ) -> MapResult<Paginator<RecommendedVideo>> {
let mut warnings = r.warnings; let mut warnings = r.warnings;
@ -475,6 +469,12 @@ fn map_recommendations(
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if let Some(continuations) = continuations {
continuations.into_iter().for_each(|c| {
ctoken = Some(c.next_continuation_data.continuation);
})
};
MapResult { MapResult {
c: Paginator::new(None, items, ctoken), c: Paginator::new(None, items, ctoken),
warnings, warnings,
@ -584,8 +584,9 @@ mod tests {
#[case::chapters("chapters", "nFDBxBUfE74")] #[case::chapters("chapters", "nFDBxBUfE74")]
#[case::live("live", "86YLFOog4GM")] #[case::live("live", "86YLFOog4GM")]
#[case::agegate("agegate", "HRKu0cvrr_o")] #[case::agegate("agegate", "HRKu0cvrr_o")]
#[case::newdesc("newdesc", "ZeerrnuLi5E")] #[case::newdesc("20220924_newdesc", "ZeerrnuLi5E")]
fn t_map_video_details(#[case] name: &str, #[case] id: &str) { #[case::new_cont("20221011_new_continuation", "ZeerrnuLi5E")]
fn map_video_details(#[case] name: &str, #[case] id: &str) {
let filename = format!("testfiles/video_details/video_details_{}.json", name); let filename = format!("testfiles/video_details/video_details_{}.json", name);
let json_path = Path::new(&filename); let json_path = Path::new(&filename);
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();
@ -626,10 +627,25 @@ mod tests {
}); });
} }
#[test]
fn map_recommendations_empty() {
let filename = format!("testfiles/video_details/recommendations_empty.json");
let json_path = Path::new(&filename);
let json_file = File::open(json_path).unwrap();
let recommendations: response::VideoRecommendations =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let err = recommendations
.map_response("", Language::En, None)
.unwrap_err();
assert!(matches!(err, crate::error::ExtractionError::Retry));
}
#[rstest] #[rstest]
#[case::top("top")] #[case::top("top")]
#[case::latest("latest")] #[case::latest("latest")]
fn t_map_comments(#[case] name: &str) { fn map_comments(#[case] name: &str) {
let filename = format!("testfiles/video_details/comments_{}.json", name); let filename = format!("testfiles/video_details/comments_{}.json", name);
let json_path = Path::new(&filename); let json_path = Path::new(&filename);
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();

View file

@ -75,6 +75,10 @@ pub enum ExtractionError {
VideoUnavailable(&'static str, String), VideoUnavailable(&'static str, String),
#[error("Video is age restricted")] #[error("Video is age restricted")]
VideoAgeRestricted, VideoAgeRestricted,
#[error("Content is not available. Reason (from YT): {0}")]
ContentUnavailable(String),
#[error("Got no data from YouTube")]
NoData,
#[error("deserialization error: {0}")] #[error("deserialization error: {0}")]
Deserialization(#[from] serde_json::Error), Deserialization(#[from] serde_json::Error),
#[error("got invalid data from YT: {0}")] #[error("got invalid data from YT: {0}")]
@ -83,6 +87,8 @@ pub enum ExtractionError {
WrongResult(String), WrongResult(String),
#[error("Warnings during deserialization/mapping")] #[error("Warnings during deserialization/mapping")]
DeserializationWarnings, DeserializationWarnings,
#[error("Got no data from YouTube, attempt retry")]
Retry,
} }
/// Internal error /// Internal error

View file

@ -0,0 +1,94 @@
{
"responseContext": {
"visitorData": "CgthSmp5T24zQkRjTSiom5WaBg%3D%3D",
"serviceTrackingParams": [
{
"service": "CSI",
"params": [
{
"key": "c",
"value": "WEB"
},
{
"key": "cver",
"value": "2.20221006.09.00"
},
{
"key": "yt_li",
"value": "0"
},
{
"key": "GetWatchNext_rid",
"value": "0x8836d1dc393da349"
}
]
},
{
"service": "GFEEDBACK",
"params": [
{
"key": "logged_in",
"value": "0"
},
{
"key": "e",
"value": "1714258,23804281,23882503,23885487,23918597,23934970,23940248,23946420,23966208,23983296,23986022,23998056,24001373,24002022,24002025,24004644,24007246,24034168,24036948,24077241,24080738,24108448,24120820,24135310,24140247,24152443,24161116,24162920,24164186,24166867,24169501,24181174,24185614,24187043,24187377,24191629,24197450,24199724,24199774,24211178,24217535,24219713,24223903,24224266,24225483,24226335,24227844,24228638,24229161,24241378,24243988,24248092,24248385,24254502,24255543,24255545,24256985,24259938,24260783,24262346,24263796,24265820,24267564,24267570,24268142,24268812,24268870,24278546,24278596,24279196,24279628,24279727,24280997,24281835,24282957,24283093,24283280,24286003,24286019,24287326,24287795,24288045,24289478,24289901,24289939,24290131,24290276,24290971,24292296,24295099,24295740,24297099,24298640,24298651,24298795,24299688,24299747,24390674,24391537,24392058,24392269,24394618,24590921,39322278,39322399,39322505"
}
]
},
{
"service": "GUIDED_HELP",
"params": [
{
"key": "logged_in",
"value": "0"
}
]
},
{
"service": "ECATCHER",
"params": [
{
"key": "client.version",
"value": "2.20221006"
},
{
"key": "client.name",
"value": "WEB"
},
{
"key": "client.fexp",
"value": "24286003,24298795,24256985,23804281,24001373,23946420,24283280,24289478,24223903,24298651,24286019,23885487,24077241,24265820,23918597,24255545,24036948,24259938,24279196,24199774,24282957,24279628,24268812,24169501,24225483,24590921,24197450,24298640,39322399,24290971,24108448,24287326,24219713,24278596,24002022,24181174,24227844,24287795,24229161,24283093,24162920,24248092,24241378,24166867,24002025,24280997,24391537,24278546,24288045,24034168,24290131,24211178,24289901,24226335,24268870,24295099,24135310,24191629,24394618,24007246,24004644,24243988,24281835,24392058,23998056,24185614,24262346,24187043,24224266,23986022,24228638,23934970,39322278,24292296,24260783,23940248,24263796,24267564,24299688,24390674,24152443,23966208,24267570,24080738,24290276,24217535,23882503,24279727,24164186,24289939,24187377,24268142,24120820,24199724,39322505,24392269,24254502,24255543,24299747,24161116,24140247,1714258,24297099,23983296,24295740,24248385"
}
]
}
],
"mainAppWebResponseContext": {
"loggedOut": true
},
"webResponseContextExtensionData": {
"hasDecorated": true
}
},
"trackingParams": "CAAQg2ciEwjfruPhg9j6AhXW2BEIHd9wAwc=",
"engagementPanels": [
{
"engagementPanelSectionListRenderer": {
"content": {
"adsEngagementPanelContentRenderer": {
"hack": true
}
},
"targetId": "engagement-panel-ads",
"visibility": "ENGAGEMENT_PANEL_VISIBILITY_HIDDEN",
"loggingDirectives": {
"trackingParams": "CAEQ040EGAAiEwjfruPhg9j6AhXW2BEIHd9wAwc=",
"visibility": {
"types": "12"
},
"enableDisplayloggerExperiment": true
}
}
}
]
}

File diff suppressed because one or more lines are too long

View file

@ -2,6 +2,7 @@ use chrono::{Datelike, Timelike};
use rstest::rstest; use rstest::rstest;
use rustypipe::client::{ClientType, RustyPipe}; use rustypipe::client::{ClientType, RustyPipe};
use rustypipe::error::{Error, ExtractionError};
use rustypipe::model::richtext::ToPlaintext; use rustypipe::model::richtext::ToPlaintext;
use rustypipe::model::{ use rustypipe::model::{
AudioCodec, AudioFormat, Channel, SearchItem, Verification, VideoCodec, VideoFormat, AudioCodec, AudioFormat, Channel, SearchItem, Verification, VideoCodec, VideoFormat,
@ -18,7 +19,7 @@ use rustypipe::param::{
#[case::tv_html5_embed(ClientType::TvHtml5Embed)] #[case::tv_html5_embed(ClientType::TvHtml5Embed)]
#[case::android(ClientType::Android)] #[case::android(ClientType::Android)]
#[case::ios(ClientType::Ios)] #[case::ios(ClientType::Ios)]
#[test_log::test(tokio::test)] #[tokio::test]
async fn get_player(#[case] client_type: ClientType) { async fn get_player(#[case] client_type: ClientType) {
let rp = RustyPipe::builder().strict().build(); let rp = RustyPipe::builder().strict().build();
let player_data = rp.query().player("n4tK7LYFxI0", client_type).await.unwrap(); let player_data = rp.query().player("n4tK7LYFxI0", client_type).await.unwrap();
@ -178,7 +179,7 @@ async fn get_playlist(
assert!(!playlist.thumbnail.is_empty()); assert!(!playlist.thumbnail.is_empty());
} }
#[test_log::test(tokio::test)] #[tokio::test]
async fn playlist_cont() { async fn playlist_cont() {
let rp = RustyPipe::builder().strict().build(); let rp = RustyPipe::builder().strict().build();
let mut playlist = rp let mut playlist = rp
@ -196,7 +197,7 @@ async fn playlist_cont() {
assert!(playlist.videos.count.unwrap() > 100); assert!(playlist.videos.count.unwrap() > 100);
} }
#[test_log::test(tokio::test)] #[tokio::test]
async fn playlist_cont2() { async fn playlist_cont2() {
let rp = RustyPipe::builder().strict().build(); let rp = RustyPipe::builder().strict().build();
let mut playlist = rp let mut playlist = rp
@ -210,6 +211,21 @@ async fn playlist_cont2() {
assert!(playlist.videos.count.unwrap() > 100); assert!(playlist.videos.count.unwrap() > 100);
} }
#[tokio::test]
async fn playlist_not_found() {
let rp = RustyPipe::builder().strict().build();
let err = rp
.query()
.playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qz")
.await
.unwrap_err();
assert!(matches!(
err,
Error::Extraction(ExtractionError::ContentUnavailable(_))
));
}
//#VIDEO DETAILS //#VIDEO DETAILS
#[tokio::test] #[tokio::test]
@ -310,7 +326,6 @@ async fn get_video_details_music() {
assert!(!details.is_live); assert!(!details.is_live);
assert!(!details.is_ccommons); assert!(!details.is_ccommons);
assert!(!details.recommended.items.is_empty());
assert!(!details.recommended.is_exhausted()); assert!(!details.recommended.is_exhausted());
// Comments are disabled for this video // Comments are disabled for this video
@ -368,7 +383,6 @@ async fn get_video_details_ccommons() {
assert!(!details.is_live); assert!(!details.is_live);
assert!(details.is_ccommons); assert!(details.is_ccommons);
assert!(!details.recommended.items.is_empty());
assert!(!details.recommended.is_exhausted()); assert!(!details.recommended.is_exhausted());
assert!( assert!(
@ -505,7 +519,6 @@ async fn get_video_details_chapters() {
] ]
"###); "###);
assert!(!details.recommended.items.is_empty());
assert!(!details.recommended.is_exhausted()); assert!(!details.recommended.is_exhausted());
assert!( assert!(
@ -565,7 +578,6 @@ async fn get_video_details_live() {
assert!(details.is_live); assert!(details.is_live);
assert!(!details.is_ccommons); assert!(!details.is_ccommons);
assert!(!details.recommended.items.is_empty());
assert!(!details.recommended.is_exhausted()); assert!(!details.recommended.is_exhausted());
// No comments because livestream // No comments because livestream
@ -857,6 +869,24 @@ async fn channel_more(
assert_channel(&channel_info, id, name); assert_channel(&channel_info, id, name);
} }
#[rstest]
#[case::gaming("UCOpNcN46UbXVtpKMrmU4Abg", false)]
#[case::not_found("UCOpNcN46UbXVtpKMrmU4Abx", true)]
#[tokio::test]
async fn channel_error(#[case] id: &str, #[case] not_found: bool) {
let rp = RustyPipe::builder().strict().build();
let err = rp.query().channel_videos(&id).await.unwrap_err();
if not_found {
assert!(matches!(
err,
Error::Extraction(ExtractionError::ContentUnavailable(_))
));
} else {
assert!(matches!(err, Error::Extraction(ExtractionError::NoData)));
}
}
//#CHANNEL_RSS //#CHANNEL_RSS
#[tokio::test] #[tokio::test]