Add option for manual input during fetch #219

Merged
wojtek merged 9 commits from 188---add-option-for-manual-input-during-fetch into main 2024-09-23 22:40:25 +02:00
16 changed files with 549 additions and 127 deletions
Showing only changes of commit 87fc692278 - Show all commits

View File

@ -34,9 +34,9 @@ fn main() {
let mut request = LookupArtistRequest::new(&mbid);
request.include_release_groups();
let albums = client
let response = client
.lookup_artist(request)
.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) {
_ = self.musicbrainz.insert(mbref);
self.musicbrainz.replace(mbref);
}
pub fn clear_musicbrainz_ref(&mut self) {
_ = self.musicbrainz.take();
self.musicbrainz.take();
}
}
@ -371,7 +371,7 @@ mod tests {
album
.meta
.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);
album
@ -382,12 +382,12 @@ mod tests {
album
.meta
.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);
// Clearing URLs.
album.meta.clear_musicbrainz_ref();
_ = expected.take();
expected.take();
assert_eq!(album.meta.musicbrainz, expected);
}
}

View File

@ -89,15 +89,15 @@ impl ArtistMeta {
}
pub fn clear_sort_key(&mut self) {
_ = self.sort.take();
self.sort.take();
}
pub fn set_musicbrainz_ref(&mut self, mbref: MbArtistRef) {
_ = self.musicbrainz.insert(mbref);
self.musicbrainz.replace(mbref);
}
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
@ -262,7 +262,7 @@ mod tests {
artist
.meta
.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);
artist
@ -273,12 +273,12 @@ mod tests {
artist
.meta
.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);
// Clearing URLs.
artist.meta.clear_musicbrainz_ref();
_ = expected.take();
expected.take();
assert_eq!(artist.meta.musicbrainz, expected);
}

View File

@ -454,7 +454,7 @@ mod tests {
assert!(music_hoard
.set_artist_musicbrainz(&artist_id, MUSICBRAINZ)
.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);
// Clearing URLs on an artist that does not exist is an error.
@ -463,7 +463,7 @@ mod tests {
// Clearing URLs.
assert!(music_hoard.clear_artist_musicbrainz(&artist_id).is_ok());
_ = expected.take();
expected.take();
assert_eq!(music_hoard.collection[0].meta.musicbrainz, expected);
}

View File

@ -3,7 +3,8 @@ use url::form_urlencoded;
use crate::{
collection::{
album::{AlbumDate, AlbumPrimaryType, AlbumSecondaryType},
album::{AlbumDate, AlbumId, AlbumPrimaryType, AlbumSecondaryType},
artist::ArtistId,
musicbrainz::Mbid,
},
external::musicbrainz::{
@ -36,6 +37,19 @@ impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
let response: DeserializeLookupArtistResponse = self.http.get(&url)?;
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> {
@ -59,19 +73,37 @@ impl<'a> LookupArtistRequest<'a> {
#[derive(Debug, PartialEq, Eq)]
pub struct LookupArtistResponse {
pub id: Mbid,
pub name: ArtistId,
pub sort: Option<ArtistId>,
pub disambiguation: Option<String>,
pub release_groups: Vec<LookupArtistResponseReleaseGroup>,
}
#[derive(Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
struct DeserializeLookupArtistResponse {
release_groups: Vec<DeserializeLookupArtistResponseReleaseGroup>,
id: SerdeMbid,
name: String,
sort_name: String,
disambiguation: Option<String>,
release_groups: Option<Vec<DeserializeLookupArtistResponseReleaseGroup>>,
}
impl From<DeserializeLookupArtistResponse> for LookupArtistResponse {
fn from(value: DeserializeLookupArtistResponse) -> Self {
let sort: Option<ArtistId> = Some(value.sort_name)
.filter(|s| s != &value.name)
.map(Into::into);
LookupArtistResponse {
release_groups: value.release_groups.into_iter().map(Into::into).collect(),
id: value.id.into(),
name: value.name.into(),
sort,
disambiguation: value.disambiguation,
release_groups: value
.release_groups
.map(|rgs| rgs.into_iter().map(Into::into).collect())
.unwrap_or_default(),
}
}
}
@ -107,6 +139,49 @@ impl From<DeserializeLookupArtistResponseReleaseGroup> for LookupArtistResponseR
}
}
pub struct LookupReleaseGroupRequest<'a> {
mbid: &'a Mbid,
}
impl<'a> LookupReleaseGroupRequest<'a> {
pub fn new(mbid: &'a Mbid) -> Self {
LookupReleaseGroupRequest { mbid }
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct LookupReleaseGroupResponse {
pub id: Mbid,
pub title: AlbumId,
pub first_release_date: AlbumDate,
pub primary_type: AlbumPrimaryType,
pub secondary_types: Option<Vec<AlbumSecondaryType>>,
}
#[derive(Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
struct DeserializeLookupReleaseGroupResponse {
id: SerdeMbid,
title: String,
first_release_date: SerdeAlbumDate,
primary_type: SerdeAlbumPrimaryType,
secondary_types: Option<Vec<SerdeAlbumSecondaryType>>,
}
impl From<DeserializeLookupReleaseGroupResponse> for LookupReleaseGroupResponse {
fn from(value: DeserializeLookupReleaseGroupResponse) -> Self {
LookupReleaseGroupResponse {
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;
@ -117,12 +192,14 @@ mod tests {
#[test]
fn lookup_artist() {
let mbid = "00000000-0000-0000-0000-000000000000";
let mut http = MockIMusicBrainzHttp::new();
let url = format!(
"https://musicbrainz.org/ws/2/artist/{mbid}?inc=release-groups",
mbid = "00000000-0000-0000-0000-000000000000",
);
let url = format!("https://musicbrainz.org/ws/2/artist/{mbid}?inc=release-groups",);
let de_id = SerdeMbid(mbid.try_into().unwrap());
let de_name = String::from("the artist");
let de_sort_name = String::from("artist, the");
let de_disambiguation = Some(String::from("disambig"));
let de_release_group = DeserializeLookupArtistResponseReleaseGroup {
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
title: String::from("an album"),
@ -131,7 +208,11 @@ mod tests {
secondary_types: vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Compilation)],
};
let de_response = DeserializeLookupArtistResponse {
release_groups: vec![de_release_group.clone()],
id: de_id.clone(),
name: de_name.clone(),
sort_name: de_sort_name.clone(),
disambiguation: de_disambiguation.clone(),
release_groups: Some(vec![de_release_group.clone()]),
};
let release_group = LookupArtistResponseReleaseGroup {
@ -146,6 +227,10 @@ mod tests {
.collect(),
};
let response = LookupArtistResponse {
id: de_id.0,
name: de_name.into(),
sort: Some(de_sort_name.into()),
disambiguation: de_disambiguation,
release_groups: vec![release_group],
};

View File

@ -3,7 +3,11 @@ use std::{
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::{
app::{
@ -17,12 +21,29 @@ use crate::tui::{
pub struct FetchState {
fetch_rx: FetchReceiver,
lookup_rx: Option<FetchReceiver>,
}
pub type FetchReceiver = mpsc::Receiver<MbApiResult>;
impl FetchState {
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,
_ => {
self.lookup_rx.take();
}
}
}
self.fetch_rx.try_recv()
}
}
@ -53,8 +74,46 @@ impl AppMachine<FetchState> {
Self::app_fetch(inner, fetch, false)
}
fn app_fetch(inner: AppInner, fetch: FetchState, first: bool) -> App {
match fetch.fetch_rx.try_recv() {
pub fn app_lookup_artist(
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(next_match) => {
let current = Some(next_match);
@ -95,6 +154,26 @@ impl AppMachine<FetchState> {
};
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 {
@ -133,7 +212,7 @@ mod tests {
use crate::tui::{
app::{
machine::tests::{inner, music_hoard},
Delta, IApp, IAppAccess, IAppInteractBrowse, MatchOption, MatchStateInfo,
Delta, IApp, IAppAccess, IAppInteractBrowse, MatchStateInfo, MissOption, SearchOption,
},
lib::interface::musicbrainz::{self, api::Match, daemon::MockIMbJobSender},
testmod::COLLECTION,
@ -237,7 +316,8 @@ mod tests {
let artist = COLLECTION[3].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);
tx.send(fetch_result).unwrap();
@ -250,10 +330,10 @@ mod tests {
let match_state = public.state.unwrap_match();
let match_options = vec![
artist_match.into(),
MatchOption::CannotHaveMbid,
MatchOption::ManualInputMbid,
SearchOption::None(MissOption::CannotHaveMbid),
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());
}
@ -309,7 +389,8 @@ mod tests {
assert!(matches!(app, AppState::Fetch(_)));
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();
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> {
fn from(mut app: App) -> Self {
if let Some(input) = app.input_mut().take() {
@ -43,9 +49,9 @@ impl IAppInput for AppInputMode {
self.app
}
fn confirm(mut self) -> Self::APP {
if let AppState::Match(state) = &mut self.app {
state.submit_input(self.input);
fn confirm(self) -> Self::APP {
if let AppState::Match(state) = self.app {
return state.submit_input(self.input);
}
self.app
}

View File

@ -1,26 +1,62 @@
use std::cmp;
use musichoard::collection::musicbrainz::Mbid;
use crate::tui::app::{
machine::{fetch_state::FetchState, input::Input, App, AppInner, AppMachine},
AlbumMatches, AppPublicState, AppState, ArtistMatches, IAppInteractMatch, MatchOption,
MatchStateInfo, MatchStatePublic, WidgetState,
AlbumMatches, AppPublicState, AppState, ArtistMatches, IAppInteractMatch, ListOption,
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 {
fn len(&self) -> usize {
self.list.len()
}
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) {
self.list.push(MatchOption::ManualInputMbid)
self.list.push_manual_input_mbid();
}
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) {
self.list.push(MatchOption::CannotHaveMbid)
self.list.push_cannot_have_mbid();
}
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 {
self.list.get(index) == Some(&MatchOption::ManualInputMbid)
self.list.is_manual_input_mbid(index)
}
}
@ -99,7 +135,22 @@ impl AppMachine<MatchState> {
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 {
@ -205,7 +256,7 @@ mod tests {
artist_match_2.disambiguation = Some(String::from("some disambiguation"));
let list = vec![artist_match_1.clone(), artist_match_2.clone()];
MatchStateInfo::artist(artist, list)
MatchStateInfo::artist_search(artist, list)
}
fn album_match() -> MatchStateInfo {
@ -225,7 +276,7 @@ mod tests {
let album_match_2 = Match::new(100, album_2);
let list = vec![album_match_1.clone(), album_match_2.clone()];
MatchStateInfo::album(album, list)
MatchStateInfo::album_search(album, list)
}
fn fetch_state() -> FetchState {
@ -233,8 +284,8 @@ mod tests {
FetchState::new(rx)
}
fn match_state(matches_info: Option<MatchStateInfo>) -> MatchState {
MatchState::new(matches_info, fetch_state())
fn match_state(match_state_info: Option<MatchStateInfo>) -> MatchState {
MatchState::new(match_state_info, fetch_state())
}
#[test]

View File

@ -32,6 +32,8 @@ macro_rules! IAppState {
}
use IAppState;
use super::lib::interface::musicbrainz::api::Lookup;
pub trait IApp {
type BrowseState: IAppBase<APP = Self> + IAppInteractBrowse<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;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MatchOption<T> {
Match(Match<T>),
pub enum MissOption {
CannotHaveMbid,
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 {
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)]
pub struct ArtistMatches {
pub matching: ArtistMeta,
pub list: Vec<MatchOption<ArtistMeta>>,
pub list: ListOption<ArtistMeta>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlbumMatches {
pub matching: AlbumMeta,
pub list: Vec<MatchOption<AlbumMeta>>,
pub list: ListOption<AlbumMeta>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -206,13 +231,29 @@ pub enum MatchStateInfo {
}
impl MatchStateInfo {
pub fn artist<M: Into<MatchOption<ArtistMeta>>>(matching: ArtistMeta, list: Vec<M>) -> Self {
let list: Vec<MatchOption<ArtistMeta>> = list.into_iter().map(Into::into).collect();
pub fn artist_search<M: Into<SearchOption<ArtistMeta>>>(
matching: ArtistMeta,
list: Vec<M>,
) -> Self {
let list = ListOption::Search(list.into_iter().map(Into::into).collect());
MatchStateInfo::Artist(ArtistMatches { matching, list })
}
pub fn album<M: Into<MatchOption<AlbumMeta>>>(matching: AlbumMeta, list: Vec<M>) -> Self {
let list: Vec<MatchOption<AlbumMeta>> = list.into_iter().map(Into::into).collect();
pub fn album_search<M: Into<SearchOption<AlbumMeta>>>(
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 })
}
}

View File

@ -10,6 +10,10 @@ use musichoard::{
},
external::musicbrainz::{
api::{
lookup::{
LookupArtistRequest, LookupArtistResponse, LookupReleaseGroupRequest,
LookupReleaseGroupResponse,
},
search::{
SearchArtistRequest, SearchArtistResponseArtist, SearchReleaseGroupRequest,
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
pub struct MusicBrainz<Http> {
@ -34,6 +38,22 @@ impl<Http> 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> {
let query = SearchArtistRequest::new().string(&artist.id.name);
@ -72,6 +92,32 @@ impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
}
}
fn from_lookup_artist_response(entity: LookupArtistResponse) -> Lookup<ArtistMeta> {
Lookup {
item: ArtistMeta {
id: entity.name,
sort: entity.sort.map(Into::into),
musicbrainz: Some(entity.id.into()),
properties: HashMap::new(),
},
disambiguation: entity.disambiguation,
}
}
fn from_lookup_release_group_response(entity: LookupReleaseGroupResponse) -> Lookup<AlbumMeta> {
Lookup {
item: AlbumMeta {
id: entity.title,
date: entity.first_release_date,
seq: AlbumSeq::default(),
musicbrainz: Some(entity.id.into()),
primary_type: Some(entity.primary_type),
secondary_types: entity.secondary_types.unwrap_or_default(),
},
disambiguation: None,
}
}
fn from_search_artist_response_artist(entity: SearchArtistResponseArtist) -> Match<ArtistMeta> {
Match {
score: entity.score,

View File

@ -5,7 +5,7 @@ use crate::tui::{
event::IFetchCompleteEventSender,
lib::interface::musicbrainz::{
api::{Error as ApiError, IMusicBrainz},
daemon::{Error, IMbJobSender, MbParams, ResultSender, SearchParams},
daemon::{Error, IMbJobSender, LookupParams, MbParams, ResultSender, SearchParams},
},
};
@ -105,6 +105,14 @@ impl JobChannel {
}
impl IMbJobSender for JobSender {
fn submit_foreground_job(
&self,
result_sender: ResultSender,
requests: VecDeque<MbParams>,
) -> Result<(), Error> {
self.send_foreground_job(result_sender, requests)
}
fn submit_background_job(
&self,
result_sender: ResultSender,
@ -115,6 +123,14 @@ impl IMbJobSender for JobSender {
}
impl JobSender {
fn send_foreground_job(
&self,
result_sender: ResultSender,
requests: VecDeque<MbParams>,
) -> Result<(), Error> {
self.send_job(JobPriority::Foreground, result_sender, requests)
}
fn send_background_job(
&self,
result_sender: ResultSender,
@ -238,20 +254,25 @@ impl JobInstance {
event_sender: &mut dyn IFetchCompleteEventSender,
api_params: MbParams,
) -> Result<(), JobInstanceError> {
match api_params {
MbParams::Search(search) => match search {
SearchParams::Artist(params) => {
let result = musicbrainz.search_artist(&params.artist);
let result = result.map(|list| MatchStateInfo::artist(params.artist, list));
self.return_result(event_sender, result)
}
SearchParams::ReleaseGroup(params) => {
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)
}
let result = match api_params {
MbParams::Lookup(lookup) => match lookup {
LookupParams::Artist(params) => musicbrainz
.lookup_artist(&params.mbid)
.map(|rv| MatchStateInfo::artist_lookup(params.artist, rv)),
LookupParams::ReleaseGroup(params) => musicbrainz
.lookup_release_group(&params.mbid)
.map(|rv| MatchStateInfo::album_lookup(params.album, rv)),
},
}
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(
@ -531,7 +552,7 @@ mod tests {
assert_eq!(result, Ok(()));
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(
@ -582,10 +603,10 @@ mod tests {
assert_eq!(result, Ok(()));
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();
assert_eq!(result, Ok(MatchStateInfo::album(album_4, matches_4)));
assert_eq!(result, Ok(MatchStateInfo::album_search(album_4, matches_4)));
}
#[test]

View File

@ -8,6 +8,8 @@ use musichoard::collection::{album::AlbumMeta, artist::ArtistMeta, musicbrainz::
/// Trait for interacting with the MusicBrainz API.
#[cfg_attr(test, automock)]
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_release_group(
&mut self,
@ -23,4 +25,10 @@ pub struct Match<T> {
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;

View File

@ -27,6 +27,12 @@ pub type ResultSender = mpsc::Sender<MbApiResult>;
#[cfg_attr(test, automock)]
pub trait IMbJobSender {
fn submit_foreground_job(
&self,
result_sender: ResultSender,
requests: VecDeque<MbParams>,
) -> Result<(), Error>;
fn submit_background_job(
&self,
result_sender: ResultSender,
@ -36,9 +42,28 @@ pub trait IMbJobSender {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MbParams {
Lookup(LookupParams),
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)]
pub enum SearchParams {
Artist(SearchArtistParams),
@ -57,6 +82,17 @@ pub struct SearchReleaseGroupParams {
}
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 {
MbParams::Search(SearchParams::Artist(SearchArtistParams { artist }))
}

View File

@ -4,7 +4,7 @@ use musichoard::collection::{
track::{TrackFormat, TrackQuality},
};
use crate::tui::app::{MatchOption, MatchStateInfo};
use crate::tui::app::{LookupOption, MatchStateInfo, MissOption, SearchOption};
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 {
MatchOption::Match(match_artist) => format!(
"{}{} ({}%)",
&match_artist.item.id.name,
&match_artist
.disambiguation
.as_ref()
.map(|d| format!(" ({d})"))
.unwrap_or_default(),
SearchOption::Match(match_artist) => format!(
"{} ({}%)",
Self::display_option_artist(&match_artist.item, &match_artist.disambiguation),
match_artist.score,
),
MatchOption::CannotHaveMbid => Self::display_cannot_have_mbid().to_string(),
MatchOption::ManualInputMbid => Self::display_manual_input_mbid().to_string(),
SearchOption::None(miss) => Self::display_miss_option(miss).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 {
MatchOption::Match(match_album) => format!(
"{:010} | {} [{}] ({}%)",
UiDisplay::display_album_date(&match_album.item.date),
&match_album.item.id.title,
UiDisplay::display_type(
&match_album.item.primary_type,
&match_album.item.secondary_types
),
SearchOption::Match(match_album) => format!(
"{} ({}%)",
Self::display_option_album(&match_album.item),
match_album.score,
),
MatchOption::CannotHaveMbid => Self::display_cannot_have_mbid().to_string(),
MatchOption::ManualInputMbid => Self::display_manual_input_mbid().to_string(),
SearchOption::None(miss) => Self::display_miss_option(miss).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 crate::tui::{
app::{MatchOption, MatchStateInfo, WidgetState},
app::{ListOption, MatchStateInfo, WidgetState},
ui::display::UiDisplay,
};
@ -13,7 +13,7 @@ pub struct 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 {
Some(info) => match info {
MatchStateInfo::Artist(m) => Self::artists(&m.matching, &m.list, state),
@ -33,18 +33,19 @@ impl<'a, 'b> MatchOverlay<'a, 'b> {
fn artists(
matching: &ArtistMeta,
matches: &[MatchOption<ArtistMeta>],
matches: &'a ListOption<ArtistMeta>,
state: &'b mut WidgetState,
) -> Self {
let matching = UiDisplay::display_artist_matching(matching);
let list = List::new(
matches
.iter()
.map(UiDisplay::display_artist_match)
.map(ListItem::new)
.collect::<Vec<ListItem>>(),
);
let list = match matches {
ListOption::Search(matches) => {
Self::display_list(UiDisplay::display_search_option_artist, matches)
}
ListOption::Lookup(matches) => {
Self::display_list(UiDisplay::display_lookup_option_artist, matches)
}
};
MatchOverlay {
matching,
@ -55,18 +56,19 @@ impl<'a, 'b> MatchOverlay<'a, 'b> {
fn albums(
matching: &AlbumMeta,
matches: &[MatchOption<AlbumMeta>],
matches: &'a ListOption<AlbumMeta>,
state: &'b mut WidgetState,
) -> Self {
let matching = UiDisplay::display_album_matching(matching);
let list = List::new(
matches
.iter()
.map(UiDisplay::display_album_match)
.map(ListItem::new)
.collect::<Vec<ListItem>>(),
);
let list = match matches {
ListOption::Search(matches) => {
Self::display_list(UiDisplay::display_search_option_album, matches)
}
ListOption::Lookup(matches) => {
Self::display_list(UiDisplay::display_lookup_option_album, matches)
}
};
MatchOverlay {
matching,
@ -74,4 +76,17 @@ impl<'a, 'b> MatchOverlay<'a, 'b> {
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,7 +206,7 @@ mod tests {
};
use crate::tui::{
app::{AppPublic, AppPublicInner, Delta, MatchOption, MatchStatePublic},
app::{AppPublic, AppPublicInner, Delta, MatchStatePublic, MissOption, SearchOption},
lib::interface::musicbrainz::api::Match,
testmod::COLLECTION,
tests::terminal,
@ -251,17 +251,17 @@ 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)
let mut list: Vec<SearchOption<ArtistMeta>> = list.into_iter().map(Into::into).collect();
list.push(SearchOption::None(MissOption::CannotHaveMbid));
list.push(SearchOption::None(MissOption::ManualInputMbid));
MatchStateInfo::artist_search(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)
let mut list: Vec<SearchOption<AlbumMeta>> = list.into_iter().map(Into::into).collect();
list.push(SearchOption::None(MissOption::CannotHaveMbid));
list.push(SearchOption::None(MissOption::ManualInputMbid));
MatchStateInfo::album_search(matching, list)
}
fn draw_test_suite(collection: &Collection, selection: &mut Selection) {