From cf7e23c38c17625f7dc507306688e095d47ebe78 Mon Sep 17 00:00:00 2001 From: Wojciech Kozlowski Date: Tue, 27 Aug 2024 17:55:52 +0200 Subject: [PATCH] Provide a keyboard shortcut to sync all existing albums with MusicBrainz (#167) Closes #166 Reviewed-on: https://git.thenineworlds.net/wojtek/musichoard/pulls/167 --- src/core/interface/musicbrainz/mod.rs | 6 +- src/external/musicbrainz/api/mod.rs | 11 +- src/main.rs | 13 +- src/tui/app/machine/browse.rs | 154 +++++++++++++- src/tui/app/machine/matches.rs | 295 ++++++++++++++++++++++++++ src/tui/app/machine/mod.rs | 101 ++++++--- src/tui/app/mod.rs | 38 +++- src/tui/handler.rs | 20 +- src/tui/lib.rs | 50 ++++- src/tui/mod.rs | 3 +- src/tui/ui.rs | 125 ++++++++++- 11 files changed, 759 insertions(+), 57 deletions(-) create mode 100644 src/tui/app/machine/matches.rs diff --git a/src/core/interface/musicbrainz/mod.rs b/src/core/interface/musicbrainz/mod.rs index c0c7162..9fe82e4 100644 --- a/src/core/interface/musicbrainz/mod.rs +++ b/src/core/interface/musicbrainz/mod.rs @@ -2,15 +2,11 @@ use std::{fmt, num}; -// TODO: #[cfg(test)] -// TODO: use mockall::automock; - use uuid::{self, Uuid}; use crate::collection::album::Album; /// Trait for interacting with the MusicBrainz API. -// TODO: #[cfg_attr(test, automock)] pub trait IMusicBrainz { fn lookup_artist_release_groups(&mut self, mbid: &Mbid) -> Result, Error>; fn search_release_group( @@ -20,7 +16,7 @@ pub trait IMusicBrainz { ) -> Result>, Error>; } -#[derive(Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct Match { pub score: u8, pub item: T, diff --git a/src/external/musicbrainz/api/mod.rs b/src/external/musicbrainz/api/mod.rs index 1fbf909..b2ad0a5 100644 --- a/src/external/musicbrainz/api/mod.rs +++ b/src/external/musicbrainz/api/mod.rs @@ -150,6 +150,7 @@ struct SearchReleaseGroup { title: String, first_release_date: String, primary_type: SerdeAlbumPrimaryType, + secondary_types: Option>, } impl TryFrom for Match { @@ -160,7 +161,10 @@ impl TryFrom for Match { entity.title, AlbumDate::from_mb_date(&entity.first_release_date)?, Some(entity.primary_type.into()), - vec![], + entity + .secondary_types + .map(|v| v.into_iter().map(|st| st.into()).collect()) + .unwrap_or_default(), ); let mbref = MbAlbumRef::from_uuid_str(entity.id) .map_err(|err| Error::MbidParse(err.to_string()))?; @@ -229,7 +233,7 @@ pub enum SerdeAlbumSecondaryTypeDef { FieldRecording, } -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] pub struct SerdeAlbumSecondaryType( #[serde(with = "SerdeAlbumSecondaryTypeDef")] AlbumSecondaryType, ); @@ -320,6 +324,7 @@ mod tests { title: String::from("an album"), first_release_date: String::from("1986-04"), primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album), + secondary_types: Some(vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Live)]), }; let response = ResponseSearchReleaseGroup { release_groups: vec![release_group], @@ -350,7 +355,7 @@ mod tests { AlbumId::new("an album"), (1986, 4), Some(AlbumPrimaryType::Album), - vec![], + vec![AlbumSecondaryType::Live], ); album.set_musicbrainz_ref( MbAlbumRef::from_uuid_str("11111111-1111-1111-1111-111111111111").unwrap(), diff --git a/src/main.rs b/src/main.rs index 3f60c88..2672911 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ use musichoard::{ executor::{ssh::BeetsLibrarySshExecutor, BeetsLibraryProcessExecutor}, BeetsLibrary, }, + musicbrainz::api::{client::MusicBrainzApiClient, MusicBrainzApi}, }, interface::{ database::{IDatabase, NullDatabase}, @@ -26,6 +27,12 @@ use musichoard::{ use tui::{App, EventChannel, EventHandler, EventListener, Tui, Ui}; +const MUSICHOARD_HTTP_USER_AGENT: &str = concat!( + "MusicHoard/", + env!("CARGO_PKG_VERSION"), + " ( musichoard@thenineworlds.net )" +); + #[derive(StructOpt)] struct Opt { #[structopt(flatten)] @@ -74,7 +81,11 @@ fn with(builder: MusicHoardBuilder IAppInteractBrowse for AppMachine { AppMachine::search(self.inner, orig).into() } + fn fetch_musicbrainz(mut self) -> Self::APP { + let coll = self.inner.music_hoard.get_collection(); + let artist = match self.inner.selection.state_artist(coll) { + Some(artist_state) => &coll[artist_state.index], + None => { + return AppMachine::error(self.inner, "cannot fetch: no artist selected").into() + } + }; + + let arid = match artist.musicbrainz { + Some(ref mbid) => mbid.mbid(), + None => { + return AppMachine::error(self.inner, "cannot fetch: missing artist MBID").into() + } + }; + + let mut artist_album_matches = vec![]; + let mut album_iter = artist.albums.iter().peekable(); + while let Some(album) = album_iter.next() { + if album.musicbrainz.is_some() { + continue; + } + + match self.inner.mb_api.search_release_group(arid, album) { + Ok(matches) => artist_album_matches.push(AppMatchesInfo { + matching: album.clone(), + matches, + }), + Err(err) => return AppMachine::error(self.inner, err.to_string()).into(), + } + + if album_iter.peek().is_some() { + thread::sleep(time::Duration::from_secs(1)); + } + } + + AppMachine::matches(self.inner, artist_album_matches).into() + } + fn no_op(self) -> Self::APP { self.into() } @@ -88,10 +131,17 @@ impl IAppInteractBrowse for AppMachine { #[cfg(test)] mod tests { + use mockall::{predicate, Sequence}; + use musichoard::collection::album::Album; + use crate::tui::{ app::{ - machine::tests::{inner, music_hoard}, - Category, IAppInteract, + machine::tests::{inner, inner_with_mb, music_hoard}, + Category, IAppAccess, IAppInteract, IAppInteractMatches, + }, + lib::external::musicbrainz::{ + self, + api::{Match, Mbid, MockIMusicBrainz}, }, testmod::COLLECTION, }; @@ -168,6 +218,104 @@ mod tests { app.unwrap_search(); } + #[test] + fn fetch_musicbrainz() { + let mut mb_api = Box::new(MockIMusicBrainz::new()); + + let arid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap(); + let album_1 = COLLECTION[1].albums[0].clone(); + let album_4 = COLLECTION[1].albums[3].clone(); + + let album_match_1_1 = Match::new(100, album_1.clone()); + let album_match_1_2 = Match::new(50, album_4.clone()); + let album_match_4_1 = Match::new(100, album_4.clone()); + let album_match_4_2 = Match::new(30, album_1.clone()); + let matches_1 = vec![album_match_1_1.clone(), album_match_1_2.clone()]; + let matches_4 = vec![album_match_4_1.clone(), album_match_4_2.clone()]; + + let result_1: Result>, musicbrainz::api::Error> = Ok(matches_1.clone()); + let result_4: Result>, musicbrainz::api::Error> = Ok(matches_4.clone()); + + // Other albums have an MBID and so they will be skipped. + let mut seq = Sequence::new(); + + mb_api + .expect_search_release_group() + .with(predicate::eq(arid.clone()), predicate::eq(album_1.clone())) + .times(1) + .in_sequence(&mut seq) + .return_once(|_, _| result_1); + mb_api + .expect_search_release_group() + .with(predicate::eq(arid.clone()), predicate::eq(album_4.clone())) + .times(1) + .in_sequence(&mut seq) + .return_once(|_, _| result_4); + + let browse = AppMachine::browse(inner_with_mb(music_hoard(COLLECTION.to_owned()), mb_api)); + + // Use the second artist for this test. + let browse = browse.increment_selection(Delta::Line).unwrap_browse(); + let mut app = browse.fetch_musicbrainz(); + + let public = app.get(); + assert!(matches!(public.state, AppState::Matches(_))); + + let public_matches = public.state.unwrap_matches(); + + assert_eq!(public_matches.matching, Some(&album_1)); + assert_eq!(public_matches.matches, Some(matches_1.as_slice())); + + let mut app = app.unwrap_matches().select(); + + let public = app.get(); + assert!(matches!(public.state, AppState::Matches(_))); + + let public_matches = public.state.unwrap_matches(); + + assert_eq!(public_matches.matching, Some(&album_4)); + assert_eq!(public_matches.matches, Some(matches_4.as_slice())); + + let app = app.unwrap_matches().select(); + app.unwrap_browse(); + } + + #[test] + fn fetch_musicbrainz_no_artist() { + let browse = AppMachine::browse(inner(music_hoard(vec![]))); + let app = browse.fetch_musicbrainz(); + app.unwrap_error(); + } + + #[test] + fn fetch_musicbrainz_no_mbid() { + let browse = AppMachine::browse(inner(music_hoard(COLLECTION.to_owned()))); + + // Use the fourth artist for this test as they have no MBID. + let browse = browse.increment_selection(Delta::Line).unwrap_browse(); + let browse = browse.increment_selection(Delta::Line).unwrap_browse(); + let browse = browse.increment_selection(Delta::Line).unwrap_browse(); + let app = browse.fetch_musicbrainz(); + app.unwrap_error(); + } + + #[test] + fn fetch_musicbrainz_api_error() { + let mut mb_api = Box::new(MockIMusicBrainz::new()); + + let error = Err(musicbrainz::api::Error::RateLimit); + + mb_api + .expect_search_release_group() + .times(1) + .return_once(|_, _| error); + + let browse = AppMachine::browse(inner_with_mb(music_hoard(COLLECTION.to_owned()), mb_api)); + + let app = browse.fetch_musicbrainz(); + app.unwrap_error(); + } + #[test] fn no_op() { let browse = AppMachine::browse(inner(music_hoard(vec![]))); diff --git a/src/tui/app/machine/matches.rs b/src/tui/app/machine/matches.rs new file mode 100644 index 0000000..575ef67 --- /dev/null +++ b/src/tui/app/machine/matches.rs @@ -0,0 +1,295 @@ +use std::cmp; + +use musichoard::{collection::album::Album, interface::musicbrainz::Match}; + +use crate::tui::{ + app::{ + machine::{App, AppInner, AppMachine}, + AppPublic, AppPublicMatches, AppState, IAppInteractMatches, WidgetState, + }, + lib::IMusicHoard, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AppMatchesInfo { + pub matching: Album, + pub matches: Vec>, +} + +pub struct AppMatches { + matches_info_vec: Vec, + index: Option, + state: WidgetState, +} + +impl AppMachine { + pub fn matches(inner: AppInner, matches_info_vec: Vec) -> Self { + let mut index = None; + let mut state = WidgetState::default(); + if let Some(matches_info) = matches_info_vec.first() { + index = Some(0); + if !matches_info.matches.is_empty() { + state.list.select(Some(0)); + } + } + + AppMachine { + inner, + state: AppMatches { + matches_info_vec, + index, + state, + }, + } + } +} + +impl From> for App { + fn from(machine: AppMachine) -> Self { + AppState::Matches(machine) + } +} + +impl<'a, MH: IMusicHoard> From<&'a mut AppMachine> for AppPublic<'a> { + fn from(machine: &'a mut AppMachine) -> Self { + let (matching, matches) = match machine.state.index { + Some(index) => ( + Some(&machine.state.matches_info_vec[index].matching), + Some(machine.state.matches_info_vec[index].matches.as_slice()), + ), + None => (None, None), + }; + + AppPublic { + inner: (&mut machine.inner).into(), + state: AppState::Matches(AppPublicMatches { + matching, + matches, + state: &mut machine.state.state, + }), + } + } +} + +impl IAppInteractMatches for AppMachine { + type APP = App; + + fn prev_match(mut self) -> Self::APP { + if let Some(list_index) = self.state.state.list.selected() { + let result = list_index.saturating_sub(1); + self.state.state.list.select(Some(result)); + } + + self.into() + } + + fn next_match(mut self) -> Self::APP { + if let Some(list_index) = self.state.state.list.selected() { + let result = list_index.saturating_add(1); + let to = cmp::min( + result, + self.state.matches_info_vec[self.state.index.unwrap()] + .matches + .len() + .saturating_sub(1), + ); + self.state.state.list.select(Some(to)); + } + + self.into() + } + + fn select(mut self) -> Self::APP { + self.state.index = self.state.index.map(|i| i.saturating_add(1)); + self.state.state = WidgetState::default(); + if let Some(index) = self.state.index { + if let Some(matches_info) = self.state.matches_info_vec.get(index) { + if !matches_info.matches.is_empty() { + self.state.state.list.select(Some(0)); + } + return self.into(); + } + } + + AppMachine::browse(self.inner).into() + } + + fn abort(self) -> Self::APP { + AppMachine::browse(self.inner).into() + } + + fn no_op(self) -> Self::APP { + self.into() + } +} + +#[cfg(test)] +mod tests { + use musichoard::collection::album::{AlbumDate, AlbumId, AlbumPrimaryType, AlbumSecondaryType}; + + use crate::tui::app::{ + machine::tests::{inner, music_hoard}, + IAppAccess, + }; + + use super::*; + + fn matches_info_vec() -> Vec { + let album_1 = Album::new( + AlbumId::new("Album 1"), + AlbumDate::new(Some(1990), Some(5), None), + Some(AlbumPrimaryType::Album), + vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation], + ); + + let album_1_1 = album_1.clone(); + let album_match_1_1 = Match { + score: 100, + item: album_1_1, + }; + + let mut album_1_2 = album_1.clone(); + album_1_2.id.title.push_str(" extra title part"); + album_1_2.secondary_types.pop(); + let album_match_1_2 = Match { + score: 100, + item: album_1_2, + }; + + let matches_info_1 = AppMatchesInfo { + matching: album_1.clone(), + matches: vec![album_match_1_1.clone(), album_match_1_2.clone()], + }; + + let album_2 = Album::new( + AlbumId::new("Album 2"), + AlbumDate::new(Some(2001), None, None), + Some(AlbumPrimaryType::Album), + vec![], + ); + + let album_2_1 = album_1.clone(); + let album_match_2_1 = Match { + score: 100, + item: album_2_1, + }; + + let matches_info_2 = AppMatchesInfo { + matching: album_2.clone(), + matches: vec![album_match_2_1.clone()], + }; + + vec![matches_info_1, matches_info_2] + } + + #[test] + fn create_empty() { + let matches = AppMachine::matches(inner(music_hoard(vec![])), vec![]); + + let widget_state = WidgetState::default(); + + assert_eq!(matches.state.matches_info_vec, vec![]); + assert_eq!(matches.state.index, None); + assert_eq!(matches.state.state, widget_state); + + let mut app = matches.no_op(); + let public = app.get(); + let public_matches = public.state.unwrap_matches(); + + assert_eq!(public_matches.matching, None); + assert_eq!(public_matches.matches, None); + assert_eq!(public_matches.state, &widget_state); + } + + #[test] + fn create_nonempty() { + let matches_info_vec = matches_info_vec(); + let matches = AppMachine::matches(inner(music_hoard(vec![])), matches_info_vec.clone()); + + let mut widget_state = WidgetState::default(); + widget_state.list.select(Some(0)); + + assert_eq!(matches.state.matches_info_vec, matches_info_vec); + assert_eq!(matches.state.index, Some(0)); + assert_eq!(matches.state.state, widget_state); + + let mut app = matches.no_op(); + let public = app.get(); + let public_matches = public.state.unwrap_matches(); + + assert_eq!(public_matches.matching, Some(&matches_info_vec[0].matching)); + assert_eq!( + public_matches.matches, + Some(matches_info_vec[0].matches.as_slice()) + ); + assert_eq!(public_matches.state, &widget_state); + } + + #[test] + fn matches_flow() { + let matches_info_vec = matches_info_vec(); + let matches = AppMachine::matches(inner(music_hoard(vec![])), matches_info_vec.clone()); + + let mut widget_state = WidgetState::default(); + widget_state.list.select(Some(0)); + + assert_eq!(matches.state.matches_info_vec, matches_info_vec); + assert_eq!(matches.state.index, Some(0)); + assert_eq!(matches.state.state, widget_state); + + let matches = matches.prev_match().unwrap_matches(); + + assert_eq!(matches.state.index, Some(0)); + assert_eq!(matches.state.state.list.selected(), Some(0)); + + let matches = matches.next_match().unwrap_matches(); + + assert_eq!(matches.state.index, Some(0)); + assert_eq!(matches.state.state.list.selected(), Some(1)); + + let matches = matches.next_match().unwrap_matches(); + + assert_eq!(matches.state.index, Some(0)); + assert_eq!(matches.state.state.list.selected(), Some(1)); + + let matches = matches.select().unwrap_matches(); + + assert_eq!(matches.state.index, Some(1)); + assert_eq!(matches.state.state.list.selected(), Some(0)); + + // And it's done + matches.select().unwrap_browse(); + } + + #[test] + fn matches_abort() { + let matches_info_vec = matches_info_vec(); + let matches = AppMachine::matches(inner(music_hoard(vec![])), matches_info_vec.clone()); + + let mut widget_state = WidgetState::default(); + widget_state.list.select(Some(0)); + + assert_eq!(matches.state.matches_info_vec, matches_info_vec); + assert_eq!(matches.state.index, Some(0)); + assert_eq!(matches.state.state, widget_state); + + matches.abort().unwrap_browse(); + } + + #[test] + fn matches_select_empty() { + let matches = AppMachine::matches(inner(music_hoard(vec![])), vec![]); + + assert_eq!(matches.state.matches_info_vec, vec![]); + assert_eq!(matches.state.index, None); + + matches.select().unwrap_browse(); + } + + #[test] + fn no_op() { + let matches = AppMachine::matches(inner(music_hoard(vec![])), vec![]); + let app = matches.no_op(); + app.unwrap_matches(); + } +} diff --git a/src/tui/app/machine/mod.rs b/src/tui/app/machine/mod.rs index e4e9c60..3f07f72 100644 --- a/src/tui/app/machine/mod.rs +++ b/src/tui/app/machine/mod.rs @@ -2,18 +2,20 @@ mod browse; mod critical; mod error; mod info; +mod matches; mod reload; mod search; use crate::tui::{ app::{selection::Selection, AppPublic, AppPublicInner, AppState, IAppAccess, IAppInteract}, - lib::IMusicHoard, + lib::{external::musicbrainz::api::IMusicBrainz, IMusicHoard}, }; use browse::AppBrowse; use critical::AppCritical; use error::AppError; use info::AppInfo; +use matches::AppMatches; use reload::AppReload; use search::AppSearch; @@ -22,6 +24,7 @@ pub type App = AppState< AppMachine, AppMachine, AppMachine, + AppMachine, AppMachine, AppMachine, >; @@ -34,13 +37,14 @@ pub struct AppMachine { pub struct AppInner { running: bool, music_hoard: MH, + mb_api: Box, selection: Selection, } impl App { - pub fn new(mut music_hoard: MH) -> Self { + pub fn new(mut music_hoard: MH, mb_api: Box) -> Self { let init_result = Self::init(&mut music_hoard); - let inner = AppInner::new(music_hoard); + let inner = AppInner::new(music_hoard, mb_api); match init_result { Ok(()) => AppMachine::browse(inner).into(), Err(err) => AppMachine::critical(inner, err.to_string()).into(), @@ -56,6 +60,7 @@ impl App { match self { AppState::Browse(browse) => &browse.inner, AppState::Info(info) => &info.inner, + AppState::Matches(matches) => &matches.inner, AppState::Reload(reload) => &reload.inner, AppState::Search(search) => &search.inner, AppState::Error(error) => &error.inner, @@ -67,6 +72,7 @@ impl App { match self { AppState::Browse(browse) => &mut browse.inner, AppState::Info(info) => &mut info.inner, + AppState::Matches(matches) => &mut matches.inner, AppState::Reload(reload) => &mut reload.inner, AppState::Search(search) => &mut search.inner, AppState::Error(error) => &mut error.inner, @@ -80,6 +86,7 @@ impl IAppInteract for App { type IS = AppMachine; type RS = AppMachine; type SS = AppMachine; + type MS = AppMachine; type ES = AppMachine; type CS = AppMachine; @@ -92,7 +99,9 @@ impl IAppInteract for App { self } - fn state(self) -> AppState { + fn state( + self, + ) -> AppState { self } } @@ -102,6 +111,7 @@ impl IAppAccess for App { match self { AppState::Browse(browse) => browse.into(), AppState::Info(info) => info.into(), + AppState::Matches(matches) => matches.into(), AppState::Reload(reload) => reload.into(), AppState::Search(search) => search.into(), AppState::Error(error) => error.into(), @@ -111,11 +121,12 @@ impl IAppAccess for App { } impl AppInner { - pub fn new(music_hoard: MH) -> Self { + pub fn new(music_hoard: MH, mb_api: Box) -> Self { let selection = Selection::new(music_hoard.get_collection()); AppInner { running: true, music_hoard, + mb_api, selection, } } @@ -136,12 +147,12 @@ mod tests { use crate::tui::{ app::{AppState, IAppInteract, IAppInteractBrowse}, - lib::MockIMusicHoard, + lib::{external::musicbrainz::api::MockIMusicBrainz, MockIMusicHoard}, }; use super::*; - impl AppState { + impl AppState { pub fn unwrap_browse(self) -> BS { match self { AppState::Browse(browse) => browse, @@ -170,6 +181,13 @@ mod tests { } } + pub fn unwrap_matches(self) -> MS { + match self { + AppState::Matches(matches) => matches, + _ => panic!(), + } + } + pub fn unwrap_error(self) -> ES { match self { AppState::Error(error) => error, @@ -203,21 +221,32 @@ mod tests { music_hoard } + fn mb_api() -> Box { + Box::new(MockIMusicBrainz::new()) + } + pub fn inner(music_hoard: MockIMusicHoard) -> AppInner { - AppInner::new(music_hoard) + AppInner::new(music_hoard, mb_api()) + } + + pub fn inner_with_mb( + music_hoard: MockIMusicHoard, + mb_api: Box, + ) -> AppInner { + AppInner::new(music_hoard, mb_api) } #[test] fn state_browse() { - let mut app = App::new(music_hoard_init(vec![])); + let mut app = App::new(music_hoard_init(vec![]), mb_api()); assert!(app.is_running()); let state = app.state(); - matches!(state, AppState::Browse(_)); + assert!(matches!(state, AppState::Browse(_))); app = state; let public = app.get(); - matches!(public.state, AppState::Browse(_)); + assert!(matches!(public.state, AppState::Browse(_))); let app = app.force_quit(); assert!(!app.is_running()); @@ -225,17 +254,17 @@ mod tests { #[test] fn state_info() { - let mut app = App::new(music_hoard_init(vec![])); + let mut app = App::new(music_hoard_init(vec![]), mb_api()); assert!(app.is_running()); app = app.unwrap_browse().show_info_overlay(); let state = app.state(); - matches!(state, AppState::Info(_)); + assert!(matches!(state, AppState::Info(_))); app = state; let public = app.get(); - matches!(public.state, AppState::Info(_)); + assert!(matches!(public.state, AppState::Info(_))); let app = app.force_quit(); assert!(!app.is_running()); @@ -243,17 +272,17 @@ mod tests { #[test] fn state_reload() { - let mut app = App::new(music_hoard_init(vec![])); + let mut app = App::new(music_hoard_init(vec![]), mb_api()); assert!(app.is_running()); app = app.unwrap_browse().show_reload_menu(); let state = app.state(); - matches!(state, AppState::Reload(_)); + assert!(matches!(state, AppState::Reload(_))); app = state; let public = app.get(); - matches!(public.state, AppState::Reload(_)); + assert!(matches!(public.state, AppState::Reload(_))); let app = app.force_quit(); assert!(!app.is_running()); @@ -261,17 +290,35 @@ mod tests { #[test] fn state_search() { - let mut app = App::new(music_hoard_init(vec![])); + let mut app = App::new(music_hoard_init(vec![]), mb_api()); assert!(app.is_running()); app = app.unwrap_browse().begin_search(); let state = app.state(); - matches!(state, AppState::Search(_)); + assert!(matches!(state, AppState::Search(_))); app = state; let public = app.get(); - matches!(public.state, AppState::Search("")); + assert!(matches!(public.state, AppState::Search(""))); + + let app = app.force_quit(); + assert!(!app.is_running()); + } + + #[test] + fn state_matches() { + let mut app = App::new(music_hoard_init(vec![]), mb_api()); + assert!(app.is_running()); + + app = AppMachine::matches(app.unwrap_browse().inner, vec![]).into(); + + let state = app.state(); + assert!(matches!(state, AppState::Matches(_))); + app = state; + + let public = app.get(); + assert!(matches!(public.state, AppState::Matches(_))); let app = app.force_quit(); assert!(!app.is_running()); @@ -279,17 +326,17 @@ mod tests { #[test] fn state_error() { - let mut app = App::new(music_hoard_init(vec![])); + let mut app = App::new(music_hoard_init(vec![]), mb_api()); assert!(app.is_running()); app = AppMachine::error(app.unwrap_browse().inner, "get rekt").into(); let state = app.state(); - matches!(state, AppState::Error(_)); + assert!(matches!(state, AppState::Error(_))); app = state; let public = app.get(); - matches!(public.state, AppState::Error("get rekt")); + assert!(matches!(public.state, AppState::Error("get rekt"))); app = app.force_quit(); assert!(!app.is_running()); @@ -297,17 +344,17 @@ mod tests { #[test] fn state_critical() { - let mut app = App::new(music_hoard_init(vec![])); + let mut app = App::new(music_hoard_init(vec![]), mb_api()); assert!(app.is_running()); app = AppMachine::critical(app.unwrap_browse().inner, "get rekt").into(); let state = app.state(); - matches!(state, AppState::Critical(_)); + assert!(matches!(state, AppState::Critical(_))); app = state; let public = app.get(); - matches!(public.state, AppState::Critical("get rekt")); + assert!(matches!(public.state, AppState::Critical("get rekt"))); app = app.force_quit(); assert!(!app.is_running()); @@ -323,7 +370,7 @@ mod tests { .return_once(|| Err(musichoard::Error::LibraryError(String::from("get rekt")))); music_hoard.expect_get_collection().return_const(vec![]); - let app = App::new(music_hoard); + let app = App::new(music_hoard, mb_api()); assert!(app.is_running()); app.unwrap_critical(); } diff --git a/src/tui/app/mod.rs b/src/tui/app/mod.rs index 8f86587..ea18f9b 100644 --- a/src/tui/app/mod.rs +++ b/src/tui/app/mod.rs @@ -4,13 +4,17 @@ mod selection; pub use machine::App; pub use selection::{Category, Delta, Selection, WidgetState}; -use musichoard::collection::Collection; +use musichoard::{ + collection::{album::Album, Collection}, + interface::musicbrainz::Match, +}; -pub enum AppState { +pub enum AppState { Browse(BS), Info(IS), Reload(RS), Search(SS), + Matches(MS), Error(ES), Critical(CS), } @@ -20,6 +24,7 @@ pub trait IAppInteract { type IS: IAppInteractInfo; type RS: IAppInteractReload; type SS: IAppInteractSearch; + type MS: IAppInteractMatches; type ES: IAppInteractError; type CS: IAppInteractCritical; @@ -27,7 +32,9 @@ pub trait IAppInteract { fn force_quit(self) -> Self; #[allow(clippy::type_complexity)] - fn state(self) -> AppState; + fn state( + self, + ) -> AppState; } pub trait IAppInteractBrowse { @@ -46,6 +53,8 @@ pub trait IAppInteractBrowse { fn begin_search(self) -> Self::APP; + fn fetch_musicbrainz(self) -> Self::APP; + fn no_op(self) -> Self::APP; } @@ -79,6 +88,18 @@ pub trait IAppInteractSearch { fn no_op(self) -> Self::APP; } +pub trait IAppInteractMatches { + type APP: IAppInteract; + + fn prev_match(self) -> Self::APP; + fn next_match(self) -> Self::APP; + fn select(self) -> Self::APP; + + fn abort(self) -> Self::APP; + + fn no_op(self) -> Self::APP; +} + pub trait IAppInteractError { type APP: IAppInteract; @@ -109,9 +130,16 @@ pub struct AppPublicInner<'app> { pub selection: &'app mut Selection, } -pub type AppPublicState<'app> = AppState<(), (), (), &'app str, &'app str, &'app str>; +pub struct AppPublicMatches<'app> { + pub matching: Option<&'app Album>, + pub matches: Option<&'app [Match]>, + pub state: &'app mut WidgetState, +} -impl AppState { +pub type AppPublicState<'app> = + AppState<(), (), (), &'app str, AppPublicMatches<'app>, &'app str, &'app str>; + +impl AppState { pub fn is_search(&self) -> bool { matches!(self, AppState::Search(_)) } diff --git a/src/tui/handler.rs b/src/tui/handler.rs index 038e93e..13f4ec1 100644 --- a/src/tui/handler.rs +++ b/src/tui/handler.rs @@ -6,7 +6,7 @@ use mockall::automock; use crate::tui::{ app::{ AppState, Delta, IAppInteract, IAppInteractBrowse, IAppInteractCritical, IAppInteractError, - IAppInteractInfo, IAppInteractReload, IAppInteractSearch, + IAppInteractInfo, IAppInteractMatches, IAppInteractReload, IAppInteractSearch, }, event::{Event, EventError, EventReceiver}, }; @@ -22,6 +22,7 @@ trait IEventHandlerPrivate { fn handle_info_key_event(app: ::IS, key_event: KeyEvent) -> APP; fn handle_reload_key_event(app: ::RS, key_event: KeyEvent) -> APP; fn handle_search_key_event(app: ::SS, key_event: KeyEvent) -> APP; + fn handle_matches_key_event(app: ::MS, key_event: KeyEvent) -> APP; fn handle_error_key_event(app: ::ES, key_event: KeyEvent) -> APP; fn handle_critical_key_event(app: ::CS, key_event: KeyEvent) -> APP; } @@ -69,6 +70,9 @@ impl IEventHandlerPrivate for EventHandler { AppState::Search(search) => { >::handle_search_key_event(search, key_event) } + AppState::Matches(matches) => { + >::handle_matches_key_event(matches, key_event) + } AppState::Error(error) => { >::handle_error_key_event(error, key_event) } @@ -102,6 +106,7 @@ impl IEventHandlerPrivate for EventHandler { app.no_op() } } + KeyCode::Char('f') | KeyCode::Char('F') => app.fetch_musicbrainz(), // Othey keys. _ => app.no_op(), } @@ -156,6 +161,19 @@ impl IEventHandlerPrivate for EventHandler { } } + fn handle_matches_key_event(app: ::MS, key_event: KeyEvent) -> APP { + match key_event.code { + // Abort. + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => app.abort(), + // Select. + KeyCode::Up => app.prev_match(), + KeyCode::Down => app.next_match(), + KeyCode::Enter => app.select(), + // Othey keys. + _ => app.no_op(), + } + } + fn handle_error_key_event(app: ::ES, _key_event: KeyEvent) -> APP { // Any key dismisses the error. app.dismiss_error() diff --git a/src/tui/lib.rs b/src/tui/lib.rs index d0a812c..9696999 100644 --- a/src/tui/lib.rs +++ b/src/tui/lib.rs @@ -1,6 +1,6 @@ use musichoard::{ - collection::Collection, interface::database::IDatabase, interface::library::ILibrary, - IMusicHoardBase, IMusicHoardDatabase, IMusicHoardLibrary, MusicHoard, + collection::Collection, interface, IMusicHoardBase, IMusicHoardDatabase, IMusicHoardLibrary, + MusicHoard, }; #[cfg(test)] @@ -14,7 +14,9 @@ pub trait IMusicHoard { } // GRCOV_EXCL_START -impl IMusicHoard for MusicHoard { +impl IMusicHoard + for MusicHoard +{ fn rescan_library(&mut self) -> Result<(), musichoard::Error> { ::rescan_library(self) } @@ -28,3 +30,45 @@ impl IMusicHoard for MusicHoard = interface::musicbrainz::Match; + pub type Mbid = interface::musicbrainz::Mbid; + pub type Error = interface::musicbrainz::Error; + + #[cfg_attr(test, automock)] + pub trait IMusicBrainz { + fn search_release_group( + &mut self, + arid: &Mbid, + album: &Album, + ) -> Result>, Error>; + } + + // GRCOV_EXCL_START + impl IMusicBrainz for MusicBrainzApi { + fn search_release_group( + &mut self, + arid: &Mbid, + album: &Album, + ) -> Result>, Error> { + ::search_release_group( + self, arid, album, + ) + } + } + // GRCOV_EXCL_STOP + } + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 0601ba1..806053e 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -173,6 +173,7 @@ mod testmod; mod tests { use std::{io, thread}; + use lib::external::musicbrainz::api::MockIMusicBrainz; use ratatui::{backend::TestBackend, Terminal}; use musichoard::collection::Collection; @@ -201,7 +202,7 @@ mod tests { } fn app(collection: Collection) -> App { - App::new(music_hoard(collection)) + App::new(music_hoard(collection), Box::new(MockIMusicBrainz::new())) } fn listener() -> MockIEventListener { diff --git a/src/tui/ui.rs b/src/tui/ui.rs index c066da3..15fbc17 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1,11 +1,14 @@ use std::collections::HashMap; -use musichoard::collection::{ - album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus}, - artist::Artist, - musicbrainz::IMusicBrainzRef, - track::{Track, TrackFormat, TrackQuality}, - Collection, +use musichoard::{ + collection::{ + album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus}, + artist::Artist, + musicbrainz::IMusicBrainzRef, + track::{Track, TrackFormat, TrackQuality}, + Collection, + }, + interface::musicbrainz::Match, }; use ratatui::{ layout::{Alignment, Rect}, @@ -501,6 +504,7 @@ impl Minibuffer<'_> { Paragraph::new("m: show info overlay"), Paragraph::new("g: show reload menu"), Paragraph::new("ctrl+s: search artist"), + Paragraph::new("f: fetch musicbrainz"), ], columns, }, @@ -525,6 +529,13 @@ impl Minibuffer<'_> { ], columns, }, + AppState::Matches(public) => Minibuffer { + paragraphs: vec![ + Paragraph::new(Minibuffer::display_matching_info(public.matching)), + Paragraph::new("q: abort"), + ], + columns: 2, + }, AppState::Error(_) => Minibuffer { paragraphs: vec![Paragraph::new( "Press any key to dismiss the error message...", @@ -547,6 +558,17 @@ impl Minibuffer<'_> { mb } + + fn display_matching_info(matching: Option<&Album>) -> String { + match matching { + Some(matching) => format!( + "Matching: {} | {}", + AlbumState::display_album_date(&matching.date), + &matching.id.title + ), + None => String::from("Matching: nothing"), + } + } } struct ReloadMenu; @@ -621,6 +643,18 @@ impl Ui { state.height = area.height.saturating_sub(2) as usize; } + fn render_overlay_list_widget( + title: &str, + list: List, + state: &mut WidgetState, + active: bool, + area: Rect, + frame: &mut Frame, + ) { + frame.render_widget(Clear, area); + Self::render_list_widget(title, list, state, active, area, frame); + } + fn render_info_widget( title: &str, paragraph: Paragraph, @@ -792,6 +826,41 @@ impl Ui { } } + fn display_match_string(match_album: &Match) -> String { + format!( + "{:010} | {} [{}] ({}%)", + AlbumState::display_album_date(&match_album.item.date), + &match_album.item.id.title, + AlbumState::display_type( + &match_album.item.primary_type, + &match_album.item.secondary_types + ), + match_album.score, + ) + } + + fn build_match_list(matches: &[Match]) -> List { + List::new( + matches + .iter() + .map(Ui::display_match_string) + .map(ListItem::new) + .collect::>(), + ) + } + + fn render_matches_overlay( + matching: Option<&Album>, + matches: Option<&[Match]>, + state: &mut WidgetState, + frame: &mut Frame, + ) { + let area = OverlayBuilder::default().build(frame.size()); + let matching_string = Minibuffer::display_matching_info(matching); + let list = matches.map(|m| Ui::build_match_list(m)).unwrap_or_default(); + Self::render_overlay_list_widget(&matching_string, list, state, true, area, frame) + } + fn render_reload_overlay(frame: &mut Frame) { let area = OverlayBuilder::default() .with_width(OverlaySize::Value(39)) @@ -827,6 +896,9 @@ impl IUi for Ui { Self::render_main_frame(collection, selection, &state, frame); match state { AppState::Info(_) => Self::render_info_overlay(collection, selection, frame), + AppState::Matches(public) => { + Self::render_matches_overlay(public.matching, public.matches, public.state, frame) + } AppState::Reload(_) => Self::render_reload_overlay(frame), AppState::Error(msg) => Self::render_error_overlay("Error", msg, frame), AppState::Critical(msg) => Self::render_error_overlay("Critical Error", msg, frame), @@ -837,10 +909,10 @@ impl IUi for Ui { #[cfg(test)] mod tests { - use musichoard::collection::artist::ArtistId; + use musichoard::collection::{album::AlbumId, artist::ArtistId}; use crate::tui::{ - app::{AppPublic, AppPublicInner, Delta}, + app::{AppPublic, AppPublicInner, AppPublicMatches, Delta}, testmod::COLLECTION, tests::terminal, }; @@ -860,6 +932,11 @@ mod tests { AppState::Info(()) => AppState::Info(()), AppState::Reload(()) => AppState::Reload(()), AppState::Search(s) => AppState::Search(s), + AppState::Matches(ref mut m) => AppState::Matches(AppPublicMatches { + matching: m.matching, + matches: m.matches, + state: m.state, + }), AppState::Error(s) => AppState::Error(s), AppState::Critical(s) => AppState::Critical(s), }, @@ -870,6 +947,18 @@ mod tests { fn draw_test_suite(collection: &Collection, selection: &mut Selection) { let mut terminal = terminal(); + let album = Album::new( + AlbumId::new("An Album"), + AlbumDate::new(Some(1990), Some(5), None), + Some(AlbumPrimaryType::Album), + vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation], + ); + + let album_match = Match { + score: 80, + item: album.clone(), + }; + let mut app = AppPublic { inner: AppPublicInner { collection, @@ -888,6 +977,26 @@ mod tests { app.state = AppState::Search(""); terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); + let album_matches = [album_match.clone(), album_match.clone()]; + let mut widget_state = WidgetState::default(); + widget_state.list.select(Some(0)); + + app.state = AppState::Matches(AppPublicMatches { + matching: Some(&album), + matches: Some(&album_matches), + state: &mut widget_state, + }); + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); + + let mut widget_state = WidgetState::default(); + + app.state = AppState::Matches(AppPublicMatches { + matching: None, + matches: None, + state: &mut widget_state, + }); + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); + app.state = AppState::Error("get rekt scrub"); terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();