From 43961b3ea1cba94b1ba0b25ada1ab49cae45c609 Mon Sep 17 00:00:00 2001 From: Wojciech Kozlowski Date: Wed, 28 Aug 2024 18:21:13 +0200 Subject: [PATCH] Decide carefully where `external::musicbrainz` belongs (#196) Closes #193 Reviewed-on: https://git.thenineworlds.net/wojtek/musichoard/pulls/196 --- Cargo.toml | 10 +- examples/musicbrainz_api/lookup_artist.rs | 42 ++ .../lookup_artist_release_groups.rs | 36 -- .../musicbrainz_api/search_release_group.rs | 52 +- src/core/collection/musicbrainz.rs | 24 +- src/core/interface/musicbrainz/mod.rs | 164 +----- src/external/mod.rs | 1 + src/external/musicbrainz/api/lookup.rs | 164 ++++++ src/external/musicbrainz/api/mod.rs | 504 +++++++----------- src/external/musicbrainz/api/search.rs | 272 ++++++++++ .../musicbrainz/{api/client.rs => http.rs} | 20 +- src/external/musicbrainz/mod.rs | 19 +- src/main.rs | 13 +- src/tui/app/machine/browse.rs | 15 +- src/tui/app/machine/matches.rs | 11 +- src/tui/app/machine/mod.rs | 10 +- src/tui/app/mod.rs | 7 +- src/tui/lib.rs | 74 --- src/tui/lib/external/mod.rs | 1 + src/tui/lib/external/musicbrainz/mod.rs | 66 +++ src/tui/lib/interface/mod.rs | 1 + src/tui/lib/interface/musicbrainz/mod.rs | 30 ++ src/tui/lib/mod.rs | 34 ++ src/tui/mod.rs | 3 +- src/tui/ui.rs | 20 +- 25 files changed, 940 insertions(+), 653 deletions(-) create mode 100644 examples/musicbrainz_api/lookup_artist.rs delete mode 100644 examples/musicbrainz_api/lookup_artist_release_groups.rs create mode 100644 src/external/musicbrainz/api/lookup.rs create mode 100644 src/external/musicbrainz/api/search.rs rename src/external/musicbrainz/{api/client.rs => http.rs} (64%) delete mode 100644 src/tui/lib.rs create mode 100644 src/tui/lib/external/mod.rs create mode 100644 src/tui/lib/external/musicbrainz/mod.rs create mode 100644 src/tui/lib/interface/mod.rs create mode 100644 src/tui/lib/interface/musicbrainz/mod.rs create mode 100644 src/tui/lib/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 52c31d0..a3a58c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ bin = ["structopt"] database-json = ["serde", "serde_json"] library-beets = [] library-beets-ssh = ["openssh", "tokio"] -musicbrainz-api = ["reqwest", "serde", "serde_json"] +musicbrainz = ["reqwest", "serde", "serde_json"] tui = ["aho-corasick", "crossterm", "once_cell", "ratatui"] [[bin]] @@ -45,14 +45,14 @@ name = "musichoard-edit" required-features = ["bin", "database-json"] [[example]] -name = "musicbrainz-api---lookup-artist-release-groups" -path = "examples/musicbrainz_api/lookup_artist_release_groups.rs" -required-features = ["bin", "musicbrainz-api"] +name = "musicbrainz-api---lookup-artist" +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" -required-features = ["bin", "musicbrainz-api"] +required-features = ["bin", "musicbrainz"] [package.metadata.docs.rs] all-features = true diff --git a/examples/musicbrainz_api/lookup_artist.rs b/examples/musicbrainz_api/lookup_artist.rs new file mode 100644 index 0000000..feb9821 --- /dev/null +++ b/examples/musicbrainz_api/lookup_artist.rs @@ -0,0 +1,42 @@ +#![allow(non_snake_case)] + +use musichoard::{ + external::musicbrainz::{ + api::{lookup::LookupArtistRequest, MusicBrainzClient}, + http::MusicBrainzHttp, + }, + interface::musicbrainz::Mbid, +}; +use structopt::StructOpt; +use uuid::Uuid; + +const USER_AGENT: &str = concat!( + "MusicHoard---examples---musicbrainz-api---lookup-artist/", + env!("CARGO_PKG_VERSION"), + " ( musichoard@thenineworlds.net )" +); + +#[derive(StructOpt)] +struct Opt { + #[structopt(help = "Artist MBID to lookup")] + mbid: Uuid, +} + +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 mbid: Mbid = opt.mbid.into(); + let mut request = LookupArtistRequest::new(&mbid); + request.include_release_groups(); + + let albums = client + .lookup_artist(request) + .expect("failed to make API call"); + + println!("{albums:#?}"); +} diff --git a/examples/musicbrainz_api/lookup_artist_release_groups.rs b/examples/musicbrainz_api/lookup_artist_release_groups.rs deleted file mode 100644 index 51a2e86..0000000 --- a/examples/musicbrainz_api/lookup_artist_release_groups.rs +++ /dev/null @@ -1,36 +0,0 @@ -#![allow(non_snake_case)] - -use musichoard::{ - external::musicbrainz::api::{client::MusicBrainzApiClient, MusicBrainzApi}, - interface::musicbrainz::{IMusicBrainz, Mbid}, -}; -use structopt::StructOpt; -use uuid::Uuid; - -const USER_AGENT: &str = concat!( - "MusicHoard---examples---musicbrainz-api---lookup-artist-release-groups/", - env!("CARGO_PKG_VERSION"), - " ( musichoard@thenineworlds.net )" -); - -#[derive(StructOpt)] -struct Opt { - #[structopt(help = "Artist MBID to lookup")] - mbid: Uuid, -} - -fn main() { - let opt = Opt::from_args(); - - println!("USER_AGENT: {USER_AGENT}"); - - let client = MusicBrainzApiClient::new(USER_AGENT).expect("failed to create API client"); - let mut api = MusicBrainzApi::new(client); - - let mbid: Mbid = opt.mbid.into(); - let albums = api - .lookup_artist_release_groups(&mbid) - .expect("failed to make API call"); - - println!("{albums:#?}"); -} diff --git a/examples/musicbrainz_api/search_release_group.rs b/examples/musicbrainz_api/search_release_group.rs index 0b27b95..bd7aa66 100644 --- a/examples/musicbrainz_api/search_release_group.rs +++ b/examples/musicbrainz_api/search_release_group.rs @@ -3,9 +3,11 @@ use std::{num::ParseIntError, str::FromStr}; use musichoard::{ - collection::album::{Album, AlbumDate, AlbumId}, - external::musicbrainz::api::{client::MusicBrainzApiClient, MusicBrainzApi}, - interface::musicbrainz::{IMusicBrainz, Mbid}, + collection::album::AlbumDate, + external::musicbrainz::{ + api::search::SearchReleaseGroupRequest, api::MusicBrainzClient, http::MusicBrainzHttp, + }, + interface::musicbrainz::Mbid, }; use structopt::StructOpt; use uuid::Uuid; @@ -18,16 +20,13 @@ const USER_AGENT: &str = concat!( #[derive(StructOpt)] struct Opt { - #[structopt(help = "Release group's artist MBID")] - arid: Uuid, - #[structopt(subcommand)] command: OptCommand, } #[derive(StructOpt)] enum OptCommand { - #[structopt(about = "Search by title (and date)")] + #[structopt(about = "Search by artist MBID, title(, and date)")] Title(OptTitle), #[structopt(about = "Search by release group MBID")] Rgid(OptRgid), @@ -35,6 +34,9 @@ enum OptCommand { #[derive(StructOpt)] struct OptTitle { + #[structopt(help = "Release group's artist MBID")] + arid: Uuid, + #[structopt(help = "Release group title")] title: String, @@ -80,30 +82,32 @@ fn main() { println!("USER_AGENT: {USER_AGENT}"); - let client = MusicBrainzApiClient::new(USER_AGENT).expect("failed to create API client"); - let mut api = MusicBrainzApi::new(client); + let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client"); + let mut client = MusicBrainzClient::new(http); - let arid: Mbid = opt.arid.into(); - - let album = match opt.command { + let mut request = SearchReleaseGroupRequest::default(); + let arid: Mbid; + let date: AlbumDate; + let title: String; + let rgid: Mbid; + match opt.command { OptCommand::Title(opt_title) => { - let date: AlbumDate = opt_title.date.map(Into::into).unwrap_or_default(); - Album::new(AlbumId::new(opt_title.title), date, None, vec![]) + 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); } OptCommand::Rgid(opt_rgid) => { - let mut album = Album::new( - AlbumId::new(String::default()), - AlbumDate::default(), - None, - vec![], - ); - album.set_musicbrainz_ref(opt_rgid.rgid.into()); - album + rgid = opt_rgid.rgid.into(); + request.rgid(&rgid); } }; - let matches = api - .search_release_group(&arid, &album) + let matches = client + .search_release_group(request) .expect("failed to make API call"); println!("{matches:#?}"); diff --git a/src/core/collection/musicbrainz.rs b/src/core/collection/musicbrainz.rs index 938f705..ae73f74 100644 --- a/src/core/collection/musicbrainz.rs +++ b/src/core/collection/musicbrainz.rs @@ -51,7 +51,13 @@ macro_rules! impl_imusicbrainzref { impl From for $mbref { fn from(uuid: Uuid) -> Self { - $mbref(MusicBrainzRef::from_uuid(uuid, $mbref::entity())) + $mbref(MusicBrainzRef::from_mbid(uuid, $mbref::entity())) + } + } + + impl From for $mbref { + fn from(mbid: Mbid) -> Self { + $mbref(MusicBrainzRef::from_mbid(mbid, $mbref::entity())) } } @@ -98,9 +104,9 @@ impl MusicBrainzRef { Ok(MusicBrainzRef { mbid, url }) } - fn from_uuid(uuid: Uuid, entity: &'static str) -> Self { - let uuid_str = uuid.to_string(); - let mbid = uuid.into(); + fn from_mbid>(id: ID, entity: &'static str) -> Self { + let mbid = id.into(); + let uuid_str = mbid.uuid().to_string(); let url = Url::parse(&format!("https://{MB_DOMAIN}/{entity}/{uuid_str}")).unwrap(); MusicBrainzRef { mbid, url } } @@ -127,6 +133,11 @@ mod tests { assert_eq!(url_str, mb.url().as_ref()); assert_eq!(uuid, mb.mbid().uuid().to_string()); + let mbid: Mbid = TryInto::::try_into(uuid).unwrap().into(); + let mb: MbArtistRef = mbid.into(); + assert_eq!(url_str, mb.url().as_ref()); + assert_eq!(uuid, mb.mbid().uuid().to_string()); + let url: Url = url_str.as_str().try_into().unwrap(); let mb: MbArtistRef = url.try_into().unwrap(); assert_eq!(url_str, mb.url().as_ref()); @@ -146,6 +157,11 @@ mod tests { assert_eq!(url_str, mb.url().as_ref()); assert_eq!(uuid, mb.mbid().uuid().to_string()); + let mbid: Mbid = TryInto::::try_into(uuid).unwrap().into(); + let mb: MbAlbumRef = mbid.into(); + assert_eq!(url_str, mb.url().as_ref()); + assert_eq!(uuid, mb.mbid().uuid().to_string()); + let url: Url = url_str.as_str().try_into().unwrap(); let mb: MbAlbumRef = url.try_into().unwrap(); assert_eq!(url_str, mb.url().as_ref()); diff --git a/src/core/interface/musicbrainz/mod.rs b/src/core/interface/musicbrainz/mod.rs index 9fe82e4..a59a348 100644 --- a/src/core/interface/musicbrainz/mod.rs +++ b/src/core/interface/musicbrainz/mod.rs @@ -1,52 +1,7 @@ -//! Module for accessing MusicBrainz metadata. - -use std::{fmt, num}; +use std::fmt; use uuid::{self, Uuid}; -use crate::collection::album::Album; - -/// Trait for interacting with the MusicBrainz API. -pub trait IMusicBrainz { - fn lookup_artist_release_groups(&mut self, mbid: &Mbid) -> Result, Error>; - fn search_release_group( - &mut self, - arid: &Mbid, - album: &Album, - ) -> Result>, Error>; -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match { - pub score: u8, - pub item: T, -} - -impl Match { - pub fn new(score: u8, item: T) -> Self { - Match { score, item } - } -} - -/// Null implementation of [`IMusicBrainz`] for when the trait is required, but no communication -/// with the MusicBrainz is desired. -pub struct NullMusicBrainz; - -impl IMusicBrainz for NullMusicBrainz { - fn lookup_artist_release_groups(&mut self, _mbid: &Mbid) -> Result, Error> { - Ok(vec![]) - } - - fn search_release_group( - &mut self, - _arid: &Mbid, - _album: &Album, - ) -> Result>, Error> { - Ok(vec![]) - } -} - -/// The MusicBrainz ID. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Mbid(Uuid); @@ -62,10 +17,25 @@ impl From for Mbid { } } +#[derive(Debug)] +pub struct MbidError(String); + +impl fmt::Display for MbidError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "failed to parse a MBID: {}", self.0) + } +} + +impl From for MbidError { + fn from(value: uuid::Error) -> Self { + MbidError(value.to_string()) + } +} + macro_rules! try_from_impl_for_mbid { ($from:ty) => { impl TryFrom<$from> for Mbid { - type Error = Error; + type Error = MbidError; fn try_from(value: $from) -> Result { Ok(Uuid::parse_str(value.as_ref())?.into()) @@ -78,99 +48,9 @@ try_from_impl_for_mbid!(&str); try_from_impl_for_mbid!(&String); try_from_impl_for_mbid!(String); -/// Error type for musicbrainz calls. -#[derive(Debug, PartialEq, Eq)] -pub enum Error { - /// Failed to parse input into an MBID. - MbidParse(String), - /// The API client failed. - Client(String), - /// The client reached the API rate limit. - RateLimit, - /// The API response could not be understood. - Unknown(u16), - /// Part of the response could not be parsed. - Parse(String), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - Error::MbidParse(s) => write!(f, "failed to parse input into an MBID: {s}"), - Error::Client(s) => write!(f, "the API client failed: {s}"), - Error::RateLimit => write!(f, "the API client reached the rate limit"), - Error::Unknown(u) => write!(f, "the API response could not be understood: status {u}"), - Error::Parse(s) => write!(f, "part of the response could not be parsed: {s}"), - } - } -} - -impl From for Error { - fn from(value: uuid::Error) -> Self { - Error::MbidParse(value.to_string()) - } -} - -impl From for Error { - fn from(err: num::ParseIntError) -> Error { - Error::Parse(err.to_string()) - } -} - -#[cfg(test)] -mod tests { - use crate::core::collection::album::{AlbumDate, AlbumId}; - - use super::*; - - #[test] - fn null_lookup_artist_release_groups() { - let mut musicbrainz = NullMusicBrainz; - let mbid: Mbid = "d368baa8-21ca-4759-9731-0b2753071ad8".try_into().unwrap(); - assert!(musicbrainz - .lookup_artist_release_groups(&mbid) - .unwrap() - .is_empty()); - } - - #[test] - fn null_search_release_group() { - let mut musicbrainz = NullMusicBrainz; - let mbid: Mbid = "d368baa8-21ca-4759-9731-0b2753071ad8".try_into().unwrap(); - let album = Album::new(AlbumId::new("an album"), AlbumDate::default(), None, vec![]); - assert!(musicbrainz - .search_release_group(&mbid, &album) - .unwrap() - .is_empty()); - } - - #[test] - fn match_type() { - let album = Album::new(AlbumId::new("an album"), AlbumDate::default(), None, vec![]); - let hit = Match::new(56, album); - assert!(!format!("{hit:?}").is_empty()); - } - - #[test] - fn errors() { - let mbid_err: Error = TryInto::::try_into("i-am-not-a-uuid").unwrap_err(); - assert!(!mbid_err.to_string().is_empty()); - assert!(!format!("{mbid_err:?}").is_empty()); - - let client_err: Error = Error::Client(String::from("a client error")); - assert!(!client_err.to_string().is_empty()); - assert!(!format!("{client_err:?}").is_empty()); - - let rate_err: Error = Error::RateLimit; - assert!(!rate_err.to_string().is_empty()); - assert!(!format!("{rate_err:?}").is_empty()); - - let unk_err: Error = Error::Unknown(404); - assert!(!unk_err.to_string().is_empty()); - assert!(!format!("{unk_err:?}").is_empty()); - - let parse_err: Error = "not-a-number".parse::().unwrap_err().into(); - assert!(!parse_err.to_string().is_empty()); - assert!(!format!("{parse_err:?}").is_empty()); - } +#[test] +fn errors() { + let mbid_err: MbidError = TryInto::::try_into("i-am-not-a-uuid").unwrap_err(); + assert!(!mbid_err.to_string().is_empty()); + assert!(!format!("{mbid_err:?}").is_empty()); } diff --git a/src/external/mod.rs b/src/external/mod.rs index 6becfd7..5087a8d 100644 --- a/src/external/mod.rs +++ b/src/external/mod.rs @@ -1,3 +1,4 @@ pub mod database; pub mod library; +#[cfg(feature = "musicbrainz")] pub mod musicbrainz; diff --git a/src/external/musicbrainz/api/lookup.rs b/src/external/musicbrainz/api/lookup.rs new file mode 100644 index 0000000..41e4f07 --- /dev/null +++ b/src/external/musicbrainz/api/lookup.rs @@ -0,0 +1,164 @@ +use serde::Deserialize; +use url::form_urlencoded; + +use crate::{ + collection::album::{AlbumDate, AlbumPrimaryType, AlbumSecondaryType}, + external::musicbrainz::{ + api::{ + Error, MusicBrainzClient, SerdeAlbumDate, SerdeAlbumPrimaryType, + SerdeAlbumSecondaryType, SerdeMbid, MB_BASE_URL, + }, + IMusicBrainzHttp, + }, + interface::musicbrainz::Mbid, +}; + +impl MusicBrainzClient { + pub fn lookup_artist( + &mut self, + request: LookupArtistRequest, + ) -> Result { + let mut include: Vec = vec![]; + + if request.release_groups { + include.push(String::from("release-groups")); + } + + let include: String = + form_urlencoded::byte_serialize(include.join("+").as_bytes()).collect(); + let url = format!( + "{MB_BASE_URL}/artist/{mbid}?inc={include}", + mbid = request.mbid.uuid().as_hyphenated() + ); + + let response: DeserializeLookupArtistResponse = self.http.get(&url)?; + Ok(response.into()) + } +} + +pub struct LookupArtistRequest<'a> { + mbid: &'a Mbid, + release_groups: bool, +} + +impl<'a> LookupArtistRequest<'a> { + pub fn new(mbid: &'a Mbid) -> Self { + LookupArtistRequest { + mbid, + release_groups: false, + } + } + + pub fn include_release_groups(&mut self) -> &mut Self { + self.release_groups = true; + self + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct LookupArtistResponse { + pub release_groups: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all(deserialize = "kebab-case"))] +struct DeserializeLookupArtistResponse { + release_groups: Vec, +} + +impl From for LookupArtistResponse { + fn from(value: DeserializeLookupArtistResponse) -> Self { + LookupArtistResponse { + release_groups: value.release_groups.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct LookupArtistResponseReleaseGroup { + pub id: Mbid, + pub title: String, + pub first_release_date: AlbumDate, + pub primary_type: AlbumPrimaryType, + pub secondary_types: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all(deserialize = "kebab-case"))] +struct DeserializeLookupArtistResponseReleaseGroup { + id: SerdeMbid, + title: String, + first_release_date: SerdeAlbumDate, + primary_type: SerdeAlbumPrimaryType, + secondary_types: Vec, +} + +impl From for LookupArtistResponseReleaseGroup { + fn from(value: DeserializeLookupArtistResponseReleaseGroup) -> Self { + LookupArtistResponseReleaseGroup { + 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.into_iter().map(Into::into).collect(), + } + } +} + +#[cfg(test)] +mod tests { + use mockall::predicate; + + use crate::external::musicbrainz::MockIMusicBrainzHttp; + + use super::*; + + #[test] + fn lookup_artist() { + let mut http = MockIMusicBrainzHttp::new(); + let url = format!( + "https://musicbrainz.org/ws/2/artist/{mbid}?inc=release-groups", + mbid = "00000000-0000-0000-0000-000000000000", + ); + + let de_release_group = DeserializeLookupArtistResponseReleaseGroup { + 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: vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Compilation)], + }; + let de_response = DeserializeLookupArtistResponse { + release_groups: vec![de_release_group.clone()], + }; + + let release_group = LookupArtistResponseReleaseGroup { + 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 + .into_iter() + .map(|st| st.0) + .collect(), + }; + let response = LookupArtistResponse { + release_groups: vec![release_group], + }; + + http.expect_get() + .times(1) + .with(predicate::eq(url)) + .return_once(|_| Ok(de_response)); + + let mut client = MusicBrainzClient::new(http); + + let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap(); + let mut request = LookupArtistRequest::new(&mbid); + request.include_release_groups(); + let result = client.lookup_artist(request).unwrap(); + + assert_eq!(result, response); + } +} diff --git a/src/external/musicbrainz/api/mod.rs b/src/external/musicbrainz/api/mod.rs index b2ad0a5..9291561 100644 --- a/src/external/musicbrainz/api/mod.rs +++ b/src/external/musicbrainz/api/mod.rs @@ -1,40 +1,43 @@ -//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API). +use std::{fmt, num}; -pub mod client; +use serde::{de::Visitor, Deserialize, Deserializer}; -use serde::{de::DeserializeOwned, Deserialize}; -use url::form_urlencoded; - -#[cfg(test)] -use mockall::automock; - -use crate::core::{ - collection::{ - album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType}, - musicbrainz::{IMusicBrainzRef, MbAlbumRef}, - }, - interface::musicbrainz::{Error, IMusicBrainz, Match, Mbid}, +use crate::{ + collection::album::{AlbumDate, AlbumPrimaryType, AlbumSecondaryType}, + external::musicbrainz::HttpError, + interface::musicbrainz::{Mbid, MbidError}, }; +pub mod lookup; +pub mod search; + const MB_BASE_URL: &str = "https://musicbrainz.org/ws/2"; const MB_RATE_LIMIT_CODE: u16 = 503; - -#[cfg_attr(test, automock)] -pub trait IMusicBrainzApiClient { - fn get(&mut self, url: &str) -> Result; +#[derive(Debug, PartialEq, Eq)] +pub enum Error { + /// The HTTP client failed. + Http(String), + /// The client reached the API rate limit. + RateLimit, + /// The API response could not be understood. + Unknown(u16), } -#[derive(Debug)] -pub enum ClientError { - Client(String), - Status(u16), +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Error::Http(s) => write!(f, "the HTTP client failed: {s}"), + Error::RateLimit => write!(f, "the API rate limit has been reached"), + Error::Unknown(u) => write!(f, "the API response could not be understood: status {u}"), + } + } } -impl From for Error { - fn from(err: ClientError) -> Self { +impl From for Error { + fn from(err: HttpError) -> Self { match err { - ClientError::Client(s) => Error::Client(s), - ClientError::Status(status) => match status { + HttpError::Client(s) => Error::Http(s), + HttpError::Status(status) => match status { MB_RATE_LIMIT_CODE => Error::RateLimit, _ => Error::Unknown(status), }, @@ -42,159 +45,125 @@ impl From for Error { } } -pub struct MusicBrainzApi { - client: Mbc, +pub struct MusicBrainzClient { + http: Http, } -impl MusicBrainzApi { - pub fn new(client: Mbc) -> Self { - MusicBrainzApi { client } - } -} - -impl IMusicBrainz for MusicBrainzApi { - fn lookup_artist_release_groups(&mut self, mbid: &Mbid) -> Result, Error> { - let mbid = mbid.uuid().as_hyphenated().to_string(); - - let artist: ResponseLookupArtist = self - .client - .get(&format!("{MB_BASE_URL}/artist/{mbid}?inc=release-groups"))?; - - artist - .release_groups - .into_iter() - .map(TryInto::try_into) - .collect() +impl MusicBrainzClient { + pub fn new(http: Http) -> Self { + MusicBrainzClient { http } } - fn search_release_group( - &mut self, - arid: &Mbid, - album: &Album, - ) -> Result>, Error> { - let title = &album.id.title; - let arid = arid.uuid().as_hyphenated().to_string(); - let mut query = format!("arid:{arid}"); - - match album.musicbrainz { - Some(ref mbref) => { - let rgid = mbref.mbid().uuid().as_hyphenated().to_string(); - query.push_str(&format!(" AND rgid:{rgid}")); - } - None => { - query.push_str(&format!(" AND releasegroup:\"{title}\"")); - if let Some(year) = album.date.year { - query.push_str(&format!(" AND firstreleasedate:{year}")); - } - } + fn format_album_date(date: &AlbumDate) -> Option { + 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}")), + }, + None => Some(format!("{year}")), + }, + None => None, } - - let query: String = form_urlencoded::byte_serialize(query.as_bytes()).collect(); - - let results: ResponseSearchReleaseGroup = self - .client - .get(&format!("{MB_BASE_URL}/release-group?query={query}"))?; - - results - .release_groups - .into_iter() - .map(TryInto::try_into) - .collect() } } -#[derive(Debug, Deserialize)] -#[serde(rename_all(deserialize = "kebab-case"))] -struct ResponseLookupArtist { - release_groups: Vec, -} +#[derive(Clone, Debug)] +pub struct SerdeMbid(Mbid); -#[derive(Debug, Deserialize)] -#[serde(rename_all(deserialize = "kebab-case"))] -struct LookupReleaseGroup { - id: String, - title: String, - first_release_date: String, - primary_type: SerdeAlbumPrimaryType, - secondary_types: Vec, -} - -impl TryFrom for Album { - type Error = Error; - - fn try_from(entity: LookupReleaseGroup) -> Result { - let mut album = Album::new( - entity.title, - AlbumDate::from_mb_date(&entity.first_release_date)?, - Some(entity.primary_type.into()), - entity.secondary_types.into_iter().map(Into::into).collect(), - ); - let mbref = MbAlbumRef::from_uuid_str(entity.id) - .map_err(|err| Error::MbidParse(err.to_string()))?; - album.set_musicbrainz_ref(mbref); - Ok(album) +impl From for Mbid { + fn from(value: SerdeMbid) -> Self { + value.0 } } -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all(deserialize = "kebab-case"))] -struct ResponseSearchReleaseGroup { - release_groups: Vec, -} +struct SerdeMbidVisitor; -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all(deserialize = "kebab-case"))] -struct SearchReleaseGroup { - score: u8, - id: String, - title: String, - first_release_date: String, - primary_type: SerdeAlbumPrimaryType, - secondary_types: Option>, -} +impl<'de> Visitor<'de> for SerdeMbidVisitor { + type Value = SerdeMbid; -impl TryFrom for Match { - type Error = Error; + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid MusicBrainz identifier") + } - fn try_from(entity: SearchReleaseGroup) -> Result { - let mut album = Album::new( - entity.title, - AlbumDate::from_mb_date(&entity.first_release_date)?, - Some(entity.primary_type.into()), - entity - .secondary_types - .map(|v| v.into_iter().map(|st| st.into()).collect()) - .unwrap_or_default(), - ); - let mbref = MbAlbumRef::from_uuid_str(entity.id) - .map_err(|err| Error::MbidParse(err.to_string()))?; - album.set_musicbrainz_ref(mbref); - Ok(Match::new(entity.score, album)) + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + Ok(SerdeMbid( + v.try_into() + .map_err(|e: MbidError| E::custom(e.to_string()))?, + )) } } -impl AlbumDate { - fn from_mb_date(mb_date: &str) -> Result { - let mut elems = mb_date.split('-'); +impl<'de> Deserialize<'de> for SerdeMbid { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(SerdeMbidVisitor) + } +} + +#[derive(Debug, Clone)] +pub struct SerdeAlbumDate(AlbumDate); + +impl From for AlbumDate { + fn from(value: SerdeAlbumDate) -> Self { + value.0 + } +} + +struct SerdeAlbumDateVisitor; + +impl<'de> Visitor<'de> for SerdeAlbumDateVisitor { + type Value = SerdeAlbumDate; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid YYYY(-MM-(-DD)) date") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + let mut elems = v.split('-'); let elem = elems.next(); let year = elem .and_then(|s| if s.is_empty() { None } else { Some(s.parse()) }) - .transpose()?; + .transpose() + .map_err(|e: num::ParseIntError| E::custom(e.to_string()))?; let elem = elems.next(); - let month = elem.map(|s| s.parse()).transpose()?; + let month = elem + .map(|s| s.parse()) + .transpose() + .map_err(|e: num::ParseIntError| E::custom(e.to_string()))?; let elem = elems.next(); - let day = elem.map(|s| s.parse()).transpose()?; + let day = elem + .map(|s| s.parse()) + .transpose() + .map_err(|e: num::ParseIntError| E::custom(e.to_string()))?; - Ok(AlbumDate::new(year, month, day)) + Ok(SerdeAlbumDate(AlbumDate::new(year, month, day))) + } +} + +impl<'de> Deserialize<'de> for SerdeAlbumDate { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(SerdeAlbumDateVisitor) } } #[derive(Debug, Deserialize)] #[serde(remote = "AlbumPrimaryType")] -pub enum SerdeAlbumPrimaryTypeDef { +pub enum AlbumPrimaryTypeDef { Album, Single, #[serde(rename = "EP")] @@ -204,7 +173,7 @@ pub enum SerdeAlbumPrimaryTypeDef { } #[derive(Clone, Debug, Deserialize)] -pub struct SerdeAlbumPrimaryType(#[serde(with = "SerdeAlbumPrimaryTypeDef")] AlbumPrimaryType); +pub struct SerdeAlbumPrimaryType(#[serde(with = "AlbumPrimaryTypeDef")] AlbumPrimaryType); impl From for AlbumPrimaryType { fn from(value: SerdeAlbumPrimaryType) -> Self { @@ -214,7 +183,7 @@ impl From for AlbumPrimaryType { #[derive(Debug, Deserialize)] #[serde(remote = "AlbumSecondaryType")] -pub enum SerdeAlbumSecondaryTypeDef { +pub enum AlbumSecondaryTypeDef { Compilation, Soundtrack, Spokenword, @@ -234,9 +203,7 @@ pub enum SerdeAlbumSecondaryTypeDef { } #[derive(Clone, Debug, Deserialize)] -pub struct SerdeAlbumSecondaryType( - #[serde(with = "SerdeAlbumSecondaryTypeDef")] AlbumSecondaryType, -); +pub struct SerdeAlbumSecondaryType(#[serde(with = "AlbumSecondaryTypeDef")] AlbumSecondaryType); impl From for AlbumSecondaryType { fn from(value: SerdeAlbumSecondaryType) -> Self { @@ -246,188 +213,89 @@ impl From for AlbumSecondaryType { #[cfg(test)] mod tests { - use mockall::{predicate, Sequence}; - - use crate::collection::album::AlbumId; - use super::*; #[test] - fn lookup_artist_release_group() { - let mut client = MockIMusicBrainzApiClient::new(); - let url = format!( - "https://musicbrainz.org/ws/2/artist/{mbid}?inc=release-groups", - mbid = "00000000-0000-0000-0000-000000000000", - ); + fn errors() { + let http_err = HttpError::Client(String::from("a http error")); + let http_err: Error = http_err.into(); + assert!(matches!(http_err, Error::Http(_))); + assert!(!http_err.to_string().is_empty()); + assert!(!format!("{http_err:?}").is_empty()); - let release_group = LookupReleaseGroup { - id: String::from("11111111-1111-1111-1111-111111111111"), - title: String::from("an album"), - first_release_date: String::from("1986-04"), - primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album), - secondary_types: vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Compilation)], - }; - let response = ResponseLookupArtist { - release_groups: vec![release_group], - }; + let rate_err = HttpError::Status(MB_RATE_LIMIT_CODE); + let rate_err: Error = rate_err.into(); + assert!(matches!(rate_err, Error::RateLimit)); + assert!(!rate_err.to_string().is_empty()); + assert!(!format!("{rate_err:?}").is_empty()); - // For code coverage of derive(Debug). - assert!(!format!("{response:?}").is_empty()); - - client - .expect_get() - .times(1) - .with(predicate::eq(url)) - .return_once(|_| Ok(response)); - - let mut api = MusicBrainzApi::new(client); - - let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap(); - let results = api.lookup_artist_release_groups(&mbid).unwrap(); - - let mut album = Album::new( - AlbumId::new("an album"), - (1986, 4), - Some(AlbumPrimaryType::Album), - vec![AlbumSecondaryType::Compilation], - ); - album.set_musicbrainz_ref( - MbAlbumRef::from_uuid_str("11111111-1111-1111-1111-111111111111").unwrap(), - ); - let expected = vec![album]; - - assert_eq!(results, expected); + let unk_err = HttpError::Status(404); + let unk_err: Error = unk_err.into(); + assert!(matches!(unk_err, Error::Unknown(_))); + assert!(!unk_err.to_string().is_empty()); + assert!(!format!("{unk_err:?}").is_empty()); } #[test] - fn search_release_group() { - let mut client = MockIMusicBrainzApiClient::new(); - let url_title = format!( - "https://musicbrainz.org/ws/2\ - /release-group\ - ?query=arid%3A{arid}+AND+releasegroup%3A%22{title}%22+AND+firstreleasedate%3A{year}", - arid = "00000000-0000-0000-0000-000000000000", - title = "an+album", - year = "1986" - ); - let url_rgid = format!( - "https://musicbrainz.org/ws/2\ - /release-group\ - ?query=arid%3A{arid}+AND+rgid%3A{rgid}", - arid = "00000000-0000-0000-0000-000000000000", - rgid = "11111111-1111-1111-1111-111111111111", - ); - - let release_group = SearchReleaseGroup { - score: 67, - id: String::from("11111111-1111-1111-1111-111111111111"), - title: String::from("an album"), - first_release_date: String::from("1986-04"), - primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album), - secondary_types: Some(vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Live)]), - }; - let response = ResponseSearchReleaseGroup { - release_groups: vec![release_group], - }; - - // For code coverage of derive(Debug). - assert!(!format!("{response:?}").is_empty()); - - let mut seq = Sequence::new(); - - let title_response = response.clone(); - client - .expect_get() - .times(1) - .with(predicate::eq(url_title)) - .return_once(|_| Ok(title_response)) - .in_sequence(&mut seq); - - let rgid_response = response; - client - .expect_get() - .times(1) - .with(predicate::eq(url_rgid)) - .return_once(|_| Ok(rgid_response)) - .in_sequence(&mut seq); - - let mut album = Album::new( - AlbumId::new("an album"), - (1986, 4), - Some(AlbumPrimaryType::Album), - vec![AlbumSecondaryType::Live], - ); - album.set_musicbrainz_ref( - MbAlbumRef::from_uuid_str("11111111-1111-1111-1111-111111111111").unwrap(), - ); - let expected = vec![Match::new(67, album)]; - - let mut api = MusicBrainzApi::new(client); - - let arid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap(); - let mut album = Album::new(AlbumId::new("an album"), (1986, 4), None, vec![]); - let matches = api.search_release_group(&arid, &album).unwrap(); - assert_eq!(matches, expected); - - let rgid = MbAlbumRef::from_uuid_str("11111111-1111-1111-1111-111111111111").unwrap(); - album.set_musicbrainz_ref(rgid); - let matches = api.search_release_group(&arid, &album).unwrap(); - assert_eq!(matches, expected); - } - - #[test] - fn client_errors() { - let mut client = MockIMusicBrainzApiClient::new(); - - let error = ClientError::Client(String::from("get rekt")); - assert!(!format!("{error:?}").is_empty()); - - client - .expect_get::() - .times(1) - .return_once(|_| Err(ClientError::Client(String::from("get rekt scrub")))); - - client - .expect_get::() - .times(1) - .return_once(|_| Err(ClientError::Status(503))); - - client - .expect_get::() - .times(1) - .return_once(|_| Err(ClientError::Status(504))); - - let mut api = MusicBrainzApi::new(client); - - let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap(); - - let error = api.lookup_artist_release_groups(&mbid).unwrap_err(); - assert_eq!(error, Error::Client(String::from("get rekt scrub"))); - - let error = api.lookup_artist_release_groups(&mbid).unwrap_err(); - assert_eq!(error, Error::RateLimit); - - let error = api.lookup_artist_release_groups(&mbid).unwrap_err(); - assert_eq!(error, Error::Unknown(504)); - } - - #[test] - fn from_mb_date() { - assert_eq!(AlbumDate::from_mb_date("").unwrap(), AlbumDate::default()); - assert_eq!(AlbumDate::from_mb_date("1984").unwrap(), 1984.into()); + fn format_album_date() { + struct Null; assert_eq!( - AlbumDate::from_mb_date("1984-05").unwrap(), - (1984, 5).into() + MusicBrainzClient::::format_album_date(&AlbumDate::new(None, None, None)), + None ); assert_eq!( - AlbumDate::from_mb_date("1984-05-18").unwrap(), - (1984, 5, 18).into() + 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")) ); - assert!(AlbumDate::from_mb_date("1984-get-rekt").is_err()); } #[test] fn serde() { + let mbid = "\"d368baa8-21ca-4759-9731-0b2753071ad8\""; + let mbid: SerdeMbid = serde_json::from_str(mbid).unwrap(); + let mbid: Mbid = mbid.into(); + assert_eq!( + mbid, + "d368baa8-21ca-4759-9731-0b2753071ad8".try_into().unwrap() + ); + + let mbid = "0"; + let result: Result = serde_json::from_str(mbid); + assert!(result + .unwrap_err() + .to_string() + .contains("a valid MusicBrainz identifier")); + + let album_date = "\"1986-04-21\""; + let album_date: SerdeAlbumDate = serde_json::from_str(album_date).unwrap(); + let album_date: AlbumDate = album_date.into(); + assert_eq!(album_date, AlbumDate::new(Some(1986), Some(4), Some(21))); + + let album_date = "\"1986-04\""; + let album_date: SerdeAlbumDate = serde_json::from_str(album_date).unwrap(); + let album_date: AlbumDate = album_date.into(); + assert_eq!(album_date, AlbumDate::new(Some(1986), Some(4), None)); + + let album_date = "\"1986\""; + let album_date: SerdeAlbumDate = serde_json::from_str(album_date).unwrap(); + let album_date: AlbumDate = album_date.into(); + assert_eq!(album_date, AlbumDate::new(Some(1986), None, None)); + + let album_date = "0"; + let result: Result = serde_json::from_str(album_date); + assert!(result + .unwrap_err() + .to_string() + .contains("a valid YYYY(-MM-(-DD)) date")); + let primary_type = "\"EP\""; let primary_type: SerdeAlbumPrimaryType = serde_json::from_str(primary_type).unwrap(); let primary_type: AlbumPrimaryType = primary_type.into(); diff --git a/src/external/musicbrainz/api/search.rs b/src/external/musicbrainz/api/search.rs new file mode 100644 index 0000000..5378300 --- /dev/null +++ b/src/external/musicbrainz/api/search.rs @@ -0,0 +1,272 @@ +//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API). + +use serde::Deserialize; +use url::form_urlencoded; + +use crate::{ + collection::album::AlbumDate, + core::{ + collection::album::{AlbumPrimaryType, AlbumSecondaryType}, + interface::musicbrainz::Mbid, + }, + external::musicbrainz::{ + api::{ + Error, MusicBrainzClient, SerdeAlbumDate, SerdeAlbumPrimaryType, + SerdeAlbumSecondaryType, SerdeMbid, MB_BASE_URL, + }, + IMusicBrainzHttp, + }, +}; + +impl MusicBrainzClient { + pub fn search_release_group( + &mut self, + request: 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(); + let url = format!("{MB_BASE_URL}/release-group?query={query}"); + + let response: DeserializeSearchReleaseGroupResponse = self.http.get(&url)?; + Ok(response.into()) + } +} + +#[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>, +} + +impl<'a> SearchReleaseGroupRequest<'a> { + pub fn new() -> Self { + Self::default() + } + + pub fn arid(&mut self, arid: &'a Mbid) -> &mut Self { + self.arid = Some(arid); + self + } + + pub fn first_release_date(&mut self, first_release_date: &'a AlbumDate) -> &mut Self { + self.first_release_date = Some(first_release_date); + self + } + + pub fn release_group(&mut self, release_group: &'a str) -> &mut Self { + self.release_group = Some(release_group); + self + } + + pub fn rgid(&mut self, rgid: &'a Mbid) -> &mut Self { + self.rgid = Some(rgid); + self + } +} + +#[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 mut request = SearchReleaseGroupRequest::new(); + request + .arid(&arid) + .release_group(&title.title) + .first_release_date(&date); + + let matches = client.search_release_group(request).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 matches = client.search_release_group(request).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 mut request = SearchReleaseGroupRequest::new(); + request + .arid(&arid) + .release_group(&title.title) + .first_release_date(&date); + + let _ = client.search_release_group(request).unwrap(); + } +} diff --git a/src/external/musicbrainz/api/client.rs b/src/external/musicbrainz/http.rs similarity index 64% rename from src/external/musicbrainz/api/client.rs rename to src/external/musicbrainz/http.rs index deb405b..c1ffc5d 100644 --- a/src/external/musicbrainz/api/client.rs +++ b/src/external/musicbrainz/http.rs @@ -3,13 +3,13 @@ use reqwest::{self, blocking::Client, header}; use serde::de::DeserializeOwned; -use crate::external::musicbrainz::api::{ClientError, IMusicBrainzApiClient}; +use crate::external::musicbrainz::{HttpError, IMusicBrainzHttp}; // GRCOV_EXCL_START -pub struct MusicBrainzApiClient(Client); +pub struct MusicBrainzHttp(Client); -impl MusicBrainzApiClient { - pub fn new(user_agent: &'static str) -> Result { +impl MusicBrainzHttp { + pub fn new(user_agent: &'static str) -> Result { let mut headers = header::HeaderMap::new(); headers.insert( header::USER_AGENT, @@ -20,27 +20,27 @@ impl MusicBrainzApiClient { header::HeaderValue::from_static("application/json"), ); - Ok(MusicBrainzApiClient( + Ok(MusicBrainzHttp( Client::builder().default_headers(headers).build()?, )) } } -impl IMusicBrainzApiClient for MusicBrainzApiClient { - fn get(&mut self, url: &str) -> Result { +impl IMusicBrainzHttp for MusicBrainzHttp { + fn get(&mut self, url: &str) -> Result { let response = self.0.get(url).send()?; if response.status().is_success() { Ok(response.json()?) } else { - Err(ClientError::Status(response.status().as_u16())) + Err(HttpError::Status(response.status().as_u16())) } } } -impl From for ClientError { +impl From for HttpError { fn from(err: reqwest::Error) -> Self { - ClientError::Client(err.to_string()) + HttpError::Client(err.to_string()) } } // GRCOV_EXCL_STOP diff --git a/src/external/musicbrainz/mod.rs b/src/external/musicbrainz/mod.rs index 98e2d12..96fe786 100644 --- a/src/external/musicbrainz/mod.rs +++ b/src/external/musicbrainz/mod.rs @@ -1,2 +1,19 @@ -#[cfg(feature = "musicbrainz-api")] +//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API). + pub mod api; +pub mod http; + +#[cfg(test)] +use mockall::automock; +use serde::de::DeserializeOwned; + +#[cfg_attr(test, automock)] +pub trait IMusicBrainzHttp { + fn get(&mut self, url: &str) -> Result; +} + +#[derive(Debug)] +pub enum HttpError { + Client(String), + Status(u16), +} diff --git a/src/main.rs b/src/main.rs index ffb82f6..86feb45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,7 @@ use musichoard::{ executor::{ssh::BeetsLibrarySshExecutor, BeetsLibraryProcessExecutor}, BeetsLibrary, }, - musicbrainz::api::{client::MusicBrainzApiClient, MusicBrainzApi}, + musicbrainz::{api::MusicBrainzClient, http::MusicBrainzHttp}, }, interface::{ database::{IDatabase, NullDatabase}, @@ -25,7 +25,7 @@ use musichoard::{ MusicHoardBuilder, NoDatabase, NoLibrary, }; -use tui::{App, EventChannel, EventHandler, EventListener, Tui, Ui}; +use tui::{App, EventChannel, EventHandler, EventListener, MusicBrainz, Tui, Ui}; const MUSICHOARD_HTTP_USER_AGENT: &str = concat!( "MusicHoard/", @@ -83,11 +83,12 @@ fn with( let listener = EventListener::new(channel.sender()); let handler = EventHandler::new(channel.receiver()); - let client = MusicBrainzApiClient::new(MUSICHOARD_HTTP_USER_AGENT) - .expect("failed to initialise HTTP client"); - let api = Box::new(MusicBrainzApi::new(client)); + let http = + MusicBrainzHttp::new(MUSICHOARD_HTTP_USER_AGENT).expect("failed to initialise HTTP client"); + let client = MusicBrainzClient::new(http); + let musicbrainz = Box::new(MusicBrainz::new(client)); - let app = App::new(music_hoard, api); + let app = App::new(music_hoard, musicbrainz); let ui = Ui; // Run the TUI application. diff --git a/src/tui/app/machine/browse.rs b/src/tui/app/machine/browse.rs index e86ec9a..e0f0ec9 100644 --- a/src/tui/app/machine/browse.rs +++ b/src/tui/app/machine/browse.rs @@ -105,7 +105,7 @@ impl IAppInteractBrowse for AppMachine { continue; } - match self.inner.mb_api.search_release_group(arid, album) { + match self.inner.musicbrainz.search_release_group(arid, album) { Ok(matches) => artist_album_matches.push(AppMatchesInfo { matching: album.clone(), matches, @@ -129,17 +129,14 @@ impl IAppInteractBrowse for AppMachine { #[cfg(test)] mod tests { use mockall::{predicate, Sequence}; - use musichoard::collection::album::Album; + use musichoard::{collection::album::Album, interface::musicbrainz::Mbid}; use crate::tui::{ app::{ machine::tests::{inner, inner_with_mb, music_hoard}, Category, IAppAccess, IAppInteract, IAppInteractMatches, }, - lib::external::musicbrainz::{ - self, - api::{Match, Mbid, MockIMusicBrainz}, - }, + lib::interface::musicbrainz::{self, Match, MockIMusicBrainz}, testmod::COLLECTION, }; @@ -230,8 +227,8 @@ mod tests { let matches_1 = vec![album_match_1_1.clone(), album_match_1_2.clone()]; let matches_4 = vec![album_match_4_1.clone(), album_match_4_2.clone()]; - let result_1: Result>, musicbrainz::api::Error> = Ok(matches_1.clone()); - let result_4: Result>, musicbrainz::api::Error> = Ok(matches_4.clone()); + let result_1: Result>, musicbrainz::Error> = Ok(matches_1.clone()); + let result_4: Result>, musicbrainz::Error> = Ok(matches_4.clone()); // Other albums have an MBID and so they will be skipped. let mut seq = Sequence::new(); @@ -300,7 +297,7 @@ mod tests { fn fetch_musicbrainz_api_error() { let mut mb_api = Box::new(MockIMusicBrainz::new()); - let error = Err(musicbrainz::api::Error::RateLimit); + let error = Err(musicbrainz::Error::RateLimit); mb_api .expect_search_release_group() diff --git a/src/tui/app/machine/matches.rs b/src/tui/app/machine/matches.rs index 4c2eabf..ee1e5df 100644 --- a/src/tui/app/machine/matches.rs +++ b/src/tui/app/machine/matches.rs @@ -1,10 +1,13 @@ use std::cmp; -use musichoard::{collection::album::Album, interface::musicbrainz::Match}; +use musichoard::collection::album::Album; -use crate::tui::app::{ - machine::{App, AppInner, AppMachine}, - AppPublic, AppPublicMatches, AppState, IAppInteractMatches, WidgetState, +use crate::tui::{ + app::{ + machine::{App, AppInner, AppMachine}, + AppPublic, AppPublicMatches, AppState, IAppInteractMatches, WidgetState, + }, + lib::interface::musicbrainz::Match, }; #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/src/tui/app/machine/mod.rs b/src/tui/app/machine/mod.rs index c3a1185..f138f67 100644 --- a/src/tui/app/machine/mod.rs +++ b/src/tui/app/machine/mod.rs @@ -8,7 +8,7 @@ mod search; use crate::tui::{ app::{selection::Selection, AppPublic, AppPublicInner, AppState, IAppAccess, IAppInteract}, - lib::{external::musicbrainz::api::IMusicBrainz, IMusicHoard}, + lib::{interface::musicbrainz::IMusicBrainz, IMusicHoard}, }; use browse::AppBrowse; @@ -37,7 +37,7 @@ pub struct AppMachine { pub struct AppInner { running: bool, music_hoard: Box, - mb_api: Box, + musicbrainz: Box, selection: Selection, } @@ -121,12 +121,12 @@ impl IAppAccess for App { } impl AppInner { - pub fn new(music_hoard: Box, mb_api: Box) -> Self { + pub fn new(music_hoard: Box, musicbrainz: Box) -> Self { let selection = Selection::new(music_hoard.get_collection()); AppInner { running: true, music_hoard, - mb_api, + musicbrainz, selection, } } @@ -147,7 +147,7 @@ mod tests { use crate::tui::{ app::{AppState, IAppInteract, IAppInteractBrowse}, - lib::{external::musicbrainz::api::MockIMusicBrainz, MockIMusicHoard}, + lib::{interface::musicbrainz::MockIMusicBrainz, MockIMusicHoard}, }; use super::*; diff --git a/src/tui/app/mod.rs b/src/tui/app/mod.rs index ea18f9b..224cbc9 100644 --- a/src/tui/app/mod.rs +++ b/src/tui/app/mod.rs @@ -4,10 +4,9 @@ mod selection; pub use machine::App; pub use selection::{Category, Delta, Selection, WidgetState}; -use musichoard::{ - collection::{album::Album, Collection}, - interface::musicbrainz::Match, -}; +use musichoard::collection::{album::Album, Collection}; + +use crate::tui::lib::interface::musicbrainz::Match; pub enum AppState { Browse(BS), diff --git a/src/tui/lib.rs b/src/tui/lib.rs deleted file mode 100644 index 9696999..0000000 --- a/src/tui/lib.rs +++ /dev/null @@ -1,74 +0,0 @@ -use musichoard::{ - collection::Collection, interface, IMusicHoardBase, IMusicHoardDatabase, IMusicHoardLibrary, - MusicHoard, -}; - -#[cfg(test)] -use mockall::automock; - -#[cfg_attr(test, automock)] -pub trait IMusicHoard { - fn rescan_library(&mut self) -> Result<(), musichoard::Error>; - fn reload_database(&mut self) -> Result<(), musichoard::Error>; - fn get_collection(&self) -> &Collection; -} - -// GRCOV_EXCL_START -impl IMusicHoard - for MusicHoard -{ - fn rescan_library(&mut self) -> Result<(), musichoard::Error> { - ::rescan_library(self) - } - - fn reload_database(&mut self) -> Result<(), musichoard::Error> { - ::reload_database(self) - } - - fn get_collection(&self) -> &Collection { - ::get_collection(self) - } -} -// GRCOV_EXCL_STOP - -pub mod external { - pub mod musicbrainz { - pub mod api { - use musichoard::{ - collection::album::Album, - external::musicbrainz::api::{IMusicBrainzApiClient, MusicBrainzApi}, - interface, - }; - - #[cfg(test)] - use mockall::automock; - - pub type Match = interface::musicbrainz::Match; - pub type Mbid = interface::musicbrainz::Mbid; - pub type Error = interface::musicbrainz::Error; - - #[cfg_attr(test, automock)] - pub trait IMusicBrainz { - fn search_release_group( - &mut self, - arid: &Mbid, - album: &Album, - ) -> Result>, Error>; - } - - // GRCOV_EXCL_START - impl IMusicBrainz for MusicBrainzApi { - fn search_release_group( - &mut self, - arid: &Mbid, - album: &Album, - ) -> Result>, Error> { - ::search_release_group( - self, arid, album, - ) - } - } - // GRCOV_EXCL_STOP - } - } -} diff --git a/src/tui/lib/external/mod.rs b/src/tui/lib/external/mod.rs new file mode 100644 index 0000000..c33a79a --- /dev/null +++ b/src/tui/lib/external/mod.rs @@ -0,0 +1 @@ +pub mod musicbrainz; diff --git a/src/tui/lib/external/musicbrainz/mod.rs b/src/tui/lib/external/musicbrainz/mod.rs new file mode 100644 index 0000000..78b8f5f --- /dev/null +++ b/src/tui/lib/external/musicbrainz/mod.rs @@ -0,0 +1,66 @@ +//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API). + +use musichoard::{ + collection::album::{Album, AlbumDate}, + external::musicbrainz::{ + api::{ + search::{SearchReleaseGroupRequest, SearchReleaseGroupResponseReleaseGroup}, + MusicBrainzClient, + }, + IMusicBrainzHttp, + }, + interface::musicbrainz::Mbid, +}; + +use crate::tui::lib::interface::musicbrainz::{Error, IMusicBrainz, Match}; + +// GRCOV_EXCL_START +pub struct MusicBrainz { + client: MusicBrainzClient, +} + +impl MusicBrainz { + pub fn new(client: MusicBrainzClient) -> Self { + MusicBrainz { client } + } +} + +impl IMusicBrainz for MusicBrainz { + fn search_release_group( + &mut self, + arid: &Mbid, + album: &Album, + ) -> Result>, Error> { + // Some release groups may have a promotional early release messing up the search. Searching + // 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 mb_response = self.client.search_release_group(request)?; + + Ok(mb_response + .release_groups + .into_iter() + .map(from_search_release_group_response_release_group) + .collect()) + } +} + +fn from_search_release_group_response_release_group( + entity: SearchReleaseGroupResponseReleaseGroup, +) -> Match { + let mut album = Album::new( + entity.title, + entity.first_release_date, + Some(entity.primary_type), + entity.secondary_types.unwrap_or_default(), + ); + album.set_musicbrainz_ref(entity.id.into()); + Match::new(entity.score, album) +} +// GRCOV_EXCL_STOP diff --git a/src/tui/lib/interface/mod.rs b/src/tui/lib/interface/mod.rs new file mode 100644 index 0000000..c33a79a --- /dev/null +++ b/src/tui/lib/interface/mod.rs @@ -0,0 +1 @@ +pub mod musicbrainz; diff --git a/src/tui/lib/interface/musicbrainz/mod.rs b/src/tui/lib/interface/musicbrainz/mod.rs new file mode 100644 index 0000000..354764a --- /dev/null +++ b/src/tui/lib/interface/musicbrainz/mod.rs @@ -0,0 +1,30 @@ +//! Module for accessing MusicBrainz metadata. + +#[cfg(test)] +use mockall::automock; + +use musichoard::{collection::album::Album, interface::musicbrainz::Mbid}; + +/// Trait for interacting with the MusicBrainz API. +#[cfg_attr(test, automock)] +pub trait IMusicBrainz { + fn search_release_group( + &mut self, + arid: &Mbid, + album: &Album, + ) -> Result>, Error>; +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match { + pub score: u8, + pub item: T, +} + +impl Match { + pub fn new(score: u8, item: T) -> Self { + Match { score, item } + } +} + +pub type Error = musichoard::external::musicbrainz::api::Error; diff --git a/src/tui/lib/mod.rs b/src/tui/lib/mod.rs new file mode 100644 index 0000000..31643b2 --- /dev/null +++ b/src/tui/lib/mod.rs @@ -0,0 +1,34 @@ +pub mod external; +pub mod interface; + +use musichoard::{ + collection::Collection, + interface::{database::IDatabase, library::ILibrary}, + IMusicHoardBase, IMusicHoardDatabase, IMusicHoardLibrary, MusicHoard, +}; + +#[cfg(test)] +use mockall::automock; + +#[cfg_attr(test, automock)] +pub trait IMusicHoard { + fn rescan_library(&mut self) -> Result<(), musichoard::Error>; + fn reload_database(&mut self) -> Result<(), musichoard::Error>; + fn get_collection(&self) -> &Collection; +} + +// GRCOV_EXCL_START +impl IMusicHoard for MusicHoard { + fn rescan_library(&mut self) -> Result<(), musichoard::Error> { + ::rescan_library(self) + } + + fn reload_database(&mut self) -> Result<(), musichoard::Error> { + ::reload_database(self) + } + + fn get_collection(&self) -> &Collection { + ::get_collection(self) + } +} +// GRCOV_EXCL_STOP diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 66557ca..f25baa2 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -8,6 +8,7 @@ mod ui; pub use app::App; pub use event::EventChannel; pub use handler::EventHandler; +pub use lib::external::musicbrainz::MusicBrainz; pub use listener::EventListener; pub use ui::Ui; @@ -173,7 +174,7 @@ mod testmod; mod tests { use std::{io, thread}; - use lib::external::musicbrainz::api::MockIMusicBrainz; + use lib::interface::musicbrainz::MockIMusicBrainz; use ratatui::{backend::TestBackend, Terminal}; use musichoard::collection::Collection; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 15fbc17..545a4a1 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1,14 +1,11 @@ use std::collections::HashMap; -use musichoard::{ - collection::{ - album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus}, - artist::Artist, - musicbrainz::IMusicBrainzRef, - track::{Track, TrackFormat, TrackQuality}, - Collection, - }, - interface::musicbrainz::Match, +use musichoard::collection::{ + album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus}, + artist::Artist, + musicbrainz::IMusicBrainzRef, + track::{Track, TrackFormat, TrackQuality}, + Collection, }; use ratatui::{ layout::{Alignment, Rect}, @@ -18,7 +15,10 @@ use ratatui::{ Frame, }; -use crate::tui::app::{AppPublicState, AppState, Category, IAppAccess, Selection, WidgetState}; +use crate::tui::{ + app::{AppPublicState, AppState, Category, IAppAccess, Selection, WidgetState}, + lib::interface::musicbrainz::Match, +}; const COLOR_BG: Color = Color::Black; const COLOR_BG_HL: Color = Color::DarkGray;