Provide a keyboard shortcut to sync all existing albums with MusicBrainz #167

Merged
wojtek merged 13 commits from 166---provide-a-keyboard-shortcut-to-sync-all-existing-albums-with-musicbrainz into main 2024-08-27 17:55:53 +02:00
11 changed files with 759 additions and 57 deletions

View File

@ -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<Vec<Album>, Error>;
fn search_release_group(
@ -20,7 +16,7 @@ pub trait IMusicBrainz {
) -> Result<Vec<Match<Album>>, Error>;
}
#[derive(Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match<T> {
pub score: u8,
pub item: T,

View File

@ -150,6 +150,7 @@ struct SearchReleaseGroup {
title: String,
first_release_date: String,
primary_type: SerdeAlbumPrimaryType,
secondary_types: Option<Vec<SerdeAlbumSecondaryType>>,
}
impl TryFrom<SearchReleaseGroup> for Match<Album> {
@ -160,7 +161,10 @@ impl TryFrom<SearchReleaseGroup> for Match<Album> {
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(),

View File

@ -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<Database: IDatabase, Library: ILibrary>(builder: MusicHoardBuilder<Datab
let listener = EventListener::new(channel.sender());
let handler = EventHandler::new(channel.receiver());
let app = App::new(music_hoard);
let client = MusicBrainzApiClient::new(MUSICHOARD_HTTP_USER_AGENT)
.expect("failed to initialise HTTP client");
let api = Box::new(MusicBrainzApi::new(client));
let app = App::new(music_hoard, api);
let ui = Ui;
// Run the TUI application.

View File

@ -1,6 +1,10 @@
use std::{thread, time};
use musichoard::collection::musicbrainz::IMusicBrainzRef;
use crate::tui::{
app::{
machine::{App, AppInner, AppMachine},
machine::{matches::AppMatchesInfo, App, AppInner, AppMachine},
selection::{Delta, ListSelection},
AppPublic, AppState, IAppInteractBrowse,
},
@ -81,6 +85,45 @@ impl<MH: IMusicHoard> IAppInteractBrowse for AppMachine<MH, AppBrowse> {
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<MH: IMusicHoard> IAppInteractBrowse for AppMachine<MH, AppBrowse> {
#[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<Vec<Match<Album>>, musicbrainz::api::Error> = Ok(matches_1.clone());
let result_4: Result<Vec<Match<Album>>, 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![])));

View File

@ -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<Match<Album>>,
}
pub struct AppMatches {
matches_info_vec: Vec<AppMatchesInfo>,
index: Option<usize>,
state: WidgetState,
}
impl<MH: IMusicHoard> AppMachine<MH, AppMatches> {
pub fn matches(inner: AppInner<MH>, matches_info_vec: Vec<AppMatchesInfo>) -> 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<MH: IMusicHoard> From<AppMachine<MH, AppMatches>> for App<MH> {
fn from(machine: AppMachine<MH, AppMatches>) -> Self {
AppState::Matches(machine)
}
}
impl<'a, MH: IMusicHoard> From<&'a mut AppMachine<MH, AppMatches>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<MH, AppMatches>) -> Self {
let (matching, matches) = match machine.state.index {
Some(index) => (
Some(&machine.state.matches_info_vec[index].matching),
Some(machine.state.matches_info_vec[index].matches.as_slice()),
),
None => (None, None),
};
AppPublic {
inner: (&mut machine.inner).into(),
state: AppState::Matches(AppPublicMatches {
matching,
matches,
state: &mut machine.state.state,
}),
}
}
}
impl<MH: IMusicHoard> IAppInteractMatches for AppMachine<MH, AppMatches> {
type APP = App<MH>;
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<AppMatchesInfo> {
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();
}
}

View File

@ -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<MH> = AppState<
AppMachine<MH, AppInfo>,
AppMachine<MH, AppReload>,
AppMachine<MH, AppSearch>,
AppMachine<MH, AppMatches>,
AppMachine<MH, AppError>,
AppMachine<MH, AppCritical>,
>;
@ -34,13 +37,14 @@ pub struct AppMachine<MH: IMusicHoard, STATE> {
pub struct AppInner<MH: IMusicHoard> {
running: bool,
music_hoard: MH,
mb_api: Box<dyn IMusicBrainz>,
selection: Selection,
}
impl<MH: IMusicHoard> App<MH> {
pub fn new(mut music_hoard: MH) -> Self {
pub fn new(mut music_hoard: MH, mb_api: Box<dyn IMusicBrainz>) -> 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<MH: IMusicHoard> App<MH> {
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<MH: IMusicHoard> App<MH> {
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<MH: IMusicHoard> IAppInteract for App<MH> {
type IS = AppMachine<MH, AppInfo>;
type RS = AppMachine<MH, AppReload>;
type SS = AppMachine<MH, AppSearch>;
type MS = AppMachine<MH, AppMatches>;
type ES = AppMachine<MH, AppError>;
type CS = AppMachine<MH, AppCritical>;
@ -92,7 +99,9 @@ impl<MH: IMusicHoard> IAppInteract for App<MH> {
self
}
fn state(self) -> AppState<Self::BS, Self::IS, Self::RS, Self::SS, Self::ES, Self::CS> {
fn state(
self,
) -> AppState<Self::BS, Self::IS, Self::RS, Self::SS, Self::MS, Self::ES, Self::CS> {
self
}
}
@ -102,6 +111,7 @@ impl<MH: IMusicHoard> IAppAccess for App<MH> {
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<MH: IMusicHoard> IAppAccess for App<MH> {
}
impl<MH: IMusicHoard> AppInner<MH> {
pub fn new(music_hoard: MH) -> Self {
pub fn new(music_hoard: MH, mb_api: Box<dyn IMusicBrainz>) -> 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<BS, IS, RS, SS, ES, CS> AppState<BS, IS, RS, SS, ES, CS> {
impl<BS, IS, RS, SS, MS, ES, CS> AppState<BS, IS, RS, SS, MS, ES, CS> {
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<MockIMusicBrainz> {
Box::new(MockIMusicBrainz::new())
}
pub fn inner(music_hoard: MockIMusicHoard) -> AppInner<MockIMusicHoard> {
AppInner::new(music_hoard)
AppInner::new(music_hoard, mb_api())
}
pub fn inner_with_mb(
music_hoard: MockIMusicHoard,
mb_api: Box<MockIMusicBrainz>,
) -> AppInner<MockIMusicHoard> {
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();
}

View File

@ -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<BS, IS, RS, SS, ES, CS> {
pub enum AppState<BS, IS, RS, SS, MS, ES, CS> {
Browse(BS),
Info(IS),
Reload(RS),
Search(SS),
Matches(MS),
Error(ES),
Critical(CS),
}
@ -20,6 +24,7 @@ pub trait IAppInteract {
type IS: IAppInteractInfo<APP = Self>;
type RS: IAppInteractReload<APP = Self>;
type SS: IAppInteractSearch<APP = Self>;
type MS: IAppInteractMatches<APP = Self>;
type ES: IAppInteractError<APP = Self>;
type CS: IAppInteractCritical<APP = Self>;
@ -27,7 +32,9 @@ pub trait IAppInteract {
fn force_quit(self) -> Self;
#[allow(clippy::type_complexity)]
fn state(self) -> AppState<Self::BS, Self::IS, Self::RS, Self::SS, Self::ES, Self::CS>;
fn state(
self,
) -> AppState<Self::BS, Self::IS, Self::RS, Self::SS, Self::MS, Self::ES, Self::CS>;
}
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<Album>]>,
pub state: &'app mut WidgetState,
}
impl<BS, IS, RS, SS, ES, CS> AppState<BS, IS, RS, SS, ES, CS> {
pub type AppPublicState<'app> =
AppState<(), (), (), &'app str, AppPublicMatches<'app>, &'app str, &'app str>;
impl<BS, IS, RS, SS, MS, ES, CS> AppState<BS, IS, RS, SS, MS, ES, CS> {
pub fn is_search(&self) -> bool {
matches!(self, AppState::Search(_))
}

View File

@ -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<APP: IAppInteract> {
fn handle_info_key_event(app: <APP as IAppInteract>::IS, key_event: KeyEvent) -> APP;
fn handle_reload_key_event(app: <APP as IAppInteract>::RS, key_event: KeyEvent) -> APP;
fn handle_search_key_event(app: <APP as IAppInteract>::SS, key_event: KeyEvent) -> APP;
fn handle_matches_key_event(app: <APP as IAppInteract>::MS, key_event: KeyEvent) -> APP;
fn handle_error_key_event(app: <APP as IAppInteract>::ES, key_event: KeyEvent) -> APP;
fn handle_critical_key_event(app: <APP as IAppInteract>::CS, key_event: KeyEvent) -> APP;
}
@ -69,6 +70,9 @@ impl<APP: IAppInteract> IEventHandlerPrivate<APP> for EventHandler {
AppState::Search(search) => {
<Self as IEventHandlerPrivate<APP>>::handle_search_key_event(search, key_event)
}
AppState::Matches(matches) => {
<Self as IEventHandlerPrivate<APP>>::handle_matches_key_event(matches, key_event)
}
AppState::Error(error) => {
<Self as IEventHandlerPrivate<APP>>::handle_error_key_event(error, key_event)
}
@ -102,6 +106,7 @@ impl<APP: IAppInteract> IEventHandlerPrivate<APP> for EventHandler {
app.no_op()
}
}
KeyCode::Char('f') | KeyCode::Char('F') => app.fetch_musicbrainz(),
// Othey keys.
_ => app.no_op(),
}
@ -156,6 +161,19 @@ impl<APP: IAppInteract> IEventHandlerPrivate<APP> for EventHandler {
}
}
fn handle_matches_key_event(app: <APP as IAppInteract>::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: <APP as IAppInteract>::ES, _key_event: KeyEvent) -> APP {
// Any key dismisses the error.
app.dismiss_error()

View File

@ -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<Database: IDatabase, Library: ILibrary> IMusicHoard for MusicHoard<Database, Library> {
impl<Database: interface::database::IDatabase, Library: interface::library::ILibrary> IMusicHoard
for MusicHoard<Database, Library>
{
fn rescan_library(&mut self) -> Result<(), musichoard::Error> {
<Self as IMusicHoardLibrary>::rescan_library(self)
}
@ -28,3 +30,45 @@ impl<Database: IDatabase, Library: ILibrary> IMusicHoard for MusicHoard<Database
}
}
// GRCOV_EXCL_STOP
pub mod external {
pub mod musicbrainz {
pub mod api {
use musichoard::{
collection::album::Album,
external::musicbrainz::api::{IMusicBrainzApiClient, MusicBrainzApi},
interface,
};
#[cfg(test)]
use mockall::automock;
pub type Match<T> = interface::musicbrainz::Match<T>;
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<Vec<Match<Album>>, Error>;
}
// GRCOV_EXCL_START
impl<Mbc: IMusicBrainzApiClient> IMusicBrainz for MusicBrainzApi<Mbc> {
fn search_release_group(
&mut self,
arid: &Mbid,
album: &Album,
) -> Result<Vec<Match<Album>>, Error> {
<Self as interface::musicbrainz::IMusicBrainz>::search_release_group(
self, arid, album,
)
}
}
// GRCOV_EXCL_STOP
}
}
}

View File

@ -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<MockIMusicHoard> {
App::new(music_hoard(collection))
App::new(music_hoard(collection), Box::new(MockIMusicBrainz::new()))
}
fn listener() -> MockIEventListener {

View File

@ -1,11 +1,14 @@
use std::collections::HashMap;
use musichoard::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<Album>) -> 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<Album>]) -> List {
List::new(
matches
.iter()
.map(Ui::display_match_string)
.map(ListItem::new)
.collect::<Vec<ListItem>>(),
)
}
fn render_matches_overlay(
matching: Option<&Album>,
matches: Option<&[Match<Album>]>,
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();