A functional state

This commit is contained in:
Wojciech Kozlowski 2024-08-27 22:43:04 +02:00
parent b70499d8de
commit f4a52a4edc
19 changed files with 849 additions and 709 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]]

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, "{}", 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,10 @@ 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.
#[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<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] #[test]
fn errors() { fn errors() {
let mbid_err: Error = TryInto::<Mbid>::try_into("i-am-not-a-uuid").unwrap_err(); let mbid_err: MbidError = TryInto::<Mbid>::try_into("i-am-not-a-uuid").unwrap_err();
assert!(!mbid_err.to_string().is_empty()); assert!(!mbid_err.to_string().is_empty());
assert!(!format!("{mbid_err:?}").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;

View File

@ -1,441 +0,0 @@
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
pub mod client;
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},
};
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<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, ClientError>;
}
#[derive(Debug)]
pub enum ClientError {
Client(String),
Status(u16),
}
impl From<ClientError> for Error {
fn from(err: ClientError) -> Self {
match err {
ClientError::Client(s) => Error::Client(s),
ClientError::Status(status) => match status {
MB_RATE_LIMIT_CODE => Error::RateLimit,
_ => Error::Unknown(status),
},
}
}
}
pub struct MusicBrainzApi<Mbc> {
client: Mbc,
}
impl<Mbc> MusicBrainzApi<Mbc> {
pub fn new(client: Mbc) -> Self {
MusicBrainzApi { client }
}
}
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(
&mut self,
arid: &Mbid,
album: &Album,
) -> Result<Vec<Match<Album>>, 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}"));
}
}
}
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<LookupReleaseGroup>,
}
#[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<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)]
#[serde(rename_all(deserialize = "kebab-case"))]
struct ResponseSearchReleaseGroup {
release_groups: Vec<SearchReleaseGroup>,
}
#[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<Vec<SerdeAlbumSecondaryType>>,
}
impl TryFrom<SearchReleaseGroup> for Match<Album> {
type Error = Error;
fn try_from(entity: SearchReleaseGroup) -> 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
.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 {
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))
}
}
#[derive(Debug, Deserialize)]
#[serde(remote = "AlbumPrimaryType")]
pub enum SerdeAlbumPrimaryTypeDef {
Album,
Single,
#[serde(rename = "EP")]
Ep,
Broadcast,
Other,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SerdeAlbumPrimaryType(#[serde(with = "SerdeAlbumPrimaryTypeDef")] AlbumPrimaryType);
impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
fn from(value: SerdeAlbumPrimaryType) -> Self {
value.0
}
}
#[derive(Debug, Deserialize)]
#[serde(remote = "AlbumSecondaryType")]
pub enum SerdeAlbumSecondaryTypeDef {
Compilation,
Soundtrack,
Spokenword,
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
}
}
#[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",
);
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],
};
// 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);
}
#[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::<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!(
AlbumDate::from_mb_date("1984-05").unwrap(),
(1984, 5).into()
);
assert_eq!(
AlbumDate::from_mb_date("1984-05-18").unwrap(),
(1984, 5, 18).into()
);
assert!(AlbumDate::from_mb_date("1984-get-rekt").is_err());
}
#[test]
fn serde() {
let primary_type = "\"EP\"";
let primary_type: SerdeAlbumPrimaryType = serde_json::from_str(primary_type).unwrap();
let primary_type: AlbumPrimaryType = primary_type.into();
assert_eq!(primary_type, AlbumPrimaryType::Ep);
let secondary_type = "\"Field recording\"";
let secondary_type: SerdeAlbumSecondaryType = serde_json::from_str(secondary_type).unwrap();
let secondary_type: AlbumSecondaryType = secondary_type.into();
assert_eq!(secondary_type, AlbumSecondaryType::FieldRecording);
}
}

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,391 @@
#[cfg(feature = "musicbrainz-api")] //! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
pub mod api;
pub mod http;
use std::fmt;
#[cfg(test)]
use mockall::automock;
use serde::{de::DeserializeOwned, Deserialize};
use url::form_urlencoded;
use crate::core::{
collection::{
album::{Album, AlbumPrimaryType, AlbumSecondaryType},
musicbrainz::IMusicBrainzRef,
},
interface::musicbrainz::Mbid,
};
#[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),
}
const MB_BASE_URL: &str = "https://musicbrainz.org/ws/2";
const MB_RATE_LIMIT_CODE: u16 = 503;
#[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),
}
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<HttpError> for Error {
fn from(err: HttpError) -> Self {
match err {
HttpError::Client(s) => Error::Http(s),
HttpError::Status(status) => match status {
MB_RATE_LIMIT_CODE => Error::RateLimit,
_ => Error::Unknown(status),
},
}
}
}
pub struct MusicBrainzClient<Http> {
http: Http,
}
impl<Http> MusicBrainzClient<Http> {
pub fn new(http: Http) -> Self {
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(
&mut self,
arid: &Mbid,
album: &Album,
) -> Result<ResponseSearchReleaseGroup, 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}"));
}
}
}
let query: String = form_urlencoded::byte_serialize(query.as_bytes()).collect();
let url = format!("{MB_BASE_URL}/release-group?query={query}");
Ok(self.http.get(&url)?)
}
}
#[derive(Debug, 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"))]
pub struct ResponseSearchReleaseGroup {
pub release_groups: Vec<SearchReleaseGroup>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
pub struct SearchReleaseGroup {
pub score: u8,
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 AlbumDate
pub secondary_types: Option<Vec<SerdeAlbumSecondaryType>>, // TODO: Change to Vec<AlbumSecondaryType>
}
#[derive(Debug, Deserialize)]
#[serde(remote = "AlbumPrimaryType")]
pub enum SerdeAlbumPrimaryTypeDef {
Album,
Single,
#[serde(rename = "EP")]
Ep,
Broadcast,
Other,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SerdeAlbumPrimaryType(#[serde(with = "SerdeAlbumPrimaryTypeDef")] AlbumPrimaryType);
impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
fn from(value: SerdeAlbumPrimaryType) -> Self {
value.0
}
}
#[derive(Debug, Deserialize)]
#[serde(remote = "AlbumSecondaryType")]
pub enum SerdeAlbumSecondaryTypeDef {
Compilation,
Soundtrack,
Spokenword,
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
}
}
// #[cfg(test)]
// mod tests {
// use mockall::{predicate, Sequence};
// use crate::collection::album::AlbumId;
// use super::*;
// #[test]
// fn errors() {
// 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());
// }
// #[test]
// fn lookup_artist_release_group() {
// let mut client = MockIMusicBrainzClient::new();
// let url = format!(
// "https://musicbrainz.org/ws/2/artist/{mbid}?inc=release-groups",
// mbid = "00000000-0000-0000-0000-000000000000",
// );
// 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],
// };
// client
// .expect_get()
// .times(1)
// .with(predicate::eq(url))
// .return_once(|_| Ok(response));
// let mut api = MusicBrainz::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]
// fn search_release_group() {
// let mut client = MockIMusicBrainzClient::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 = MusicBrainz::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 = MockIMusicBrainzClient::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 = MusicBrainz::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 serde() {
// let primary_type = "\"EP\"";
// let primary_type: SerdeAlbumPrimaryType = serde_json::from_str(primary_type).unwrap();
// let primary_type: AlbumPrimaryType = primary_type.into();
// assert_eq!(primary_type, AlbumPrimaryType::Ep);
// let secondary_type = "\"Field recording\"";
// let secondary_type: SerdeAlbumSecondaryType = serde_json::from_str(secondary_type).unwrap();
// let secondary_type: AlbumSecondaryType = secondary_type.into();
// assert_eq!(secondary_type, AlbumSecondaryType::FieldRecording);
// }
// }

View File

@ -16,7 +16,7 @@ use musichoard::{
executor::{ssh::BeetsLibrarySshExecutor, BeetsLibraryProcessExecutor}, executor::{ssh::BeetsLibrarySshExecutor, BeetsLibraryProcessExecutor},
BeetsLibrary, BeetsLibrary,
}, },
musicbrainz::api::{client::MusicBrainzApiClient, MusicBrainzApi}, musicbrainz::{http::MusicBrainzHttp, MusicBrainzClient},
}, },
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::{
app::{
machine::{App, AppInner, AppMachine}, machine::{App, AppInner, AppMachine},
AppPublic, AppPublicMatches, AppState, IAppInteractMatches, WidgetState, 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;

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

@ -0,0 +1,291 @@
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
use std::num;
use musichoard::{
collection::{
album::{Album, AlbumDate},
musicbrainz::MbAlbumRef,
},
external::musicbrainz::{IMusicBrainzHttp, MusicBrainzClient, SearchReleaseGroup},
interface::musicbrainz::Mbid,
};
use crate::tui::lib::interface::musicbrainz::{Error, IMusicBrainz, Match};
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> {
self.client
.search_release_group(arid, album)?
.release_groups
.into_iter()
.map(from_search_release_group)
.collect()
}
}
impl From<musichoard::external::musicbrainz::Error> for Error {
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(
entity.title,
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))
}
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)]
// mod tests {
// use mockall::{predicate, Sequence};
// use crate::collection::album::AlbumId;
// use super::*;
// #[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 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",
// );
// 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],
// };
// // 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);
// }
// #[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::<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!(
// AlbumDate::from_mb_date("1984-05").unwrap(),
// (1984, 5).into()
// );
// assert_eq!(
// AlbumDate::from_mb_date("1984-05-18").unwrap(),
// (1984, 5, 18).into()
// );
// assert!(AlbumDate::from_mb_date("1984-get-rekt").is_err());
// }
// #[test]
// fn serde() {
// let primary_type = "\"EP\"";
// let primary_type: SerdeAlbumPrimaryType = serde_json::from_str(primary_type).unwrap();
// let primary_type: AlbumPrimaryType = primary_type.into();
// assert_eq!(primary_type, AlbumPrimaryType::Ep);
// let secondary_type = "\"Field recording\"";
// let secondary_type: SerdeAlbumSecondaryType = serde_json::from_str(secondary_type).unwrap();
// let secondary_type: AlbumSecondaryType = secondary_type.into();
// assert_eq!(secondary_type, AlbumSecondaryType::FieldRecording);
// }
// }

View File

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

View File

@ -0,0 +1,56 @@
//! Module for accessing MusicBrainz metadata.
use std::fmt;
#[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 }
}
}
#[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}"),
}
}
}

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

@ -9,6 +9,7 @@ pub use app::App;
pub use event::EventChannel; pub use event::EventChannel;
pub use handler::EventHandler; pub use handler::EventHandler;
pub use listener::EventListener; pub use listener::EventListener;
pub use lib::external::musicbrainz::MusicBrainz;
pub use ui::Ui; pub use ui::Ui;
use crossterm::{ use crossterm::{
@ -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;