Provide a keyboard shortcut to sync all existing albums with MusicBrainz (#167)
Closes #166 Reviewed-on: #167
This commit is contained in:
parent
d8fd952456
commit
cf7e23c38c
@ -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,
|
||||
|
11
src/external/musicbrainz/api/mod.rs
vendored
11
src/external/musicbrainz/api/mod.rs
vendored
@ -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(),
|
||||
|
13
src/main.rs
13
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<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.
|
||||
|
@ -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![])));
|
||||
|
295
src/tui/app/machine/matches.rs
Normal file
295
src/tui/app/machine/matches.rs
Normal 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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
@ -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(_))
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
115
src/tui/ui.rs
115
src/tui/ui.rs
@ -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();
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user