From 096b8ed0751272023f0539724a3c1ecd7d490ab3 Mon Sep 17 00:00:00 2001 From: Wojciech Kozlowski Date: Fri, 30 Aug 2024 11:40:12 +0200 Subject: [PATCH] Support for artist musicbrainz search --- Cargo.lock | 5 +- Cargo.toml | 7 +- examples/musicbrainz_api/search.rs | 150 +++++++++ .../musicbrainz_api/search_release_group.rs | 118 ------- src/external/musicbrainz/api/search/artist.rs | 76 +++++ src/external/musicbrainz/api/search/mod.rs | 289 +++--------------- .../musicbrainz/api/search/release_group.rs | 241 +++++++++++++++ 7 files changed, 513 insertions(+), 373 deletions(-) create mode 100644 examples/musicbrainz_api/search.rs delete mode 100644 examples/musicbrainz_api/search_release_group.rs create mode 100644 src/external/musicbrainz/api/search/artist.rs create mode 100644 src/external/musicbrainz/api/search/release_group.rs diff --git a/Cargo.lock b/Cargo.lock index 294a0ff..327ce37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -645,6 +645,7 @@ dependencies = [ "mockall", "once_cell", "openssh", + "paste", "ratatui", "reqwest", "serde", @@ -812,9 +813,9 @@ dependencies = [ [[package]] name = "paste" -version = "1.0.14" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "percent-encoding" diff --git a/Cargo.toml b/Cargo.toml index a3a58c7..89ce141 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ aho-corasick = { version = "1.1.2", optional = true } crossterm = { version = "0.27.0", optional = true} once_cell = { version = "1.19.0", optional = true} openssh = { version = "0.10.3", features = ["native-mux"], default-features = false, optional = true} +paste = { version = "1.0.15", optional = true } ratatui = { version = "0.26.0", optional = true} reqwest = { version = "0.11.25", features = ["blocking", "json"], optional = true } serde = { version = "1.0.196", features = ["derive"], optional = true } @@ -33,7 +34,7 @@ bin = ["structopt"] database-json = ["serde", "serde_json"] library-beets = [] library-beets-ssh = ["openssh", "tokio"] -musicbrainz = ["reqwest", "serde", "serde_json"] +musicbrainz = ["paste", "reqwest", "serde", "serde_json"] tui = ["aho-corasick", "crossterm", "once_cell", "ratatui"] [[bin]] @@ -50,8 +51,8 @@ path = "examples/musicbrainz_api/lookup_artist.rs" required-features = ["bin", "musicbrainz"] [[example]] -name = "musicbrainz-api---search-release-group" -path = "examples/musicbrainz_api/search_release_group.rs" +name = "musicbrainz-api---search" +path = "examples/musicbrainz_api/search.rs" required-features = ["bin", "musicbrainz"] [package.metadata.docs.rs] diff --git a/examples/musicbrainz_api/search.rs b/examples/musicbrainz_api/search.rs new file mode 100644 index 0000000..c88da97 --- /dev/null +++ b/examples/musicbrainz_api/search.rs @@ -0,0 +1,150 @@ +#![allow(non_snake_case)] + +use std::{num::ParseIntError, str::FromStr}; + +use musichoard::{ + collection::{album::AlbumDate, musicbrainz::Mbid}, + external::musicbrainz::{ + api::{ + search::{SearchArtistRequest, SearchReleaseGroupRequest}, + MusicBrainzClient, + }, + http::MusicBrainzHttp, + }, +}; +use structopt::StructOpt; +use uuid::Uuid; + +const USER_AGENT: &str = concat!( + "MusicHoard---examples---musicbrainz-api---search/", + env!("CARGO_PKG_VERSION"), + " ( musichoard@thenineworlds.net )" +); + +#[derive(StructOpt)] +struct Opt { + #[structopt(subcommand)] + entity: OptEntity, +} + +#[derive(StructOpt)] +enum OptEntity { + #[structopt(about = "Search artist")] + Artist(OptArtist), + #[structopt(about = "Search release group")] + ReleaseGroup(OptReleaseGroup), +} + +#[derive(StructOpt)] +struct OptArtist { + #[structopt(help = "Artist search string")] + string: String, +} + +#[derive(StructOpt)] +enum OptReleaseGroup { + #[structopt(about = "Search by artist MBID, title(, and date)")] + Title(OptReleaseGroupTitle), + #[structopt(about = "Search by release group MBID")] + Rgid(OptReleaseGroupRgid), +} + +#[derive(StructOpt)] +struct OptReleaseGroupTitle { + #[structopt(help = "Release group's artist MBID")] + arid: Uuid, + + #[structopt(help = "Release group title")] + title: String, + + #[structopt(help = "Release group release date")] + date: Option, +} + +#[derive(StructOpt)] +struct OptReleaseGroupRgid { + #[structopt(help = "Release group MBID")] + rgid: Uuid, +} + +struct Date(AlbumDate); + +impl FromStr for Date { + type Err = ParseIntError; + + fn from_str(s: &str) -> Result { + let mut elems = s.split('-'); + + let elem = elems.next(); + let year = elem.map(|s| s.parse()).transpose()?; + + let elem = elems.next(); + let month = elem.map(|s| s.parse()).transpose()?; + + let elem = elems.next(); + let day = elem.map(|s| s.parse()).transpose()?; + + Ok(Date(AlbumDate::new(year, month, day))) + } +} + +impl From for AlbumDate { + fn from(value: Date) -> Self { + value.0 + } +} + +fn main() { + let opt = Opt::from_args(); + + println!("USER_AGENT: {USER_AGENT}"); + + let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client"); + let mut client = MusicBrainzClient::new(http); + + match opt.entity { + OptEntity::Artist(opt_artist) => { + let query = SearchArtistRequest::new().no_field(&opt_artist.string); + + println!("Query: {query}"); + + let matches = client + .search_artist(query) + .expect("failed to make API call"); + + println!("{matches:#?}"); + } + OptEntity::ReleaseGroup(opt_release_group) => { + let arid: Mbid; + let date: AlbumDate; + let title: String; + let rgid: Mbid; + + let query = match opt_release_group { + OptReleaseGroup::Title(opt_title) => { + arid = opt_title.arid.into(); + date = opt_title.date.map(Into::into).unwrap_or_default(); + title = opt_title.title; + SearchReleaseGroupRequest::new() + .arid(&arid) + .and() + .release_group(&title) + .and() + .first_release_date(&date) + } + OptReleaseGroup::Rgid(opt_rgid) => { + rgid = opt_rgid.rgid.into(); + SearchReleaseGroupRequest::new().rgid(&rgid) + } + }; + + println!("Query: {query}"); + + let matches = client + .search_release_group(query) + .expect("failed to make API call"); + + println!("{matches:#?}"); + } + } +} diff --git a/examples/musicbrainz_api/search_release_group.rs b/examples/musicbrainz_api/search_release_group.rs deleted file mode 100644 index dc73c09..0000000 --- a/examples/musicbrainz_api/search_release_group.rs +++ /dev/null @@ -1,118 +0,0 @@ -#![allow(non_snake_case)] - -use std::{num::ParseIntError, str::FromStr}; - -use musichoard::{ - collection::{album::AlbumDate, musicbrainz::Mbid}, - external::musicbrainz::{ - api::{search::SearchReleaseGroupRequest, MusicBrainzClient}, - http::MusicBrainzHttp, - }, -}; -use structopt::StructOpt; -use uuid::Uuid; - -const USER_AGENT: &str = concat!( - "MusicHoard---examples---musicbrainz-api---search-release-group/", - env!("CARGO_PKG_VERSION"), - " ( musichoard@thenineworlds.net )" -); - -#[derive(StructOpt)] -struct Opt { - #[structopt(subcommand)] - command: OptCommand, -} - -#[derive(StructOpt)] -enum OptCommand { - #[structopt(about = "Search by artist MBID, title(, and date)")] - Title(OptTitle), - #[structopt(about = "Search by release group MBID")] - Rgid(OptRgid), -} - -#[derive(StructOpt)] -struct OptTitle { - #[structopt(help = "Release group's artist MBID")] - arid: Uuid, - - #[structopt(help = "Release group title")] - title: String, - - #[structopt(help = "Release group release date")] - date: Option, -} - -#[derive(StructOpt)] -struct OptRgid { - #[structopt(help = "Release group MBID")] - rgid: Uuid, -} - -struct Date(AlbumDate); - -impl FromStr for Date { - type Err = ParseIntError; - - fn from_str(s: &str) -> Result { - let mut elems = s.split('-'); - - let elem = elems.next(); - let year = elem.map(|s| s.parse()).transpose()?; - - let elem = elems.next(); - let month = elem.map(|s| s.parse()).transpose()?; - - let elem = elems.next(); - let day = elem.map(|s| s.parse()).transpose()?; - - Ok(Date(AlbumDate::new(year, month, day))) - } -} - -impl From for AlbumDate { - fn from(value: Date) -> Self { - value.0 - } -} - -fn main() { - let opt = Opt::from_args(); - - println!("USER_AGENT: {USER_AGENT}"); - - let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client"); - let mut client = MusicBrainzClient::new(http); - - let arid: Mbid; - let date: AlbumDate; - let title: String; - let rgid: Mbid; - - let 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; - SearchReleaseGroupRequest::new() - .arid(&arid) - .and() - .release_group(&title) - .and() - .first_release_date(&date) - } - OptCommand::Rgid(opt_rgid) => { - rgid = opt_rgid.rgid.into(); - SearchReleaseGroupRequest::new().rgid(&rgid) - } - }; - - println!("Query: {query}"); - - let matches = client - .search_release_group(query) - .expect("failed to make API call"); - - println!("{matches:#?}"); -} diff --git a/src/external/musicbrainz/api/search/artist.rs b/src/external/musicbrainz/api/search/artist.rs new file mode 100644 index 0000000..bede1dd --- /dev/null +++ b/src/external/musicbrainz/api/search/artist.rs @@ -0,0 +1,76 @@ +use std::fmt; + +use serde::Deserialize; + +use crate::{collection::{artist::ArtistId, musicbrainz::Mbid}, external::musicbrainz::api::SerdeMbid}; + +use super::query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin}; + +pub enum SearchArtist<'a> { + NoField(&'a str), +} + +impl<'a> fmt::Display for SearchArtist<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoField(s) => write!(f, "\"{s}\""), + } + } +} + +pub type SearchArtistRequest<'a> = Query>; + +impl_term!(no_field, SearchArtist<'a>, NoField, &'a str); + +#[derive(Debug, PartialEq, Eq)] +pub struct SearchArtistResponse { + pub artists: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all(deserialize = "kebab-case"))] +pub struct DeserializeSearchArtistResponse { + artists: Vec, +} + +impl From for SearchArtistResponse { + fn from(value: DeserializeSearchArtistResponse) -> Self { + SearchArtistResponse { + artists: value.artists.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SearchArtistResponseArtist { + pub score: u8, + pub id: Mbid, + pub name: ArtistId, + pub sort: Option, + pub disambiguation: Option, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all(deserialize = "kebab-case"))] +struct DeserializeSearchArtistResponseArtist { + score: u8, + id: SerdeMbid, + name: String, + sort_name: String, + disambiguation: Option, +} + +impl From for SearchArtistResponseArtist { + fn from(value: DeserializeSearchArtistResponseArtist) -> Self { + let sort: Option = Some(value.sort_name) + .filter(|s| s != &value.name) + .map(Into::into); + SearchArtistResponseArtist { + score: value.score, + id: value.id.into(), + name: value.name.into(), + sort, + disambiguation: value.disambiguation, + } + } +} diff --git a/src/external/musicbrainz/api/search/mod.rs b/src/external/musicbrainz/api/search/mod.rs index b266879..564063b 100644 --- a/src/external/musicbrainz/api/search/mod.rs +++ b/src/external/musicbrainz/api/search/mod.rs @@ -1,257 +1,46 @@ //! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API). +mod artist; mod query; +mod release_group; -use std::fmt; - -use query::{impl_term, EmptyQuery, EmptyQueryJoin, QueryJoin}; -use serde::Deserialize; -use url::form_urlencoded; - -use crate::{ - collection::{album::AlbumDate, musicbrainz::Mbid}, - core::collection::album::{AlbumPrimaryType, AlbumSecondaryType}, - external::musicbrainz::{ - api::{ - search::query::Query, ApiDisplay, Error, MusicBrainzClient, SerdeAlbumDate, - SerdeAlbumPrimaryType, SerdeAlbumSecondaryType, SerdeMbid, MB_BASE_URL, - }, - IMusicBrainzHttp, - }, +pub use artist::{SearchArtistRequest, SearchArtistResponse, SearchArtistResponseArtist}; +pub use release_group::{ + SearchReleaseGroupRequest, SearchReleaseGroupResponse, SearchReleaseGroupResponseReleaseGroup, }; +use paste::paste; +use url::form_urlencoded; + +use crate::external::musicbrainz::{ + api::{ + search::{ + artist::DeserializeSearchArtistResponse, + release_group::DeserializeSearchReleaseGroupResponse, + }, + Error, MusicBrainzClient, MB_BASE_URL, + }, + IMusicBrainzHttp, +}; + +macro_rules! impl_search_entity { + ($name:ident, $entity:literal) => { + paste! { + pub fn []( + &mut self, + query: [] + ) -> Result<[], Error> { + let query: String = + form_urlencoded::byte_serialize(format!("{query}").as_bytes()).collect(); + let url = format!("{MB_BASE_URL}/{entity}?query={query}", entity = $entity); + + let response: [] = self.http.get(&url)?; + Ok(response.into()) + } + } + }; +} + impl MusicBrainzClient { - pub fn search_release_group( - &mut self, - query: SearchReleaseGroupRequest, - ) -> Result { - let query: String = - 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)?; - Ok(response.into()) - } -} - -pub enum SearchReleaseGroup<'a> { - NoField(&'a str), - Arid(&'a Mbid), - FirstReleaseDate(&'a AlbumDate), - ReleaseGroup(&'a str), - Rgid(&'a Mbid), -} - -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>; - -impl_term!(no_field, SearchReleaseGroup<'a>, NoField, &'a str); -impl_term!(arid, SearchReleaseGroup<'a>, Arid, &'a Mbid); -impl_term!( - first_release_date, - SearchReleaseGroup<'a>, - FirstReleaseDate, - &'a AlbumDate -); -impl_term!(release_group, SearchReleaseGroup<'a>, ReleaseGroup, &'a str); -impl_term!(rgid, SearchReleaseGroup<'a>, Rgid, &'a Mbid); - -#[derive(Debug, PartialEq, Eq)] -pub struct SearchReleaseGroupResponse { - pub release_groups: Vec, -} - -#[derive(Clone, Deserialize)] -#[serde(rename_all(deserialize = "kebab-case"))] -struct DeserializeSearchReleaseGroupResponse { - release_groups: Vec, -} - -impl From for SearchReleaseGroupResponse { - fn from(value: DeserializeSearchReleaseGroupResponse) -> Self { - SearchReleaseGroupResponse { - release_groups: value.release_groups.into_iter().map(Into::into).collect(), - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SearchReleaseGroupResponseReleaseGroup { - pub score: u8, - pub id: Mbid, - pub title: String, - pub first_release_date: AlbumDate, - pub primary_type: AlbumPrimaryType, - pub secondary_types: Option>, -} - -#[derive(Clone, Deserialize)] -#[serde(rename_all(deserialize = "kebab-case"))] -struct DeserializeSearchReleaseGroupResponseReleaseGroup { - score: u8, - id: SerdeMbid, - title: String, - first_release_date: SerdeAlbumDate, - primary_type: SerdeAlbumPrimaryType, - secondary_types: Option>, -} - -impl From - for SearchReleaseGroupResponseReleaseGroup -{ - fn from(value: DeserializeSearchReleaseGroupResponseReleaseGroup) -> Self { - SearchReleaseGroupResponseReleaseGroup { - score: value.score, - id: value.id.into(), - title: value.title, - first_release_date: value.first_release_date.into(), - primary_type: value.primary_type.into(), - secondary_types: value - .secondary_types - .map(|v| v.into_iter().map(Into::into).collect()), - } - } -} - -#[cfg(test)] -mod tests { - use mockall::{predicate, Sequence}; - - use crate::{collection::album::AlbumId, external::musicbrainz::MockIMusicBrainzHttp}; - - use super::*; - - #[test] - fn search_release_group() { - let mut http = MockIMusicBrainzHttp::new(); - let url_title = format!( - "https://musicbrainz.org/ws/2\ - /release-group\ - ?query=arid%3A{arid}+AND+firstreleasedate%3A{date}+AND+releasegroup%3A%22{title}%22", - arid = "00000000-0000-0000-0000-000000000000", - date = "1986-04", - title = "an+album", - ); - let url_rgid = format!( - "https://musicbrainz.org/ws/2\ - /release-group\ - ?query=rgid%3A{rgid}", - rgid = "11111111-1111-1111-1111-111111111111", - ); - - let de_release_group = DeserializeSearchReleaseGroupResponseReleaseGroup { - score: 67, - id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()), - title: String::from("an album"), - first_release_date: SerdeAlbumDate((1986, 4).into()), - primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album), - secondary_types: Some(vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Live)]), - }; - let de_response = DeserializeSearchReleaseGroupResponse { - release_groups: vec![de_release_group.clone()], - }; - - let release_group = SearchReleaseGroupResponseReleaseGroup { - score: 67, - id: de_release_group.id.0, - title: de_release_group.title, - first_release_date: de_release_group.first_release_date.0, - primary_type: de_release_group.primary_type.0, - secondary_types: de_release_group - .secondary_types - .map(|v| v.into_iter().map(|st| st.0).collect()), - }; - let response = SearchReleaseGroupResponse { - release_groups: vec![release_group.clone()], - }; - - let mut seq = Sequence::new(); - - let title_response = de_response.clone(); - http.expect_get() - .times(1) - .with(predicate::eq(url_title)) - .return_once(|_| Ok(title_response)) - .in_sequence(&mut seq); - - let rgid_response = de_response; - http.expect_get() - .times(1) - .with(predicate::eq(url_rgid)) - .return_once(|_| Ok(rgid_response)) - .in_sequence(&mut seq); - - let mut client = MusicBrainzClient::new(http); - - let arid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap(); - let title: AlbumId = AlbumId::new("an album"); - let date = (1986, 4).into(); - - let query = SearchReleaseGroupRequest::new() - .arid(&arid) - .and() - .release_group(&title.title) - .and() - .first_release_date(&date); - - let matches = client.search_release_group(query).unwrap(); - assert_eq!(matches, response); - - let rgid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap(); - - let query = SearchReleaseGroupRequest::new().rgid(&rgid); - - let matches = client.search_release_group(query).unwrap(); - assert_eq!(matches, response); - } - - #[test] - fn search_release_group_empty_date() { - let mut http = MockIMusicBrainzHttp::new(); - let url = format!( - "https://musicbrainz.org/ws/2\ - /release-group\ - ?query=arid%3A{arid}+AND+releasegroup%3A%22{title}%22", - arid = "00000000-0000-0000-0000-000000000000", - title = "an+album", - ); - - let de_response = DeserializeSearchReleaseGroupResponse { - release_groups: vec![], - }; - - http.expect_get() - .times(1) - .with(predicate::eq(url)) - .return_once(|_| Ok(de_response)); - - let mut client = MusicBrainzClient::new(http); - - let arid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap(); - let title: AlbumId = AlbumId::new("an album"); - let date = AlbumDate::default(); - - let query = SearchReleaseGroupRequest::new() - .arid(&arid) - .and() - .release_group(&title.title) - .and() - .first_release_date(&date); - - let _ = client.search_release_group(query).unwrap(); - } + impl_search_entity!(Artist, "artist"); + impl_search_entity!(ReleaseGroup, "release-group"); } diff --git a/src/external/musicbrainz/api/search/release_group.rs b/src/external/musicbrainz/api/search/release_group.rs new file mode 100644 index 0000000..4cfac19 --- /dev/null +++ b/src/external/musicbrainz/api/search/release_group.rs @@ -0,0 +1,241 @@ +use std::fmt; + +use serde::Deserialize; + +use crate::{ + collection::{ + album::{AlbumDate, AlbumPrimaryType, AlbumSecondaryType}, + musicbrainz::Mbid, + }, + external::musicbrainz::api::{ + ApiDisplay, SerdeAlbumDate, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType, SerdeMbid, + }, +}; + +use super::query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin}; + +pub enum SearchReleaseGroup<'a> { + NoField(&'a str), + Arid(&'a Mbid), + FirstReleaseDate(&'a AlbumDate), + ReleaseGroup(&'a str), + Rgid(&'a Mbid), +} + +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>; + +impl_term!(no_field, SearchReleaseGroup<'a>, NoField, &'a str); +impl_term!(arid, SearchReleaseGroup<'a>, Arid, &'a Mbid); +impl_term!( + first_release_date, + SearchReleaseGroup<'a>, + FirstReleaseDate, + &'a AlbumDate +); +impl_term!(release_group, SearchReleaseGroup<'a>, ReleaseGroup, &'a str); +impl_term!(rgid, SearchReleaseGroup<'a>, Rgid, &'a Mbid); + +#[derive(Debug, PartialEq, Eq)] +pub struct SearchReleaseGroupResponse { + pub release_groups: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all(deserialize = "kebab-case"))] +pub struct DeserializeSearchReleaseGroupResponse { + release_groups: Vec, +} + +impl From for SearchReleaseGroupResponse { + fn from(value: DeserializeSearchReleaseGroupResponse) -> Self { + SearchReleaseGroupResponse { + release_groups: value.release_groups.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SearchReleaseGroupResponseReleaseGroup { + pub score: u8, + pub id: Mbid, + pub title: String, + pub first_release_date: AlbumDate, + pub primary_type: AlbumPrimaryType, + pub secondary_types: Option>, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all(deserialize = "kebab-case"))] +struct DeserializeSearchReleaseGroupResponseReleaseGroup { + score: u8, + id: SerdeMbid, + title: String, + first_release_date: SerdeAlbumDate, + primary_type: SerdeAlbumPrimaryType, + secondary_types: Option>, +} + +impl From + for SearchReleaseGroupResponseReleaseGroup +{ + fn from(value: DeserializeSearchReleaseGroupResponseReleaseGroup) -> Self { + SearchReleaseGroupResponseReleaseGroup { + score: value.score, + id: value.id.into(), + title: value.title, + first_release_date: value.first_release_date.into(), + primary_type: value.primary_type.into(), + secondary_types: value + .secondary_types + .map(|v| v.into_iter().map(Into::into).collect()), + } + } +} + +#[cfg(test)] +mod tests { + use mockall::{predicate, Sequence}; + + use crate::{ + collection::album::AlbumId, + external::musicbrainz::{api::MusicBrainzClient, MockIMusicBrainzHttp}, + }; + + use super::*; + + #[test] + fn search_release_group() { + let mut http = MockIMusicBrainzHttp::new(); + let url_title = format!( + "https://musicbrainz.org/ws/2\ + /release-group\ + ?query=arid%3A{arid}+AND+firstreleasedate%3A{date}+AND+releasegroup%3A%22{title}%22", + arid = "00000000-0000-0000-0000-000000000000", + date = "1986-04", + title = "an+album", + ); + let url_rgid = format!( + "https://musicbrainz.org/ws/2\ + /release-group\ + ?query=rgid%3A{rgid}", + rgid = "11111111-1111-1111-1111-111111111111", + ); + + let de_release_group = DeserializeSearchReleaseGroupResponseReleaseGroup { + score: 67, + id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()), + title: String::from("an album"), + first_release_date: SerdeAlbumDate((1986, 4).into()), + primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album), + secondary_types: Some(vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Live)]), + }; + let de_response = DeserializeSearchReleaseGroupResponse { + release_groups: vec![de_release_group.clone()], + }; + + let release_group = SearchReleaseGroupResponseReleaseGroup { + score: 67, + id: de_release_group.id.0, + title: de_release_group.title, + first_release_date: de_release_group.first_release_date.0, + primary_type: de_release_group.primary_type.0, + secondary_types: de_release_group + .secondary_types + .map(|v| v.into_iter().map(|st| st.0).collect()), + }; + let response = SearchReleaseGroupResponse { + release_groups: vec![release_group.clone()], + }; + + let mut seq = Sequence::new(); + + let title_response = de_response.clone(); + http.expect_get() + .times(1) + .with(predicate::eq(url_title)) + .return_once(|_| Ok(title_response)) + .in_sequence(&mut seq); + + let rgid_response = de_response; + http.expect_get() + .times(1) + .with(predicate::eq(url_rgid)) + .return_once(|_| Ok(rgid_response)) + .in_sequence(&mut seq); + + let mut client = MusicBrainzClient::new(http); + + let arid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap(); + let title: AlbumId = AlbumId::new("an album"); + let date = (1986, 4).into(); + + let query = SearchReleaseGroupRequest::new() + .arid(&arid) + .and() + .release_group(&title.title) + .and() + .first_release_date(&date); + + let matches = client.search_release_group(query).unwrap(); + assert_eq!(matches, response); + + let rgid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap(); + + let query = SearchReleaseGroupRequest::new().rgid(&rgid); + + let matches = client.search_release_group(query).unwrap(); + assert_eq!(matches, response); + } + + #[test] + fn search_release_group_empty_date() { + let mut http = MockIMusicBrainzHttp::new(); + let url = format!( + "https://musicbrainz.org/ws/2\ + /release-group\ + ?query=arid%3A{arid}+AND+releasegroup%3A%22{title}%22", + arid = "00000000-0000-0000-0000-000000000000", + title = "an+album", + ); + + let de_response = DeserializeSearchReleaseGroupResponse { + release_groups: vec![], + }; + + http.expect_get() + .times(1) + .with(predicate::eq(url)) + .return_once(|_| Ok(de_response)); + + let mut client = MusicBrainzClient::new(http); + + let arid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap(); + let title: AlbumId = AlbumId::new("an album"); + let date = AlbumDate::default(); + + let query = SearchReleaseGroupRequest::new() + .arid(&arid) + .and() + .release_group(&title.title) + .and() + .first_release_date(&date); + + let _ = client.search_release_group(query).unwrap(); + } +}