Decide carefully where external::musicbrainz belongs #196

Merged
wojtek merged 11 commits from 193---decide-carefully-where-external--musicbrainz-belongs into main 2024-08-28 18:21:13 +02:00
4 changed files with 153 additions and 157 deletions
Showing only changes of commit 2f7271f53b - Show all commits

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 }
} }

View File

@ -2,19 +2,26 @@
pub mod http; pub mod http;
use std::fmt; use std::{fmt, num::ParseIntError};
#[cfg(test)] #[cfg(test)]
use mockall::automock; use mockall::automock;
use serde::{de::DeserializeOwned, Deserialize}; use serde::{
de::{DeserializeOwned, Visitor},
Deserialize, Deserializer,
};
use url::form_urlencoded; use url::form_urlencoded;
use crate::core::{ use crate::{
collection::{ collection::album::AlbumDate,
album::{Album, AlbumPrimaryType, AlbumSecondaryType}, core::{
musicbrainz::IMusicBrainzRef, collection::{
album::{Album, AlbumPrimaryType, AlbumSecondaryType},
musicbrainz::IMusicBrainzRef,
},
interface::musicbrainz::Mbid,
}, },
interface::musicbrainz::Mbid, interface::musicbrainz::MbidError,
}; };
#[cfg_attr(test, automock)] #[cfg_attr(test, automock)]
@ -74,15 +81,6 @@ impl<Http> MusicBrainzClient<Http> {
} }
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> { impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
pub fn lookup_artist_release_groups(
&mut self,
mbid: &Mbid,
) -> Result<ResponseLookupArtist, Error> {
let mbid = mbid.uuid().as_hyphenated().to_string();
let url = format!("{MB_BASE_URL}/artist/{mbid}?inc=release-groups");
Ok(self.http.get(&url)?)
}
pub fn search_release_group( pub fn search_release_group(
&mut self, &mut self,
arid: &Mbid, arid: &Mbid,
@ -112,42 +110,100 @@ impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
} }
} }
#[derive(Debug, Deserialize)] #[derive(Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
pub struct ResponseLookupArtist {
pub release_groups: Vec<LookupReleaseGroup>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
pub struct LookupReleaseGroup {
pub id: String, // TODO: Change to MBID
pub title: String,
pub first_release_date: String, // TODO: Change to AlbumDate
pub primary_type: SerdeAlbumPrimaryType, // TODO: Change to AlbumPrimaryType
pub secondary_types: Vec<SerdeAlbumSecondaryType>, // TODO: Change to Vec<AlbumSecondaryType>
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))] #[serde(rename_all(deserialize = "kebab-case"))]
pub struct ResponseSearchReleaseGroup { pub struct ResponseSearchReleaseGroup {
pub release_groups: Vec<SearchReleaseGroup>, pub release_groups: Vec<SearchReleaseGroup>,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))] #[serde(rename_all(deserialize = "kebab-case"))]
pub struct SearchReleaseGroup { pub struct SearchReleaseGroup {
pub score: u8, pub score: u8,
pub id: String, // TODO: Change to MBID pub id: Mbid,
pub title: String, pub title: String,
pub first_release_date: String, // TODO: Change to AlbumDate pub first_release_date: AlbumDate,
pub primary_type: SerdeAlbumPrimaryType, // TODO: Change to AlbumDate #[serde(with = "AlbumPrimaryTypeDef")]
pub secondary_types: Option<Vec<SerdeAlbumSecondaryType>>, // TODO: Change to Vec<AlbumSecondaryType> pub primary_type: AlbumPrimaryType,
pub secondary_types: Option<Vec<AlbumSecondaryType>>,
}
struct MbidVisitor;
impl<'de> Visitor<'de> for MbidVisitor {
type Value = Mbid;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a valid MusicBrainz identifier")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(v.try_into()
.map_err(|e: MbidError| E::custom(e.to_string()))?)
}
}
impl<'de> Deserialize<'de> for Mbid {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(MbidVisitor)
}
}
struct AlbumDateVisitor;
impl<'de> Visitor<'de> for AlbumDateVisitor {
type Value = AlbumDate;
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 year = elem
.and_then(|s| if s.is_empty() { None } else { Some(s.parse()) })
.transpose()
.map_err(|e: ParseIntError| E::custom(e.to_string()))?;
let elem = elems.next();
let month = elem
.map(|s| s.parse())
.transpose()
.map_err(|e: ParseIntError| E::custom(e.to_string()))?;
let elem = elems.next();
let day = elem
.map(|s| s.parse())
.transpose()
.map_err(|e: ParseIntError| E::custom(e.to_string()))?;
Ok(AlbumDate::new(year, month, day))
}
}
impl<'de> Deserialize<'de> for AlbumDate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(AlbumDateVisitor)
}
} }
#[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")]
@ -156,44 +212,47 @@ pub enum SerdeAlbumPrimaryTypeDef {
Other, Other,
} }
#[derive(Clone, Debug, Deserialize)] // AlbumSecondaryType is implemented manually because deserializing to a remote type is not (yet)
pub struct SerdeAlbumPrimaryType(#[serde(with = "SerdeAlbumPrimaryTypeDef")] AlbumPrimaryType); // supported for Option/Vec/Map by serde: https://github.com/serde-rs/serde/issues/723.
struct AlbumSecondaryTypeVisitor;
impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType { impl<'de> Visitor<'de> for AlbumSecondaryTypeVisitor {
fn from(value: SerdeAlbumPrimaryType) -> Self { type Value = AlbumSecondaryType;
value.0
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a valid MusicBrainz album secondary type")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let variant = match v {
"Compilation" => AlbumSecondaryType::Compilation,
"Soundtrack" => AlbumSecondaryType::Soundtrack,
"Spokenword" => AlbumSecondaryType::Spokenword,
"Interview" => AlbumSecondaryType::Interview,
"Audiobook" => AlbumSecondaryType::Audiobook,
"Audio drama" => AlbumSecondaryType::AudioDrama,
"Live" => AlbumSecondaryType::Live,
"Remix" => AlbumSecondaryType::Remix,
"DJ-mix" => AlbumSecondaryType::DjMix,
"Mixtape/Street" => AlbumSecondaryType::MixtapeStreet,
"Demo" => AlbumSecondaryType::Demo,
"Field recording" => AlbumSecondaryType::FieldRecording,
_ => return Err(E::custom(format!("unknown album secondary type: {v}"))),
};
Ok(variant)
} }
} }
#[derive(Debug, Deserialize)] impl<'de> Deserialize<'de> for AlbumSecondaryType {
#[serde(remote = "AlbumSecondaryType")] fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
pub enum SerdeAlbumSecondaryTypeDef { where
Compilation, D: Deserializer<'de>,
Soundtrack, {
Spokenword, deserializer.deserialize_str(AlbumSecondaryTypeVisitor)
Interview,
Audiobook,
#[serde(rename = "Audio drama")]
AudioDrama,
Live,
Remix,
#[serde(rename = "DJ-mix")]
DjMix,
#[serde(rename = "Mixtape/Street")]
MixtapeStreet,
Demo,
#[serde(rename = "Field recording")]
FieldRecording,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SerdeAlbumSecondaryType(
#[serde(with = "SerdeAlbumSecondaryTypeDef")] AlbumSecondaryType,
);
impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
fn from(value: SerdeAlbumSecondaryType) -> Self {
value.0
} }
} }

View File

@ -1,12 +1,7 @@
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API). //! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
use std::num;
use musichoard::{ use musichoard::{
collection::{ collection::album::Album,
album::{Album, AlbumDate},
musicbrainz::MbAlbumRef,
},
external::musicbrainz::{IMusicBrainzHttp, MusicBrainzClient, SearchReleaseGroup}, external::musicbrainz::{IMusicBrainzHttp, MusicBrainzClient, SearchReleaseGroup},
interface::musicbrainz::Mbid, interface::musicbrainz::Mbid,
}; };
@ -29,62 +24,24 @@ impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
arid: &Mbid, arid: &Mbid,
album: &Album, album: &Album,
) -> Result<Vec<Match<Album>>, Error> { ) -> Result<Vec<Match<Album>>, Error> {
self.client let mb_response = self.client.search_release_group(arid, album)?;
.search_release_group(arid, album)? Ok(mb_response
.release_groups .release_groups
.into_iter() .into_iter()
.map(from_search_release_group) .map(from_search_release_group)
.collect() .collect())
} }
} }
impl From<musichoard::external::musicbrainz::Error> for Error { fn from_search_release_group(entity: SearchReleaseGroup) -> Match<Album> {
fn from(value: musichoard::external::musicbrainz::Error) -> Self {
match value {
musichoard::external::musicbrainz::Error::Http(s) => Error::Client(s),
musichoard::external::musicbrainz::Error::RateLimit => Error::RateLimit,
musichoard::external::musicbrainz::Error::Unknown(u) => Error::Unknown(u),
}
}
}
fn from_search_release_group(entity: SearchReleaseGroup) -> Result<Match<Album>, Error> {
let mut album = Album::new( let mut album = Album::new(
entity.title, entity.title,
from_mb_date(&entity.first_release_date)?, entity.first_release_date,
Some(entity.primary_type.into()), Some(entity.primary_type),
entity entity.secondary_types.unwrap_or_default(),
.secondary_types
.map(|v| v.into_iter().map(|st| st.into()).collect())
.unwrap_or_default(),
); );
let mbref = album.set_musicbrainz_ref(entity.id.into());
MbAlbumRef::from_uuid_str(entity.id).map_err(|err| Error::MbidParse(err.to_string()))?; Match::new(entity.score, album)
album.set_musicbrainz_ref(mbref);
Ok(Match::new(entity.score, album))
}
impl From<num::ParseIntError> for Error {
fn from(err: num::ParseIntError) -> Error {
Error::Parse(err.to_string())
}
}
fn from_mb_date(mb_date: &str) -> Result<AlbumDate, Error> {
let mut elems = mb_date.split('-');
let elem = elems.next();
let year = elem
.and_then(|s| if s.is_empty() { None } else { Some(s.parse()) })
.transpose()?;
let elem = elems.next();
let month = elem.map(|s| s.parse()).transpose()?;
let elem = elems.next();
let day = elem.map(|s| s.parse()).transpose()?;
Ok(AlbumDate::new(year, month, day))
} }
// #[cfg(test)] // #[cfg(test)]

View File

@ -1,7 +1,5 @@
//! Module for accessing MusicBrainz metadata. //! Module for accessing MusicBrainz metadata.
use std::fmt;
#[cfg(test)] #[cfg(test)]
use mockall::automock; use mockall::automock;
@ -29,28 +27,4 @@ impl<T> Match<T> {
} }
} }
#[derive(Debug, PartialEq, Eq)] pub type Error = musichoard::external::musicbrainz::Error;
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}"),
}
}
}