Make fetch asynchronous #210

Merged
wojtek merged 3 commits from 187---make-fetch-asynchronous into main 2024-09-01 17:47:39 +02:00
5 changed files with 229 additions and 105 deletions

View File

@ -73,7 +73,7 @@ struct DbOpt {
fn with<Database: IDatabase + 'static, Library: ILibrary + 'static>( fn with<Database: IDatabase + 'static, Library: ILibrary + 'static>(
builder: MusicHoardBuilder<Database, Library>, builder: MusicHoardBuilder<Database, Library>,
) { ) {
let music_hoard = Box::new(builder.build().expect("failed to initialise MusicHoard")); let music_hoard = builder.build().expect("failed to initialise MusicHoard");
// Initialize the terminal user interface. // Initialize the terminal user interface.
let backend = CrosstermBackend::new(io::stdout()); let backend = CrosstermBackend::new(io::stdout());
@ -86,7 +86,7 @@ fn with<Database: IDatabase + 'static, Library: ILibrary + 'static>(
let http = let http =
MusicBrainzHttp::new(MUSICHOARD_HTTP_USER_AGENT).expect("failed to initialise HTTP client"); MusicBrainzHttp::new(MUSICHOARD_HTTP_USER_AGENT).expect("failed to initialise HTTP client");
let client = MusicBrainzClient::new(http); let client = MusicBrainzClient::new(http);
let musicbrainz = Box::new(MusicBrainz::new(client)); let musicbrainz = MusicBrainz::new(client);
let app = App::new(music_hoard, musicbrainz); let app = App::new(music_hoard, musicbrainz);
let ui = Ui; let ui = Ui;

View File

@ -1,11 +1,21 @@
use std::{sync::mpsc, thread, time}; use std::{
sync::{mpsc, Arc, Mutex},
thread, time,
};
use musichoard::collection::musicbrainz::IMusicBrainzRef; use musichoard::collection::{
album::AlbumMeta,
artist::ArtistMeta,
musicbrainz::{IMusicBrainzRef, Mbid},
};
use crate::tui::app::{ use crate::tui::{
machine::{App, AppInner, AppMachine}, app::{
selection::{Delta, ListSelection}, machine::{App, AppInner, AppMachine},
AppMatchesInfo, AppPublic, AppState, IAppInteractBrowse, selection::{Delta, ListSelection},
AppMatchesInfo, AppPublic, AppState, IAppInteractBrowse,
},
lib::interface::musicbrainz::{Error as MbError, IMusicBrainz},
}; };
pub struct AppBrowse; pub struct AppBrowse;
@ -82,7 +92,7 @@ impl IAppInteractBrowse for AppMachine<AppBrowse> {
AppMachine::search(self.inner, orig).into() AppMachine::search(self.inner, orig).into()
} }
fn fetch_musicbrainz(mut self) -> Self::APP { fn fetch_musicbrainz(self) -> Self::APP {
let coll = self.inner.music_hoard.get_collection(); let coll = self.inner.music_hoard.get_collection();
let artist = match self.inner.selection.state_artist(coll) { let artist = match self.inner.selection.state_artist(coll) {
Some(artist_state) => &coll[artist_state.index], Some(artist_state) => &coll[artist_state.index],
@ -91,43 +101,23 @@ impl IAppInteractBrowse for AppMachine<AppBrowse> {
} }
}; };
let (matches_tx, matches_rx) = mpsc::channel::<AppMatchesInfo>(); let (matches_tx, matches_rx) = mpsc::channel::<FetchResult>();
match artist.meta.musicbrainz { match artist.meta.musicbrainz {
Some(ref mbid) => { Some(ref arid) => {
let arid = mbid.mbid(); let musicbrainz = Arc::clone(&self.inner.musicbrainz);
let arid = arid.mbid().clone();
let mut album_iter = artist.albums.iter().peekable(); let albums = artist.albums.iter().map(|a| &a.meta).cloned().collect();
while let Some(album) = album_iter.next() { thread::spawn(|| Self::fetch_albums(musicbrainz, matches_tx, arid, albums));
if album.meta.musicbrainz.is_some() { }
continue; None => {
} let musicbrainz = Arc::clone(&self.inner.musicbrainz);
let artist = artist.meta.clone();
match self thread::spawn(|| Self::fetch_artist(musicbrainz, matches_tx, artist));
.inner
.musicbrainz
.search_release_group(arid, &album.meta)
{
Ok(list) => matches_tx
.send(AppMatchesInfo::album(album.meta.clone(), list))
.expect("send fails only if receiver is disconnected"),
Err(err) => return AppMachine::error(self.inner, err.to_string()).into(),
}
if album_iter.peek().is_some() {
thread::sleep(time::Duration::from_secs(1));
}
}
} }
None => match self.inner.musicbrainz.search_artist(&artist.meta) {
Ok(list) => matches_tx
.send(AppMatchesInfo::artist(artist.meta.clone(), list))
.expect("send fails only if receiver is disconnected"),
Err(err) => return AppMachine::error(self.inner, err.to_string()).into(),
},
}; };
AppMachine::matches(self.inner, matches_rx).into() AppMachine::app_matches(self.inner, matches_rx)
} }
fn no_op(self) -> Self::APP { fn no_op(self) -> Self::APP {
@ -135,6 +125,63 @@ impl IAppInteractBrowse for AppMachine<AppBrowse> {
} }
} }
pub type FetchError = MbError;
pub type FetchResult = Result<AppMatchesInfo, FetchError>;
pub type FetchSender = mpsc::Sender<FetchResult>;
pub type FetchReceiver = mpsc::Receiver<FetchResult>;
trait IAppInteractBrowsePrivate {
fn fetch_artist(
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
matches_tx: FetchSender,
artist: ArtistMeta,
);
fn fetch_albums(
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
matches_tx: FetchSender,
arid: Mbid,
albums: Vec<AlbumMeta>,
);
}
impl IAppInteractBrowsePrivate for AppMachine<AppBrowse> {
fn fetch_artist(
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
matches_tx: FetchSender,
artist: ArtistMeta,
) {
let result = musicbrainz.lock().unwrap().search_artist(&artist);
let result = result.map(|list| AppMatchesInfo::artist(artist, list));
matches_tx.send(result).ok();
}
fn fetch_albums(
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
matches_tx: FetchSender,
arid: Mbid,
albums: Vec<AlbumMeta>,
) {
let mut musicbrainz = musicbrainz.lock().unwrap();
let mut album_iter = albums.into_iter().peekable();
while let Some(album) = album_iter.next() {
if album.musicbrainz.is_some() {
continue;
}
let result = musicbrainz.search_release_group(&arid, &album);
let result = result.map(|list| AppMatchesInfo::album(album, list));
if matches_tx.send(result).is_err() {
// If receiver disconnects just drop the rest.
return;
}
if album_iter.peek().is_some() {
thread::sleep(time::Duration::from_secs(1));
}
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use mockall::{predicate, Sequence}; use mockall::{predicate, Sequence};
@ -224,7 +271,7 @@ mod tests {
#[test] #[test]
fn fetch_musicbrainz() { fn fetch_musicbrainz() {
let mut mb_api = Box::new(MockIMusicBrainz::new()); let mut mb_api = MockIMusicBrainz::new();
let arid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap(); let arid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap();
let album_1 = COLLECTION[1].albums[0].meta.clone(); let album_1 = COLLECTION[1].albums[0].meta.clone();
@ -305,7 +352,7 @@ mod tests {
#[test] #[test]
fn fetch_musicbrainz_no_artist_mbid() { fn fetch_musicbrainz_no_artist_mbid() {
let mut mb_api = Box::new(MockIMusicBrainz::new()); let mut mb_api = MockIMusicBrainz::new();
let artist = COLLECTION[3].meta.clone(); let artist = COLLECTION[3].meta.clone();
@ -349,7 +396,7 @@ mod tests {
#[test] #[test]
fn fetch_musicbrainz_artist_api_error() { fn fetch_musicbrainz_artist_api_error() {
let mut mb_api = Box::new(MockIMusicBrainz::new()); let mut mb_api = MockIMusicBrainz::new();
let error = Err(musicbrainz::Error::RateLimit); let error = Err(musicbrainz::Error::RateLimit);
@ -371,7 +418,7 @@ mod tests {
#[test] #[test]
fn fetch_musicbrainz_album_api_error() { fn fetch_musicbrainz_album_api_error() {
let mut mb_api = Box::new(MockIMusicBrainz::new()); let mut mb_api = MockIMusicBrainz::new();
let error = Err(musicbrainz::Error::RateLimit); let error = Err(musicbrainz::Error::RateLimit);
@ -386,6 +433,45 @@ mod tests {
app.unwrap_error(); app.unwrap_error();
} }
#[test]
fn fetch_musicbrainz_artist_receiver_disconnect() {
let (tx, rx) = mpsc::channel::<FetchResult>();
drop(rx);
let mut mb_api = MockIMusicBrainz::new();
mb_api
.expect_search_artist()
.times(1)
.return_once(|_| Ok(vec![]));
// We only check it does not panic and that it doesn't call the API more than once.
AppMachine::fetch_artist(Arc::new(Mutex::new(mb_api)), tx, COLLECTION[3].meta.clone());
}
#[test]
fn fetch_musicbrainz_albums_receiver_disconnect() {
let (tx, rx) = mpsc::channel::<FetchResult>();
drop(rx);
let mut mb_api = MockIMusicBrainz::new();
mb_api
.expect_search_release_group()
.times(1)
.return_once(|_, _| Ok(vec![]));
// We only check it does not panic and that it doesn't call the API more than once.
let mbref = &COLLECTION[1].meta.musicbrainz;
let albums = &COLLECTION[1].albums;
AppMachine::fetch_albums(
Arc::new(Mutex::new(mb_api)),
tx,
mbref.as_ref().unwrap().mbid().clone(),
albums.iter().map(|a| &a.meta).cloned().collect(),
);
}
#[test] #[test]
fn no_op() { fn no_op() {
let browse = AppMachine::browse(inner(music_hoard(vec![]))); let browse = AppMachine::browse(inner(music_hoard(vec![])));

View File

@ -1,7 +1,7 @@
use std::{cmp, sync::mpsc}; use std::cmp;
use crate::tui::app::{ use crate::tui::app::{
machine::{App, AppInner, AppMachine}, machine::{browse::FetchReceiver, App, AppInner, AppMachine},
AppAlbumMatches, AppArtistMatches, AppMatchesInfo, AppPublic, AppPublicMatches, AppState, AppAlbumMatches, AppArtistMatches, AppMatchesInfo, AppPublic, AppPublicMatches, AppState,
IAppInteractMatches, MatchOption, WidgetState, IAppInteractMatches, MatchOption, WidgetState,
}; };
@ -43,21 +43,29 @@ impl AppMatchesInfo {
} }
pub struct AppMatches { pub struct AppMatches {
matches_rx: mpsc::Receiver<AppMatchesInfo>, matches_rx: FetchReceiver,
current: Option<AppMatchesInfo>, current: Option<AppMatchesInfo>,
state: WidgetState, state: WidgetState,
} }
impl AppMachine<AppMatches> { impl AppMatches {
pub fn matches(inner: AppInner, matches_rx: mpsc::Receiver<AppMatchesInfo>) -> Self { fn empty(matches_rx: FetchReceiver) -> Self {
let mut state = AppMatches { AppMatches {
matches_rx, matches_rx,
current: None, current: None,
state: WidgetState::default(), state: WidgetState::default(),
}; }
state.next_matches_info(); }
}
impl AppMachine<AppMatches> {
fn matches(inner: AppInner, state: AppMatches) -> Self {
AppMachine { inner, state } AppMachine { inner, state }
} }
pub fn app_matches(inner: AppInner, matches_rx: FetchReceiver) -> App {
AppMachine::matches(inner, AppMatches::empty(matches_rx)).fetch_first()
}
} }
impl From<AppMachine<AppMatches>> for App { impl From<AppMachine<AppMatches>> for App {
@ -104,12 +112,8 @@ impl IAppInteractMatches for AppMachine<AppMatches> {
self.into() self.into()
} }
fn select(mut self) -> Self::APP { fn select(self) -> Self::APP {
self.state.next_matches_info(); self.fetch_next()
match self.state.current {
Some(_) => self.into(),
None => AppMachine::browse(self.inner).into(),
}
} }
fn abort(self) -> Self::APP { fn abort(self) -> Self::APP {
@ -121,28 +125,51 @@ impl IAppInteractMatches for AppMachine<AppMatches> {
} }
} }
trait IAppInteractMatchesPrivate { trait IAppInteractMatchesPrivate
fn next_matches_info(&mut self); where
Self: Sized,
{
fn fetch_first(self) -> App;
fn fetch_next(self) -> App;
fn fetch(self, first: bool) -> App;
} }
impl IAppInteractMatchesPrivate for AppMatches { impl IAppInteractMatchesPrivate for AppMachine<AppMatches> {
fn next_matches_info(&mut self) { fn fetch_first(self) -> App {
// FIXME: try_recv might not be appropriate for asynchronous version. self.fetch(true)
(self.current, self.state) = match self.matches_rx.try_recv() { }
Ok(mut next_match) => {
next_match.push_cannot_have_mbid(); fn fetch_next(self) -> App {
let mut state = WidgetState::default(); self.fetch(false)
state.list.select(Some(0)); }
(Some(next_match), state)
fn fetch(mut self, first: bool) -> App {
match self.state.matches_rx.recv() {
Ok(fetch_result) => match fetch_result {
Ok(mut next_match) => {
next_match.push_cannot_have_mbid();
self.state.current = Some(next_match);
self.state.state.list.select(Some(0));
AppMachine::matches(self.inner, self.state).into()
}
Err(err) => AppMachine::error(self.inner, format!("fetch failed: {err}")).into(),
},
// only happens when the sender disconnects which means it finished its job
Err(_) => {
if first {
AppMachine::matches(self.inner, AppMatches::empty(self.state.matches_rx)).into()
} else {
AppMachine::browse(self.inner).into()
}
} }
Err(_) => (None, WidgetState::default()),
} }
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use mpsc::Receiver; use std::sync::mpsc;
use musichoard::collection::{ use musichoard::collection::{
album::{AlbumDate, AlbumId, AlbumMeta, AlbumPrimaryType, AlbumSecondaryType}, album::{AlbumDate, AlbumId, AlbumMeta, AlbumPrimaryType, AlbumSecondaryType},
artist::{ArtistId, ArtistMeta}, artist::{ArtistId, ArtistMeta},
@ -227,10 +254,10 @@ mod tests {
vec![matches_info_1, matches_info_2] vec![matches_info_1, matches_info_2]
} }
fn receiver(matches_info_vec: Vec<AppMatchesInfo>) -> Receiver<AppMatchesInfo> { fn receiver(matches_info_vec: Vec<AppMatchesInfo>) -> FetchReceiver {
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
for matches_info in matches_info_vec.into_iter() { for matches_info in matches_info_vec.into_iter() {
tx.send(matches_info).unwrap(); tx.send(Ok(matches_info)).unwrap();
} }
rx rx
} }
@ -243,7 +270,8 @@ mod tests {
#[test] #[test]
fn create_empty() { fn create_empty() {
let matches = AppMachine::matches(inner(music_hoard(vec![])), receiver(vec![])); let matches =
AppMachine::app_matches(inner(music_hoard(vec![])), receiver(vec![])).unwrap_matches();
let widget_state = WidgetState::default(); let widget_state = WidgetState::default();
@ -261,10 +289,11 @@ mod tests {
#[test] #[test]
fn create_nonempty() { fn create_nonempty() {
let mut matches_info_vec = album_matches_info_vec(); let mut matches_info_vec = album_matches_info_vec();
let matches = AppMachine::matches( let matches = AppMachine::app_matches(
inner(music_hoard(vec![])), inner(music_hoard(vec![])),
receiver(matches_info_vec.clone()), receiver(matches_info_vec.clone()),
); )
.unwrap_matches();
push_cannot_have_mbid(&mut matches_info_vec); push_cannot_have_mbid(&mut matches_info_vec);
let mut widget_state = WidgetState::default(); let mut widget_state = WidgetState::default();
@ -282,10 +311,11 @@ mod tests {
} }
fn matches_flow(mut matches_info_vec: Vec<AppMatchesInfo>) { fn matches_flow(mut matches_info_vec: Vec<AppMatchesInfo>) {
let matches = AppMachine::matches( let matches = AppMachine::app_matches(
inner(music_hoard(vec![])), inner(music_hoard(vec![])),
receiver(matches_info_vec.clone()), receiver(matches_info_vec.clone()),
); )
.unwrap_matches();
push_cannot_have_mbid(&mut matches_info_vec); push_cannot_have_mbid(&mut matches_info_vec);
let mut widget_state = WidgetState::default(); let mut widget_state = WidgetState::default();
@ -337,10 +367,11 @@ mod tests {
#[test] #[test]
fn matches_abort() { fn matches_abort() {
let mut matches_info_vec = album_matches_info_vec(); let mut matches_info_vec = album_matches_info_vec();
let matches = AppMachine::matches( let matches = AppMachine::app_matches(
inner(music_hoard(vec![])), inner(music_hoard(vec![])),
receiver(matches_info_vec.clone()), receiver(matches_info_vec.clone()),
); )
.unwrap_matches();
push_cannot_have_mbid(&mut matches_info_vec); push_cannot_have_mbid(&mut matches_info_vec);
let mut widget_state = WidgetState::default(); let mut widget_state = WidgetState::default();
@ -354,7 +385,8 @@ mod tests {
#[test] #[test]
fn matches_select_empty() { fn matches_select_empty() {
let matches = AppMachine::matches(inner(music_hoard(vec![])), receiver(vec![])); let matches =
AppMachine::app_matches(inner(music_hoard(vec![])), receiver(vec![])).unwrap_matches();
assert_eq!(matches.state.current, None); assert_eq!(matches.state.current, None);
@ -363,7 +395,8 @@ mod tests {
#[test] #[test]
fn no_op() { fn no_op() {
let matches = AppMachine::matches(inner(music_hoard(vec![])), receiver(vec![])); let matches =
AppMachine::app_matches(inner(music_hoard(vec![])), receiver(vec![])).unwrap_matches();
let app = matches.no_op(); let app = matches.no_op();
app.unwrap_matches(); app.unwrap_matches();
} }

View File

@ -6,6 +6,8 @@ mod matches;
mod reload; mod reload;
mod search; mod search;
use std::sync::{Arc, Mutex};
use crate::tui::{ use crate::tui::{
app::{selection::Selection, AppPublic, AppPublicInner, AppState, IAppAccess, IAppInteract}, app::{selection::Selection, AppPublic, AppPublicInner, AppState, IAppAccess, IAppInteract},
lib::{interface::musicbrainz::IMusicBrainz, IMusicHoard}, lib::{interface::musicbrainz::IMusicBrainz, IMusicHoard},
@ -37,21 +39,24 @@ pub struct AppMachine<STATE> {
pub struct AppInner { pub struct AppInner {
running: bool, running: bool,
music_hoard: Box<dyn IMusicHoard>, music_hoard: Box<dyn IMusicHoard>,
musicbrainz: Box<dyn IMusicBrainz>, musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
selection: Selection, selection: Selection,
} }
impl App { impl App {
pub fn new(mut music_hoard: Box<dyn IMusicHoard>, mb_api: Box<dyn IMusicBrainz>) -> Self { pub fn new<MH: IMusicHoard + 'static, MB: IMusicBrainz + Send + 'static>(
mut music_hoard: MH,
musicbrainz: MB,
) -> Self {
let init_result = Self::init(&mut music_hoard); let init_result = Self::init(&mut music_hoard);
let inner = AppInner::new(music_hoard, mb_api); let inner = AppInner::new(music_hoard, musicbrainz);
match init_result { match init_result {
Ok(()) => AppMachine::browse(inner).into(), Ok(()) => AppMachine::browse(inner).into(),
Err(err) => AppMachine::critical(inner, err.to_string()).into(), Err(err) => AppMachine::critical(inner, err.to_string()).into(),
} }
} }
fn init(music_hoard: &mut Box<dyn IMusicHoard>) -> Result<(), musichoard::Error> { fn init<MH: IMusicHoard>(music_hoard: &mut MH) -> Result<(), musichoard::Error> {
music_hoard.rescan_library()?; music_hoard.rescan_library()?;
Ok(()) Ok(())
} }
@ -121,12 +126,15 @@ impl IAppAccess for App {
} }
impl AppInner { impl AppInner {
pub fn new(music_hoard: Box<dyn IMusicHoard>, musicbrainz: Box<dyn IMusicBrainz>) -> Self { pub fn new<MH: IMusicHoard + 'static, MB: IMusicBrainz + Send + 'static>(
music_hoard: MH,
musicbrainz: MB,
) -> Self {
let selection = Selection::new(music_hoard.get_collection()); let selection = Selection::new(music_hoard.get_collection());
AppInner { AppInner {
running: true, running: true,
music_hoard, music_hoard: Box::new(music_hoard),
musicbrainz, musicbrainz: Arc::new(Mutex::new(musicbrainz)),
selection, selection,
} }
} }
@ -205,14 +213,14 @@ mod tests {
} }
} }
pub fn music_hoard(collection: Collection) -> Box<MockIMusicHoard> { pub fn music_hoard(collection: Collection) -> MockIMusicHoard {
let mut music_hoard = Box::new(MockIMusicHoard::new()); let mut music_hoard = MockIMusicHoard::new();
music_hoard.expect_get_collection().return_const(collection); music_hoard.expect_get_collection().return_const(collection);
music_hoard music_hoard
} }
fn music_hoard_init(collection: Collection) -> Box<MockIMusicHoard> { fn music_hoard_init(collection: Collection) -> MockIMusicHoard {
let mut music_hoard = music_hoard(collection); let mut music_hoard = music_hoard(collection);
music_hoard music_hoard
@ -223,18 +231,15 @@ mod tests {
music_hoard music_hoard
} }
fn mb_api() -> Box<MockIMusicBrainz> { fn mb_api() -> MockIMusicBrainz {
Box::new(MockIMusicBrainz::new()) MockIMusicBrainz::new()
} }
pub fn inner(music_hoard: Box<MockIMusicHoard>) -> AppInner { pub fn inner(music_hoard: MockIMusicHoard) -> AppInner {
AppInner::new(music_hoard, mb_api()) AppInner::new(music_hoard, mb_api())
} }
pub fn inner_with_mb( pub fn inner_with_mb(music_hoard: MockIMusicHoard, mb_api: MockIMusicBrainz) -> AppInner {
music_hoard: Box<MockIMusicHoard>,
mb_api: Box<MockIMusicBrainz>,
) -> AppInner {
AppInner::new(music_hoard, mb_api) AppInner::new(music_hoard, mb_api)
} }
@ -314,7 +319,7 @@ mod tests {
assert!(app.is_running()); assert!(app.is_running());
let (_, rx) = mpsc::channel(); let (_, rx) = mpsc::channel();
app = AppMachine::matches(app.unwrap_browse().inner, rx).into(); app = AppMachine::app_matches(app.unwrap_browse().inner, rx);
let state = app.state(); let state = app.state();
assert!(matches!(state, AppState::Matches(_))); assert!(matches!(state, AppState::Matches(_)));
@ -365,7 +370,7 @@ mod tests {
#[test] #[test]
fn init_error() { fn init_error() {
let mut music_hoard = Box::new(MockIMusicHoard::new()); let mut music_hoard = MockIMusicHoard::new();
music_hoard music_hoard
.expect_rescan_library() .expect_rescan_library()

View File

@ -192,8 +192,8 @@ mod tests {
Terminal::new(backend).unwrap() Terminal::new(backend).unwrap()
} }
fn music_hoard(collection: Collection) -> Box<MockIMusicHoard> { fn music_hoard(collection: Collection) -> MockIMusicHoard {
let mut music_hoard = Box::new(MockIMusicHoard::new()); let mut music_hoard = MockIMusicHoard::new();
music_hoard.expect_reload_database().returning(|| Ok(())); music_hoard.expect_reload_database().returning(|| Ok(()));
music_hoard.expect_rescan_library().returning(|| Ok(())); music_hoard.expect_rescan_library().returning(|| Ok(()));
@ -203,7 +203,7 @@ mod tests {
} }
fn app(collection: Collection) -> App { fn app(collection: Collection) -> App {
App::new(music_hoard(collection), Box::new(MockIMusicBrainz::new())) App::new(music_hoard(collection), MockIMusicBrainz::new())
} }
fn listener() -> MockIEventListener { fn listener() -> MockIEventListener {