use std::borrow::Cow; use serde::Serialize; use crate::{ client::response::music_item::MusicListMapper, error::{Error, ExtractionError}, model::{ paginator::Paginator, traits::FromYtItem, AlbumItem, ArtistItem, MusicPlaylistItem, MusicSearchFiltered, MusicSearchResult, MusicSearchSuggestion, TrackItem, }, serializer::MapResult, }; use super::{response, ClientType, MapResponse, RustyPipeQuery, YTContext}; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct QSearch<'a> { context: YTContext<'a>, query: &'a str, #[serde(skip_serializing_if = "Option::is_none")] params: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct QSearchSuggestion<'a> { context: YTContext<'a>, input: &'a str, } #[derive(Debug, Serialize)] enum Params { #[serde(rename = "EgWKAQIIAWoMEAMQBBAJEA4QChAF")] Tracks, #[serde(rename = "EgWKAQIQAWoMEAMQBBAJEA4QChAF")] Videos, #[serde(rename = "EgWKAQIYAWoMEAMQBBAJEA4QChAF")] Albums, #[serde(rename = "EgWKAQIgAWoMEAMQBBAJEA4QChAF")] Artists, #[serde(rename = "EgWKAQIoAWoMEAMQBBAJEA4QChAF")] Playlists, #[serde(rename = "EgeKAQQoADgBagwQAxAEEAkQDhAKEAU%3D")] YtmPlaylists, #[serde(rename = "EgeKAQQoAEABagwQAxAEEAkQDhAKEAU%3D")] CommunityPlaylists, } impl RustyPipeQuery { /// Search YouTube Music. Returns items from any type. pub async fn music_search>(&self, query: S) -> Result { let query = query.as_ref(); let context = self.get_context(ClientType::DesktopMusic, true, None).await; let request_body = QSearch { context, query, params: None, }; self.execute_request::( ClientType::DesktopMusic, "music_search", query, "search", &request_body, ) .await } /// Search YouTube Music tracks pub async fn music_search_tracks>( &self, query: S, ) -> Result, Error> { self._music_search_tracks(query, Params::Tracks).await } /// Search YouTube Music videos pub async fn music_search_videos>( &self, query: S, ) -> Result, Error> { self._music_search_tracks(query, Params::Videos).await } async fn _music_search_tracks>( &self, query: S, params: Params, ) -> Result, Error> { let query = query.as_ref(); let context = self.get_context(ClientType::DesktopMusic, true, None).await; let request_body = QSearch { context, query, params: Some(params), }; self.execute_request::( ClientType::DesktopMusic, "music_search_tracks", query, "search", &request_body, ) .await } /// Search YouTube Music albums pub async fn music_search_albums>( &self, query: S, ) -> Result, Error> { let query = query.as_ref(); let context = self.get_context(ClientType::DesktopMusic, true, None).await; let request_body = QSearch { context, query, params: Some(Params::Albums), }; self.execute_request::( ClientType::DesktopMusic, "music_search_albums", query, "search", &request_body, ) .await } /// Search YouTube Music artists pub async fn music_search_artists( &self, query: &str, ) -> Result, Error> { let context = self.get_context(ClientType::DesktopMusic, true, None).await; let request_body = QSearch { context, query, params: Some(Params::Artists), }; self.execute_request::( ClientType::DesktopMusic, "music_search_albums", query, "search", &request_body, ) .await } /// Search YouTube Music playlists pub async fn music_search_playlists>( &self, query: S, ) -> Result, Error> { self._music_search_playlists(query, Params::Playlists).await } /// Search YouTube Music playlists that were created by users /// (`community=true`) or by YouTube Music (`community=false`) pub async fn music_search_playlists_filter>( &self, query: S, community: bool, ) -> Result, Error> { self._music_search_playlists( query, if community { Params::CommunityPlaylists } else { Params::YtmPlaylists }, ) .await } async fn _music_search_playlists>( &self, query: S, params: Params, ) -> Result, Error> { let query = query.as_ref(); let context = self.get_context(ClientType::DesktopMusic, true, None).await; let request_body = QSearch { context, query, params: Some(params), }; self.execute_request::( ClientType::DesktopMusic, "music_search_playlists", query, "search", &request_body, ) .await } /// Get YouTube Music search suggestions pub async fn music_search_suggestion>( &self, query: S, ) -> Result { let query = query.as_ref(); let context = self.get_context(ClientType::DesktopMusic, true, None).await; let request_body = QSearchSuggestion { context, input: query, }; self.execute_request::( ClientType::DesktopMusic, "music_search_suggestion", query, "music/get_search_suggestions", &request_body, ) .await } } impl MapResponse for response::MusicSearch { fn map_response( self, _id: &str, lang: crate::param::Language, _deobf: Option<&crate::deobfuscate::DeobfData>, _vdata: Option<&str>, ) -> Result, crate::error::ExtractionError> { // dbg!(&self); let sections = self .contents .tabbed_search_results_renderer .contents .into_iter() .next() .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no tab")))? .tab_renderer .content .section_list_renderer .contents; let mut corrected_query = None; let mut order = Vec::new(); let mut mapper = MusicListMapper::new(lang); sections.into_iter().for_each(|section| match section { response::music_search::ItemSection::MusicShelfRenderer(shelf) => { if let Some(etype) = mapper.map_response(shelf.contents) { if !order.contains(&etype) { order.push(etype); } } } response::music_search::ItemSection::MusicCardShelfRenderer(card) => { if let Some(etype) = mapper.map_card(card) { if !order.contains(&etype) { order.push(etype); } } } response::music_search::ItemSection::ItemSectionRenderer { contents } => { if let Some(corrected) = contents.into_iter().next() { corrected_query = Some(corrected.showing_results_for_renderer.corrected_query); } } response::music_search::ItemSection::None => {} }); mapper.check_unknown()?; let map_res = mapper.group_items(); Ok(MapResult { c: MusicSearchResult { tracks: map_res.c.tracks, albums: map_res.c.albums, artists: map_res.c.artists, playlists: map_res.c.playlists, corrected_query, order, }, warnings: map_res.warnings, }) } } impl MapResponse> for response::MusicSearch { fn map_response( self, _id: &str, lang: crate::param::Language, _deobf: Option<&crate::deobfuscate::DeobfData>, _vdata: Option<&str>, ) -> Result>, ExtractionError> { // dbg!(&self); let tabs = self.contents.tabbed_search_results_renderer.contents; let sections = tabs .into_iter() .next() .ok_or(ExtractionError::InvalidData(Cow::Borrowed("no tab")))? .tab_renderer .content .section_list_renderer .contents; let mut corrected_query = None; let mut ctoken = None; let mut mapper = MusicListMapper::new(lang); sections.into_iter().for_each(|section| match section { response::music_search::ItemSection::MusicShelfRenderer(shelf) => { mapper.map_response(shelf.contents); if let Some(cont) = shelf.continuations.into_iter().next() { ctoken = Some(cont.next_continuation_data.continuation); } } response::music_search::ItemSection::MusicCardShelfRenderer(card) => { mapper.map_card(card); } response::music_search::ItemSection::ItemSectionRenderer { contents } => { if let Some(corrected) = contents.into_iter().next() { corrected_query = Some(corrected.showing_results_for_renderer.corrected_query); } } response::music_search::ItemSection::None => {} }); mapper.check_unknown()?; let map_res = mapper.conv_items(); Ok(MapResult { c: MusicSearchFiltered { items: Paginator::new_ext( None, map_res.c, ctoken, None, crate::model::paginator::ContinuationEndpoint::MusicSearch, ), corrected_query, }, warnings: map_res.warnings, }) } } impl MapResponse for response::MusicSearchSuggestion { fn map_response( self, _id: &str, lang: crate::param::Language, _deobf: Option<&crate::deobfuscate::DeobfData>, _vdata: Option<&str>, ) -> Result, ExtractionError> { let mut mapper = MusicListMapper::new(lang); let mut terms = Vec::new(); for section in self.contents { for item in section.search_suggestions_section_renderer.contents { match item { response::music_search::SearchSuggestionItem::SearchSuggestionRenderer { suggestion, } => { terms.push(suggestion); }, response::music_search::SearchSuggestionItem::MusicResponsiveListItemRenderer(item) => { mapper.add_response_item(response::music_item::MusicResponseItem::MusicResponsiveListItemRenderer(*item)); } response::music_search::SearchSuggestionItem::None => {}, } } } mapper.check_unknown()?; let map_res = mapper.conv_items(); Ok(MapResult { c: MusicSearchSuggestion { terms, items: map_res.c, }, warnings: map_res.warnings, }) } } #[cfg(test)] mod tests { use std::{fs::File, io::BufReader}; use path_macro::path; use rstest::rstest; use crate::{ client::{response, MapResponse}, model::{ AlbumItem, ArtistItem, MusicPlaylistItem, MusicSearchFiltered, MusicSearchResult, MusicSearchSuggestion, TrackItem, }, param::Language, serializer::MapResult, util::tests::TESTFILES, }; #[rstest] #[case::default("default")] #[case::typo("typo")] #[case::radio("radio")] #[case::artist("artist")] fn map_music_search_main(#[case] name: &str) { let json_path = path!(*TESTFILES / "music_search" / format!("main_{name}.json")); let json_file = File::open(json_path).unwrap(); let search: response::MusicSearch = serde_json::from_reader(BufReader::new(json_file)).unwrap(); let map_res: MapResult = search.map_response("", Language::En, None, None).unwrap(); assert!( map_res.warnings.is_empty(), "deserialization/mapping warnings: {:?}", map_res.warnings ); insta::assert_ron_snapshot!(format!("map_music_search_main_{name}"), map_res.c); } #[rstest] #[case::default("default")] #[case::typo("typo")] #[case::videos("videos")] #[case::no_artist_link("no_artist_link")] fn map_music_search_tracks(#[case] name: &str) { let json_path = path!(*TESTFILES / "music_search" / format!("tracks_{name}.json")); let json_file = File::open(json_path).unwrap(); let search: response::MusicSearch = serde_json::from_reader(BufReader::new(json_file)).unwrap(); let map_res: MapResult> = search.map_response("", Language::En, None, None).unwrap(); assert!( map_res.warnings.is_empty(), "deserialization/mapping warnings: {:?}", map_res.warnings ); insta::assert_ron_snapshot!(format!("map_music_search_tracks_{name}"), map_res.c); } #[test] fn map_music_search_albums() { let json_path = path!(*TESTFILES / "music_search" / "albums.json"); let json_file = File::open(json_path).unwrap(); let search: response::MusicSearch = serde_json::from_reader(BufReader::new(json_file)).unwrap(); let map_res: MapResult> = search.map_response("", Language::En, None, None).unwrap(); assert!( map_res.warnings.is_empty(), "deserialization/mapping warnings: {:?}", map_res.warnings ); insta::assert_ron_snapshot!("map_music_search_albums", map_res.c); } #[test] fn map_music_search_artists() { let json_path = path!(*TESTFILES / "music_search" / "artists.json"); let json_file = File::open(json_path).unwrap(); let search: response::MusicSearch = serde_json::from_reader(BufReader::new(json_file)).unwrap(); let map_res: MapResult> = search.map_response("", Language::En, None, None).unwrap(); assert!( map_res.warnings.is_empty(), "deserialization/mapping warnings: {:?}", map_res.warnings ); insta::assert_ron_snapshot!("map_music_search_artists", map_res.c); } #[rstest] #[case::ytm("ytm")] #[case::community("community")] fn map_music_search_playlists(#[case] name: &str) { let json_path = path!(*TESTFILES / "music_search" / format!("playlists_{name}.json")); let json_file = File::open(json_path).unwrap(); let search: response::MusicSearch = serde_json::from_reader(BufReader::new(json_file)).unwrap(); let map_res: MapResult> = search.map_response("", Language::En, None, None).unwrap(); assert!( map_res.warnings.is_empty(), "deserialization/mapping warnings: {:?}", map_res.warnings ); insta::assert_ron_snapshot!(format!("map_music_search_playlists_{name}"), map_res.c); } #[rstest] #[case::default("default")] #[case::empty("empty")] fn map_music_search_suggestion(#[case] name: &str) { let json_path = path!(*TESTFILES / "music_search" / format!("suggestion_{name}.json")); let json_file = File::open(json_path).unwrap(); let suggestion: response::MusicSearchSuggestion = serde_json::from_reader(BufReader::new(json_file)).unwrap(); let map_res: MapResult = suggestion .map_response("", Language::En, None, None) .unwrap(); assert!( map_res.warnings.is_empty(), "deserialization/mapping warnings: {:?}", map_res.warnings ); insta::assert_ron_snapshot!(format!("map_music_search_suggestion_{name}"), map_res.c); } }