Decide carefully where external::musicbrainz belongs (#196)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m57s
Cargo CI / Lint (push) Successful in 1m5s

Closes #193

Reviewed-on: #196
This commit is contained in:
Wojciech Kozlowski 2024-08-28 18:21:13 +02:00
parent b70499d8de
commit 43961b3ea1
25 changed files with 940 additions and 653 deletions

View File

@ -33,7 +33,7 @@ bin = ["structopt"]
database-json = ["serde", "serde_json"] database-json = ["serde", "serde_json"]
library-beets = [] library-beets = []
library-beets-ssh = ["openssh", "tokio"] library-beets-ssh = ["openssh", "tokio"]
musicbrainz-api = ["reqwest", "serde", "serde_json"] musicbrainz = ["reqwest", "serde", "serde_json"]
tui = ["aho-corasick", "crossterm", "once_cell", "ratatui"] tui = ["aho-corasick", "crossterm", "once_cell", "ratatui"]
[[bin]] [[bin]]
@ -45,14 +45,14 @@ name = "musichoard-edit"
required-features = ["bin", "database-json"] required-features = ["bin", "database-json"]
[[example]] [[example]]
name = "musicbrainz-api---lookup-artist-release-groups" name = "musicbrainz-api---lookup-artist"
path = "examples/musicbrainz_api/lookup_artist_release_groups.rs" path = "examples/musicbrainz_api/lookup_artist.rs"
required-features = ["bin", "musicbrainz-api"] required-features = ["bin", "musicbrainz"]
[[example]] [[example]]
name = "musicbrainz-api---search-release-group" name = "musicbrainz-api---search-release-group"
path = "examples/musicbrainz_api/search_release_group.rs" path = "examples/musicbrainz_api/search_release_group.rs"
required-features = ["bin", "musicbrainz-api"] required-features = ["bin", "musicbrainz"]
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true all-features = true

View File

@ -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:#?}");
}

View File

@ -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:#?}");
}

View File

@ -3,9 +3,11 @@
use std::{num::ParseIntError, str::FromStr}; use std::{num::ParseIntError, str::FromStr};
use musichoard::{ use musichoard::{
collection::album::{Album, AlbumDate, AlbumId}, collection::album::AlbumDate,
external::musicbrainz::api::{client::MusicBrainzApiClient, MusicBrainzApi}, external::musicbrainz::{
interface::musicbrainz::{IMusicBrainz, Mbid}, api::search::SearchReleaseGroupRequest, api::MusicBrainzClient, http::MusicBrainzHttp,
},
interface::musicbrainz::Mbid,
}; };
use structopt::StructOpt; use structopt::StructOpt;
use uuid::Uuid; use uuid::Uuid;
@ -18,16 +20,13 @@ const USER_AGENT: &str = concat!(
#[derive(StructOpt)] #[derive(StructOpt)]
struct Opt { struct Opt {
#[structopt(help = "Release group's artist MBID")]
arid: Uuid,
#[structopt(subcommand)] #[structopt(subcommand)]
command: OptCommand, command: OptCommand,
} }
#[derive(StructOpt)] #[derive(StructOpt)]
enum OptCommand { enum OptCommand {
#[structopt(about = "Search by title (and date)")] #[structopt(about = "Search by artist MBID, title(, and date)")]
Title(OptTitle), Title(OptTitle),
#[structopt(about = "Search by release group MBID")] #[structopt(about = "Search by release group MBID")]
Rgid(OptRgid), Rgid(OptRgid),
@ -35,6 +34,9 @@ enum OptCommand {
#[derive(StructOpt)] #[derive(StructOpt)]
struct OptTitle { struct OptTitle {
#[structopt(help = "Release group's artist MBID")]
arid: Uuid,
#[structopt(help = "Release group title")] #[structopt(help = "Release group title")]
title: String, title: String,
@ -80,30 +82,32 @@ fn main() {
println!("USER_AGENT: {USER_AGENT}"); println!("USER_AGENT: {USER_AGENT}");
let client = MusicBrainzApiClient::new(USER_AGENT).expect("failed to create API client"); let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client");
let mut api = MusicBrainzApi::new(client); let mut client = MusicBrainzClient::new(http);
let arid: Mbid = opt.arid.into(); let mut request = SearchReleaseGroupRequest::default();
let arid: Mbid;
let album = match opt.command { let date: AlbumDate;
let title: String;
let rgid: Mbid;
match opt.command {
OptCommand::Title(opt_title) => { OptCommand::Title(opt_title) => {
let date: AlbumDate = opt_title.date.map(Into::into).unwrap_or_default(); arid = opt_title.arid.into();
Album::new(AlbumId::new(opt_title.title), date, None, vec![]) 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) => { OptCommand::Rgid(opt_rgid) => {
let mut album = Album::new( rgid = opt_rgid.rgid.into();
AlbumId::new(String::default()), request.rgid(&rgid);
AlbumDate::default(),
None,
vec![],
);
album.set_musicbrainz_ref(opt_rgid.rgid.into());
album
} }
}; };
let matches = api let matches = client
.search_release_group(&arid, &album) .search_release_group(request)
.expect("failed to make API call"); .expect("failed to make API call");
println!("{matches:#?}"); println!("{matches:#?}");

View File

@ -51,7 +51,13 @@ macro_rules! impl_imusicbrainzref {
impl From<Uuid> for $mbref { impl From<Uuid> for $mbref {
fn from(uuid: Uuid) -> Self { fn from(uuid: Uuid) -> Self {
$mbref(MusicBrainzRef::from_uuid(uuid, $mbref::entity())) $mbref(MusicBrainzRef::from_mbid(uuid, $mbref::entity()))
}
}
impl From<Mbid> for $mbref {
fn from(mbid: Mbid) -> Self {
$mbref(MusicBrainzRef::from_mbid(mbid, $mbref::entity()))
} }
} }
@ -98,9 +104,9 @@ impl MusicBrainzRef {
Ok(MusicBrainzRef { mbid, url }) Ok(MusicBrainzRef { mbid, url })
} }
fn from_uuid(uuid: Uuid, entity: &'static str) -> Self { fn from_mbid<ID: Into<Mbid>>(id: ID, entity: &'static str) -> Self {
let uuid_str = uuid.to_string(); let mbid = id.into();
let mbid = uuid.into(); let uuid_str = mbid.uuid().to_string();
let url = Url::parse(&format!("https://{MB_DOMAIN}/{entity}/{uuid_str}")).unwrap(); let url = Url::parse(&format!("https://{MB_DOMAIN}/{entity}/{uuid_str}")).unwrap();
MusicBrainzRef { mbid, url } MusicBrainzRef { mbid, url }
} }
@ -127,6 +133,11 @@ mod tests {
assert_eq!(url_str, mb.url().as_ref()); assert_eq!(url_str, mb.url().as_ref());
assert_eq!(uuid, mb.mbid().uuid().to_string()); assert_eq!(uuid, mb.mbid().uuid().to_string());
let mbid: Mbid = TryInto::<Uuid>::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 url: Url = url_str.as_str().try_into().unwrap();
let mb: MbArtistRef = url.try_into().unwrap(); let mb: MbArtistRef = url.try_into().unwrap();
assert_eq!(url_str, mb.url().as_ref()); assert_eq!(url_str, mb.url().as_ref());
@ -146,6 +157,11 @@ mod tests {
assert_eq!(url_str, mb.url().as_ref()); assert_eq!(url_str, mb.url().as_ref());
assert_eq!(uuid, mb.mbid().uuid().to_string()); assert_eq!(uuid, mb.mbid().uuid().to_string());
let mbid: Mbid = TryInto::<Uuid>::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 url: Url = url_str.as_str().try_into().unwrap();
let mb: MbAlbumRef = url.try_into().unwrap(); let mb: MbAlbumRef = url.try_into().unwrap();
assert_eq!(url_str, mb.url().as_ref()); assert_eq!(url_str, mb.url().as_ref());

View File

@ -1,52 +1,7 @@
//! Module for accessing MusicBrainz metadata. use std::fmt;
use std::{fmt, num};
use uuid::{self, Uuid}; 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<Vec<Album>, Error>;
fn search_release_group(
&mut self,
arid: &Mbid,
album: &Album,
) -> Result<Vec<Match<Album>>, Error>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match<T> {
pub score: u8,
pub item: T,
}
impl<T> Match<T> {
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<Vec<Album>, Error> {
Ok(vec![])
}
fn search_release_group(
&mut self,
_arid: &Mbid,
_album: &Album,
) -> Result<Vec<Match<Album>>, Error> {
Ok(vec![])
}
}
/// The MusicBrainz ID.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct Mbid(Uuid); pub struct Mbid(Uuid);
@ -62,10 +17,25 @@ impl From<Uuid> 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<uuid::Error> for MbidError {
fn from(value: uuid::Error) -> Self {
MbidError(value.to_string())
}
}
macro_rules! try_from_impl_for_mbid { macro_rules! try_from_impl_for_mbid {
($from:ty) => { ($from:ty) => {
impl TryFrom<$from> for Mbid { impl TryFrom<$from> for Mbid {
type Error = Error; type Error = MbidError;
fn try_from(value: $from) -> Result<Self, Self::Error> { fn try_from(value: $from) -> Result<Self, Self::Error> {
Ok(Uuid::parse_str(value.as_ref())?.into()) 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);
try_from_impl_for_mbid!(String); try_from_impl_for_mbid!(String);
/// Error type for musicbrainz calls. #[test]
#[derive(Debug, PartialEq, Eq)] fn errors() {
pub enum Error { let mbid_err: MbidError = TryInto::<Mbid>::try_into("i-am-not-a-uuid").unwrap_err();
/// Failed to parse input into an MBID. assert!(!mbid_err.to_string().is_empty());
MbidParse(String), assert!(!format!("{mbid_err:?}").is_empty());
/// 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<uuid::Error> for Error {
fn from(value: uuid::Error) -> Self {
Error::MbidParse(value.to_string())
}
}
impl From<num::ParseIntError> 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::<Mbid>::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::<u32>().unwrap_err().into();
assert!(!parse_err.to_string().is_empty());
assert!(!format!("{parse_err:?}").is_empty());
}
} }

1
src/external/mod.rs vendored
View File

@ -1,3 +1,4 @@
pub mod database; pub mod database;
pub mod library; pub mod library;
#[cfg(feature = "musicbrainz")]
pub mod musicbrainz; pub mod musicbrainz;

164
src/external/musicbrainz/api/lookup.rs vendored Normal file
View File

@ -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<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
pub fn lookup_artist(
&mut self,
request: LookupArtistRequest,
) -> Result<LookupArtistResponse, Error> {
let mut include: Vec<String> = 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<LookupArtistResponseReleaseGroup>,
}
#[derive(Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
struct DeserializeLookupArtistResponse {
release_groups: Vec<DeserializeLookupArtistResponseReleaseGroup>,
}
impl From<DeserializeLookupArtistResponse> 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<AlbumSecondaryType>,
}
#[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<SerdeAlbumSecondaryType>,
}
impl From<DeserializeLookupArtistResponseReleaseGroup> 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);
}
}

View File

@ -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 crate::{
use url::form_urlencoded; collection::album::{AlbumDate, AlbumPrimaryType, AlbumSecondaryType},
external::musicbrainz::HttpError,
#[cfg(test)] interface::musicbrainz::{Mbid, MbidError},
use mockall::automock;
use crate::core::{
collection::{
album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType},
musicbrainz::{IMusicBrainzRef, MbAlbumRef},
},
interface::musicbrainz::{Error, IMusicBrainz, Match, Mbid},
}; };
pub mod lookup;
pub mod search;
const MB_BASE_URL: &str = "https://musicbrainz.org/ws/2"; const MB_BASE_URL: &str = "https://musicbrainz.org/ws/2";
const MB_RATE_LIMIT_CODE: u16 = 503; const MB_RATE_LIMIT_CODE: u16 = 503;
#[derive(Debug, PartialEq, Eq)]
#[cfg_attr(test, automock)] pub enum Error {
pub trait IMusicBrainzApiClient { /// The HTTP client failed.
fn get<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, ClientError>; Http(String),
/// The client reached the API rate limit.
RateLimit,
/// The API response could not be understood.
Unknown(u16),
} }
#[derive(Debug)] impl fmt::Display for Error {
pub enum ClientError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
Client(String), match self {
Status(u16), 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<ClientError> for Error { impl From<HttpError> for Error {
fn from(err: ClientError) -> Self { fn from(err: HttpError) -> Self {
match err { match err {
ClientError::Client(s) => Error::Client(s), HttpError::Client(s) => Error::Http(s),
ClientError::Status(status) => match status { HttpError::Status(status) => match status {
MB_RATE_LIMIT_CODE => Error::RateLimit, MB_RATE_LIMIT_CODE => Error::RateLimit,
_ => Error::Unknown(status), _ => Error::Unknown(status),
}, },
@ -42,159 +45,125 @@ impl From<ClientError> for Error {
} }
} }
pub struct MusicBrainzApi<Mbc> { pub struct MusicBrainzClient<Http> {
client: Mbc, http: Http,
} }
impl<Mbc> MusicBrainzApi<Mbc> { impl<Http> MusicBrainzClient<Http> {
pub fn new(client: Mbc) -> Self { pub fn new(http: Http) -> Self {
MusicBrainzApi { client } MusicBrainzClient { http }
}
}
impl<Mbc: IMusicBrainzApiClient> IMusicBrainz for MusicBrainzApi<Mbc> {
fn lookup_artist_release_groups(&mut self, mbid: &Mbid) -> Result<Vec<Album>, 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()
} }
fn search_release_group( fn format_album_date(date: &AlbumDate) -> Option<String> {
&mut self, match date.year {
arid: &Mbid, Some(year) => match date.month {
album: &Album, Some(month) => match date.day {
) -> Result<Vec<Match<Album>>, Error> { Some(day) => Some(format!("{year}-{month:02}-{day:02}")),
let title = &album.id.title; None => Some(format!("{year}-{month:02}")),
let arid = arid.uuid().as_hyphenated().to_string(); },
let mut query = format!("arid:{arid}"); None => Some(format!("{year}")),
},
match album.musicbrainz { None => None,
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}"));
}
}
} }
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)] #[derive(Clone, Debug)]
#[serde(rename_all(deserialize = "kebab-case"))] pub struct SerdeMbid(Mbid);
struct ResponseLookupArtist {
release_groups: Vec<LookupReleaseGroup>,
}
#[derive(Debug, Deserialize)] impl From<SerdeMbid> for Mbid {
#[serde(rename_all(deserialize = "kebab-case"))] fn from(value: SerdeMbid) -> Self {
struct LookupReleaseGroup { value.0
id: String,
title: String,
first_release_date: String,
primary_type: SerdeAlbumPrimaryType,
secondary_types: Vec<SerdeAlbumSecondaryType>,
}
impl TryFrom<LookupReleaseGroup> for Album {
type Error = Error;
fn try_from(entity: LookupReleaseGroup) -> Result<Self, Self::Error> {
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)
} }
} }
#[derive(Clone, Debug, Deserialize)] struct SerdeMbidVisitor;
#[serde(rename_all(deserialize = "kebab-case"))]
struct ResponseSearchReleaseGroup {
release_groups: Vec<SearchReleaseGroup>,
}
#[derive(Clone, Debug, Deserialize)] impl<'de> Visitor<'de> for SerdeMbidVisitor {
#[serde(rename_all(deserialize = "kebab-case"))] type Value = SerdeMbid;
struct SearchReleaseGroup {
score: u8,
id: String,
title: String,
first_release_date: String,
primary_type: SerdeAlbumPrimaryType,
secondary_types: Option<Vec<SerdeAlbumSecondaryType>>,
}
impl TryFrom<SearchReleaseGroup> for Match<Album> { fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
type Error = Error; formatter.write_str("a valid MusicBrainz identifier")
}
fn try_from(entity: SearchReleaseGroup) -> Result<Self, Self::Error> { fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
let mut album = Album::new( where
entity.title, E: serde::de::Error,
AlbumDate::from_mb_date(&entity.first_release_date)?, {
Some(entity.primary_type.into()), Ok(SerdeMbid(
entity v.try_into()
.secondary_types .map_err(|e: MbidError| E::custom(e.to_string()))?,
.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))
} }
} }
impl AlbumDate { impl<'de> Deserialize<'de> for SerdeMbid {
fn from_mb_date(mb_date: &str) -> Result<AlbumDate, Error> { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
let mut elems = mb_date.split('-'); where
D: Deserializer<'de>,
{
deserializer.deserialize_str(SerdeMbidVisitor)
}
}
#[derive(Debug, Clone)]
pub struct SerdeAlbumDate(AlbumDate);
impl From<SerdeAlbumDate> 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<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let mut elems = v.split('-');
let elem = elems.next(); let elem = elems.next();
let year = elem let year = elem
.and_then(|s| if s.is_empty() { None } else { Some(s.parse()) }) .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 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 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<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(SerdeAlbumDateVisitor)
} }
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(remote = "AlbumPrimaryType")] #[serde(remote = "AlbumPrimaryType")]
pub enum SerdeAlbumPrimaryTypeDef { pub enum AlbumPrimaryTypeDef {
Album, Album,
Single, Single,
#[serde(rename = "EP")] #[serde(rename = "EP")]
@ -204,7 +173,7 @@ pub enum SerdeAlbumPrimaryTypeDef {
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct SerdeAlbumPrimaryType(#[serde(with = "SerdeAlbumPrimaryTypeDef")] AlbumPrimaryType); pub struct SerdeAlbumPrimaryType(#[serde(with = "AlbumPrimaryTypeDef")] AlbumPrimaryType);
impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType { impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
fn from(value: SerdeAlbumPrimaryType) -> Self { fn from(value: SerdeAlbumPrimaryType) -> Self {
@ -214,7 +183,7 @@ impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(remote = "AlbumSecondaryType")] #[serde(remote = "AlbumSecondaryType")]
pub enum SerdeAlbumSecondaryTypeDef { pub enum AlbumSecondaryTypeDef {
Compilation, Compilation,
Soundtrack, Soundtrack,
Spokenword, Spokenword,
@ -234,9 +203,7 @@ pub enum SerdeAlbumSecondaryTypeDef {
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct SerdeAlbumSecondaryType( pub struct SerdeAlbumSecondaryType(#[serde(with = "AlbumSecondaryTypeDef")] AlbumSecondaryType);
#[serde(with = "SerdeAlbumSecondaryTypeDef")] AlbumSecondaryType,
);
impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType { impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
fn from(value: SerdeAlbumSecondaryType) -> Self { fn from(value: SerdeAlbumSecondaryType) -> Self {
@ -246,188 +213,89 @@ impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use mockall::{predicate, Sequence};
use crate::collection::album::AlbumId;
use super::*; use super::*;
#[test] #[test]
fn lookup_artist_release_group() { fn errors() {
let mut client = MockIMusicBrainzApiClient::new(); let http_err = HttpError::Client(String::from("a http error"));
let url = format!( let http_err: Error = http_err.into();
"https://musicbrainz.org/ws/2/artist/{mbid}?inc=release-groups", assert!(matches!(http_err, Error::Http(_)));
mbid = "00000000-0000-0000-0000-000000000000", assert!(!http_err.to_string().is_empty());
); assert!(!format!("{http_err:?}").is_empty());
let release_group = LookupReleaseGroup { let rate_err = HttpError::Status(MB_RATE_LIMIT_CODE);
id: String::from("11111111-1111-1111-1111-111111111111"), let rate_err: Error = rate_err.into();
title: String::from("an album"), assert!(matches!(rate_err, Error::RateLimit));
first_release_date: String::from("1986-04"), assert!(!rate_err.to_string().is_empty());
primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album), assert!(!format!("{rate_err:?}").is_empty());
secondary_types: vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Compilation)],
};
let response = ResponseLookupArtist {
release_groups: vec![release_group],
};
// For code coverage of derive(Debug). let unk_err = HttpError::Status(404);
assert!(!format!("{response:?}").is_empty()); let unk_err: Error = unk_err.into();
assert!(matches!(unk_err, Error::Unknown(_)));
client assert!(!unk_err.to_string().is_empty());
.expect_get() assert!(!format!("{unk_err:?}").is_empty());
.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);
} }
#[test] #[test]
fn search_release_group() { fn format_album_date() {
let mut client = MockIMusicBrainzApiClient::new(); struct Null;
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::<ResponseLookupArtist>()
.times(1)
.return_once(|_| Err(ClientError::Client(String::from("get rekt scrub"))));
client
.expect_get::<ResponseLookupArtist>()
.times(1)
.return_once(|_| Err(ClientError::Status(503)));
client
.expect_get::<ResponseLookupArtist>()
.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());
assert_eq!( assert_eq!(
AlbumDate::from_mb_date("1984-05").unwrap(), MusicBrainzClient::<Null>::format_album_date(&AlbumDate::new(None, None, None)),
(1984, 5).into() None
); );
assert_eq!( assert_eq!(
AlbumDate::from_mb_date("1984-05-18").unwrap(), MusicBrainzClient::<Null>::format_album_date(&(1986).into()),
(1984, 5, 18).into() Some(String::from("1986"))
);
assert_eq!(
MusicBrainzClient::<Null>::format_album_date(&(1986, 4).into()),
Some(String::from("1986-04"))
);
assert_eq!(
MusicBrainzClient::<Null>::format_album_date(&(1986, 4, 21).into()),
Some(String::from("1986-04-21"))
); );
assert!(AlbumDate::from_mb_date("1984-get-rekt").is_err());
} }
#[test] #[test]
fn serde() { 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<SerdeMbid, _> = 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<SerdeAlbumDate, _> = 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 = "\"EP\"";
let primary_type: SerdeAlbumPrimaryType = serde_json::from_str(primary_type).unwrap(); let primary_type: SerdeAlbumPrimaryType = serde_json::from_str(primary_type).unwrap();
let primary_type: AlbumPrimaryType = primary_type.into(); let primary_type: AlbumPrimaryType = primary_type.into();

272
src/external/musicbrainz/api/search.rs vendored Normal file
View File

@ -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<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
pub fn search_release_group(
&mut self,
request: SearchReleaseGroupRequest,
) -> Result<SearchReleaseGroupResponse, Error> {
let mut query: Vec<String> = 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<SearchReleaseGroupResponseReleaseGroup>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
struct DeserializeSearchReleaseGroupResponse {
release_groups: Vec<DeserializeSearchReleaseGroupResponseReleaseGroup>,
}
impl From<DeserializeSearchReleaseGroupResponse> 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<Vec<AlbumSecondaryType>>,
}
#[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<Vec<SerdeAlbumSecondaryType>>,
}
impl From<DeserializeSearchReleaseGroupResponseReleaseGroup>
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();
}
}

View File

@ -3,13 +3,13 @@
use reqwest::{self, blocking::Client, header}; use reqwest::{self, blocking::Client, header};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use crate::external::musicbrainz::api::{ClientError, IMusicBrainzApiClient}; use crate::external::musicbrainz::{HttpError, IMusicBrainzHttp};
// GRCOV_EXCL_START // GRCOV_EXCL_START
pub struct MusicBrainzApiClient(Client); pub struct MusicBrainzHttp(Client);
impl MusicBrainzApiClient { impl MusicBrainzHttp {
pub fn new(user_agent: &'static str) -> Result<Self, ClientError> { pub fn new(user_agent: &'static str) -> Result<Self, HttpError> {
let mut headers = header::HeaderMap::new(); let mut headers = header::HeaderMap::new();
headers.insert( headers.insert(
header::USER_AGENT, header::USER_AGENT,
@ -20,27 +20,27 @@ impl MusicBrainzApiClient {
header::HeaderValue::from_static("application/json"), header::HeaderValue::from_static("application/json"),
); );
Ok(MusicBrainzApiClient( Ok(MusicBrainzHttp(
Client::builder().default_headers(headers).build()?, Client::builder().default_headers(headers).build()?,
)) ))
} }
} }
impl IMusicBrainzApiClient for MusicBrainzApiClient { impl IMusicBrainzHttp for MusicBrainzHttp {
fn get<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, ClientError> { fn get<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, HttpError> {
let response = self.0.get(url).send()?; let response = self.0.get(url).send()?;
if response.status().is_success() { if response.status().is_success() {
Ok(response.json()?) Ok(response.json()?)
} else { } else {
Err(ClientError::Status(response.status().as_u16())) Err(HttpError::Status(response.status().as_u16()))
} }
} }
} }
impl From<reqwest::Error> for ClientError { impl From<reqwest::Error> for HttpError {
fn from(err: reqwest::Error) -> Self { fn from(err: reqwest::Error) -> Self {
ClientError::Client(err.to_string()) HttpError::Client(err.to_string())
} }
} }
// GRCOV_EXCL_STOP // GRCOV_EXCL_STOP

View File

@ -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 api;
pub mod http;
#[cfg(test)]
use mockall::automock;
use serde::de::DeserializeOwned;
#[cfg_attr(test, automock)]
pub trait IMusicBrainzHttp {
fn get<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, HttpError>;
}
#[derive(Debug)]
pub enum HttpError {
Client(String),
Status(u16),
}

View File

@ -16,7 +16,7 @@ use musichoard::{
executor::{ssh::BeetsLibrarySshExecutor, BeetsLibraryProcessExecutor}, executor::{ssh::BeetsLibrarySshExecutor, BeetsLibraryProcessExecutor},
BeetsLibrary, BeetsLibrary,
}, },
musicbrainz::api::{client::MusicBrainzApiClient, MusicBrainzApi}, musicbrainz::{api::MusicBrainzClient, http::MusicBrainzHttp},
}, },
interface::{ interface::{
database::{IDatabase, NullDatabase}, database::{IDatabase, NullDatabase},
@ -25,7 +25,7 @@ use musichoard::{
MusicHoardBuilder, NoDatabase, NoLibrary, 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!( const MUSICHOARD_HTTP_USER_AGENT: &str = concat!(
"MusicHoard/", "MusicHoard/",
@ -83,11 +83,12 @@ fn with<Database: IDatabase + 'static, Library: ILibrary + 'static>(
let listener = EventListener::new(channel.sender()); let listener = EventListener::new(channel.sender());
let handler = EventHandler::new(channel.receiver()); let handler = EventHandler::new(channel.receiver());
let client = MusicBrainzApiClient::new(MUSICHOARD_HTTP_USER_AGENT) let http =
.expect("failed to initialise HTTP client"); MusicBrainzHttp::new(MUSICHOARD_HTTP_USER_AGENT).expect("failed to initialise HTTP client");
let api = Box::new(MusicBrainzApi::new(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; let ui = Ui;
// Run the TUI application. // Run the TUI application.

View File

@ -105,7 +105,7 @@ impl IAppInteractBrowse for AppMachine<AppBrowse> {
continue; 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 { Ok(matches) => artist_album_matches.push(AppMatchesInfo {
matching: album.clone(), matching: album.clone(),
matches, matches,
@ -129,17 +129,14 @@ impl IAppInteractBrowse for AppMachine<AppBrowse> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use mockall::{predicate, Sequence}; use mockall::{predicate, Sequence};
use musichoard::collection::album::Album; use musichoard::{collection::album::Album, interface::musicbrainz::Mbid};
use crate::tui::{ use crate::tui::{
app::{ app::{
machine::tests::{inner, inner_with_mb, music_hoard}, machine::tests::{inner, inner_with_mb, music_hoard},
Category, IAppAccess, IAppInteract, IAppInteractMatches, Category, IAppAccess, IAppInteract, IAppInteractMatches,
}, },
lib::external::musicbrainz::{ lib::interface::musicbrainz::{self, Match, MockIMusicBrainz},
self,
api::{Match, Mbid, MockIMusicBrainz},
},
testmod::COLLECTION, testmod::COLLECTION,
}; };
@ -230,8 +227,8 @@ mod tests {
let matches_1 = vec![album_match_1_1.clone(), album_match_1_2.clone()]; 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 matches_4 = vec![album_match_4_1.clone(), album_match_4_2.clone()];
let result_1: Result<Vec<Match<Album>>, musicbrainz::api::Error> = Ok(matches_1.clone()); let result_1: Result<Vec<Match<Album>>, musicbrainz::Error> = Ok(matches_1.clone());
let result_4: Result<Vec<Match<Album>>, musicbrainz::api::Error> = Ok(matches_4.clone()); let result_4: Result<Vec<Match<Album>>, musicbrainz::Error> = Ok(matches_4.clone());
// Other albums have an MBID and so they will be skipped. // Other albums have an MBID and so they will be skipped.
let mut seq = Sequence::new(); let mut seq = Sequence::new();
@ -300,7 +297,7 @@ mod tests {
fn fetch_musicbrainz_api_error() { fn fetch_musicbrainz_api_error() {
let mut mb_api = Box::new(MockIMusicBrainz::new()); let mut mb_api = Box::new(MockIMusicBrainz::new());
let error = Err(musicbrainz::api::Error::RateLimit); let error = Err(musicbrainz::Error::RateLimit);
mb_api mb_api
.expect_search_release_group() .expect_search_release_group()

View File

@ -1,10 +1,13 @@
use std::cmp; use std::cmp;
use musichoard::{collection::album::Album, interface::musicbrainz::Match}; use musichoard::collection::album::Album;
use crate::tui::app::{ use crate::tui::{
machine::{App, AppInner, AppMachine}, app::{
AppPublic, AppPublicMatches, AppState, IAppInteractMatches, WidgetState, machine::{App, AppInner, AppMachine},
AppPublic, AppPublicMatches, AppState, IAppInteractMatches, WidgetState,
},
lib::interface::musicbrainz::Match,
}; };
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]

View File

@ -8,7 +8,7 @@ mod search;
use crate::tui::{ use crate::tui::{
app::{selection::Selection, AppPublic, AppPublicInner, AppState, IAppAccess, IAppInteract}, app::{selection::Selection, AppPublic, AppPublicInner, AppState, IAppAccess, IAppInteract},
lib::{external::musicbrainz::api::IMusicBrainz, IMusicHoard}, lib::{interface::musicbrainz::IMusicBrainz, IMusicHoard},
}; };
use browse::AppBrowse; use browse::AppBrowse;
@ -37,7 +37,7 @@ pub struct AppMachine<STATE> {
pub struct AppInner { pub struct AppInner {
running: bool, running: bool,
music_hoard: Box<dyn IMusicHoard>, music_hoard: Box<dyn IMusicHoard>,
mb_api: Box<dyn IMusicBrainz>, musicbrainz: Box<dyn IMusicBrainz>,
selection: Selection, selection: Selection,
} }
@ -121,12 +121,12 @@ impl IAppAccess for App {
} }
impl AppInner { impl AppInner {
pub fn new(music_hoard: Box<dyn IMusicHoard>, mb_api: Box<dyn IMusicBrainz>) -> Self { pub fn new(music_hoard: Box<dyn IMusicHoard>, musicbrainz: Box<dyn IMusicBrainz>) -> Self {
let selection = Selection::new(music_hoard.get_collection()); let selection = Selection::new(music_hoard.get_collection());
AppInner { AppInner {
running: true, running: true,
music_hoard, music_hoard,
mb_api, musicbrainz,
selection, selection,
} }
} }
@ -147,7 +147,7 @@ mod tests {
use crate::tui::{ use crate::tui::{
app::{AppState, IAppInteract, IAppInteractBrowse}, app::{AppState, IAppInteract, IAppInteractBrowse},
lib::{external::musicbrainz::api::MockIMusicBrainz, MockIMusicHoard}, lib::{interface::musicbrainz::MockIMusicBrainz, MockIMusicHoard},
}; };
use super::*; use super::*;

View File

@ -4,10 +4,9 @@ mod selection;
pub use machine::App; pub use machine::App;
pub use selection::{Category, Delta, Selection, WidgetState}; pub use selection::{Category, Delta, Selection, WidgetState};
use musichoard::{ use musichoard::collection::{album::Album, Collection};
collection::{album::Album, Collection},
interface::musicbrainz::Match, use crate::tui::lib::interface::musicbrainz::Match;
};
pub enum AppState<BS, IS, RS, SS, MS, ES, CS> { pub enum AppState<BS, IS, RS, SS, MS, ES, CS> {
Browse(BS), Browse(BS),

View File

@ -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<Database: interface::database::IDatabase, Library: interface::library::ILibrary> IMusicHoard
for MusicHoard<Database, Library>
{
fn rescan_library(&mut self) -> Result<(), musichoard::Error> {
<Self as IMusicHoardLibrary>::rescan_library(self)
}
fn reload_database(&mut self) -> Result<(), musichoard::Error> {
<Self as IMusicHoardDatabase>::reload_database(self)
}
fn get_collection(&self) -> &Collection {
<Self as IMusicHoardBase>::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<T> = interface::musicbrainz::Match<T>;
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<Vec<Match<Album>>, Error>;
}
// GRCOV_EXCL_START
impl<Mbc: IMusicBrainzApiClient> IMusicBrainz for MusicBrainzApi<Mbc> {
fn search_release_group(
&mut self,
arid: &Mbid,
album: &Album,
) -> Result<Vec<Match<Album>>, Error> {
<Self as interface::musicbrainz::IMusicBrainz>::search_release_group(
self, arid, album,
)
}
}
// GRCOV_EXCL_STOP
}
}
}

1
src/tui/lib/external/mod.rs vendored Normal file
View File

@ -0,0 +1 @@
pub mod musicbrainz;

66
src/tui/lib/external/musicbrainz/mod.rs vendored Normal file
View File

@ -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<Http> {
client: MusicBrainzClient<Http>,
}
impl<Http> MusicBrainz<Http> {
pub fn new(client: MusicBrainzClient<Http>) -> Self {
MusicBrainz { client }
}
}
impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
fn search_release_group(
&mut self,
arid: &Mbid,
album: &Album,
) -> Result<Vec<Match<Album>>, 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<Album> {
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

View File

@ -0,0 +1 @@
pub mod musicbrainz;

View File

@ -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<Vec<Match<Album>>, Error>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match<T> {
pub score: u8,
pub item: T,
}
impl<T> Match<T> {
pub fn new(score: u8, item: T) -> Self {
Match { score, item }
}
}
pub type Error = musichoard::external::musicbrainz::api::Error;

34
src/tui/lib/mod.rs Normal file
View File

@ -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<Database: IDatabase, Library: ILibrary> IMusicHoard for MusicHoard<Database, Library> {
fn rescan_library(&mut self) -> Result<(), musichoard::Error> {
<Self as IMusicHoardLibrary>::rescan_library(self)
}
fn reload_database(&mut self) -> Result<(), musichoard::Error> {
<Self as IMusicHoardDatabase>::reload_database(self)
}
fn get_collection(&self) -> &Collection {
<Self as IMusicHoardBase>::get_collection(self)
}
}
// GRCOV_EXCL_STOP

View File

@ -8,6 +8,7 @@ mod ui;
pub use app::App; pub use app::App;
pub use event::EventChannel; pub use event::EventChannel;
pub use handler::EventHandler; pub use handler::EventHandler;
pub use lib::external::musicbrainz::MusicBrainz;
pub use listener::EventListener; pub use listener::EventListener;
pub use ui::Ui; pub use ui::Ui;
@ -173,7 +174,7 @@ mod testmod;
mod tests { mod tests {
use std::{io, thread}; use std::{io, thread};
use lib::external::musicbrainz::api::MockIMusicBrainz; use lib::interface::musicbrainz::MockIMusicBrainz;
use ratatui::{backend::TestBackend, Terminal}; use ratatui::{backend::TestBackend, Terminal};
use musichoard::collection::Collection; use musichoard::collection::Collection;

View File

@ -1,14 +1,11 @@
use std::collections::HashMap; use std::collections::HashMap;
use musichoard::{ use musichoard::collection::{
collection::{ album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus},
album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus}, artist::Artist,
artist::Artist, musicbrainz::IMusicBrainzRef,
musicbrainz::IMusicBrainzRef, track::{Track, TrackFormat, TrackQuality},
track::{Track, TrackFormat, TrackQuality}, Collection,
Collection,
},
interface::musicbrainz::Match,
}; };
use ratatui::{ use ratatui::{
layout::{Alignment, Rect}, layout::{Alignment, Rect},
@ -18,7 +15,10 @@ use ratatui::{
Frame, 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: Color = Color::Black;
const COLOR_BG_HL: Color = Color::DarkGray; const COLOR_BG_HL: Color = Color::DarkGray;