Make fetch also fetch artist MBID if it is missing (#201)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m4s
Cargo CI / Lint (push) Successful in 1m6s

Closes #191

Reviewed-on: #201
This commit is contained in:
Wojciech Kozlowski 2024-08-30 17:58:44 +02:00
parent c38961c3c1
commit 398963b9fd
19 changed files with 1525 additions and 561 deletions

5
Cargo.lock generated
View File

@ -645,6 +645,7 @@ dependencies = [
"mockall",
"once_cell",
"openssh",
"paste",
"ratatui",
"reqwest",
"serde",
@ -812,9 +813,9 @@ dependencies = [
[[package]]
name = "paste"
version = "1.0.14"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "percent-encoding"

View File

@ -10,6 +10,7 @@ aho-corasick = { version = "1.1.2", optional = true }
crossterm = { version = "0.27.0", optional = true}
once_cell = { version = "1.19.0", optional = true}
openssh = { version = "0.10.3", features = ["native-mux"], default-features = false, optional = true}
paste = { version = "1.0.15", optional = true }
ratatui = { version = "0.26.0", optional = true}
reqwest = { version = "0.11.25", features = ["blocking", "json"], optional = true }
serde = { version = "1.0.196", features = ["derive"], optional = true }
@ -33,7 +34,7 @@ bin = ["structopt"]
database-json = ["serde", "serde_json"]
library-beets = []
library-beets-ssh = ["openssh", "tokio"]
musicbrainz = ["reqwest", "serde", "serde_json"]
musicbrainz = ["paste", "reqwest", "serde", "serde_json"]
tui = ["aho-corasick", "crossterm", "once_cell", "ratatui"]
[[bin]]
@ -50,8 +51,8 @@ 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"
name = "musicbrainz-api---search"
path = "examples/musicbrainz_api/search.rs"
required-features = ["bin", "musicbrainz"]
[package.metadata.docs.rs]

View File

@ -0,0 +1,150 @@
#![allow(non_snake_case)]
use std::{num::ParseIntError, str::FromStr};
use musichoard::{
collection::{album::AlbumDate, musicbrainz::Mbid},
external::musicbrainz::{
api::{
search::{SearchArtistRequest, SearchReleaseGroupRequest},
MusicBrainzClient,
},
http::MusicBrainzHttp,
},
};
use structopt::StructOpt;
use uuid::Uuid;
const USER_AGENT: &str = concat!(
"MusicHoard---examples---musicbrainz-api---search/",
env!("CARGO_PKG_VERSION"),
" ( musichoard@thenineworlds.net )"
);
#[derive(StructOpt)]
struct Opt {
#[structopt(subcommand)]
entity: OptEntity,
}
#[derive(StructOpt)]
enum OptEntity {
#[structopt(about = "Search artist")]
Artist(OptArtist),
#[structopt(about = "Search release group")]
ReleaseGroup(OptReleaseGroup),
}
#[derive(StructOpt)]
struct OptArtist {
#[structopt(help = "Artist search string")]
string: String,
}
#[derive(StructOpt)]
enum OptReleaseGroup {
#[structopt(about = "Search by artist MBID, title(, and date)")]
Title(OptReleaseGroupTitle),
#[structopt(about = "Search by release group MBID")]
Rgid(OptReleaseGroupRgid),
}
#[derive(StructOpt)]
struct OptReleaseGroupTitle {
#[structopt(help = "Release group's artist MBID")]
arid: Uuid,
#[structopt(help = "Release group title")]
title: String,
#[structopt(help = "Release group release date")]
date: Option<Date>,
}
#[derive(StructOpt)]
struct OptReleaseGroupRgid {
#[structopt(help = "Release group MBID")]
rgid: Uuid,
}
struct Date(AlbumDate);
impl FromStr for Date {
type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut elems = s.split('-');
let elem = elems.next();
let year = elem.map(|s| s.parse()).transpose()?;
let elem = elems.next();
let month = elem.map(|s| s.parse()).transpose()?;
let elem = elems.next();
let day = elem.map(|s| s.parse()).transpose()?;
Ok(Date(AlbumDate::new(year, month, day)))
}
}
impl From<Date> for AlbumDate {
fn from(value: Date) -> Self {
value.0
}
}
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);
match opt.entity {
OptEntity::Artist(opt_artist) => {
let query = SearchArtistRequest::new().string(&opt_artist.string);
println!("Query: {query}");
let matches = client
.search_artist(query)
.expect("failed to make API call");
println!("{matches:#?}");
}
OptEntity::ReleaseGroup(opt_release_group) => {
let arid: Mbid;
let date: AlbumDate;
let title: String;
let rgid: Mbid;
let query = match opt_release_group {
OptReleaseGroup::Title(opt_title) => {
arid = opt_title.arid.into();
date = opt_title.date.map(Into::into).unwrap_or_default();
title = opt_title.title;
SearchReleaseGroupRequest::new()
.arid(&arid)
.and()
.release_group(&title)
.and()
.first_release_date(&date)
}
OptReleaseGroup::Rgid(opt_rgid) => {
rgid = opt_rgid.rgid.into();
SearchReleaseGroupRequest::new().rgid(&rgid)
}
};
println!("Query: {query}");
let matches = client
.search_release_group(query)
.expect("failed to make API call");
println!("{matches:#?}");
}
}
}

View File

@ -1,114 +0,0 @@
#![allow(non_snake_case)]
use std::{num::ParseIntError, str::FromStr};
use musichoard::{
collection::{album::AlbumDate, musicbrainz::Mbid},
external::musicbrainz::{
api::{search::SearchReleaseGroupRequest, MusicBrainzClient},
http::MusicBrainzHttp,
},
};
use structopt::StructOpt;
use uuid::Uuid;
const USER_AGENT: &str = concat!(
"MusicHoard---examples---musicbrainz-api---search-release-group/",
env!("CARGO_PKG_VERSION"),
" ( musichoard@thenineworlds.net )"
);
#[derive(StructOpt)]
struct Opt {
#[structopt(subcommand)]
command: OptCommand,
}
#[derive(StructOpt)]
enum OptCommand {
#[structopt(about = "Search by artist MBID, title(, and date)")]
Title(OptTitle),
#[structopt(about = "Search by release group MBID")]
Rgid(OptRgid),
}
#[derive(StructOpt)]
struct OptTitle {
#[structopt(help = "Release group's artist MBID")]
arid: Uuid,
#[structopt(help = "Release group title")]
title: String,
#[structopt(help = "Release group release date")]
date: Option<Date>,
}
#[derive(StructOpt)]
struct OptRgid {
#[structopt(help = "Release group MBID")]
rgid: Uuid,
}
struct Date(AlbumDate);
impl FromStr for Date {
type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut elems = s.split('-');
let elem = elems.next();
let year = elem.map(|s| s.parse()).transpose()?;
let elem = elems.next();
let month = elem.map(|s| s.parse()).transpose()?;
let elem = elems.next();
let day = elem.map(|s| s.parse()).transpose()?;
Ok(Date(AlbumDate::new(year, month, day)))
}
}
impl From<Date> for AlbumDate {
fn from(value: Date) -> Self {
value.0
}
}
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 mut request = SearchReleaseGroupRequest::default();
let arid: Mbid;
let date: AlbumDate;
let title: String;
let rgid: Mbid;
match opt.command {
OptCommand::Title(opt_title) => {
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) => {
rgid = opt_rgid.rgid.into();
request.rgid(&rgid);
}
};
let matches = client
.search_release_group(request)
.expect("failed to make API call");
println!("{matches:#?}");
}

View File

@ -56,17 +56,21 @@ impl<Http> MusicBrainzClient<Http> {
pub fn new(http: Http) -> Self {
MusicBrainzClient { http }
}
}
fn format_album_date(date: &AlbumDate) -> Option<String> {
pub struct ApiDisplay;
impl ApiDisplay {
fn format_album_date(date: &AlbumDate) -> 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}")),
Some(day) => format!("{year}-{month:02}-{day:02}"),
None => format!("{year}-{month:02}"),
},
None => Some(format!("{year}")),
None => format!("{year}"),
},
None => None,
None => String::from("*"),
}
}
}
@ -241,22 +245,15 @@ mod tests {
#[test]
fn format_album_date() {
struct Null;
assert_eq!(
MusicBrainzClient::<Null>::format_album_date(&AlbumDate::new(None, None, None)),
None
ApiDisplay::format_album_date(&AlbumDate::new(None, None, None)),
"*"
);
assert_eq!(ApiDisplay::format_album_date(&(1986).into()), "1986");
assert_eq!(ApiDisplay::format_album_date(&(1986, 4).into()), "1986-04");
assert_eq!(
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"))
ApiDisplay::format_album_date(&(1986, 4, 21).into()),
"1986-04-21"
);
}

View File

@ -1,269 +0,0 @@
//! 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, musicbrainz::Mbid},
core::collection::album::{AlbumPrimaryType, AlbumSecondaryType},
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();
}
}

View File

@ -0,0 +1,145 @@
use std::fmt;
use serde::Deserialize;
use crate::{
collection::{artist::ArtistId, musicbrainz::Mbid},
external::musicbrainz::api::SerdeMbid,
};
use super::query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin};
pub enum SearchArtist<'a> {
String(&'a str),
}
impl<'a> fmt::Display for SearchArtist<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::String(s) => write!(f, "\"{s}\""),
}
}
}
pub type SearchArtistRequest<'a> = Query<SearchArtist<'a>>;
impl_term!(string, SearchArtist<'a>, String, &'a str);
#[derive(Debug, PartialEq, Eq)]
pub struct SearchArtistResponse {
pub artists: Vec<SearchArtistResponseArtist>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
pub struct DeserializeSearchArtistResponse {
artists: Vec<DeserializeSearchArtistResponseArtist>,
}
impl From<DeserializeSearchArtistResponse> for SearchArtistResponse {
fn from(value: DeserializeSearchArtistResponse) -> Self {
SearchArtistResponse {
artists: value.artists.into_iter().map(Into::into).collect(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SearchArtistResponseArtist {
pub score: u8,
pub id: Mbid,
pub name: ArtistId,
pub sort: Option<ArtistId>,
pub disambiguation: Option<String>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
struct DeserializeSearchArtistResponseArtist {
score: u8,
id: SerdeMbid,
name: String,
sort_name: String,
disambiguation: Option<String>,
}
impl From<DeserializeSearchArtistResponseArtist> for SearchArtistResponseArtist {
fn from(value: DeserializeSearchArtistResponseArtist) -> Self {
let sort: Option<ArtistId> = Some(value.sort_name)
.filter(|s| s != &value.name)
.map(Into::into);
SearchArtistResponseArtist {
score: value.score,
id: value.id.into(),
name: value.name.into(),
sort,
disambiguation: value.disambiguation,
}
}
}
#[cfg(test)]
mod tests {
use mockall::predicate;
use crate::external::musicbrainz::{api::MusicBrainzClient, MockIMusicBrainzHttp};
use super::*;
fn de_response() -> DeserializeSearchArtistResponse {
let de_artist = DeserializeSearchArtistResponseArtist {
score: 67,
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
name: String::from("an artist"),
sort_name: String::from("artist, an"),
disambiguation: None,
};
DeserializeSearchArtistResponse {
artists: vec![de_artist.clone()],
}
}
fn response(de_response: DeserializeSearchArtistResponse) -> SearchArtistResponse {
SearchArtistResponse {
artists: de_response
.artists
.into_iter()
.map(|a| SearchArtistResponseArtist {
score: 67,
id: a.id.0,
name: a.name.clone().into(),
sort: Some(a.sort_name).filter(|sn| sn != &a.name).map(Into::into),
disambiguation: a.disambiguation,
})
.collect(),
}
}
#[test]
fn search_string() {
let mut http = MockIMusicBrainzHttp::new();
let url = format!(
"https://musicbrainz.org/ws/2\
/artist\
?query=%22{no_field}%22",
no_field = "an+artist",
);
let de_response = de_response();
let response = response(de_response.clone());
http.expect_get()
.times(1)
.with(predicate::eq(url))
.return_once(|_| Ok(de_response));
let mut client = MusicBrainzClient::new(http);
let name = "an artist";
let query = SearchArtistRequest::new().string(name);
let matches = client.search_artist(query).unwrap();
assert_eq!(matches, response);
}
}

View File

@ -0,0 +1,46 @@
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
mod artist;
mod query;
mod release_group;
pub use artist::{SearchArtistRequest, SearchArtistResponse, SearchArtistResponseArtist};
pub use release_group::{
SearchReleaseGroupRequest, SearchReleaseGroupResponse, SearchReleaseGroupResponseReleaseGroup,
};
use paste::paste;
use url::form_urlencoded;
use crate::external::musicbrainz::{
api::{
search::{
artist::DeserializeSearchArtistResponse,
release_group::DeserializeSearchReleaseGroupResponse,
},
Error, MusicBrainzClient, MB_BASE_URL,
},
IMusicBrainzHttp,
};
macro_rules! impl_search_entity {
($name:ident, $entity:literal) => {
paste! {
pub fn [<search_ $name:snake>](
&mut self,
query: [<Search $name Request>]
) -> Result<[<Search $name Response>], Error> {
let query: String =
form_urlencoded::byte_serialize(format!("{query}").as_bytes()).collect();
let url = format!("{MB_BASE_URL}/{entity}?query={query}", entity = $entity);
let response: [<DeserializeSearch $name Response>] = self.http.get(&url)?;
Ok(response.into())
}
}
};
}
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
impl_search_entity!(Artist, "artist");
impl_search_entity!(ReleaseGroup, "release-group");
}

View File

@ -0,0 +1,312 @@
use std::{fmt, marker::PhantomData};
pub enum Logical {
Unary(Unary),
Binary(Boolean),
}
impl fmt::Display for Logical {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Logical::Unary(u) => write!(f, "{u}"),
Logical::Binary(b) => write!(f, "{b}"),
}
}
}
pub enum Unary {
Require,
Prohibit,
}
impl fmt::Display for Unary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Unary::Require => write!(f, "+"),
Unary::Prohibit => write!(f, "-"),
}
}
}
pub enum Boolean {
And,
Or,
Not,
}
impl fmt::Display for Boolean {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Boolean::And => write!(f, "AND "),
Boolean::Or => write!(f, "OR "),
Boolean::Not => write!(f, "NOT "),
}
}
}
pub enum Expression<Entity> {
Term(Entity),
Expr(Query<Entity>),
}
impl<Entity> From<Entity> for Expression<Entity> {
fn from(value: Entity) -> Self {
Expression::Term(value)
}
}
impl<Entity> From<Query<Entity>> for Expression<Entity> {
fn from(value: Query<Entity>) -> Self {
Expression::Expr(value)
}
}
impl<Entity: fmt::Display> fmt::Display for Expression<Entity> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Expression::Term(t) => write!(f, "{t}"),
Expression::Expr(q) => write!(f, "({q})"),
}
}
}
pub struct EmptyQuery<Entity> {
_marker: PhantomData<Entity>,
}
impl<Entity> Default for EmptyQuery<Entity> {
fn default() -> Self {
EmptyQuery {
_marker: PhantomData,
}
}
}
impl<Entity> EmptyQuery<Entity> {
pub fn expression<Expr: Into<Expression<Entity>>>(self, expr: Expr) -> Query<Entity> {
Query {
left: (None, Box::new(expr.into())),
right: vec![],
}
}
pub fn require(self) -> EmptyQueryJoin<Entity> {
EmptyQueryJoin {
unary: Unary::Require,
_marker: PhantomData,
}
}
pub fn prohibit(self) -> EmptyQueryJoin<Entity> {
EmptyQueryJoin {
unary: Unary::Prohibit,
_marker: PhantomData,
}
}
}
pub struct EmptyQueryJoin<Entity> {
unary: Unary,
_marker: PhantomData<Entity>,
}
impl<Entity> EmptyQueryJoin<Entity> {
pub fn expression<Expr: Into<Expression<Entity>>>(self, expr: Expr) -> Query<Entity> {
Query {
left: (Some(self.unary), Box::new(expr.into())),
right: vec![],
}
}
}
pub struct Query<Entity> {
left: (Option<Unary>, Box<Expression<Entity>>),
right: Vec<(Logical, Box<Expression<Entity>>)>,
}
impl<Entity: fmt::Display> fmt::Display for Query<Entity> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(u) = &self.left.0 {
write!(f, "{u}")?;
}
write!(f, "{}", self.left.1)?;
for (logical, expr) in self.right.iter() {
write!(f, " {logical}{expr}")?;
}
Ok(())
}
}
impl<Entity> Query<Entity> {
#[allow(clippy::new_ret_no_self)]
pub fn new() -> EmptyQuery<Entity> {
EmptyQuery::default()
}
pub fn require(self) -> QueryJoin<Entity> {
QueryJoin {
logical: Logical::Unary(Unary::Require),
query: self,
}
}
pub fn prohibit(self) -> QueryJoin<Entity> {
QueryJoin {
logical: Logical::Unary(Unary::Prohibit),
query: self,
}
}
pub fn and(self) -> QueryJoin<Entity> {
QueryJoin {
logical: Logical::Binary(Boolean::And),
query: self,
}
}
pub fn or(self) -> QueryJoin<Entity> {
QueryJoin {
logical: Logical::Binary(Boolean::Or),
query: self,
}
}
pub fn not(self) -> QueryJoin<Entity> {
QueryJoin {
logical: Logical::Binary(Boolean::Not),
query: self,
}
}
}
pub struct QueryJoin<Entity> {
logical: Logical,
query: Query<Entity>,
}
impl<Entity> QueryJoin<Entity> {
pub fn expression<Expr: Into<Expression<Entity>>>(mut self, expr: Expr) -> Query<Entity> {
self.query.right.push((self.logical, Box::new(expr.into())));
self.query
}
}
macro_rules! impl_term {
($name:ident, $enum:ty, $variant:ident, $type:ty) => {
impl<'a> EmptyQuery<$enum> {
pub fn $name(self, $name: $type) -> Query<$enum> {
self.expression(<$enum>::$variant($name))
}
}
impl<'a> EmptyQueryJoin<$enum> {
pub fn $name(self, $name: $type) -> Query<$enum> {
self.expression(<$enum>::$variant($name))
}
}
impl<'a> QueryJoin<$enum> {
pub fn $name(self, $name: $type) -> Query<$enum> {
self.expression(<$enum>::$variant($name))
}
}
};
}
pub(crate) use impl_term;
#[cfg(test)]
mod tests {
use std::fmt;
use super::*;
pub enum TestEntity<'a> {
String(&'a str),
}
impl<'a> fmt::Display for TestEntity<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::String(s) => write!(f, "\"{s}\""),
}
}
}
type TestEntityRequest<'a> = Query<TestEntity<'a>>;
impl_term!(string, TestEntity<'a>, String, &'a str);
#[test]
fn lucene_logical() {
let query = TestEntityRequest::new()
.string("jakarta apache")
.or()
.string("jakarta");
assert_eq!(format!("{query}"), "\"jakarta apache\" OR \"jakarta\"");
let query = TestEntityRequest::new()
.string("jakarta apache")
.and()
.string("jakarta");
assert_eq!(format!("{query}"), "\"jakarta apache\" AND \"jakarta\"");
let query = TestEntityRequest::new()
.require()
.string("jakarta")
.or()
.string("lucene");
assert_eq!(format!("{query}"), "+\"jakarta\" OR \"lucene\"");
let query = TestEntityRequest::new()
.string("lucene")
.require()
.string("jakarta");
assert_eq!(format!("{query}"), "\"lucene\" +\"jakarta\"");
let query = TestEntityRequest::new()
.string("jakarta apache")
.not()
.string("Apache Lucene");
assert_eq!(
format!("{query}"),
"\"jakarta apache\" NOT \"Apache Lucene\""
);
let query = TestEntityRequest::new()
.prohibit()
.string("Apache Lucene")
.or()
.string("jakarta apache");
assert_eq!(
format!("{query}"),
"-\"Apache Lucene\" OR \"jakarta apache\""
);
let query = TestEntityRequest::new()
.string("jakarta apache")
.prohibit()
.string("Apache Lucene");
assert_eq!(format!("{query}"), "\"jakarta apache\" -\"Apache Lucene\"");
}
#[test]
fn lucene_grouping() {
let query = TestEntityRequest::new()
.expression(
TestEntityRequest::new()
.string("jakarta")
.or()
.string("apache"),
)
.and()
.string("website");
assert_eq!(
format!("{query}"),
"(\"jakarta\" OR \"apache\") AND \"website\""
);
}
}

View File

@ -0,0 +1,244 @@
use std::fmt;
use serde::Deserialize;
use crate::{
collection::{
album::{AlbumDate, AlbumId, AlbumPrimaryType, AlbumSecondaryType},
musicbrainz::Mbid,
},
external::musicbrainz::api::{
ApiDisplay, SerdeAlbumDate, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType, SerdeMbid,
},
};
use super::query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin};
pub enum SearchReleaseGroup<'a> {
String(&'a str),
Arid(&'a Mbid),
FirstReleaseDate(&'a AlbumDate),
ReleaseGroup(&'a str),
Rgid(&'a Mbid),
}
impl<'a> fmt::Display for SearchReleaseGroup<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::String(s) => write!(f, "\"{s}\""),
Self::Arid(arid) => write!(f, "arid:{}", arid.uuid().as_hyphenated()),
Self::FirstReleaseDate(date) => write!(
f,
"firstreleasedate:{}",
ApiDisplay::format_album_date(date)
),
Self::ReleaseGroup(release_group) => write!(f, "releasegroup:\"{release_group}\""),
Self::Rgid(rgid) => write!(f, "rgid:{}", rgid.uuid().as_hyphenated()),
}
}
}
pub type SearchReleaseGroupRequest<'a> = Query<SearchReleaseGroup<'a>>;
impl_term!(string, SearchReleaseGroup<'a>, String, &'a str);
impl_term!(arid, SearchReleaseGroup<'a>, Arid, &'a Mbid);
impl_term!(
first_release_date,
SearchReleaseGroup<'a>,
FirstReleaseDate,
&'a AlbumDate
);
impl_term!(release_group, SearchReleaseGroup<'a>, ReleaseGroup, &'a str);
impl_term!(rgid, SearchReleaseGroup<'a>, Rgid, &'a Mbid);
#[derive(Debug, PartialEq, Eq)]
pub struct SearchReleaseGroupResponse {
pub release_groups: Vec<SearchReleaseGroupResponseReleaseGroup>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
pub 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: AlbumId,
pub first_release_date: AlbumDate,
pub primary_type: AlbumPrimaryType,
pub secondary_types: Option<Vec<AlbumSecondaryType>>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
pub 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.into(),
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;
use crate::external::musicbrainz::{api::MusicBrainzClient, MockIMusicBrainzHttp};
use super::*;
fn de_response() -> DeserializeSearchReleaseGroupResponse {
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)]),
};
DeserializeSearchReleaseGroupResponse {
release_groups: vec![de_release_group.clone()],
}
}
fn response(de_response: DeserializeSearchReleaseGroupResponse) -> SearchReleaseGroupResponse {
SearchReleaseGroupResponse {
release_groups: de_response
.release_groups
.into_iter()
.map(|rg| SearchReleaseGroupResponseReleaseGroup {
score: 67,
id: rg.id.0,
title: rg.title.into(),
first_release_date: rg.first_release_date.0,
primary_type: rg.primary_type.0,
secondary_types: rg
.secondary_types
.map(|v| v.into_iter().map(|st| st.0).collect()),
})
.collect(),
}
}
#[test]
fn search_string() {
let mut http = MockIMusicBrainzHttp::new();
let url = format!(
"https://musicbrainz.org/ws/2\
/release-group\
?query=%22{title}%22",
title = "an+album",
);
let de_response = de_response();
let response = response(de_response.clone());
http.expect_get()
.times(1)
.with(predicate::eq(url))
.return_once(|_| Ok(de_response));
let mut client = MusicBrainzClient::new(http);
let title = "an album";
let query = SearchReleaseGroupRequest::new().string(title);
let matches = client.search_release_group(query).unwrap();
assert_eq!(matches, response);
}
#[test]
fn search_arid_album_date_release_group() {
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+AND+firstreleasedate%3A{date}",
arid = "00000000-0000-0000-0000-000000000000",
date = "1986-04",
title = "an+album",
);
let de_response = de_response();
let response = response(de_response.clone());
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 = "an album";
let date = (1986, 4).into();
let query = SearchReleaseGroupRequest::new()
.arid(&arid)
.and()
.release_group(title)
.and()
.first_release_date(&date);
let matches = client.search_release_group(query).unwrap();
assert_eq!(matches, response);
}
#[test]
fn search_rgid() {
let mut http = MockIMusicBrainzHttp::new();
let url = format!(
"https://musicbrainz.org/ws/2\
/release-group\
?query=rgid%3A{rgid}",
rgid = "11111111-1111-1111-1111-111111111111",
);
let de_response = de_response();
let response = response(de_response.clone());
http.expect_get()
.times(1)
.with(predicate::eq(url))
.return_once(|_| Ok(de_response));
let mut client = MusicBrainzClient::new(http);
let rgid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap();
let query = SearchReleaseGroupRequest::new().rgid(&rgid);
let matches = client.search_release_group(query).unwrap();
assert_eq!(matches, response);
}
}

View File

@ -91,14 +91,12 @@ impl IAppInteractBrowse for AppMachine<AppBrowse> {
}
};
let arid = match artist.musicbrainz {
Some(ref mbid) => mbid.mbid(),
None => {
return AppMachine::error(self.inner, "cannot fetch: missing artist MBID").into()
}
};
let mut matches = vec![];
match artist.musicbrainz {
Some(ref mbid) => {
let arid = mbid.mbid();
let mut artist_album_matches = vec![];
let mut album_iter = artist.albums.iter().peekable();
while let Some(album) = album_iter.next() {
if album.musicbrainz.is_some() {
@ -106,10 +104,7 @@ impl IAppInteractBrowse for AppMachine<AppBrowse> {
}
match self.inner.musicbrainz.search_release_group(arid, album) {
Ok(matches) => artist_album_matches.push(AppMatchesInfo {
matching: album.clone(),
matches,
}),
Ok(list) => matches.push(AppMatchesInfo::album(album.clone(), list)),
Err(err) => return AppMachine::error(self.inner, err.to_string()).into(),
}
@ -117,8 +112,14 @@ impl IAppInteractBrowse for AppMachine<AppBrowse> {
thread::sleep(time::Duration::from_secs(1));
}
}
}
None => match self.inner.musicbrainz.search_artist(artist) {
Ok(list) => matches.push(AppMatchesInfo::artist(artist.clone(), list)),
Err(err) => return AppMachine::error(self.inner, err.to_string()).into(),
},
};
AppMachine::matches(self.inner, artist_album_matches).into()
AppMachine::matches(self.inner, matches).into()
}
fn no_op(self) -> Self::APP {
@ -129,7 +130,7 @@ impl IAppInteractBrowse for AppMachine<AppBrowse> {
#[cfg(test)]
mod tests {
use mockall::{predicate, Sequence};
use musichoard::collection::{album::Album, musicbrainz::Mbid};
use musichoard::collection::{album::Album, artist::Artist, musicbrainz::Mbid};
use crate::tui::{
app::{
@ -257,8 +258,19 @@ mod tests {
let public_matches = public.state.unwrap_matches();
assert_eq!(public_matches.matching, Some(&album_1));
assert_eq!(public_matches.matches, Some(matches_1.as_slice()));
assert_eq!(
public_matches
.matches
.as_ref()
.unwrap()
.album_ref()
.matching,
&album_1
);
assert_eq!(
public_matches.matches.as_ref().unwrap().album_ref().list,
matches_1.as_slice()
);
let mut app = app.unwrap_matches().select();
@ -267,8 +279,19 @@ mod tests {
let public_matches = public.state.unwrap_matches();
assert_eq!(public_matches.matching, Some(&album_4));
assert_eq!(public_matches.matches, Some(matches_4.as_slice()));
assert_eq!(
public_matches
.matches
.as_ref()
.unwrap()
.album_ref()
.matching,
&album_4
);
assert_eq!(
public_matches.matches.as_ref().unwrap().album_ref().list,
matches_4.as_slice()
);
let app = app.unwrap_matches().select();
app.unwrap_browse();
@ -282,19 +305,78 @@ mod tests {
}
#[test]
fn fetch_musicbrainz_no_mbid() {
let browse = AppMachine::browse(inner(music_hoard(COLLECTION.to_owned())));
fn fetch_musicbrainz_no_artist_mbid() {
let mut mb_api = Box::new(MockIMusicBrainz::new());
let artist = COLLECTION[3].clone();
let artist_match_1 = Match::new(100, artist.clone());
let artist_match_2 = Match::new(50, artist.clone());
let matches = vec![artist_match_1.clone(), artist_match_2.clone()];
let result: Result<Vec<Match<Artist>>, musicbrainz::Error> = Ok(matches.clone());
mb_api
.expect_search_artist()
.with(predicate::eq(artist.clone()))
.times(1)
.return_once(|_| result);
let browse = AppMachine::browse(inner_with_mb(music_hoard(COLLECTION.to_owned()), mb_api));
// Use the fourth artist for this test as they have no MBID.
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let mut app = browse.fetch_musicbrainz();
let public = app.get();
assert!(matches!(public.state, AppState::Matches(_)));
let public_matches = public.state.unwrap_matches();
assert_eq!(
public_matches
.matches
.as_ref()
.unwrap()
.artist_ref()
.matching,
&artist
);
assert_eq!(
public_matches.matches.as_ref().unwrap().artist_ref().list,
matches.as_slice()
);
let app = app.unwrap_matches().select();
app.unwrap_browse();
}
#[test]
fn fetch_musicbrainz_artist_api_error() {
let mut mb_api = Box::new(MockIMusicBrainz::new());
let error = Err(musicbrainz::Error::RateLimit);
mb_api
.expect_search_artist()
.times(1)
.return_once(|_| error);
let browse = AppMachine::browse(inner_with_mb(music_hoard(COLLECTION.to_owned()), mb_api));
// Use the fourth artist for this test as they have no MBID.
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let app = browse.fetch_musicbrainz();
app.unwrap_error();
}
#[test]
fn fetch_musicbrainz_api_error() {
fn fetch_musicbrainz_album_api_error() {
let mut mb_api = Box::new(MockIMusicBrainz::new());
let error = Err(musicbrainz::Error::RateLimit);

View File

@ -1,19 +1,103 @@
use std::cmp;
use musichoard::collection::album::Album;
use musichoard::collection::{album::Album, artist::Artist};
use crate::tui::{
app::{
machine::{App, AppInner, AppMachine},
AppPublic, AppPublicMatches, AppState, IAppInteractMatches, WidgetState,
AppPublic, AppPublicAlbumMatches, AppPublicArtistMatches, AppPublicMatches,
AppPublicMatchesInfo, AppState, IAppInteractMatches, WidgetState,
},
lib::interface::musicbrainz::Match,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AppMatchesInfo {
pub struct AppArtistMatchesInfo {
pub matching: Artist,
pub list: Vec<Match<Artist>>,
}
impl AppArtistMatchesInfo {
fn is_empty(&self) -> bool {
self.list.is_empty()
}
fn len(&self) -> usize {
self.list.len()
}
}
impl<'app> From<&'app AppArtistMatchesInfo> for AppPublicArtistMatches<'app> {
fn from(value: &'app AppArtistMatchesInfo) -> Self {
AppPublicArtistMatches {
matching: &value.matching,
list: &value.list,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AppAlbumMatchesInfo {
pub matching: Album,
pub matches: Vec<Match<Album>>,
pub list: Vec<Match<Album>>,
}
impl AppAlbumMatchesInfo {
fn is_empty(&self) -> bool {
self.list.is_empty()
}
fn len(&self) -> usize {
self.list.len()
}
}
impl<'app> From<&'app AppAlbumMatchesInfo> for AppPublicAlbumMatches<'app> {
fn from(value: &'app AppAlbumMatchesInfo) -> Self {
AppPublicAlbumMatches {
matching: &value.matching,
list: &value.list,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AppMatchesInfo {
Artist(AppArtistMatchesInfo),
Album(AppAlbumMatchesInfo),
}
impl AppMatchesInfo {
fn is_empty(&self) -> bool {
match self {
Self::Artist(a) => a.is_empty(),
Self::Album(a) => a.is_empty(),
}
}
fn len(&self) -> usize {
match self {
Self::Artist(a) => a.len(),
Self::Album(a) => a.len(),
}
}
pub fn artist(matching: Artist, list: Vec<Match<Artist>>) -> Self {
AppMatchesInfo::Artist(AppArtistMatchesInfo { matching, list })
}
pub fn album(matching: Album, list: Vec<Match<Album>>) -> Self {
AppMatchesInfo::Album(AppAlbumMatchesInfo { matching, list })
}
}
impl<'app> From<&'app AppMatchesInfo> for AppPublicMatchesInfo<'app> {
fn from(value: &'app AppMatchesInfo) -> Self {
match value {
AppMatchesInfo::Artist(a) => AppPublicMatchesInfo::Artist(a.into()),
AppMatchesInfo::Album(a) => AppPublicMatchesInfo::Album(a.into()),
}
}
}
pub struct AppMatches {
@ -28,7 +112,7 @@ impl AppMachine<AppMatches> {
let mut state = WidgetState::default();
if let Some(matches_info) = matches_info_vec.first() {
index = Some(0);
if !matches_info.matches.is_empty() {
if !matches_info.is_empty() {
state.list.select(Some(0));
}
}
@ -52,18 +136,14 @@ impl From<AppMachine<AppMatches>> for App {
impl<'a> From<&'a mut AppMachine<AppMatches>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<AppMatches>) -> Self {
let (matching, matches) = match machine.state.index {
Some(index) => (
Some(&machine.state.matches_info_vec[index].matching),
Some(machine.state.matches_info_vec[index].matches.as_slice()),
),
None => (None, None),
};
let matches = machine
.state
.index
.map(|index| (&machine.state.matches_info_vec[index]).into());
AppPublic {
inner: (&mut machine.inner).into(),
state: AppState::Matches(AppPublicMatches {
matching,
matches,
state: &mut machine.state.state,
}),
@ -89,7 +169,6 @@ impl IAppInteractMatches for AppMachine<AppMatches> {
let to = cmp::min(
result,
self.state.matches_info_vec[self.state.index.unwrap()]
.matches
.len()
.saturating_sub(1),
);
@ -104,7 +183,7 @@ impl IAppInteractMatches for AppMachine<AppMatches> {
self.state.state = WidgetState::default();
if let Some(index) = self.state.index {
if let Some(matches_info) = self.state.matches_info_vec.get(index) {
if !matches_info.matches.is_empty() {
if !matches_info.is_empty() {
self.state.state.list.select(Some(0));
}
return self.into();
@ -125,7 +204,10 @@ impl IAppInteractMatches for AppMachine<AppMatches> {
#[cfg(test)]
mod tests {
use musichoard::collection::album::{AlbumDate, AlbumId, AlbumPrimaryType, AlbumSecondaryType};
use musichoard::collection::{
album::{AlbumDate, AlbumId, AlbumPrimaryType, AlbumSecondaryType},
artist::ArtistId,
};
use crate::tui::app::{
machine::tests::{inner, music_hoard},
@ -134,7 +216,40 @@ mod tests {
use super::*;
fn matches_info_vec() -> Vec<AppMatchesInfo> {
impl AppMatchesInfo {
fn album_ref(&self) -> &AppAlbumMatchesInfo {
match self {
Self::Album(a) => a,
Self::Artist(_) => panic!(),
}
}
}
fn artist_matches_info_vec() -> Vec<AppMatchesInfo> {
let artist_1 = Artist::new(ArtistId::new("Artist 1"));
let artist_1_1 = artist_1.clone();
let artist_match_1_1 = Match::new(100, artist_1_1);
let artist_1_2 = artist_1.clone();
let mut artist_match_1_2 = Match::new(100, artist_1_2);
artist_match_1_2.set_disambiguation("some disambiguation");
let list = vec![artist_match_1_1.clone(), artist_match_1_2.clone()];
let matches_info_1 = AppMatchesInfo::artist(artist_1.clone(), list);
let artist_2 = Artist::new(ArtistId::new("Artist 2"));
let artist_2_1 = artist_1.clone();
let album_match_2_1 = Match::new(100, artist_2_1);
let list = vec![album_match_2_1.clone()];
let matches_info_2 = AppMatchesInfo::artist(artist_2.clone(), list);
vec![matches_info_1, matches_info_2]
}
fn album_matches_info_vec() -> Vec<AppMatchesInfo> {
let album_1 = Album::new(
AlbumId::new("Album 1"),
AlbumDate::new(Some(1990), Some(5), None),
@ -143,23 +258,15 @@ mod tests {
);
let album_1_1 = album_1.clone();
let album_match_1_1 = Match {
score: 100,
item: album_1_1,
};
let album_match_1_1 = Match::new(100, album_1_1);
let mut album_1_2 = album_1.clone();
album_1_2.id.title.push_str(" extra title part");
album_1_2.secondary_types.pop();
let album_match_1_2 = Match {
score: 100,
item: album_1_2,
};
let album_match_1_2 = Match::new(100, album_1_2);
let matches_info_1 = AppMatchesInfo {
matching: album_1.clone(),
matches: vec![album_match_1_1.clone(), album_match_1_2.clone()],
};
let list = vec![album_match_1_1.clone(), album_match_1_2.clone()];
let matches_info_1 = AppMatchesInfo::album(album_1.clone(), list);
let album_2 = Album::new(
AlbumId::new("Album 2"),
@ -169,15 +276,10 @@ mod tests {
);
let album_2_1 = album_1.clone();
let album_match_2_1 = Match {
score: 100,
item: album_2_1,
};
let album_match_2_1 = Match::new(100, album_2_1);
let matches_info_2 = AppMatchesInfo {
matching: album_2.clone(),
matches: vec![album_match_2_1.clone()],
};
let list = vec![album_match_2_1.clone()];
let matches_info_2 = AppMatchesInfo::album(album_2.clone(), list);
vec![matches_info_1, matches_info_2]
}
@ -196,14 +298,13 @@ mod tests {
let public = app.get();
let public_matches = public.state.unwrap_matches();
assert_eq!(public_matches.matching, None);
assert_eq!(public_matches.matches, None);
assert_eq!(public_matches.state, &widget_state);
}
#[test]
fn create_nonempty() {
let matches_info_vec = matches_info_vec();
let matches_info_vec = album_matches_info_vec();
let matches = AppMachine::matches(inner(music_hoard(vec![])), matches_info_vec.clone());
let mut widget_state = WidgetState::default();
@ -217,17 +318,23 @@ mod tests {
let public = app.get();
let public_matches = public.state.unwrap_matches();
assert_eq!(public_matches.matching, Some(&matches_info_vec[0].matching));
assert_eq!(
public_matches.matches,
Some(matches_info_vec[0].matches.as_slice())
public_matches
.matches
.as_ref()
.unwrap()
.album_ref()
.matching,
&matches_info_vec[0].album_ref().matching
);
assert_eq!(
public_matches.matches.as_ref().unwrap().album_ref().list,
matches_info_vec[0].album_ref().list.as_slice()
);
assert_eq!(public_matches.state, &widget_state);
}
#[test]
fn matches_flow() {
let matches_info_vec = matches_info_vec();
fn matches_flow(matches_info_vec: Vec<AppMatchesInfo>) {
let matches = AppMachine::matches(inner(music_hoard(vec![])), matches_info_vec.clone());
let mut widget_state = WidgetState::default();
@ -261,9 +368,19 @@ mod tests {
matches.select().unwrap_browse();
}
#[test]
fn artist_matches_flow() {
matches_flow(artist_matches_info_vec());
}
#[test]
fn album_matches_flow() {
matches_flow(album_matches_info_vec());
}
#[test]
fn matches_abort() {
let matches_info_vec = matches_info_vec();
let matches_info_vec = album_matches_info_vec();
let matches = AppMachine::matches(inner(music_hoard(vec![])), matches_info_vec.clone());
let mut widget_state = WidgetState::default();

View File

@ -4,7 +4,7 @@ mod selection;
pub use machine::App;
pub use selection::{Category, Delta, Selection, WidgetState};
use musichoard::collection::{album::Album, Collection};
use musichoard::collection::{album::Album, artist::Artist, Collection};
use crate::tui::lib::interface::musicbrainz::Match;
@ -129,9 +129,26 @@ pub struct AppPublicInner<'app> {
pub selection: &'app mut Selection,
}
#[derive(Debug, PartialEq, Eq)]
pub struct AppPublicArtistMatches<'app> {
pub matching: &'app Artist,
pub list: &'app [Match<Artist>],
}
#[derive(Debug, PartialEq, Eq)]
pub struct AppPublicAlbumMatches<'app> {
pub matching: &'app Album,
pub list: &'app [Match<Album>],
}
#[derive(Debug, PartialEq, Eq)]
pub enum AppPublicMatchesInfo<'app> {
Artist(AppPublicArtistMatches<'app>),
Album(AppPublicAlbumMatches<'app>),
}
pub struct AppPublicMatches<'app> {
pub matching: Option<&'app Album>,
pub matches: Option<&'app [Match<Album>]>,
pub matches: Option<AppPublicMatchesInfo<'app>>,
pub state: &'app mut WidgetState,
}
@ -148,6 +165,22 @@ impl<BS, IS, RS, SS, MS, ES, CS> AppState<BS, IS, RS, SS, MS, ES, CS> {
mod tests {
use super::*;
impl<'app> AppPublicMatchesInfo<'app> {
pub fn artist_ref(&self) -> &AppPublicArtistMatches<'app> {
match self {
Self::Artist(m) => m,
_ => panic!(),
}
}
pub fn album_ref(&self) -> &AppPublicAlbumMatches<'app> {
match self {
Self::Album(m) => m,
_ => panic!(),
}
}
}
#[test]
fn app_is_state() {
let state = AppPublicState::Search("get rekt");

View File

@ -3,11 +3,15 @@
use musichoard::{
collection::{
album::{Album, AlbumDate},
artist::Artist,
musicbrainz::Mbid,
},
external::musicbrainz::{
api::{
search::{SearchReleaseGroupRequest, SearchReleaseGroupResponseReleaseGroup},
search::{
SearchArtistRequest, SearchArtistResponseArtist, SearchReleaseGroupRequest,
SearchReleaseGroupResponseReleaseGroup,
},
MusicBrainzClient,
},
IMusicBrainzHttp,
@ -28,6 +32,18 @@ impl<Http> MusicBrainz<Http> {
}
impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
fn search_artist(&mut self, artist: &Artist) -> Result<Vec<Match<Artist>>, Error> {
let query = SearchArtistRequest::new().string(&artist.id.name);
let mb_response = self.client.search_artist(query)?;
Ok(mb_response
.artists
.into_iter()
.map(from_search_artist_response_artist)
.collect())
}
fn search_release_group(
&mut self,
arid: &Mbid,
@ -37,13 +53,14 @@ impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
// with just the year should be enough anyway.
let date = AlbumDate::new(album.date.year, None, None);
let mut request = SearchReleaseGroupRequest::default();
request
let query = SearchReleaseGroupRequest::new()
.arid(arid)
.and()
.first_release_date(&date)
.and()
.release_group(&album.id.title);
let mb_response = self.client.search_release_group(request)?;
let mb_response = self.client.search_release_group(query)?;
Ok(mb_response
.release_groups
@ -53,6 +70,21 @@ impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
}
}
fn from_search_artist_response_artist(entity: SearchArtistResponseArtist) -> Match<Artist> {
let mut artist = Artist::new(entity.name);
if let Some(sort) = entity.sort {
artist.set_sort_key(sort);
}
artist.set_musicbrainz_ref(entity.id.into());
let mut artist_match = Match::new(entity.score, artist);
if let Some(disambiguation) = entity.disambiguation {
artist_match.set_disambiguation(disambiguation);
}
artist_match
}
fn from_search_release_group_response_release_group(
entity: SearchReleaseGroupResponseReleaseGroup,
) -> Match<Album> {

View File

@ -3,11 +3,12 @@
#[cfg(test)]
use mockall::automock;
use musichoard::collection::{album::Album, musicbrainz::Mbid};
use musichoard::collection::{album::Album, artist::Artist, musicbrainz::Mbid};
/// Trait for interacting with the MusicBrainz API.
#[cfg_attr(test, automock)]
pub trait IMusicBrainz {
fn search_artist(&mut self, name: &Artist) -> Result<Vec<Match<Artist>>, Error>;
fn search_release_group(
&mut self,
arid: &Mbid,
@ -19,11 +20,20 @@ pub trait IMusicBrainz {
pub struct Match<T> {
pub score: u8,
pub item: T,
pub disambiguation: Option<String>,
}
impl<T> Match<T> {
pub fn new(score: u8, item: T) -> Self {
Match { score, item }
Match {
score,
item,
disambiguation: None,
}
}
pub fn set_disambiguation<S: Into<String>>(&mut self, disambiguation: S) {
self.disambiguation = Some(disambiguation.into())
}
}

View File

@ -1,9 +1,10 @@
use musichoard::collection::{
album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus},
artist::Artist,
track::{TrackFormat, TrackQuality},
};
use crate::tui::lib::interface::musicbrainz::Match;
use crate::tui::{app::AppPublicMatchesInfo, lib::interface::musicbrainz::Match};
pub struct UiDisplay;
@ -97,18 +98,46 @@ impl UiDisplay {
}
}
pub fn display_matching_info(matching: Option<&Album>) -> String {
match matching {
Some(matching) => format!(
"Matching: {} | {}",
UiDisplay::display_album_date(&matching.date),
&matching.id.title
),
None => String::from("Matching: nothing"),
pub fn display_artist_matching(artist: &Artist) -> String {
format!("Matching artist: {}", &artist.id.name)
}
pub fn display_album_matching(album: &Album) -> String {
format!(
"Matching album: {} | {}",
UiDisplay::display_album_date(&album.date),
&album.id.title
)
}
pub fn display_nothing_matching() -> &'static str {
"Matching nothing"
}
pub fn display_matching_info(matches: Option<&AppPublicMatchesInfo>) -> String {
match matches.as_ref() {
Some(kind) => match kind {
AppPublicMatchesInfo::Artist(m) => UiDisplay::display_artist_matching(m.matching),
AppPublicMatchesInfo::Album(m) => UiDisplay::display_album_matching(m.matching),
},
None => UiDisplay::display_nothing_matching().to_string(),
}
}
pub fn display_match_string(match_album: &Match<Album>) -> String {
pub fn display_artist_match(match_artist: &Match<Artist>) -> String {
format!(
"{}{} ({}%)",
&match_artist.item.id.name,
&match_artist
.disambiguation
.as_ref()
.map(|d| format!(" ({d})"))
.unwrap_or_default(),
match_artist.score,
)
}
pub fn display_album_match(match_album: &Match<Album>) -> String {
format!(
"{:010} | {} [{}] ({}%)",
UiDisplay::display_album_date(&match_album.item.date),

View File

@ -1,23 +1,70 @@
use musichoard::collection::album::Album;
use musichoard::collection::{album::Album, artist::Artist};
use ratatui::widgets::{List, ListItem};
use crate::tui::{app::WidgetState, lib::interface::musicbrainz::Match, ui::display::UiDisplay};
use crate::tui::{
app::{AppPublicMatchesInfo, WidgetState},
lib::interface::musicbrainz::Match,
ui::display::UiDisplay,
};
pub struct AlbumMatchesState<'a, 'b> {
pub struct MatchesState<'a, 'b> {
pub matching: String,
pub list: List<'a>,
pub state: &'b mut WidgetState,
}
impl<'a, 'b> AlbumMatchesState<'a, 'b> {
pub fn new(matches: &[Match<Album>], state: &'b mut WidgetState) -> Self {
impl<'a, 'b> MatchesState<'a, 'b> {
pub fn new(matches: Option<AppPublicMatchesInfo>, state: &'b mut WidgetState) -> Self {
match matches {
Some(info) => match info {
AppPublicMatchesInfo::Artist(m) => Self::artists(m.matching, m.list, state),
AppPublicMatchesInfo::Album(m) => Self::albums(m.matching, m.list, state),
},
None => Self::empty(state),
}
}
fn empty(state: &'b mut WidgetState) -> Self {
MatchesState {
matching: UiDisplay::display_nothing_matching().to_string(),
list: List::default(),
state,
}
}
fn artists(matching: &Artist, matches: &[Match<Artist>], state: &'b mut WidgetState) -> Self {
let matching = UiDisplay::display_artist_matching(matching);
let list = List::new(
matches
.iter()
.map(UiDisplay::display_match_string)
.map(UiDisplay::display_artist_match)
.map(ListItem::new)
.collect::<Vec<ListItem>>(),
);
AlbumMatchesState { list, state }
MatchesState {
matching,
list,
state,
}
}
fn albums(matching: &Album, matches: &[Match<Album>], state: &'b mut WidgetState) -> Self {
let matching = UiDisplay::display_album_matching(matching);
let list = List::new(
matches
.iter()
.map(UiDisplay::display_album_match)
.map(ListItem::new)
.collect::<Vec<ListItem>>(),
);
MatchesState {
matching,
list,
state,
}
}
}

View File

@ -59,7 +59,7 @@ impl Minibuffer<'_> {
},
AppState::Matches(public) => Minibuffer {
paragraphs: vec![
Paragraph::new(UiDisplay::display_matching_info(public.matching)),
Paragraph::new(UiDisplay::display_matching_info(public.matches.as_ref())),
Paragraph::new("q: abort"),
],
columns: 2,

View File

@ -14,8 +14,10 @@ use ratatui::{layout::Rect, widgets::Paragraph, Frame};
use musichoard::collection::{album::Album, Collection};
use crate::tui::{
app::{AppPublicState, AppState, Category, IAppAccess, Selection, WidgetState},
lib::interface::musicbrainz::Match,
app::{
AppPublicMatchesInfo, AppPublicState, AppState, Category, IAppAccess, Selection,
WidgetState,
},
ui::{
browse::{
AlbumArea, AlbumState, ArtistArea, ArtistState, FrameArea, TrackArea, TrackState,
@ -23,7 +25,7 @@ use crate::tui::{
display::UiDisplay,
error::ErrorOverlay,
info::{AlbumOverlay, ArtistOverlay},
matches::AlbumMatchesState,
matches::MatchesState,
minibuffer::Minibuffer,
overlay::{OverlayBuilder, OverlaySize},
reload::ReloadOverlay,
@ -133,15 +135,13 @@ impl Ui {
}
fn render_matches_overlay(
matching: Option<&Album>,
matches: Option<&[Match<Album>]>,
matches: Option<AppPublicMatchesInfo>,
state: &mut WidgetState,
frame: &mut Frame,
) {
let area = OverlayBuilder::default().build(frame.size());
let matching_string = UiDisplay::display_matching_info(matching);
let st = AlbumMatchesState::new(matches.unwrap_or_default(), state);
UiWidget::render_overlay_list_widget(&matching_string, st.list, st.state, true, area, frame)
let st = MatchesState::new(matches, state);
UiWidget::render_overlay_list_widget(&st.matching, st.list, st.state, true, area, frame)
}
fn render_error_overlay<S: AsRef<str>>(title: S, msg: S, frame: &mut Frame) {
@ -167,7 +167,7 @@ impl IUi for Ui {
match state {
AppState::Info(_) => Self::render_info_overlay(collection, selection, frame),
AppState::Matches(public) => {
Self::render_matches_overlay(public.matching, public.matches, public.state, frame)
Self::render_matches_overlay(public.matches, public.state, frame)
}
AppState::Reload(_) => Self::render_reload_overlay(frame),
AppState::Error(msg) => Self::render_error_overlay("Error", msg, frame),
@ -185,15 +185,46 @@ mod tests {
};
use crate::tui::{
app::{AppPublic, AppPublicInner, AppPublicMatches, Delta},
app::{
AppPublic, AppPublicAlbumMatches, AppPublicArtistMatches, AppPublicInner,
AppPublicMatches, Delta,
},
lib::interface::musicbrainz::Match,
testmod::COLLECTION,
tests::terminal,
};
use super::*;
impl<'app> AppPublicArtistMatches<'app> {
fn get(&self) -> AppPublicArtistMatches<'app> {
AppPublicArtistMatches {
matching: self.matching,
list: self.list,
}
}
}
impl<'app> AppPublicAlbumMatches<'app> {
fn get(&self) -> AppPublicAlbumMatches<'app> {
AppPublicAlbumMatches {
matching: self.matching,
list: self.list,
}
}
}
impl<'app> AppPublicMatchesInfo<'app> {
fn get(&self) -> AppPublicMatchesInfo<'app> {
match self {
Self::Artist(a) => Self::Artist(a.get()),
Self::Album(a) => Self::Album(a.get()),
}
}
}
// Automock does not support returning types with generic lifetimes.
impl IAppAccess for AppPublic<'_> {
impl<'app> IAppAccess for AppPublic<'app> {
fn get(&mut self) -> AppPublic {
AppPublic {
inner: AppPublicInner {
@ -206,8 +237,7 @@ mod tests {
AppState::Reload(()) => AppState::Reload(()),
AppState::Search(s) => AppState::Search(s),
AppState::Matches(ref mut m) => AppState::Matches(AppPublicMatches {
matching: m.matching,
matches: m.matches,
matches: m.matches.as_mut().map(|k| k.get()),
state: m.state,
}),
AppState::Error(s) => AppState::Error(s),
@ -217,26 +247,35 @@ mod tests {
}
}
fn public_inner<'app>(
collection: &'app Collection,
selection: &'app mut Selection,
) -> AppPublicInner<'app> {
AppPublicInner {
collection,
selection,
}
}
fn artist_matches<'app>(
matching: &'app Artist,
list: &'app [Match<Artist>],
) -> AppPublicMatchesInfo<'app> {
AppPublicMatchesInfo::Artist(AppPublicArtistMatches { matching, list })
}
fn album_matches<'app>(
matching: &'app Album,
list: &'app [Match<Album>],
) -> AppPublicMatchesInfo<'app> {
AppPublicMatchesInfo::Album(AppPublicAlbumMatches { matching, list })
}
fn draw_test_suite(collection: &Collection, selection: &mut Selection) {
let mut terminal = terminal();
let album = Album::new(
AlbumId::new("An Album"),
AlbumDate::new(Some(1990), Some(5), None),
Some(AlbumPrimaryType::Album),
vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation],
);
let album_match = Match {
score: 80,
item: album.clone(),
};
let mut app = AppPublic {
inner: AppPublicInner {
collection,
selection,
},
inner: public_inner(collection, selection),
state: AppState::Browse(()),
};
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
@ -250,26 +289,6 @@ mod tests {
app.state = AppState::Search("");
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
let album_matches = [album_match.clone(), album_match.clone()];
let mut widget_state = WidgetState::default();
widget_state.list.select(Some(0));
app.state = AppState::Matches(AppPublicMatches {
matching: Some(&album),
matches: Some(&album_matches),
state: &mut widget_state,
});
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
let mut widget_state = WidgetState::default();
app.state = AppState::Matches(AppPublicMatches {
matching: None,
matches: None,
state: &mut widget_state,
});
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
app.state = AppState::Error("get rekt scrub");
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
@ -317,4 +336,86 @@ mod tests {
draw_test_suite(artists, &mut selection);
}
#[test]
fn draw_empty_matches() {
let collection = &COLLECTION;
let mut selection = Selection::new(collection);
let mut terminal = terminal();
let mut widget_state = WidgetState::default();
let mut app = AppPublic {
inner: public_inner(collection, &mut selection),
state: AppState::Matches(AppPublicMatches {
matches: None,
state: &mut widget_state,
}),
};
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
}
#[test]
fn draw_artist_matches() {
let collection = &COLLECTION;
let mut selection = Selection::new(collection);
let mut terminal = terminal();
let artist = Artist::new(ArtistId::new("an artist"));
let artist_match = Match {
score: 80,
item: artist.clone(),
disambiguation: None,
};
let list = [artist_match.clone(), artist_match.clone()];
let mut widget_state = WidgetState::default();
widget_state.list.select(Some(0));
let mut app = AppPublic {
inner: public_inner(collection, &mut selection),
state: AppState::Matches(AppPublicMatches {
matches: Some(artist_matches(&artist, &list)),
state: &mut widget_state,
}),
};
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
}
#[test]
fn draw_album_matches() {
let collection = &COLLECTION;
let mut selection = Selection::new(collection);
let mut terminal = terminal();
let album = Album::new(
AlbumId::new("An Album"),
AlbumDate::new(Some(1990), Some(5), None),
Some(AlbumPrimaryType::Album),
vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation],
);
let album_match = Match {
score: 80,
item: album.clone(),
disambiguation: None,
};
let list = [album_match.clone(), album_match.clone()];
let mut widget_state = WidgetState::default();
widget_state.list.select(Some(0));
let mut app = AppPublic {
inner: public_inner(collection, &mut selection),
state: AppState::Matches(AppPublicMatches {
matches: Some(album_matches(&album, &list)),
state: &mut widget_state,
}),
};
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
}
}