Decide carefully where external::musicbrainz
belongs
#196
@ -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 }
|
||||||
}
|
}
|
||||||
|
197
src/external/musicbrainz/mod.rs
vendored
197
src/external/musicbrainz/mod.rs
vendored
@ -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::album::AlbumDate,
|
||||||
|
core::{
|
||||||
collection::{
|
collection::{
|
||||||
album::{Album, AlbumPrimaryType, AlbumSecondaryType},
|
album::{Album, AlbumPrimaryType, AlbumSecondaryType},
|
||||||
musicbrainz::IMusicBrainzRef,
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
63
src/tui/lib/external/musicbrainz/mod.rs
vendored
63
src/tui/lib/external/musicbrainz/mod.rs
vendored
@ -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)]
|
||||||
|
@ -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}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Loading…
Reference in New Issue
Block a user