Provide a keyboard shortcut to pull all release groups of an artist #233

Merged
wojtek merged 14 commits from 160---provide-a-keyboard-shortcut-to-pull-all-release-groups-of-an-artist into main 2024-12-30 23:42:20 +01:00
11 changed files with 449 additions and 173 deletions

View File

@ -144,19 +144,18 @@ impl AlbumStatus {
} }
impl Album { impl Album {
pub fn new<Id: Into<AlbumId>, Date: Into<AlbumDate>>( pub fn new<Id: Into<AlbumId>>(id: Id) -> Self {
id: Id,
date: Date,
primary_type: Option<AlbumPrimaryType>,
secondary_types: Vec<AlbumSecondaryType>,
) -> Self {
let info = AlbumInfo::new(MbRefOption::None, primary_type, secondary_types);
Album { Album {
meta: AlbumMeta::new(id, date, info), meta: AlbumMeta::new(id),
tracks: vec![], tracks: vec![],
} }
} }
pub fn with_date<Date: Into<AlbumDate>>(mut self, date: Date) -> Self {
self.meta.date = date.into();
self
}
pub fn get_status(&self) -> AlbumStatus { pub fn get_status(&self) -> AlbumStatus {
AlbumStatus::from_tracks(&self.tracks) AlbumStatus::from_tracks(&self.tracks)
} }
@ -183,19 +182,25 @@ impl Merge for Album {
} }
impl AlbumMeta { impl AlbumMeta {
pub fn new<Id: Into<AlbumId>, Date: Into<AlbumDate>>( pub fn new<Id: Into<AlbumId>>(id: Id) -> Self {
id: Id,
date: Date,
info: AlbumInfo,
) -> Self {
AlbumMeta { AlbumMeta {
id: id.into(), id: id.into(),
date: date.into(), date: AlbumDate::default(),
seq: AlbumSeq::default(), seq: AlbumSeq::default(),
info, info: AlbumInfo::default(),
} }
} }
pub fn with_date<Date: Into<AlbumDate>>(mut self, date: Date) -> Self {
self.date = date.into();
self
}
pub fn with_info(mut self, info: AlbumInfo) -> Self {
self.info = info;
self
}
pub fn get_sort_key(&self) -> (&AlbumDate, &AlbumSeq, &AlbumId) { pub fn get_sort_key(&self) -> (&AlbumDate, &AlbumSeq, &AlbumId) {
(&self.date, &self.seq, &self.id) (&self.date, &self.seq, &self.id)
} }
@ -258,7 +263,9 @@ impl Merge for AlbumInfo {
fn merge_in_place(&mut self, other: Self) { fn merge_in_place(&mut self, other: Self) {
self.musicbrainz = self.musicbrainz.take().or(other.musicbrainz); self.musicbrainz = self.musicbrainz.take().or(other.musicbrainz);
self.primary_type = self.primary_type.take().or(other.primary_type); self.primary_type = self.primary_type.take().or(other.primary_type);
self.secondary_types.merge_in_place(other.secondary_types); if self.secondary_types.is_empty() {
self.secondary_types = other.secondary_types;
}
} }
} }
@ -311,13 +318,13 @@ mod tests {
let album_id_1 = AlbumId { let album_id_1 = AlbumId {
title: String::from("album z"), title: String::from("album z"),
}; };
let mut album_1 = Album::new(album_id_1, date.clone(), None, vec![]); let mut album_1 = Album::new(album_id_1).with_date(date.clone());
album_1.meta.set_seq(AlbumSeq(1)); album_1.meta.set_seq(AlbumSeq(1));
let album_id_2 = AlbumId { let album_id_2 = AlbumId {
title: String::from("album a"), title: String::from("album a"),
}; };
let mut album_2 = Album::new(album_id_2, date.clone(), None, vec![]); let mut album_2 = Album::new(album_id_2).with_date(date.clone());
album_2.meta.set_seq(AlbumSeq(2)); album_2.meta.set_seq(AlbumSeq(2));
assert_ne!(album_1, album_2); assert_ne!(album_1, album_2);
@ -327,7 +334,7 @@ mod tests {
#[test] #[test]
fn set_clear_seq() { fn set_clear_seq() {
let mut album = Album::new("An album", AlbumDate::default(), None, vec![]); let mut album = Album::new("An album");
assert_eq!(album.meta.seq, AlbumSeq(0)); assert_eq!(album.meta.seq, AlbumSeq(0));

View File

@ -46,6 +46,14 @@ pub enum MbRefOption<T> {
} }
impl<T> MbRefOption<T> { impl<T> MbRefOption<T> {
pub fn is_some(&self) -> bool {
matches!(self, MbRefOption::Some(_))
}
pub fn is_none(&self) -> bool {
matches!(self, MbRefOption::None)
}
pub fn or(self, optb: MbRefOption<T>) -> MbRefOption<T> { pub fn or(self, optb: MbRefOption<T>) -> MbRefOption<T> {
match (&self, &optb) { match (&self, &optb) {
(MbRefOption::Some(_), _) | (MbRefOption::CannotHaveMbid, MbRefOption::None) => self, (MbRefOption::Some(_), _) | (MbRefOption::CannotHaveMbid, MbRefOption::None) => self,

View File

@ -34,6 +34,7 @@ pub trait IMusicHoardBasePrivate {
artist_id: &ArtistId, artist_id: &ArtistId,
) -> Result<&'a mut Artist, Error>; ) -> Result<&'a mut Artist, Error>;
fn get_album<'a>(artist: &'a mut Artist, album_id: &AlbumId) -> Option<&'a Album>;
fn get_album_mut<'a>(artist: &'a mut Artist, album_id: &AlbumId) -> Option<&'a mut Album>; fn get_album_mut<'a>(artist: &'a mut Artist, album_id: &AlbumId) -> Option<&'a mut Album>;
fn get_album_mut_or_err<'a>( fn get_album_mut_or_err<'a>(
artist: &'a mut Artist, artist: &'a mut Artist,
@ -79,6 +80,10 @@ impl<Database, Library> IMusicHoardBasePrivate for MusicHoard<Database, Library>
}) })
} }
fn get_album<'a>(artist: &'a mut Artist, album_id: &AlbumId) -> Option<&'a Album> {
artist.albums.iter().find(|a| &a.meta.id == album_id)
}
fn get_album_mut<'a>(artist: &'a mut Artist, album_id: &AlbumId) -> Option<&'a mut Album> { fn get_album_mut<'a>(artist: &'a mut Artist, album_id: &AlbumId) -> Option<&'a mut Album> {
artist.albums.iter_mut().find(|a| &a.meta.id == album_id) artist.albums.iter_mut().find(|a| &a.meta.id == album_id)
} }

View File

@ -1,7 +1,11 @@
use std::mem; use std::mem;
use crate::{ use crate::{
collection::{album::AlbumInfo, artist::ArtistInfo, merge::Merge}, collection::{
album::{AlbumInfo, AlbumMeta},
artist::ArtistInfo,
merge::Merge,
},
core::{ core::{
collection::{ collection::{
album::{Album, AlbumId, AlbumSeq}, album::{Album, AlbumId, AlbumSeq},
@ -57,6 +61,17 @@ pub trait IMusicHoardDatabase {
property: S, property: S,
) -> Result<(), Error>; ) -> Result<(), Error>;
fn add_album<ArtistIdRef: AsRef<ArtistId>>(
&mut self,
artist_id: ArtistIdRef,
album_meta: AlbumMeta,
) -> Result<(), Error>;
fn remove_album<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self,
artist_id: ArtistIdRef,
album_id: AlbumIdRef,
) -> Result<(), Error>;
fn set_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>( fn set_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self, &mut self,
artist_id: ArtistIdRef, artist_id: ArtistIdRef,
@ -68,15 +83,15 @@ pub trait IMusicHoardDatabase {
artist_id: ArtistIdRef, artist_id: ArtistIdRef,
album_id: AlbumIdRef, album_id: AlbumIdRef,
) -> Result<(), Error>; ) -> Result<(), Error>;
fn merge_album_info<Id: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>( fn merge_album_info<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self, &mut self,
artist_id: Id, artist_id: ArtistIdRef,
album_id: AlbumIdRef, album_id: AlbumIdRef,
info: AlbumInfo, info: AlbumInfo,
) -> Result<(), Error>; ) -> Result<(), Error>;
fn clear_album_info<Id: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>( fn clear_album_info<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self, &mut self,
artist_id: Id, artist_id: ArtistIdRef,
album_id: AlbumIdRef, album_id: AlbumIdRef,
) -> Result<(), Error>; ) -> Result<(), Error>;
} }
@ -194,6 +209,39 @@ impl<Database: IDatabase, Library> IMusicHoardDatabase for MusicHoard<Database,
}) })
} }
fn add_album<ArtistIdRef: AsRef<ArtistId>>(
&mut self,
artist_id: ArtistIdRef,
album_meta: AlbumMeta,
) -> Result<(), Error> {
let album = Album {
meta: album_meta,
tracks: vec![],
};
self.update_artist(artist_id.as_ref(), |artist| {
if Self::get_album(artist, &album.meta.id).is_none() {
artist.albums.push(album);
artist.albums.sort_unstable();
}
})
}
fn remove_album<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self,
artist_id: ArtistIdRef,
album_id: AlbumIdRef,
) -> Result<(), Error> {
self.update_artist(artist_id.as_ref(), |artist| {
let index_opt = artist
.albums
.iter()
.position(|a| &a.meta.id == album_id.as_ref());
if let Some(index) = index_opt {
artist.albums.remove(index);
}
})
}
fn set_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>( fn set_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self, &mut self,
artist_id: ArtistIdRef, artist_id: ArtistIdRef,
@ -344,7 +392,7 @@ mod tests {
musicbrainz::{MbArtistRef, MbRefOption}, musicbrainz::{MbArtistRef, MbRefOption},
}, },
core::{ core::{
collection::{album::AlbumDate, artist::ArtistId}, collection::artist::ArtistId,
interface::database::{self, MockIDatabase}, interface::database::{self, MockIDatabase},
musichoard::{base::IMusicHoardBase, NoLibrary}, musichoard::{base::IMusicHoardBase, NoLibrary},
testmod::FULL_COLLECTION, testmod::FULL_COLLECTION,
@ -604,6 +652,62 @@ mod tests {
assert!(music_hoard.collection[0].meta.info.properties.is_empty()); assert!(music_hoard.collection[0].meta.info.properties.is_empty());
} }
#[test]
fn album_new_delete() {
let album_id = AlbumId::new("an album");
let album_meta = AlbumMeta::new(album_id.clone());
let album_id_2 = AlbumId::new("another album");
let album_meta_2 = AlbumMeta::new(album_id_2);
let collection = FULL_COLLECTION.to_owned();
let artist_id = collection[0].meta.id.clone();
let mut with_album = collection.clone();
with_album[0].albums.push(Album::new(album_id));
with_album[0].albums.sort_unstable();
let mut database = MockIDatabase::new();
let mut seq = Sequence::new();
database
.expect_load()
.times(1)
.times(1)
.in_sequence(&mut seq)
.returning(|| Ok(FULL_COLLECTION.to_owned()));
database
.expect_save()
.times(1)
.in_sequence(&mut seq)
.with(predicate::eq(with_album.clone()))
.returning(|_| Ok(()));
database
.expect_save()
.times(1)
.in_sequence(&mut seq)
.with(predicate::eq(collection.clone()))
.returning(|_| Ok(()));
let mut music_hoard = MusicHoard::database(database).unwrap();
assert_eq!(music_hoard.collection, collection);
assert!(music_hoard
.add_album(&artist_id, album_meta.clone())
.is_ok());
assert_eq!(music_hoard.collection, with_album);
assert!(music_hoard
.add_album(&artist_id, album_meta.clone())
.is_ok());
assert_eq!(music_hoard.collection, with_album);
assert!(music_hoard
.remove_album(&artist_id, &album_meta_2.id)
.is_ok());
assert_eq!(music_hoard.collection, with_album);
assert!(music_hoard.remove_album(&artist_id, &album_meta.id).is_ok());
assert_eq!(music_hoard.collection, collection);
}
#[test] #[test]
fn set_clear_album_seq() { fn set_clear_album_seq() {
let mut database = MockIDatabase::new(); let mut database = MockIDatabase::new();
@ -613,12 +717,7 @@ mod tests {
let album_id_2 = AlbumId::new("another album"); let album_id_2 = AlbumId::new("another album");
let mut database_result = vec![Artist::new(artist_id.clone())]; let mut database_result = vec![Artist::new(artist_id.clone())];
database_result[0].albums.push(Album::new( database_result[0].albums.push(Album::new(album_id.clone()));
album_id.clone(),
AlbumDate::default(),
None,
vec![],
));
database database
.expect_load() .expect_load()
@ -658,12 +757,7 @@ mod tests {
let album_id_2 = AlbumId::new("another album"); let album_id_2 = AlbumId::new("another album");
let mut database_result = vec![Artist::new(artist_id.clone())]; let mut database_result = vec![Artist::new(artist_id.clone())];
database_result[0].albums.push(Album::new( database_result[0].albums.push(Album::new(album_id.clone()));
album_id.clone(),
AlbumDate::default(),
None,
vec![],
));
database database
.expect_load() .expect_load()

View File

@ -109,7 +109,7 @@ impl<Database, Library: ILibrary> MusicHoard<Database, Library> {
{ {
Some(album) => album.tracks.push(track), Some(album) => album.tracks.push(track),
None => { None => {
let mut album = Album::new(album_id, album_date, None, vec![]); let mut album = Album::new(album_id).with_date(album_date);
album.tracks.push(track); album.tracks.push(track);
artist.albums.push(album); artist.albums.push(album);
} }

View File

@ -5,7 +5,7 @@ use std::{
}; };
use musichoard::collection::{ use musichoard::collection::{
album::{Album, AlbumId}, album::{Album, AlbumId, AlbumMeta},
artist::{Artist, ArtistId, ArtistMeta}, artist::{Artist, ArtistId, ArtistMeta},
musicbrainz::{IMusicBrainzRef, MbArtistRef, MbRefOption, Mbid}, musicbrainz::{IMusicBrainzRef, MbArtistRef, MbRefOption, Mbid},
}; };
@ -13,48 +13,65 @@ use musichoard::collection::{
use crate::tui::{ use crate::tui::{
app::{ app::{
machine::{match_state::MatchState, App, AppInner, AppMachine}, machine::{match_state::MatchState, App, AppInner, AppMachine},
selection::KeySelection,
AppPublicState, AppState, Category, IAppEventFetch, IAppInteractFetch, AppPublicState, AppState, Category, IAppEventFetch, IAppInteractFetch,
}, },
lib::interface::musicbrainz::daemon::{ lib::interface::musicbrainz::daemon::{
Error as DaemonError, IMbJobSender, MbApiResult, MbParams, MbReturn, ResultSender, EntityList, Error as DaemonError, IMbJobSender, MbApiResult, MbParams, MbReturn,
ResultSender,
}, },
}; };
pub type FetchReceiver = mpsc::Receiver<MbApiResult>; pub type MbApiReceiver = mpsc::Receiver<MbApiResult>;
pub struct FetchState { pub struct FetchState {
fetch_rx: FetchReceiver, search_rx: Option<MbApiReceiver>,
lookup_rx: Option<FetchReceiver>, lookup_rx: Option<MbApiReceiver>,
fetch_rx: Option<MbApiReceiver>,
}
struct SubmitJob {
fetch: FetchState,
requests: VecDeque<MbParams>,
}
macro_rules! try_recv_mb_api_receiver {
($rx:expr) => {
if let Some(rx) = &($rx) {
match rx.try_recv() {
x @ Ok(_) | x @ Err(TryRecvError::Empty) => return x,
Err(TryRecvError::Disconnected) => {
($rx).take();
}
}
}
};
} }
impl FetchState { impl FetchState {
pub fn new(fetch_rx: FetchReceiver) -> Self { pub fn search(search_rx: MbApiReceiver) -> Self {
FetchState { FetchState {
fetch_rx, search_rx: Some(search_rx),
lookup_rx: None, lookup_rx: None,
fetch_rx: None,
}
}
pub fn fetch(fetch_rx: MbApiReceiver) -> Self {
FetchState {
search_rx: None,
lookup_rx: None,
fetch_rx: Some(fetch_rx),
} }
} }
fn try_recv(&mut self) -> Result<MbApiResult, TryRecvError> { fn try_recv(&mut self) -> Result<MbApiResult, TryRecvError> {
if let Some(lookup_rx) = &self.lookup_rx { try_recv_mb_api_receiver!(self.lookup_rx);
match lookup_rx.try_recv() { try_recv_mb_api_receiver!(self.search_rx);
x @ Ok(_) | x @ Err(TryRecvError::Empty) => return x,
Err(TryRecvError::Disconnected) => { match &self.fetch_rx {
self.lookup_rx.take(); Some(fetch_rx) => fetch_rx.try_recv(),
} None => Err(TryRecvError::Disconnected),
}
} }
self.fetch_rx.try_recv()
}
}
enum FetchError {
NothingToFetch,
SubmitError(DaemonError),
}
impl From<DaemonError> for FetchError {
fn from(value: DaemonError) -> Self {
FetchError::SubmitError(value)
} }
} }
@ -68,75 +85,137 @@ impl AppMachine<FetchState> {
} }
fn app_fetch_new(inner: AppInner) -> App { fn app_fetch_new(inner: AppInner) -> App {
let (tx, rx) = mpsc::channel::<MbApiResult>();
let job = match Self::fetch_job(&inner, rx) {
Ok(job) => job,
Err(err) => return AppMachine::error_state(inner, err.to_string()).into(),
};
if job.requests.is_empty() {
return AppMachine::browse_state(inner).into();
}
match inner.musicbrainz.submit_background_job(tx, job.requests) {
Ok(()) => AppMachine::fetch_state(inner, job.fetch).into(),
Err(err) => AppMachine::error_state(inner, err.to_string()).into(),
}
}
fn fetch_job(inner: &AppInner, rx: MbApiReceiver) -> Result<SubmitJob, &'static str> {
let coll = inner.music_hoard.get_collection(); let coll = inner.music_hoard.get_collection();
let artist = match inner.selection.state_artist(coll) { let artist = match inner.selection.state_artist(coll) {
Some(artist_state) => &coll[artist_state.index], Some(artist_state) => &coll[artist_state.index],
None => { None => return Err("cannot fetch: no artist selected"),
let err = "cannot fetch artist: no artist selected";
return AppMachine::error_state(inner, err).into();
}
}; };
let (fetch_tx, fetch_rx) = mpsc::channel::<MbApiResult>(); let requests = match inner.selection.category() {
let fetch = FetchState::new(fetch_rx); Category::Artist => {
let fetch: FetchState;
let mb = &*inner.musicbrainz; let mut requests = Self::search_artist_job(artist);
let result = match inner.selection.category() { if requests.is_empty() {
Category::Artist => Self::submit_search_artist_job(mb, fetch_tx, artist), fetch = FetchState::fetch(rx);
requests = Self::browse_release_group_job(&artist.meta.info.musicbrainz);
} else {
fetch = FetchState::search(rx);
}
SubmitJob { fetch, requests }
}
_ => { _ => {
let arid = match artist.meta.info.musicbrainz { let arid = match artist.meta.info.musicbrainz {
MbRefOption::Some(ref mbref) => mbref, MbRefOption::Some(ref mbref) => mbref,
_ => { _ => return Err("cannot fetch album: artist has no MBID"),
let err = "cannot fetch album: artist has no MBID";
return AppMachine::error_state(inner, err).into();
}
}; };
let album = match inner.selection.state_album(coll) { let album = match inner.selection.state_album(coll) {
Some(album_state) => &artist.albums[album_state.index], Some(album_state) => &artist.albums[album_state.index],
None => { None => return Err("cannot fetch album: no album selected"),
let err = "cannot fetch album: no album selected";
return AppMachine::error_state(inner, err).into();
}
}; };
let artist_id = &artist.meta.id; let artist_id = &artist.meta.id;
Self::submit_search_release_group_job(mb, fetch_tx, artist_id, arid, album) SubmitJob {
fetch: FetchState::search(rx),
requests: Self::search_release_group_job(artist_id, arid, album),
}
} }
}; };
match result { Ok(requests)
Ok(()) => AppMachine::fetch_state(inner, fetch).into(),
Err(FetchError::NothingToFetch) => AppMachine::browse_state(inner).into(),
Err(FetchError::SubmitError(daemon_err)) => {
AppMachine::error_state(inner, daemon_err.to_string()).into()
}
}
} }
pub fn app_fetch_next(inner: AppInner, mut fetch: FetchState) -> App { pub fn app_fetch_next(mut inner: AppInner, mut fetch: FetchState) -> App {
match fetch.try_recv() { loop {
Ok(fetch_result) => match fetch_result { let app: App = match fetch.try_recv() {
Ok(retval) => Self::handle_mb_api_return(inner, fetch, retval), Ok(fetch_result) => match fetch_result {
Err(fetch_err) => { Ok(MbReturn::Match(next_match)) => {
AppMachine::error_state(inner, format!("fetch failed: {fetch_err}")).into() AppMachine::match_state(inner, MatchState::new(next_match, fetch)).into()
}
Ok(MbReturn::Fetch(list)) => {
match Self::apply_fetch_results(&mut inner, list) {
Ok(()) => continue,
Err(err) => AppMachine::error_state(inner, err.to_string()).into(),
}
}
Err(fetch_err) => {
AppMachine::error_state(inner, format!("fetch failed: {fetch_err}")).into()
}
},
Err(TryRecvError::Empty) => AppMachine::fetch_state(inner, fetch).into(),
Err(TryRecvError::Disconnected) => {
if fetch.fetch_rx.is_some() {
AppMachine::browse_state(inner).into()
} else {
Self::app_fetch_new(inner)
}
} }
}, };
Err(recv_err) => match recv_err { return app;
TryRecvError::Empty => AppMachine::fetch_state(inner, fetch).into(),
TryRecvError::Disconnected => Self::app_fetch_new(inner),
},
} }
} }
fn handle_mb_api_return(inner: AppInner, fetch: FetchState, retval: MbReturn) -> App { fn apply_fetch_results(
match retval { inner: &mut AppInner,
MbReturn::Match(next_match) => { list: EntityList,
AppMachine::match_state(inner, MatchState::new(next_match, fetch)).into() ) -> Result<(), musichoard::Error> {
} match list {
_ => unimplemented!(), EntityList::Album(fetch_albums) => Self::apply_album_results(inner, fetch_albums),
} }
} }
fn apply_album_results(
inner: &mut AppInner,
fetch_albums: Vec<AlbumMeta>,
) -> Result<(), musichoard::Error> {
let coll = inner.music_hoard.get_collection();
let artist_state = inner.selection.state_artist(coll).unwrap();
let artist = &coll[artist_state.index];
let selection = KeySelection::get(coll, &inner.selection);
let artist_id = &artist.meta.id.clone();
for new in Self::new_albums(fetch_albums, &artist.albums).into_iter() {
inner.music_hoard.add_album(artist_id, new)?;
}
let coll = inner.music_hoard.get_collection();
inner.selection.select_by_id(coll, selection);
Ok(())
}
fn new_albums(fetch_albums: Vec<AlbumMeta>, albums: &[Album]) -> Vec<AlbumMeta> {
let mut new_albums = vec![];
for alb in fetch_albums.into_iter() {
let existing = albums.iter().find(|old| Self::album_match(&old.meta, &alb));
if existing.is_none() {
new_albums.push(alb);
}
}
new_albums
}
fn album_match(old: &AlbumMeta, new: &AlbumMeta) -> bool {
old.info.musicbrainz.is_some() && (old.info.musicbrainz == new.info.musicbrainz)
}
pub fn app_lookup_artist( pub fn app_lookup_artist(
inner: AppInner, inner: AppInner,
fetch: FetchState, fetch: FetchState,
@ -178,36 +257,26 @@ impl AppMachine<FetchState> {
Self::app_fetch_next(inner, fetch) Self::app_fetch_next(inner, fetch)
} }
fn submit_search_artist_job( fn search_artist_job(artist: &Artist) -> VecDeque<MbParams> {
musicbrainz: &dyn IMbJobSender, match artist.meta.info.musicbrainz {
result_sender: ResultSender,
artist: &Artist,
) -> Result<(), FetchError> {
let requests = match artist.meta.info.musicbrainz {
MbRefOption::Some(ref arid) => { MbRefOption::Some(ref arid) => {
Self::search_albums_requests(&artist.meta.id, arid, &artist.albums) Self::search_albums_requests(&artist.meta.id, arid, &artist.albums)
} }
MbRefOption::CannotHaveMbid => VecDeque::new(), MbRefOption::CannotHaveMbid => VecDeque::new(),
MbRefOption::None => Self::search_artist_request(&artist.meta), MbRefOption::None => Self::search_artist_request(&artist.meta),
};
if requests.is_empty() {
return Err(FetchError::NothingToFetch);
} }
Ok(musicbrainz.submit_background_job(result_sender, requests)?)
} }
fn submit_search_release_group_job( fn search_release_group_job(
musicbrainz: &dyn IMbJobSender,
result_sender: ResultSender,
artist_id: &ArtistId, artist_id: &ArtistId,
artist_mbid: &MbArtistRef, artist_mbid: &MbArtistRef,
album: &Album, album: &Album,
) -> Result<(), FetchError> { ) -> VecDeque<MbParams> {
if !matches!(album.meta.info.musicbrainz, MbRefOption::None) { Self::search_albums_requests(artist_id, artist_mbid, slice::from_ref(album))
return Err(FetchError::NothingToFetch); }
}
let requests = Self::search_albums_requests(artist_id, artist_mbid, slice::from_ref(album)); fn search_artist_request(meta: &ArtistMeta) -> VecDeque<MbParams> {
Ok(musicbrainz.submit_background_job(result_sender, requests)?) VecDeque::from([MbParams::search_artist(meta.clone())])
} }
fn search_albums_requests( fn search_albums_requests(
@ -218,15 +287,22 @@ impl AppMachine<FetchState> {
let arid = arid.mbid(); let arid = arid.mbid();
albums albums
.iter() .iter()
.filter(|album| matches!(album.meta.info.musicbrainz, MbRefOption::None)) .filter(|album| album.meta.info.musicbrainz.is_none())
.map(|album| { .map(|album| {
MbParams::search_release_group(artist.clone(), arid.clone(), album.meta.clone()) MbParams::search_release_group(artist.clone(), arid.clone(), album.meta.clone())
}) })
.collect() .collect()
} }
fn search_artist_request(meta: &ArtistMeta) -> VecDeque<MbParams> { fn browse_release_group_job(mbopt: &MbRefOption<MbArtistRef>) -> VecDeque<MbParams> {
VecDeque::from([MbParams::search_artist(meta.clone())]) match mbopt {
MbRefOption::Some(mbref) => Self::browse_release_group_request(mbref),
_ => VecDeque::new(),
}
}
fn browse_release_group_request(mbref: &MbArtistRef) -> VecDeque<MbParams> {
VecDeque::from([MbParams::browse_release_group(mbref.mbid().clone())])
} }
fn submit_lookup_artist_job( fn submit_lookup_artist_job(
@ -294,7 +370,7 @@ mod tests {
use crate::tui::{ use crate::tui::{
app::{ app::{
machine::tests::{inner, music_hoard}, machine::tests::{inner, inner_with_mb, music_hoard},
Delta, EntityMatches, IApp, IAppAccess, IAppInteractBrowse, MatchOption, Delta, EntityMatches, IApp, IAppAccess, IAppInteractBrowse, MatchOption,
}, },
lib::interface::musicbrainz::{self, api::Entity, daemon::MockIMbJobSender}, lib::interface::musicbrainz::{self, api::Entity, daemon::MockIMbJobSender},
@ -312,7 +388,7 @@ mod tests {
let (fetch_tx, fetch_rx) = mpsc::channel(); let (fetch_tx, fetch_rx) = mpsc::channel();
let (lookup_tx, lookup_rx) = mpsc::channel(); let (lookup_tx, lookup_rx) = mpsc::channel();
let mut fetch = FetchState::new(fetch_rx); let mut fetch = FetchState::search(fetch_rx);
fetch.lookup_rx.replace(lookup_rx); fetch.lookup_rx.replace(lookup_rx);
let artist = COLLECTION[3].meta.clone(); let artist = COLLECTION[3].meta.clone();
@ -494,7 +570,7 @@ mod tests {
let inner = AppInner::new(music_hoard, mb_job_sender); let inner = AppInner::new(music_hoard, mb_job_sender);
let (_fetch_tx, fetch_rx) = mpsc::channel(); let (_fetch_tx, fetch_rx) = mpsc::channel();
let fetch = FetchState::new(fetch_rx); let fetch = FetchState::search(fetch_rx);
AppMachine::app_lookup_album(inner, fetch, &artist_id, &album_id, mbid()); AppMachine::app_lookup_album(inner, fetch, &artist_id, &album_id, mbid());
} }
@ -548,7 +624,7 @@ mod tests {
let inner = AppInner::new(music_hoard, mb_job_sender); let inner = AppInner::new(music_hoard, mb_job_sender);
let (_fetch_tx, fetch_rx) = mpsc::channel(); let (_fetch_tx, fetch_rx) = mpsc::channel();
let fetch = FetchState::new(fetch_rx); let fetch = FetchState::search(fetch_rx);
AppMachine::app_lookup_artist(inner, fetch, &artist, mbid()); AppMachine::app_lookup_artist(inner, fetch, &artist, mbid());
} }
@ -597,14 +673,14 @@ mod tests {
let inner = AppInner::new(music_hoard, mb_job_sender); let inner = AppInner::new(music_hoard, mb_job_sender);
let (_fetch_tx, fetch_rx) = mpsc::channel(); let (_fetch_tx, fetch_rx) = mpsc::channel();
let fetch = FetchState::new(fetch_rx); let fetch = FetchState::search(fetch_rx);
let app = AppMachine::app_lookup_artist(inner, fetch, &artist, mbid()); let app = AppMachine::app_lookup_artist(inner, fetch, &artist, mbid());
assert!(matches!(app, AppState::Error(_))); assert!(matches!(app, AppState::Error(_)));
} }
#[test] #[test]
fn recv_ok_fetch_ok() { fn recv_ok_match_ok() {
let (tx, rx) = mpsc::channel::<MbApiResult>(); let (tx, rx) = mpsc::channel::<MbApiResult>();
let artist = COLLECTION[3].meta.clone(); let artist = COLLECTION[3].meta.clone();
@ -615,7 +691,7 @@ mod tests {
tx.send(fetch_result).unwrap(); tx.send(fetch_result).unwrap();
let inner = inner(music_hoard(COLLECTION.clone())); let inner = inner(music_hoard(COLLECTION.clone()));
let fetch = FetchState::new(rx); let fetch = FetchState::search(rx);
let mut app = AppMachine::app_fetch_next(inner, fetch); let mut app = AppMachine::app_fetch_next(inner, fetch);
assert!(matches!(app, AppState::Match(_))); assert!(matches!(app, AppState::Match(_)));
@ -631,35 +707,102 @@ mod tests {
} }
#[test] #[test]
fn recv_ok_fetch_err() { fn recv_ok_search_err() {
let (tx, rx) = mpsc::channel::<MbApiResult>(); let (tx, rx) = mpsc::channel::<MbApiResult>();
let fetch_result = Err(musicbrainz::api::Error::RateLimit); 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()));
let fetch = FetchState::new(rx); let fetch = FetchState::search(rx);
let app = AppMachine::app_fetch_next(inner, fetch); let app = AppMachine::app_fetch_next(inner, fetch);
assert!(matches!(app, AppState::Error(_))); assert!(matches!(app, AppState::Error(_)));
} }
#[test]
fn recv_ok_fetch_ok() {
let collection = COLLECTION.clone();
let (tx, rx) = mpsc::channel::<MbApiResult>();
let fetch = FetchState::fetch(rx);
let artist_id = collection[0].meta.id.clone();
let old_album = collection[0].albums[0].meta.clone();
let new_album = AlbumMeta::new(AlbumId::new("some new album"));
let release_group_fetch = EntityList::Album(vec![old_album.clone(), new_album.clone()]);
let fetch_result = Ok(MbReturn::Fetch(release_group_fetch));
tx.send(fetch_result).unwrap();
drop(tx);
let mut music_hoard = music_hoard(collection);
music_hoard
.expect_add_album()
.with(predicate::eq(artist_id), predicate::eq(new_album))
.times(1)
.return_once(|_, _| Ok(()));
let app = AppMachine::app_fetch_next(inner(music_hoard), fetch);
assert!(matches!(app, AppState::Browse(_)));
}
#[test]
fn recv_ok_fetch_ok_add_album_err() {
let collection = COLLECTION.clone();
let (tx, rx) = mpsc::channel::<MbApiResult>();
let fetch = FetchState::fetch(rx);
let artist_id = collection[0].meta.id.clone();
let old_album = collection[0].albums[0].meta.clone();
let new_album = AlbumMeta::new(AlbumId::new("some new album"));
let release_group_fetch = EntityList::Album(vec![old_album.clone(), new_album.clone()]);
let fetch_result = Ok(MbReturn::Fetch(release_group_fetch));
tx.send(fetch_result).unwrap();
drop(tx);
let mut music_hoard = music_hoard(collection);
music_hoard
.expect_add_album()
.with(predicate::eq(artist_id), predicate::eq(new_album))
.times(1)
.return_once(|_, _| Err(musichoard::Error::CollectionError(String::from("get rekt"))));
let app = AppMachine::app_fetch_next(inner(music_hoard), fetch);
assert!(matches!(app, AppState::Error(_)));
}
#[test] #[test]
fn recv_err_empty() { fn recv_err_empty() {
let (_tx, rx) = mpsc::channel::<MbApiResult>(); 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::search(rx);
let app = AppMachine::app_fetch_next(inner, fetch); let app = AppMachine::app_fetch_next(inner, fetch);
assert!(matches!(app, AppState::Fetch(_))); assert!(matches!(app, AppState::Fetch(_)));
} }
fn browse_release_group_expectation(artist: &Artist) -> MockIMbJobSender {
let requests = AppMachine::browse_release_group_job(&artist.meta.info.musicbrainz);
let mut mb_job_sender = MockIMbJobSender::new();
mb_job_sender
.expect_submit_background_job()
.with(predicate::always(), predicate::eq(requests))
.times(1)
.return_once(|_, _| Ok(()));
mb_job_sender
}
#[test] #[test]
fn recv_err_empty_first() { fn recv_err_empty_first() {
let mut collection = COLLECTION.clone(); let mut collection = COLLECTION.clone();
collection[0].albums.clear(); collection[0].albums.clear();
let app = AppMachine::app_fetch_first(inner(music_hoard(collection))); let mb_job_sender = browse_release_group_expectation(&collection[0]);
assert!(matches!(app, AppState::Browse(_))); let inner = inner_with_mb(music_hoard(collection), mb_job_sender);
let app = AppMachine::app_fetch_first(inner);
assert!(matches!(app, AppState::Fetch(_)));
} }
#[test] #[test]
@ -667,11 +810,25 @@ mod tests {
let mut collection = COLLECTION.clone(); let mut collection = COLLECTION.clone();
collection[0].albums.clear(); collection[0].albums.clear();
let (_, rx) = mpsc::channel::<MbApiResult>(); let (_tx, rx) = mpsc::channel::<MbApiResult>();
let fetch = FetchState::new(rx); let fetch = FetchState::search(rx);
let app = AppMachine::app_fetch_next(inner(music_hoard(collection)), fetch); let app = AppMachine::app_fetch_next(inner(music_hoard(collection)), fetch);
assert!(matches!(app, AppState::Browse(_))); assert!(matches!(app, AppState::Fetch(_)));
}
#[test]
fn recv_err_disconnected_search_next() {
let mut collection = COLLECTION.clone();
collection[0].albums.clear();
let (_, rx) = mpsc::channel::<MbApiResult>();
let fetch = FetchState::search(rx);
let mb_job_sender = browse_release_group_expectation(&collection[0]);
let inner = inner_with_mb(music_hoard(collection), mb_job_sender);
let app = AppMachine::app_fetch_next(inner, fetch);
assert!(matches!(app, AppState::Fetch(_)));
} }
#[test] #[test]
@ -679,7 +836,7 @@ mod tests {
let (tx, rx) = mpsc::channel::<MbApiResult>(); 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::search(rx);
let app = AppMachine::app_fetch_next(inner, fetch); let app = AppMachine::app_fetch_next(inner, fetch);
assert!(matches!(app, AppState::Fetch(_))); assert!(matches!(app, AppState::Fetch(_)));
@ -696,7 +853,7 @@ mod tests {
fn abort() { fn abort() {
let (_, rx) = mpsc::channel::<MbApiResult>(); let (_, rx) = mpsc::channel::<MbApiResult>();
let fetch = FetchState::new(rx); let fetch = FetchState::search(rx);
let app = AppMachine::fetch_state(inner(music_hoard(COLLECTION.clone())), fetch); let app = AppMachine::fetch_state(inner(music_hoard(COLLECTION.clone())), fetch);
let app = app.abort(); let app = app.abort();

View File

@ -319,15 +319,13 @@ mod tests {
} }
fn album_meta(id: AlbumId) -> AlbumMeta { fn album_meta(id: AlbumId) -> AlbumMeta {
AlbumMeta::new( AlbumMeta::new(id)
id, .with_date(AlbumDate::new(Some(1990), Some(5), None))
AlbumDate::new(Some(1990), Some(5), None), .with_info(AlbumInfo::new(
AlbumInfo::new(
MbRefOption::Some(mbid().into()), MbRefOption::Some(mbid().into()),
Some(AlbumPrimaryType::Album), Some(AlbumPrimaryType::Album),
vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation], vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation],
), ))
)
} }
fn album_match() -> EntityMatches { fn album_match() -> EntityMatches {
@ -357,7 +355,7 @@ mod tests {
fn fetch_state() -> FetchState { fn fetch_state() -> FetchState {
let (_, rx) = mpsc::channel(); let (_, rx) = mpsc::channel();
FetchState::new(rx) FetchState::search(rx)
} }
fn match_state(match_state_info: EntityMatches) -> MatchState { fn match_state(match_state_info: EntityMatches) -> MatchState {
@ -388,7 +386,7 @@ mod tests {
fn match_state_flow(mut matches_info: EntityMatches, len: usize) { fn match_state_flow(mut matches_info: EntityMatches, len: usize) {
// tx must exist for rx to return Empty rather than Disconnected. // tx must exist for rx to return Empty rather than Disconnected.
let (_tx, rx) = mpsc::channel(); let (_tx, rx) = mpsc::channel();
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx)); let app_matches = MatchState::new(matches_info.clone(), FetchState::search(rx));
let mut music_hoard = music_hoard(vec![]); let mut music_hoard = music_hoard(vec![]);
let artist_id = ArtistId::new("Artist"); let artist_id = ArtistId::new("Artist");
@ -487,7 +485,7 @@ mod tests {
let matches_info = artist_match(); let matches_info = artist_match();
let (_tx, rx) = mpsc::channel(); let (_tx, rx) = mpsc::channel();
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx)); let app_matches = MatchState::new(matches_info.clone(), FetchState::search(rx));
let mut music_hoard = music_hoard(vec![]); let mut music_hoard = music_hoard(vec![]);
match matches_info { match matches_info {
@ -511,7 +509,7 @@ mod tests {
let matches_info = album_match(); let matches_info = album_match();
let (_tx, rx) = mpsc::channel(); let (_tx, rx) = mpsc::channel();
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx)); let app_matches = MatchState::new(matches_info.clone(), FetchState::search(rx));
let mut music_hoard = music_hoard(vec![]); let mut music_hoard = music_hoard(vec![]);
match matches_info { match matches_info {
@ -535,7 +533,7 @@ mod tests {
let matches_info = artist_match(); let matches_info = artist_match();
let (_tx, rx) = mpsc::channel(); let (_tx, rx) = mpsc::channel();
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx)); let app_matches = MatchState::new(matches_info.clone(), FetchState::search(rx));
let mut music_hoard = music_hoard(vec![]); let mut music_hoard = music_hoard(vec![]);
match matches_info { match matches_info {
@ -625,7 +623,7 @@ mod tests {
fn select_manual_input_album() { fn select_manual_input_album() {
let mut mb_job_sender = MockIMbJobSender::new(); let mut mb_job_sender = MockIMbJobSender::new();
let artist_id = ArtistId::new("Artist"); let artist_id = ArtistId::new("Artist");
let album = AlbumMeta::new("Album", 1990, AlbumInfo::default()); let album = AlbumMeta::new("Album").with_date(1990);
let requests = VecDeque::from([MbParams::lookup_release_group( let requests = VecDeque::from([MbParams::lookup_release_group(
artist_id.clone(), artist_id.clone(),
album.id.clone(), album.id.clone(),

View File

@ -493,7 +493,7 @@ mod tests {
let (_, rx) = mpsc::channel(); let (_, rx) = mpsc::channel();
let inner = app.unwrap_browse().inner; let inner = app.unwrap_browse().inner;
let state = FetchState::new(rx); let state = FetchState::search(rx);
app = AppMachine::new(inner, state).into(); app = AppMachine::new(inner, state).into();
let state = app.state(); let state = app.state();
@ -518,7 +518,7 @@ mod tests {
assert!(app.is_running()); assert!(app.is_running());
let (_, rx) = mpsc::channel(); let (_, rx) = mpsc::channel();
let fetch = FetchState::new(rx); let fetch = FetchState::search(rx);
let artist = ArtistMeta::new(ArtistId::new("Artist")); let artist = ArtistMeta::new(ArtistId::new("Artist"));
let info = EntityMatches::artist_lookup(artist.clone(), Entity::new(artist.clone())); let info = EntityMatches::artist_lookup(artist.clone(), Entity::new(artist.clone()));
app = app =

View File

@ -59,7 +59,6 @@ pub trait IMbJobSender {
pub enum MbParams { pub enum MbParams {
Lookup(LookupParams), Lookup(LookupParams),
Search(SearchParams), Search(SearchParams),
#[allow(dead_code)] // TODO: remove with completion of #160
Browse(BrowseParams), Browse(BrowseParams),
} }
@ -102,7 +101,6 @@ pub struct SearchReleaseGroupParams {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum BrowseParams { pub enum BrowseParams {
#[allow(dead_code)] // TODO: remove with completion of #160
ReleaseGroup(BrowseReleaseGroupParams), ReleaseGroup(BrowseReleaseGroupParams),
} }
@ -136,7 +134,6 @@ impl MbParams {
})) }))
} }
#[allow(dead_code)] // TODO: to be removed by completion of #160
pub fn browse_release_group(artist: Mbid) -> Self { pub fn browse_release_group(artist: Mbid) -> Self {
MbParams::Browse(BrowseParams::ReleaseGroup(BrowseReleaseGroupParams { MbParams::Browse(BrowseParams::ReleaseGroup(BrowseReleaseGroupParams {
artist, artist,

View File

@ -3,7 +3,7 @@ pub mod interface;
use musichoard::{ use musichoard::{
collection::{ collection::{
album::{AlbumId, AlbumInfo}, album::{AlbumId, AlbumInfo, AlbumMeta},
artist::{ArtistId, ArtistInfo}, artist::{ArtistId, ArtistInfo},
Collection, Collection,
}, },
@ -20,6 +20,12 @@ pub trait IMusicHoard {
fn reload_database(&mut self) -> Result<(), musichoard::Error>; fn reload_database(&mut self) -> Result<(), musichoard::Error>;
fn get_collection(&self) -> &Collection; fn get_collection(&self) -> &Collection;
fn add_album(
&mut self,
artist_id: &ArtistId,
album_meta: AlbumMeta,
) -> Result<(), musichoard::Error>;
fn merge_artist_info( fn merge_artist_info(
&mut self, &mut self,
id: &ArtistId, id: &ArtistId,
@ -47,6 +53,14 @@ impl<Database: IDatabase, Library: ILibrary> IMusicHoard for MusicHoard<Database
<Self as IMusicHoardBase>::get_collection(self) <Self as IMusicHoardBase>::get_collection(self)
} }
fn add_album(
&mut self,
artist_id: &ArtistId,
album_meta: AlbumMeta,
) -> Result<(), musichoard::Error> {
<Self as IMusicHoardDatabase>::add_album(self, artist_id, album_meta)
}
fn merge_artist_info( fn merge_artist_info(
&mut self, &mut self,
id: &ArtistId, id: &ArtistId,

View File

@ -289,9 +289,7 @@ mod tests {
#[test] #[test]
fn empty_album() { fn empty_album() {
let mut artists: Vec<Artist> = vec![Artist::new(ArtistId::new("An artist"))]; let mut artists: Vec<Artist> = vec![Artist::new(ArtistId::new("An artist"))];
artists[0] artists[0].albums.push(Album::new("An album"));
.albums
.push(Album::new("An album", AlbumDate::default(), None, vec![]));
let mut selection = Selection::new(&artists); let mut selection = Selection::new(&artists);
draw_test_suite(&artists, &mut selection); draw_test_suite(&artists, &mut selection);
@ -361,15 +359,13 @@ mod tests {
} }
fn album_meta(id: AlbumId) -> AlbumMeta { fn album_meta(id: AlbumId) -> AlbumMeta {
AlbumMeta::new( AlbumMeta::new(id)
id, .with_date(AlbumDate::new(Some(1990), Some(5), None))
AlbumDate::new(Some(1990), Some(5), None), .with_info(AlbumInfo::new(
AlbumInfo::new(
MbRefOption::None, MbRefOption::None,
Some(AlbumPrimaryType::Album), Some(AlbumPrimaryType::Album),
vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation], vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation],
), ))
)
} }
fn album_matches() -> EntityMatches { fn album_matches() -> EntityMatches {