diff --git a/Cargo.lock b/Cargo.lock index 294a0ff..327ce37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index a3a58c7..89ce141 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/examples/musicbrainz_api/search.rs b/examples/musicbrainz_api/search.rs new file mode 100644 index 0000000..96fd893 --- /dev/null +++ b/examples/musicbrainz_api/search.rs @@ -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, +} + +#[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 { + 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 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:#?}"); + } + } +} diff --git a/examples/musicbrainz_api/search_release_group.rs b/examples/musicbrainz_api/search_release_group.rs deleted file mode 100644 index 93e2a0a..0000000 --- a/examples/musicbrainz_api/search_release_group.rs +++ /dev/null @@ -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, -} - -#[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 { - 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 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:#?}"); -} diff --git a/src/external/musicbrainz/api/mod.rs b/src/external/musicbrainz/api/mod.rs index b828450..16173cd 100644 --- a/src/external/musicbrainz/api/mod.rs +++ b/src/external/musicbrainz/api/mod.rs @@ -56,17 +56,21 @@ impl MusicBrainzClient { pub fn new(http: Http) -> Self { MusicBrainzClient { http } } +} - fn format_album_date(date: &AlbumDate) -> Option { +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::::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::::format_album_date(&(1986).into()), - Some(String::from("1986")) - ); - assert_eq!( - MusicBrainzClient::::format_album_date(&(1986, 4).into()), - Some(String::from("1986-04")) - ); - assert_eq!( - MusicBrainzClient::::format_album_date(&(1986, 4, 21).into()), - Some(String::from("1986-04-21")) + ApiDisplay::format_album_date(&(1986, 4, 21).into()), + "1986-04-21" ); } diff --git a/src/external/musicbrainz/api/search.rs b/src/external/musicbrainz/api/search.rs deleted file mode 100644 index 71ada9f..0000000 --- a/src/external/musicbrainz/api/search.rs +++ /dev/null @@ -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 MusicBrainzClient { - pub fn search_release_group( - &mut self, - request: SearchReleaseGroupRequest, - ) -> Result { - let mut query: Vec = 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, -} - -#[derive(Clone, Deserialize)] -#[serde(rename_all(deserialize = "kebab-case"))] -struct DeserializeSearchReleaseGroupResponse { - release_groups: Vec, -} - -impl From 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>, -} - -#[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>, -} - -impl From - 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(); - } -} diff --git a/src/external/musicbrainz/api/search/artist.rs b/src/external/musicbrainz/api/search/artist.rs new file mode 100644 index 0000000..6ad7a41 --- /dev/null +++ b/src/external/musicbrainz/api/search/artist.rs @@ -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>; + +impl_term!(string, SearchArtist<'a>, String, &'a str); + +#[derive(Debug, PartialEq, Eq)] +pub struct SearchArtistResponse { + pub artists: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all(deserialize = "kebab-case"))] +pub struct DeserializeSearchArtistResponse { + artists: Vec, +} + +impl From 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, + pub disambiguation: Option, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all(deserialize = "kebab-case"))] +struct DeserializeSearchArtistResponseArtist { + score: u8, + id: SerdeMbid, + name: String, + sort_name: String, + disambiguation: Option, +} + +impl From for SearchArtistResponseArtist { + fn from(value: DeserializeSearchArtistResponseArtist) -> Self { + let sort: Option = 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); + } +} diff --git a/src/external/musicbrainz/api/search/mod.rs b/src/external/musicbrainz/api/search/mod.rs new file mode 100644 index 0000000..564063b --- /dev/null +++ b/src/external/musicbrainz/api/search/mod.rs @@ -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 []( + &mut self, + query: [] + ) -> Result<[], 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: [] = self.http.get(&url)?; + Ok(response.into()) + } + } + }; +} + +impl MusicBrainzClient { + impl_search_entity!(Artist, "artist"); + impl_search_entity!(ReleaseGroup, "release-group"); +} diff --git a/src/external/musicbrainz/api/search/query.rs b/src/external/musicbrainz/api/search/query.rs new file mode 100644 index 0000000..6147d3c --- /dev/null +++ b/src/external/musicbrainz/api/search/query.rs @@ -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 { + Term(Entity), + Expr(Query), +} + +impl From for Expression { + fn from(value: Entity) -> Self { + Expression::Term(value) + } +} + +impl From> for Expression { + fn from(value: Query) -> Self { + Expression::Expr(value) + } +} + +impl fmt::Display for Expression { + 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 { + _marker: PhantomData, +} + +impl Default for EmptyQuery { + fn default() -> Self { + EmptyQuery { + _marker: PhantomData, + } + } +} + +impl EmptyQuery { + pub fn expression>>(self, expr: Expr) -> Query { + Query { + left: (None, Box::new(expr.into())), + right: vec![], + } + } + + pub fn require(self) -> EmptyQueryJoin { + EmptyQueryJoin { + unary: Unary::Require, + _marker: PhantomData, + } + } + + pub fn prohibit(self) -> EmptyQueryJoin { + EmptyQueryJoin { + unary: Unary::Prohibit, + _marker: PhantomData, + } + } +} + +pub struct EmptyQueryJoin { + unary: Unary, + _marker: PhantomData, +} + +impl EmptyQueryJoin { + pub fn expression>>(self, expr: Expr) -> Query { + Query { + left: (Some(self.unary), Box::new(expr.into())), + right: vec![], + } + } +} + +pub struct Query { + left: (Option, Box>), + right: Vec<(Logical, Box>)>, +} + +impl fmt::Display for Query { + 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 Query { + #[allow(clippy::new_ret_no_self)] + pub fn new() -> EmptyQuery { + EmptyQuery::default() + } + + pub fn require(self) -> QueryJoin { + QueryJoin { + logical: Logical::Unary(Unary::Require), + query: self, + } + } + + pub fn prohibit(self) -> QueryJoin { + QueryJoin { + logical: Logical::Unary(Unary::Prohibit), + query: self, + } + } + + pub fn and(self) -> QueryJoin { + QueryJoin { + logical: Logical::Binary(Boolean::And), + query: self, + } + } + + pub fn or(self) -> QueryJoin { + QueryJoin { + logical: Logical::Binary(Boolean::Or), + query: self, + } + } + + pub fn not(self) -> QueryJoin { + QueryJoin { + logical: Logical::Binary(Boolean::Not), + query: self, + } + } +} + +pub struct QueryJoin { + logical: Logical, + query: Query, +} + +impl QueryJoin { + pub fn expression>>(mut self, expr: Expr) -> Query { + 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>; + + 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\"" + ); + } +} diff --git a/src/external/musicbrainz/api/search/release_group.rs b/src/external/musicbrainz/api/search/release_group.rs new file mode 100644 index 0000000..4192630 --- /dev/null +++ b/src/external/musicbrainz/api/search/release_group.rs @@ -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>; + +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, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all(deserialize = "kebab-case"))] +pub struct DeserializeSearchReleaseGroupResponse { + release_groups: Vec, +} + +impl From 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>, +} + +#[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>, +} + +impl From + 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); + } +} diff --git a/src/tui/app/machine/browse.rs b/src/tui/app/machine/browse.rs index 486ce5e..9d5f97e 100644 --- a/src/tui/app/machine/browse.rs +++ b/src/tui/app/machine/browse.rs @@ -91,34 +91,35 @@ impl IAppInteractBrowse for AppMachine { } }; - 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 album_iter = artist.albums.iter().peekable(); + while let Some(album) = album_iter.next() { + if album.musicbrainz.is_some() { + continue; + } + + match self.inner.musicbrainz.search_release_group(arid, album) { + Ok(list) => matches.push(AppMatchesInfo::album(album.clone(), list)), + Err(err) => return AppMachine::error(self.inner, err.to_string()).into(), + } + + if album_iter.peek().is_some() { + 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(), + }, }; - 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() { - continue; - } - - match self.inner.musicbrainz.search_release_group(arid, album) { - Ok(matches) => artist_album_matches.push(AppMatchesInfo { - matching: album.clone(), - matches, - }), - Err(err) => return AppMachine::error(self.inner, err.to_string()).into(), - } - - if album_iter.peek().is_some() { - thread::sleep(time::Duration::from_secs(1)); - } - } - - 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 { #[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>, 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); diff --git a/src/tui/app/machine/matches.rs b/src/tui/app/machine/matches.rs index ee1e5df..6d028e5 100644 --- a/src/tui/app/machine/matches.rs +++ b/src/tui/app/machine/matches.rs @@ -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>, +} + +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>, + pub list: Vec>, +} + +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>) -> Self { + AppMatchesInfo::Artist(AppArtistMatchesInfo { matching, list }) + } + + pub fn album(matching: Album, list: Vec>) -> 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 { 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> for App { impl<'a> From<&'a mut AppMachine> for AppPublic<'a> { fn from(machine: &'a mut AppMachine) -> 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 { 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 { 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 { #[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 { + impl AppMatchesInfo { + fn album_ref(&self) -> &AppAlbumMatchesInfo { + match self { + Self::Album(a) => a, + Self::Artist(_) => panic!(), + } + } + } + + fn artist_matches_info_vec() -> Vec { + 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 { 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) { 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(); diff --git a/src/tui/app/mod.rs b/src/tui/app/mod.rs index 224cbc9..b938247 100644 --- a/src/tui/app/mod.rs +++ b/src/tui/app/mod.rs @@ -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], +} + +#[derive(Debug, PartialEq, Eq)] +pub struct AppPublicAlbumMatches<'app> { + pub matching: &'app Album, + pub list: &'app [Match], +} + +#[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]>, + pub matches: Option>, pub state: &'app mut WidgetState, } @@ -148,6 +165,22 @@ impl AppState { 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"); diff --git a/src/tui/lib/external/musicbrainz/mod.rs b/src/tui/lib/external/musicbrainz/mod.rs index b68312a..903c48c 100644 --- a/src/tui/lib/external/musicbrainz/mod.rs +++ b/src/tui/lib/external/musicbrainz/mod.rs @@ -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 MusicBrainz { } impl IMusicBrainz for MusicBrainz { + fn search_artist(&mut self, artist: &Artist) -> Result>, 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 IMusicBrainz for MusicBrainz { // 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 IMusicBrainz for MusicBrainz { } } +fn from_search_artist_response_artist(entity: SearchArtistResponseArtist) -> Match { + 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 { diff --git a/src/tui/lib/interface/musicbrainz/mod.rs b/src/tui/lib/interface/musicbrainz/mod.rs index 1df8176..5679d44 100644 --- a/src/tui/lib/interface/musicbrainz/mod.rs +++ b/src/tui/lib/interface/musicbrainz/mod.rs @@ -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>, Error>; fn search_release_group( &mut self, arid: &Mbid, @@ -19,11 +20,20 @@ pub trait IMusicBrainz { pub struct Match { pub score: u8, pub item: T, + pub disambiguation: Option, } impl Match { pub fn new(score: u8, item: T) -> Self { - Match { score, item } + Match { + score, + item, + disambiguation: None, + } + } + + pub fn set_disambiguation>(&mut self, disambiguation: S) { + self.disambiguation = Some(disambiguation.into()) } } diff --git a/src/tui/ui/display.rs b/src/tui/ui/display.rs index 893a515..f566c56 100644 --- a/src/tui/ui/display.rs +++ b/src/tui/ui/display.rs @@ -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) -> String { + pub fn display_artist_match(match_artist: &Match) -> 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) -> String { format!( "{:010} | {} [{}] ({}%)", UiDisplay::display_album_date(&match_album.item.date), diff --git a/src/tui/ui/matches.rs b/src/tui/ui/matches.rs index cbd2da4..301ed17 100644 --- a/src/tui/ui/matches.rs +++ b/src/tui/ui/matches.rs @@ -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], state: &'b mut WidgetState) -> Self { +impl<'a, 'b> MatchesState<'a, 'b> { + pub fn new(matches: Option, 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], 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::>(), ); - AlbumMatchesState { list, state } + MatchesState { + matching, + list, + state, + } + } + + fn albums(matching: &Album, matches: &[Match], 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::>(), + ); + + MatchesState { + matching, + list, + state, + } } } diff --git a/src/tui/ui/minibuffer.rs b/src/tui/ui/minibuffer.rs index 114d34a..e8e0610 100644 --- a/src/tui/ui/minibuffer.rs +++ b/src/tui/ui/minibuffer.rs @@ -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, diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs index cf8ab4d..47b2939 100644 --- a/src/tui/ui/mod.rs +++ b/src/tui/ui/mod.rs @@ -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]>, + matches: Option, 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>(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], + ) -> AppPublicMatchesInfo<'app> { + AppPublicMatchesInfo::Artist(AppPublicArtistMatches { matching, list }) + } + + fn album_matches<'app>( + matching: &'app Album, + list: &'app [Match], + ) -> 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(); + } }