use serde::Serialize; use crate::{ deobfuscate::Deobfuscator, error::{Error, ExtractionError}, model::{ ChannelId, ChannelTag, Paginator, SearchChannel, SearchItem, SearchPlaylist, SearchPlaylistVideo, SearchResult, SearchVideo, }, param::{search_filter::SearchFilter, Language}, timeago, util::{self, TryRemove}, }; use super::{ response::{self, IsLive, IsShort}, ClientType, MapResponse, MapResult, QContinuation, RustyPipeQuery, YTContext, }; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct QSearch<'a> { context: YTContext, query: &'a str, #[serde(skip_serializing_if = "Option::is_none")] params: Option, } impl RustyPipeQuery { pub async fn search(self, query: &str) -> Result { let context = self.get_context(ClientType::Desktop, true).await; let request_body = QSearch { context, query, params: None, }; self.execute_request::( ClientType::Desktop, "search", query, "search", &request_body, ) .await } pub async fn search_filter( self, query: &str, filter: &SearchFilter, ) -> Result { let context = self.get_context(ClientType::Desktop, true).await; let request_body = QSearch { context, query, params: Some(filter.encode()), }; self.execute_request::( ClientType::Desktop, "search_filter", query, "search", &request_body, ) .await } pub async fn search_continuation(self, ctoken: &str) -> Result, Error> { let context = self.get_context(ClientType::Desktop, true).await; let request_body = QContinuation { context, continuation: ctoken, }; self.execute_request::( ClientType::Desktop, "search_continuation", ctoken, "search", &request_body, ) .await } } impl MapResponse for response::Search { fn map_response( self, _id: &str, lang: Language, _deobf: Option<&Deobfuscator>, ) -> Result, ExtractionError> { let section_list_items = self .contents .two_column_search_results_renderer .primary_contents .section_list_renderer .contents; let (items, ctoken) = map_section_list_items(section_list_items)?; let mut warnings = items.warnings; let (mut mapped, corrected_query) = map_search_items(items.c, lang); warnings.append(&mut mapped.warnings); Ok(MapResult { c: SearchResult { items: Paginator::new(self.estimated_results, mapped.c, ctoken), corrected_query, }, warnings, }) } } impl MapResponse> for response::SearchCont { fn map_response( self, _id: &str, lang: Language, _deobf: Option<&Deobfuscator>, ) -> Result>, ExtractionError> { let mut commands = self.on_response_received_commands; let cont_command = some_or_bail!( commands.try_swap_remove(0), Err(ExtractionError::InvalidData( "no item section renderer".into() )) ); let (items, ctoken) = map_section_list_items( cont_command .append_continuation_items_action .continuation_items, )?; let mut warnings = items.warnings; let (mut mapped, _) = map_search_items(items.c, lang); warnings.append(&mut mapped.warnings); Ok(MapResult { c: Paginator::new(self.estimated_results, mapped.c, ctoken), warnings, }) } } fn map_section_list_items( section_list_items: Vec, ) -> Result<(MapResult>, Option), ExtractionError> { let mut items = None; let mut ctoken = None; section_list_items.into_iter().for_each(|item| match item { response::search::SectionListItem::ItemSectionRenderer { contents } => { items = Some(contents); } response::search::SectionListItem::ContinuationItemRenderer { continuation_endpoint, } => { ctoken = Some(continuation_endpoint.continuation_command.token); } }); let items = some_or_bail!( items, Err(ExtractionError::InvalidData( "no item section renderer".into() )) ); Ok((items, ctoken)) } fn map_search_items( items: Vec, lang: Language, ) -> (MapResult>, Option) { let mut warnings = Vec::new(); let mut c_query = None; let mapped_items = items .into_iter() .filter_map(|item| match item { response::search::SearchItem::VideoRenderer(mut video) => { match ChannelId::try_from(video.channel) { Ok(channel) => Some(SearchItem::Video(SearchVideo { id: video.video_id, title: video.title, length: video .length_text .and_then(|txt| util::parse_video_length_or_warn(&txt, &mut warnings)), thumbnail: video.thumbnail.into(), channel: ChannelTag { id: channel.id, name: channel.name, avatar: video .channel_thumbnail_supported_renderers .channel_thumbnail_with_link_renderer .thumbnail .into(), verification: video.owner_badges.into(), subscriber_count: None, }, publish_date: video.published_time_text.as_ref().and_then(|txt| { timeago::parse_timeago_or_warn(lang, txt, &mut warnings) }), publish_date_txt: video.published_time_text, view_count: video .view_count_text .and_then(|txt| util::parse_numeric(&txt).ok()) .unwrap_or_default(), is_live: video.thumbnail_overlays.is_live(), is_short: video.thumbnail_overlays.is_short(), short_description: video .detailed_metadata_snippets .try_swap_remove(0) .map(|s| s.snippet_text) .unwrap_or_default(), })), Err(e) => { warnings.push(e.to_string()); None } } } response::search::SearchItem::PlaylistRenderer(mut playlist) => { Some(SearchItem::Playlist(SearchPlaylist { id: playlist.playlist_id, name: playlist.title, thumbnail: playlist .thumbnails .try_swap_remove(0) .unwrap_or_default() .into(), video_count: playlist.video_count, first_videos: playlist .videos .into_iter() .map(|v| SearchPlaylistVideo { id: v.child_video_renderer.video_id, title: v.child_video_renderer.title, length: v.child_video_renderer.length_text.and_then(|txt| { util::parse_video_length_or_warn(&txt, &mut warnings) }), }) .collect(), })) } response::search::SearchItem::ChannelRenderer(channel) => { Some(SearchItem::Channel(SearchChannel { id: channel.channel_id, name: channel.title, avatar: channel.thumbnail.into(), verification: channel.owner_badges.into(), subscriber_count: channel .subscriber_count_text .and_then(|txt| util::parse_numeric_or_warn(&txt, &mut warnings)), video_count: channel .video_count_text .and_then(|txt| util::parse_numeric(&txt).ok()) .unwrap_or_default(), short_description: channel.description_snippet, })) } response::search::SearchItem::ShowingResultsForRenderer { corrected_query } => { c_query = Some(corrected_query); None } response::search::SearchItem::None => None, }) .collect(); ( MapResult { c: mapped_items, warnings, }, c_query, ) } #[cfg(test)] mod tests { use std::{fs::File, io::BufReader, path::Path}; use crate::{ client::{response, MapResponse}, model::{Paginator, SearchItem, SearchResult}, param::Language, serializer::MapResult, }; use rstest::rstest; #[rstest] #[case::default("default")] #[case::playlists("playlists")] #[case::playlists("empty")] fn t_map_search(#[case] name: &str) { let filename = format!("testfiles/search/{}.json", name); let json_path = Path::new(&filename); let json_file = File::open(json_path).unwrap(); let search: response::Search = serde_json::from_reader(BufReader::new(json_file)).unwrap(); let map_res: MapResult = search.map_response("", Language::En, None).unwrap(); assert!( map_res.warnings.is_empty(), "deserialization/mapping warnings: {:?}", map_res.warnings ); insta::assert_ron_snapshot!(format!("map_search_{}", name), map_res.c, { ".items.items.*.publish_date" => "[date]", }); } #[test] fn t_map_search_cont() { let filename = format!("testfiles/search/cont.json"); let json_path = Path::new(&filename); let json_file = File::open(json_path).unwrap(); let search_cont: response::SearchCont = serde_json::from_reader(BufReader::new(json_file)).unwrap(); let map_res: MapResult> = search_cont.map_response("", Language::En, None).unwrap(); assert!( map_res.warnings.is_empty(), "deserialization/mapping warnings: {:?}", map_res.warnings ); insta::assert_ron_snapshot!("map_search_cont", map_res.c, { ".items.*.publish_date" => "[date]", }); } }