Decide carefully where external::musicbrainz
belongs (#196)
Closes #193 Reviewed-on: #196
This commit is contained in:
parent
b70499d8de
commit
43961b3ea1
10
Cargo.toml
10
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]]
|
||||
@ -45,14 +45,14 @@ name = "musichoard-edit"
|
||||
required-features = ["bin", "database-json"]
|
||||
|
||||
[[example]]
|
||||
name = "musicbrainz-api---lookup-artist-release-groups"
|
||||
path = "examples/musicbrainz_api/lookup_artist_release_groups.rs"
|
||||
required-features = ["bin", "musicbrainz-api"]
|
||||
name = "musicbrainz-api---lookup-artist"
|
||||
path = "examples/musicbrainz_api/lookup_artist.rs"
|
||||
required-features = ["bin", "musicbrainz"]
|
||||
|
||||
[[example]]
|
||||
name = "musicbrainz-api---search-release-group"
|
||||
path = "examples/musicbrainz_api/search_release_group.rs"
|
||||
required-features = ["bin", "musicbrainz-api"]
|
||||
required-features = ["bin", "musicbrainz"]
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
|
42
examples/musicbrainz_api/lookup_artist.rs
Normal file
42
examples/musicbrainz_api/lookup_artist.rs
Normal file
@ -0,0 +1,42 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use musichoard::{
|
||||
external::musicbrainz::{
|
||||
api::{lookup::LookupArtistRequest, MusicBrainzClient},
|
||||
http::MusicBrainzHttp,
|
||||
},
|
||||
interface::musicbrainz::Mbid,
|
||||
};
|
||||
use structopt::StructOpt;
|
||||
use uuid::Uuid;
|
||||
|
||||
const USER_AGENT: &str = concat!(
|
||||
"MusicHoard---examples---musicbrainz-api---lookup-artist/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" ( musichoard@thenineworlds.net )"
|
||||
);
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct Opt {
|
||||
#[structopt(help = "Artist MBID to lookup")]
|
||||
mbid: Uuid,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let opt = Opt::from_args();
|
||||
|
||||
println!("USER_AGENT: {USER_AGENT}");
|
||||
|
||||
let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client");
|
||||
let mut client = MusicBrainzClient::new(http);
|
||||
|
||||
let mbid: Mbid = opt.mbid.into();
|
||||
let mut request = LookupArtistRequest::new(&mbid);
|
||||
request.include_release_groups();
|
||||
|
||||
let albums = client
|
||||
.lookup_artist(request)
|
||||
.expect("failed to make API call");
|
||||
|
||||
println!("{albums:#?}");
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use musichoard::{
|
||||
external::musicbrainz::api::{client::MusicBrainzApiClient, MusicBrainzApi},
|
||||
interface::musicbrainz::{IMusicBrainz, Mbid},
|
||||
};
|
||||
use structopt::StructOpt;
|
||||
use uuid::Uuid;
|
||||
|
||||
const USER_AGENT: &str = concat!(
|
||||
"MusicHoard---examples---musicbrainz-api---lookup-artist-release-groups/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" ( musichoard@thenineworlds.net )"
|
||||
);
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct Opt {
|
||||
#[structopt(help = "Artist MBID to lookup")]
|
||||
mbid: Uuid,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let opt = Opt::from_args();
|
||||
|
||||
println!("USER_AGENT: {USER_AGENT}");
|
||||
|
||||
let client = MusicBrainzApiClient::new(USER_AGENT).expect("failed to create API client");
|
||||
let mut api = MusicBrainzApi::new(client);
|
||||
|
||||
let mbid: Mbid = opt.mbid.into();
|
||||
let albums = api
|
||||
.lookup_artist_release_groups(&mbid)
|
||||
.expect("failed to make API call");
|
||||
|
||||
println!("{albums:#?}");
|
||||
}
|
@ -3,9 +3,11 @@
|
||||
use std::{num::ParseIntError, str::FromStr};
|
||||
|
||||
use musichoard::{
|
||||
collection::album::{Album, AlbumDate, AlbumId},
|
||||
external::musicbrainz::api::{client::MusicBrainzApiClient, MusicBrainzApi},
|
||||
interface::musicbrainz::{IMusicBrainz, Mbid},
|
||||
collection::album::AlbumDate,
|
||||
external::musicbrainz::{
|
||||
api::search::SearchReleaseGroupRequest, api::MusicBrainzClient, http::MusicBrainzHttp,
|
||||
},
|
||||
interface::musicbrainz::Mbid,
|
||||
};
|
||||
use structopt::StructOpt;
|
||||
use uuid::Uuid;
|
||||
@ -18,16 +20,13 @@ const USER_AGENT: &str = concat!(
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct Opt {
|
||||
#[structopt(help = "Release group's artist MBID")]
|
||||
arid: Uuid,
|
||||
|
||||
#[structopt(subcommand)]
|
||||
command: OptCommand,
|
||||
}
|
||||
|
||||
#[derive(StructOpt)]
|
||||
enum OptCommand {
|
||||
#[structopt(about = "Search by title (and date)")]
|
||||
#[structopt(about = "Search by artist MBID, title(, and date)")]
|
||||
Title(OptTitle),
|
||||
#[structopt(about = "Search by release group MBID")]
|
||||
Rgid(OptRgid),
|
||||
@ -35,6 +34,9 @@ enum OptCommand {
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct OptTitle {
|
||||
#[structopt(help = "Release group's artist MBID")]
|
||||
arid: Uuid,
|
||||
|
||||
#[structopt(help = "Release group title")]
|
||||
title: String,
|
||||
|
||||
@ -80,30 +82,32 @@ fn main() {
|
||||
|
||||
println!("USER_AGENT: {USER_AGENT}");
|
||||
|
||||
let client = MusicBrainzApiClient::new(USER_AGENT).expect("failed to create API client");
|
||||
let mut api = MusicBrainzApi::new(client);
|
||||
let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client");
|
||||
let mut client = MusicBrainzClient::new(http);
|
||||
|
||||
let arid: Mbid = opt.arid.into();
|
||||
|
||||
let album = match opt.command {
|
||||
let mut request = SearchReleaseGroupRequest::default();
|
||||
let arid: Mbid;
|
||||
let date: AlbumDate;
|
||||
let title: String;
|
||||
let rgid: Mbid;
|
||||
match opt.command {
|
||||
OptCommand::Title(opt_title) => {
|
||||
let date: AlbumDate = opt_title.date.map(Into::into).unwrap_or_default();
|
||||
Album::new(AlbumId::new(opt_title.title), date, None, vec![])
|
||||
arid = opt_title.arid.into();
|
||||
date = opt_title.date.map(Into::into).unwrap_or_default();
|
||||
title = opt_title.title;
|
||||
request
|
||||
.arid(&arid)
|
||||
.first_release_date(&date)
|
||||
.release_group(&title);
|
||||
}
|
||||
OptCommand::Rgid(opt_rgid) => {
|
||||
let mut album = Album::new(
|
||||
AlbumId::new(String::default()),
|
||||
AlbumDate::default(),
|
||||
None,
|
||||
vec![],
|
||||
);
|
||||
album.set_musicbrainz_ref(opt_rgid.rgid.into());
|
||||
album
|
||||
rgid = opt_rgid.rgid.into();
|
||||
request.rgid(&rgid);
|
||||
}
|
||||
};
|
||||
|
||||
let matches = api
|
||||
.search_release_group(&arid, &album)
|
||||
let matches = client
|
||||
.search_release_group(request)
|
||||
.expect("failed to make API call");
|
||||
|
||||
println!("{matches:#?}");
|
||||
|
@ -51,7 +51,13 @@ macro_rules! impl_imusicbrainzref {
|
||||
|
||||
impl From<Uuid> for $mbref {
|
||||
fn from(uuid: Uuid) -> Self {
|
||||
$mbref(MusicBrainzRef::from_uuid(uuid, $mbref::entity()))
|
||||
$mbref(MusicBrainzRef::from_mbid(uuid, $mbref::entity()))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Mbid> for $mbref {
|
||||
fn from(mbid: Mbid) -> Self {
|
||||
$mbref(MusicBrainzRef::from_mbid(mbid, $mbref::entity()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,9 +104,9 @@ impl MusicBrainzRef {
|
||||
Ok(MusicBrainzRef { mbid, url })
|
||||
}
|
||||
|
||||
fn from_uuid(uuid: Uuid, entity: &'static str) -> Self {
|
||||
let uuid_str = uuid.to_string();
|
||||
let mbid = uuid.into();
|
||||
fn from_mbid<ID: Into<Mbid>>(id: ID, entity: &'static str) -> Self {
|
||||
let mbid = id.into();
|
||||
let uuid_str = mbid.uuid().to_string();
|
||||
let url = Url::parse(&format!("https://{MB_DOMAIN}/{entity}/{uuid_str}")).unwrap();
|
||||
MusicBrainzRef { mbid, url }
|
||||
}
|
||||
@ -127,6 +133,11 @@ mod tests {
|
||||
assert_eq!(url_str, mb.url().as_ref());
|
||||
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||
|
||||
let mbid: Mbid = TryInto::<Uuid>::try_into(uuid).unwrap().into();
|
||||
let mb: MbArtistRef = mbid.into();
|
||||
assert_eq!(url_str, mb.url().as_ref());
|
||||
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||
|
||||
let url: Url = url_str.as_str().try_into().unwrap();
|
||||
let mb: MbArtistRef = url.try_into().unwrap();
|
||||
assert_eq!(url_str, mb.url().as_ref());
|
||||
@ -146,6 +157,11 @@ mod tests {
|
||||
assert_eq!(url_str, mb.url().as_ref());
|
||||
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||
|
||||
let mbid: Mbid = TryInto::<Uuid>::try_into(uuid).unwrap().into();
|
||||
let mb: MbAlbumRef = mbid.into();
|
||||
assert_eq!(url_str, mb.url().as_ref());
|
||||
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||
|
||||
let url: Url = url_str.as_str().try_into().unwrap();
|
||||
let mb: MbAlbumRef = url.try_into().unwrap();
|
||||
assert_eq!(url_str, mb.url().as_ref());
|
||||
|
@ -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, "failed to parse a MBID: {}", 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,9 @@ 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();
|
||||
#[test]
|
||||
fn errors() {
|
||||
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;
|
||||
|
164
src/external/musicbrainz/api/lookup.rs
vendored
Normal file
164
src/external/musicbrainz/api/lookup.rs
vendored
Normal file
@ -0,0 +1,164 @@
|
||||
use serde::Deserialize;
|
||||
use url::form_urlencoded;
|
||||
|
||||
use crate::{
|
||||
collection::album::{AlbumDate, AlbumPrimaryType, AlbumSecondaryType},
|
||||
external::musicbrainz::{
|
||||
api::{
|
||||
Error, MusicBrainzClient, SerdeAlbumDate, SerdeAlbumPrimaryType,
|
||||
SerdeAlbumSecondaryType, SerdeMbid, MB_BASE_URL,
|
||||
},
|
||||
IMusicBrainzHttp,
|
||||
},
|
||||
interface::musicbrainz::Mbid,
|
||||
};
|
||||
|
||||
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
|
||||
pub fn lookup_artist(
|
||||
&mut self,
|
||||
request: LookupArtistRequest,
|
||||
) -> Result<LookupArtistResponse, Error> {
|
||||
let mut include: Vec<String> = vec![];
|
||||
|
||||
if request.release_groups {
|
||||
include.push(String::from("release-groups"));
|
||||
}
|
||||
|
||||
let include: String =
|
||||
form_urlencoded::byte_serialize(include.join("+").as_bytes()).collect();
|
||||
let url = format!(
|
||||
"{MB_BASE_URL}/artist/{mbid}?inc={include}",
|
||||
mbid = request.mbid.uuid().as_hyphenated()
|
||||
);
|
||||
|
||||
let response: DeserializeLookupArtistResponse = self.http.get(&url)?;
|
||||
Ok(response.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LookupArtistRequest<'a> {
|
||||
mbid: &'a Mbid,
|
||||
release_groups: bool,
|
||||
}
|
||||
|
||||
impl<'a> LookupArtistRequest<'a> {
|
||||
pub fn new(mbid: &'a Mbid) -> Self {
|
||||
LookupArtistRequest {
|
||||
mbid,
|
||||
release_groups: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn include_release_groups(&mut self) -> &mut Self {
|
||||
self.release_groups = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct LookupArtistResponse {
|
||||
pub release_groups: Vec<LookupArtistResponseReleaseGroup>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
struct DeserializeLookupArtistResponse {
|
||||
release_groups: Vec<DeserializeLookupArtistResponseReleaseGroup>,
|
||||
}
|
||||
|
||||
impl From<DeserializeLookupArtistResponse> for LookupArtistResponse {
|
||||
fn from(value: DeserializeLookupArtistResponse) -> Self {
|
||||
LookupArtistResponse {
|
||||
release_groups: value.release_groups.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct LookupArtistResponseReleaseGroup {
|
||||
pub id: Mbid,
|
||||
pub title: String,
|
||||
pub first_release_date: AlbumDate,
|
||||
pub primary_type: AlbumPrimaryType,
|
||||
pub secondary_types: Vec<AlbumSecondaryType>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
struct DeserializeLookupArtistResponseReleaseGroup {
|
||||
id: SerdeMbid,
|
||||
title: String,
|
||||
first_release_date: SerdeAlbumDate,
|
||||
primary_type: SerdeAlbumPrimaryType,
|
||||
secondary_types: Vec<SerdeAlbumSecondaryType>,
|
||||
}
|
||||
|
||||
impl From<DeserializeLookupArtistResponseReleaseGroup> for LookupArtistResponseReleaseGroup {
|
||||
fn from(value: DeserializeLookupArtistResponseReleaseGroup) -> Self {
|
||||
LookupArtistResponseReleaseGroup {
|
||||
id: value.id.into(),
|
||||
title: value.title,
|
||||
first_release_date: value.first_release_date.into(),
|
||||
primary_type: value.primary_type.into(),
|
||||
secondary_types: value.secondary_types.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mockall::predicate;
|
||||
|
||||
use crate::external::musicbrainz::MockIMusicBrainzHttp;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lookup_artist() {
|
||||
let mut http = MockIMusicBrainzHttp::new();
|
||||
let url = format!(
|
||||
"https://musicbrainz.org/ws/2/artist/{mbid}?inc=release-groups",
|
||||
mbid = "00000000-0000-0000-0000-000000000000",
|
||||
);
|
||||
|
||||
let de_release_group = DeserializeLookupArtistResponseReleaseGroup {
|
||||
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||
title: String::from("an album"),
|
||||
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
||||
primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album),
|
||||
secondary_types: vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Compilation)],
|
||||
};
|
||||
let de_response = DeserializeLookupArtistResponse {
|
||||
release_groups: vec![de_release_group.clone()],
|
||||
};
|
||||
|
||||
let release_group = LookupArtistResponseReleaseGroup {
|
||||
id: de_release_group.id.0,
|
||||
title: de_release_group.title,
|
||||
first_release_date: de_release_group.first_release_date.0,
|
||||
primary_type: de_release_group.primary_type.0,
|
||||
secondary_types: de_release_group
|
||||
.secondary_types
|
||||
.into_iter()
|
||||
.map(|st| st.0)
|
||||
.collect(),
|
||||
};
|
||||
let response = LookupArtistResponse {
|
||||
release_groups: vec![release_group],
|
||||
};
|
||||
|
||||
http.expect_get()
|
||||
.times(1)
|
||||
.with(predicate::eq(url))
|
||||
.return_once(|_| Ok(de_response));
|
||||
|
||||
let mut client = MusicBrainzClient::new(http);
|
||||
|
||||
let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
|
||||
let mut request = LookupArtistRequest::new(&mbid);
|
||||
request.include_release_groups();
|
||||
let result = client.lookup_artist(request).unwrap();
|
||||
|
||||
assert_eq!(result, response);
|
||||
}
|
||||
}
|
504
src/external/musicbrainz/api/mod.rs
vendored
504
src/external/musicbrainz/api/mod.rs
vendored
@ -1,40 +1,43 @@
|
||||
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
|
||||
use std::{fmt, num};
|
||||
|
||||
pub mod client;
|
||||
use serde::{de::Visitor, Deserialize, Deserializer};
|
||||
|
||||
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},
|
||||
use crate::{
|
||||
collection::album::{AlbumDate, AlbumPrimaryType, AlbumSecondaryType},
|
||||
external::musicbrainz::HttpError,
|
||||
interface::musicbrainz::{Mbid, MbidError},
|
||||
};
|
||||
|
||||
pub mod lookup;
|
||||
pub mod search;
|
||||
|
||||
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, 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),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClientError {
|
||||
Client(String),
|
||||
Status(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<ClientError> for Error {
|
||||
fn from(err: ClientError) -> Self {
|
||||
impl From<HttpError> for Error {
|
||||
fn from(err: HttpError) -> Self {
|
||||
match err {
|
||||
ClientError::Client(s) => Error::Client(s),
|
||||
ClientError::Status(status) => match status {
|
||||
HttpError::Client(s) => Error::Http(s),
|
||||
HttpError::Status(status) => match status {
|
||||
MB_RATE_LIMIT_CODE => Error::RateLimit,
|
||||
_ => Error::Unknown(status),
|
||||
},
|
||||
@ -42,159 +45,125 @@ impl From<ClientError> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MusicBrainzApi<Mbc> {
|
||||
client: Mbc,
|
||||
pub struct MusicBrainzClient<Http> {
|
||||
http: Http,
|
||||
}
|
||||
|
||||
impl<Mbc> MusicBrainzApi<Mbc> {
|
||||
pub fn new(client: Mbc) -> Self {
|
||||
MusicBrainzApi { client }
|
||||
impl<Http> MusicBrainzClient<Http> {
|
||||
pub fn new(http: Http) -> Self {
|
||||
MusicBrainzClient { http }
|
||||
}
|
||||
|
||||
fn format_album_date(date: &AlbumDate) -> Option<String> {
|
||||
match date.year {
|
||||
Some(year) => match date.month {
|
||||
Some(month) => match date.day {
|
||||
Some(day) => Some(format!("{year}-{month:02}-{day:02}")),
|
||||
None => Some(format!("{year}-{month:02}")),
|
||||
},
|
||||
None => Some(format!("{year}")),
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SerdeMbid(Mbid);
|
||||
|
||||
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()
|
||||
impl From<SerdeMbid> for Mbid {
|
||||
fn from(value: SerdeMbid) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
struct ResponseLookupArtist {
|
||||
release_groups: Vec<LookupReleaseGroup>,
|
||||
}
|
||||
struct SerdeMbidVisitor;
|
||||
|
||||
#[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<'de> Visitor<'de> for SerdeMbidVisitor {
|
||||
type Value = SerdeMbid;
|
||||
|
||||
impl TryFrom<LookupReleaseGroup> for Album {
|
||||
type Error = Error;
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a valid MusicBrainz identifier")
|
||||
}
|
||||
|
||||
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)
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(SerdeMbid(
|
||||
v.try_into()
|
||||
.map_err(|e: MbidError| E::custom(e.to_string()))?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[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<'de> Deserialize<'de> for SerdeMbid {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_str(SerdeMbidVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
impl AlbumDate {
|
||||
fn from_mb_date(mb_date: &str) -> Result<AlbumDate, Error> {
|
||||
let mut elems = mb_date.split('-');
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SerdeAlbumDate(AlbumDate);
|
||||
|
||||
impl From<SerdeAlbumDate> for AlbumDate {
|
||||
fn from(value: SerdeAlbumDate) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
struct SerdeAlbumDateVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for SerdeAlbumDateVisitor {
|
||||
type Value = SerdeAlbumDate;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a valid YYYY(-MM-(-DD)) date")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
let mut elems = v.split('-');
|
||||
|
||||
let elem = elems.next();
|
||||
let year = elem
|
||||
.and_then(|s| if s.is_empty() { None } else { Some(s.parse()) })
|
||||
.transpose()?;
|
||||
.transpose()
|
||||
.map_err(|e: num::ParseIntError| E::custom(e.to_string()))?;
|
||||
|
||||
let elem = elems.next();
|
||||
let month = elem.map(|s| s.parse()).transpose()?;
|
||||
let month = elem
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e: num::ParseIntError| E::custom(e.to_string()))?;
|
||||
|
||||
let elem = elems.next();
|
||||
let day = elem.map(|s| s.parse()).transpose()?;
|
||||
let day = elem
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e: num::ParseIntError| E::custom(e.to_string()))?;
|
||||
|
||||
Ok(AlbumDate::new(year, month, day))
|
||||
Ok(SerdeAlbumDate(AlbumDate::new(year, month, day)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SerdeAlbumDate {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_str(SerdeAlbumDateVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(remote = "AlbumPrimaryType")]
|
||||
pub enum SerdeAlbumPrimaryTypeDef {
|
||||
pub enum AlbumPrimaryTypeDef {
|
||||
Album,
|
||||
Single,
|
||||
#[serde(rename = "EP")]
|
||||
@ -204,7 +173,7 @@ pub enum SerdeAlbumPrimaryTypeDef {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SerdeAlbumPrimaryType(#[serde(with = "SerdeAlbumPrimaryTypeDef")] AlbumPrimaryType);
|
||||
pub struct SerdeAlbumPrimaryType(#[serde(with = "AlbumPrimaryTypeDef")] AlbumPrimaryType);
|
||||
|
||||
impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
|
||||
fn from(value: SerdeAlbumPrimaryType) -> Self {
|
||||
@ -214,7 +183,7 @@ impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(remote = "AlbumSecondaryType")]
|
||||
pub enum SerdeAlbumSecondaryTypeDef {
|
||||
pub enum AlbumSecondaryTypeDef {
|
||||
Compilation,
|
||||
Soundtrack,
|
||||
Spokenword,
|
||||
@ -234,9 +203,7 @@ pub enum SerdeAlbumSecondaryTypeDef {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SerdeAlbumSecondaryType(
|
||||
#[serde(with = "SerdeAlbumSecondaryTypeDef")] AlbumSecondaryType,
|
||||
);
|
||||
pub struct SerdeAlbumSecondaryType(#[serde(with = "AlbumSecondaryTypeDef")] AlbumSecondaryType);
|
||||
|
||||
impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
|
||||
fn from(value: SerdeAlbumSecondaryType) -> Self {
|
||||
@ -246,188 +213,89 @@ impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
|
||||
|
||||
#[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",
|
||||
);
|
||||
fn errors() {
|
||||
let http_err = HttpError::Client(String::from("a http error"));
|
||||
let http_err: Error = http_err.into();
|
||||
assert!(matches!(http_err, Error::Http(_)));
|
||||
assert!(!http_err.to_string().is_empty());
|
||||
assert!(!format!("{http_err:?}").is_empty());
|
||||
|
||||
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],
|
||||
};
|
||||
let rate_err = HttpError::Status(MB_RATE_LIMIT_CODE);
|
||||
let rate_err: Error = rate_err.into();
|
||||
assert!(matches!(rate_err, Error::RateLimit));
|
||||
assert!(!rate_err.to_string().is_empty());
|
||||
assert!(!format!("{rate_err:?}").is_empty());
|
||||
|
||||
// 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);
|
||||
let unk_err = HttpError::Status(404);
|
||||
let unk_err: Error = unk_err.into();
|
||||
assert!(matches!(unk_err, Error::Unknown(_)));
|
||||
assert!(!unk_err.to_string().is_empty());
|
||||
assert!(!format!("{unk_err:?}").is_empty());
|
||||
}
|
||||
|
||||
#[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());
|
||||
fn format_album_date() {
|
||||
struct Null;
|
||||
assert_eq!(
|
||||
AlbumDate::from_mb_date("1984-05").unwrap(),
|
||||
(1984, 5).into()
|
||||
MusicBrainzClient::<Null>::format_album_date(&AlbumDate::new(None, None, None)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
AlbumDate::from_mb_date("1984-05-18").unwrap(),
|
||||
(1984, 5, 18).into()
|
||||
MusicBrainzClient::<Null>::format_album_date(&(1986).into()),
|
||||
Some(String::from("1986"))
|
||||
);
|
||||
assert_eq!(
|
||||
MusicBrainzClient::<Null>::format_album_date(&(1986, 4).into()),
|
||||
Some(String::from("1986-04"))
|
||||
);
|
||||
assert_eq!(
|
||||
MusicBrainzClient::<Null>::format_album_date(&(1986, 4, 21).into()),
|
||||
Some(String::from("1986-04-21"))
|
||||
);
|
||||
assert!(AlbumDate::from_mb_date("1984-get-rekt").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde() {
|
||||
let mbid = "\"d368baa8-21ca-4759-9731-0b2753071ad8\"";
|
||||
let mbid: SerdeMbid = serde_json::from_str(mbid).unwrap();
|
||||
let mbid: Mbid = mbid.into();
|
||||
assert_eq!(
|
||||
mbid,
|
||||
"d368baa8-21ca-4759-9731-0b2753071ad8".try_into().unwrap()
|
||||
);
|
||||
|
||||
let mbid = "0";
|
||||
let result: Result<SerdeMbid, _> = serde_json::from_str(mbid);
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("a valid MusicBrainz identifier"));
|
||||
|
||||
let album_date = "\"1986-04-21\"";
|
||||
let album_date: SerdeAlbumDate = serde_json::from_str(album_date).unwrap();
|
||||
let album_date: AlbumDate = album_date.into();
|
||||
assert_eq!(album_date, AlbumDate::new(Some(1986), Some(4), Some(21)));
|
||||
|
||||
let album_date = "\"1986-04\"";
|
||||
let album_date: SerdeAlbumDate = serde_json::from_str(album_date).unwrap();
|
||||
let album_date: AlbumDate = album_date.into();
|
||||
assert_eq!(album_date, AlbumDate::new(Some(1986), Some(4), None));
|
||||
|
||||
let album_date = "\"1986\"";
|
||||
let album_date: SerdeAlbumDate = serde_json::from_str(album_date).unwrap();
|
||||
let album_date: AlbumDate = album_date.into();
|
||||
assert_eq!(album_date, AlbumDate::new(Some(1986), None, None));
|
||||
|
||||
let album_date = "0";
|
||||
let result: Result<SerdeAlbumDate, _> = serde_json::from_str(album_date);
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("a valid YYYY(-MM-(-DD)) date"));
|
||||
|
||||
let primary_type = "\"EP\"";
|
||||
let primary_type: SerdeAlbumPrimaryType = serde_json::from_str(primary_type).unwrap();
|
||||
let primary_type: AlbumPrimaryType = primary_type.into();
|
||||
|
272
src/external/musicbrainz/api/search.rs
vendored
Normal file
272
src/external/musicbrainz/api/search.rs
vendored
Normal file
@ -0,0 +1,272 @@
|
||||
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
|
||||
|
||||
use serde::Deserialize;
|
||||
use url::form_urlencoded;
|
||||
|
||||
use crate::{
|
||||
collection::album::AlbumDate,
|
||||
core::{
|
||||
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
|
||||
interface::musicbrainz::Mbid,
|
||||
},
|
||||
external::musicbrainz::{
|
||||
api::{
|
||||
Error, MusicBrainzClient, SerdeAlbumDate, SerdeAlbumPrimaryType,
|
||||
SerdeAlbumSecondaryType, SerdeMbid, MB_BASE_URL,
|
||||
},
|
||||
IMusicBrainzHttp,
|
||||
},
|
||||
};
|
||||
|
||||
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
|
||||
pub fn search_release_group(
|
||||
&mut self,
|
||||
request: SearchReleaseGroupRequest,
|
||||
) -> Result<SearchReleaseGroupResponse, Error> {
|
||||
let mut query: Vec<String> = vec![];
|
||||
|
||||
if let Some(arid) = request.arid {
|
||||
query.push(format!("arid:{}", arid.uuid().as_hyphenated()));
|
||||
}
|
||||
|
||||
if let Some(date) = request.first_release_date {
|
||||
if let Some(date_string) = Self::format_album_date(date) {
|
||||
query.push(format!("firstreleasedate:{date_string}"))
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(release_group) = request.release_group {
|
||||
query.push(format!("releasegroup:\"{release_group}\""));
|
||||
}
|
||||
|
||||
if let Some(rgid) = request.rgid {
|
||||
query.push(format!("rgid:{}", rgid.uuid().as_hyphenated()));
|
||||
}
|
||||
|
||||
let query: String =
|
||||
form_urlencoded::byte_serialize(query.join(" AND ").as_bytes()).collect();
|
||||
let url = format!("{MB_BASE_URL}/release-group?query={query}");
|
||||
|
||||
let response: DeserializeSearchReleaseGroupResponse = self.http.get(&url)?;
|
||||
Ok(response.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SearchReleaseGroupRequest<'a> {
|
||||
arid: Option<&'a Mbid>,
|
||||
first_release_date: Option<&'a AlbumDate>,
|
||||
release_group: Option<&'a str>,
|
||||
rgid: Option<&'a Mbid>,
|
||||
}
|
||||
|
||||
impl<'a> SearchReleaseGroupRequest<'a> {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn arid(&mut self, arid: &'a Mbid) -> &mut Self {
|
||||
self.arid = Some(arid);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn first_release_date(&mut self, first_release_date: &'a AlbumDate) -> &mut Self {
|
||||
self.first_release_date = Some(first_release_date);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn release_group(&mut self, release_group: &'a str) -> &mut Self {
|
||||
self.release_group = Some(release_group);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn rgid(&mut self, rgid: &'a Mbid) -> &mut Self {
|
||||
self.rgid = Some(rgid);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct SearchReleaseGroupResponse {
|
||||
pub release_groups: Vec<SearchReleaseGroupResponseReleaseGroup>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
struct DeserializeSearchReleaseGroupResponse {
|
||||
release_groups: Vec<DeserializeSearchReleaseGroupResponseReleaseGroup>,
|
||||
}
|
||||
|
||||
impl From<DeserializeSearchReleaseGroupResponse> for SearchReleaseGroupResponse {
|
||||
fn from(value: DeserializeSearchReleaseGroupResponse) -> Self {
|
||||
SearchReleaseGroupResponse {
|
||||
release_groups: value.release_groups.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SearchReleaseGroupResponseReleaseGroup {
|
||||
pub score: u8,
|
||||
pub id: Mbid,
|
||||
pub title: String,
|
||||
pub first_release_date: AlbumDate,
|
||||
pub primary_type: AlbumPrimaryType,
|
||||
pub secondary_types: Option<Vec<AlbumSecondaryType>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
struct DeserializeSearchReleaseGroupResponseReleaseGroup {
|
||||
score: u8,
|
||||
id: SerdeMbid,
|
||||
title: String,
|
||||
first_release_date: SerdeAlbumDate,
|
||||
primary_type: SerdeAlbumPrimaryType,
|
||||
secondary_types: Option<Vec<SerdeAlbumSecondaryType>>,
|
||||
}
|
||||
|
||||
impl From<DeserializeSearchReleaseGroupResponseReleaseGroup>
|
||||
for SearchReleaseGroupResponseReleaseGroup
|
||||
{
|
||||
fn from(value: DeserializeSearchReleaseGroupResponseReleaseGroup) -> Self {
|
||||
SearchReleaseGroupResponseReleaseGroup {
|
||||
score: value.score,
|
||||
id: value.id.into(),
|
||||
title: value.title,
|
||||
first_release_date: value.first_release_date.into(),
|
||||
primary_type: value.primary_type.into(),
|
||||
secondary_types: value
|
||||
.secondary_types
|
||||
.map(|v| v.into_iter().map(Into::into).collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mockall::{predicate, Sequence};
|
||||
|
||||
use crate::{collection::album::AlbumId, external::musicbrainz::MockIMusicBrainzHttp};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn search_release_group() {
|
||||
let mut http = MockIMusicBrainzHttp::new();
|
||||
let url_title = format!(
|
||||
"https://musicbrainz.org/ws/2\
|
||||
/release-group\
|
||||
?query=arid%3A{arid}+AND+firstreleasedate%3A{date}+AND+releasegroup%3A%22{title}%22",
|
||||
arid = "00000000-0000-0000-0000-000000000000",
|
||||
date = "1986-04",
|
||||
title = "an+album",
|
||||
);
|
||||
let url_rgid = format!(
|
||||
"https://musicbrainz.org/ws/2\
|
||||
/release-group\
|
||||
?query=rgid%3A{rgid}",
|
||||
rgid = "11111111-1111-1111-1111-111111111111",
|
||||
);
|
||||
|
||||
let de_release_group = DeserializeSearchReleaseGroupResponseReleaseGroup {
|
||||
score: 67,
|
||||
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||
title: String::from("an album"),
|
||||
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
||||
primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album),
|
||||
secondary_types: Some(vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Live)]),
|
||||
};
|
||||
let de_response = DeserializeSearchReleaseGroupResponse {
|
||||
release_groups: vec![de_release_group.clone()],
|
||||
};
|
||||
|
||||
let release_group = SearchReleaseGroupResponseReleaseGroup {
|
||||
score: 67,
|
||||
id: de_release_group.id.0,
|
||||
title: de_release_group.title,
|
||||
first_release_date: de_release_group.first_release_date.0,
|
||||
primary_type: de_release_group.primary_type.0,
|
||||
secondary_types: de_release_group
|
||||
.secondary_types
|
||||
.map(|v| v.into_iter().map(|st| st.0).collect()),
|
||||
};
|
||||
let response = SearchReleaseGroupResponse {
|
||||
release_groups: vec![release_group.clone()],
|
||||
};
|
||||
|
||||
let mut seq = Sequence::new();
|
||||
|
||||
let title_response = de_response.clone();
|
||||
http.expect_get()
|
||||
.times(1)
|
||||
.with(predicate::eq(url_title))
|
||||
.return_once(|_| Ok(title_response))
|
||||
.in_sequence(&mut seq);
|
||||
|
||||
let rgid_response = de_response;
|
||||
http.expect_get()
|
||||
.times(1)
|
||||
.with(predicate::eq(url_rgid))
|
||||
.return_once(|_| Ok(rgid_response))
|
||||
.in_sequence(&mut seq);
|
||||
|
||||
let mut client = MusicBrainzClient::new(http);
|
||||
|
||||
let arid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
|
||||
let title: AlbumId = AlbumId::new("an album");
|
||||
let date = (1986, 4).into();
|
||||
|
||||
let mut request = SearchReleaseGroupRequest::new();
|
||||
request
|
||||
.arid(&arid)
|
||||
.release_group(&title.title)
|
||||
.first_release_date(&date);
|
||||
|
||||
let matches = client.search_release_group(request).unwrap();
|
||||
assert_eq!(matches, response);
|
||||
|
||||
let rgid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap();
|
||||
|
||||
let mut request = SearchReleaseGroupRequest::new();
|
||||
request.rgid(&rgid);
|
||||
|
||||
let matches = client.search_release_group(request).unwrap();
|
||||
assert_eq!(matches, response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_release_group_empty_date() {
|
||||
let mut http = MockIMusicBrainzHttp::new();
|
||||
let url = format!(
|
||||
"https://musicbrainz.org/ws/2\
|
||||
/release-group\
|
||||
?query=arid%3A{arid}+AND+releasegroup%3A%22{title}%22",
|
||||
arid = "00000000-0000-0000-0000-000000000000",
|
||||
title = "an+album",
|
||||
);
|
||||
|
||||
let de_response = DeserializeSearchReleaseGroupResponse {
|
||||
release_groups: vec![],
|
||||
};
|
||||
|
||||
http.expect_get()
|
||||
.times(1)
|
||||
.with(predicate::eq(url))
|
||||
.return_once(|_| Ok(de_response));
|
||||
|
||||
let mut client = MusicBrainzClient::new(http);
|
||||
|
||||
let arid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
|
||||
let title: AlbumId = AlbumId::new("an album");
|
||||
let date = AlbumDate::default();
|
||||
|
||||
let mut request = SearchReleaseGroupRequest::new();
|
||||
request
|
||||
.arid(&arid)
|
||||
.release_group(&title.title)
|
||||
.first_release_date(&date);
|
||||
|
||||
let _ = client.search_release_group(request).unwrap();
|
||||
}
|
||||
}
|
@ -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
|
19
src/external/musicbrainz/mod.rs
vendored
19
src/external/musicbrainz/mod.rs
vendored
@ -1,2 +1,19 @@
|
||||
#[cfg(feature = "musicbrainz-api")]
|
||||
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
|
||||
|
||||
pub mod api;
|
||||
pub mod http;
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
#[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),
|
||||
}
|
||||
|
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::{api::MusicBrainzClient, http::MusicBrainzHttp},
|
||||
},
|
||||
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;
|
66
src/tui/lib/external/musicbrainz/mod.rs
vendored
Normal file
66
src/tui/lib/external/musicbrainz/mod.rs
vendored
Normal file
@ -0,0 +1,66 @@
|
||||
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
|
||||
|
||||
use musichoard::{
|
||||
collection::album::{Album, AlbumDate},
|
||||
external::musicbrainz::{
|
||||
api::{
|
||||
search::{SearchReleaseGroupRequest, SearchReleaseGroupResponseReleaseGroup},
|
||||
MusicBrainzClient,
|
||||
},
|
||||
IMusicBrainzHttp,
|
||||
},
|
||||
interface::musicbrainz::Mbid,
|
||||
};
|
||||
|
||||
use crate::tui::lib::interface::musicbrainz::{Error, IMusicBrainz, Match};
|
||||
|
||||
// GRCOV_EXCL_START
|
||||
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> {
|
||||
// Some release groups may have a promotional early release messing up the search. Searching
|
||||
// with just the year should be enough anyway.
|
||||
let date = AlbumDate::new(album.date.year, None, None);
|
||||
|
||||
let mut request = SearchReleaseGroupRequest::default();
|
||||
request
|
||||
.arid(arid)
|
||||
.first_release_date(&date)
|
||||
.release_group(&album.id.title);
|
||||
|
||||
let mb_response = self.client.search_release_group(request)?;
|
||||
|
||||
Ok(mb_response
|
||||
.release_groups
|
||||
.into_iter()
|
||||
.map(from_search_release_group_response_release_group)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn from_search_release_group_response_release_group(
|
||||
entity: SearchReleaseGroupResponseReleaseGroup,
|
||||
) -> Match<Album> {
|
||||
let mut album = Album::new(
|
||||
entity.title,
|
||||
entity.first_release_date,
|
||||
Some(entity.primary_type),
|
||||
entity.secondary_types.unwrap_or_default(),
|
||||
);
|
||||
album.set_musicbrainz_ref(entity.id.into());
|
||||
Match::new(entity.score, album)
|
||||
}
|
||||
// GRCOV_EXCL_STOP
|
1
src/tui/lib/interface/mod.rs
Normal file
1
src/tui/lib/interface/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod musicbrainz;
|
30
src/tui/lib/interface/musicbrainz/mod.rs
Normal file
30
src/tui/lib/interface/musicbrainz/mod.rs
Normal file
@ -0,0 +1,30 @@
|
||||
//! Module for accessing MusicBrainz metadata.
|
||||
|
||||
#[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 }
|
||||
}
|
||||
}
|
||||
|
||||
pub type Error = musichoard::external::musicbrainz::api::Error;
|
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
|
@ -8,6 +8,7 @@ mod ui;
|
||||
pub use app::App;
|
||||
pub use event::EventChannel;
|
||||
pub use handler::EventHandler;
|
||||
pub use lib::external::musicbrainz::MusicBrainz;
|
||||
pub use listener::EventListener;
|
||||
pub use ui::Ui;
|
||||
|
||||
@ -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