The MapResponse trait needed too many arguments, so I added the MapRespCtx object. Also added the client_type to the context, so it can be added to the extracted player data. This is necessary to be able to download videos with the correct user agent
722 lines
26 KiB
Rust
722 lines
26 KiB
Rust
use std::{collections::HashMap, fmt::Debug};
|
|
|
|
use serde::Serialize;
|
|
|
|
use crate::{
|
|
error::{Error, ExtractionError},
|
|
model::{
|
|
paginator::{ContinuationEndpoint, Paginator},
|
|
ChannelTag, Chapter, Comment, Verification, VideoDetails, VideoItem,
|
|
},
|
|
param::Language,
|
|
serializer::MapResult,
|
|
util::{self, timeago, TryRemove},
|
|
};
|
|
|
|
use super::{
|
|
response::{self, video_details::Payload, IconType},
|
|
ClientType, MapRespCtx, MapResponse, QContinuation, RustyPipeQuery, YTContext,
|
|
};
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct QVideo<'a> {
|
|
context: YTContext<'a>,
|
|
/// YouTube video ID
|
|
video_id: &'a str,
|
|
/// Set to true to allow extraction of streams with sensitive content
|
|
content_check_ok: bool,
|
|
/// Probably refers to allowing sensitive content, too
|
|
racy_check_ok: bool,
|
|
}
|
|
|
|
impl RustyPipeQuery {
|
|
/// Get the metadata for a video
|
|
#[tracing::instrument(skip(self))]
|
|
pub async fn video_details<S: AsRef<str> + Debug>(
|
|
&self,
|
|
video_id: S,
|
|
) -> Result<VideoDetails, Error> {
|
|
let video_id = video_id.as_ref();
|
|
let context = self.get_context(ClientType::Desktop, true, None).await;
|
|
let request_body = QVideo {
|
|
context,
|
|
video_id,
|
|
content_check_ok: true,
|
|
racy_check_ok: true,
|
|
};
|
|
|
|
self.execute_request::<response::VideoDetails, _, _>(
|
|
ClientType::Desktop,
|
|
"video_details",
|
|
video_id,
|
|
"next",
|
|
&request_body,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Get the comments for a video using the continuation token obtained from `rusty_pipe_query.video_details()`
|
|
#[tracing::instrument(skip(self))]
|
|
pub async fn video_comments<S: AsRef<str> + Debug>(
|
|
&self,
|
|
ctoken: S,
|
|
visitor_data: Option<&str>,
|
|
) -> Result<Paginator<Comment>, Error> {
|
|
let ctoken = ctoken.as_ref();
|
|
let context = self
|
|
.get_context(ClientType::Desktop, true, visitor_data)
|
|
.await;
|
|
let request_body = QContinuation {
|
|
context,
|
|
continuation: ctoken,
|
|
};
|
|
|
|
self.execute_request::<response::VideoComments, _, _>(
|
|
ClientType::Desktop,
|
|
"video_comments",
|
|
ctoken,
|
|
"next",
|
|
&request_body,
|
|
)
|
|
.await
|
|
.map(|p| Paginator {
|
|
visitor_data: visitor_data.map(str::to_owned),
|
|
..p
|
|
})
|
|
}
|
|
}
|
|
|
|
impl MapResponse<VideoDetails> for response::VideoDetails {
|
|
fn map_response(
|
|
self,
|
|
ctx: &MapRespCtx<'_>,
|
|
) -> Result<MapResult<VideoDetails>, ExtractionError> {
|
|
let mut warnings = Vec::new();
|
|
|
|
let contents = self.contents.ok_or_else(|| ExtractionError::NotFound {
|
|
id: ctx.id.to_owned(),
|
|
msg: "no content".into(),
|
|
})?;
|
|
let current_video_endpoint =
|
|
self.current_video_endpoint
|
|
.ok_or_else(|| ExtractionError::NotFound {
|
|
id: ctx.id.to_owned(),
|
|
msg: "no current_video_endpoint".into(),
|
|
})?;
|
|
|
|
let video_id = current_video_endpoint.watch_endpoint.video_id;
|
|
if ctx.id != video_id {
|
|
return Err(ExtractionError::WrongResult(format!(
|
|
"got wrong video id {}, expected {}",
|
|
video_id, ctx.id
|
|
)));
|
|
}
|
|
|
|
let mut primary_results = contents
|
|
.two_column_watch_next_results
|
|
.results
|
|
.results
|
|
.contents
|
|
.ok_or_else(|| ExtractionError::NotFound {
|
|
id: ctx.id.into(),
|
|
msg: "no primary_results".into(),
|
|
})?;
|
|
warnings.append(&mut primary_results.warnings);
|
|
|
|
let mut primary_info = None;
|
|
let mut secondary_info = None;
|
|
let mut comment_count_section = None;
|
|
let mut comment_ctoken_section = None;
|
|
|
|
primary_results.c.into_iter().for_each(|r| match r {
|
|
response::video_details::VideoResultsItem::VideoPrimaryInfoRenderer { .. } => {
|
|
primary_info = Some(r);
|
|
}
|
|
response::video_details::VideoResultsItem::VideoSecondaryInfoRenderer { .. } => {
|
|
secondary_info = Some(r);
|
|
}
|
|
response::video_details::VideoResultsItem::ItemSectionRenderer(section) => {
|
|
match section {
|
|
response::video_details::ItemSection::CommentsEntryPoint { contents } => {
|
|
comment_count_section = contents.into_iter().next();
|
|
}
|
|
response::video_details::ItemSection::CommentItemSection { contents } => {
|
|
comment_ctoken_section = contents.into_iter().next();
|
|
}
|
|
response::video_details::ItemSection::None => {}
|
|
}
|
|
}
|
|
response::video_details::VideoResultsItem::None => {}
|
|
});
|
|
|
|
let (title, view_count, like_count, publish_date, publish_date_txt, is_live) =
|
|
match primary_info {
|
|
Some(response::video_details::VideoResultsItem::VideoPrimaryInfoRenderer {
|
|
title,
|
|
view_count,
|
|
video_actions,
|
|
date_text,
|
|
}) => {
|
|
let like_text = video_actions
|
|
.menu_renderer
|
|
.top_level_buttons
|
|
.into_iter()
|
|
.find_map(|button| {
|
|
let (icon, text) = match button {
|
|
response::video_details::TopLevelButton::ToggleButtonRenderer(btn) => (btn.default_icon.icon_type, btn.accessibility_data),
|
|
response::video_details::TopLevelButton::SegmentedLikeDislikeButtonRenderer { like_button } => (like_button.toggle_button_renderer.default_icon.icon_type, like_button.toggle_button_renderer.accessibility_data),
|
|
response::video_details::TopLevelButton::SegmentedLikeDislikeButtonViewModel { like_button_view_model } => {
|
|
(IconType::Like, like_button_view_model.like_button_view_model.toggle_button_view_model.toggle_button_view_model.default_button_view_model.button_view_model.accessibility_text)
|
|
},
|
|
};
|
|
match icon {
|
|
IconType::Like => Some(text),
|
|
_ => None
|
|
}
|
|
});
|
|
(
|
|
title,
|
|
// view count field contains `No views` if the view count is zero
|
|
view_count
|
|
.as_ref()
|
|
.and_then(|vc| {
|
|
util::parse_numeric(&vc.video_view_count_renderer.view_count).ok()
|
|
})
|
|
.unwrap_or_default(),
|
|
// accessibility_data contains no digits if the like count is hidden,
|
|
// so we ignore parse errors here for now
|
|
like_text.and_then(|txt| util::parse_numeric(&txt).ok()),
|
|
date_text.as_deref().and_then(|txt| {
|
|
timeago::parse_textual_date_or_warn(ctx.lang, txt, &mut warnings)
|
|
}),
|
|
date_text,
|
|
view_count
|
|
.map(|vc| vc.video_view_count_renderer.is_live)
|
|
.unwrap_or_default(),
|
|
)
|
|
}
|
|
_ => {
|
|
return Err(ExtractionError::InvalidData(
|
|
"could not find primary_info".into(),
|
|
))
|
|
}
|
|
};
|
|
|
|
let comment_count = comment_count_section.and_then(|s| {
|
|
util::parse_large_numstr_or_warn::<u64>(
|
|
&s.comments_entry_point_header_renderer.comment_count,
|
|
ctx.lang,
|
|
&mut warnings,
|
|
)
|
|
});
|
|
|
|
let comment_ctoken = comment_ctoken_section.map(|s| {
|
|
s.continuation_item_renderer
|
|
.continuation_endpoint
|
|
.continuation_command
|
|
.token
|
|
});
|
|
|
|
let (owner, description, is_ccommons) = match secondary_info {
|
|
Some(response::video_details::VideoResultsItem::VideoSecondaryInfoRenderer {
|
|
owner,
|
|
description,
|
|
attributed_description,
|
|
metadata_row_container,
|
|
}) => {
|
|
let is_ccommons = metadata_row_container
|
|
.map(|c| {
|
|
c.metadata_row_container_renderer.rows.iter().any(|cr| {
|
|
cr.metadata_row_renderer.contents.iter().any(|links| {
|
|
links.0.iter().any(|link| match link {
|
|
crate::serializer::text::TextComponent::Web {
|
|
text: _,
|
|
url,
|
|
} => url == "https://www.youtube.com/t/creative_commons",
|
|
_ => false,
|
|
})
|
|
})
|
|
})
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let desc = description
|
|
.or(attributed_description)
|
|
.unwrap_or_default()
|
|
.into();
|
|
|
|
(owner.video_owner_renderer, desc, is_ccommons)
|
|
}
|
|
_ => {
|
|
return Err(ExtractionError::InvalidData(
|
|
"could not find secondary_info".into(),
|
|
))
|
|
}
|
|
};
|
|
|
|
let (channel_id, channel_name) = match owner.title {
|
|
crate::serializer::text::TextComponent::Browse {
|
|
text,
|
|
page_type,
|
|
browse_id,
|
|
} => match page_type {
|
|
response::url_endpoint::PageType::Channel => (browse_id, text),
|
|
_ => {
|
|
return Err(ExtractionError::InvalidData(
|
|
"invalid channel link type".into(),
|
|
))
|
|
}
|
|
},
|
|
_ => return Err(ExtractionError::InvalidData("invalid channel link".into())),
|
|
};
|
|
|
|
let visitor_data = self
|
|
.response_context
|
|
.visitor_data
|
|
.or_else(|| ctx.visitor_data.map(str::to_owned));
|
|
let recommended = contents
|
|
.two_column_watch_next_results
|
|
.secondary_results
|
|
.and_then(|sr| {
|
|
sr.secondary_results.results.map(|r| {
|
|
let mut res = map_recommendations(
|
|
r,
|
|
sr.secondary_results.continuations,
|
|
visitor_data.clone(),
|
|
ctx.lang,
|
|
);
|
|
warnings.append(&mut res.warnings);
|
|
res.c
|
|
})
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let mut engagement_panels = self.engagement_panels;
|
|
warnings.append(&mut engagement_panels.warnings);
|
|
|
|
let mut chapter_panel = None;
|
|
let mut comment_panel = None;
|
|
engagement_panels.c.into_iter().for_each(|panel| match panel.engagement_panel_section_list_renderer {
|
|
response::video_details::EngagementPanelRenderer::EngagementPanelMacroMarkersDescriptionChapters { content } => {
|
|
chapter_panel = Some(content);
|
|
},
|
|
response::video_details::EngagementPanelRenderer::EngagementPanelCommentsSection { header } => {
|
|
comment_panel = Some(header);
|
|
},
|
|
response::video_details::EngagementPanelRenderer::None => {},
|
|
});
|
|
|
|
let chapters = chapter_panel
|
|
.map(|chapters| {
|
|
let mut content = chapters.macro_markers_list_renderer.contents;
|
|
warnings.append(&mut content.warnings);
|
|
content
|
|
.c
|
|
.into_iter()
|
|
.map(|item| Chapter {
|
|
name: item.macro_markers_list_item_renderer.title,
|
|
position: item
|
|
.macro_markers_list_item_renderer
|
|
.on_tap
|
|
.watch_endpoint
|
|
.start_time_seconds,
|
|
thumbnail: item.macro_markers_list_item_renderer.thumbnail.into(),
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let latest_comments_ctoken = comment_panel.and_then(|comments| {
|
|
let mut items = comments
|
|
.engagement_panel_title_header_renderer
|
|
.menu
|
|
.sort_filter_sub_menu_renderer
|
|
.sub_menu_items;
|
|
items
|
|
.try_swap_remove(1)
|
|
.map(|c| c.service_endpoint.continuation_command.token)
|
|
});
|
|
|
|
Ok(MapResult {
|
|
c: VideoDetails {
|
|
id: video_id,
|
|
name: title,
|
|
description,
|
|
channel: ChannelTag {
|
|
id: channel_id,
|
|
name: channel_name,
|
|
avatar: owner.thumbnail.into(),
|
|
verification: owner.badges.into(),
|
|
subscriber_count: owner.subscriber_count_text.and_then(|txt| {
|
|
util::parse_large_numstr_or_warn(&txt, ctx.lang, &mut warnings)
|
|
}),
|
|
},
|
|
view_count,
|
|
like_count,
|
|
publish_date,
|
|
publish_date_txt,
|
|
is_live,
|
|
is_ccommons,
|
|
chapters,
|
|
recommended,
|
|
top_comments: Paginator::new_ext(
|
|
comment_count,
|
|
Vec::new(),
|
|
comment_ctoken,
|
|
visitor_data.clone(),
|
|
ContinuationEndpoint::Next,
|
|
),
|
|
latest_comments: Paginator::new_ext(
|
|
comment_count,
|
|
Vec::new(),
|
|
latest_comments_ctoken,
|
|
visitor_data.clone(),
|
|
ContinuationEndpoint::Next,
|
|
),
|
|
visitor_data,
|
|
},
|
|
warnings,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl MapResponse<Paginator<Comment>> for response::VideoComments {
|
|
fn map_response(
|
|
self,
|
|
ctx: &MapRespCtx<'_>,
|
|
) -> Result<MapResult<Paginator<Comment>>, ExtractionError> {
|
|
let received_endpoints = self.on_response_received_endpoints;
|
|
let mut warnings = Vec::new();
|
|
|
|
let mut comments = Vec::new();
|
|
let mut comment_count = None;
|
|
let mut ctoken = None;
|
|
|
|
let mut mutations = if let Some(upd) = self.framework_updates {
|
|
let mut m = upd.entity_batch_update.mutations;
|
|
warnings.append(&mut m.warnings);
|
|
m.items
|
|
} else {
|
|
HashMap::new()
|
|
};
|
|
|
|
received_endpoints.c.into_iter().for_each(|citem| {
|
|
let mut items = citem.append_continuation_items_action.continuation_items;
|
|
warnings.append(&mut items.warnings);
|
|
items.c.into_iter().for_each(|item| match item {
|
|
response::video_details::CommentListItem::CommentThreadRenderer(thread) => {
|
|
if let Some(comment) = thread.comment {
|
|
comments.push(map_comment(
|
|
comment.comment_renderer,
|
|
Some(thread.replies),
|
|
thread.rendering_priority,
|
|
ctx.lang,
|
|
&mut warnings,
|
|
));
|
|
} else if let Some(vm) = thread.comment_view_model {
|
|
if let Some(c) = map_comment_vm(
|
|
vm.comment_view_model,
|
|
&mut mutations,
|
|
Some(thread.replies),
|
|
thread.rendering_priority,
|
|
ctx.lang,
|
|
&mut warnings,
|
|
) {
|
|
comments.push(c);
|
|
}
|
|
} else {
|
|
warnings.push(
|
|
"comment does not contain comment or commentViewModel field".to_owned(),
|
|
);
|
|
}
|
|
}
|
|
response::video_details::CommentListItem::CommentRenderer(comment) => {
|
|
comments.push(map_comment(
|
|
comment,
|
|
None,
|
|
response::video_details::CommentPriority::RenderingPriorityUnknown,
|
|
ctx.lang,
|
|
&mut warnings,
|
|
));
|
|
}
|
|
response::video_details::CommentListItem::CommentViewModel(vm) => {
|
|
if let Some(c) = map_comment_vm(
|
|
vm,
|
|
&mut mutations,
|
|
None,
|
|
response::video_details::CommentPriority::RenderingPriorityUnknown,
|
|
ctx.lang,
|
|
&mut warnings,
|
|
) {
|
|
comments.push(c);
|
|
}
|
|
}
|
|
response::video_details::CommentListItem::ContinuationItemRenderer(cont) => {
|
|
ctoken = Some(cont.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 {
|
|
c: Paginator::new(comment_count, comments, ctoken),
|
|
warnings,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn map_recommendations(
|
|
r: MapResult<Vec<response::YouTubeListItem>>,
|
|
continuations: Option<Vec<response::MusicContinuationData>>,
|
|
visitor_data: Option<String>,
|
|
lang: Language,
|
|
) -> MapResult<Paginator<VideoItem>> {
|
|
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(lang);
|
|
mapper.map_response(r);
|
|
|
|
mapper.ctoken = mapper.ctoken.or_else(|| {
|
|
continuations
|
|
.and_then(|c| c.into_iter().next())
|
|
.map(|c| c.next_continuation_data.continuation)
|
|
});
|
|
|
|
MapResult {
|
|
c: Paginator::new_ext(
|
|
None,
|
|
mapper.items,
|
|
mapper.ctoken,
|
|
visitor_data,
|
|
ContinuationEndpoint::Next,
|
|
),
|
|
warnings: mapper.warnings,
|
|
}
|
|
}
|
|
|
|
fn map_replies(
|
|
replies: Option<response::video_details::Replies>,
|
|
lang: Language,
|
|
warnings: &mut Vec<String>,
|
|
) -> (Vec<Comment>, Option<String>) {
|
|
let mut reply_ctoken = None;
|
|
let replies = replies
|
|
.map(|replies| {
|
|
replies
|
|
.comment_replies_renderer
|
|
.contents
|
|
.into_iter()
|
|
.filter_map(|item| match item {
|
|
response::video_details::CommentListItem::CommentRenderer(comment) => {
|
|
Some(map_comment(
|
|
comment,
|
|
None,
|
|
response::video_details::CommentPriority::default(),
|
|
lang,
|
|
warnings,
|
|
))
|
|
}
|
|
response::video_details::CommentListItem::ContinuationItemRenderer(cont) => {
|
|
reply_ctoken = Some(cont.token());
|
|
None
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
(replies, reply_ctoken)
|
|
}
|
|
|
|
fn map_comment(
|
|
c: response::video_details::CommentRenderer,
|
|
replies: Option<response::video_details::Replies>,
|
|
priority: response::video_details::CommentPriority,
|
|
lang: Language,
|
|
warnings: &mut Vec<String>,
|
|
) -> Comment {
|
|
let (replies, reply_ctoken) = map_replies(replies, lang, warnings);
|
|
|
|
Comment {
|
|
id: c.comment_id,
|
|
text: c.content_text.into(),
|
|
author: match (c.author_endpoint, c.author_text) {
|
|
(Some(aep), Some(name)) => Some(ChannelTag {
|
|
id: aep.browse_endpoint.browse_id,
|
|
name,
|
|
avatar: c.author_thumbnail.into(),
|
|
verification: c
|
|
.author_comment_badge
|
|
.map(|b| b.author_comment_badge_renderer.icon.into())
|
|
.unwrap_or_default(),
|
|
subscriber_count: None,
|
|
}),
|
|
_ => None,
|
|
},
|
|
publish_date: timeago::parse_timeago_dt_or_warn(lang, &c.published_time_text, warnings),
|
|
publish_date_txt: c.published_time_text,
|
|
like_count: match c.vote_count {
|
|
Some(txt) => util::parse_numeric_or_warn(&txt, warnings),
|
|
None => Some(0),
|
|
},
|
|
reply_count: c.reply_count as u32,
|
|
replies: Paginator::new(Some(c.reply_count), replies, reply_ctoken),
|
|
by_owner: c.author_is_channel_owner,
|
|
pinned: priority.into(),
|
|
hearted: c
|
|
.action_buttons
|
|
.comment_action_buttons_renderer
|
|
.creator_heart
|
|
.map(|h| h.creator_heart_renderer.is_hearted)
|
|
.unwrap_or_default(),
|
|
}
|
|
}
|
|
|
|
fn map_comment_vm(
|
|
vm: response::video_details::CommentViewModel,
|
|
mutations: &mut HashMap<String, response::video_details::Payload>,
|
|
replies: Option<response::video_details::Replies>,
|
|
priority: response::video_details::CommentPriority,
|
|
lang: Language,
|
|
warnings: &mut Vec<String>,
|
|
) -> Option<Comment> {
|
|
let (replies, reply_ctoken) = map_replies(replies, lang, warnings);
|
|
|
|
let ce = if let Some(Payload::CommentEntityPayload(ce)) = mutations.remove(&vm.comment_key) {
|
|
ce
|
|
} else {
|
|
warnings.push(format!(
|
|
"comment `{}` does not have entity payload (key: `{}`)",
|
|
vm.comment_id, vm.comment_key
|
|
));
|
|
return None;
|
|
};
|
|
let hearted = if let Some(Payload::EngagementToolbarStateEntityPayload { heart_state }) =
|
|
mutations.get(&vm.toolbar_state_key)
|
|
{
|
|
(*heart_state).into()
|
|
} else {
|
|
false
|
|
};
|
|
|
|
let mut parse_num = |s: &str| -> Option<u32> {
|
|
if s.is_empty() || s == " " {
|
|
Some(0)
|
|
} else {
|
|
util::parse_large_numstr_or_warn(s, lang, warnings)
|
|
}
|
|
};
|
|
|
|
let reply_count = parse_num(&ce.toolbar.reply_count).unwrap_or_default();
|
|
|
|
Some(Comment {
|
|
id: vm.comment_id,
|
|
text: ce.properties.content.into(),
|
|
by_owner: ce.author.as_ref().map(|a| a.is_creator).unwrap_or_default(),
|
|
author: ce.author.map(|a| ChannelTag {
|
|
id: a.channel_id,
|
|
name: a.display_name,
|
|
avatar: ce.avatar.image.into(),
|
|
verification: if a.is_artist {
|
|
Verification::Artist
|
|
} else if a.is_verified {
|
|
Verification::Verified
|
|
} else {
|
|
Verification::None
|
|
},
|
|
subscriber_count: None,
|
|
}),
|
|
like_count: parse_num(&ce.toolbar.like_count_notliked),
|
|
reply_count,
|
|
replies: Paginator::new(Some(reply_count.into()), replies, reply_ctoken),
|
|
publish_date: timeago::parse_timeago_dt_or_warn(
|
|
lang,
|
|
&ce.properties.published_time,
|
|
warnings,
|
|
),
|
|
publish_date_txt: ce.properties.published_time,
|
|
pinned: priority.into(),
|
|
hearted,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{fs::File, io::BufReader};
|
|
|
|
use path_macro::path;
|
|
use rstest::rstest;
|
|
|
|
use crate::{
|
|
client::{response, MapRespCtx, MapResponse},
|
|
util::tests::TESTFILES,
|
|
};
|
|
|
|
#[rstest]
|
|
#[case::mv("mv", "ZeerrnuLi5E")]
|
|
#[case::music("music", "XuM2onMGvTI")]
|
|
#[case::ccommons("ccommons", "0rb9CfOvojk")]
|
|
#[case::chapters("chapters", "nFDBxBUfE74")]
|
|
#[case::live("live", "86YLFOog4GM")]
|
|
#[case::agegate("agegate", "HRKu0cvrr_o")]
|
|
#[case::ab_newdesc("20220924_newdesc", "ZeerrnuLi5E")]
|
|
#[case::ab_new_cont("20221011_new_continuation", "ZeerrnuLi5E")]
|
|
#[case::ab_no_recommends("20221011_rec_isr", "nFDBxBUfE74")]
|
|
#[case::ab_new_likes("20231103_likes", "ZeerrnuLi5E")]
|
|
fn map_video_details(#[case] name: &str, #[case] id: &str) {
|
|
let json_path = path!(*TESTFILES / "video_details" / format!("video_details_{name}.json"));
|
|
let json_file = File::open(json_path).unwrap();
|
|
|
|
let details: response::VideoDetails =
|
|
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
|
let map_res = details.map_response(&MapRespCtx::test(id)).unwrap();
|
|
|
|
assert!(
|
|
map_res.warnings.is_empty(),
|
|
"deserialization/mapping warnings: {:?}",
|
|
map_res.warnings
|
|
);
|
|
insta::assert_ron_snapshot!(format!("map_video_details_{name}"), map_res.c, {
|
|
".publish_date" => "[date]",
|
|
".recommended.items[].publish_date" => "[date]",
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn map_video_details_not_found() {
|
|
let json_path = path!(*TESTFILES / "video_details" / "video_details_not_found.json");
|
|
let json_file = File::open(json_path).unwrap();
|
|
|
|
let details: response::VideoDetails =
|
|
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
|
let err = details.map_response(&MapRespCtx::test("")).unwrap_err();
|
|
assert!(matches!(
|
|
err,
|
|
crate::error::ExtractionError::NotFound { .. }
|
|
));
|
|
}
|
|
|
|
#[rstest]
|
|
#[case::top("top")]
|
|
#[case::latest("latest")]
|
|
#[case::frameworkupd("20240401_frameworkupd")]
|
|
#[case::frameworkupd_reply("20240401_frameworkupd_reply")]
|
|
fn map_comments(#[case] name: &str) {
|
|
let json_path = path!(*TESTFILES / "video_details" / format!("comments_{name}.json"));
|
|
let json_file = File::open(json_path).unwrap();
|
|
|
|
let comments: response::VideoComments =
|
|
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
|
let map_res = comments.map_response(&MapRespCtx::test("")).unwrap();
|
|
|
|
assert!(
|
|
map_res.warnings.is_empty(),
|
|
"deserialization/mapping warnings: {:?}",
|
|
map_res.warnings
|
|
);
|
|
insta::assert_ron_snapshot!(format!("map_comments_{name}"), map_res.c, {
|
|
".items[].publish_date" => "[date]",
|
|
});
|
|
}
|
|
}
|