From f4a52a4edc42bc3d99a9339fb34289c6a42152ae Mon Sep 17 00:00:00 2001 From: Wojciech Kozlowski Date: Tue, 27 Aug 2024 22:43:04 +0200 Subject: [PATCH] A functional state --- Cargo.toml | 2 +- src/core/interface/musicbrainz/mod.rs | 165 +------ src/external/mod.rs | 1 + src/external/musicbrainz/api/mod.rs | 441 ------------------ .../musicbrainz/{api/client.rs => http.rs} | 20 +- src/external/musicbrainz/mod.rs | 393 +++++++++++++++- src/main.rs | 13 +- src/tui/app/machine/browse.rs | 15 +- src/tui/app/machine/matches.rs | 11 +- src/tui/app/machine/mod.rs | 10 +- src/tui/app/mod.rs | 7 +- src/tui/lib.rs | 74 --- src/tui/lib/external/mod.rs | 1 + src/tui/lib/external/musicbrainz/mod.rs | 291 ++++++++++++ src/tui/lib/interface/mod.rs | 1 + src/tui/lib/interface/musicbrainz/mod.rs | 56 +++ src/tui/lib/mod.rs | 34 ++ src/tui/mod.rs | 3 +- src/tui/ui.rs | 20 +- 19 files changed, 849 insertions(+), 709 deletions(-) delete mode 100644 src/external/musicbrainz/api/mod.rs rename src/external/musicbrainz/{api/client.rs => http.rs} (64%) delete mode 100644 src/tui/lib.rs create mode 100644 src/tui/lib/external/mod.rs create mode 100644 src/tui/lib/external/musicbrainz/mod.rs create mode 100644 src/tui/lib/interface/mod.rs create mode 100644 src/tui/lib/interface/musicbrainz/mod.rs create mode 100644 src/tui/lib/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 52c31d0..0ec7b8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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]] diff --git a/src/core/interface/musicbrainz/mod.rs b/src/core/interface/musicbrainz/mod.rs index 9fe82e4..d586e23 100644 --- a/src/core/interface/musicbrainz/mod.rs +++ b/src/core/interface/musicbrainz/mod.rs @@ -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, Error>; - fn search_release_group( - &mut self, - arid: &Mbid, - album: &Album, - ) -> Result>, Error>; -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match { - pub score: u8, - pub item: T, -} - -impl Match { - 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, Error> { - Ok(vec![]) - } - - fn search_release_group( - &mut self, - _arid: &Mbid, - _album: &Album, - ) -> Result>, Error> { - Ok(vec![]) - } -} - -/// The MusicBrainz ID. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Mbid(Uuid); @@ -62,10 +17,25 @@ impl From 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 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 { 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 for Error { - fn from(value: uuid::Error) -> Self { - Error::MbidParse(value.to_string()) - } -} - -impl From 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::::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::().unwrap_err().into(); - assert!(!parse_err.to_string().is_empty()); - assert!(!format!("{parse_err:?}").is_empty()); - } + +#[test] +fn errors() { + let mbid_err: MbidError = TryInto::::try_into("i-am-not-a-uuid").unwrap_err(); + assert!(!mbid_err.to_string().is_empty()); + assert!(!format!("{mbid_err:?}").is_empty()); } diff --git a/src/external/mod.rs b/src/external/mod.rs index 6becfd7..5087a8d 100644 --- a/src/external/mod.rs +++ b/src/external/mod.rs @@ -1,3 +1,4 @@ pub mod database; pub mod library; +#[cfg(feature = "musicbrainz")] pub mod musicbrainz; diff --git a/src/external/musicbrainz/api/mod.rs b/src/external/musicbrainz/api/mod.rs deleted file mode 100644 index b2ad0a5..0000000 --- a/src/external/musicbrainz/api/mod.rs +++ /dev/null @@ -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(&mut self, url: &str) -> Result; -} - -#[derive(Debug)] -pub enum ClientError { - Client(String), - Status(u16), -} - -impl From 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 { - client: Mbc, -} - -impl MusicBrainzApi { - pub fn new(client: Mbc) -> Self { - MusicBrainzApi { client } - } -} - -impl IMusicBrainz for MusicBrainzApi { - fn lookup_artist_release_groups(&mut self, mbid: &Mbid) -> Result, 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>, 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, -} - -#[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, -} - -impl TryFrom for Album { - type Error = Error; - - fn try_from(entity: LookupReleaseGroup) -> Result { - 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, -} - -#[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>, -} - -impl TryFrom for Match { - type Error = Error; - - fn try_from(entity: SearchReleaseGroup) -> Result { - 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 { - 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 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 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::() - .times(1) - .return_once(|_| Err(ClientError::Client(String::from("get rekt scrub")))); - - client - .expect_get::() - .times(1) - .return_once(|_| Err(ClientError::Status(503))); - - client - .expect_get::() - .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); - } -} diff --git a/src/external/musicbrainz/api/client.rs b/src/external/musicbrainz/http.rs similarity index 64% rename from src/external/musicbrainz/api/client.rs rename to src/external/musicbrainz/http.rs index deb405b..c1ffc5d 100644 --- a/src/external/musicbrainz/api/client.rs +++ b/src/external/musicbrainz/http.rs @@ -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 { +impl MusicBrainzHttp { + pub fn new(user_agent: &'static str) -> Result { 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(&mut self, url: &str) -> Result { +impl IMusicBrainzHttp for MusicBrainzHttp { + fn get(&mut self, url: &str) -> Result { 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 for ClientError { +impl From for HttpError { fn from(err: reqwest::Error) -> Self { - ClientError::Client(err.to_string()) + HttpError::Client(err.to_string()) } } // GRCOV_EXCL_STOP diff --git a/src/external/musicbrainz/mod.rs b/src/external/musicbrainz/mod.rs index 98e2d12..81acb72 100644 --- a/src/external/musicbrainz/mod.rs +++ b/src/external/musicbrainz/mod.rs @@ -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(&mut self, url: &str) -> Result; +} + +#[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 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, +} + +impl MusicBrainzClient { + pub fn new(http: Http) -> Self { + MusicBrainzClient { http } + } +} + +impl MusicBrainzClient { + pub fn lookup_artist_release_groups( + &mut self, + mbid: &Mbid, + ) -> Result { + 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 { + 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, +} + +#[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, // TODO: Change to Vec +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all(deserialize = "kebab-case"))] +pub struct ResponseSearchReleaseGroup { + pub release_groups: Vec, +} + +#[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>, // TODO: Change to Vec +} + +#[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 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 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::() +// .times(1) +// .return_once(|_| Err(ClientError::Client(String::from("get rekt scrub")))); + +// client +// .expect_get::() +// .times(1) +// .return_once(|_| Err(ClientError::Status(503))); + +// client +// .expect_get::() +// .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); +// } +// } diff --git a/src/main.rs b/src/main.rs index ffb82f6..9cf8046 100644 --- a/src/main.rs +++ b/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( 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. diff --git a/src/tui/app/machine/browse.rs b/src/tui/app/machine/browse.rs index e86ec9a..e0f0ec9 100644 --- a/src/tui/app/machine/browse.rs +++ b/src/tui/app/machine/browse.rs @@ -105,7 +105,7 @@ impl IAppInteractBrowse for AppMachine { 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 { #[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>, musicbrainz::api::Error> = Ok(matches_1.clone()); - let result_4: Result>, musicbrainz::api::Error> = Ok(matches_4.clone()); + let result_1: Result>, musicbrainz::Error> = Ok(matches_1.clone()); + let result_4: Result>, 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() diff --git a/src/tui/app/machine/matches.rs b/src/tui/app/machine/matches.rs index 4c2eabf..ee1e5df 100644 --- a/src/tui/app/machine/matches.rs +++ b/src/tui/app/machine/matches.rs @@ -1,10 +1,13 @@ use std::cmp; -use musichoard::{collection::album::Album, interface::musicbrainz::Match}; +use musichoard::collection::album::Album; -use crate::tui::app::{ - machine::{App, AppInner, AppMachine}, - AppPublic, AppPublicMatches, AppState, IAppInteractMatches, WidgetState, +use crate::tui::{ + app::{ + machine::{App, AppInner, AppMachine}, + AppPublic, AppPublicMatches, AppState, IAppInteractMatches, WidgetState, + }, + lib::interface::musicbrainz::Match, }; #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/src/tui/app/machine/mod.rs b/src/tui/app/machine/mod.rs index c3a1185..f138f67 100644 --- a/src/tui/app/machine/mod.rs +++ b/src/tui/app/machine/mod.rs @@ -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 { pub struct AppInner { running: bool, music_hoard: Box, - mb_api: Box, + musicbrainz: Box, selection: Selection, } @@ -121,12 +121,12 @@ impl IAppAccess for App { } impl AppInner { - pub fn new(music_hoard: Box, mb_api: Box) -> Self { + pub fn new(music_hoard: Box, musicbrainz: Box) -> 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::*; diff --git a/src/tui/app/mod.rs b/src/tui/app/mod.rs index ea18f9b..224cbc9 100644 --- a/src/tui/app/mod.rs +++ b/src/tui/app/mod.rs @@ -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 { Browse(BS), diff --git a/src/tui/lib.rs b/src/tui/lib.rs deleted file mode 100644 index 9696999..0000000 --- a/src/tui/lib.rs +++ /dev/null @@ -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 IMusicHoard - for MusicHoard -{ - fn rescan_library(&mut self) -> Result<(), musichoard::Error> { - ::rescan_library(self) - } - - fn reload_database(&mut self) -> Result<(), musichoard::Error> { - ::reload_database(self) - } - - fn get_collection(&self) -> &Collection { - ::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 = interface::musicbrainz::Match; - 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>, Error>; - } - - // GRCOV_EXCL_START - impl IMusicBrainz for MusicBrainzApi { - fn search_release_group( - &mut self, - arid: &Mbid, - album: &Album, - ) -> Result>, Error> { - ::search_release_group( - self, arid, album, - ) - } - } - // GRCOV_EXCL_STOP - } - } -} diff --git a/src/tui/lib/external/mod.rs b/src/tui/lib/external/mod.rs new file mode 100644 index 0000000..c33a79a --- /dev/null +++ b/src/tui/lib/external/mod.rs @@ -0,0 +1 @@ +pub mod musicbrainz; diff --git a/src/tui/lib/external/musicbrainz/mod.rs b/src/tui/lib/external/musicbrainz/mod.rs new file mode 100644 index 0000000..49269ef --- /dev/null +++ b/src/tui/lib/external/musicbrainz/mod.rs @@ -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 { + client: MusicBrainzClient, +} + +impl MusicBrainz { + pub fn new(client: MusicBrainzClient) -> Self { + MusicBrainz { client } + } +} + +impl IMusicBrainz for MusicBrainz { + fn search_release_group( + &mut self, + arid: &Mbid, + album: &Album, + ) -> Result>, Error> { + self.client + .search_release_group(arid, album)? + .release_groups + .into_iter() + .map(from_search_release_group) + .collect() + } +} + +impl From 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, 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 for Error { + fn from(err: num::ParseIntError) -> Error { + Error::Parse(err.to_string()) + } +} + +fn from_mb_date(mb_date: &str) -> Result { + 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::() +// .times(1) +// .return_once(|_| Err(ClientError::Client(String::from("get rekt scrub")))); + +// client +// .expect_get::() +// .times(1) +// .return_once(|_| Err(ClientError::Status(503))); + +// client +// .expect_get::() +// .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); +// } +// } diff --git a/src/tui/lib/interface/mod.rs b/src/tui/lib/interface/mod.rs new file mode 100644 index 0000000..c33a79a --- /dev/null +++ b/src/tui/lib/interface/mod.rs @@ -0,0 +1 @@ +pub mod musicbrainz; diff --git a/src/tui/lib/interface/musicbrainz/mod.rs b/src/tui/lib/interface/musicbrainz/mod.rs new file mode 100644 index 0000000..949c319 --- /dev/null +++ b/src/tui/lib/interface/musicbrainz/mod.rs @@ -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>, Error>; +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match { + pub score: u8, + pub item: T, +} + +impl Match { + 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}"), + } + } +} diff --git a/src/tui/lib/mod.rs b/src/tui/lib/mod.rs new file mode 100644 index 0000000..31643b2 --- /dev/null +++ b/src/tui/lib/mod.rs @@ -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 IMusicHoard for MusicHoard { + fn rescan_library(&mut self) -> Result<(), musichoard::Error> { + ::rescan_library(self) + } + + fn reload_database(&mut self) -> Result<(), musichoard::Error> { + ::reload_database(self) + } + + fn get_collection(&self) -> &Collection { + ::get_collection(self) + } +} +// GRCOV_EXCL_STOP diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 66557ca..69b2ad3 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -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; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 15fbc17..545a4a1 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1,14 +1,11 @@ use std::collections::HashMap; -use musichoard::{ - collection::{ - album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus}, - artist::Artist, - musicbrainz::IMusicBrainzRef, - track::{Track, TrackFormat, TrackQuality}, - Collection, - }, - interface::musicbrainz::Match, +use musichoard::collection::{ + album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus}, + artist::Artist, + musicbrainz::IMusicBrainzRef, + track::{Track, TrackFormat, TrackQuality}, + Collection, }; 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;