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"]
|
database-json = ["serde", "serde_json"]
|
||||||
library-beets = []
|
library-beets = []
|
||||||
library-beets-ssh = ["openssh", "tokio"]
|
library-beets-ssh = ["openssh", "tokio"]
|
||||||
musicbrainz-api = ["reqwest", "serde", "serde_json"]
|
musicbrainz = ["reqwest", "serde", "serde_json"]
|
||||||
tui = ["aho-corasick", "crossterm", "once_cell", "ratatui"]
|
tui = ["aho-corasick", "crossterm", "once_cell", "ratatui"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
@ -45,14 +45,14 @@ name = "musichoard-edit"
|
|||||||
required-features = ["bin", "database-json"]
|
required-features = ["bin", "database-json"]
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "musicbrainz-api---lookup-artist-release-groups"
|
name = "musicbrainz-api---lookup-artist"
|
||||||
path = "examples/musicbrainz_api/lookup_artist_release_groups.rs"
|
path = "examples/musicbrainz_api/lookup_artist.rs"
|
||||||
required-features = ["bin", "musicbrainz-api"]
|
required-features = ["bin", "musicbrainz"]
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "musicbrainz-api---search-release-group"
|
name = "musicbrainz-api---search-release-group"
|
||||||
path = "examples/musicbrainz_api/search_release_group.rs"
|
path = "examples/musicbrainz_api/search_release_group.rs"
|
||||||
required-features = ["bin", "musicbrainz-api"]
|
required-features = ["bin", "musicbrainz"]
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
[package.metadata.docs.rs]
|
||||||
all-features = true
|
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 std::{num::ParseIntError, str::FromStr};
|
||||||
|
|
||||||
use musichoard::{
|
use musichoard::{
|
||||||
collection::album::{Album, AlbumDate, AlbumId},
|
collection::album::AlbumDate,
|
||||||
external::musicbrainz::api::{client::MusicBrainzApiClient, MusicBrainzApi},
|
external::musicbrainz::{
|
||||||
interface::musicbrainz::{IMusicBrainz, Mbid},
|
api::search::SearchReleaseGroupRequest, api::MusicBrainzClient, http::MusicBrainzHttp,
|
||||||
|
},
|
||||||
|
interface::musicbrainz::Mbid,
|
||||||
};
|
};
|
||||||
use structopt::StructOpt;
|
use structopt::StructOpt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -18,16 +20,13 @@ const USER_AGENT: &str = concat!(
|
|||||||
|
|
||||||
#[derive(StructOpt)]
|
#[derive(StructOpt)]
|
||||||
struct Opt {
|
struct Opt {
|
||||||
#[structopt(help = "Release group's artist MBID")]
|
|
||||||
arid: Uuid,
|
|
||||||
|
|
||||||
#[structopt(subcommand)]
|
#[structopt(subcommand)]
|
||||||
command: OptCommand,
|
command: OptCommand,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(StructOpt)]
|
#[derive(StructOpt)]
|
||||||
enum OptCommand {
|
enum OptCommand {
|
||||||
#[structopt(about = "Search by title (and date)")]
|
#[structopt(about = "Search by artist MBID, title(, and date)")]
|
||||||
Title(OptTitle),
|
Title(OptTitle),
|
||||||
#[structopt(about = "Search by release group MBID")]
|
#[structopt(about = "Search by release group MBID")]
|
||||||
Rgid(OptRgid),
|
Rgid(OptRgid),
|
||||||
@ -35,6 +34,9 @@ enum OptCommand {
|
|||||||
|
|
||||||
#[derive(StructOpt)]
|
#[derive(StructOpt)]
|
||||||
struct OptTitle {
|
struct OptTitle {
|
||||||
|
#[structopt(help = "Release group's artist MBID")]
|
||||||
|
arid: Uuid,
|
||||||
|
|
||||||
#[structopt(help = "Release group title")]
|
#[structopt(help = "Release group title")]
|
||||||
title: String,
|
title: String,
|
||||||
|
|
||||||
@ -80,30 +82,32 @@ fn main() {
|
|||||||
|
|
||||||
println!("USER_AGENT: {USER_AGENT}");
|
println!("USER_AGENT: {USER_AGENT}");
|
||||||
|
|
||||||
let client = MusicBrainzApiClient::new(USER_AGENT).expect("failed to create API client");
|
let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client");
|
||||||
let mut api = MusicBrainzApi::new(client);
|
let mut client = MusicBrainzClient::new(http);
|
||||||
|
|
||||||
let arid: Mbid = opt.arid.into();
|
let mut request = SearchReleaseGroupRequest::default();
|
||||||
|
let arid: Mbid;
|
||||||
let album = match opt.command {
|
let date: AlbumDate;
|
||||||
|
let title: String;
|
||||||
|
let rgid: Mbid;
|
||||||
|
match opt.command {
|
||||||
OptCommand::Title(opt_title) => {
|
OptCommand::Title(opt_title) => {
|
||||||
let date: AlbumDate = opt_title.date.map(Into::into).unwrap_or_default();
|
arid = opt_title.arid.into();
|
||||||
Album::new(AlbumId::new(opt_title.title), date, None, vec![])
|
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) => {
|
OptCommand::Rgid(opt_rgid) => {
|
||||||
let mut album = Album::new(
|
rgid = opt_rgid.rgid.into();
|
||||||
AlbumId::new(String::default()),
|
request.rgid(&rgid);
|
||||||
AlbumDate::default(),
|
|
||||||
None,
|
|
||||||
vec![],
|
|
||||||
);
|
|
||||||
album.set_musicbrainz_ref(opt_rgid.rgid.into());
|
|
||||||
album
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let matches = api
|
let matches = client
|
||||||
.search_release_group(&arid, &album)
|
.search_release_group(request)
|
||||||
.expect("failed to make API call");
|
.expect("failed to make API call");
|
||||||
|
|
||||||
println!("{matches:#?}");
|
println!("{matches:#?}");
|
||||||
|
@ -51,7 +51,13 @@ macro_rules! impl_imusicbrainzref {
|
|||||||
|
|
||||||
impl From<Uuid> for $mbref {
|
impl From<Uuid> for $mbref {
|
||||||
fn from(uuid: Uuid) -> Self {
|
fn from(uuid: Uuid) -> Self {
|
||||||
$mbref(MusicBrainzRef::from_uuid(uuid, $mbref::entity()))
|
$mbref(MusicBrainzRef::from_mbid(uuid, $mbref::entity()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Mbid> for $mbref {
|
||||||
|
fn from(mbid: Mbid) -> Self {
|
||||||
|
$mbref(MusicBrainzRef::from_mbid(mbid, $mbref::entity()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,9 +104,9 @@ impl MusicBrainzRef {
|
|||||||
Ok(MusicBrainzRef { mbid, url })
|
Ok(MusicBrainzRef { mbid, url })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn from_uuid(uuid: Uuid, entity: &'static str) -> Self {
|
fn from_mbid<ID: Into<Mbid>>(id: ID, entity: &'static str) -> Self {
|
||||||
let uuid_str = uuid.to_string();
|
let mbid = id.into();
|
||||||
let mbid = uuid.into();
|
let uuid_str = mbid.uuid().to_string();
|
||||||
let url = Url::parse(&format!("https://{MB_DOMAIN}/{entity}/{uuid_str}")).unwrap();
|
let url = Url::parse(&format!("https://{MB_DOMAIN}/{entity}/{uuid_str}")).unwrap();
|
||||||
MusicBrainzRef { mbid, url }
|
MusicBrainzRef { mbid, url }
|
||||||
}
|
}
|
||||||
@ -127,6 +133,11 @@ mod tests {
|
|||||||
assert_eq!(url_str, mb.url().as_ref());
|
assert_eq!(url_str, mb.url().as_ref());
|
||||||
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
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 url: Url = url_str.as_str().try_into().unwrap();
|
||||||
let mb: MbArtistRef = url.try_into().unwrap();
|
let mb: MbArtistRef = url.try_into().unwrap();
|
||||||
assert_eq!(url_str, mb.url().as_ref());
|
assert_eq!(url_str, mb.url().as_ref());
|
||||||
@ -146,6 +157,11 @@ mod tests {
|
|||||||
assert_eq!(url_str, mb.url().as_ref());
|
assert_eq!(url_str, mb.url().as_ref());
|
||||||
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
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 url: Url = url_str.as_str().try_into().unwrap();
|
||||||
let mb: MbAlbumRef = url.try_into().unwrap();
|
let mb: MbAlbumRef = url.try_into().unwrap();
|
||||||
assert_eq!(url_str, mb.url().as_ref());
|
assert_eq!(url_str, mb.url().as_ref());
|
||||||
|
@ -1,52 +1,7 @@
|
|||||||
//! Module for accessing MusicBrainz metadata.
|
use std::fmt;
|
||||||
|
|
||||||
use std::{fmt, num};
|
|
||||||
|
|
||||||
use uuid::{self, Uuid};
|
use uuid::{self, Uuid};
|
||||||
|
|
||||||
use crate::collection::album::Album;
|
|
||||||
|
|
||||||
/// Trait for interacting with the MusicBrainz API.
|
|
||||||
pub trait IMusicBrainz {
|
|
||||||
fn lookup_artist_release_groups(&mut self, mbid: &Mbid) -> Result<Vec<Album>, Error>;
|
|
||||||
fn search_release_group(
|
|
||||||
&mut self,
|
|
||||||
arid: &Mbid,
|
|
||||||
album: &Album,
|
|
||||||
) -> Result<Vec<Match<Album>>, Error>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct Match<T> {
|
|
||||||
pub score: u8,
|
|
||||||
pub item: T,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> Match<T> {
|
|
||||||
pub fn new(score: u8, item: T) -> Self {
|
|
||||||
Match { score, item }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Null implementation of [`IMusicBrainz`] for when the trait is required, but no communication
|
|
||||||
/// with the MusicBrainz is desired.
|
|
||||||
pub struct NullMusicBrainz;
|
|
||||||
|
|
||||||
impl IMusicBrainz for NullMusicBrainz {
|
|
||||||
fn lookup_artist_release_groups(&mut self, _mbid: &Mbid) -> Result<Vec<Album>, Error> {
|
|
||||||
Ok(vec![])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn search_release_group(
|
|
||||||
&mut self,
|
|
||||||
_arid: &Mbid,
|
|
||||||
_album: &Album,
|
|
||||||
) -> Result<Vec<Match<Album>>, Error> {
|
|
||||||
Ok(vec![])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The MusicBrainz ID.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct Mbid(Uuid);
|
pub struct Mbid(Uuid);
|
||||||
|
|
||||||
@ -62,10 +17,25 @@ impl From<Uuid> for Mbid {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MbidError(String);
|
||||||
|
|
||||||
|
impl fmt::Display for MbidError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
write!(f, "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 {
|
macro_rules! try_from_impl_for_mbid {
|
||||||
($from:ty) => {
|
($from:ty) => {
|
||||||
impl TryFrom<$from> for Mbid {
|
impl TryFrom<$from> for Mbid {
|
||||||
type Error = Error;
|
type Error = MbidError;
|
||||||
|
|
||||||
fn try_from(value: $from) -> Result<Self, Self::Error> {
|
fn try_from(value: $from) -> Result<Self, Self::Error> {
|
||||||
Ok(Uuid::parse_str(value.as_ref())?.into())
|
Ok(Uuid::parse_str(value.as_ref())?.into())
|
||||||
@ -78,99 +48,9 @@ try_from_impl_for_mbid!(&str);
|
|||||||
try_from_impl_for_mbid!(&String);
|
try_from_impl_for_mbid!(&String);
|
||||||
try_from_impl_for_mbid!(String);
|
try_from_impl_for_mbid!(String);
|
||||||
|
|
||||||
/// Error type for musicbrainz calls.
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
pub enum Error {
|
|
||||||
/// Failed to parse input into an MBID.
|
|
||||||
MbidParse(String),
|
|
||||||
/// The API client failed.
|
|
||||||
Client(String),
|
|
||||||
/// The client reached the API rate limit.
|
|
||||||
RateLimit,
|
|
||||||
/// The API response could not be understood.
|
|
||||||
Unknown(u16),
|
|
||||||
/// Part of the response could not be parsed.
|
|
||||||
Parse(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for Error {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Error::MbidParse(s) => write!(f, "failed to parse input into an MBID: {s}"),
|
|
||||||
Error::Client(s) => write!(f, "the API client failed: {s}"),
|
|
||||||
Error::RateLimit => write!(f, "the API client reached the rate limit"),
|
|
||||||
Error::Unknown(u) => write!(f, "the API response could not be understood: status {u}"),
|
|
||||||
Error::Parse(s) => write!(f, "part of the response could not be parsed: {s}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<uuid::Error> for Error {
|
|
||||||
fn from(value: uuid::Error) -> Self {
|
|
||||||
Error::MbidParse(value.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<num::ParseIntError> for Error {
|
|
||||||
fn from(err: num::ParseIntError) -> Error {
|
|
||||||
Error::Parse(err.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::core::collection::album::{AlbumDate, AlbumId};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn null_lookup_artist_release_groups() {
|
|
||||||
let mut musicbrainz = NullMusicBrainz;
|
|
||||||
let mbid: Mbid = "d368baa8-21ca-4759-9731-0b2753071ad8".try_into().unwrap();
|
|
||||||
assert!(musicbrainz
|
|
||||||
.lookup_artist_release_groups(&mbid)
|
|
||||||
.unwrap()
|
|
||||||
.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn null_search_release_group() {
|
|
||||||
let mut musicbrainz = NullMusicBrainz;
|
|
||||||
let mbid: Mbid = "d368baa8-21ca-4759-9731-0b2753071ad8".try_into().unwrap();
|
|
||||||
let album = Album::new(AlbumId::new("an album"), AlbumDate::default(), None, vec![]);
|
|
||||||
assert!(musicbrainz
|
|
||||||
.search_release_group(&mbid, &album)
|
|
||||||
.unwrap()
|
|
||||||
.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn match_type() {
|
|
||||||
let album = Album::new(AlbumId::new("an album"), AlbumDate::default(), None, vec![]);
|
|
||||||
let hit = Match::new(56, album);
|
|
||||||
assert!(!format!("{hit:?}").is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn errors() {
|
fn errors() {
|
||||||
let mbid_err: Error = TryInto::<Mbid>::try_into("i-am-not-a-uuid").unwrap_err();
|
let mbid_err: MbidError = TryInto::<Mbid>::try_into("i-am-not-a-uuid").unwrap_err();
|
||||||
assert!(!mbid_err.to_string().is_empty());
|
assert!(!mbid_err.to_string().is_empty());
|
||||||
assert!(!format!("{mbid_err:?}").is_empty());
|
assert!(!format!("{mbid_err:?}").is_empty());
|
||||||
|
|
||||||
let client_err: Error = Error::Client(String::from("a client error"));
|
|
||||||
assert!(!client_err.to_string().is_empty());
|
|
||||||
assert!(!format!("{client_err:?}").is_empty());
|
|
||||||
|
|
||||||
let rate_err: Error = Error::RateLimit;
|
|
||||||
assert!(!rate_err.to_string().is_empty());
|
|
||||||
assert!(!format!("{rate_err:?}").is_empty());
|
|
||||||
|
|
||||||
let unk_err: Error = Error::Unknown(404);
|
|
||||||
assert!(!unk_err.to_string().is_empty());
|
|
||||||
assert!(!format!("{unk_err:?}").is_empty());
|
|
||||||
|
|
||||||
let parse_err: Error = "not-a-number".parse::<u32>().unwrap_err().into();
|
|
||||||
assert!(!parse_err.to_string().is_empty());
|
|
||||||
assert!(!format!("{parse_err:?}").is_empty());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
1
src/external/mod.rs
vendored
1
src/external/mod.rs
vendored
@ -1,3 +1,4 @@
|
|||||||
pub mod database;
|
pub mod database;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
|
#[cfg(feature = "musicbrainz")]
|
||||||
pub mod musicbrainz;
|
pub mod musicbrainz;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
500
src/external/musicbrainz/api/mod.rs
vendored
500
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 crate::{
|
||||||
use url::form_urlencoded;
|
collection::album::{AlbumDate, AlbumPrimaryType, AlbumSecondaryType},
|
||||||
|
external::musicbrainz::HttpError,
|
||||||
#[cfg(test)]
|
interface::musicbrainz::{Mbid, MbidError},
|
||||||
use mockall::automock;
|
|
||||||
|
|
||||||
use crate::core::{
|
|
||||||
collection::{
|
|
||||||
album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType},
|
|
||||||
musicbrainz::{IMusicBrainzRef, MbAlbumRef},
|
|
||||||
},
|
|
||||||
interface::musicbrainz::{Error, IMusicBrainz, Match, Mbid},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub mod lookup;
|
||||||
|
pub mod search;
|
||||||
|
|
||||||
const MB_BASE_URL: &str = "https://musicbrainz.org/ws/2";
|
const MB_BASE_URL: &str = "https://musicbrainz.org/ws/2";
|
||||||
const MB_RATE_LIMIT_CODE: u16 = 503;
|
const MB_RATE_LIMIT_CODE: u16 = 503;
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
#[cfg_attr(test, automock)]
|
pub enum Error {
|
||||||
pub trait IMusicBrainzApiClient {
|
/// The HTTP client failed.
|
||||||
fn get<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, ClientError>;
|
Http(String),
|
||||||
|
/// The client reached the API rate limit.
|
||||||
|
RateLimit,
|
||||||
|
/// The API response could not be understood.
|
||||||
|
Unknown(u16),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
impl fmt::Display for Error {
|
||||||
pub enum ClientError {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
Client(String),
|
match self {
|
||||||
Status(u16),
|
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 {
|
impl From<HttpError> for Error {
|
||||||
fn from(err: ClientError) -> Self {
|
fn from(err: HttpError) -> Self {
|
||||||
match err {
|
match err {
|
||||||
ClientError::Client(s) => Error::Client(s),
|
HttpError::Client(s) => Error::Http(s),
|
||||||
ClientError::Status(status) => match status {
|
HttpError::Status(status) => match status {
|
||||||
MB_RATE_LIMIT_CODE => Error::RateLimit,
|
MB_RATE_LIMIT_CODE => Error::RateLimit,
|
||||||
_ => Error::Unknown(status),
|
_ => Error::Unknown(status),
|
||||||
},
|
},
|
||||||
@ -42,159 +45,125 @@ impl From<ClientError> for Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct MusicBrainzApi<Mbc> {
|
pub struct MusicBrainzClient<Http> {
|
||||||
client: Mbc,
|
http: Http,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Mbc> MusicBrainzApi<Mbc> {
|
impl<Http> MusicBrainzClient<Http> {
|
||||||
pub fn new(client: Mbc) -> Self {
|
pub fn new(http: Http) -> Self {
|
||||||
MusicBrainzApi { client }
|
MusicBrainzClient { http }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Mbc: IMusicBrainzApiClient> IMusicBrainz for MusicBrainzApi<Mbc> {
|
fn format_album_date(date: &AlbumDate) -> Option<String> {
|
||||||
fn lookup_artist_release_groups(&mut self, mbid: &Mbid) -> Result<Vec<Album>, Error> {
|
match date.year {
|
||||||
let mbid = mbid.uuid().as_hyphenated().to_string();
|
Some(year) => match date.month {
|
||||||
|
Some(month) => match date.day {
|
||||||
let artist: ResponseLookupArtist = self
|
Some(day) => Some(format!("{year}-{month:02}-{day:02}")),
|
||||||
.client
|
None => Some(format!("{year}-{month:02}")),
|
||||||
.get(&format!("{MB_BASE_URL}/artist/{mbid}?inc=release-groups"))?;
|
},
|
||||||
|
None => Some(format!("{year}")),
|
||||||
artist
|
},
|
||||||
.release_groups
|
None => None,
|
||||||
.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();
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SerdeMbid(Mbid);
|
||||||
|
|
||||||
let results: ResponseSearchReleaseGroup = self
|
impl From<SerdeMbid> for Mbid {
|
||||||
.client
|
fn from(value: SerdeMbid) -> Self {
|
||||||
.get(&format!("{MB_BASE_URL}/release-group?query={query}"))?;
|
value.0
|
||||||
|
|
||||||
results
|
|
||||||
.release_groups
|
|
||||||
.into_iter()
|
|
||||||
.map(TryInto::try_into)
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
struct SerdeMbidVisitor;
|
||||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
|
||||||
struct ResponseLookupArtist {
|
impl<'de> Visitor<'de> for SerdeMbidVisitor {
|
||||||
release_groups: Vec<LookupReleaseGroup>,
|
type Value = SerdeMbid;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
formatter.write_str("a valid MusicBrainz identifier")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
where
|
||||||
struct LookupReleaseGroup {
|
E: serde::de::Error,
|
||||||
id: String,
|
{
|
||||||
title: String,
|
Ok(SerdeMbid(
|
||||||
first_release_date: String,
|
v.try_into()
|
||||||
primary_type: SerdeAlbumPrimaryType,
|
.map_err(|e: MbidError| E::custom(e.to_string()))?,
|
||||||
secondary_types: Vec<SerdeAlbumSecondaryType>,
|
))
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<LookupReleaseGroup> for Album {
|
|
||||||
type Error = Error;
|
|
||||||
|
|
||||||
fn try_from(entity: LookupReleaseGroup) -> Result<Self, Self::Error> {
|
|
||||||
let mut album = Album::new(
|
|
||||||
entity.title,
|
|
||||||
AlbumDate::from_mb_date(&entity.first_release_date)?,
|
|
||||||
Some(entity.primary_type.into()),
|
|
||||||
entity.secondary_types.into_iter().map(Into::into).collect(),
|
|
||||||
);
|
|
||||||
let mbref = MbAlbumRef::from_uuid_str(entity.id)
|
|
||||||
.map_err(|err| Error::MbidParse(err.to_string()))?;
|
|
||||||
album.set_musicbrainz_ref(mbref);
|
|
||||||
Ok(album)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
impl<'de> Deserialize<'de> for SerdeMbid {
|
||||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
struct ResponseSearchReleaseGroup {
|
where
|
||||||
release_groups: Vec<SearchReleaseGroup>,
|
D: Deserializer<'de>,
|
||||||
}
|
{
|
||||||
|
deserializer.deserialize_str(SerdeMbidVisitor)
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
|
||||||
struct SearchReleaseGroup {
|
|
||||||
score: u8,
|
|
||||||
id: String,
|
|
||||||
title: String,
|
|
||||||
first_release_date: String,
|
|
||||||
primary_type: SerdeAlbumPrimaryType,
|
|
||||||
secondary_types: Option<Vec<SerdeAlbumSecondaryType>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SearchReleaseGroup> for Match<Album> {
|
|
||||||
type Error = Error;
|
|
||||||
|
|
||||||
fn try_from(entity: SearchReleaseGroup) -> Result<Self, Self::Error> {
|
|
||||||
let mut album = Album::new(
|
|
||||||
entity.title,
|
|
||||||
AlbumDate::from_mb_date(&entity.first_release_date)?,
|
|
||||||
Some(entity.primary_type.into()),
|
|
||||||
entity
|
|
||||||
.secondary_types
|
|
||||||
.map(|v| v.into_iter().map(|st| st.into()).collect())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
);
|
|
||||||
let mbref = MbAlbumRef::from_uuid_str(entity.id)
|
|
||||||
.map_err(|err| Error::MbidParse(err.to_string()))?;
|
|
||||||
album.set_musicbrainz_ref(mbref);
|
|
||||||
Ok(Match::new(entity.score, album))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AlbumDate {
|
#[derive(Debug, Clone)]
|
||||||
fn from_mb_date(mb_date: &str) -> Result<AlbumDate, Error> {
|
pub struct SerdeAlbumDate(AlbumDate);
|
||||||
let mut elems = mb_date.split('-');
|
|
||||||
|
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 elem = elems.next();
|
||||||
let year = elem
|
let year = elem
|
||||||
.and_then(|s| if s.is_empty() { None } else { Some(s.parse()) })
|
.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 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 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)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(remote = "AlbumPrimaryType")]
|
#[serde(remote = "AlbumPrimaryType")]
|
||||||
pub enum SerdeAlbumPrimaryTypeDef {
|
pub enum AlbumPrimaryTypeDef {
|
||||||
Album,
|
Album,
|
||||||
Single,
|
Single,
|
||||||
#[serde(rename = "EP")]
|
#[serde(rename = "EP")]
|
||||||
@ -204,7 +173,7 @@ pub enum SerdeAlbumPrimaryTypeDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct SerdeAlbumPrimaryType(#[serde(with = "SerdeAlbumPrimaryTypeDef")] AlbumPrimaryType);
|
pub struct SerdeAlbumPrimaryType(#[serde(with = "AlbumPrimaryTypeDef")] AlbumPrimaryType);
|
||||||
|
|
||||||
impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
|
impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
|
||||||
fn from(value: SerdeAlbumPrimaryType) -> Self {
|
fn from(value: SerdeAlbumPrimaryType) -> Self {
|
||||||
@ -214,7 +183,7 @@ impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(remote = "AlbumSecondaryType")]
|
#[serde(remote = "AlbumSecondaryType")]
|
||||||
pub enum SerdeAlbumSecondaryTypeDef {
|
pub enum AlbumSecondaryTypeDef {
|
||||||
Compilation,
|
Compilation,
|
||||||
Soundtrack,
|
Soundtrack,
|
||||||
Spokenword,
|
Spokenword,
|
||||||
@ -234,9 +203,7 @@ pub enum SerdeAlbumSecondaryTypeDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct SerdeAlbumSecondaryType(
|
pub struct SerdeAlbumSecondaryType(#[serde(with = "AlbumSecondaryTypeDef")] AlbumSecondaryType);
|
||||||
#[serde(with = "SerdeAlbumSecondaryTypeDef")] AlbumSecondaryType,
|
|
||||||
);
|
|
||||||
|
|
||||||
impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
|
impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
|
||||||
fn from(value: SerdeAlbumSecondaryType) -> Self {
|
fn from(value: SerdeAlbumSecondaryType) -> Self {
|
||||||
@ -246,188 +213,89 @@ impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use mockall::{predicate, Sequence};
|
|
||||||
|
|
||||||
use crate::collection::album::AlbumId;
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lookup_artist_release_group() {
|
fn errors() {
|
||||||
let mut client = MockIMusicBrainzApiClient::new();
|
let http_err = HttpError::Client(String::from("a http error"));
|
||||||
let url = format!(
|
let http_err: Error = http_err.into();
|
||||||
"https://musicbrainz.org/ws/2/artist/{mbid}?inc=release-groups",
|
assert!(matches!(http_err, Error::Http(_)));
|
||||||
mbid = "00000000-0000-0000-0000-000000000000",
|
assert!(!http_err.to_string().is_empty());
|
||||||
);
|
assert!(!format!("{http_err:?}").is_empty());
|
||||||
|
|
||||||
let release_group = LookupReleaseGroup {
|
let rate_err = HttpError::Status(MB_RATE_LIMIT_CODE);
|
||||||
id: String::from("11111111-1111-1111-1111-111111111111"),
|
let rate_err: Error = rate_err.into();
|
||||||
title: String::from("an album"),
|
assert!(matches!(rate_err, Error::RateLimit));
|
||||||
first_release_date: String::from("1986-04"),
|
assert!(!rate_err.to_string().is_empty());
|
||||||
primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album),
|
assert!(!format!("{rate_err:?}").is_empty());
|
||||||
secondary_types: vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Compilation)],
|
|
||||||
};
|
|
||||||
let response = ResponseLookupArtist {
|
|
||||||
release_groups: vec![release_group],
|
|
||||||
};
|
|
||||||
|
|
||||||
// For code coverage of derive(Debug).
|
let unk_err = HttpError::Status(404);
|
||||||
assert!(!format!("{response:?}").is_empty());
|
let unk_err: Error = unk_err.into();
|
||||||
|
assert!(matches!(unk_err, Error::Unknown(_)));
|
||||||
client
|
assert!(!unk_err.to_string().is_empty());
|
||||||
.expect_get()
|
assert!(!format!("{unk_err:?}").is_empty());
|
||||||
.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]
|
#[test]
|
||||||
fn search_release_group() {
|
fn format_album_date() {
|
||||||
let mut client = MockIMusicBrainzApiClient::new();
|
struct Null;
|
||||||
let url_title = format!(
|
|
||||||
"https://musicbrainz.org/ws/2\
|
|
||||||
/release-group\
|
|
||||||
?query=arid%3A{arid}+AND+releasegroup%3A%22{title}%22+AND+firstreleasedate%3A{year}",
|
|
||||||
arid = "00000000-0000-0000-0000-000000000000",
|
|
||||||
title = "an+album",
|
|
||||||
year = "1986"
|
|
||||||
);
|
|
||||||
let url_rgid = format!(
|
|
||||||
"https://musicbrainz.org/ws/2\
|
|
||||||
/release-group\
|
|
||||||
?query=arid%3A{arid}+AND+rgid%3A{rgid}",
|
|
||||||
arid = "00000000-0000-0000-0000-000000000000",
|
|
||||||
rgid = "11111111-1111-1111-1111-111111111111",
|
|
||||||
);
|
|
||||||
|
|
||||||
let release_group = SearchReleaseGroup {
|
|
||||||
score: 67,
|
|
||||||
id: String::from("11111111-1111-1111-1111-111111111111"),
|
|
||||||
title: String::from("an album"),
|
|
||||||
first_release_date: String::from("1986-04"),
|
|
||||||
primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album),
|
|
||||||
secondary_types: Some(vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Live)]),
|
|
||||||
};
|
|
||||||
let response = ResponseSearchReleaseGroup {
|
|
||||||
release_groups: vec![release_group],
|
|
||||||
};
|
|
||||||
|
|
||||||
// For code coverage of derive(Debug).
|
|
||||||
assert!(!format!("{response:?}").is_empty());
|
|
||||||
|
|
||||||
let mut seq = Sequence::new();
|
|
||||||
|
|
||||||
let title_response = response.clone();
|
|
||||||
client
|
|
||||||
.expect_get()
|
|
||||||
.times(1)
|
|
||||||
.with(predicate::eq(url_title))
|
|
||||||
.return_once(|_| Ok(title_response))
|
|
||||||
.in_sequence(&mut seq);
|
|
||||||
|
|
||||||
let rgid_response = response;
|
|
||||||
client
|
|
||||||
.expect_get()
|
|
||||||
.times(1)
|
|
||||||
.with(predicate::eq(url_rgid))
|
|
||||||
.return_once(|_| Ok(rgid_response))
|
|
||||||
.in_sequence(&mut seq);
|
|
||||||
|
|
||||||
let mut album = Album::new(
|
|
||||||
AlbumId::new("an album"),
|
|
||||||
(1986, 4),
|
|
||||||
Some(AlbumPrimaryType::Album),
|
|
||||||
vec![AlbumSecondaryType::Live],
|
|
||||||
);
|
|
||||||
album.set_musicbrainz_ref(
|
|
||||||
MbAlbumRef::from_uuid_str("11111111-1111-1111-1111-111111111111").unwrap(),
|
|
||||||
);
|
|
||||||
let expected = vec![Match::new(67, album)];
|
|
||||||
|
|
||||||
let mut api = MusicBrainzApi::new(client);
|
|
||||||
|
|
||||||
let arid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
|
|
||||||
let mut album = Album::new(AlbumId::new("an album"), (1986, 4), None, vec![]);
|
|
||||||
let matches = api.search_release_group(&arid, &album).unwrap();
|
|
||||||
assert_eq!(matches, expected);
|
|
||||||
|
|
||||||
let rgid = MbAlbumRef::from_uuid_str("11111111-1111-1111-1111-111111111111").unwrap();
|
|
||||||
album.set_musicbrainz_ref(rgid);
|
|
||||||
let matches = api.search_release_group(&arid, &album).unwrap();
|
|
||||||
assert_eq!(matches, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn client_errors() {
|
|
||||||
let mut client = MockIMusicBrainzApiClient::new();
|
|
||||||
|
|
||||||
let error = ClientError::Client(String::from("get rekt"));
|
|
||||||
assert!(!format!("{error:?}").is_empty());
|
|
||||||
|
|
||||||
client
|
|
||||||
.expect_get::<ResponseLookupArtist>()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Err(ClientError::Client(String::from("get rekt scrub"))));
|
|
||||||
|
|
||||||
client
|
|
||||||
.expect_get::<ResponseLookupArtist>()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Err(ClientError::Status(503)));
|
|
||||||
|
|
||||||
client
|
|
||||||
.expect_get::<ResponseLookupArtist>()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| Err(ClientError::Status(504)));
|
|
||||||
|
|
||||||
let mut api = MusicBrainzApi::new(client);
|
|
||||||
|
|
||||||
let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
|
|
||||||
|
|
||||||
let error = api.lookup_artist_release_groups(&mbid).unwrap_err();
|
|
||||||
assert_eq!(error, Error::Client(String::from("get rekt scrub")));
|
|
||||||
|
|
||||||
let error = api.lookup_artist_release_groups(&mbid).unwrap_err();
|
|
||||||
assert_eq!(error, Error::RateLimit);
|
|
||||||
|
|
||||||
let error = api.lookup_artist_release_groups(&mbid).unwrap_err();
|
|
||||||
assert_eq!(error, Error::Unknown(504));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn from_mb_date() {
|
|
||||||
assert_eq!(AlbumDate::from_mb_date("").unwrap(), AlbumDate::default());
|
|
||||||
assert_eq!(AlbumDate::from_mb_date("1984").unwrap(), 1984.into());
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AlbumDate::from_mb_date("1984-05").unwrap(),
|
MusicBrainzClient::<Null>::format_album_date(&AlbumDate::new(None, None, None)),
|
||||||
(1984, 5).into()
|
None
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AlbumDate::from_mb_date("1984-05-18").unwrap(),
|
MusicBrainzClient::<Null>::format_album_date(&(1986).into()),
|
||||||
(1984, 5, 18).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]
|
#[test]
|
||||||
fn serde() {
|
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 = "\"EP\"";
|
||||||
let primary_type: SerdeAlbumPrimaryType = serde_json::from_str(primary_type).unwrap();
|
let primary_type: SerdeAlbumPrimaryType = serde_json::from_str(primary_type).unwrap();
|
||||||
let primary_type: AlbumPrimaryType = primary_type.into();
|
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 reqwest::{self, blocking::Client, header};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
use crate::external::musicbrainz::api::{ClientError, IMusicBrainzApiClient};
|
use crate::external::musicbrainz::{HttpError, IMusicBrainzHttp};
|
||||||
|
|
||||||
// GRCOV_EXCL_START
|
// GRCOV_EXCL_START
|
||||||
pub struct MusicBrainzApiClient(Client);
|
pub struct MusicBrainzHttp(Client);
|
||||||
|
|
||||||
impl MusicBrainzApiClient {
|
impl MusicBrainzHttp {
|
||||||
pub fn new(user_agent: &'static str) -> Result<Self, ClientError> {
|
pub fn new(user_agent: &'static str) -> Result<Self, HttpError> {
|
||||||
let mut headers = header::HeaderMap::new();
|
let mut headers = header::HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
header::USER_AGENT,
|
header::USER_AGENT,
|
||||||
@ -20,27 +20,27 @@ impl MusicBrainzApiClient {
|
|||||||
header::HeaderValue::from_static("application/json"),
|
header::HeaderValue::from_static("application/json"),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(MusicBrainzApiClient(
|
Ok(MusicBrainzHttp(
|
||||||
Client::builder().default_headers(headers).build()?,
|
Client::builder().default_headers(headers).build()?,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IMusicBrainzApiClient for MusicBrainzApiClient {
|
impl IMusicBrainzHttp for MusicBrainzHttp {
|
||||||
fn get<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, ClientError> {
|
fn get<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, HttpError> {
|
||||||
let response = self.0.get(url).send()?;
|
let response = self.0.get(url).send()?;
|
||||||
|
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
Ok(response.json()?)
|
Ok(response.json()?)
|
||||||
} else {
|
} else {
|
||||||
Err(ClientError::Status(response.status().as_u16()))
|
Err(HttpError::Status(response.status().as_u16()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<reqwest::Error> for ClientError {
|
impl From<reqwest::Error> for HttpError {
|
||||||
fn from(err: reqwest::Error) -> Self {
|
fn from(err: reqwest::Error) -> Self {
|
||||||
ClientError::Client(err.to_string())
|
HttpError::Client(err.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// GRCOV_EXCL_STOP
|
// GRCOV_EXCL_STOP
|
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 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},
|
executor::{ssh::BeetsLibrarySshExecutor, BeetsLibraryProcessExecutor},
|
||||||
BeetsLibrary,
|
BeetsLibrary,
|
||||||
},
|
},
|
||||||
musicbrainz::api::{client::MusicBrainzApiClient, MusicBrainzApi},
|
musicbrainz::{api::MusicBrainzClient, http::MusicBrainzHttp},
|
||||||
},
|
},
|
||||||
interface::{
|
interface::{
|
||||||
database::{IDatabase, NullDatabase},
|
database::{IDatabase, NullDatabase},
|
||||||
@ -25,7 +25,7 @@ use musichoard::{
|
|||||||
MusicHoardBuilder, NoDatabase, NoLibrary,
|
MusicHoardBuilder, NoDatabase, NoLibrary,
|
||||||
};
|
};
|
||||||
|
|
||||||
use tui::{App, EventChannel, EventHandler, EventListener, Tui, Ui};
|
use tui::{App, EventChannel, EventHandler, EventListener, MusicBrainz, Tui, Ui};
|
||||||
|
|
||||||
const MUSICHOARD_HTTP_USER_AGENT: &str = concat!(
|
const MUSICHOARD_HTTP_USER_AGENT: &str = concat!(
|
||||||
"MusicHoard/",
|
"MusicHoard/",
|
||||||
@ -83,11 +83,12 @@ fn with<Database: IDatabase + 'static, Library: ILibrary + 'static>(
|
|||||||
let listener = EventListener::new(channel.sender());
|
let listener = EventListener::new(channel.sender());
|
||||||
let handler = EventHandler::new(channel.receiver());
|
let handler = EventHandler::new(channel.receiver());
|
||||||
|
|
||||||
let client = MusicBrainzApiClient::new(MUSICHOARD_HTTP_USER_AGENT)
|
let http =
|
||||||
.expect("failed to initialise HTTP client");
|
MusicBrainzHttp::new(MUSICHOARD_HTTP_USER_AGENT).expect("failed to initialise HTTP client");
|
||||||
let api = Box::new(MusicBrainzApi::new(client));
|
let client = MusicBrainzClient::new(http);
|
||||||
|
let musicbrainz = Box::new(MusicBrainz::new(client));
|
||||||
|
|
||||||
let app = App::new(music_hoard, api);
|
let app = App::new(music_hoard, musicbrainz);
|
||||||
let ui = Ui;
|
let ui = Ui;
|
||||||
|
|
||||||
// Run the TUI application.
|
// Run the TUI application.
|
||||||
|
@ -105,7 +105,7 @@ impl IAppInteractBrowse for AppMachine<AppBrowse> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
match self.inner.mb_api.search_release_group(arid, album) {
|
match self.inner.musicbrainz.search_release_group(arid, album) {
|
||||||
Ok(matches) => artist_album_matches.push(AppMatchesInfo {
|
Ok(matches) => artist_album_matches.push(AppMatchesInfo {
|
||||||
matching: album.clone(),
|
matching: album.clone(),
|
||||||
matches,
|
matches,
|
||||||
@ -129,17 +129,14 @@ impl IAppInteractBrowse for AppMachine<AppBrowse> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use mockall::{predicate, Sequence};
|
use mockall::{predicate, Sequence};
|
||||||
use musichoard::collection::album::Album;
|
use musichoard::{collection::album::Album, interface::musicbrainz::Mbid};
|
||||||
|
|
||||||
use crate::tui::{
|
use crate::tui::{
|
||||||
app::{
|
app::{
|
||||||
machine::tests::{inner, inner_with_mb, music_hoard},
|
machine::tests::{inner, inner_with_mb, music_hoard},
|
||||||
Category, IAppAccess, IAppInteract, IAppInteractMatches,
|
Category, IAppAccess, IAppInteract, IAppInteractMatches,
|
||||||
},
|
},
|
||||||
lib::external::musicbrainz::{
|
lib::interface::musicbrainz::{self, Match, MockIMusicBrainz},
|
||||||
self,
|
|
||||||
api::{Match, Mbid, MockIMusicBrainz},
|
|
||||||
},
|
|
||||||
testmod::COLLECTION,
|
testmod::COLLECTION,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -230,8 +227,8 @@ mod tests {
|
|||||||
let matches_1 = vec![album_match_1_1.clone(), album_match_1_2.clone()];
|
let matches_1 = vec![album_match_1_1.clone(), album_match_1_2.clone()];
|
||||||
let matches_4 = vec![album_match_4_1.clone(), album_match_4_2.clone()];
|
let matches_4 = vec![album_match_4_1.clone(), album_match_4_2.clone()];
|
||||||
|
|
||||||
let result_1: Result<Vec<Match<Album>>, musicbrainz::api::Error> = Ok(matches_1.clone());
|
let result_1: Result<Vec<Match<Album>>, musicbrainz::Error> = Ok(matches_1.clone());
|
||||||
let result_4: Result<Vec<Match<Album>>, musicbrainz::api::Error> = Ok(matches_4.clone());
|
let result_4: Result<Vec<Match<Album>>, musicbrainz::Error> = Ok(matches_4.clone());
|
||||||
|
|
||||||
// Other albums have an MBID and so they will be skipped.
|
// Other albums have an MBID and so they will be skipped.
|
||||||
let mut seq = Sequence::new();
|
let mut seq = Sequence::new();
|
||||||
@ -300,7 +297,7 @@ mod tests {
|
|||||||
fn fetch_musicbrainz_api_error() {
|
fn fetch_musicbrainz_api_error() {
|
||||||
let mut mb_api = Box::new(MockIMusicBrainz::new());
|
let mut mb_api = Box::new(MockIMusicBrainz::new());
|
||||||
|
|
||||||
let error = Err(musicbrainz::api::Error::RateLimit);
|
let error = Err(musicbrainz::Error::RateLimit);
|
||||||
|
|
||||||
mb_api
|
mb_api
|
||||||
.expect_search_release_group()
|
.expect_search_release_group()
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
use std::cmp;
|
use std::cmp;
|
||||||
|
|
||||||
use musichoard::{collection::album::Album, interface::musicbrainz::Match};
|
use musichoard::collection::album::Album;
|
||||||
|
|
||||||
use crate::tui::app::{
|
use crate::tui::{
|
||||||
|
app::{
|
||||||
machine::{App, AppInner, AppMachine},
|
machine::{App, AppInner, AppMachine},
|
||||||
AppPublic, AppPublicMatches, AppState, IAppInteractMatches, WidgetState,
|
AppPublic, AppPublicMatches, AppState, IAppInteractMatches, WidgetState,
|
||||||
|
},
|
||||||
|
lib::interface::musicbrainz::Match,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
@ -8,7 +8,7 @@ mod search;
|
|||||||
|
|
||||||
use crate::tui::{
|
use crate::tui::{
|
||||||
app::{selection::Selection, AppPublic, AppPublicInner, AppState, IAppAccess, IAppInteract},
|
app::{selection::Selection, AppPublic, AppPublicInner, AppState, IAppAccess, IAppInteract},
|
||||||
lib::{external::musicbrainz::api::IMusicBrainz, IMusicHoard},
|
lib::{interface::musicbrainz::IMusicBrainz, IMusicHoard},
|
||||||
};
|
};
|
||||||
|
|
||||||
use browse::AppBrowse;
|
use browse::AppBrowse;
|
||||||
@ -37,7 +37,7 @@ pub struct AppMachine<STATE> {
|
|||||||
pub struct AppInner {
|
pub struct AppInner {
|
||||||
running: bool,
|
running: bool,
|
||||||
music_hoard: Box<dyn IMusicHoard>,
|
music_hoard: Box<dyn IMusicHoard>,
|
||||||
mb_api: Box<dyn IMusicBrainz>,
|
musicbrainz: Box<dyn IMusicBrainz>,
|
||||||
selection: Selection,
|
selection: Selection,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,12 +121,12 @@ impl IAppAccess for App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppInner {
|
impl AppInner {
|
||||||
pub fn new(music_hoard: Box<dyn IMusicHoard>, mb_api: Box<dyn IMusicBrainz>) -> Self {
|
pub fn new(music_hoard: Box<dyn IMusicHoard>, musicbrainz: Box<dyn IMusicBrainz>) -> Self {
|
||||||
let selection = Selection::new(music_hoard.get_collection());
|
let selection = Selection::new(music_hoard.get_collection());
|
||||||
AppInner {
|
AppInner {
|
||||||
running: true,
|
running: true,
|
||||||
music_hoard,
|
music_hoard,
|
||||||
mb_api,
|
musicbrainz,
|
||||||
selection,
|
selection,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -147,7 +147,7 @@ mod tests {
|
|||||||
|
|
||||||
use crate::tui::{
|
use crate::tui::{
|
||||||
app::{AppState, IAppInteract, IAppInteractBrowse},
|
app::{AppState, IAppInteract, IAppInteractBrowse},
|
||||||
lib::{external::musicbrainz::api::MockIMusicBrainz, MockIMusicHoard},
|
lib::{interface::musicbrainz::MockIMusicBrainz, MockIMusicHoard},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
@ -4,10 +4,9 @@ mod selection;
|
|||||||
pub use machine::App;
|
pub use machine::App;
|
||||||
pub use selection::{Category, Delta, Selection, WidgetState};
|
pub use selection::{Category, Delta, Selection, WidgetState};
|
||||||
|
|
||||||
use musichoard::{
|
use musichoard::collection::{album::Album, Collection};
|
||||||
collection::{album::Album, Collection},
|
|
||||||
interface::musicbrainz::Match,
|
use crate::tui::lib::interface::musicbrainz::Match;
|
||||||
};
|
|
||||||
|
|
||||||
pub enum AppState<BS, IS, RS, SS, MS, ES, CS> {
|
pub enum AppState<BS, IS, RS, SS, MS, ES, CS> {
|
||||||
Browse(BS),
|
Browse(BS),
|
||||||
|
@ -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 app::App;
|
||||||
pub use event::EventChannel;
|
pub use event::EventChannel;
|
||||||
pub use handler::EventHandler;
|
pub use handler::EventHandler;
|
||||||
|
pub use lib::external::musicbrainz::MusicBrainz;
|
||||||
pub use listener::EventListener;
|
pub use listener::EventListener;
|
||||||
pub use ui::Ui;
|
pub use ui::Ui;
|
||||||
|
|
||||||
@ -173,7 +174,7 @@ mod testmod;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::{io, thread};
|
use std::{io, thread};
|
||||||
|
|
||||||
use lib::external::musicbrainz::api::MockIMusicBrainz;
|
use lib::interface::musicbrainz::MockIMusicBrainz;
|
||||||
use ratatui::{backend::TestBackend, Terminal};
|
use ratatui::{backend::TestBackend, Terminal};
|
||||||
|
|
||||||
use musichoard::collection::Collection;
|
use musichoard::collection::Collection;
|
||||||
|
@ -1,14 +1,11 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use musichoard::{
|
use musichoard::collection::{
|
||||||
collection::{
|
|
||||||
album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus},
|
album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus},
|
||||||
artist::Artist,
|
artist::Artist,
|
||||||
musicbrainz::IMusicBrainzRef,
|
musicbrainz::IMusicBrainzRef,
|
||||||
track::{Track, TrackFormat, TrackQuality},
|
track::{Track, TrackFormat, TrackQuality},
|
||||||
Collection,
|
Collection,
|
||||||
},
|
|
||||||
interface::musicbrainz::Match,
|
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Alignment, Rect},
|
layout::{Alignment, Rect},
|
||||||
@ -18,7 +15,10 @@ use ratatui::{
|
|||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::tui::app::{AppPublicState, AppState, Category, IAppAccess, Selection, WidgetState};
|
use crate::tui::{
|
||||||
|
app::{AppPublicState, AppState, Category, IAppAccess, Selection, WidgetState},
|
||||||
|
lib::interface::musicbrainz::Match,
|
||||||
|
};
|
||||||
|
|
||||||
const COLOR_BG: Color = Color::Black;
|
const COLOR_BG: Color = Color::Black;
|
||||||
const COLOR_BG_HL: Color = Color::DarkGray;
|
const COLOR_BG_HL: Color = Color::DarkGray;
|
||||||
|
Loading…
Reference in New Issue
Block a user