Daemonize the musicbrainz thread #217

Merged
wojtek merged 15 commits from 188---add-option-for-manual-input-during-fetch into main 2024-09-21 23:03:47 +02:00
18 changed files with 1132 additions and 475 deletions

View File

@ -4,7 +4,7 @@ extern crate test;
mod tui; mod tui;
use std::{ffi::OsString, fs::OpenOptions, io, path::PathBuf}; use std::{ffi::OsString, fs::OpenOptions, io, path::PathBuf, thread};
use ratatui::{backend::CrosstermBackend, Terminal}; use ratatui::{backend::CrosstermBackend, Terminal};
use structopt::StructOpt; use structopt::StructOpt;
@ -25,7 +25,10 @@ use musichoard::{
MusicHoardBuilder, NoDatabase, NoLibrary, MusicHoardBuilder, NoDatabase, NoLibrary,
}; };
use tui::{App, EventChannel, EventHandler, EventListener, MusicBrainz, Tui, Ui}; use tui::{
App, EventChannel, EventHandler, EventListener, JobChannel, MusicBrainz, MusicBrainzDaemon,
Tui, Ui,
};
const MUSICHOARD_HTTP_USER_AGENT: &str = concat!( const MUSICHOARD_HTTP_USER_AGENT: &str = concat!(
"MusicHoard/", "MusicHoard/",
@ -91,10 +94,13 @@ fn with<Database: IDatabase + 'static, Library: ILibrary + 'static>(
let listener = EventListener::new(listener_sender); let listener = EventListener::new(listener_sender);
let handler = EventHandler::new(channel.receiver()); let handler = EventHandler::new(channel.receiver());
let app = App::new(music_hoard, musicbrainz, app_sender); let mb_job_channel = JobChannel::new();
let app = App::new(music_hoard, mb_job_channel.sender());
let ui = Ui; let ui = Ui;
// Run the TUI application. // Run the TUI application.
thread::spawn(|| MusicBrainzDaemon::run(musicbrainz, mb_job_channel.receiver(), app_sender));
Tui::run(terminal, app, ui, handler, listener).expect("failed to run tui"); Tui::run(terminal, app, ui, handler, listener).expect("failed to run tui");
} }

View File

@ -84,7 +84,7 @@ mod tests {
machine::tests::{inner, inner_with_mb, music_hoard}, machine::tests::{inner, inner_with_mb, music_hoard},
Category, IApp, IAppAccess, Category, IApp, IAppAccess,
}, },
lib::interface::musicbrainz::MockIMusicBrainz, lib::interface::musicbrainz::daemon::MockIMbJobSender,
testmod::COLLECTION, testmod::COLLECTION,
}; };
@ -162,9 +162,16 @@ mod tests {
#[test] #[test]
fn fetch_musicbrainz() { fn fetch_musicbrainz() {
let mb_api = MockIMusicBrainz::new(); let mut mb_job_sender = MockIMbJobSender::new();
let browse = mb_job_sender
AppMachine::browse_state(inner_with_mb(music_hoard(COLLECTION.to_owned()), mb_api)); .expect_submit_background_job()
.times(1)
.returning(|_, _| Ok(()));
let browse = AppMachine::browse_state(inner_with_mb(
music_hoard(COLLECTION.to_owned()),
mb_job_sender,
));
// Use the second artist for this test. // Use the second artist for this test.
let browse = browse.increment_selection(Delta::Line).unwrap_browse(); let browse = browse.increment_selection(Delta::Line).unwrap_browse();

View File

@ -1,41 +1,31 @@
use std::{ use std::{
sync::{ collections::VecDeque,
mpsc::{self, TryRecvError}, sync::mpsc::{self, TryRecvError},
Arc, Mutex,
},
thread, time,
}; };
use musichoard::collection::{ use musichoard::collection::{artist::Artist, musicbrainz::IMusicBrainzRef};
album::AlbumMeta,
artist::{Artist, ArtistMeta},
musicbrainz::{IMusicBrainzRef, Mbid},
};
use crate::tui::{ use crate::tui::{
app::{ app::{
machine::{match_state::MatchState, App, AppInner, AppMachine}, machine::{match_state::MatchState, App, AppInner, AppMachine},
AppPublicState, AppState, IAppEventFetch, IAppInteractFetch, MatchStateInfo, AppPublicState, AppState, IAppEventFetch, IAppInteractFetch,
},
lib::interface::musicbrainz::daemon::{
Error as DaemonError, IMbJobSender, MbApiResult, MbParams, ResultSender,
}, },
event::{Event, EventSender},
lib::interface::musicbrainz::{self, Error as MbError, IMusicBrainz},
}; };
pub struct FetchState { pub struct FetchState {
fetch_rx: FetchReceiver, fetch_rx: FetchReceiver,
} }
pub type FetchReceiver = mpsc::Receiver<MbApiResult>;
impl FetchState { impl FetchState {
pub fn new(fetch_rx: FetchReceiver) -> Self { pub fn new(fetch_rx: FetchReceiver) -> Self {
FetchState { fetch_rx } FetchState { fetch_rx }
} }
} }
pub type FetchError = MbError;
pub type FetchResult = Result<MatchStateInfo, FetchError>;
pub type FetchSender = mpsc::Sender<FetchResult>;
pub type FetchReceiver = mpsc::Receiver<FetchResult>;
impl AppMachine<FetchState> { impl AppMachine<FetchState> {
fn fetch_state(inner: AppInner, state: FetchState) -> Self { fn fetch_state(inner: AppInner, state: FetchState) -> Self {
AppMachine::new(inner, state) AppMachine::new(inner, state)
@ -50,8 +40,10 @@ impl AppMachine<FetchState> {
} }
}; };
let (fetch_tx, fetch_rx) = mpsc::channel::<FetchResult>(); let (fetch_tx, fetch_rx) = mpsc::channel::<MbApiResult>();
Self::spawn_fetch_thread(&inner, artist, fetch_tx); if let Err(err) = Self::submit_fetch_job(&*inner.musicbrainz, fetch_tx, artist) {
return AppMachine::error_state(inner, err.to_string()).into();
}
let fetch = FetchState::new(fetch_rx); let fetch = FetchState::new(fetch_rx);
AppMachine::app_fetch(inner, fetch, true) AppMachine::app_fetch(inner, fetch, true)
@ -85,78 +77,23 @@ impl AppMachine<FetchState> {
} }
} }
fn spawn_fetch_thread( fn submit_fetch_job(
inner: &AppInner, musicbrainz: &dyn IMbJobSender,
result_sender: ResultSender,
artist: &Artist, artist: &Artist,
tx: FetchSender, ) -> Result<(), DaemonError> {
) -> thread::JoinHandle<()> { let requests = match artist.meta.musicbrainz {
match artist.meta.musicbrainz {
Some(ref arid) => { Some(ref arid) => {
let musicbrainz = Arc::clone(&inner.musicbrainz); let arid = arid.mbid();
let events = inner.events.clone(); let albums = artist.albums.iter();
let arid = arid.mbid().clone(); albums
let albums = artist.albums.iter().map(|a| &a.meta).cloned().collect(); .filter(|album| album.meta.musicbrainz.is_none())
thread::spawn(|| Self::fetch_albums(musicbrainz, tx, events, arid, albums)) .map(|album| MbParams::search_release_group(arid.clone(), album.meta.clone()))
.collect()
} }
None => { None => VecDeque::from([MbParams::search_artist(artist.meta.clone())]),
let musicbrainz = Arc::clone(&inner.musicbrainz); };
let events = inner.events.clone(); musicbrainz.submit_background_job(result_sender, requests)
let artist = artist.meta.clone();
thread::spawn(|| Self::fetch_artist(musicbrainz, tx, events, artist))
}
}
}
fn fetch_artist(
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
fetch_tx: FetchSender,
events: EventSender,
artist: ArtistMeta,
) {
let result = musicbrainz.lock().unwrap().search_artist(&artist);
let result = result.map(|list| MatchStateInfo::artist(artist, list));
Self::send_fetch_result(&fetch_tx, &events, result).ok();
}
fn fetch_albums(
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
fetch_tx: FetchSender,
events: EventSender,
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| MatchStateInfo::album(album, list));
if Self::send_fetch_result(&fetch_tx, &events, result).is_err() {
return;
};
if album_iter.peek().is_some() {
thread::sleep(time::Duration::from_secs(1));
}
}
}
fn send_fetch_result(
fetch_tx: &FetchSender,
events: &EventSender,
result: Result<MatchStateInfo, musicbrainz::Error>,
) -> Result<(), ()> {
// If receiver disconnects just drop the rest.
fetch_tx.send(result).map_err(|_| ())?;
// If this send fails the event listener is dead. Don't panic as this function runs in a
// detached thread so this might be happening during normal shut down.
events.send(Event::FetchResultReady).map_err(|_| ())?;
Ok(())
} }
} }
@ -190,18 +127,16 @@ impl IAppEventFetch for AppMachine<FetchState> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use mockall::{predicate, Sequence}; use mockall::predicate;
use musichoard::collection::artist::ArtistMeta; use musichoard::collection::{album::AlbumMeta, artist::ArtistMeta, musicbrainz::Mbid};
use crate::tui::{ use crate::tui::{
app::{ app::{
machine::tests::{inner, music_hoard}, machine::tests::{inner, music_hoard},
AlbumMatches, ArtistMatches, IApp, Delta, IApp, IAppAccess, IAppInteractBrowse, MatchOption, MatchStateInfo,
}, },
event::EventReceiver, lib::interface::musicbrainz::{self, api::Match, daemon::MockIMbJobSender},
lib::interface::musicbrainz::{self, Match, MockIMusicBrainz},
testmod::COLLECTION, testmod::COLLECTION,
EventChannel,
}; };
use super::*; use super::*;
@ -212,210 +147,121 @@ mod tests {
assert!(matches!(app.state(), AppState::Error(_))); assert!(matches!(app.state(), AppState::Error(_)));
} }
fn event_channel() -> (EventSender, EventReceiver) {
let event_channel = EventChannel::new();
let events_tx = event_channel.sender();
let events_rx = event_channel.receiver();
(events_tx, events_rx)
}
fn album_expectations_1() -> (AlbumMeta, Vec<Match<AlbumMeta>>) {
let album_1 = COLLECTION[1].albums[0].meta.clone();
let album_4 = COLLECTION[1].albums[3].meta.clone();
let album_match_1_1 = Match::new(100, album_1.clone());
let album_match_1_2 = Match::new(50, album_4.clone());
let matches_1 = vec![album_match_1_1.clone(), album_match_1_2.clone()];
(album_1, matches_1)
}
fn album_expectations_4() -> (AlbumMeta, Vec<Match<AlbumMeta>>) {
let album_1 = COLLECTION[1].albums[0].meta.clone();
let album_4 = COLLECTION[1].albums[3].meta.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_4 = vec![album_match_4_1.clone(), album_match_4_2.clone()];
(album_4, matches_4)
}
fn search_release_group_expectation( fn search_release_group_expectation(
api: &mut MockIMusicBrainz, job_sender: &mut MockIMbJobSender,
seq: &mut Sequence,
arid: &Mbid, arid: &Mbid,
album: &AlbumMeta, albums: &[AlbumMeta],
matches: &[Match<AlbumMeta>],
) { ) {
let result = Ok(matches.to_owned()); let mut requests = VecDeque::new();
api.expect_search_release_group() for album in albums.iter() {
.with(predicate::eq(arid.clone()), predicate::eq(album.clone())) requests.push_back(MbParams::search_release_group(arid.clone(), album.clone()));
}
job_sender
.expect_submit_background_job()
.with(predicate::always(), predicate::eq(requests))
.times(1) .times(1)
.in_sequence(seq) .return_once(|_, _| Ok(()));
.return_once(|_, _| result);
} }
#[test] #[test]
fn fetch_albums() { fn fetch_albums() {
let mut mb_api = MockIMusicBrainz::new(); let mut mb_job_sender = MockIMbJobSender::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, matches_1) = album_expectations_1(); let album_1_meta = COLLECTION[1].albums[0].meta.clone();
let (album_4, matches_4) = album_expectations_4(); let album_4_meta = COLLECTION[1].albums[3].meta.clone();
// Other albums have an MBID and so they will be skipped. // Other albums have an MBID and so they will be skipped.
let mut seq = Sequence::new(); search_release_group_expectation(&mut mb_job_sender, &arid, &[album_1_meta, album_4_meta]);
search_release_group_expectation(&mut mb_api, &mut seq, &arid, &album_1, &matches_1);
search_release_group_expectation(&mut mb_api, &mut seq, &arid, &album_4, &matches_4);
let music_hoard = music_hoard(COLLECTION.to_owned()); let music_hoard = music_hoard(COLLECTION.to_owned());
let (events_tx, events_rx) = event_channel(); let inner = AppInner::new(music_hoard, mb_job_sender);
let inner = AppInner::new(music_hoard, mb_api, events_tx);
let (fetch_tx, fetch_rx) = mpsc::channel(); // Use second artist to match the expectation.
// Use the second artist for this test. let browse = AppMachine::browse_state(inner);
let handle = AppMachine::spawn_fetch_thread(&inner, &COLLECTION[1], fetch_tx); let app = browse.increment_selection(Delta::Line);
handle.join().unwrap();
assert_eq!(events_rx.try_recv().unwrap(), Event::FetchResultReady); let app = app.unwrap_browse().fetch_musicbrainz();
let result = fetch_rx.try_recv().unwrap(); assert!(matches!(app, AppState::Match(_)));
let expected = Ok(MatchStateInfo::Album(AlbumMatches {
matching: album_1.clone(),
list: matches_1.iter().cloned().map(Into::into).collect(),
}));
assert_eq!(result, expected);
assert_eq!(events_rx.try_recv().unwrap(), Event::FetchResultReady);
let result = fetch_rx.try_recv().unwrap();
let expected = Ok(MatchStateInfo::Album(AlbumMatches {
matching: album_4.clone(),
list: matches_4.iter().cloned().map(Into::into).collect(),
}));
assert_eq!(result, expected);
} }
fn artist_expectations() -> (ArtistMeta, Vec<Match<ArtistMeta>>) { fn search_artist_expectation(job_sender: &mut MockIMbJobSender, artist: &ArtistMeta) {
let artist = COLLECTION[3].meta.clone(); let requests = VecDeque::from([MbParams::search_artist(artist.clone())]);
job_sender
let artist_match_1 = Match::new(100, artist.clone()); .expect_submit_background_job()
let artist_match_2 = Match::new(50, artist.clone()); .with(predicate::always(), predicate::eq(requests))
let matches = vec![artist_match_1.clone(), artist_match_2.clone()];
(artist, matches)
}
fn search_artist_expectation(
api: &mut MockIMusicBrainz,
seq: &mut Sequence,
artist: &ArtistMeta,
matches: &[Match<ArtistMeta>],
) {
let result = Ok(matches.to_owned());
api.expect_search_artist()
.with(predicate::eq(artist.clone()))
.times(1) .times(1)
.in_sequence(seq) .return_once(|_, _| Ok(()));
.return_once(|_| result);
} }
#[test] #[test]
fn fetch_artist() { fn fetch_artist() {
let mut mb_api = MockIMusicBrainz::new(); let mut mb_job_sender = MockIMbJobSender::new();
let (artist, matches) = artist_expectations();
let mut seq = Sequence::new();
search_artist_expectation(&mut mb_api, &mut seq, &artist, &matches);
let music_hoard = music_hoard(COLLECTION.to_owned());
let (events_tx, events_rx) = event_channel();
let inner = AppInner::new(music_hoard, mb_api, events_tx);
let (fetch_tx, fetch_rx) = mpsc::channel();
// Use the fourth artist for this test as they have no MBID.
let handle = AppMachine::spawn_fetch_thread(&inner, &COLLECTION[3], fetch_tx);
handle.join().unwrap();
assert_eq!(events_rx.try_recv().unwrap(), Event::FetchResultReady);
let result = fetch_rx.try_recv().unwrap();
let expected = Ok(MatchStateInfo::Artist(ArtistMatches {
matching: artist.clone(),
list: matches.iter().cloned().map(Into::into).collect(),
}));
assert_eq!(result, expected);
}
#[test]
fn fetch_artist_fetch_disconnect() {
let mut mb_api = MockIMusicBrainz::new();
let (artist, matches) = artist_expectations();
let mut seq = Sequence::new();
search_artist_expectation(&mut mb_api, &mut seq, &artist, &matches);
let music_hoard = music_hoard(COLLECTION.to_owned());
let (events_tx, events_rx) = event_channel();
let inner = AppInner::new(music_hoard, mb_api, events_tx);
let (fetch_tx, _) = mpsc::channel();
// Use the fourth artist for this test as they have no MBID.
let handle = AppMachine::spawn_fetch_thread(&inner, &COLLECTION[3], fetch_tx);
handle.join().unwrap();
assert!(events_rx.try_recv().is_err());
}
#[test]
fn fetch_albums_event_disconnect() {
let mut mb_api = MockIMusicBrainz::new();
let arid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap();
let (album_1, matches_1) = album_expectations_1();
let mut seq = Sequence::new();
search_release_group_expectation(&mut mb_api, &mut seq, &arid, &album_1, &matches_1);
let music_hoard = music_hoard(COLLECTION.to_owned());
let (events_tx, _) = event_channel();
let inner = AppInner::new(music_hoard, mb_api, events_tx);
let (fetch_tx, fetch_rx) = mpsc::channel();
// Use the second artist for this test.
let handle = AppMachine::spawn_fetch_thread(&inner, &COLLECTION[1], fetch_tx);
handle.join().unwrap();
let result = fetch_rx.try_recv().unwrap();
let expected = Ok(MatchStateInfo::Album(AlbumMatches {
matching: album_1.clone(),
list: matches_1.iter().cloned().map(Into::into).collect(),
}));
assert_eq!(result, expected);
assert_eq!(fetch_rx.try_recv().unwrap_err(), TryRecvError::Disconnected);
}
#[test]
fn recv_ok_fetch_ok() {
let (tx, rx) = mpsc::channel::<FetchResult>();
let artist = COLLECTION[3].meta.clone(); let artist = COLLECTION[3].meta.clone();
let fetch_result = Ok(MatchStateInfo::artist::<Match<ArtistMeta>>(artist, vec![])); search_artist_expectation(&mut mb_job_sender, &artist);
tx.send(fetch_result).unwrap();
let inner = inner(music_hoard(COLLECTION.clone())); let music_hoard = music_hoard(COLLECTION.to_owned());
let fetch = FetchState::new(rx); let inner = AppInner::new(music_hoard, mb_job_sender);
let app = AppMachine::app_fetch(inner, fetch, true);
// Use fourth artist to match the expectation.
let browse = AppMachine::browse_state(inner);
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let app = browse.increment_selection(Delta::Line);
let app = app.unwrap_browse().fetch_musicbrainz();
assert!(matches!(app, AppState::Match(_))); assert!(matches!(app, AppState::Match(_)));
} }
#[test] #[test]
fn recv_ok_fetch_err() { fn fetch_artist_job_sender_err() {
let (tx, rx) = mpsc::channel::<FetchResult>(); let mut mb_job_sender = MockIMbJobSender::new();
let fetch_result = Err(musicbrainz::Error::RateLimit); mb_job_sender
.expect_submit_background_job()
.return_once(|_, _| Err(DaemonError::JobChannelDisconnected));
let music_hoard = music_hoard(COLLECTION.to_owned());
let inner = AppInner::new(music_hoard, mb_job_sender);
let browse = AppMachine::browse_state(inner);
let app = browse.fetch_musicbrainz();
assert!(matches!(app, AppState::Error(_)));
}
#[test]
fn recv_ok_fetch_ok() {
let (tx, rx) = mpsc::channel::<MbApiResult>();
let artist = COLLECTION[3].meta.clone();
let artist_match = Match::new(80, COLLECTION[2].meta.clone());
let artist_match_info = MatchStateInfo::artist(artist.clone(), vec![artist_match.clone()]);
let fetch_result = Ok(artist_match_info);
tx.send(fetch_result).unwrap();
let inner = inner(music_hoard(COLLECTION.clone()));
let fetch = FetchState::new(rx);
let mut app = AppMachine::app_fetch(inner, fetch, true);
assert!(matches!(app, AppState::Match(_)));
let public = app.get();
let match_state = public.state.unwrap_match();
let match_options = vec![
artist_match.into(),
MatchOption::CannotHaveMbid,
MatchOption::ManualInputMbid,
];
let expected = MatchStateInfo::artist(artist, match_options);
assert_eq!(match_state.info, Some(expected).as_ref());
}
#[test]
fn recv_ok_fetch_err() {
let (tx, rx) = mpsc::channel::<MbApiResult>();
let fetch_result = Err(musicbrainz::api::Error::RateLimit);
tx.send(fetch_result).unwrap(); tx.send(fetch_result).unwrap();
let inner = inner(music_hoard(COLLECTION.clone())); let inner = inner(music_hoard(COLLECTION.clone()));
@ -426,7 +272,7 @@ mod tests {
#[test] #[test]
fn recv_err_empty() { fn recv_err_empty() {
let (_tx, rx) = mpsc::channel::<FetchResult>(); let (_tx, rx) = mpsc::channel::<MbApiResult>();
let inner = inner(music_hoard(COLLECTION.clone())); let inner = inner(music_hoard(COLLECTION.clone()));
let fetch = FetchState::new(rx); let fetch = FetchState::new(rx);
@ -436,7 +282,7 @@ mod tests {
#[test] #[test]
fn recv_err_disconnected_first() { fn recv_err_disconnected_first() {
let (_, rx) = mpsc::channel::<FetchResult>(); let (_, rx) = mpsc::channel::<MbApiResult>();
let inner = inner(music_hoard(COLLECTION.clone())); let inner = inner(music_hoard(COLLECTION.clone()));
let fetch = FetchState::new(rx); let fetch = FetchState::new(rx);
@ -446,7 +292,7 @@ mod tests {
#[test] #[test]
fn recv_err_disconnected_next() { fn recv_err_disconnected_next() {
let (_, rx) = mpsc::channel::<FetchResult>(); let (_, rx) = mpsc::channel::<MbApiResult>();
let fetch = FetchState::new(rx); let fetch = FetchState::new(rx);
let app = AppMachine::app_fetch_next(inner(music_hoard(COLLECTION.clone())), fetch); let app = AppMachine::app_fetch_next(inner(music_hoard(COLLECTION.clone())), fetch);
@ -455,7 +301,7 @@ mod tests {
#[test] #[test]
fn empty_first_then_ready() { fn empty_first_then_ready() {
let (tx, rx) = mpsc::channel::<FetchResult>(); let (tx, rx) = mpsc::channel::<MbApiResult>();
let inner = inner(music_hoard(COLLECTION.clone())); let inner = inner(music_hoard(COLLECTION.clone()));
let fetch = FetchState::new(rx); let fetch = FetchState::new(rx);
@ -472,7 +318,7 @@ mod tests {
#[test] #[test]
fn abort() { fn abort() {
let (_, rx) = mpsc::channel::<FetchResult>(); let (_, rx) = mpsc::channel::<MbApiResult>();
let fetch = FetchState::new(rx); let fetch = FetchState::new(rx);
let app = AppMachine::fetch_state(inner(music_hoard(COLLECTION.clone())), fetch); let app = AppMachine::fetch_state(inner(music_hoard(COLLECTION.clone())), fetch);

View File

@ -58,7 +58,7 @@ impl IAppInput for AppInputMode {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::tui::app::{ use crate::tui::app::{
machine::tests::{events, mb_api, music_hoard_init}, machine::tests::{mb_job_sender, music_hoard_init},
IApp, IApp,
}; };
@ -74,7 +74,7 @@ mod tests {
#[test] #[test]
fn handle_input() { fn handle_input() {
let mut app = App::new(music_hoard_init(vec![]), mb_api(), events()); let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
app.input_mut().replace(Input::default()); app.input_mut().replace(Input::default());
let input = app.mode().unwrap_input(); let input = app.mode().unwrap_input();

View File

@ -179,7 +179,7 @@ mod tests {
machine::tests::{inner, music_hoard}, machine::tests::{inner, music_hoard},
IApp, IAppAccess, IAppInput, IApp, IAppAccess, IAppInput,
}, },
lib::interface::musicbrainz::Match, lib::interface::musicbrainz::api::Match,
}; };
use super::*; use super::*;

View File

@ -8,15 +8,12 @@ mod match_state;
mod reload_state; mod reload_state;
mod search_state; mod search_state;
use std::sync::{Arc, Mutex};
use crate::tui::{ use crate::tui::{
app::{ app::{
selection::Selection, AppMode, AppPublic, AppPublicInner, AppPublicState, AppState, IApp, selection::Selection, AppMode, AppPublic, AppPublicInner, AppPublicState, AppState, IApp,
IAppAccess, IAppBase, IAppState, IAppAccess, IAppBase, IAppState,
}, },
event::EventSender, lib::{interface::musicbrainz::daemon::IMbJobSender, IMusicHoard},
lib::{interface::musicbrainz::IMusicBrainz, IMusicHoard},
}; };
use browse_state::BrowseState; use browse_state::BrowseState;
@ -49,9 +46,8 @@ 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: Arc<Mutex<dyn IMusicBrainz + Send>>, musicbrainz: Box<dyn IMbJobSender>,
selection: Selection, selection: Selection,
events: EventSender,
} }
macro_rules! app_field_ref { macro_rules! app_field_ref {
@ -85,13 +81,12 @@ macro_rules! app_field_mut {
} }
impl App { impl App {
pub fn new<MH: IMusicHoard + 'static, MB: IMusicBrainz + Send + 'static>( pub fn new<MH: IMusicHoard + 'static, MB: IMbJobSender + 'static>(
mut music_hoard: MH, mut music_hoard: MH,
musicbrainz: MB, musicbrainz: MB,
events: EventSender,
) -> Self { ) -> Self {
let init_result = Self::init(&mut music_hoard); let init_result = Self::init(&mut music_hoard);
let inner = AppInner::new(music_hoard, musicbrainz, events); let inner = AppInner::new(music_hoard, musicbrainz);
match init_result { match init_result {
Ok(()) => AppMachine::browse_state(inner).into(), Ok(()) => AppMachine::browse_state(inner).into(),
Err(err) => AppMachine::critical_state(inner, err.to_string()).into(), Err(err) => AppMachine::critical_state(inner, err.to_string()).into(),
@ -174,18 +169,16 @@ impl IAppAccess for App {
} }
impl AppInner { impl AppInner {
pub fn new<MH: IMusicHoard + 'static, MB: IMusicBrainz + Send + 'static>( pub fn new<MH: IMusicHoard + 'static, MB: IMbJobSender + 'static>(
music_hoard: MH, music_hoard: MH,
musicbrainz: MB, musicbrainz: MB,
events: EventSender,
) -> Self { ) -> 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: Box::new(music_hoard), music_hoard: Box::new(music_hoard),
musicbrainz: Arc::new(Mutex::new(musicbrainz)), musicbrainz: Box::new(musicbrainz),
selection, selection,
events,
} }
} }
} }
@ -230,8 +223,7 @@ mod tests {
use crate::tui::{ use crate::tui::{
app::{AppState, IApp, IAppInput, IAppInteractBrowse}, app::{AppState, IApp, IAppInput, IAppInteractBrowse},
lib::{interface::musicbrainz::MockIMusicBrainz, MockIMusicHoard}, lib::{interface::musicbrainz::daemon::MockIMbJobSender, MockIMusicHoard},
EventChannel,
}; };
use super::*; use super::*;
@ -348,25 +340,24 @@ mod tests {
music_hoard music_hoard
} }
pub fn mb_api() -> MockIMusicBrainz { pub fn mb_job_sender() -> MockIMbJobSender {
MockIMusicBrainz::new() MockIMbJobSender::new()
}
pub fn events() -> EventSender {
EventChannel::new().sender()
} }
pub fn inner(music_hoard: MockIMusicHoard) -> AppInner { pub fn inner(music_hoard: MockIMusicHoard) -> AppInner {
AppInner::new(music_hoard, mb_api(), events()) AppInner::new(music_hoard, mb_job_sender())
} }
pub fn inner_with_mb(music_hoard: MockIMusicHoard, mb_api: MockIMusicBrainz) -> AppInner { pub fn inner_with_mb(
AppInner::new(music_hoard, mb_api, events()) music_hoard: MockIMusicHoard,
mb_job_sender: MockIMbJobSender,
) -> AppInner {
AppInner::new(music_hoard, mb_job_sender)
} }
#[test] #[test]
fn input_mode() { fn input_mode() {
let app = App::new(music_hoard_init(vec![]), mb_api(), events()); let app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running()); assert!(app.is_running());
let mode = app.mode(); let mode = app.mode();
@ -393,7 +384,7 @@ mod tests {
#[test] #[test]
fn state_browse() { fn state_browse() {
let mut app = App::new(music_hoard_init(vec![]), mb_api(), events()); let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running()); assert!(app.is_running());
let state = app.state(); let state = app.state();
@ -414,7 +405,7 @@ mod tests {
#[test] #[test]
fn state_info() { fn state_info() {
let mut app = App::new(music_hoard_init(vec![]), mb_api(), events()); let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running()); assert!(app.is_running());
app = app.unwrap_browse().show_info_overlay(); app = app.unwrap_browse().show_info_overlay();
@ -437,7 +428,7 @@ mod tests {
#[test] #[test]
fn state_reload() { fn state_reload() {
let mut app = App::new(music_hoard_init(vec![]), mb_api(), events()); let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running()); assert!(app.is_running());
app = app.unwrap_browse().show_reload_menu(); app = app.unwrap_browse().show_reload_menu();
@ -460,7 +451,7 @@ mod tests {
#[test] #[test]
fn state_search() { fn state_search() {
let mut app = App::new(music_hoard_init(vec![]), mb_api(), events()); let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running()); assert!(app.is_running());
app = app.unwrap_browse().begin_search(); app = app.unwrap_browse().begin_search();
@ -483,7 +474,7 @@ mod tests {
#[test] #[test]
fn state_fetch() { fn state_fetch() {
let mut app = App::new(music_hoard_init(vec![]), mb_api(), events()); let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running()); assert!(app.is_running());
let (_, rx) = mpsc::channel(); let (_, rx) = mpsc::channel();
@ -509,7 +500,7 @@ mod tests {
#[test] #[test]
fn state_match() { fn state_match() {
let mut app = App::new(music_hoard_init(vec![]), mb_api(), events()); let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running()); assert!(app.is_running());
let (_, rx) = mpsc::channel(); let (_, rx) = mpsc::channel();
@ -535,7 +526,7 @@ mod tests {
#[test] #[test]
fn state_error() { fn state_error() {
let mut app = App::new(music_hoard_init(vec![]), mb_api(), events()); let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running()); assert!(app.is_running());
app = AppMachine::error_state(app.unwrap_browse().inner, "get rekt").into(); app = AppMachine::error_state(app.unwrap_browse().inner, "get rekt").into();
@ -558,7 +549,7 @@ mod tests {
#[test] #[test]
fn state_critical() { fn state_critical() {
let mut app = App::new(music_hoard_init(vec![]), mb_api(), events()); let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running()); assert!(app.is_running());
app = AppMachine::critical_state(app.unwrap_browse().inner, "get rekt").into(); app = AppMachine::critical_state(app.unwrap_browse().inner, "get rekt").into();
@ -589,7 +580,7 @@ mod tests {
.return_once(|| Err(musichoard::Error::LibraryError(String::from("get rekt")))); .return_once(|| Err(musichoard::Error::LibraryError(String::from("get rekt"))));
music_hoard.expect_get_collection().return_const(vec![]); music_hoard.expect_get_collection().return_const(vec![]);
let app = App::new(music_hoard, mb_api(), events()); let app = App::new(music_hoard, mb_job_sender());
assert!(app.is_running()); assert!(app.is_running());
app.unwrap_critical(); app.unwrap_critical();
} }

View File

@ -6,7 +6,7 @@ pub use selection::{Category, Delta, Selection, WidgetState};
use musichoard::collection::{album::AlbumMeta, artist::ArtistMeta, Collection}; use musichoard::collection::{album::AlbumMeta, artist::ArtistMeta, Collection};
use crate::tui::lib::interface::musicbrainz::Match; use crate::tui::lib::interface::musicbrainz::api::Match;
pub enum AppState<B, I, R, S, F, M, E, C> { pub enum AppState<B, I, R, S, F, M, E, C> {
Browse(B), Browse(B),

View File

@ -2,6 +2,9 @@ use crossterm::event::KeyEvent;
use std::fmt; use std::fmt;
use std::sync::mpsc; use std::sync::mpsc;
#[cfg(test)]
use mockall::automock;
#[derive(Debug)] #[derive(Debug)]
pub enum EventError { pub enum EventError {
Send(Event), Send(Event),
@ -42,7 +45,7 @@ impl From<mpsc::TryRecvError> for EventError {
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Event { pub enum Event {
Key(KeyEvent), Key(KeyEvent),
FetchResultReady, FetchComplete,
} }
pub struct EventChannel { pub struct EventChannel {
@ -50,6 +53,15 @@ pub struct EventChannel {
receiver: mpsc::Receiver<Event>, receiver: mpsc::Receiver<Event>,
} }
pub trait IKeyEventSender {
fn send_key(&self, key_event: KeyEvent) -> Result<(), EventError>;
}
#[cfg_attr(test, automock)]
pub trait IFetchCompleteEventSender {
fn send_fetch_complete(&self) -> Result<(), EventError>;
}
#[derive(Clone)] #[derive(Clone)]
pub struct EventSender { pub struct EventSender {
sender: mpsc::Sender<Event>, sender: mpsc::Sender<Event>,
@ -78,9 +90,15 @@ impl EventChannel {
} }
} }
impl EventSender { impl IKeyEventSender for EventSender {
pub fn send(&self, event: Event) -> Result<(), EventError> { fn send_key(&self, key_event: KeyEvent) -> Result<(), EventError> {
Ok(self.sender.send(event)?) Ok(self.sender.send(Event::Key(key_event))?)
}
}
impl IFetchCompleteEventSender for EventSender {
fn send_fetch_complete(&self) -> Result<(), EventError> {
Ok(self.sender.send(Event::FetchComplete)?)
} }
} }
@ -108,13 +126,13 @@ mod tests {
let channel = EventChannel::new(); let channel = EventChannel::new();
let sender = channel.sender(); let sender = channel.sender();
let receiver = channel.receiver(); let receiver = channel.receiver();
let event = Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::empty())); let key_event = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
let result = sender.send(event); let result = sender.send_key(key_event);
assert!(result.is_ok()); assert!(result.is_ok());
drop(receiver); drop(receiver);
let result = sender.send(event); let result = sender.send_key(key_event);
assert!(result.is_err()); assert!(result.is_err());
} }
@ -123,9 +141,9 @@ mod tests {
let channel = EventChannel::new(); let channel = EventChannel::new();
let sender = channel.sender(); let sender = channel.sender();
let receiver = channel.receiver(); let receiver = channel.receiver();
let event = Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::empty())); let key_event = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
sender.send(event).unwrap(); sender.send_key(key_event).unwrap();
let result = receiver.recv(); let result = receiver.recv();
assert!(result.is_ok()); assert!(result.is_ok());
@ -139,12 +157,11 @@ mod tests {
let channel = EventChannel::new(); let channel = EventChannel::new();
let sender = channel.sender(); let sender = channel.sender();
let receiver = channel.receiver(); let receiver = channel.receiver();
let event = Event::FetchResultReady;
let result = receiver.try_recv(); let result = receiver.try_recv();
assert!(result.is_err()); assert!(result.is_err());
sender.send(event).unwrap(); sender.send_fetch_complete().unwrap();
let result = receiver.try_recv(); let result = receiver.try_recv();
assert!(result.is_ok()); assert!(result.is_ok());

View File

@ -28,7 +28,7 @@ trait IEventHandlerPrivate<APP: IApp> {
fn handle_error_key_event(app: <APP as IApp>::ErrorState, key_event: KeyEvent) -> APP; fn handle_error_key_event(app: <APP as IApp>::ErrorState, key_event: KeyEvent) -> APP;
fn handle_critical_key_event(app: <APP as IApp>::CriticalState, key_event: KeyEvent) -> APP; fn handle_critical_key_event(app: <APP as IApp>::CriticalState, key_event: KeyEvent) -> APP;
fn handle_fetch_result_ready_event(app: APP) -> APP; fn handle_fetch_complete_event(app: APP) -> APP;
fn handle_input_key_event<Input: IAppInput<APP = APP>>(app: Input, key_event: KeyEvent) -> APP; fn handle_input_key_event<Input: IAppInput<APP = APP>>(app: Input, key_event: KeyEvent) -> APP;
} }
@ -48,7 +48,7 @@ impl<APP: IApp> IEventHandler<APP> for EventHandler {
fn handle_next_event(&self, app: APP) -> Result<APP, EventError> { fn handle_next_event(&self, app: APP) -> Result<APP, EventError> {
Ok(match self.events.recv()? { Ok(match self.events.recv()? {
Event::Key(key_event) => Self::handle_key_event(app, key_event), Event::Key(key_event) => Self::handle_key_event(app, key_event),
Event::FetchResultReady => Self::handle_fetch_result_ready_event(app), Event::FetchComplete => Self::handle_fetch_complete_event(app),
}) })
} }
} }
@ -92,7 +92,7 @@ impl<APP: IApp> IEventHandlerPrivate<APP> for EventHandler {
} }
} }
fn handle_fetch_result_ready_event(app: APP) -> APP { fn handle_fetch_complete_event(app: APP) -> APP {
match app.state() { match app.state() {
AppState::Browse(state) => state.no_op(), AppState::Browse(state) => state.no_op(),
AppState::Info(state) => state.no_op(), AppState::Info(state) => state.no_op(),

View File

@ -0,0 +1,104 @@
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
use std::collections::HashMap;
use musichoard::{
collection::{
album::{AlbumDate, AlbumMeta, AlbumSeq},
artist::ArtistMeta,
musicbrainz::Mbid,
},
external::musicbrainz::{
api::{
search::{
SearchArtistRequest, SearchArtistResponseArtist, SearchReleaseGroupRequest,
SearchReleaseGroupResponseReleaseGroup,
},
MusicBrainzClient,
},
IMusicBrainzHttp,
},
};
use crate::tui::lib::interface::musicbrainz::api::{Error, IMusicBrainz, Match};
// GRCOV_EXCL_START
pub struct MusicBrainz<Http> {
client: MusicBrainzClient<Http>,
}
impl<Http> MusicBrainz<Http> {
pub fn new(client: MusicBrainzClient<Http>) -> Self {
MusicBrainz { client }
}
}
impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Match<ArtistMeta>>, Error> {
let query = SearchArtistRequest::new().string(&artist.id.name);
let mb_response = self.client.search_artist(query)?;
Ok(mb_response
.artists
.into_iter()
.map(from_search_artist_response_artist)
.collect())
}
fn search_release_group(
&mut self,
arid: &Mbid,
album: &AlbumMeta,
) -> Result<Vec<Match<AlbumMeta>>, Error> {
// Some release groups may have a promotional early release messing up the search. Searching
// with just the year should be enough anyway.
let date = AlbumDate::new(album.date.year, None, None);
let query = SearchReleaseGroupRequest::new()
.arid(arid)
.and()
.first_release_date(&date)
.and()
.release_group(&album.id.title);
let mb_response = self.client.search_release_group(query)?;
Ok(mb_response
.release_groups
.into_iter()
.map(from_search_release_group_response_release_group)
.collect())
}
}
fn from_search_artist_response_artist(entity: SearchArtistResponseArtist) -> Match<ArtistMeta> {
Match {
score: entity.score,
item: ArtistMeta {
id: entity.name,
sort: entity.sort.map(Into::into),
musicbrainz: Some(entity.id.into()),
properties: HashMap::new(),
},
disambiguation: entity.disambiguation,
}
}
fn from_search_release_group_response_release_group(
entity: SearchReleaseGroupResponseReleaseGroup,
) -> Match<AlbumMeta> {
Match {
score: entity.score,
item: AlbumMeta {
id: entity.title,
date: entity.first_release_date,
seq: AlbumSeq::default(),
musicbrainz: Some(entity.id.into()),
primary_type: Some(entity.primary_type),
secondary_types: entity.secondary_types.unwrap_or_default(),
},
disambiguation: None,
}
}
// GRCOV_EXCL_STOP

View File

@ -0,0 +1,705 @@
use std::{collections::VecDeque, sync::mpsc, thread, time};
use crate::tui::{
app::MatchStateInfo,
event::IFetchCompleteEventSender,
lib::interface::musicbrainz::{
api::{Error as ApiError, IMusicBrainz},
daemon::{Error, IMbJobSender, MbParams, ResultSender, SearchParams},
},
};
pub struct MusicBrainzDaemon {
musicbrainz: Box<dyn IMusicBrainz>,
job_receiver: mpsc::Receiver<Job>,
job_queue: JobQueue,
event_sender: Box<dyn IFetchCompleteEventSender>,
}
struct JobQueue {
foreground_queue: VecDeque<JobInstance>,
background_queue: VecDeque<JobInstance>,
}
#[derive(Debug)]
struct Job {
priority: JobPriority,
instance: JobInstance,
}
impl Job {
pub fn new(priority: JobPriority, instance: JobInstance) -> Self {
Job { priority, instance }
}
}
#[derive(Debug)]
enum JobPriority {
#[cfg(test)]
Foreground,
Background,
}
#[derive(Debug)]
struct JobInstance {
result_sender: ResultSender,
requests: VecDeque<MbParams>,
}
#[derive(Debug, PartialEq, Eq)]
enum JobInstanceStatus {
Continue,
Complete,
}
#[derive(Debug, PartialEq, Eq)]
enum JobInstanceError {
ReturnChannelDisconnected,
EventChannelDisconnected,
}
impl JobInstance {
fn new(result_sender: ResultSender, requests: VecDeque<MbParams>) -> Self {
JobInstance {
result_sender,
requests,
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum JobError {
JobQueueEmpty,
EventChannelDisconnected,
}
pub struct JobChannel {
sender: mpsc::Sender<Job>,
receiver: mpsc::Receiver<Job>,
}
pub struct JobSender {
sender: mpsc::Sender<Job>,
}
pub struct JobReceiver {
receiver: mpsc::Receiver<Job>,
}
impl JobChannel {
pub fn new() -> Self {
let (sender, receiver) = mpsc::channel();
JobChannel { receiver, sender }
}
pub fn sender(&self) -> JobSender {
JobSender {
sender: self.sender.clone(),
}
}
pub fn receiver(self) -> JobReceiver {
JobReceiver {
receiver: self.receiver,
}
}
}
impl IMbJobSender for JobSender {
fn submit_background_job(
&self,
result_sender: ResultSender,
requests: VecDeque<MbParams>,
) -> Result<(), Error> {
self.send_background_job(result_sender, requests)
}
}
impl JobSender {
fn send_background_job(
&self,
result_sender: ResultSender,
requests: VecDeque<MbParams>,
) -> Result<(), Error> {
self.send_job(JobPriority::Background, result_sender, requests)
}
fn send_job(
&self,
priority: JobPriority,
result_sender: ResultSender,
requests: VecDeque<MbParams>,
) -> Result<(), Error> {
let instance = JobInstance::new(result_sender, requests);
let job = Job::new(priority, instance);
self.sender.send(job).or(Err(Error::JobChannelDisconnected))
}
}
impl MusicBrainzDaemon {
// GRCOV_EXCL_START
pub fn run<MB: IMusicBrainz + 'static, ES: IFetchCompleteEventSender + 'static>(
musicbrainz: MB,
job_receiver: JobReceiver,
event_sender: ES,
) -> Result<(), Error> {
let daemon = MusicBrainzDaemon {
musicbrainz: Box::new(musicbrainz),
job_receiver: job_receiver.receiver,
job_queue: JobQueue::new(),
event_sender: Box::new(event_sender),
};
daemon.main()
}
fn main(mut self) -> Result<(), Error> {
loop {
self.enqueue_all_pending_jobs()?;
match self.execute_next_job() {
Ok(()) => {
// Sleep for one second. Required by MB API rate limiting. Assume all other
// processing takes negligible time such that regardless of how much other
// processing there is to be done, this one second sleep is necessary.
thread::sleep(time::Duration::from_secs(1));
}
Err(JobError::JobQueueEmpty) => {
self.wait_for_jobs()?;
}
Err(JobError::EventChannelDisconnected) => {
return Err(Error::EventChannelDisconnected);
}
}
}
}
// GRCOV_EXCL_STOP
fn enqueue_all_pending_jobs(&mut self) -> Result<(), Error> {
loop {
match self.job_receiver.try_recv() {
Ok(job) => self.job_queue.push_back(job),
Err(mpsc::TryRecvError::Empty) => return Ok(()),
Err(mpsc::TryRecvError::Disconnected) => {
return Err(Error::JobChannelDisconnected);
}
}
}
}
fn execute_next_job(&mut self) -> Result<(), JobError> {
if let Some(instance) = self.job_queue.front_mut() {
let result = instance.execute_next(&mut *self.musicbrainz, &mut *self.event_sender);
match result {
Ok(JobInstanceStatus::Continue) => {}
Ok(JobInstanceStatus::Complete)
| Err(JobInstanceError::ReturnChannelDisconnected) => {
self.job_queue.pop_front();
}
Err(JobInstanceError::EventChannelDisconnected) => {
return Err(JobError::EventChannelDisconnected)
}
}
return Ok(());
}
Err(JobError::JobQueueEmpty)
}
fn wait_for_jobs(&mut self) -> Result<(), Error> {
assert!(self.job_queue.is_empty());
match self.job_receiver.recv() {
Ok(job) => self.job_queue.push_back(job),
Err(mpsc::RecvError) => return Err(Error::JobChannelDisconnected),
}
Ok(())
}
}
impl JobInstance {
fn execute_next(
&mut self,
musicbrainz: &mut dyn IMusicBrainz,
event_sender: &mut dyn IFetchCompleteEventSender,
) -> Result<JobInstanceStatus, JobInstanceError> {
// self.requests can be empty if the caller submits an empty job.
if let Some(params) = self.requests.pop_front() {
self.execute(musicbrainz, event_sender, params)?
}
if self.requests.is_empty() {
Ok(JobInstanceStatus::Complete)
} else {
Ok(JobInstanceStatus::Continue)
}
}
fn execute(
&mut self,
musicbrainz: &mut dyn IMusicBrainz,
event_sender: &mut dyn IFetchCompleteEventSender,
api_params: MbParams,
) -> Result<(), JobInstanceError> {
match api_params {
MbParams::Search(search) => match search {
SearchParams::Artist(params) => {
let result = musicbrainz.search_artist(&params.artist);
let result = result.map(|list| MatchStateInfo::artist(params.artist, list));
self.return_result(event_sender, result)
}
SearchParams::ReleaseGroup(params) => {
let result = musicbrainz.search_release_group(&params.arid, &params.album);
let result = result.map(|list| MatchStateInfo::album(params.album, list));
self.return_result(event_sender, result)
}
},
}
}
fn return_result(
&mut self,
event_sender: &mut dyn IFetchCompleteEventSender,
result: Result<MatchStateInfo, ApiError>,
) -> Result<(), JobInstanceError> {
self.result_sender
.send(result)
.map_err(|_| JobInstanceError::ReturnChannelDisconnected)?;
// If this send fails the event listener is dead. Don't panic as this function runs in a
// detached thread so this might be happening during normal shut down.
event_sender
.send_fetch_complete()
.map_err(|_| JobInstanceError::EventChannelDisconnected)?;
Ok(())
}
}
impl JobQueue {
fn new() -> Self {
JobQueue {
foreground_queue: VecDeque::new(),
background_queue: VecDeque::new(),
}
}
fn is_empty(&self) -> bool {
self.foreground_queue.is_empty() && self.background_queue.is_empty()
}
fn front_mut(&mut self) -> Option<&mut JobInstance> {
self.foreground_queue
.front_mut()
.or_else(|| self.background_queue.front_mut())
}
fn pop_front(&mut self) -> Option<JobInstance> {
self.foreground_queue
.pop_front()
.or_else(|| self.background_queue.pop_front())
}
#[cfg(test)]
fn push_back(&mut self, job: Job) {
match job.priority {
JobPriority::Foreground => self.foreground_queue.push_back(job.instance),
JobPriority::Background => self.background_queue.push_back(job.instance),
}
}
// GRCOV_EXCL_START
#[cfg(not(test))]
fn push_back(&mut self, job: Job) {
match job.priority {
JobPriority::Background => self.background_queue.push_back(job.instance),
}
}
// GRCOV_EXCL_STOP
}
#[cfg(test)]
mod tests {
use mockall::{predicate, Sequence};
use musichoard::collection::{
album::AlbumMeta,
artist::ArtistMeta,
musicbrainz::{IMusicBrainzRef, Mbid},
};
use crate::tui::{
event::{Event, EventError, MockIFetchCompleteEventSender},
lib::interface::musicbrainz::api::{Match, MockIMusicBrainz},
testmod::COLLECTION,
};
use super::*;
fn musicbrainz() -> MockIMusicBrainz {
MockIMusicBrainz::new()
}
fn job_channel() -> (JobSender, JobReceiver) {
let channel = JobChannel::new();
let sender = channel.sender();
let receiver = channel.receiver();
(sender, receiver)
}
fn event_sender() -> MockIFetchCompleteEventSender {
MockIFetchCompleteEventSender::new()
}
fn fetch_complete_expectation(event_sender: &mut MockIFetchCompleteEventSender, times: usize) {
event_sender
.expect_send_fetch_complete()
.times(times)
.returning(|| Ok(()));
}
fn daemon(job_receiver: JobReceiver) -> MusicBrainzDaemon {
MusicBrainzDaemon {
musicbrainz: Box::new(musicbrainz()),
job_receiver: job_receiver.receiver,
job_queue: JobQueue::new(),
event_sender: Box::new(event_sender()),
}
}
fn daemon_with(
musicbrainz: MockIMusicBrainz,
job_receiver: JobReceiver,
event_sender: MockIFetchCompleteEventSender,
) -> MusicBrainzDaemon {
MusicBrainzDaemon {
musicbrainz: Box::new(musicbrainz),
job_receiver: job_receiver.receiver,
job_queue: JobQueue::new(),
event_sender: Box::new(event_sender),
}
}
fn search_artist_requests() -> VecDeque<MbParams> {
let artist = COLLECTION[3].meta.clone();
VecDeque::from([MbParams::search_artist(artist)])
}
fn artist_expectations() -> (ArtistMeta, Vec<Match<ArtistMeta>>) {
let artist = COLLECTION[3].meta.clone();
let artist_match_1 = Match::new(100, artist.clone());
let artist_match_2 = Match::new(50, artist.clone());
let matches = vec![artist_match_1.clone(), artist_match_2.clone()];
(artist, matches)
}
fn search_albums_requests() -> VecDeque<MbParams> {
let mbref = COLLECTION[1].meta.musicbrainz.as_ref();
let arid = mbref.unwrap().mbid().clone();
let album_1 = COLLECTION[1].albums[0].meta.clone();
let album_4 = COLLECTION[1].albums[3].meta.clone();
VecDeque::from([
MbParams::search_release_group(arid.clone(), album_1),
MbParams::search_release_group(arid.clone(), album_4),
])
}
fn album_arid_expectation() -> Mbid {
let mbref = COLLECTION[1].meta.musicbrainz.as_ref();
mbref.unwrap().mbid().clone()
}
fn album_expectations_1() -> (AlbumMeta, Vec<Match<AlbumMeta>>) {
let album_1 = COLLECTION[1].albums[0].meta.clone();
let album_4 = COLLECTION[1].albums[3].meta.clone();
let album_match_1_1 = Match::new(100, album_1.clone());
let album_match_1_2 = Match::new(50, album_4.clone());
let matches_1 = vec![album_match_1_1.clone(), album_match_1_2.clone()];
(album_1, matches_1)
}
fn album_expectations_4() -> (AlbumMeta, Vec<Match<AlbumMeta>>) {
let album_1 = COLLECTION[1].albums[0].meta.clone();
let album_4 = COLLECTION[1].albums[3].meta.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_4 = vec![album_match_4_1.clone(), album_match_4_2.clone()];
(album_4, matches_4)
}
#[test]
fn enqueue_job_channel_disconnected() {
let (_, job_receiver) = job_channel();
let mut daemon = daemon(job_receiver);
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Err(Error::JobChannelDisconnected));
}
#[test]
fn wait_for_job_channel_disconnected() {
let (_, job_receiver) = job_channel();
let mut daemon = daemon(job_receiver);
let result = daemon.wait_for_jobs();
assert_eq!(result, Err(Error::JobChannelDisconnected));
}
#[test]
fn enqueue_job_queue_empty() {
let (_job_sender, job_receiver) = job_channel();
let mut daemon = daemon(job_receiver);
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
}
#[test]
fn enqueue_job() {
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon(job_receiver);
let requests = search_artist_requests();
let (result_sender, _) = mpsc::channel();
let result = job_sender.submit_background_job(result_sender, requests.clone());
assert_eq!(result, Ok(()));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
assert_eq!(daemon.job_queue.pop_front().unwrap().requests, requests);
}
#[test]
fn wait_for_jobs_job() {
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon(job_receiver);
let requests = search_artist_requests();
let (result_sender, _) = mpsc::channel();
let result = job_sender.submit_background_job(result_sender, requests.clone());
assert_eq!(result, Ok(()));
let result = daemon.wait_for_jobs();
assert_eq!(result, Ok(()));
assert_eq!(daemon.job_queue.pop_front().unwrap().requests, requests);
}
#[test]
fn execute_empty() {
let (_job_sender, job_receiver) = job_channel();
let mut daemon = daemon(job_receiver);
let (result_sender, _) = mpsc::channel();
let mut instance = JobInstance::new(result_sender, VecDeque::new());
let result = instance.execute_next(&mut musicbrainz(), &mut event_sender());
assert_eq!(result, Ok(JobInstanceStatus::Complete));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Err(JobError::JobQueueEmpty));
}
fn search_artist_expectation(
musicbrainz: &mut MockIMusicBrainz,
artist: &ArtistMeta,
matches: &[Match<ArtistMeta>],
) {
let result = Ok(matches.to_owned());
musicbrainz
.expect_search_artist()
.with(predicate::eq(artist.clone()))
.times(1)
.return_once(|_| result);
}
#[test]
fn execute_search_artist() {
let mut musicbrainz = musicbrainz();
let (artist, matches) = artist_expectations();
search_artist_expectation(&mut musicbrainz, &artist, &matches);
let mut event_sender = event_sender();
fetch_complete_expectation(&mut event_sender, 1);
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
let requests = search_artist_requests();
let (result_sender, result_receiver) = mpsc::channel();
let result = job_sender.submit_background_job(result_sender, requests);
assert_eq!(result, Ok(()));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Ok(()));
let result = result_receiver.try_recv().unwrap();
assert_eq!(result, Ok(MatchStateInfo::artist(artist, matches)));
}
fn search_release_group_expectation(
musicbrainz: &mut MockIMusicBrainz,
seq: &mut Sequence,
arid: &Mbid,
album: &AlbumMeta,
matches: &[Match<AlbumMeta>],
) {
let result = Ok(matches.to_owned());
musicbrainz
.expect_search_release_group()
.with(predicate::eq(arid.clone()), predicate::eq(album.clone()))
.times(1)
.in_sequence(seq)
.return_once(|_, _| result);
}
#[test]
fn execute_search_release_groups() {
let mut musicbrainz = musicbrainz();
let arid = album_arid_expectation();
let (album_1, matches_1) = album_expectations_1();
let (album_4, matches_4) = album_expectations_4();
let mut seq = Sequence::new();
search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_1, &matches_1);
search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_4, &matches_4);
let mut event_sender = event_sender();
fetch_complete_expectation(&mut event_sender, 2);
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
let requests = search_albums_requests();
let (result_sender, result_receiver) = mpsc::channel();
let result = job_sender.submit_background_job(result_sender, requests);
assert_eq!(result, Ok(()));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Ok(()));
let result = result_receiver.try_recv().unwrap();
assert_eq!(result, Ok(MatchStateInfo::album(album_1, matches_1)));
let result = result_receiver.try_recv().unwrap();
assert_eq!(result, Ok(MatchStateInfo::album(album_4, matches_4)));
}
#[test]
fn execute_search_release_groups_result_disconnect() {
let mut musicbrainz = musicbrainz();
let arid = album_arid_expectation();
let (album_1, matches_1) = album_expectations_1();
let mut seq = Sequence::new();
search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_1, &matches_1);
let mut event_sender = event_sender();
fetch_complete_expectation(&mut event_sender, 0);
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
let requests = search_albums_requests();
let (result_sender, _) = mpsc::channel();
let result = job_sender.submit_background_job(result_sender, requests);
assert_eq!(result, Ok(()));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Ok(()));
// Two albums were submitted, but as one job. If the first one fails due to the
// result_channel disconnecting, all remaining requests in that job are dropped.
assert!(daemon.job_queue.pop_front().is_none());
}
#[test]
fn execute_search_artist_event_disconnect() {
let mut musicbrainz = musicbrainz();
let (artist, matches) = artist_expectations();
search_artist_expectation(&mut musicbrainz, &artist, &matches);
let mut event_sender = event_sender();
event_sender
.expect_send_fetch_complete()
.times(1)
.return_once(|| Err(EventError::Send(Event::FetchComplete)));
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
let requests = search_artist_requests();
let (result_sender, _result_receiver) = mpsc::channel();
let result = job_sender.submit_background_job(result_sender, requests);
assert_eq!(result, Ok(()));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Err(JobError::EventChannelDisconnected));
}
#[test]
fn job_queue() {
let mut queue = JobQueue::new();
assert!(queue.is_empty());
assert_eq!(queue.foreground_queue.len(), 0);
assert_eq!(queue.background_queue.len(), 0);
let (result_sender, _) = mpsc::channel();
let bg = Job::new(
JobPriority::Background,
JobInstance::new(result_sender, VecDeque::new()),
);
queue.push_back(bg);
assert!(!queue.is_empty());
assert_eq!(queue.foreground_queue.len(), 0);
assert_eq!(queue.background_queue.len(), 1);
let (result_sender, _) = mpsc::channel();
let fg = Job::new(
JobPriority::Foreground,
JobInstance::new(result_sender, VecDeque::new()),
);
queue.push_back(fg);
assert!(!queue.is_empty());
assert_eq!(queue.foreground_queue.len(), 1);
assert_eq!(queue.background_queue.len(), 1);
let instance = queue.pop_front();
assert!(instance.is_some());
assert!(!queue.is_empty());
assert_eq!(queue.foreground_queue.len(), 0);
assert_eq!(queue.background_queue.len(), 1);
let instance = queue.pop_front();
assert!(instance.is_some());
assert!(queue.is_empty());
assert_eq!(queue.foreground_queue.len(), 0);
assert_eq!(queue.background_queue.len(), 0);
let instance = queue.pop_front();
assert!(instance.is_none());
assert!(queue.is_empty());
}
}

View File

@ -1,104 +1,2 @@
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API). pub mod api;
pub mod daemon;
use std::collections::HashMap;
use musichoard::{
collection::{
album::{AlbumDate, AlbumMeta, AlbumSeq},
artist::ArtistMeta,
musicbrainz::Mbid,
},
external::musicbrainz::{
api::{
search::{
SearchArtistRequest, SearchArtistResponseArtist, SearchReleaseGroupRequest,
SearchReleaseGroupResponseReleaseGroup,
},
MusicBrainzClient,
},
IMusicBrainzHttp,
},
};
use crate::tui::lib::interface::musicbrainz::{Error, IMusicBrainz, Match};
// GRCOV_EXCL_START
pub struct MusicBrainz<Http> {
client: MusicBrainzClient<Http>,
}
impl<Http> MusicBrainz<Http> {
pub fn new(client: MusicBrainzClient<Http>) -> Self {
MusicBrainz { client }
}
}
impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Match<ArtistMeta>>, Error> {
let query = SearchArtistRequest::new().string(&artist.id.name);
let mb_response = self.client.search_artist(query)?;
Ok(mb_response
.artists
.into_iter()
.map(from_search_artist_response_artist)
.collect())
}
fn search_release_group(
&mut self,
arid: &Mbid,
album: &AlbumMeta,
) -> Result<Vec<Match<AlbumMeta>>, Error> {
// Some release groups may have a promotional early release messing up the search. Searching
// with just the year should be enough anyway.
let date = AlbumDate::new(album.date.year, None, None);
let query = SearchReleaseGroupRequest::new()
.arid(arid)
.and()
.first_release_date(&date)
.and()
.release_group(&album.id.title);
let mb_response = self.client.search_release_group(query)?;
Ok(mb_response
.release_groups
.into_iter()
.map(from_search_release_group_response_release_group)
.collect())
}
}
fn from_search_artist_response_artist(entity: SearchArtistResponseArtist) -> Match<ArtistMeta> {
Match {
score: entity.score,
item: ArtistMeta {
id: entity.name,
sort: entity.sort.map(Into::into),
musicbrainz: Some(entity.id.into()),
properties: HashMap::new(),
},
disambiguation: entity.disambiguation,
}
}
fn from_search_release_group_response_release_group(
entity: SearchReleaseGroupResponseReleaseGroup,
) -> Match<AlbumMeta> {
Match {
score: entity.score,
item: AlbumMeta {
id: entity.title,
date: entity.first_release_date,
seq: AlbumSeq::default(),
musicbrainz: Some(entity.id.into()),
primary_type: Some(entity.primary_type),
secondary_types: entity.secondary_types.unwrap_or_default(),
},
disambiguation: None,
}
}
// GRCOV_EXCL_STOP

View File

@ -0,0 +1,26 @@
//! Module for accessing MusicBrainz metadata.
#[cfg(test)]
use mockall::automock;
use musichoard::collection::{album::AlbumMeta, artist::ArtistMeta, musicbrainz::Mbid};
/// Trait for interacting with the MusicBrainz API.
#[cfg_attr(test, automock)]
pub trait IMusicBrainz {
fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Match<ArtistMeta>>, Error>;
fn search_release_group(
&mut self,
arid: &Mbid,
album: &AlbumMeta,
) -> Result<Vec<Match<AlbumMeta>>, Error>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match<T> {
pub score: u8,
pub item: T,
pub disambiguation: Option<String>,
}
pub type Error = musichoard::external::musicbrainz::api::Error;

View File

@ -0,0 +1,81 @@
use std::{collections::VecDeque, fmt, sync::mpsc};
use musichoard::collection::{album::AlbumMeta, artist::ArtistMeta, musicbrainz::Mbid};
use crate::tui::{app::MatchStateInfo, lib::interface::musicbrainz::api::Error as MbApiError};
#[cfg(test)]
use mockall::automock;
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
EventChannelDisconnected,
JobChannelDisconnected,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Error::EventChannelDisconnected => write!(f, "the event channel is disconnected"),
Error::JobChannelDisconnected => write!(f, "the job channel is disconnected"),
}
}
}
pub type MbApiResult = Result<MatchStateInfo, MbApiError>;
pub type ResultSender = mpsc::Sender<MbApiResult>;
#[cfg_attr(test, automock)]
pub trait IMbJobSender {
fn submit_background_job(
&self,
result_sender: ResultSender,
requests: VecDeque<MbParams>,
) -> Result<(), Error>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MbParams {
Search(SearchParams),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SearchParams {
Artist(SearchArtistParams),
ReleaseGroup(SearchReleaseGroupParams),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SearchArtistParams {
pub artist: ArtistMeta,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SearchReleaseGroupParams {
pub arid: Mbid,
pub album: AlbumMeta,
}
impl MbParams {
pub fn search_artist(artist: ArtistMeta) -> Self {
MbParams::Search(SearchParams::Artist(SearchArtistParams { artist }))
}
pub fn search_release_group(arid: Mbid, album: AlbumMeta) -> Self {
MbParams::Search(SearchParams::ReleaseGroup(SearchReleaseGroupParams {
arid,
album,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn errors() {
assert!(!format!("{}", Error::EventChannelDisconnected).is_empty());
assert!(!format!("{}", Error::JobChannelDisconnected).is_empty());
}
}

View File

@ -1,26 +1,2 @@
//! Module for accessing MusicBrainz metadata. pub mod api;
pub mod daemon;
#[cfg(test)]
use mockall::automock;
use musichoard::collection::{album::AlbumMeta, artist::ArtistMeta, musicbrainz::Mbid};
/// Trait for interacting with the MusicBrainz API.
#[cfg_attr(test, automock)]
pub trait IMusicBrainz {
fn search_artist(&mut self, name: &ArtistMeta) -> Result<Vec<Match<ArtistMeta>>, Error>;
fn search_release_group(
&mut self,
arid: &Mbid,
album: &AlbumMeta,
) -> Result<Vec<Match<AlbumMeta>>, Error>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match<T> {
pub score: u8,
pub item: T,
pub disambiguation: Option<String>,
}
pub type Error = musichoard::external::musicbrainz::api::Error;

View File

@ -4,7 +4,7 @@ use std::thread;
#[cfg(test)] #[cfg(test)]
use mockall::automock; use mockall::automock;
use crate::tui::event::{Event, EventError, EventSender}; use crate::tui::event::{EventError, IKeyEventSender};
#[cfg_attr(test, automock)] #[cfg_attr(test, automock)]
pub trait IEventListener { pub trait IEventListener {
@ -12,13 +12,15 @@ pub trait IEventListener {
} }
pub struct EventListener { pub struct EventListener {
events: EventSender, event_sender: Box<dyn IKeyEventSender + Send>,
} }
// GRCOV_EXCL_START // GRCOV_EXCL_START
impl EventListener { impl EventListener {
pub fn new(events: EventSender) -> Self { pub fn new<ES: IKeyEventSender + Send + 'static>(event_sender: ES) -> Self {
EventListener { events } EventListener {
event_sender: Box::new(event_sender),
}
} }
} }
@ -32,7 +34,7 @@ impl IEventListener for EventListener {
match event::read() { match event::read() {
Ok(event) => { Ok(event) => {
if let Err(err) = match event { if let Err(err) = match event {
CrosstermEvent::Key(e) => self.events.send(Event::Key(e)), CrosstermEvent::Key(e) => self.event_sender.send_key(e),
_ => Ok(()), _ => Ok(()),
} { } {
return err; return err;

View File

@ -8,7 +8,10 @@ mod ui;
pub use app::App; pub use app::App;
pub use event::EventChannel; pub use event::EventChannel;
pub use handler::EventHandler; pub use handler::EventHandler;
pub use lib::external::musicbrainz::MusicBrainz; pub use lib::external::musicbrainz::{
api::MusicBrainz,
daemon::{JobChannel, MusicBrainzDaemon},
};
pub use listener::EventListener; pub use listener::EventListener;
pub use ui::Ui; pub use ui::Ui;
@ -174,8 +177,7 @@ mod testmod;
mod tests { mod tests {
use std::{io, thread}; use std::{io, thread};
use event::EventSender; use lib::interface::musicbrainz::daemon::MockIMbJobSender;
use lib::interface::musicbrainz::MockIMusicBrainz;
use ratatui::{backend::TestBackend, Terminal}; use ratatui::{backend::TestBackend, Terminal};
use musichoard::collection::Collection; use musichoard::collection::Collection;
@ -203,12 +205,8 @@ mod tests {
music_hoard music_hoard
} }
fn events() -> EventSender {
EventChannel::new().sender()
}
fn app(collection: Collection) -> App { fn app(collection: Collection) -> App {
App::new(music_hoard(collection), MockIMusicBrainz::new(), events()) App::new(music_hoard(collection), MockIMbJobSender::new())
} }
fn listener() -> MockIEventListener { fn listener() -> MockIEventListener {

View File

@ -207,7 +207,7 @@ mod tests {
use crate::tui::{ use crate::tui::{
app::{AppPublic, AppPublicInner, Delta, MatchOption, MatchStatePublic}, app::{AppPublic, AppPublicInner, Delta, MatchOption, MatchStatePublic},
lib::interface::musicbrainz::Match, lib::interface::musicbrainz::api::Match,
testmod::COLLECTION, testmod::COLLECTION,
tests::terminal, tests::terminal,
}; };