diff --git a/README.md b/README.md index 7a6ddb3..7e19f77 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ inspired by [NewPipe](https://github.com/TeamNewPipe/NewPipeExtractor). - [X] **Album** - [X] **Artist** - [X] **Search** -- [ ] **Search suggestions** +- [X] **Search suggestions** - [X] **Radio** - [X] **Track details** (lyrics, recommendations) - [ ] **Moods** diff --git a/codegen/src/download_testfiles.rs b/codegen/src/download_testfiles.rs index 9ff42a9..790eaab 100644 --- a/codegen/src/download_testfiles.rs +++ b/codegen/src/download_testfiles.rs @@ -48,6 +48,7 @@ pub async fn download_testfiles(project_root: &Path) { music_search_artists(&testfiles).await; music_search_playlists(&testfiles).await; music_search_cont(&testfiles).await; + music_search_suggestion(&testfiles).await; music_artist(&testfiles).await; music_details(&testfiles).await; music_lyrics(&testfiles).await; @@ -585,10 +586,14 @@ async fn music_album(testfiles: &Path) { } async fn music_search(testfiles: &Path) { - for (name, query) in [("default", "black mamba"), ("typo", "liblingsmensch")] { + for (name, query) in [ + ("default", "black mamba"), + ("typo", "liblingsmensch"), + ("radio", "pop radio"), + ] { let mut json_path = testfiles.to_path_buf(); json_path.push("music_search"); - json_path.push(format!("{}.json", name)); + json_path.push(format!("main_{}.json", name)); if json_path.exists() { continue; } @@ -684,6 +689,20 @@ async fn music_search_cont(testfiles: &Path) { res.items.next(&rp.query()).await.unwrap().unwrap(); } +async fn music_search_suggestion(testfiles: &Path) { + for (name, query) in [("default", "t"), ("empty", "reujbhevmfndxnjrze")] { + let mut json_path = testfiles.to_path_buf(); + json_path.push("music_search"); + json_path.push(format!("suggestion_{}.json", name)); + if json_path.exists() { + continue; + } + + let rp = rp_testfile(&json_path); + rp.query().music_search_suggestion(query).await.unwrap(); + } +} + async fn music_artist(testfiles: &Path) { for (name, id) in [ ("default", "UClmXPfaYhXOYsNn_QUyheWQ"), diff --git a/src/client/music_search.rs b/src/client/music_search.rs index b9e34da..3cc7ba1 100644 --- a/src/client/music_search.rs +++ b/src/client/music_search.rs @@ -24,6 +24,13 @@ struct QSearch<'a> { 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")] @@ -182,6 +189,23 @@ impl RustyPipeQuery { ) .await } + + pub async fn music_search_suggestion(&self, query: &str) -> Result, Error> { + 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 { @@ -293,6 +317,41 @@ impl MapResponse> for response::MusicSearc } } +impl MapResponse> for response::MusicSearchSuggestion { + fn map_response( + self, + _id: &str, + _lang: crate::param::Language, + _deobf: Option<&crate::deobfuscate::Deobfuscator>, + ) -> Result>, ExtractionError> { + let items = self + .contents + .into_iter() + .next() + .map(|content| { + content + .search_suggestions_section_renderer + .contents + .into_iter() + .filter_map(|itm| { + match itm { + response::music_search::SearchSuggestionItem::SearchSuggestionRenderer { + suggestion, + } => Some(suggestion), + response::music_search::SearchSuggestionItem::None => None, + } + }) + .collect::>() + }) + .unwrap_or_default(); + + Ok(MapResult { + c: items, + warnings: Vec::new(), + }) + } +} + #[cfg(test)] mod tests { use std::{fs::File, io::BufReader, path::Path}; @@ -417,4 +476,26 @@ mod tests { 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 filename = format!("testfiles/music_search/suggestion_{}.json", name); + let json_path = Path::new(&filename); + 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).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); + } } diff --git a/src/client/response/mod.rs b/src/client/response/mod.rs index ee46a24..e2fe6ca 100644 --- a/src/client/response/mod.rs +++ b/src/client/response/mod.rs @@ -21,6 +21,7 @@ pub(crate) use music_details::MusicRelated; pub(crate) use music_item::MusicContinuation; pub(crate) use music_playlist::MusicPlaylist; pub(crate) use music_search::MusicSearch; +pub(crate) use music_search::MusicSearchSuggestion; pub(crate) use player::Player; pub(crate) use playlist::Playlist; pub(crate) use playlist::PlaylistCont; diff --git a/src/client/response/music_search.rs b/src/client/response/music_search.rs index e95e373..4f8950d 100644 --- a/src/client/response/music_search.rs +++ b/src/client/response/music_search.rs @@ -12,6 +12,16 @@ pub(crate) struct MusicSearch { pub contents: Contents, } +/// Response model for YouTube Music suggestion +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MusicSearchSuggestion { + #[serde(default)] + #[serde_as(as = "VecSkipError<_>")] + pub contents: Vec, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct Contents { @@ -45,3 +55,21 @@ pub(crate) struct ShowingResultsForRenderer { #[serde_as(as = "Text")] pub corrected_query: String, } + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SearchSuggestionsSection { + pub search_suggestions_section_renderer: ContentsRenderer, +} + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum SearchSuggestionItem { + SearchSuggestionRenderer { + #[serde_as(as = "Text")] + suggestion: String, + }, + #[serde(other, deserialize_with = "deserialize_ignore_any")] + None, +} diff --git a/src/client/snapshots/rustypipe__client__music_search__tests__map_music_search_suggestion_default.snap b/src/client/snapshots/rustypipe__client__music_search__tests__map_music_search_suggestion_default.snap new file mode 100644 index 0000000..45f3060 --- /dev/null +++ b/src/client/snapshots/rustypipe__client__music_search__tests__map_music_search_suggestion_default.snap @@ -0,0 +1,13 @@ +--- +source: src/client/music_search.rs +expression: map_res.c +--- +[ + "taylor swift", + "tkkg", + "techno", + "t low", + "the weeknd", + "tiktok songs", + "toten hosen", +] diff --git a/src/client/snapshots/rustypipe__client__music_search__tests__map_music_search_suggestion_empty.snap b/src/client/snapshots/rustypipe__client__music_search__tests__map_music_search_suggestion_empty.snap new file mode 100644 index 0000000..f2e8d09 --- /dev/null +++ b/src/client/snapshots/rustypipe__client__music_search__tests__map_music_search_suggestion_empty.snap @@ -0,0 +1,5 @@ +--- +source: src/client/music_search.rs +expression: map_res.c +--- +[] diff --git a/testfiles/music_search/suggestion_default.json b/testfiles/music_search/suggestion_default.json new file mode 100644 index 0000000..8978552 --- /dev/null +++ b/testfiles/music_search/suggestion_default.json @@ -0,0 +1,242 @@ +{ + "contents": [ + { + "searchSuggestionsSectionRenderer": { + "contents": [ + { + "searchSuggestionRenderer": { + "icon": { + "iconType": "SEARCH" + }, + "navigationEndpoint": { + "clickTrackingParams": "CAcQpWEYASITCKbj7Pb3yPsCFcfUEQgdRe0MBA==", + "searchEndpoint": { + "query": "taylor swift" + } + }, + "suggestion": { + "runs": [ + { + "bold": true, + "text": "t" + }, + { + "text": "aylor swift" + } + ] + }, + "trackingParams": "CAcQpWEYASITCKbj7Pb3yPsCFcfUEQgdRe0MBA==" + } + }, + { + "searchSuggestionRenderer": { + "icon": { + "iconType": "SEARCH" + }, + "navigationEndpoint": { + "clickTrackingParams": "CAYQpWEYAiITCKbj7Pb3yPsCFcfUEQgdRe0MBA==", + "searchEndpoint": { + "query": "tkkg" + } + }, + "suggestion": { + "runs": [ + { + "bold": true, + "text": "t" + }, + { + "text": "kkg" + } + ] + }, + "trackingParams": "CAYQpWEYAiITCKbj7Pb3yPsCFcfUEQgdRe0MBA==" + } + }, + { + "searchSuggestionRenderer": { + "icon": { + "iconType": "SEARCH" + }, + "navigationEndpoint": { + "clickTrackingParams": "CAUQpWEYAyITCKbj7Pb3yPsCFcfUEQgdRe0MBA==", + "searchEndpoint": { + "query": "techno" + } + }, + "suggestion": { + "runs": [ + { + "bold": true, + "text": "t" + }, + { + "text": "echno" + } + ] + }, + "trackingParams": "CAUQpWEYAyITCKbj7Pb3yPsCFcfUEQgdRe0MBA==" + } + }, + { + "searchSuggestionRenderer": { + "icon": { + "iconType": "SEARCH" + }, + "navigationEndpoint": { + "clickTrackingParams": "CAQQpWEYBCITCKbj7Pb3yPsCFcfUEQgdRe0MBA==", + "searchEndpoint": { + "query": "t low" + } + }, + "suggestion": { + "runs": [ + { + "bold": true, + "text": "t" + }, + { + "text": " low" + } + ] + }, + "trackingParams": "CAQQpWEYBCITCKbj7Pb3yPsCFcfUEQgdRe0MBA==" + } + }, + { + "searchSuggestionRenderer": { + "icon": { + "iconType": "SEARCH" + }, + "navigationEndpoint": { + "clickTrackingParams": "CAMQpWEYBSITCKbj7Pb3yPsCFcfUEQgdRe0MBA==", + "searchEndpoint": { + "query": "the weeknd" + } + }, + "suggestion": { + "runs": [ + { + "bold": true, + "text": "t" + }, + { + "text": "he weeknd" + } + ] + }, + "trackingParams": "CAMQpWEYBSITCKbj7Pb3yPsCFcfUEQgdRe0MBA==" + } + }, + { + "searchSuggestionRenderer": { + "icon": { + "iconType": "SEARCH" + }, + "navigationEndpoint": { + "clickTrackingParams": "CAIQpWEYBiITCKbj7Pb3yPsCFcfUEQgdRe0MBA==", + "searchEndpoint": { + "query": "tiktok songs" + } + }, + "suggestion": { + "runs": [ + { + "bold": true, + "text": "t" + }, + { + "text": "iktok songs" + } + ] + }, + "trackingParams": "CAIQpWEYBiITCKbj7Pb3yPsCFcfUEQgdRe0MBA==" + } + }, + { + "searchSuggestionRenderer": { + "icon": { + "iconType": "SEARCH" + }, + "navigationEndpoint": { + "clickTrackingParams": "CAEQpWEYByITCKbj7Pb3yPsCFcfUEQgdRe0MBA==", + "searchEndpoint": { + "query": "toten hosen" + } + }, + "suggestion": { + "runs": [ + { + "bold": true, + "text": "t" + }, + { + "text": "oten hosen" + } + ] + }, + "trackingParams": "CAEQpWEYByITCKbj7Pb3yPsCFcfUEQgdRe0MBA==" + } + } + ] + } + } + ], + "responseContext": { + "serviceTrackingParams": [ + { + "params": [ + { + "key": "c", + "value": "WEB_REMIX" + }, + { + "key": "cver", + "value": "1.20221121.01.00" + }, + { + "key": "yt_li", + "value": "0" + }, + { + "key": "GetMusicSearchSuggestions_rid", + "value": "0xe343e5421f9bc4f5" + } + ], + "service": "CSI" + }, + { + "params": [ + { + "key": "logged_in", + "value": "0" + }, + { + "key": "e", + "value": "1714247,9407157,23804281,23882685,23885487,23918597,23934970,23946420,23966208,23983296,23998056,24001373,24002022,24002025,24004644,24007246,24034168,24036948,24077241,24080738,24108448,24120820,24135310,24140247,24161116,24162920,24164186,24169501,24181174,24187043,24187377,24191629,24197450,24199724,24200839,24211178,24217535,24219713,24237297,24241378,24255165,24255543,24255545,24257695,24262346,24263796,24265426,24267564,24268142,24271464,24279196,24282724,24287327,24287604,24288043,24288442,24290971,24292955,24293803,24296354,24298084,24299747,24299875,24390374,24390675,24391018,24391541,24391579,24392268,24392403,24392452,24398048,24401557,24402891,24405024,24406605,24407200,24407452,24407665,24410273,24410853,24412682,24412897,24413820,24414162,24414917,24415579,24415866,24416290,24419371,24420756,24421162,24421894,24422904,24424806,24590921,39322504,39322574" + } + ], + "service": "GFEEDBACK" + }, + { + "params": [ + { + "key": "client.version", + "value": "1.20000101" + }, + { + "key": "client.name", + "value": "WEB_REMIX" + }, + { + "key": "client.fexp", + "value": "24257695,24288043,24415579,23882685,24412682,39322574,24287604,24410273,23885487,24217535,24392452,1714247,9407157,24390675,24299747,24401557,39322504,24410853,24191629,24108448,24279196,24255545,24292955,24001373,24161116,24290971,24413820,23934970,24007246,24187377,23998056,24391018,24415866,24293803,24262346,24282724,24398048,24407452,24187043,24407200,24419371,24199724,24421162,24211178,24287327,24265426,24392268,24164186,24241378,24391541,24422904,24255165,24402891,24140247,24416290,23983296,24002025,24414917,24414162,24391579,24237297,24406605,24424806,24255543,24299875,24263796,24298084,24135310,24267564,24004644,24077241,24036948,24405024,24420756,24296354,24590921,24268142,23918597,24390374,24271464,23946420,24197450,24412897,24120820,24421894,23804281,24392403,24181174,24002022,24200839,24288442,24219713,23966208,24034168,24080738,24162920,24169501,24407665" + } + ], + "service": "ECATCHER" + } + ], + "visitorData": "CgtHV2dSNHFYMDhLSSjZ_4GcBg%3D%3D" + }, + "trackingParams": "CAAQi24iEwim4-z298j7AhXH1BEIHUXtDAQ=" +} diff --git a/testfiles/music_search/suggestion_empty.json b/testfiles/music_search/suggestion_empty.json new file mode 100644 index 0000000..1393b55 --- /dev/null +++ b/testfiles/music_search/suggestion_empty.json @@ -0,0 +1,59 @@ +{ + "responseContext": { + "serviceTrackingParams": [ + { + "params": [ + { + "key": "c", + "value": "WEB_REMIX" + }, + { + "key": "cver", + "value": "1.20221121.01.00" + }, + { + "key": "yt_li", + "value": "0" + }, + { + "key": "GetMusicSearchSuggestions_rid", + "value": "0x0a90fd4a664adea1" + } + ], + "service": "CSI" + }, + { + "params": [ + { + "key": "logged_in", + "value": "0" + }, + { + "key": "e", + "value": "1714245,23804281,23882685,23918597,23934970,23946420,23966208,23983296,23998056,24001373,24002022,24002025,24004644,24007246,24034168,24036948,24077241,24080738,24120819,24135310,24140247,24161116,24162920,24164186,24169501,24181174,24184893,24187043,24187377,24191629,24197450,24199724,24200839,24211178,24217535,24219713,24241378,24255163,24255543,24255545,24262346,24263796,24265426,24267564,24268142,24279196,24280999,24281671,24282722,24287327,24288043,24290971,24292955,24293803,24299747,24299875,24390374,24390675,24391018,24391539,24392399,24400178,24400185,24401291,24401557,24402891,24404641,24406605,24407200,24407665,24412154,24413415,24414074,24414162,24415866,24416291,24416439,24417792,24418790,24420756,24421162,24423785,24426598,24426910,24590921,24591020,39322504,39322574" + } + ], + "service": "GFEEDBACK" + }, + { + "params": [ + { + "key": "client.version", + "value": "1.20000101" + }, + { + "key": "client.name", + "value": "WEB_REMIX" + }, + { + "key": "client.fexp", + "value": "24292955,24413415,24077241,24001373,24263796,24267564,24161116,24290971,24412154,24191629,24420756,23882685,24299747,24288043,24591020,24184893,24414162,24255543,24406605,24200839,24287327,24280999,24407665,23946420,23804281,24390374,24140247,24414074,23966208,24080738,24418790,23983296,24219713,24164186,24187043,24400178,24400185,24404641,24426910,23998056,24391539,24007246,24401291,24293803,24135310,24255163,24268142,23918597,24187377,24004644,24390675,24255545,24036948,24423785,24392399,24417792,24299875,1714245,24217535,39322574,24281671,24002025,24426598,24199724,24401557,39322504,24421162,24120819,24211178,24265426,24402891,24391018,24181174,24002022,24416439,24407200,24034168,24415866,24282722,24241378,24162920,24416291,23934970,24169501,24590921,24279196,24262346,24197450" + } + ], + "service": "ECATCHER" + } + ], + "visitorData": "Cgtyb1QtdzRqZXZGayisj4KcBg%3D%3D" + }, + "trackingParams": "CAAQi24iEwi6jvCx_8j7AhWbyxEIHYe1Bno=" +} diff --git a/tests/youtube.rs b/tests/youtube.rs index 685492d..e506001 100644 --- a/tests/youtube.rs +++ b/tests/youtube.rs @@ -1798,6 +1798,29 @@ async fn music_search_genre_radio() { rp.query().music_search("pop radio").await.unwrap(); } +#[rstest] +#[case::default("ed sheer", Some("ed sheeran"))] +#[case::empty("reujbhevmfndxnjrze", None)] +#[tokio::test] +async fn music_search_suggestion(#[case] query: &str, #[case] expect: Option<&str>) { + let rp = RustyPipe::builder().strict().build(); + let suggestion = rp.query().music_search_suggestion(query).await.unwrap(); + + match expect { + Some(expect) => assert!( + suggestion.iter().any(|s| s == expect), + "suggestion: {:?}, expected: {}", + suggestion, + expect + ), + None => assert!( + suggestion.is_empty(), + "suggestion: {:?}, expected to be empty", + suggestion + ), + } +} + #[rstest] #[case::mv("mv", "ZeerrnuLi5E")] #[case::track("track", "7nigXQS1Xb0")]