From a236052b4b30f6a5f8999aafd560aa2e84d189fe Mon Sep 17 00:00:00 2001 From: Wojciech Kozlowski Date: Thu, 29 Aug 2024 22:31:02 +0200 Subject: [PATCH] Broader search syntax works --- .../musicbrainz_api/search_release_group.rs | 22 +- src/external/musicbrainz/api/mod.rs | 33 +-- src/external/musicbrainz/api/search.rs | 271 ++++++++++++++---- src/tui/lib/external/musicbrainz/mod.rs | 12 +- 4 files changed, 249 insertions(+), 89 deletions(-) diff --git a/examples/musicbrainz_api/search_release_group.rs b/examples/musicbrainz_api/search_release_group.rs index 93e2a0a..6edf3f3 100644 --- a/examples/musicbrainz_api/search_release_group.rs +++ b/examples/musicbrainz_api/search_release_group.rs @@ -5,7 +5,10 @@ use std::{num::ParseIntError, str::FromStr}; use musichoard::{ collection::{album::AlbumDate, musicbrainz::Mbid}, external::musicbrainz::{ - api::{search::SearchReleaseGroupRequest, MusicBrainzClient}, + api::{ + search::{Expression, Query, SearchReleaseGroup}, + MusicBrainzClient, + }, http::MusicBrainzHttp, }, }; @@ -85,29 +88,30 @@ fn main() { let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client"); let mut client = MusicBrainzClient::new(http); - let mut request = SearchReleaseGroupRequest::default(); let arid: Mbid; let date: AlbumDate; let title: String; let rgid: Mbid; - match opt.command { + + let query: Query = match opt.command { OptCommand::Title(opt_title) => { arid = opt_title.arid.into(); date = opt_title.date.map(Into::into).unwrap_or_default(); title = opt_title.title; - request - .arid(&arid) - .first_release_date(&date) - .release_group(&title); + Query::expression(Expression::arid(&arid)) + .and(Expression::release_group(&title)) + .and(Expression::first_release_date(&date)) } OptCommand::Rgid(opt_rgid) => { rgid = opt_rgid.rgid.into(); - request.rgid(&rgid); + Query::expression(Expression::rgid(&rgid)) } }; + println!("Query: {query}"); + let matches = client - .search_release_group(request) + .search_release_group(query) .expect("failed to make API call"); println!("{matches:#?}"); diff --git a/src/external/musicbrainz/api/mod.rs b/src/external/musicbrainz/api/mod.rs index b828450..f4a2d5e 100644 --- a/src/external/musicbrainz/api/mod.rs +++ b/src/external/musicbrainz/api/mod.rs @@ -56,17 +56,21 @@ impl MusicBrainzClient { pub fn new(http: Http) -> Self { MusicBrainzClient { http } } +} - fn format_album_date(date: &AlbumDate) -> Option { +pub struct ApiDisplay; + +impl ApiDisplay { + fn format_album_date(date: &AlbumDate) -> String { match date.year { Some(year) => match date.month { Some(month) => match date.day { - Some(day) => Some(format!("{year}-{month:02}-{day:02}")), - None => Some(format!("{year}-{month:02}")), + Some(day) => format!("{year}-{month:02}-{day:02}"), + None => format!("{year}-{month:02}"), }, - None => Some(format!("{year}")), + None => format!("{year}"), }, - None => None, + None => format!("*"), } } } @@ -241,22 +245,15 @@ mod tests { #[test] fn format_album_date() { - struct Null; assert_eq!( - MusicBrainzClient::::format_album_date(&AlbumDate::new(None, None, None)), - None + ApiDisplay::format_album_date(&AlbumDate::new(None, None, None)), + "*" ); + assert_eq!(ApiDisplay::format_album_date(&(1986).into()), "1986"); + assert_eq!(ApiDisplay::format_album_date(&(1986, 4).into()), "1986-04"); assert_eq!( - MusicBrainzClient::::format_album_date(&(1986).into()), - Some(String::from("1986")) - ); - assert_eq!( - MusicBrainzClient::::format_album_date(&(1986, 4).into()), - Some(String::from("1986-04")) - ); - assert_eq!( - MusicBrainzClient::::format_album_date(&(1986, 4, 21).into()), - Some(String::from("1986-04-21")) + ApiDisplay::format_album_date(&(1986, 4, 21).into()), + "1986-04-21" ); } diff --git a/src/external/musicbrainz/api/search.rs b/src/external/musicbrainz/api/search.rs index 71ada9f..7b5bf21 100644 --- a/src/external/musicbrainz/api/search.rs +++ b/src/external/musicbrainz/api/search.rs @@ -1,5 +1,7 @@ //! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API). +use std::fmt; + use serde::Deserialize; use url::form_urlencoded; @@ -8,7 +10,7 @@ use crate::{ core::collection::album::{AlbumPrimaryType, AlbumSecondaryType}, external::musicbrainz::{ api::{ - Error, MusicBrainzClient, SerdeAlbumDate, SerdeAlbumPrimaryType, + ApiDisplay, Error, MusicBrainzClient, SerdeAlbumDate, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType, SerdeMbid, MB_BASE_URL, }, IMusicBrainzHttp, @@ -18,30 +20,10 @@ use crate::{ impl MusicBrainzClient { pub fn search_release_group( &mut self, - request: SearchReleaseGroupRequest, + query: SearchReleaseGroupRequest, ) -> Result { - let mut query: Vec = vec![]; - - if let Some(arid) = request.arid { - query.push(format!("arid:{}", arid.uuid().as_hyphenated())); - } - - if let Some(date) = request.first_release_date { - if let Some(date_string) = Self::format_album_date(date) { - query.push(format!("firstreleasedate:{date_string}")) - } - } - - if let Some(release_group) = request.release_group { - query.push(format!("releasegroup:\"{release_group}\"")); - } - - if let Some(rgid) = request.rgid { - query.push(format!("rgid:{}", rgid.uuid().as_hyphenated())); - } - let query: String = - form_urlencoded::byte_serialize(query.join(" AND ").as_bytes()).collect(); + form_urlencoded::byte_serialize(format!("{query}").as_bytes()).collect(); let url = format!("{MB_BASE_URL}/release-group?query={query}"); let response: DeserializeSearchReleaseGroupResponse = self.http.get(&url)?; @@ -49,40 +31,186 @@ impl MusicBrainzClient { } } -#[derive(Default)] -pub struct SearchReleaseGroupRequest<'a> { - arid: Option<&'a Mbid>, - first_release_date: Option<&'a AlbumDate>, - release_group: Option<&'a str>, - rgid: Option<&'a Mbid>, +pub enum Logical { + Unary(Unary), + Binary(Boolean), } -impl<'a> SearchReleaseGroupRequest<'a> { - pub fn new() -> Self { - Self::default() +impl fmt::Display for Logical { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Logical::Unary(u) => write!(f, "{u}"), + Logical::Binary(b) => write!(f, "{b}"), + } + } +} + +pub enum Unary { + Require, + Prohibit, +} + +impl fmt::Display for Unary { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Unary::Require => write!(f, "+"), + Unary::Prohibit => write!(f, "-"), + } + } +} + +pub enum Boolean { + And, + Or, + Not, +} + +impl fmt::Display for Boolean { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Boolean::And => write!(f, "AND "), + Boolean::Or => write!(f, "OR "), + Boolean::Not => write!(f, "NOT "), + } + } +} + +pub enum Expression { + Term(Entity), + Expr(Query), +} + +impl fmt::Display for Expression { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Expression::Term(t) => write!(f, "{t}"), + Expression::Expr(q) => write!(f, "({q})"), + } + } +} + +pub struct Query { + left: (Option, Box>), + right: Vec<(Logical, Box>)>, +} + +impl fmt::Display for Query { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(u) = &self.left.0 { + write!(f, "{u}")?; + } + + write!(f, "{}", self.left.1)?; + + for (logical, expr) in self.right.iter() { + write!(f, " {logical}{expr}")?; + } + + Ok(()) + } +} + +impl<'a, Entity> Query { + pub fn expression(expr: Expression) -> Self { + Query { + left: (None, Box::new(expr)), + right: vec![], + } } - pub fn arid(&mut self, arid: &'a Mbid) -> &mut Self { - self.arid = Some(arid); + pub fn require(expr: Expression) -> Self { + Query { + left: (Some(Unary::Require), Box::new(expr)), + right: vec![], + } + } + + pub fn and_require(mut self, expr: Expression) -> Self { + self.right + .push((Logical::Unary(Unary::Require), Box::new(expr))); self } - pub fn first_release_date(&mut self, first_release_date: &'a AlbumDate) -> &mut Self { - self.first_release_date = Some(first_release_date); + pub fn prohibit(expr: Expression) -> Self { + Query { + left: (Some(Unary::Prohibit), Box::new(expr)), + right: vec![], + } + } + + pub fn and_prohibit(mut self, expr: Expression) -> Self { + self.right + .push((Logical::Unary(Unary::Prohibit), Box::new(expr))); self } - pub fn release_group(&mut self, release_group: &'a str) -> &mut Self { - self.release_group = Some(release_group); + pub fn and(mut self, expr: Expression) -> Self { + self.right + .push((Logical::Binary(Boolean::And), Box::new(expr))); self } - pub fn rgid(&mut self, rgid: &'a Mbid) -> &mut Self { - self.rgid = Some(rgid); + pub fn or(mut self, expr: Expression) -> Self { + self.right + .push((Logical::Binary(Boolean::Or), Box::new(expr))); + self + } + + pub fn not(mut self, expr: Expression) -> Self { + self.right + .push((Logical::Binary(Boolean::Not), Box::new(expr))); self } } +pub enum SearchReleaseGroup<'a> { + NoField(&'a str), + Arid(&'a Mbid), + FirstReleaseDate(&'a AlbumDate), + ReleaseGroup(&'a str), + Rgid(&'a Mbid), +} + +impl<'a> Expression> { + pub fn no_field(string: &'a str) -> Self { + Expression::Term(SearchReleaseGroup::NoField(string)) + } + + pub fn arid(arid: &'a Mbid) -> Self { + Expression::Term(SearchReleaseGroup::Arid(arid)) + } + + pub fn first_release_date(first_release_date: &'a AlbumDate) -> Self { + Expression::Term(SearchReleaseGroup::FirstReleaseDate(first_release_date)) + } + + pub fn release_group(release_group: &'a str) -> Self { + Expression::Term(SearchReleaseGroup::ReleaseGroup(release_group)) + } + + pub fn rgid(rgid: &'a Mbid) -> Self { + Expression::Term(SearchReleaseGroup::Rgid(rgid)) + } +} + +impl<'a> fmt::Display for SearchReleaseGroup<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoField(s) => write!(f, "\"{s}\""), + Self::Arid(arid) => write!(f, "arid:{}", arid.uuid().as_hyphenated()), + Self::FirstReleaseDate(date) => write!( + f, + "firstreleasedate:{}", + ApiDisplay::format_album_date(date) + ), + Self::ReleaseGroup(release_group) => write!(f, "releasegroup:\"{release_group}\""), + Self::Rgid(rgid) => write!(f, "rgid:{}", rgid.uuid().as_hyphenated()), + } + } +} + +pub type SearchReleaseGroupRequest<'a> = Query>; + #[derive(Debug, PartialEq, Eq)] pub struct SearchReleaseGroupResponse { pub release_groups: Vec, @@ -148,6 +276,44 @@ mod tests { use super::*; + #[test] + fn lucene_logical() { + let query = Query::expression(Expression::no_field("jakarta apache")) + .or(Expression::no_field("jakarta")); + assert_eq!(format!("{query}"), "\"jakarta apache\" OR \"jakarta\""); + + let query = Query::expression(Expression::no_field("jakarta apache")) + .and(Expression::no_field("jakarta")); + assert_eq!(format!("{query}"), "\"jakarta apache\" AND \"jakarta\""); + + let query = + Query::require(Expression::no_field("jakarta")).or(Expression::no_field("lucene")); + assert_eq!(format!("{query}"), "+\"jakarta\" OR \"lucene\""); + + let query = Query::expression(Expression::no_field("jakarta apache")) + .not(Expression::no_field("Apache Lucene")); + assert_eq!( + format!("{query}"), + "\"jakarta apache\" NOT \"Apache Lucene\"" + ); + + let query = Query::expression(Expression::no_field("jakarta apache")) + .and_prohibit(Expression::no_field("Apache Lucene")); + assert_eq!(format!("{query}"), "\"jakarta apache\" -\"Apache Lucene\""); + } + + #[test] + fn lucene_grouping() { + let query = Query::expression(Expression::Expr( + Query::expression(Expression::no_field("jakarta")).or(Expression::no_field("apache")), + )) + .and(Expression::no_field("website")); + assert_eq!( + format!("{query}"), + "(\"jakarta\" OR \"apache\") AND \"website\"" + ); + } + #[test] fn search_release_group() { let mut http = MockIMusicBrainzHttp::new(); @@ -214,21 +380,18 @@ mod tests { let title: AlbumId = AlbumId::new("an album"); let date = (1986, 4).into(); - let mut request = SearchReleaseGroupRequest::new(); - request - .arid(&arid) - .release_group(&title.title) - .first_release_date(&date); + let query = Query::expression(Expression::arid(&arid)) + .and(Expression::release_group(&title.title)) + .and(Expression::first_release_date(&date)); - let matches = client.search_release_group(request).unwrap(); + let matches = client.search_release_group(query).unwrap(); assert_eq!(matches, response); let rgid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap(); - let mut request = SearchReleaseGroupRequest::new(); - request.rgid(&rgid); + let query = Query::expression(Expression::rgid(&rgid)); - let matches = client.search_release_group(request).unwrap(); + let matches = client.search_release_group(query).unwrap(); assert_eq!(matches, response); } @@ -258,12 +421,10 @@ mod tests { let title: AlbumId = AlbumId::new("an album"); let date = AlbumDate::default(); - let mut request = SearchReleaseGroupRequest::new(); - request - .arid(&arid) - .release_group(&title.title) - .first_release_date(&date); + let query = Query::expression(Expression::arid(&arid)) + .and(Expression::release_group(&title.title)) + .and(Expression::first_release_date(&date)); - let _ = client.search_release_group(request).unwrap(); + let _ = client.search_release_group(query).unwrap(); } } diff --git a/src/tui/lib/external/musicbrainz/mod.rs b/src/tui/lib/external/musicbrainz/mod.rs index b68312a..ec16231 100644 --- a/src/tui/lib/external/musicbrainz/mod.rs +++ b/src/tui/lib/external/musicbrainz/mod.rs @@ -7,7 +7,7 @@ use musichoard::{ }, external::musicbrainz::{ api::{ - search::{SearchReleaseGroupRequest, SearchReleaseGroupResponseReleaseGroup}, + search::{Expression, Query, SearchReleaseGroupResponseReleaseGroup}, MusicBrainzClient, }, IMusicBrainzHttp, @@ -37,13 +37,11 @@ impl IMusicBrainz for MusicBrainz { // with just the year should be enough anyway. let date = AlbumDate::new(album.date.year, None, None); - let mut request = SearchReleaseGroupRequest::default(); - request - .arid(arid) - .first_release_date(&date) - .release_group(&album.id.title); + let query = Query::expression(Expression::arid(arid)) + .and(Expression::first_release_date(&date)) + .and(Expression::release_group(&album.id.title)); - let mb_response = self.client.search_release_group(request)?; + let mb_response = self.client.search_release_group(query)?; Ok(mb_response .release_groups