Add option for manual input during fetch (#219)
All checks were successful
Cargo CI / Lint (push) Successful in 1m5s
Cargo CI / Build and Test (push) Successful in 1m57s

Closes #188

Reviewed-on: #219
This commit is contained in:
Wojciech Kozlowski 2024-09-23 22:40:25 +02:00
parent d6f4b2b6b7
commit 90db5faae7
20 changed files with 1095 additions and 366 deletions

View File

@ -34,9 +34,9 @@ fn main() {
let mut request = LookupArtistRequest::new(&mbid); let mut request = LookupArtistRequest::new(&mbid);
request.include_release_groups(); request.include_release_groups();
let albums = client let response = client
.lookup_artist(request) .lookup_artist(request)
.expect("failed to make API call"); .expect("failed to make API call");
println!("{albums:#?}"); println!("{response:#?}");
} }

View File

@ -205,11 +205,11 @@ impl AlbumMeta {
} }
pub fn set_musicbrainz_ref(&mut self, mbref: MbAlbumRef) { pub fn set_musicbrainz_ref(&mut self, mbref: MbAlbumRef) {
_ = self.musicbrainz.insert(mbref); self.musicbrainz.replace(mbref);
} }
pub fn clear_musicbrainz_ref(&mut self) { pub fn clear_musicbrainz_ref(&mut self) {
_ = self.musicbrainz.take(); self.musicbrainz.take();
} }
} }
@ -371,7 +371,7 @@ mod tests {
album album
.meta .meta
.set_musicbrainz_ref(MbAlbumRef::from_url_str(MUSICBRAINZ).unwrap()); .set_musicbrainz_ref(MbAlbumRef::from_url_str(MUSICBRAINZ).unwrap());
_ = expected.insert(MbAlbumRef::from_url_str(MUSICBRAINZ).unwrap()); expected.replace(MbAlbumRef::from_url_str(MUSICBRAINZ).unwrap());
assert_eq!(album.meta.musicbrainz, expected); assert_eq!(album.meta.musicbrainz, expected);
album album
@ -382,12 +382,12 @@ mod tests {
album album
.meta .meta
.set_musicbrainz_ref(MbAlbumRef::from_url_str(MUSICBRAINZ_2).unwrap()); .set_musicbrainz_ref(MbAlbumRef::from_url_str(MUSICBRAINZ_2).unwrap());
_ = expected.insert(MbAlbumRef::from_url_str(MUSICBRAINZ_2).unwrap()); expected.replace(MbAlbumRef::from_url_str(MUSICBRAINZ_2).unwrap());
assert_eq!(album.meta.musicbrainz, expected); assert_eq!(album.meta.musicbrainz, expected);
// Clearing URLs. // Clearing URLs.
album.meta.clear_musicbrainz_ref(); album.meta.clear_musicbrainz_ref();
_ = expected.take(); expected.take();
assert_eq!(album.meta.musicbrainz, expected); assert_eq!(album.meta.musicbrainz, expected);
} }
} }

View File

@ -89,15 +89,15 @@ impl ArtistMeta {
} }
pub fn clear_sort_key(&mut self) { pub fn clear_sort_key(&mut self) {
_ = self.sort.take(); self.sort.take();
} }
pub fn set_musicbrainz_ref(&mut self, mbref: MbArtistRef) { pub fn set_musicbrainz_ref(&mut self, mbref: MbArtistRef) {
_ = self.musicbrainz.insert(mbref); self.musicbrainz.replace(mbref);
} }
pub fn clear_musicbrainz_ref(&mut self) { pub fn clear_musicbrainz_ref(&mut self) {
_ = self.musicbrainz.take(); self.musicbrainz.take();
} }
// In the functions below, it would be better to use `contains` instead of `iter().any`, but for // In the functions below, it would be better to use `contains` instead of `iter().any`, but for
@ -262,7 +262,7 @@ mod tests {
artist artist
.meta .meta
.set_musicbrainz_ref(MbArtistRef::from_url_str(MUSICBRAINZ).unwrap()); .set_musicbrainz_ref(MbArtistRef::from_url_str(MUSICBRAINZ).unwrap());
_ = expected.insert(MbArtistRef::from_url_str(MUSICBRAINZ).unwrap()); expected.replace(MbArtistRef::from_url_str(MUSICBRAINZ).unwrap());
assert_eq!(artist.meta.musicbrainz, expected); assert_eq!(artist.meta.musicbrainz, expected);
artist artist
@ -273,12 +273,12 @@ mod tests {
artist artist
.meta .meta
.set_musicbrainz_ref(MbArtistRef::from_url_str(MUSICBRAINZ_2).unwrap()); .set_musicbrainz_ref(MbArtistRef::from_url_str(MUSICBRAINZ_2).unwrap());
_ = expected.insert(MbArtistRef::from_url_str(MUSICBRAINZ_2).unwrap()); expected.replace(MbArtistRef::from_url_str(MUSICBRAINZ_2).unwrap());
assert_eq!(artist.meta.musicbrainz, expected); assert_eq!(artist.meta.musicbrainz, expected);
// Clearing URLs. // Clearing URLs.
artist.meta.clear_musicbrainz_ref(); artist.meta.clear_musicbrainz_ref();
_ = expected.take(); expected.take();
assert_eq!(artist.meta.musicbrainz, expected); assert_eq!(artist.meta.musicbrainz, expected);
} }

View File

@ -454,7 +454,7 @@ mod tests {
assert!(music_hoard assert!(music_hoard
.set_artist_musicbrainz(&artist_id, MUSICBRAINZ) .set_artist_musicbrainz(&artist_id, MUSICBRAINZ)
.is_ok()); .is_ok());
_ = expected.insert(MbArtistRef::from_url_str(MUSICBRAINZ).unwrap()); expected.replace(MbArtistRef::from_url_str(MUSICBRAINZ).unwrap());
assert_eq!(music_hoard.collection[0].meta.musicbrainz, expected); assert_eq!(music_hoard.collection[0].meta.musicbrainz, expected);
// Clearing URLs on an artist that does not exist is an error. // Clearing URLs on an artist that does not exist is an error.
@ -463,7 +463,7 @@ mod tests {
// Clearing URLs. // Clearing URLs.
assert!(music_hoard.clear_artist_musicbrainz(&artist_id).is_ok()); assert!(music_hoard.clear_artist_musicbrainz(&artist_id).is_ok());
_ = expected.take(); expected.take();
assert_eq!(music_hoard.collection[0].meta.musicbrainz, expected); assert_eq!(music_hoard.collection[0].meta.musicbrainz, expected);
} }

View File

@ -2,19 +2,15 @@ use serde::Deserialize;
use url::form_urlencoded; use url::form_urlencoded;
use crate::{ use crate::{
collection::{ collection::musicbrainz::Mbid,
album::{AlbumDate, AlbumPrimaryType, AlbumSecondaryType},
musicbrainz::Mbid,
},
external::musicbrainz::{ external::musicbrainz::{
api::{ api::{Error, MusicBrainzClient, MB_BASE_URL},
Error, MusicBrainzClient, SerdeAlbumDate, SerdeAlbumPrimaryType,
SerdeAlbumSecondaryType, SerdeMbid, MB_BASE_URL,
},
IMusicBrainzHttp, IMusicBrainzHttp,
}, },
}; };
use super::{MbArtistMeta, MbReleaseGroupMeta, SerdeMbArtistMeta, SerdeMbReleaseGroupMeta};
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> { impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
pub fn lookup_artist( pub fn lookup_artist(
&mut self, &mut self,
@ -36,6 +32,19 @@ impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
let response: DeserializeLookupArtistResponse = self.http.get(&url)?; let response: DeserializeLookupArtistResponse = self.http.get(&url)?;
Ok(response.into()) Ok(response.into())
} }
pub fn lookup_release_group(
&mut self,
request: LookupReleaseGroupRequest,
) -> Result<LookupReleaseGroupResponse, Error> {
let url = format!(
"{MB_BASE_URL}/release-group/{mbid}",
mbid = request.mbid.uuid().as_hyphenated()
);
let response: DeserializeLookupReleaseGroupResponse = self.http.get(&url)?;
Ok(response.into())
}
} }
pub struct LookupArtistRequest<'a> { pub struct LookupArtistRequest<'a> {
@ -59,50 +68,56 @@ impl<'a> LookupArtistRequest<'a> {
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub struct LookupArtistResponse { pub struct LookupArtistResponse {
pub release_groups: Vec<LookupArtistResponseReleaseGroup>, pub meta: MbArtistMeta,
pub release_groups: Vec<MbReleaseGroupMeta>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))] #[serde(rename_all(deserialize = "kebab-case"))]
struct DeserializeLookupArtistResponse { struct DeserializeLookupArtistResponse {
release_groups: Vec<DeserializeLookupArtistResponseReleaseGroup>, #[serde(flatten)]
meta: SerdeMbArtistMeta,
release_groups: Option<Vec<SerdeMbReleaseGroupMeta>>,
} }
impl From<DeserializeLookupArtistResponse> for LookupArtistResponse { impl From<DeserializeLookupArtistResponse> for LookupArtistResponse {
fn from(value: DeserializeLookupArtistResponse) -> Self { fn from(value: DeserializeLookupArtistResponse) -> Self {
LookupArtistResponse { LookupArtistResponse {
release_groups: value.release_groups.into_iter().map(Into::into).collect(), meta: value.meta.into(),
release_groups: value
.release_groups
.map(|rgs| rgs.into_iter().map(Into::into).collect())
.unwrap_or_default(),
} }
} }
} }
pub struct LookupReleaseGroupRequest<'a> {
mbid: &'a Mbid,
}
impl<'a> LookupReleaseGroupRequest<'a> {
pub fn new(mbid: &'a Mbid) -> Self {
LookupReleaseGroupRequest { mbid }
}
}
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub struct LookupArtistResponseReleaseGroup { pub struct LookupReleaseGroupResponse {
pub id: Mbid, pub meta: MbReleaseGroupMeta,
pub title: String,
pub first_release_date: AlbumDate,
pub primary_type: AlbumPrimaryType,
pub secondary_types: Vec<AlbumSecondaryType>,
} }
#[derive(Clone, Deserialize)] #[derive(Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))] #[serde(rename_all(deserialize = "kebab-case"))]
struct DeserializeLookupArtistResponseReleaseGroup { struct DeserializeLookupReleaseGroupResponse {
id: SerdeMbid, #[serde(flatten)]
title: String, meta: SerdeMbReleaseGroupMeta,
first_release_date: SerdeAlbumDate,
primary_type: SerdeAlbumPrimaryType,
secondary_types: Vec<SerdeAlbumSecondaryType>,
} }
impl From<DeserializeLookupArtistResponseReleaseGroup> for LookupArtistResponseReleaseGroup { impl From<DeserializeLookupReleaseGroupResponse> for LookupReleaseGroupResponse {
fn from(value: DeserializeLookupArtistResponseReleaseGroup) -> Self { fn from(value: DeserializeLookupReleaseGroupResponse) -> Self {
LookupArtistResponseReleaseGroup { LookupReleaseGroupResponse {
id: value.id.into(), meta: value.meta.into(),
title: value.title,
first_release_date: value.first_release_date.into(),
primary_type: value.primary_type.into(),
secondary_types: value.secondary_types.into_iter().map(Into::into).collect(),
} }
} }
} }
@ -111,42 +126,45 @@ impl From<DeserializeLookupArtistResponseReleaseGroup> for LookupArtistResponseR
mod tests { mod tests {
use mockall::predicate; use mockall::predicate;
use crate::external::musicbrainz::MockIMusicBrainzHttp; use crate::{
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
external::musicbrainz::{
api::{SerdeAlbumDate, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType, SerdeMbid},
MockIMusicBrainzHttp,
},
};
use super::*; use super::*;
#[test] #[test]
fn lookup_artist() { fn lookup_artist() {
let mbid = "00000000-0000-0000-0000-000000000000";
let mut http = MockIMusicBrainzHttp::new(); let mut http = MockIMusicBrainzHttp::new();
let url = format!( let url = format!("https://musicbrainz.org/ws/2/artist/{mbid}?inc=release-groups",);
"https://musicbrainz.org/ws/2/artist/{mbid}?inc=release-groups",
mbid = "00000000-0000-0000-0000-000000000000",
);
let de_release_group = DeserializeLookupArtistResponseReleaseGroup { let de_meta = SerdeMbArtistMeta {
id: SerdeMbid(mbid.try_into().unwrap()),
name: String::from("the artist"),
sort_name: String::from("artist, the"),
disambiguation: Some(String::from("disambig")),
};
let de_release_group = SerdeMbReleaseGroupMeta {
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()), id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
title: String::from("an album"), title: String::from("an album"),
first_release_date: SerdeAlbumDate((1986, 4).into()), first_release_date: SerdeAlbumDate((1986, 4).into()),
primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album), primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album),
secondary_types: vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Compilation)], secondary_types: Some(vec![SerdeAlbumSecondaryType(
AlbumSecondaryType::Compilation,
)]),
}; };
let de_response = DeserializeLookupArtistResponse { let de_response = DeserializeLookupArtistResponse {
release_groups: vec![de_release_group.clone()], meta: de_meta.clone(),
release_groups: Some(vec![de_release_group.clone()]),
}; };
let release_group = LookupArtistResponseReleaseGroup {
id: de_release_group.id.0,
title: de_release_group.title,
first_release_date: de_release_group.first_release_date.0,
primary_type: de_release_group.primary_type.0,
secondary_types: de_release_group
.secondary_types
.into_iter()
.map(|st| st.0)
.collect(),
};
let response = LookupArtistResponse { let response = LookupArtistResponse {
release_groups: vec![release_group], meta: de_meta.into(),
release_groups: vec![de_release_group.into()],
}; };
http.expect_get() http.expect_get()
@ -163,4 +181,41 @@ mod tests {
assert_eq!(result, response); assert_eq!(result, response);
} }
#[test]
fn lookup_release_group() {
let mbid = "00000000-0000-0000-0000-000000000000";
let mut http = MockIMusicBrainzHttp::new();
let url = format!("https://musicbrainz.org/ws/2/release-group/{mbid}",);
let de_meta = SerdeMbReleaseGroupMeta {
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::Compilation,
)]),
};
let de_response = DeserializeLookupReleaseGroupResponse {
meta: de_meta.clone(),
};
let response = LookupReleaseGroupResponse {
meta: de_meta.into(),
};
http.expect_get()
.times(1)
.with(predicate::eq(url))
.return_once(|_| Ok(de_response));
let mut client = MusicBrainzClient::new(http);
let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
let request = LookupReleaseGroupRequest::new(&mbid);
let result = client.lookup_release_group(request).unwrap();
assert_eq!(result, response);
}
} }

View File

@ -4,7 +4,8 @@ use serde::{de::Visitor, Deserialize, Deserializer};
use crate::{ use crate::{
collection::{ collection::{
album::{AlbumDate, AlbumPrimaryType, AlbumSecondaryType}, album::{AlbumDate, AlbumId, AlbumPrimaryType, AlbumSecondaryType},
artist::ArtistId,
musicbrainz::Mbid, musicbrainz::Mbid,
Error as CollectionError, Error as CollectionError,
}, },
@ -58,6 +59,67 @@ impl<Http> MusicBrainzClient<Http> {
} }
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MbArtistMeta {
pub id: Mbid,
pub name: ArtistId,
pub sort_name: ArtistId,
pub disambiguation: Option<String>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
struct SerdeMbArtistMeta {
id: SerdeMbid,
name: String,
sort_name: String,
disambiguation: Option<String>,
}
impl From<SerdeMbArtistMeta> for MbArtistMeta {
fn from(value: SerdeMbArtistMeta) -> Self {
MbArtistMeta {
id: value.id.into(),
name: value.name.into(),
sort_name: value.sort_name.into(),
disambiguation: value.disambiguation,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MbReleaseGroupMeta {
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 SerdeMbReleaseGroupMeta {
id: SerdeMbid,
title: String,
first_release_date: SerdeAlbumDate,
primary_type: SerdeAlbumPrimaryType,
secondary_types: Option<Vec<SerdeAlbumSecondaryType>>,
}
impl From<SerdeMbReleaseGroupMeta> for MbReleaseGroupMeta {
fn from(value: SerdeMbReleaseGroupMeta) -> Self {
MbReleaseGroupMeta {
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()),
}
}
}
pub struct ApiDisplay; pub struct ApiDisplay;
impl ApiDisplay { impl ApiDisplay {

View File

@ -2,12 +2,9 @@ use std::fmt;
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::external::musicbrainz::api::{
collection::{artist::ArtistId, musicbrainz::Mbid},
external::musicbrainz::api::{
search::query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin}, search::query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin},
SerdeMbid, MbArtistMeta, SerdeMbArtistMeta,
},
}; };
pub enum SearchArtist<'a> { pub enum SearchArtist<'a> {
@ -48,33 +45,22 @@ impl From<DeserializeSearchArtistResponse> for SearchArtistResponse {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct SearchArtistResponseArtist { pub struct SearchArtistResponseArtist {
pub score: u8, pub score: u8,
pub id: Mbid, pub meta: MbArtistMeta,
pub name: ArtistId,
pub sort: Option<ArtistId>,
pub disambiguation: Option<String>,
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))] #[serde(rename_all(deserialize = "kebab-case"))]
struct DeserializeSearchArtistResponseArtist { struct DeserializeSearchArtistResponseArtist {
score: u8, score: u8,
id: SerdeMbid, #[serde(flatten)]
name: String, meta: SerdeMbArtistMeta,
sort_name: String,
disambiguation: Option<String>,
} }
impl From<DeserializeSearchArtistResponseArtist> for SearchArtistResponseArtist { impl From<DeserializeSearchArtistResponseArtist> for SearchArtistResponseArtist {
fn from(value: DeserializeSearchArtistResponseArtist) -> Self { fn from(value: DeserializeSearchArtistResponseArtist) -> Self {
let sort: Option<ArtistId> = Some(value.sort_name)
.filter(|s| s != &value.name)
.map(Into::into);
SearchArtistResponseArtist { SearchArtistResponseArtist {
score: value.score, score: value.score,
id: value.id.into(), meta: value.meta.into(),
name: value.name.into(),
sort,
disambiguation: value.disambiguation,
} }
} }
} }
@ -83,17 +69,22 @@ impl From<DeserializeSearchArtistResponseArtist> for SearchArtistResponseArtist
mod tests { mod tests {
use mockall::predicate; use mockall::predicate;
use crate::external::musicbrainz::{api::MusicBrainzClient, MockIMusicBrainzHttp}; use crate::external::musicbrainz::{
api::{MusicBrainzClient, SerdeMbid},
MockIMusicBrainzHttp,
};
use super::*; use super::*;
fn de_response() -> DeserializeSearchArtistResponse { fn de_response() -> DeserializeSearchArtistResponse {
let de_artist = DeserializeSearchArtistResponseArtist { let de_artist = DeserializeSearchArtistResponseArtist {
score: 67, score: 67,
meta: SerdeMbArtistMeta {
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()), id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
name: String::from("an artist"), name: String::from("an artist"),
sort_name: String::from("artist, an"), sort_name: String::from("artist, an"),
disambiguation: None, disambiguation: None,
},
}; };
DeserializeSearchArtistResponse { DeserializeSearchArtistResponse {
artists: vec![de_artist.clone()], artists: vec![de_artist.clone()],
@ -107,10 +98,7 @@ mod tests {
.into_iter() .into_iter()
.map(|a| SearchArtistResponseArtist { .map(|a| SearchArtistResponseArtist {
score: 67, score: 67,
id: a.id.0, meta: a.meta.into(),
name: a.name.clone().into(),
sort: Some(a.sort_name).filter(|sn| sn != &a.name).map(Into::into),
disambiguation: a.disambiguation,
}) })
.collect(), .collect(),
} }

View File

@ -3,13 +3,10 @@ use std::fmt;
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
collection::{ collection::{album::AlbumDate, musicbrainz::Mbid},
album::{AlbumDate, AlbumId, AlbumPrimaryType, AlbumSecondaryType},
musicbrainz::Mbid,
},
external::musicbrainz::api::{ external::musicbrainz::api::{
search::query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin}, search::query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin},
ApiDisplay, SerdeAlbumDate, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType, SerdeMbid, ApiDisplay, MbReleaseGroupMeta, SerdeMbReleaseGroupMeta,
}, },
}; };
@ -72,22 +69,15 @@ impl From<DeserializeSearchReleaseGroupResponse> for SearchReleaseGroupResponse
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct SearchReleaseGroupResponseReleaseGroup { pub struct SearchReleaseGroupResponseReleaseGroup {
pub score: u8, pub score: u8,
pub id: Mbid, pub meta: MbReleaseGroupMeta,
pub title: AlbumId,
pub first_release_date: AlbumDate,
pub primary_type: AlbumPrimaryType,
pub secondary_types: Option<Vec<AlbumSecondaryType>>,
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))] #[serde(rename_all(deserialize = "kebab-case"))]
pub struct DeserializeSearchReleaseGroupResponseReleaseGroup { pub struct DeserializeSearchReleaseGroupResponseReleaseGroup {
score: u8, score: u8,
id: SerdeMbid, #[serde(flatten)]
title: String, meta: SerdeMbReleaseGroupMeta,
first_release_date: SerdeAlbumDate,
primary_type: SerdeAlbumPrimaryType,
secondary_types: Option<Vec<SerdeAlbumSecondaryType>>,
} }
impl From<DeserializeSearchReleaseGroupResponseReleaseGroup> impl From<DeserializeSearchReleaseGroupResponseReleaseGroup>
@ -96,13 +86,7 @@ impl From<DeserializeSearchReleaseGroupResponseReleaseGroup>
fn from(value: DeserializeSearchReleaseGroupResponseReleaseGroup) -> Self { fn from(value: DeserializeSearchReleaseGroupResponseReleaseGroup) -> Self {
SearchReleaseGroupResponseReleaseGroup { SearchReleaseGroupResponseReleaseGroup {
score: value.score, score: value.score,
id: value.id.into(), meta: value.meta.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()),
} }
} }
} }
@ -111,18 +95,29 @@ impl From<DeserializeSearchReleaseGroupResponseReleaseGroup>
mod tests { mod tests {
use mockall::predicate; use mockall::predicate;
use crate::external::musicbrainz::{api::MusicBrainzClient, MockIMusicBrainzHttp}; use crate::{
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
external::musicbrainz::{
api::{
MusicBrainzClient, SerdeAlbumDate, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType,
SerdeMbid,
},
MockIMusicBrainzHttp,
},
};
use super::*; use super::*;
fn de_response() -> DeserializeSearchReleaseGroupResponse { fn de_response() -> DeserializeSearchReleaseGroupResponse {
let de_release_group = DeserializeSearchReleaseGroupResponseReleaseGroup { let de_release_group = DeserializeSearchReleaseGroupResponseReleaseGroup {
score: 67, score: 67,
meta: SerdeMbReleaseGroupMeta {
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()), id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
title: String::from("an album"), title: String::from("an album"),
first_release_date: SerdeAlbumDate((1986, 4).into()), first_release_date: SerdeAlbumDate((1986, 4).into()),
primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album), primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album),
secondary_types: Some(vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Live)]), secondary_types: Some(vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Live)]),
},
}; };
DeserializeSearchReleaseGroupResponse { DeserializeSearchReleaseGroupResponse {
release_groups: vec![de_release_group.clone()], release_groups: vec![de_release_group.clone()],
@ -136,13 +131,7 @@ mod tests {
.into_iter() .into_iter()
.map(|rg| SearchReleaseGroupResponseReleaseGroup { .map(|rg| SearchReleaseGroupResponseReleaseGroup {
score: 67, score: 67,
id: rg.id.0, meta: rg.meta.into(),
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(), .collect(),
} }

View File

@ -3,7 +3,11 @@ use std::{
sync::mpsc::{self, TryRecvError}, sync::mpsc::{self, TryRecvError},
}; };
use musichoard::collection::{artist::Artist, musicbrainz::IMusicBrainzRef}; use musichoard::collection::{
album::AlbumMeta,
artist::{Artist, ArtistMeta},
musicbrainz::{IMusicBrainzRef, Mbid},
};
use crate::tui::{ use crate::tui::{
app::{ app::{
@ -15,14 +19,31 @@ use crate::tui::{
}, },
}; };
pub type FetchReceiver = mpsc::Receiver<MbApiResult>;
pub struct FetchState { pub struct FetchState {
fetch_rx: FetchReceiver, fetch_rx: FetchReceiver,
lookup_rx: Option<FetchReceiver>,
} }
pub type FetchReceiver = mpsc::Receiver<MbApiResult>;
impl FetchState { impl FetchState {
pub fn new(fetch_rx: FetchReceiver) -> Self { pub fn new(fetch_rx: FetchReceiver) -> Self {
FetchState { fetch_rx } FetchState {
fetch_rx,
lookup_rx: None,
}
}
fn try_recv(&mut self) -> Result<MbApiResult, TryRecvError> {
if let Some(lookup_rx) = &self.lookup_rx {
let result = lookup_rx.try_recv();
match result {
Ok(_) | Err(TryRecvError::Empty) => return result,
Err(TryRecvError::Disconnected) => {
self.lookup_rx.take();
}
}
}
self.fetch_rx.try_recv()
} }
} }
@ -53,8 +74,46 @@ impl AppMachine<FetchState> {
Self::app_fetch(inner, fetch, false) Self::app_fetch(inner, fetch, false)
} }
fn app_fetch(inner: AppInner, fetch: FetchState, first: bool) -> App { pub fn app_lookup_artist(
match fetch.fetch_rx.try_recv() { inner: AppInner,
fetch: FetchState,
artist: &ArtistMeta,
mbid: Mbid,
) -> App {
let f = Self::submit_lookup_artist_job;
Self::app_lookup(f, inner, fetch, artist, mbid)
}
pub fn app_lookup_album(
inner: AppInner,
fetch: FetchState,
album: &AlbumMeta,
mbid: Mbid,
) -> App {
let f = Self::submit_lookup_release_group_job;
Self::app_lookup(f, inner, fetch, album, mbid)
}
fn app_lookup<F, Meta>(
submit: F,
inner: AppInner,
mut fetch: FetchState,
meta: Meta,
mbid: Mbid,
) -> App
where
F: FnOnce(&dyn IMbJobSender, ResultSender, Meta, Mbid) -> Result<(), DaemonError>,
{
let (lookup_tx, lookup_rx) = mpsc::channel::<MbApiResult>();
if let Err(err) = submit(&*inner.musicbrainz, lookup_tx, meta, mbid) {
return AppMachine::error_state(inner, err.to_string()).into();
}
fetch.lookup_rx.replace(lookup_rx);
Self::app_fetch_next(inner, fetch)
}
fn app_fetch(inner: AppInner, mut fetch: FetchState, first: bool) -> App {
match fetch.try_recv() {
Ok(fetch_result) => match fetch_result { Ok(fetch_result) => match fetch_result {
Ok(next_match) => { Ok(next_match) => {
let current = Some(next_match); let current = Some(next_match);
@ -95,6 +154,26 @@ impl AppMachine<FetchState> {
}; };
musicbrainz.submit_background_job(result_sender, requests) musicbrainz.submit_background_job(result_sender, requests)
} }
fn submit_lookup_artist_job(
musicbrainz: &dyn IMbJobSender,
result_sender: ResultSender,
artist: &ArtistMeta,
mbid: Mbid,
) -> Result<(), DaemonError> {
let requests = VecDeque::from([MbParams::lookup_artist(artist.clone(), mbid)]);
musicbrainz.submit_foreground_job(result_sender, requests)
}
fn submit_lookup_release_group_job(
musicbrainz: &dyn IMbJobSender,
result_sender: ResultSender,
album: &AlbumMeta,
mbid: Mbid,
) -> Result<(), DaemonError> {
let requests = VecDeque::from([MbParams::lookup_release_group(album.clone(), mbid)]);
musicbrainz.submit_foreground_job(result_sender, requests)
}
} }
impl From<AppMachine<FetchState>> for App { impl From<AppMachine<FetchState>> for App {
@ -133,14 +212,49 @@ mod tests {
use crate::tui::{ use crate::tui::{
app::{ app::{
machine::tests::{inner, music_hoard}, machine::tests::{inner, music_hoard},
Delta, IApp, IAppAccess, IAppInteractBrowse, MatchOption, MatchStateInfo, Delta, IApp, IAppAccess, IAppInteractBrowse, MatchStateInfo, MissOption, SearchOption,
},
lib::interface::musicbrainz::{
self,
api::{Lookup, Match},
daemon::MockIMbJobSender,
}, },
lib::interface::musicbrainz::{self, api::Match, daemon::MockIMbJobSender},
testmod::COLLECTION, testmod::COLLECTION,
}; };
use super::*; use super::*;
fn mbid() -> Mbid {
"00000000-0000-0000-0000-000000000000".try_into().unwrap()
}
#[test]
fn try_recv() {
let (fetch_tx, fetch_rx) = mpsc::channel();
let (lookup_tx, lookup_rx) = mpsc::channel();
let mut fetch = FetchState::new(fetch_rx);
fetch.lookup_rx.replace(lookup_rx);
let artist = COLLECTION[3].meta.clone();
let matches: Vec<Match<ArtistMeta>> = vec![];
let fetch_result = MatchStateInfo::artist_search(artist.clone(), matches);
fetch_tx.send(Ok(fetch_result.clone())).unwrap();
assert_eq!(fetch.try_recv(), Err(TryRecvError::Empty));
let lookup = Lookup::new(artist.clone());
let lookup_result = MatchStateInfo::artist_lookup(artist.clone(), lookup);
lookup_tx.send(Ok(lookup_result.clone())).unwrap();
assert_eq!(fetch.try_recv(), Ok(Ok(lookup_result)));
assert_eq!(fetch.try_recv(), Err(TryRecvError::Empty));
drop(lookup_tx);
assert_eq!(fetch.try_recv(), Ok(Ok(fetch_result)));
}
#[test] #[test]
fn fetch_no_artist() { fn fetch_no_artist() {
let app = AppMachine::app_fetch_new(inner(music_hoard(vec![]))); let app = AppMachine::app_fetch_new(inner(music_hoard(vec![])));
@ -186,6 +300,31 @@ mod tests {
assert!(matches!(app, AppState::Match(_))); assert!(matches!(app, AppState::Match(_)));
} }
fn lookup_album_expectation(job_sender: &mut MockIMbJobSender, album: &AlbumMeta) {
let requests = VecDeque::from([MbParams::lookup_release_group(album.clone(), mbid())]);
job_sender
.expect_submit_foreground_job()
.with(predicate::always(), predicate::eq(requests))
.times(1)
.return_once(|_, _| Ok(()));
}
#[test]
fn lookup_album() {
let mut mb_job_sender = MockIMbJobSender::new();
let album = COLLECTION[1].albums[0].meta.clone();
lookup_album_expectation(&mut mb_job_sender, &album);
let music_hoard = music_hoard(COLLECTION.to_owned());
let inner = AppInner::new(music_hoard, mb_job_sender);
let (_fetch_tx, fetch_rx) = mpsc::channel();
let fetch = FetchState::new(fetch_rx);
AppMachine::app_lookup_album(inner, fetch, &album, mbid());
}
fn search_artist_expectation(job_sender: &mut MockIMbJobSender, artist: &ArtistMeta) { fn search_artist_expectation(job_sender: &mut MockIMbJobSender, artist: &ArtistMeta) {
let requests = VecDeque::from([MbParams::search_artist(artist.clone())]); let requests = VecDeque::from([MbParams::search_artist(artist.clone())]);
job_sender job_sender
@ -215,6 +354,31 @@ mod tests {
assert!(matches!(app, AppState::Match(_))); assert!(matches!(app, AppState::Match(_)));
} }
fn lookup_artist_expectation(job_sender: &mut MockIMbJobSender, artist: &ArtistMeta) {
let requests = VecDeque::from([MbParams::lookup_artist(artist.clone(), mbid())]);
job_sender
.expect_submit_foreground_job()
.with(predicate::always(), predicate::eq(requests))
.times(1)
.return_once(|_, _| Ok(()));
}
#[test]
fn lookup_artist() {
let mut mb_job_sender = MockIMbJobSender::new();
let artist = COLLECTION[3].meta.clone();
lookup_artist_expectation(&mut mb_job_sender, &artist);
let music_hoard = music_hoard(COLLECTION.to_owned());
let inner = AppInner::new(music_hoard, mb_job_sender);
let (_fetch_tx, fetch_rx) = mpsc::channel();
let fetch = FetchState::new(fetch_rx);
AppMachine::app_lookup_artist(inner, fetch, &artist, mbid());
}
#[test] #[test]
fn fetch_artist_job_sender_err() { fn fetch_artist_job_sender_err() {
let mut mb_job_sender = MockIMbJobSender::new(); let mut mb_job_sender = MockIMbJobSender::new();
@ -231,13 +395,34 @@ mod tests {
assert!(matches!(app, AppState::Error(_))); assert!(matches!(app, AppState::Error(_)));
} }
#[test]
fn lookup_artist_job_sender_err() {
let mut mb_job_sender = MockIMbJobSender::new();
mb_job_sender
.expect_submit_foreground_job()
.return_once(|_, _| Err(DaemonError::JobChannelDisconnected));
let artist = COLLECTION[3].meta.clone();
let music_hoard = music_hoard(COLLECTION.to_owned());
let inner = AppInner::new(music_hoard, mb_job_sender);
let (_fetch_tx, fetch_rx) = mpsc::channel();
let fetch = FetchState::new(fetch_rx);
let app = AppMachine::app_lookup_artist(inner, fetch, &artist, mbid());
assert!(matches!(app, AppState::Error(_)));
}
#[test] #[test]
fn recv_ok_fetch_ok() { fn recv_ok_fetch_ok() {
let (tx, rx) = mpsc::channel::<MbApiResult>(); let (tx, rx) = mpsc::channel::<MbApiResult>();
let artist = COLLECTION[3].meta.clone(); let artist = COLLECTION[3].meta.clone();
let artist_match = Match::new(80, COLLECTION[2].meta.clone()); let artist_match = Match::new(80, COLLECTION[2].meta.clone());
let artist_match_info = MatchStateInfo::artist(artist.clone(), vec![artist_match.clone()]); let artist_match_info =
MatchStateInfo::artist_search(artist.clone(), vec![artist_match.clone()]);
let fetch_result = Ok(artist_match_info); let fetch_result = Ok(artist_match_info);
tx.send(fetch_result).unwrap(); tx.send(fetch_result).unwrap();
@ -250,10 +435,10 @@ mod tests {
let match_state = public.state.unwrap_match(); let match_state = public.state.unwrap_match();
let match_options = vec![ let match_options = vec![
artist_match.into(), artist_match.into(),
MatchOption::CannotHaveMbid, SearchOption::None(MissOption::CannotHaveMbid),
MatchOption::ManualInputMbid, SearchOption::None(MissOption::ManualInputMbid),
]; ];
let expected = MatchStateInfo::artist(artist, match_options); let expected = MatchStateInfo::artist_search(artist, match_options);
assert_eq!(match_state.info, Some(expected).as_ref()); assert_eq!(match_state.info, Some(expected).as_ref());
} }
@ -309,7 +494,8 @@ mod tests {
assert!(matches!(app, AppState::Fetch(_))); assert!(matches!(app, AppState::Fetch(_)));
let artist = COLLECTION[3].meta.clone(); let artist = COLLECTION[3].meta.clone();
let fetch_result = Ok(MatchStateInfo::artist::<Match<ArtistMeta>>(artist, vec![])); let match_info = MatchStateInfo::artist_search::<Match<ArtistMeta>>(artist, vec![]);
let fetch_result = Ok(match_info);
tx.send(fetch_result).unwrap(); tx.send(fetch_result).unwrap();
let app = app.unwrap_fetch().fetch_result_ready(); let app = app.unwrap_fetch().fetch_result_ready();

View File

@ -11,6 +11,12 @@ impl<'app> From<&'app Input> for InputPublic<'app> {
} }
} }
impl Input {
pub fn value(&self) -> &str {
self.0.value()
}
}
impl From<App> for AppMode<App, AppInputMode> { impl From<App> for AppMode<App, AppInputMode> {
fn from(mut app: App) -> Self { fn from(mut app: App) -> Self {
if let Some(input) = app.input_mut().take() { if let Some(input) = app.input_mut().take() {
@ -43,9 +49,9 @@ impl IAppInput for AppInputMode {
self.app self.app
} }
fn confirm(mut self) -> Self::APP { fn confirm(self) -> Self::APP {
if let AppState::Match(state) = &mut self.app { if let AppState::Match(state) = self.app {
state.submit_input(self.input); return state.submit_input(self.input);
} }
self.app self.app
} }
@ -58,20 +64,12 @@ impl IAppInput for AppInputMode {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::tui::app::{ use crate::tui::app::{
machine::tests::{mb_job_sender, music_hoard_init}, machine::tests::{input_event, mb_job_sender, music_hoard_init},
IApp, IApp,
}; };
use super::*; use super::*;
fn input_event(c: char) -> InputEvent {
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::empty(),
)
.into()
}
#[test] #[test]
fn handle_input() { fn handle_input() {
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender()); let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());

View File

@ -1,26 +1,62 @@
use std::cmp; use std::cmp;
use musichoard::collection::musicbrainz::Mbid;
use crate::tui::app::{ use crate::tui::app::{
machine::{fetch_state::FetchState, input::Input, App, AppInner, AppMachine}, machine::{fetch_state::FetchState, input::Input, App, AppInner, AppMachine},
AlbumMatches, AppPublicState, AppState, ArtistMatches, IAppInteractMatch, MatchOption, AlbumMatches, AppPublicState, AppState, ArtistMatches, IAppInteractMatch, ListOption,
MatchStateInfo, MatchStatePublic, WidgetState, LookupOption, MatchStateInfo, MatchStatePublic, MissOption, SearchOption, WidgetState,
}; };
impl<T: PartialEq> ListOption<T> {
fn len(&self) -> usize {
match self {
ListOption::Lookup(list) => list.len(),
ListOption::Search(list) => list.len(),
}
}
fn push_cannot_have_mbid(&mut self) {
match self {
ListOption::Lookup(list) => list.push(LookupOption::None(MissOption::CannotHaveMbid)),
ListOption::Search(list) => list.push(SearchOption::None(MissOption::CannotHaveMbid)),
}
}
fn push_manual_input_mbid(&mut self) {
match self {
ListOption::Lookup(list) => list.push(LookupOption::None(MissOption::ManualInputMbid)),
ListOption::Search(list) => list.push(SearchOption::None(MissOption::ManualInputMbid)),
}
}
fn is_manual_input_mbid(&self, index: usize) -> bool {
match self {
ListOption::Lookup(list) => {
list.get(index) == Some(&LookupOption::None(MissOption::ManualInputMbid))
}
ListOption::Search(list) => {
list.get(index) == Some(&SearchOption::None(MissOption::ManualInputMbid))
}
}
}
}
impl ArtistMatches { impl ArtistMatches {
fn len(&self) -> usize { fn len(&self) -> usize {
self.list.len() self.list.len()
} }
fn push_cannot_have_mbid(&mut self) { fn push_cannot_have_mbid(&mut self) {
self.list.push(MatchOption::CannotHaveMbid) self.list.push_cannot_have_mbid();
} }
fn push_manual_input_mbid(&mut self) { fn push_manual_input_mbid(&mut self) {
self.list.push(MatchOption::ManualInputMbid) self.list.push_manual_input_mbid();
} }
fn is_manual_input_mbid(&self, index: usize) -> bool { fn is_manual_input_mbid(&self, index: usize) -> bool {
self.list.get(index) == Some(&MatchOption::ManualInputMbid) self.list.is_manual_input_mbid(index)
} }
} }
@ -30,15 +66,15 @@ impl AlbumMatches {
} }
fn push_cannot_have_mbid(&mut self) { fn push_cannot_have_mbid(&mut self) {
self.list.push(MatchOption::CannotHaveMbid) self.list.push_cannot_have_mbid();
} }
fn push_manual_input_mbid(&mut self) { fn push_manual_input_mbid(&mut self) {
self.list.push(MatchOption::ManualInputMbid) self.list.push_manual_input_mbid();
} }
fn is_manual_input_mbid(&self, index: usize) -> bool { fn is_manual_input_mbid(&self, index: usize) -> bool {
self.list.get(index) == Some(&MatchOption::ManualInputMbid) self.list.is_manual_input_mbid(index)
} }
} }
@ -50,14 +86,14 @@ impl MatchStateInfo {
} }
} }
fn push_cannot_have_mbid(&mut self) { pub fn push_cannot_have_mbid(&mut self) {
match self { match self {
Self::Artist(a) => a.push_cannot_have_mbid(), Self::Artist(a) => a.push_cannot_have_mbid(),
Self::Album(a) => a.push_cannot_have_mbid(), Self::Album(a) => a.push_cannot_have_mbid(),
} }
} }
fn push_manual_input_mbid(&mut self) { pub fn push_manual_input_mbid(&mut self) {
match self { match self {
Self::Artist(a) => a.push_manual_input_mbid(), Self::Artist(a) => a.push_manual_input_mbid(),
Self::Album(a) => a.push_manual_input_mbid(), Self::Album(a) => a.push_manual_input_mbid(),
@ -99,7 +135,22 @@ impl AppMachine<MatchState> {
AppMachine::new(inner, state) AppMachine::new(inner, state)
} }
pub fn submit_input(&mut self, _input: Input) {} pub fn submit_input(self, input: Input) -> App {
let mbid: Mbid = match input.value().try_into() {
Ok(mbid) => mbid,
Err(err) => return AppMachine::error_state(self.inner, err.to_string()).into(),
};
match self.state.current.as_ref().unwrap() {
MatchStateInfo::Artist(artist_matches) => {
let matching = &artist_matches.matching;
AppMachine::app_lookup_artist(self.inner, self.state.fetch, matching, mbid)
}
MatchStateInfo::Album(album_matches) => {
let matching = &album_matches.matching;
AppMachine::app_lookup_album(self.inner, self.state.fetch, matching, mbid)
}
}
}
} }
impl From<AppMachine<MatchState>> for App { impl From<AppMachine<MatchState>> for App {
@ -167,8 +218,9 @@ impl IAppInteractMatch for AppMachine<MatchState> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::mpsc; use std::{collections::VecDeque, sync::mpsc};
use mockall::predicate;
use musichoard::collection::{ use musichoard::collection::{
album::{AlbumDate, AlbumId, AlbumMeta, AlbumPrimaryType, AlbumSecondaryType}, album::{AlbumDate, AlbumId, AlbumMeta, AlbumPrimaryType, AlbumSecondaryType},
artist::{ArtistId, ArtistMeta}, artist::{ArtistId, ArtistMeta},
@ -176,10 +228,13 @@ mod tests {
use crate::tui::{ use crate::tui::{
app::{ app::{
machine::tests::{inner, music_hoard}, machine::tests::{inner, inner_with_mb, input_event, music_hoard},
IApp, IAppAccess, IAppInput, IApp, IAppAccess, IAppInput,
}, },
lib::interface::musicbrainz::api::Match, lib::interface::musicbrainz::{
api::{Lookup, Match},
daemon::{MbParams, MockIMbJobSender},
},
}; };
use super::*; use super::*;
@ -194,6 +249,15 @@ mod tests {
} }
} }
impl<T> Lookup<T> {
pub fn new(item: T) -> Self {
Lookup {
item,
disambiguation: None,
}
}
}
fn artist_match() -> MatchStateInfo { fn artist_match() -> MatchStateInfo {
let artist = ArtistMeta::new(ArtistId::new("Artist")); let artist = ArtistMeta::new(ArtistId::new("Artist"));
@ -205,7 +269,13 @@ mod tests {
artist_match_2.disambiguation = Some(String::from("some disambiguation")); artist_match_2.disambiguation = Some(String::from("some disambiguation"));
let list = vec![artist_match_1.clone(), artist_match_2.clone()]; let list = vec![artist_match_1.clone(), artist_match_2.clone()];
MatchStateInfo::artist(artist, list) MatchStateInfo::artist_search(artist, list)
}
fn artist_lookup() -> MatchStateInfo {
let artist = ArtistMeta::new(ArtistId::new("Artist"));
let lookup = Lookup::new(artist.clone());
MatchStateInfo::artist_lookup(artist, lookup)
} }
fn album_match() -> MatchStateInfo { fn album_match() -> MatchStateInfo {
@ -225,7 +295,18 @@ mod tests {
let album_match_2 = Match::new(100, album_2); let album_match_2 = Match::new(100, album_2);
let list = vec![album_match_1.clone(), album_match_2.clone()]; let list = vec![album_match_1.clone(), album_match_2.clone()];
MatchStateInfo::album(album, list) MatchStateInfo::album_search(album, list)
}
fn album_lookup() -> MatchStateInfo {
let album = AlbumMeta::new(
AlbumId::new("Album"),
AlbumDate::new(Some(1990), Some(5), None),
Some(AlbumPrimaryType::Album),
vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation],
);
let lookup = Lookup::new(album.clone());
MatchStateInfo::album_lookup(album, lookup)
} }
fn fetch_state() -> FetchState { fn fetch_state() -> FetchState {
@ -233,8 +314,8 @@ mod tests {
FetchState::new(rx) FetchState::new(rx)
} }
fn match_state(matches_info: Option<MatchStateInfo>) -> MatchState { fn match_state(match_state_info: Option<MatchStateInfo>) -> MatchState {
MatchState::new(matches_info, fetch_state()) MatchState::new(match_state_info, fetch_state())
} }
#[test] #[test]
@ -278,7 +359,7 @@ mod tests {
assert_eq!(public_matches.state, &widget_state); assert_eq!(public_matches.state, &widget_state);
} }
fn match_state_flow(mut matches_info: MatchStateInfo) { fn match_state_flow(mut matches_info: MatchStateInfo, len: usize) {
// tx must exist for rx to return Empty rather than Disconnected. // tx must exist for rx to return Empty rather than Disconnected.
#[allow(unused_variables)] #[allow(unused_variables)]
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
@ -299,27 +380,30 @@ mod tests {
assert_eq!(matches.state.current.as_ref(), Some(&matches_info)); assert_eq!(matches.state.current.as_ref(), Some(&matches_info));
assert_eq!(matches.state.state.list.selected(), Some(0)); assert_eq!(matches.state.state.list.selected(), Some(0));
let matches = matches.next_match().unwrap_match(); let mut matches = matches;
for ii in 1..len {
matches = matches.next_match().unwrap_match();
assert_eq!(matches.state.current.as_ref(), Some(&matches_info)); assert_eq!(matches.state.current.as_ref(), Some(&matches_info));
assert_eq!(matches.state.state.list.selected(), Some(1)); assert_eq!(matches.state.state.list.selected(), Some(ii));
}
// Next is CannotHaveMBID // Next is CannotHaveMBID
let matches = matches.next_match().unwrap_match(); let matches = matches.next_match().unwrap_match();
assert_eq!(matches.state.current.as_ref(), Some(&matches_info)); assert_eq!(matches.state.current.as_ref(), Some(&matches_info));
assert_eq!(matches.state.state.list.selected(), Some(2)); assert_eq!(matches.state.state.list.selected(), Some(len));
// Next is ManualInputMbid // Next is ManualInputMbid
let matches = matches.next_match().unwrap_match(); let matches = matches.next_match().unwrap_match();
assert_eq!(matches.state.current.as_ref(), Some(&matches_info)); assert_eq!(matches.state.current.as_ref(), Some(&matches_info));
assert_eq!(matches.state.state.list.selected(), Some(3)); assert_eq!(matches.state.state.list.selected(), Some(len + 1));
let matches = matches.next_match().unwrap_match(); let matches = matches.next_match().unwrap_match();
assert_eq!(matches.state.current.as_ref(), Some(&matches_info)); assert_eq!(matches.state.current.as_ref(), Some(&matches_info));
assert_eq!(matches.state.state.list.selected(), Some(3)); assert_eq!(matches.state.state.list.selected(), Some(len + 1));
// Go prev_match first as selecting on manual input does not go back to fetch. // Go prev_match first as selecting on manual input does not go back to fetch.
let matches = matches.prev_match().unwrap_match(); let matches = matches.prev_match().unwrap_match();
@ -328,12 +412,22 @@ mod tests {
#[test] #[test]
fn artist_matches_flow() { fn artist_matches_flow() {
match_state_flow(artist_match()); match_state_flow(artist_match(), 2);
}
#[test]
fn artist_lookup_flow() {
match_state_flow(artist_lookup(), 1);
} }
#[test] #[test]
fn album_matches_flow() { fn album_matches_flow() {
match_state_flow(album_match()); match_state_flow(album_match(), 2);
}
#[test]
fn album_lookup_flow() {
match_state_flow(album_lookup(), 1);
} }
#[test] #[test]
@ -364,7 +458,7 @@ mod tests {
} }
#[test] #[test]
fn select_manual_input() { fn select_manual_input_empty() {
let matches = let matches =
AppMachine::match_state(inner(music_hoard(vec![])), match_state(Some(album_match()))); AppMachine::match_state(inner(music_hoard(vec![])), match_state(Some(album_match())));
@ -377,6 +471,75 @@ mod tests {
let app = matches.select(); let app = matches.select();
let input = app.mode().unwrap_input(); let input = app.mode().unwrap_input();
input.confirm().unwrap_match(); input.confirm().unwrap_error();
}
fn mbid() -> Mbid {
"00000000-0000-0000-0000-000000000000".try_into().unwrap()
}
fn input_mbid(mut app: App) -> App {
let mbid = mbid().uuid().to_string();
for c in mbid.chars() {
let input = app.mode().unwrap_input();
app = input.input(input_event(c));
}
app
}
#[test]
fn select_manual_input_artist() {
let mut mb_job_sender = MockIMbJobSender::new();
let artist = ArtistMeta::new(ArtistId::new("Artist"));
let requests = VecDeque::from([MbParams::lookup_artist(artist.clone(), mbid())]);
mb_job_sender
.expect_submit_foreground_job()
.with(predicate::always(), predicate::eq(requests))
.return_once(|_, _| Ok(()));
let matches_vec: Vec<Match<ArtistMeta>> = vec![];
let artist_match = MatchStateInfo::artist_search(artist.clone(), matches_vec);
let matches = AppMachine::match_state(
inner_with_mb(music_hoard(vec![]), mb_job_sender),
match_state(Some(artist_match)),
);
// There are no matches which means that the second option should be manual input.
let matches = matches.next_match().unwrap_match();
let matches = matches.next_match().unwrap_match();
let mut app = matches.select();
app = input_mbid(app);
let input = app.mode().unwrap_input();
input.confirm();
}
#[test]
fn select_manual_input_album() {
let mut mb_job_sender = MockIMbJobSender::new();
let album = AlbumMeta::new("Album", 1990, None, vec![]);
let requests = VecDeque::from([MbParams::lookup_release_group(album.clone(), mbid())]);
mb_job_sender
.expect_submit_foreground_job()
.with(predicate::always(), predicate::eq(requests))
.return_once(|_, _| Ok(()));
let matches_vec: Vec<Match<AlbumMeta>> = vec![];
let album_match = MatchStateInfo::album_search(album.clone(), matches_vec);
let matches = AppMachine::match_state(
inner_with_mb(music_hoard(vec![]), mb_job_sender),
match_state(Some(album_match)),
);
// There are no matches which means that the second option should be manual input.
let matches = matches.next_match().unwrap_match();
let matches = matches.next_match().unwrap_match();
let mut app = matches.select();
app = input_mbid(app);
let input = app.mode().unwrap_input();
input.confirm();
} }
} }

View File

@ -222,7 +222,7 @@ mod tests {
use musichoard::collection::Collection; use musichoard::collection::Collection;
use crate::tui::{ use crate::tui::{
app::{AppState, IApp, IAppInput, IAppInteractBrowse}, app::{AppState, IApp, IAppInput, IAppInteractBrowse, InputEvent},
lib::{interface::musicbrainz::daemon::MockIMbJobSender, MockIMusicHoard}, lib::{interface::musicbrainz::daemon::MockIMbJobSender, MockIMusicHoard},
}; };
@ -355,6 +355,14 @@ mod tests {
AppInner::new(music_hoard, mb_job_sender) AppInner::new(music_hoard, mb_job_sender)
} }
pub fn input_event(c: char) -> InputEvent {
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::empty(),
)
.into()
}
#[test] #[test]
fn input_mode() { fn input_mode() {
let app = App::new(music_hoard_init(vec![]), mb_job_sender()); let app = App::new(music_hoard_init(vec![]), mb_job_sender());

View File

@ -32,6 +32,8 @@ macro_rules! IAppState {
} }
use IAppState; use IAppState;
use super::lib::interface::musicbrainz::api::Lookup;
pub trait IApp { pub trait IApp {
type BrowseState: IAppBase<APP = Self> + IAppInteractBrowse<APP = Self>; type BrowseState: IAppBase<APP = Self> + IAppInteractBrowse<APP = Self>;
type InfoState: IAppBase<APP = Self> + IAppInteractInfo<APP = Self>; type InfoState: IAppBase<APP = Self> + IAppInteractInfo<APP = Self>;
@ -175,28 +177,51 @@ pub struct AppPublicInner<'app> {
pub type InputPublic<'app> = &'app tui_input::Input; pub type InputPublic<'app> = &'app tui_input::Input;
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum MatchOption<T> { pub enum MissOption {
Match(Match<T>),
CannotHaveMbid, CannotHaveMbid,
ManualInputMbid, ManualInputMbid,
} }
impl<T> From<Match<T>> for MatchOption<T> { #[derive(Clone, Debug, PartialEq, Eq)]
pub enum SearchOption<T> {
Match(Match<T>),
None(MissOption),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LookupOption<T> {
Match(Lookup<T>),
None(MissOption),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ListOption<T> {
Search(Vec<SearchOption<T>>),
Lookup(Vec<LookupOption<T>>),
}
impl<T> From<Match<T>> for SearchOption<T> {
fn from(value: Match<T>) -> Self { fn from(value: Match<T>) -> Self {
MatchOption::Match(value) SearchOption::Match(value)
}
}
impl<T> From<Lookup<T>> for LookupOption<T> {
fn from(value: Lookup<T>) -> Self {
LookupOption::Match(value)
} }
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtistMatches { pub struct ArtistMatches {
pub matching: ArtistMeta, pub matching: ArtistMeta,
pub list: Vec<MatchOption<ArtistMeta>>, pub list: ListOption<ArtistMeta>,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlbumMatches { pub struct AlbumMatches {
pub matching: AlbumMeta, pub matching: AlbumMeta,
pub list: Vec<MatchOption<AlbumMeta>>, pub list: ListOption<AlbumMeta>,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@ -206,13 +231,29 @@ pub enum MatchStateInfo {
} }
impl MatchStateInfo { impl MatchStateInfo {
pub fn artist<M: Into<MatchOption<ArtistMeta>>>(matching: ArtistMeta, list: Vec<M>) -> Self { pub fn artist_search<M: Into<SearchOption<ArtistMeta>>>(
let list: Vec<MatchOption<ArtistMeta>> = list.into_iter().map(Into::into).collect(); matching: ArtistMeta,
list: Vec<M>,
) -> Self {
let list = ListOption::Search(list.into_iter().map(Into::into).collect());
MatchStateInfo::Artist(ArtistMatches { matching, list }) MatchStateInfo::Artist(ArtistMatches { matching, list })
} }
pub fn album<M: Into<MatchOption<AlbumMeta>>>(matching: AlbumMeta, list: Vec<M>) -> Self { pub fn album_search<M: Into<SearchOption<AlbumMeta>>>(
let list: Vec<MatchOption<AlbumMeta>> = list.into_iter().map(Into::into).collect(); matching: AlbumMeta,
list: Vec<M>,
) -> Self {
let list = ListOption::Search(list.into_iter().map(Into::into).collect());
MatchStateInfo::Album(AlbumMatches { matching, list })
}
pub fn artist_lookup<M: Into<LookupOption<ArtistMeta>>>(matching: ArtistMeta, item: M) -> Self {
let list = ListOption::Lookup(vec![item.into()]);
MatchStateInfo::Artist(ArtistMatches { matching, list })
}
pub fn album_lookup<M: Into<LookupOption<AlbumMeta>>>(matching: AlbumMeta, item: M) -> Self {
let list = ListOption::Lookup(vec![item.into()]);
MatchStateInfo::Album(AlbumMatches { matching, list }) MatchStateInfo::Album(AlbumMatches { matching, list })
} }
} }

View File

@ -5,11 +5,15 @@ use std::collections::HashMap;
use musichoard::{ use musichoard::{
collection::{ collection::{
album::{AlbumDate, AlbumMeta, AlbumSeq}, album::{AlbumDate, AlbumMeta, AlbumSeq},
artist::ArtistMeta, artist::{ArtistId, ArtistMeta},
musicbrainz::Mbid, musicbrainz::Mbid,
}, },
external::musicbrainz::{ external::musicbrainz::{
api::{ api::{
lookup::{
LookupArtistRequest, LookupArtistResponse, LookupReleaseGroupRequest,
LookupReleaseGroupResponse,
},
search::{ search::{
SearchArtistRequest, SearchArtistResponseArtist, SearchReleaseGroupRequest, SearchArtistRequest, SearchArtistResponseArtist, SearchReleaseGroupRequest,
SearchReleaseGroupResponseReleaseGroup, SearchReleaseGroupResponseReleaseGroup,
@ -20,7 +24,7 @@ use musichoard::{
}, },
}; };
use crate::tui::lib::interface::musicbrainz::api::{Error, IMusicBrainz, Match}; use crate::tui::lib::interface::musicbrainz::api::{Error, IMusicBrainz, Lookup, Match};
// GRCOV_EXCL_START // GRCOV_EXCL_START
pub struct MusicBrainz<Http> { pub struct MusicBrainz<Http> {
@ -34,6 +38,22 @@ impl<Http> MusicBrainz<Http> {
} }
impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> { impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
fn lookup_artist(&mut self, mbid: &Mbid) -> Result<Lookup<ArtistMeta>, Error> {
let request = LookupArtistRequest::new(mbid);
let mb_response = self.client.lookup_artist(request)?;
Ok(from_lookup_artist_response(mb_response))
}
fn lookup_release_group(&mut self, mbid: &Mbid) -> Result<Lookup<AlbumMeta>, Error> {
let request = LookupReleaseGroupRequest::new(mbid);
let mb_response = self.client.lookup_release_group(request)?;
Ok(from_lookup_release_group_response(mb_response))
}
fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Match<ArtistMeta>>, Error> { fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Match<ArtistMeta>>, Error> {
let query = SearchArtistRequest::new().string(&artist.id.name); let query = SearchArtistRequest::new().string(&artist.id.name);
@ -72,16 +92,48 @@ impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
} }
} }
fn from_lookup_artist_response(entity: LookupArtistResponse) -> Lookup<ArtistMeta> {
let sort: Option<ArtistId> = Some(entity.meta.sort_name)
.filter(|s| s != &entity.meta.name)
.map(Into::into);
Lookup {
item: ArtistMeta {
id: entity.meta.name,
sort,
musicbrainz: Some(entity.meta.id.into()),
properties: HashMap::new(),
},
disambiguation: entity.meta.disambiguation,
}
}
fn from_lookup_release_group_response(entity: LookupReleaseGroupResponse) -> Lookup<AlbumMeta> {
Lookup {
item: AlbumMeta {
id: entity.meta.title,
date: entity.meta.first_release_date,
seq: AlbumSeq::default(),
musicbrainz: Some(entity.meta.id.into()),
primary_type: Some(entity.meta.primary_type),
secondary_types: entity.meta.secondary_types.unwrap_or_default(),
},
disambiguation: None,
}
}
fn from_search_artist_response_artist(entity: SearchArtistResponseArtist) -> Match<ArtistMeta> { fn from_search_artist_response_artist(entity: SearchArtistResponseArtist) -> Match<ArtistMeta> {
let sort: Option<ArtistId> = Some(entity.meta.sort_name)
.filter(|s| s != &entity.meta.name)
.map(Into::into);
Match { Match {
score: entity.score, score: entity.score,
item: ArtistMeta { item: ArtistMeta {
id: entity.name, id: entity.meta.name,
sort: entity.sort.map(Into::into), sort,
musicbrainz: Some(entity.id.into()), musicbrainz: Some(entity.meta.id.into()),
properties: HashMap::new(), properties: HashMap::new(),
}, },
disambiguation: entity.disambiguation, disambiguation: entity.meta.disambiguation,
} }
} }
@ -91,12 +143,12 @@ fn from_search_release_group_response_release_group(
Match { Match {
score: entity.score, score: entity.score,
item: AlbumMeta { item: AlbumMeta {
id: entity.title, id: entity.meta.title,
date: entity.first_release_date, date: entity.meta.first_release_date,
seq: AlbumSeq::default(), seq: AlbumSeq::default(),
musicbrainz: Some(entity.id.into()), musicbrainz: Some(entity.meta.id.into()),
primary_type: Some(entity.primary_type), primary_type: Some(entity.meta.primary_type),
secondary_types: entity.secondary_types.unwrap_or_default(), secondary_types: entity.meta.secondary_types.unwrap_or_default(),
}, },
disambiguation: None, disambiguation: None,
} }

View File

@ -5,7 +5,7 @@ use crate::tui::{
event::IFetchCompleteEventSender, event::IFetchCompleteEventSender,
lib::interface::musicbrainz::{ lib::interface::musicbrainz::{
api::{Error as ApiError, IMusicBrainz}, api::{Error as ApiError, IMusicBrainz},
daemon::{Error, IMbJobSender, MbParams, ResultSender, SearchParams}, daemon::{Error, IMbJobSender, LookupParams, MbParams, ResultSender, SearchParams},
}, },
}; };
@ -35,7 +35,6 @@ impl Job {
#[derive(Debug)] #[derive(Debug)]
enum JobPriority { enum JobPriority {
#[cfg(test)]
Foreground, Foreground,
Background, Background,
} }
@ -106,24 +105,24 @@ impl JobChannel {
} }
impl IMbJobSender for JobSender { impl IMbJobSender for JobSender {
fn submit_background_job( fn submit_foreground_job(
&self, &self,
result_sender: ResultSender, result_sender: ResultSender,
requests: VecDeque<MbParams>, requests: VecDeque<MbParams>,
) -> Result<(), Error> { ) -> Result<(), Error> {
self.send_background_job(result_sender, requests) self.send_job(JobPriority::Foreground, result_sender, requests)
} }
}
impl JobSender { fn submit_background_job(
fn send_background_job(
&self, &self,
result_sender: ResultSender, result_sender: ResultSender,
requests: VecDeque<MbParams>, requests: VecDeque<MbParams>,
) -> Result<(), Error> { ) -> Result<(), Error> {
self.send_job(JobPriority::Background, result_sender, requests) self.send_job(JobPriority::Background, result_sender, requests)
} }
}
impl JobSender {
fn send_job( fn send_job(
&self, &self,
priority: JobPriority, priority: JobPriority,
@ -239,20 +238,25 @@ impl JobInstance {
event_sender: &mut dyn IFetchCompleteEventSender, event_sender: &mut dyn IFetchCompleteEventSender,
api_params: MbParams, api_params: MbParams,
) -> Result<(), JobInstanceError> { ) -> Result<(), JobInstanceError> {
match api_params { let result = match api_params {
MbParams::Search(search) => match search { MbParams::Lookup(lookup) => match lookup {
SearchParams::Artist(params) => { LookupParams::Artist(params) => musicbrainz
let result = musicbrainz.search_artist(&params.artist); .lookup_artist(&params.mbid)
let result = result.map(|list| MatchStateInfo::artist(params.artist, list)); .map(|rv| MatchStateInfo::artist_lookup(params.artist, rv)),
self.return_result(event_sender, result) LookupParams::ReleaseGroup(params) => musicbrainz
} .lookup_release_group(&params.mbid)
SearchParams::ReleaseGroup(params) => { .map(|rv| MatchStateInfo::album_lookup(params.album, rv)),
let result = musicbrainz.search_release_group(&params.arid, &params.album);
let result = result.map(|list| MatchStateInfo::album(params.album, list));
self.return_result(event_sender, result)
}
}, },
} MbParams::Search(search) => match search {
SearchParams::Artist(params) => musicbrainz
.search_artist(&params.artist)
.map(|rv| MatchStateInfo::artist_search(params.artist, rv)),
SearchParams::ReleaseGroup(params) => musicbrainz
.search_release_group(&params.arid, &params.album)
.map(|rv| MatchStateInfo::album_search(params.album, rv)),
},
};
self.return_result(event_sender, result)
} }
fn return_result( fn return_result(
@ -298,22 +302,12 @@ impl JobQueue {
.or_else(|| self.background_queue.pop_front()) .or_else(|| self.background_queue.pop_front())
} }
#[cfg(test)]
fn push_back(&mut self, job: Job) { fn push_back(&mut self, job: Job) {
match job.priority { match job.priority {
JobPriority::Foreground => self.foreground_queue.push_back(job.instance), JobPriority::Foreground => self.foreground_queue.push_back(job.instance),
JobPriority::Background => self.background_queue.push_back(job.instance), JobPriority::Background => self.background_queue.push_back(job.instance),
} }
} }
// GRCOV_EXCL_START
#[cfg(not(test))]
fn push_back(&mut self, job: Job) {
match job.priority {
JobPriority::Background => self.background_queue.push_back(job.instance),
}
}
// GRCOV_EXCL_STOP
} }
#[cfg(test)] #[cfg(test)]
@ -327,7 +321,7 @@ mod tests {
use crate::tui::{ use crate::tui::{
event::{Event, EventError, MockIFetchCompleteEventSender}, event::{Event, EventError, MockIFetchCompleteEventSender},
lib::interface::musicbrainz::api::{Match, MockIMusicBrainz}, lib::interface::musicbrainz::api::{Lookup, Match, MockIMusicBrainz},
testmod::COLLECTION, testmod::COLLECTION,
}; };
@ -377,12 +371,28 @@ mod tests {
} }
} }
fn mbid() -> Mbid {
"00000000-0000-0000-0000-000000000000".try_into().unwrap()
}
fn lookup_artist_requests() -> VecDeque<MbParams> {
let artist = COLLECTION[3].meta.clone();
let mbid = mbid();
VecDeque::from([MbParams::lookup_artist(artist, mbid)])
}
fn lookup_release_group_requests() -> VecDeque<MbParams> {
let album = COLLECTION[1].albums[0].meta.clone();
let mbid = mbid();
VecDeque::from([MbParams::lookup_release_group(album, mbid)])
}
fn search_artist_requests() -> VecDeque<MbParams> { fn search_artist_requests() -> VecDeque<MbParams> {
let artist = COLLECTION[3].meta.clone(); let artist = COLLECTION[3].meta.clone();
VecDeque::from([MbParams::search_artist(artist)]) VecDeque::from([MbParams::search_artist(artist)])
} }
fn artist_expectations() -> (ArtistMeta, Vec<Match<ArtistMeta>>) { fn search_artist_expectations() -> (ArtistMeta, Vec<Match<ArtistMeta>>) {
let artist = COLLECTION[3].meta.clone(); let artist = COLLECTION[3].meta.clone();
let artist_match_1 = Match::new(100, artist.clone()); let artist_match_1 = Match::new(100, artist.clone());
@ -410,7 +420,7 @@ mod tests {
mbref.unwrap().mbid().clone() mbref.unwrap().mbid().clone()
} }
fn album_expectations_1() -> (AlbumMeta, Vec<Match<AlbumMeta>>) { fn search_album_expectations_1() -> (AlbumMeta, Vec<Match<AlbumMeta>>) {
let album_1 = COLLECTION[1].albums[0].meta.clone(); let album_1 = COLLECTION[1].albums[0].meta.clone();
let album_4 = COLLECTION[1].albums[3].meta.clone(); let album_4 = COLLECTION[1].albums[3].meta.clone();
@ -421,7 +431,7 @@ mod tests {
(album_1, matches_1) (album_1, matches_1)
} }
fn album_expectations_4() -> (AlbumMeta, Vec<Match<AlbumMeta>>) { fn search_album_expectations_4() -> (AlbumMeta, Vec<Match<AlbumMeta>>) {
let album_1 = COLLECTION[1].albums[0].meta.clone(); let album_1 = COLLECTION[1].albums[0].meta.clone();
let album_4 = COLLECTION[1].albums[3].meta.clone(); let album_4 = COLLECTION[1].albums[3].meta.clone();
@ -505,6 +515,90 @@ mod tests {
assert_eq!(result, Err(JobError::JobQueueEmpty)); assert_eq!(result, Err(JobError::JobQueueEmpty));
} }
fn lookup_artist_expectation(
musicbrainz: &mut MockIMusicBrainz,
mbid: &Mbid,
lookup: &Lookup<ArtistMeta>,
) {
let result = Ok(lookup.clone());
musicbrainz
.expect_lookup_artist()
.with(predicate::eq(mbid.clone()))
.times(1)
.return_once(|_| result);
}
#[test]
fn execute_lookup_artist() {
let mut musicbrainz = musicbrainz();
let mbid = mbid();
let artist = COLLECTION[3].meta.clone();
let lookup = Lookup::new(artist.clone());
lookup_artist_expectation(&mut musicbrainz, &mbid, &lookup);
let mut event_sender = event_sender();
fetch_complete_expectation(&mut event_sender, 1);
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
let requests = lookup_artist_requests();
let (result_sender, result_receiver) = mpsc::channel();
let result = job_sender.submit_foreground_job(result_sender, requests);
assert_eq!(result, Ok(()));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Ok(()));
let result = result_receiver.try_recv().unwrap();
assert_eq!(result, Ok(MatchStateInfo::artist_lookup(artist, lookup)));
}
fn lookup_release_group_expectation(
musicbrainz: &mut MockIMusicBrainz,
mbid: &Mbid,
lookup: &Lookup<AlbumMeta>,
) {
let result = Ok(lookup.clone());
musicbrainz
.expect_lookup_release_group()
.with(predicate::eq(mbid.clone()))
.times(1)
.return_once(|_| result);
}
#[test]
fn execute_lookup_release_group() {
let mut musicbrainz = musicbrainz();
let mbid = mbid();
let album = COLLECTION[1].albums[0].meta.clone();
let lookup = Lookup::new(album.clone());
lookup_release_group_expectation(&mut musicbrainz, &mbid, &lookup);
let mut event_sender = event_sender();
fetch_complete_expectation(&mut event_sender, 1);
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
let requests = lookup_release_group_requests();
let (result_sender, result_receiver) = mpsc::channel();
let result = job_sender.submit_foreground_job(result_sender, requests);
assert_eq!(result, Ok(()));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Ok(()));
let result = result_receiver.try_recv().unwrap();
assert_eq!(result, Ok(MatchStateInfo::album_lookup(album, lookup)));
}
fn search_artist_expectation( fn search_artist_expectation(
musicbrainz: &mut MockIMusicBrainz, musicbrainz: &mut MockIMusicBrainz,
artist: &ArtistMeta, artist: &ArtistMeta,
@ -521,7 +615,7 @@ mod tests {
#[test] #[test]
fn execute_search_artist() { fn execute_search_artist() {
let mut musicbrainz = musicbrainz(); let mut musicbrainz = musicbrainz();
let (artist, matches) = artist_expectations(); let (artist, matches) = search_artist_expectations();
search_artist_expectation(&mut musicbrainz, &artist, &matches); search_artist_expectation(&mut musicbrainz, &artist, &matches);
let mut event_sender = event_sender(); let mut event_sender = event_sender();
@ -542,7 +636,7 @@ mod tests {
assert_eq!(result, Ok(())); assert_eq!(result, Ok(()));
let result = result_receiver.try_recv().unwrap(); let result = result_receiver.try_recv().unwrap();
assert_eq!(result, Ok(MatchStateInfo::artist(artist, matches))); assert_eq!(result, Ok(MatchStateInfo::artist_search(artist, matches)));
} }
fn search_release_group_expectation( fn search_release_group_expectation(
@ -565,8 +659,8 @@ mod tests {
fn execute_search_release_groups() { fn execute_search_release_groups() {
let mut musicbrainz = musicbrainz(); let mut musicbrainz = musicbrainz();
let arid = album_arid_expectation(); let arid = album_arid_expectation();
let (album_1, matches_1) = album_expectations_1(); let (album_1, matches_1) = search_album_expectations_1();
let (album_4, matches_4) = album_expectations_4(); let (album_4, matches_4) = search_album_expectations_4();
let mut seq = Sequence::new(); let mut seq = Sequence::new();
search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_1, &matches_1); search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_1, &matches_1);
@ -593,17 +687,17 @@ mod tests {
assert_eq!(result, Ok(())); assert_eq!(result, Ok(()));
let result = result_receiver.try_recv().unwrap(); let result = result_receiver.try_recv().unwrap();
assert_eq!(result, Ok(MatchStateInfo::album(album_1, matches_1))); assert_eq!(result, Ok(MatchStateInfo::album_search(album_1, matches_1)));
let result = result_receiver.try_recv().unwrap(); let result = result_receiver.try_recv().unwrap();
assert_eq!(result, Ok(MatchStateInfo::album(album_4, matches_4))); assert_eq!(result, Ok(MatchStateInfo::album_search(album_4, matches_4)));
} }
#[test] #[test]
fn execute_search_release_groups_result_disconnect() { fn execute_search_release_groups_result_disconnect() {
let mut musicbrainz = musicbrainz(); let mut musicbrainz = musicbrainz();
let arid = album_arid_expectation(); let arid = album_arid_expectation();
let (album_1, matches_1) = album_expectations_1(); let (album_1, matches_1) = search_album_expectations_1();
let mut seq = Sequence::new(); let mut seq = Sequence::new();
search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_1, &matches_1); search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_1, &matches_1);
@ -633,7 +727,7 @@ mod tests {
#[test] #[test]
fn execute_search_artist_event_disconnect() { fn execute_search_artist_event_disconnect() {
let mut musicbrainz = musicbrainz(); let mut musicbrainz = musicbrainz();
let (artist, matches) = artist_expectations(); let (artist, matches) = search_artist_expectations();
search_artist_expectation(&mut musicbrainz, &artist, &matches); search_artist_expectation(&mut musicbrainz, &artist, &matches);
let mut event_sender = event_sender(); let mut event_sender = event_sender();

View File

@ -8,6 +8,8 @@ use musichoard::collection::{album::AlbumMeta, artist::ArtistMeta, musicbrainz::
/// Trait for interacting with the MusicBrainz API. /// Trait for interacting with the MusicBrainz API.
#[cfg_attr(test, automock)] #[cfg_attr(test, automock)]
pub trait IMusicBrainz { pub trait IMusicBrainz {
fn lookup_artist(&mut self, mbid: &Mbid) -> Result<Lookup<ArtistMeta>, Error>;
fn lookup_release_group(&mut self, mbid: &Mbid) -> Result<Lookup<AlbumMeta>, Error>;
fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Match<ArtistMeta>>, Error>; fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Match<ArtistMeta>>, Error>;
fn search_release_group( fn search_release_group(
&mut self, &mut self,
@ -23,4 +25,10 @@ pub struct Match<T> {
pub disambiguation: Option<String>, pub disambiguation: Option<String>,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Lookup<T> {
pub item: T,
pub disambiguation: Option<String>,
}
pub type Error = musichoard::external::musicbrainz::api::Error; pub type Error = musichoard::external::musicbrainz::api::Error;

View File

@ -27,6 +27,12 @@ pub type ResultSender = mpsc::Sender<MbApiResult>;
#[cfg_attr(test, automock)] #[cfg_attr(test, automock)]
pub trait IMbJobSender { pub trait IMbJobSender {
fn submit_foreground_job(
&self,
result_sender: ResultSender,
requests: VecDeque<MbParams>,
) -> Result<(), Error>;
fn submit_background_job( fn submit_background_job(
&self, &self,
result_sender: ResultSender, result_sender: ResultSender,
@ -36,9 +42,28 @@ pub trait IMbJobSender {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum MbParams { pub enum MbParams {
Lookup(LookupParams),
Search(SearchParams), Search(SearchParams),
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LookupParams {
Artist(LookupArtistParams),
ReleaseGroup(LookupReleaseGroupParams),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LookupArtistParams {
pub artist: ArtistMeta,
pub mbid: Mbid,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LookupReleaseGroupParams {
pub album: AlbumMeta,
pub mbid: Mbid,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum SearchParams { pub enum SearchParams {
Artist(SearchArtistParams), Artist(SearchArtistParams),
@ -57,6 +82,17 @@ pub struct SearchReleaseGroupParams {
} }
impl MbParams { impl MbParams {
pub fn lookup_artist(artist: ArtistMeta, mbid: Mbid) -> Self {
MbParams::Lookup(LookupParams::Artist(LookupArtistParams { artist, mbid }))
}
pub fn lookup_release_group(album: AlbumMeta, mbid: Mbid) -> Self {
MbParams::Lookup(LookupParams::ReleaseGroup(LookupReleaseGroupParams {
album,
mbid,
}))
}
pub fn search_artist(artist: ArtistMeta) -> Self { pub fn search_artist(artist: ArtistMeta) -> Self {
MbParams::Search(SearchParams::Artist(SearchArtistParams { artist })) MbParams::Search(SearchParams::Artist(SearchArtistParams { artist }))
} }

View File

@ -4,7 +4,7 @@ use musichoard::collection::{
track::{TrackFormat, TrackQuality}, track::{TrackFormat, TrackQuality},
}; };
use crate::tui::app::{MatchOption, MatchStateInfo}; use crate::tui::app::{LookupOption, MatchStateInfo, MissOption, SearchOption};
pub struct UiDisplay; pub struct UiDisplay;
@ -124,37 +124,69 @@ impl UiDisplay {
} }
} }
pub fn display_artist_match(match_option: &MatchOption<ArtistMeta>) -> String { pub fn display_search_option_artist(match_option: &SearchOption<ArtistMeta>) -> String {
match match_option { match match_option {
MatchOption::Match(match_artist) => format!( SearchOption::Match(match_artist) => format!(
"{}{} ({}%)", "{} ({}%)",
&match_artist.item.id.name, Self::display_option_artist(&match_artist.item, &match_artist.disambiguation),
&match_artist
.disambiguation
.as_ref()
.map(|d| format!(" ({d})"))
.unwrap_or_default(),
match_artist.score, match_artist.score,
), ),
MatchOption::CannotHaveMbid => Self::display_cannot_have_mbid().to_string(), SearchOption::None(miss) => Self::display_miss_option(miss).to_string(),
MatchOption::ManualInputMbid => Self::display_manual_input_mbid().to_string(),
} }
} }
pub fn display_album_match(match_option: &MatchOption<AlbumMeta>) -> String { pub fn display_lookup_option_artist(lookup_option: &LookupOption<ArtistMeta>) -> String {
match lookup_option {
LookupOption::Match(match_artist) => {
Self::display_option_artist(&match_artist.item, &match_artist.disambiguation)
}
LookupOption::None(miss) => Self::display_miss_option(miss).to_string(),
}
}
fn display_option_artist(artist: &ArtistMeta, disambiguation: &Option<String>) -> String {
format!(
"{}{}",
artist.id.name,
disambiguation
.as_ref()
.filter(|s| !s.is_empty())
.map(|d| format!(" ({d})"))
.unwrap_or_default(),
)
}
pub fn display_search_option_album(match_option: &SearchOption<AlbumMeta>) -> String {
match match_option { match match_option {
MatchOption::Match(match_album) => format!( SearchOption::Match(match_album) => format!(
"{:010} | {} [{}] ({}%)", "{} ({}%)",
UiDisplay::display_album_date(&match_album.item.date), Self::display_option_album(&match_album.item),
&match_album.item.id.title,
UiDisplay::display_type(
&match_album.item.primary_type,
&match_album.item.secondary_types
),
match_album.score, match_album.score,
), ),
MatchOption::CannotHaveMbid => Self::display_cannot_have_mbid().to_string(), SearchOption::None(miss) => Self::display_miss_option(miss).to_string(),
MatchOption::ManualInputMbid => Self::display_manual_input_mbid().to_string(), }
}
pub fn display_lookup_option_album(lookup_option: &LookupOption<AlbumMeta>) -> String {
match lookup_option {
LookupOption::Match(match_album) => Self::display_option_album(&match_album.item),
LookupOption::None(miss) => Self::display_miss_option(miss).to_string(),
}
}
fn display_option_album(album: &AlbumMeta) -> String {
format!(
"{:010} | {} [{}]",
UiDisplay::display_album_date(&album.date),
album.id.title,
UiDisplay::display_type(&album.primary_type, &album.secondary_types),
)
}
fn display_miss_option(miss_option: &MissOption) -> &'static str {
match miss_option {
MissOption::CannotHaveMbid => Self::display_cannot_have_mbid(),
MissOption::ManualInputMbid => Self::display_manual_input_mbid(),
} }
} }

View File

@ -2,7 +2,7 @@ use musichoard::collection::{album::AlbumMeta, artist::ArtistMeta};
use ratatui::widgets::{List, ListItem}; use ratatui::widgets::{List, ListItem};
use crate::tui::{ use crate::tui::{
app::{MatchOption, MatchStateInfo, WidgetState}, app::{ListOption, MatchStateInfo, WidgetState},
ui::display::UiDisplay, ui::display::UiDisplay,
}; };
@ -13,7 +13,7 @@ pub struct MatchOverlay<'a, 'b> {
} }
impl<'a, 'b> MatchOverlay<'a, 'b> { impl<'a, 'b> MatchOverlay<'a, 'b> {
pub fn new(info: Option<&MatchStateInfo>, state: &'b mut WidgetState) -> Self { pub fn new(info: Option<&'a MatchStateInfo>, state: &'b mut WidgetState) -> Self {
match info { match info {
Some(info) => match info { Some(info) => match info {
MatchStateInfo::Artist(m) => Self::artists(&m.matching, &m.list, state), MatchStateInfo::Artist(m) => Self::artists(&m.matching, &m.list, state),
@ -33,18 +33,19 @@ impl<'a, 'b> MatchOverlay<'a, 'b> {
fn artists( fn artists(
matching: &ArtistMeta, matching: &ArtistMeta,
matches: &[MatchOption<ArtistMeta>], matches: &'a ListOption<ArtistMeta>,
state: &'b mut WidgetState, state: &'b mut WidgetState,
) -> Self { ) -> Self {
let matching = UiDisplay::display_artist_matching(matching); let matching = UiDisplay::display_artist_matching(matching);
let list = List::new( let list = match matches {
matches ListOption::Search(matches) => {
.iter() Self::display_list(UiDisplay::display_search_option_artist, matches)
.map(UiDisplay::display_artist_match) }
.map(ListItem::new) ListOption::Lookup(matches) => {
.collect::<Vec<ListItem>>(), Self::display_list(UiDisplay::display_lookup_option_artist, matches)
); }
};
MatchOverlay { MatchOverlay {
matching, matching,
@ -55,18 +56,19 @@ impl<'a, 'b> MatchOverlay<'a, 'b> {
fn albums( fn albums(
matching: &AlbumMeta, matching: &AlbumMeta,
matches: &[MatchOption<AlbumMeta>], matches: &'a ListOption<AlbumMeta>,
state: &'b mut WidgetState, state: &'b mut WidgetState,
) -> Self { ) -> Self {
let matching = UiDisplay::display_album_matching(matching); let matching = UiDisplay::display_album_matching(matching);
let list = List::new( let list = match matches {
matches ListOption::Search(matches) => {
.iter() Self::display_list(UiDisplay::display_search_option_album, matches)
.map(UiDisplay::display_album_match) }
.map(ListItem::new) ListOption::Lookup(matches) => {
.collect::<Vec<ListItem>>(), Self::display_list(UiDisplay::display_lookup_option_album, matches)
); }
};
MatchOverlay { MatchOverlay {
matching, matching,
@ -74,4 +76,17 @@ impl<'a, 'b> MatchOverlay<'a, 'b> {
state, state,
} }
} }
fn display_list<F, T>(display: F, options: &[T]) -> List
where
F: FnMut(&T) -> String,
{
List::new(
options
.iter()
.map(display)
.map(ListItem::new)
.collect::<Vec<ListItem>>(),
)
}
} }

View File

@ -206,8 +206,8 @@ mod tests {
}; };
use crate::tui::{ use crate::tui::{
app::{AppPublic, AppPublicInner, Delta, MatchOption, MatchStatePublic}, app::{AppPublic, AppPublicInner, Delta, MatchStatePublic},
lib::interface::musicbrainz::api::Match, lib::interface::musicbrainz::api::{Lookup, Match},
testmod::COLLECTION, testmod::COLLECTION,
tests::terminal, tests::terminal,
}; };
@ -250,20 +250,6 @@ mod tests {
} }
} }
fn artist_matches(matching: ArtistMeta, list: Vec<Match<ArtistMeta>>) -> MatchStateInfo {
let mut list: Vec<MatchOption<ArtistMeta>> = list.into_iter().map(Into::into).collect();
list.push(MatchOption::CannotHaveMbid);
list.push(MatchOption::ManualInputMbid);
MatchStateInfo::artist(matching, list)
}
fn album_matches(matching: AlbumMeta, list: Vec<Match<AlbumMeta>>) -> MatchStateInfo {
let mut list: Vec<MatchOption<AlbumMeta>> = list.into_iter().map(Into::into).collect();
list.push(MatchOption::CannotHaveMbid);
list.push(MatchOption::ManualInputMbid);
MatchStateInfo::album(matching, list)
}
fn draw_test_suite(collection: &Collection, selection: &mut Selection) { fn draw_test_suite(collection: &Collection, selection: &mut Selection) {
let mut terminal = terminal(); let mut terminal = terminal();
@ -354,68 +340,83 @@ mod tests {
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
} }
#[test] fn artist_meta() -> ArtistMeta {
fn draw_artist_matches() { ArtistMeta::new(ArtistId::new("an artist"))
let collection = &COLLECTION;
let mut selection = Selection::new(collection);
let mut terminal = terminal();
let artist = ArtistMeta::new(ArtistId::new("an artist"));
let artist_match = Match {
score: 80,
item: artist.clone(),
disambiguation: None,
};
let list = vec![artist_match.clone(), artist_match.clone()];
let artist_matches = artist_matches(artist, list);
let mut widget_state = WidgetState::default();
widget_state.list.select(Some(0));
let mut app = AppPublic {
inner: public_inner(collection, &mut selection),
state: AppState::Match(MatchStatePublic {
info: Some(&artist_matches),
state: &mut widget_state,
}),
input: None,
};
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
let input = tui_input::Input::default();
app.input = Some(&input);
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
} }
#[test] fn artist_matches() -> MatchStateInfo {
fn draw_album_matches() { let artist = artist_meta();
let collection = &COLLECTION; let artist_match = Match::new(80, artist.clone());
let mut selection = Selection::new(collection); let list = vec![artist_match.clone(), artist_match.clone()];
let mut terminal = terminal(); let mut info = MatchStateInfo::artist_search(artist, list);
info.push_cannot_have_mbid();
info.push_manual_input_mbid();
info
}
let album = AlbumMeta::new( fn artist_lookup() -> MatchStateInfo {
let artist = artist_meta();
let artist_lookup = Lookup::new(artist.clone());
let mut info = MatchStateInfo::artist_lookup(artist, artist_lookup);
info.push_cannot_have_mbid();
info.push_manual_input_mbid();
info
}
fn album_meta() -> AlbumMeta {
AlbumMeta::new(
AlbumId::new("An Album"), AlbumId::new("An Album"),
AlbumDate::new(Some(1990), Some(5), None), AlbumDate::new(Some(1990), Some(5), None),
Some(AlbumPrimaryType::Album), Some(AlbumPrimaryType::Album),
vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation], vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation],
); )
let album_match = Match { }
score: 80,
item: album.clone(),
disambiguation: None,
};
let list = vec![album_match.clone(), album_match.clone()];
let album_matches = album_matches(album, list);
fn album_matches() -> MatchStateInfo {
let album = album_meta();
let album_match = Match::new(80, album.clone());
let list = vec![album_match.clone(), album_match.clone()];
let mut info = MatchStateInfo::album_search(album, list);
info.push_cannot_have_mbid();
info.push_manual_input_mbid();
info
}
fn album_lookup() -> MatchStateInfo {
let album = album_meta();
let album_lookup = Lookup::new(album.clone());
let mut info = MatchStateInfo::album_lookup(album, album_lookup);
info.push_cannot_have_mbid();
info.push_manual_input_mbid();
info
}
#[test]
fn draw_matche_state_suite() {
let collection = &COLLECTION;
let mut selection = Selection::new(collection);
let mut terminal = terminal();
let match_state_infos = vec![
artist_matches(),
album_matches(),
artist_lookup(),
album_lookup(),
];
for info in match_state_infos.iter() {
let mut widget_state = WidgetState::default(); let mut widget_state = WidgetState::default();
widget_state.list.select(Some(0)); widget_state.list.select(Some(0));
let mut app = AppPublic { let mut app = AppPublic {
inner: public_inner(collection, &mut selection), inner: public_inner(collection, &mut selection),
state: AppState::Match(MatchStatePublic { state: AppState::Match(MatchStatePublic {
info: Some(&album_matches), info: Some(info),
state: &mut widget_state, state: &mut widget_state,
}), }),
input: None, input: None,
@ -426,4 +427,5 @@ mod tests {
app.input = Some(&input); app.input = Some(&input);
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
} }
}
} }