A functional state
This commit is contained in:
parent
b70499d8de
commit
f4a52a4edc
@ -33,7 +33,7 @@ bin = ["structopt"]
|
||||
database-json = ["serde", "serde_json"]
|
||||
library-beets = []
|
||||
library-beets-ssh = ["openssh", "tokio"]
|
||||
musicbrainz-api = ["reqwest", "serde", "serde_json"]
|
||||
musicbrainz = ["reqwest", "serde", "serde_json"]
|
||||
tui = ["aho-corasick", "crossterm", "once_cell", "ratatui"]
|
||||
|
||||
[[bin]]
|
||||
|
@ -1,52 +1,7 @@
|
||||
//! Module for accessing MusicBrainz metadata.
|
||||
|
||||
use std::{fmt, num};
|
||||
use std::fmt;
|
||||
|
||||
use uuid::{self, Uuid};
|
||||
|
||||
use crate::collection::album::Album;
|
||||
|
||||
/// Trait for interacting with the MusicBrainz API.
|
||||
pub trait IMusicBrainz {
|
||||
fn lookup_artist_release_groups(&mut self, mbid: &Mbid) -> Result<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)]
|
||||
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 {
|
||||
($from:ty) => {
|
||||
impl TryFrom<$from> for Mbid {
|
||||
type Error = Error;
|
||||
type Error = MbidError;
|
||||
|
||||
fn try_from(value: $from) -> Result<Self, Self::Error> {
|
||||
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);
|
||||
|
||||
/// 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]
|
||||
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!(!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
1
src/external/mod.rs
vendored
@ -1,3 +1,4 @@
|
||||
pub mod database;
|
||||
pub mod library;
|
||||
#[cfg(feature = "musicbrainz")]
|
||||
pub mod musicbrainz;
|
||||
|
441
src/external/musicbrainz/api/mod.rs
vendored
441
src/external/musicbrainz/api/mod.rs
vendored
@ -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);
|
||||
}
|
||||
}
|
@ -3,13 +3,13 @@
|
||||
use reqwest::{self, blocking::Client, header};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::external::musicbrainz::api::{ClientError, IMusicBrainzApiClient};
|
||||
use crate::external::musicbrainz::{HttpError, IMusicBrainzHttp};
|
||||
|
||||
// GRCOV_EXCL_START
|
||||
pub struct MusicBrainzApiClient(Client);
|
||||
pub struct MusicBrainzHttp(Client);
|
||||
|
||||
impl MusicBrainzApiClient {
|
||||
pub fn new(user_agent: &'static str) -> Result<Self, ClientError> {
|
||||
impl MusicBrainzHttp {
|
||||
pub fn new(user_agent: &'static str) -> Result<Self, HttpError> {
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.insert(
|
||||
header::USER_AGENT,
|
||||
@ -20,27 +20,27 @@ impl MusicBrainzApiClient {
|
||||
header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
|
||||
Ok(MusicBrainzApiClient(
|
||||
Ok(MusicBrainzHttp(
|
||||
Client::builder().default_headers(headers).build()?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl IMusicBrainzApiClient for MusicBrainzApiClient {
|
||||
fn get<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, ClientError> {
|
||||
impl IMusicBrainzHttp for MusicBrainzHttp {
|
||||
fn get<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, HttpError> {
|
||||
let response = self.0.get(url).send()?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(response.json()?)
|
||||
} else {
|
||||
Err(ClientError::Status(response.status().as_u16()))
|
||||
Err(HttpError::Status(response.status().as_u16()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for ClientError {
|
||||
impl From<reqwest::Error> for HttpError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
ClientError::Client(err.to_string())
|
||||
HttpError::Client(err.to_string())
|
||||
}
|
||||
}
|
||||
// GRCOV_EXCL_STOP
|
393
src/external/musicbrainz/mod.rs
vendored
393
src/external/musicbrainz/mod.rs
vendored
@ -1,2 +1,391 @@
|
||||
#[cfg(feature = "musicbrainz-api")]
|
||||
pub mod api;
|
||||
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_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);
|
||||
// }
|
||||
// }
|
||||
|
13
src/main.rs
13
src/main.rs
@ -16,7 +16,7 @@ use musichoard::{
|
||||
executor::{ssh::BeetsLibrarySshExecutor, BeetsLibraryProcessExecutor},
|
||||
BeetsLibrary,
|
||||
},
|
||||
musicbrainz::api::{client::MusicBrainzApiClient, MusicBrainzApi},
|
||||
musicbrainz::{http::MusicBrainzHttp, MusicBrainzClient},
|
||||
},
|
||||
interface::{
|
||||
database::{IDatabase, NullDatabase},
|
||||
@ -25,7 +25,7 @@ use musichoard::{
|
||||
MusicHoardBuilder, NoDatabase, NoLibrary,
|
||||
};
|
||||
|
||||
use tui::{App, EventChannel, EventHandler, EventListener, Tui, Ui};
|
||||
use tui::{App, EventChannel, EventHandler, EventListener, MusicBrainz, Tui, Ui};
|
||||
|
||||
const MUSICHOARD_HTTP_USER_AGENT: &str = concat!(
|
||||
"MusicHoard/",
|
||||
@ -83,11 +83,12 @@ fn with<Database: IDatabase + 'static, Library: ILibrary + 'static>(
|
||||
let listener = EventListener::new(channel.sender());
|
||||
let handler = EventHandler::new(channel.receiver());
|
||||
|
||||
let client = MusicBrainzApiClient::new(MUSICHOARD_HTTP_USER_AGENT)
|
||||
.expect("failed to initialise HTTP client");
|
||||
let api = Box::new(MusicBrainzApi::new(client));
|
||||
let http =
|
||||
MusicBrainzHttp::new(MUSICHOARD_HTTP_USER_AGENT).expect("failed to initialise HTTP client");
|
||||
let client = MusicBrainzClient::new(http);
|
||||
let musicbrainz = Box::new(MusicBrainz::new(client));
|
||||
|
||||
let app = App::new(music_hoard, api);
|
||||
let app = App::new(music_hoard, musicbrainz);
|
||||
let ui = Ui;
|
||||
|
||||
// Run the TUI application.
|
||||
|
@ -105,7 +105,7 @@ impl IAppInteractBrowse for AppMachine<AppBrowse> {
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.inner.mb_api.search_release_group(arid, album) {
|
||||
match self.inner.musicbrainz.search_release_group(arid, album) {
|
||||
Ok(matches) => artist_album_matches.push(AppMatchesInfo {
|
||||
matching: album.clone(),
|
||||
matches,
|
||||
@ -129,17 +129,14 @@ impl IAppInteractBrowse for AppMachine<AppBrowse> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mockall::{predicate, Sequence};
|
||||
use musichoard::collection::album::Album;
|
||||
use musichoard::{collection::album::Album, interface::musicbrainz::Mbid};
|
||||
|
||||
use crate::tui::{
|
||||
app::{
|
||||
machine::tests::{inner, inner_with_mb, music_hoard},
|
||||
Category, IAppAccess, IAppInteract, IAppInteractMatches,
|
||||
},
|
||||
lib::external::musicbrainz::{
|
||||
self,
|
||||
api::{Match, Mbid, MockIMusicBrainz},
|
||||
},
|
||||
lib::interface::musicbrainz::{self, Match, MockIMusicBrainz},
|
||||
testmod::COLLECTION,
|
||||
};
|
||||
|
||||
@ -230,8 +227,8 @@ mod tests {
|
||||
let matches_1 = vec![album_match_1_1.clone(), album_match_1_2.clone()];
|
||||
let matches_4 = vec![album_match_4_1.clone(), album_match_4_2.clone()];
|
||||
|
||||
let result_1: Result<Vec<Match<Album>>, musicbrainz::api::Error> = Ok(matches_1.clone());
|
||||
let result_4: Result<Vec<Match<Album>>, musicbrainz::api::Error> = Ok(matches_4.clone());
|
||||
let result_1: Result<Vec<Match<Album>>, musicbrainz::Error> = Ok(matches_1.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.
|
||||
let mut seq = Sequence::new();
|
||||
@ -300,7 +297,7 @@ mod tests {
|
||||
fn fetch_musicbrainz_api_error() {
|
||||
let mut mb_api = Box::new(MockIMusicBrainz::new());
|
||||
|
||||
let error = Err(musicbrainz::api::Error::RateLimit);
|
||||
let error = Err(musicbrainz::Error::RateLimit);
|
||||
|
||||
mb_api
|
||||
.expect_search_release_group()
|
||||
|
@ -1,10 +1,13 @@
|
||||
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},
|
||||
AppPublic, AppPublicMatches, AppState, IAppInteractMatches, WidgetState,
|
||||
},
|
||||
lib::interface::musicbrainz::Match,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
@ -8,7 +8,7 @@ mod search;
|
||||
|
||||
use crate::tui::{
|
||||
app::{selection::Selection, AppPublic, AppPublicInner, AppState, IAppAccess, IAppInteract},
|
||||
lib::{external::musicbrainz::api::IMusicBrainz, IMusicHoard},
|
||||
lib::{interface::musicbrainz::IMusicBrainz, IMusicHoard},
|
||||
};
|
||||
|
||||
use browse::AppBrowse;
|
||||
@ -37,7 +37,7 @@ pub struct AppMachine<STATE> {
|
||||
pub struct AppInner {
|
||||
running: bool,
|
||||
music_hoard: Box<dyn IMusicHoard>,
|
||||
mb_api: Box<dyn IMusicBrainz>,
|
||||
musicbrainz: Box<dyn IMusicBrainz>,
|
||||
selection: Selection,
|
||||
}
|
||||
|
||||
@ -121,12 +121,12 @@ impl IAppAccess for App {
|
||||
}
|
||||
|
||||
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());
|
||||
AppInner {
|
||||
running: true,
|
||||
music_hoard,
|
||||
mb_api,
|
||||
musicbrainz,
|
||||
selection,
|
||||
}
|
||||
}
|
||||
@ -147,7 +147,7 @@ mod tests {
|
||||
|
||||
use crate::tui::{
|
||||
app::{AppState, IAppInteract, IAppInteractBrowse},
|
||||
lib::{external::musicbrainz::api::MockIMusicBrainz, MockIMusicHoard},
|
||||
lib::{interface::musicbrainz::MockIMusicBrainz, MockIMusicHoard},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
@ -4,10 +4,9 @@ mod selection;
|
||||
pub use machine::App;
|
||||
pub use selection::{Category, Delta, Selection, WidgetState};
|
||||
|
||||
use musichoard::{
|
||||
collection::{album::Album, Collection},
|
||||
interface::musicbrainz::Match,
|
||||
};
|
||||
use musichoard::collection::{album::Album, Collection};
|
||||
|
||||
use crate::tui::lib::interface::musicbrainz::Match;
|
||||
|
||||
pub enum AppState<BS, IS, RS, SS, MS, ES, CS> {
|
||||
Browse(BS),
|
||||
|
@ -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
1
src/tui/lib/external/mod.rs
vendored
Normal file
@ -0,0 +1 @@
|
||||
pub mod musicbrainz;
|
291
src/tui/lib/external/musicbrainz/mod.rs
vendored
Normal file
291
src/tui/lib/external/musicbrainz/mod.rs
vendored
Normal 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);
|
||||
// }
|
||||
// }
|
1
src/tui/lib/interface/mod.rs
Normal file
1
src/tui/lib/interface/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod musicbrainz;
|
56
src/tui/lib/interface/musicbrainz/mod.rs
Normal file
56
src/tui/lib/interface/musicbrainz/mod.rs
Normal 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
34
src/tui/lib/mod.rs
Normal 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
|
@ -9,6 +9,7 @@ pub use app::App;
|
||||
pub use event::EventChannel;
|
||||
pub use handler::EventHandler;
|
||||
pub use listener::EventListener;
|
||||
pub use lib::external::musicbrainz::MusicBrainz;
|
||||
pub use ui::Ui;
|
||||
|
||||
use crossterm::{
|
||||
@ -173,7 +174,7 @@ mod testmod;
|
||||
mod tests {
|
||||
use std::{io, thread};
|
||||
|
||||
use lib::external::musicbrainz::api::MockIMusicBrainz;
|
||||
use lib::interface::musicbrainz::MockIMusicBrainz;
|
||||
use ratatui::{backend::TestBackend, Terminal};
|
||||
|
||||
use musichoard::collection::Collection;
|
||||
|
@ -1,14 +1,11 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use musichoard::{
|
||||
collection::{
|
||||
use musichoard::collection::{
|
||||
album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus},
|
||||
artist::Artist,
|
||||
musicbrainz::IMusicBrainzRef,
|
||||
track::{Track, TrackFormat, TrackQuality},
|
||||
Collection,
|
||||
},
|
||||
interface::musicbrainz::Match,
|
||||
};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
@ -18,7 +15,10 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::tui::app::{AppPublicState, AppState, Category, IAppAccess, Selection, WidgetState};
|
||||
use crate::tui::{
|
||||
app::{AppPublicState, AppState, Category, IAppAccess, Selection, WidgetState},
|
||||
lib::interface::musicbrainz::Match,
|
||||
};
|
||||
|
||||
const COLOR_BG: Color = Color::Black;
|
||||
const COLOR_BG_HL: Color = Color::DarkGray;
|
||||
|
Loading…
Reference in New Issue
Block a user