diff --git a/rspotify-model/src/search.rs b/rspotify-model/src/search.rs index b75feba4..bb357c28 100644 --- a/rspotify-model/src/search.rs +++ b/rspotify-model/src/search.rs @@ -1,6 +1,7 @@ //! All object related to search use serde::{Deserialize, Serialize}; +use strum::Display; use crate::{ FullArtist, FullTrack, Page, SimplifiedAlbum, SimplifiedEpisode, SimplifiedPlaylist, @@ -60,3 +61,20 @@ pub enum SearchResult { #[serde(rename = "episodes")] Episodes(Page), } + +/// Valid filters to used in the search endpoint's query string +#[derive(Debug, Display, PartialEq, Eq, PartialOrd, Ord)] +#[strum(serialize_all = "snake_case")] +pub enum SearchFilter { + Album, + Artist, + Track, + Year, + Upc, + #[strum(serialize = "tag:hipster")] + TagHipster, + #[strum(serialize = "tag:new")] + TagNew, + Isrc, + Genre, +} diff --git a/src/clients/base.rs b/src/clients/base.rs index 2949e3f1..f6c671b9 100644 --- a/src/clients/base.rs +++ b/src/clients/base.rs @@ -436,9 +436,9 @@ where /// relevant audio content that is hosted externally. /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/search) - async fn search( + async fn search + Send>( &self, - q: &str, + q: T, _type: SearchType, market: Option, include_external: Option, @@ -447,8 +447,9 @@ where ) -> ClientResult { let limit = limit.map(|s| s.to_string()); let offset = offset.map(|s| s.to_string()); + let q: String = q.into(); let params = build_map([ - ("q", Some(q)), + ("q", Some(&q)), ("type", Some(_type.into())), ("market", market.map(Into::into)), ("include_external", include_external.map(Into::into)), diff --git a/src/lib.rs b/src/lib.rs index a1fa4142..124ed77e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -125,6 +125,7 @@ mod auth_code; mod auth_code_pkce; mod client_creds; pub mod clients; +pub mod search; pub mod sync; mod util; diff --git a/src/search/mod.rs b/src/search/mod.rs new file mode 100644 index 00000000..643fca14 --- /dev/null +++ b/src/search/mod.rs @@ -0,0 +1,152 @@ +use std::collections::BTreeMap; + +use rspotify_model::SearchFilter; + +/// Builder used to create search query. +/// +/// Note that when calling the same function multiple times, the filter will be the text from the +/// last call +/// +/// This is converted to the query string using into() +/// +/// Exemple +/// ```rust +/// use rspotify::search::SearchQuery; +/// +/// SearchQuery::default() +/// .any("foo") +/// .album("bar") +/// // Filter on album containing "bar" and anything containing "foo" +/// ``` +/// +/// For more information on the different filters, look at the [Soptify +/// documentation](https://developer.spotify.com/documentation/web-api/reference/#/operations/search) +#[derive(Debug, Default)] +pub struct SearchQuery<'a> { + no_filter_query: String, + query_map: BTreeMap, +} + +impl<'a> SearchQuery<'a> { + /// Basic filter where the given string can be anything + pub fn any(&mut self, str: &'a str) -> &mut Self { + self.no_filter_query.push_str(str); + self.no_filter_query.push(' '); + self + } + + pub fn album(&mut self, str: &'a str) -> &mut Self { + self.query_map.insert(SearchFilter::Album, str); + self + } + + pub fn artist(&mut self, str: &'a str) -> &mut Self { + self.query_map.insert(SearchFilter::Artist, str); + self + } + + pub fn track(&mut self, str: &'a str) -> &mut Self { + self.query_map.insert(SearchFilter::Track, str); + self + } + + pub fn year(&mut self, str: &'a str) -> &mut Self { + self.query_map.insert(SearchFilter::Year, str); + self + } + + pub fn upc(&mut self, str: &'a str) -> &mut Self { + self.query_map.insert(SearchFilter::Upc, str); + self + } + + pub fn tag_new(&mut self) -> &mut Self { + self.query_map.insert(SearchFilter::TagNew, ""); + self + } + + pub fn tag_hipster(&mut self) -> &mut Self { + self.query_map.insert(SearchFilter::TagHipster, ""); + self + } + + pub fn isrc(&mut self, str: &'a str) -> &mut Self { + self.query_map.insert(SearchFilter::Isrc, str); + self + } + + pub fn genre(&mut self, str: &'a str) -> &mut Self { + self.query_map.insert(SearchFilter::Genre, str); + self + } +} + +impl From<&SearchQuery<'_>> for String { + fn from(val: &SearchQuery) -> Self { + let mut rep = val.no_filter_query.clone(); + + if val.query_map.is_empty() { + return rep; + } + + rep.push_str( + val.query_map + .iter() + .map(|entry| match entry.0 { + SearchFilter::TagNew | SearchFilter::TagHipster => format!("{} ", entry.0), + _ => format!("{}:{} ", entry.0, entry.1), + }) + .collect::() + .trim(), + ); + + rep + } +} + +impl From<&mut SearchQuery<'_>> for String { + fn from(val: &mut SearchQuery) -> Self { + String::from(&(*val)) + } +} + +impl From> for String { + fn from(val: SearchQuery) -> Self { + String::from(&val) + } +} + +#[cfg(test)] +mod test { + use super::SearchQuery; + + #[test] + fn test_search_query() { + let query: String = SearchQuery::default() + .any("foo") + .any("bar") + .album("wrong album") + .album("arrival") + .artist("abba") + .tag_new() + .tag_hipster() + .track("foo") + .year("2020") + .upc("bar") + .isrc("foo") + .genre("metal") + .into(); + + let expected = "foo bar album:arrival artist:abba track:foo year:2020 upc:bar \ + tag:hipster tag:new isrc:foo genre:metal"; + + assert_eq!(expected, query); + } + + #[test] + fn test_empty_query() { + let query: String = SearchQuery::default().into(); + + assert_eq!(query, ""); + } +} diff --git a/tests/test_with_oauth.rs b/tests/test_with_oauth.rs index cd3def59..6d33f522 100644 --- a/tests/test_with_oauth.rs +++ b/tests/test_with_oauth.rs @@ -23,7 +23,9 @@ use rspotify::{ SearchType, ShowId, TimeLimits, TimeRange, TrackId, UserId, }, prelude::*, - scopes, AuthCodeSpotify, ClientResult, Credentials, OAuth, Token, + scopes, + search::SearchQuery, + AuthCodeSpotify, ClientResult, Credentials, OAuth, Token, }; use std::env; @@ -444,10 +446,16 @@ async fn test_repeat() { #[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_search_album() { - let query = "album:arrival artist:abba"; oauth_client() .await - .search(query, SearchType::Album, None, None, Some(10), Some(0)) + .search( + SearchQuery::default().album("arrival").artist("abba"), + SearchType::Album, + None, + None, + Some(10), + Some(0), + ) .await .unwrap(); }