diff --git a/src/lib.rs b/src/lib.rs index aaddaea..2ec5afb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,19 +1,15 @@ //! MusicHoard - a music collection manager. mod collection; +mod musichoard; + +// FIXME: make private and fix via re-exports. pub mod database; pub mod library; -use std::{ - collections::HashMap, - fmt::{self, Display}, - mem, -}; +use std::fmt::{self, Display}; -use collection::{artist::InvalidUrlError, Merge}; -use database::IDatabase; -use library::{ILibrary, Item, Query}; -use paste::paste; +use collection::artist::InvalidUrlError; // FIXME: validate the re-exports. pub use collection::{ @@ -23,6 +19,9 @@ pub use collection::{ Collection, }; +// FIXME: validate the re-exports +pub use musichoard::{MusicHoard, MusicHoardBuilder, NoDatabase, NoLibrary}; + /// Error type for `musichoard`. #[derive(Debug, PartialEq, Eq)] pub enum Error { @@ -88,370 +87,9 @@ impl From for Error { } } -/// The Music Hoard. It is responsible for pulling information from both the library and the -/// database, ensuring its consistent and writing back any changes. -pub struct MusicHoard { - collection: Collection, - library: LIB, - database: DB, -} - -/// Phantom type for when a library implementation is not needed. -pub struct NoLibrary; - -/// Phantom type for when a database implementation is not needed. -pub struct NoDatabase; - -macro_rules! music_hoard_unique_url_dispatch { - ($field:ident) => { - paste! { - pub fn [], S: AsRef>( - &mut self, - artist_id: ID, - url: S, - ) -> Result<(), Error> { - self.get_artist_mut_or_err(artist_id.as_ref())?.[](url) - } - - pub fn [], S: AsRef>( - &mut self, - artist_id: ID, - url: S, - ) -> Result<(), Error> { - self.get_artist_mut_or_err(artist_id.as_ref())?.[](url) - } - - pub fn [], S: AsRef>( - &mut self, - artist_id: ID, - url: S, - ) -> Result<(), Error> { - self.get_artist_mut_or_err(artist_id.as_ref())?.[](url) - } - - pub fn []>( - &mut self, - artist_id: ID, - ) -> Result<(), Error> { - self.get_artist_mut_or_err(artist_id.as_ref())?.[](); - Ok(()) - } - } - }; -} - -macro_rules! music_hoard_multi_url_dispatch { - ($field:ident) => { - paste! { - pub fn [], S: AsRef>( - &mut self, - artist_id: ID, - urls: Vec, - ) -> Result<(), Error> { - self.get_artist_mut_or_err(artist_id.as_ref())?.[](urls) - } - - pub fn [], S: AsRef>( - &mut self, - artist_id: ID, - urls: Vec, - ) -> Result<(), Error> { - self.get_artist_mut_or_err(artist_id.as_ref())?.[](urls) - } - - pub fn [], S: AsRef>( - &mut self, - artist_id: ID, - urls: Vec, - ) -> Result<(), Error> { - self.get_artist_mut_or_err(artist_id.as_ref())?.[](urls) - } - - pub fn []>( - &mut self, artist_id: ID, - ) -> Result<(), Error> { - self.get_artist_mut_or_err(artist_id.as_ref())?.[](); - Ok(()) - } - } - }; -} - -impl MusicHoard { - /// Create a new [`MusicHoard`] with the provided [`ILibrary`] and [`IDatabase`]. - pub fn new(library: LIB, database: DB) -> Self { - MusicHoard { - collection: vec![], - library, - database, - } - } - - /// Retrieve the [`Collection`]. - pub fn get_collection(&self) -> &Collection { - &self.collection - } - - pub fn add_artist>(&mut self, artist_id: ID) { - let artist_id: ArtistId = artist_id.into(); - - if self.get_artist(&artist_id).is_none() { - self.collection.push(Artist::new(artist_id)); - Self::sort_artists(&mut self.collection); - } - } - - pub fn remove_artist>(&mut self, artist_id: ID) { - let index_opt = self - .collection - .iter() - .position(|a| &a.id == artist_id.as_ref()); - - if let Some(index) = index_opt { - self.collection.remove(index); - } - } - - pub fn set_artist_sort, SORT: Into>( - &mut self, - artist_id: ID, - artist_sort: SORT, - ) -> Result<(), Error> { - self.get_artist_mut_or_err(artist_id.as_ref())? - .set_sort_key(artist_sort); - Self::sort(&mut self.collection); - Ok(()) - } - - pub fn clear_artist_sort>(&mut self, artist_id: ID) -> Result<(), Error> { - self.get_artist_mut_or_err(artist_id.as_ref())? - .clear_sort_key(); - Self::sort(&mut self.collection); - Ok(()) - } - - music_hoard_unique_url_dispatch!(musicbrainz); - - music_hoard_multi_url_dispatch!(musicbutler); - - music_hoard_multi_url_dispatch!(bandcamp); - - music_hoard_unique_url_dispatch!(qobuz); - - fn sort(collection: &mut [Artist]) { - Self::sort_artists(collection); - Self::sort_albums_and_tracks(collection.iter_mut()); - } - - fn sort_artists(collection: &mut [Artist]) { - collection.sort_unstable(); - } - - fn sort_albums_and_tracks<'a, COL: Iterator>(collection: COL) { - for artist in collection { - artist.albums.sort_unstable(); - for album in artist.albums.iter_mut() { - album.tracks.sort_unstable(); - } - } - } - - fn merge_with_primary(&mut self, primary: HashMap) { - let collection = mem::take(&mut self.collection); - self.collection = Self::merge_collections(primary, collection); - } - - fn merge_with_secondary>(&mut self, secondary: SEC) { - let primary_map: HashMap = self - .collection - .drain(..) - .map(|a| (a.id.clone(), a)) - .collect(); - self.collection = Self::merge_collections(primary_map, secondary); - } - - fn merge_collections>( - mut primary: HashMap, - secondary: SEC, - ) -> Collection { - for secondary_artist in secondary.into_iter() { - if let Some(ref mut primary_artist) = primary.get_mut(&secondary_artist.id) { - primary_artist.merge_in_place(secondary_artist); - } else { - primary.insert(secondary_artist.id.clone(), secondary_artist); - } - } - let mut collection: Collection = primary.into_values().collect(); - Self::sort_artists(&mut collection); - collection - } - - fn items_to_artists(items: Vec) -> Result, Error> { - let mut collection = HashMap::::new(); - - for item in items.into_iter() { - let artist_id = ArtistId { - name: item.album_artist, - }; - - let artist_sort = item.album_artist_sort.map(|s| ArtistId { name: s }); - - let album_id = AlbumId { - year: item.album_year, - title: item.album_title, - }; - - let track = Track { - id: TrackId { - number: item.track_number, - title: item.track_title, - }, - artist: item.track_artist, - quality: Quality { - format: item.track_format, - bitrate: item.track_bitrate, - }, - }; - - // There are usually many entries per artist. Therefore, we avoid simply calling - // .entry(artist_id.clone()).or_insert_with(..), because of the clone. The flipside is - // that insertions will thus do an additional lookup. - let artist = match collection.get_mut(&artist_id) { - Some(artist) => artist, - None => collection - .entry(artist_id.clone()) - .or_insert_with(|| Artist::new(artist_id)), - }; - - if artist.sort.is_some() { - if artist_sort.is_some() && (artist.sort != artist_sort) { - return Err(Error::CollectionError(format!( - "multiple album_artist_sort found for artist '{}': '{}' != '{}'", - artist.id, - artist.sort.as_ref().unwrap(), - artist_sort.as_ref().unwrap() - ))); - } - } else if artist_sort.is_some() { - artist.sort = artist_sort; - } - - // Do a linear search as few artists have more than a handful of albums. Search from the - // back as the original items vector is usually already sorted. - match artist - .albums - .iter_mut() - .rev() - .find(|album| album.id == album_id) - { - Some(album) => album.tracks.push(track), - None => artist.albums.push(Album { - id: album_id, - tracks: vec![track], - }), - } - } - - Ok(collection) - } - - fn get_artist(&self, artist_id: &ArtistId) -> Option<&Artist> { - self.collection.iter().find(|a| &a.id == artist_id) - } - - fn get_artist_mut(&mut self, artist_id: &ArtistId) -> Option<&mut Artist> { - self.collection.iter_mut().find(|a| &a.id == artist_id) - } - - fn get_artist_mut_or_err(&mut self, artist_id: &ArtistId) -> Result<&mut Artist, Error> { - self.get_artist_mut(artist_id).ok_or_else(|| { - Error::CollectionError(format!("artist '{}' is not in the collection", artist_id)) - }) - } -} - -impl MusicHoard { - /// Rescan the library and merge with the in-memory collection. - pub fn rescan_library(&mut self) -> Result<(), Error> { - let items = self.library.list(&Query::new())?; - let mut library_collection = Self::items_to_artists(items)?; - Self::sort_albums_and_tracks(library_collection.values_mut()); - - self.merge_with_primary(library_collection); - Ok(()) - } -} - -impl MusicHoard { - /// Load the database and merge with the in-memory collection. - pub fn load_from_database(&mut self) -> Result<(), Error> { - let mut database_collection = self.database.load()?; - Self::sort_albums_and_tracks(database_collection.iter_mut()); - - self.merge_with_secondary(database_collection); - Ok(()) - } - - /// Save the in-memory collection to the database. - pub fn save_to_database(&mut self) -> Result<(), Error> { - self.database.save(&self.collection)?; - Ok(()) - } -} - -/// Builder for [`MusicHoard`]. Its purpose is to make it easier to set various combinations of -/// library/database or their absence. -pub struct MusicHoardBuilder { - library: LIB, - database: DB, -} - -impl Default for MusicHoardBuilder { - /// Create a [`MusicHoardBuilder`]. - fn default() -> Self { - Self::new() - } -} - -impl MusicHoardBuilder { - /// Create a [`MusicHoardBuilder`]. - pub fn new() -> Self { - MusicHoardBuilder { - library: NoLibrary, - database: NoDatabase, - } - } -} - -impl MusicHoardBuilder { - /// Set a library for [`MusicHoard`]. - pub fn set_library(self, library: NEWLIB) -> MusicHoardBuilder { - MusicHoardBuilder { - library, - database: self.database, - } - } - - /// Set a database for [`MusicHoard`]. - pub fn set_database(self, database: NEWDB) -> MusicHoardBuilder { - MusicHoardBuilder { - library: self.library, - database, - } - } - - /// Build [`MusicHoard`] with the currently set library and database. - pub fn build(self) -> MusicHoard { - MusicHoard::new(self.library, self.database) - } -} - #[cfg(test)] #[macro_use] mod testmacros; #[cfg(test)] mod testlib; - -#[cfg(test)] -mod tests; diff --git a/src/musichoard/mod.rs b/src/musichoard/mod.rs new file mode 100644 index 0000000..549ee02 --- /dev/null +++ b/src/musichoard/mod.rs @@ -0,0 +1,376 @@ +use std::{collections::HashMap, mem}; + +use paste::paste; + +use super::collection::{ + album::{Album, AlbumId}, + artist::{Artist, ArtistId}, + track::{Quality, Track, TrackId}, + Collection, Merge, +}; +use super::database::IDatabase; +use super::library::{ILibrary, Item, Query}; + +// FIXME: should not be importing this error. +use crate::Error; + +/// The Music Hoard. It is responsible for pulling information from both the library and the +/// database, ensuring its consistent and writing back any changes. +pub struct MusicHoard { + collection: Collection, + library: LIB, + database: DB, +} + +macro_rules! music_hoard_unique_url_dispatch { + ($field:ident) => { + paste! { + pub fn [], S: AsRef>( + &mut self, + artist_id: ID, + url: S, + ) -> Result<(), Error> { + self.get_artist_mut_or_err(artist_id.as_ref())?.[](url) + } + + pub fn [], S: AsRef>( + &mut self, + artist_id: ID, + url: S, + ) -> Result<(), Error> { + self.get_artist_mut_or_err(artist_id.as_ref())?.[](url) + } + + pub fn [], S: AsRef>( + &mut self, + artist_id: ID, + url: S, + ) -> Result<(), Error> { + self.get_artist_mut_or_err(artist_id.as_ref())?.[](url) + } + + pub fn []>( + &mut self, + artist_id: ID, + ) -> Result<(), Error> { + self.get_artist_mut_or_err(artist_id.as_ref())?.[](); + Ok(()) + } + } + }; +} + +macro_rules! music_hoard_multi_url_dispatch { + ($field:ident) => { + paste! { + pub fn [], S: AsRef>( + &mut self, + artist_id: ID, + urls: Vec, + ) -> Result<(), Error> { + self.get_artist_mut_or_err(artist_id.as_ref())?.[](urls) + } + + pub fn [], S: AsRef>( + &mut self, + artist_id: ID, + urls: Vec, + ) -> Result<(), Error> { + self.get_artist_mut_or_err(artist_id.as_ref())?.[](urls) + } + + pub fn [], S: AsRef>( + &mut self, + artist_id: ID, + urls: Vec, + ) -> Result<(), Error> { + self.get_artist_mut_or_err(artist_id.as_ref())?.[](urls) + } + + pub fn []>( + &mut self, artist_id: ID, + ) -> Result<(), Error> { + self.get_artist_mut_or_err(artist_id.as_ref())?.[](); + Ok(()) + } + } + }; +} + +impl MusicHoard { + /// Create a new [`MusicHoard`] with the provided [`ILibrary`] and [`IDatabase`]. + pub fn new(library: LIB, database: DB) -> Self { + MusicHoard { + collection: vec![], + library, + database, + } + } + + /// Retrieve the [`Collection`]. + pub fn get_collection(&self) -> &Collection { + &self.collection + } + + pub fn add_artist>(&mut self, artist_id: ID) { + let artist_id: ArtistId = artist_id.into(); + + if self.get_artist(&artist_id).is_none() { + self.collection.push(Artist::new(artist_id)); + Self::sort_artists(&mut self.collection); + } + } + + pub fn remove_artist>(&mut self, artist_id: ID) { + let index_opt = self + .collection + .iter() + .position(|a| &a.id == artist_id.as_ref()); + + if let Some(index) = index_opt { + self.collection.remove(index); + } + } + + pub fn set_artist_sort, SORT: Into>( + &mut self, + artist_id: ID, + artist_sort: SORT, + ) -> Result<(), Error> { + self.get_artist_mut_or_err(artist_id.as_ref())? + .set_sort_key(artist_sort); + Self::sort(&mut self.collection); + Ok(()) + } + + pub fn clear_artist_sort>(&mut self, artist_id: ID) -> Result<(), Error> { + self.get_artist_mut_or_err(artist_id.as_ref())? + .clear_sort_key(); + Self::sort(&mut self.collection); + Ok(()) + } + + music_hoard_unique_url_dispatch!(musicbrainz); + + music_hoard_multi_url_dispatch!(musicbutler); + + music_hoard_multi_url_dispatch!(bandcamp); + + music_hoard_unique_url_dispatch!(qobuz); + + fn sort(collection: &mut [Artist]) { + Self::sort_artists(collection); + Self::sort_albums_and_tracks(collection.iter_mut()); + } + + fn sort_artists(collection: &mut [Artist]) { + collection.sort_unstable(); + } + + fn sort_albums_and_tracks<'a, COL: Iterator>(collection: COL) { + for artist in collection { + artist.albums.sort_unstable(); + for album in artist.albums.iter_mut() { + album.tracks.sort_unstable(); + } + } + } + + fn merge_with_primary(&mut self, primary: HashMap) { + let collection = mem::take(&mut self.collection); + self.collection = Self::merge_collections(primary, collection); + } + + fn merge_with_secondary>(&mut self, secondary: SEC) { + let primary_map: HashMap = self + .collection + .drain(..) + .map(|a| (a.id.clone(), a)) + .collect(); + self.collection = Self::merge_collections(primary_map, secondary); + } + + fn merge_collections>( + mut primary: HashMap, + secondary: SEC, + ) -> Collection { + for secondary_artist in secondary.into_iter() { + if let Some(ref mut primary_artist) = primary.get_mut(&secondary_artist.id) { + primary_artist.merge_in_place(secondary_artist); + } else { + primary.insert(secondary_artist.id.clone(), secondary_artist); + } + } + let mut collection: Collection = primary.into_values().collect(); + Self::sort_artists(&mut collection); + collection + } + + fn items_to_artists(items: Vec) -> Result, Error> { + let mut collection = HashMap::::new(); + + for item in items.into_iter() { + let artist_id = ArtistId { + name: item.album_artist, + }; + + let artist_sort = item.album_artist_sort.map(|s| ArtistId { name: s }); + + let album_id = AlbumId { + year: item.album_year, + title: item.album_title, + }; + + let track = Track { + id: TrackId { + number: item.track_number, + title: item.track_title, + }, + artist: item.track_artist, + quality: Quality { + format: item.track_format, + bitrate: item.track_bitrate, + }, + }; + + // There are usually many entries per artist. Therefore, we avoid simply calling + // .entry(artist_id.clone()).or_insert_with(..), because of the clone. The flipside is + // that insertions will thus do an additional lookup. + let artist = match collection.get_mut(&artist_id) { + Some(artist) => artist, + None => collection + .entry(artist_id.clone()) + .or_insert_with(|| Artist::new(artist_id)), + }; + + if artist.sort.is_some() { + if artist_sort.is_some() && (artist.sort != artist_sort) { + return Err(Error::CollectionError(format!( + "multiple album_artist_sort found for artist '{}': '{}' != '{}'", + artist.id, + artist.sort.as_ref().unwrap(), + artist_sort.as_ref().unwrap() + ))); + } + } else if artist_sort.is_some() { + artist.sort = artist_sort; + } + + // Do a linear search as few artists have more than a handful of albums. Search from the + // back as the original items vector is usually already sorted. + match artist + .albums + .iter_mut() + .rev() + .find(|album| album.id == album_id) + { + Some(album) => album.tracks.push(track), + None => artist.albums.push(Album { + id: album_id, + tracks: vec![track], + }), + } + } + + Ok(collection) + } + + fn get_artist(&self, artist_id: &ArtistId) -> Option<&Artist> { + self.collection.iter().find(|a| &a.id == artist_id) + } + + fn get_artist_mut(&mut self, artist_id: &ArtistId) -> Option<&mut Artist> { + self.collection.iter_mut().find(|a| &a.id == artist_id) + } + + fn get_artist_mut_or_err(&mut self, artist_id: &ArtistId) -> Result<&mut Artist, Error> { + self.get_artist_mut(artist_id).ok_or_else(|| { + Error::CollectionError(format!("artist '{}' is not in the collection", artist_id)) + }) + } +} + +impl MusicHoard { + /// Rescan the library and merge with the in-memory collection. + pub fn rescan_library(&mut self) -> Result<(), Error> { + let items = self.library.list(&Query::new())?; + let mut library_collection = Self::items_to_artists(items)?; + Self::sort_albums_and_tracks(library_collection.values_mut()); + + self.merge_with_primary(library_collection); + Ok(()) + } +} + +impl MusicHoard { + /// Load the database and merge with the in-memory collection. + pub fn load_from_database(&mut self) -> Result<(), Error> { + let mut database_collection = self.database.load()?; + Self::sort_albums_and_tracks(database_collection.iter_mut()); + + self.merge_with_secondary(database_collection); + Ok(()) + } + + /// Save the in-memory collection to the database. + pub fn save_to_database(&mut self) -> Result<(), Error> { + self.database.save(&self.collection)?; + Ok(()) + } +} + +/// Builder for [`MusicHoard`]. Its purpose is to make it easier to set various combinations of +/// library/database or their absence. +pub struct MusicHoardBuilder { + library: LIB, + database: DB, +} + +/// Phantom type for when a library implementation is not needed. +pub struct NoLibrary; + +/// Phantom type for when a database implementation is not needed. +pub struct NoDatabase; + +impl Default for MusicHoardBuilder { + /// Create a [`MusicHoardBuilder`]. + fn default() -> Self { + Self::new() + } +} + +impl MusicHoardBuilder { + /// Create a [`MusicHoardBuilder`]. + pub fn new() -> Self { + MusicHoardBuilder { + library: NoLibrary, + database: NoDatabase, + } + } +} + +impl MusicHoardBuilder { + /// Set a library for [`MusicHoard`]. + pub fn set_library(self, library: NEWLIB) -> MusicHoardBuilder { + MusicHoardBuilder { + library, + database: self.database, + } + } + + /// Set a database for [`MusicHoard`]. + pub fn set_database(self, database: NEWDB) -> MusicHoardBuilder { + MusicHoardBuilder { + library: self.library, + database, + } + } + + /// Build [`MusicHoard`] with the currently set library and database. + pub fn build(self) -> MusicHoard { + MusicHoard::new(self.library, self.database) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/tests/mod.rs b/src/musichoard/tests.rs similarity index 99% rename from src/tests/mod.rs rename to src/musichoard/tests.rs index 93589e3..809f492 100644 --- a/src/tests/mod.rs +++ b/src/musichoard/tests.rs @@ -1,14 +1,16 @@ use mockall::predicate; use super::*; -use collection::{ - artist::{Bandcamp, MusicBrainz, MusicButler, Qobuz}, + +// FIXME: check all crate::* imports - are the tests where they should be? +use crate::collection::{ + artist::{ArtistProperties, Bandcamp, MusicBrainz, MusicButler, Qobuz}, track::Format, Merge, }; -use database::MockIDatabase; -use library::{testmod::LIBRARY_ITEMS, MockILibrary}; -use testlib::{FULL_COLLECTION, LIBRARY_COLLECTION}; +use crate::database::{self, MockIDatabase}; +use crate::library::{self, testmod::LIBRARY_ITEMS, MockILibrary}; +use crate::testlib::{FULL_COLLECTION, LIBRARY_COLLECTION}; static MUSICBRAINZ: &str = "https://musicbrainz.org/artist/d368baa8-21ca-4759-9731-0b2753071ad8"; static MUSICBRAINZ_2: &str = "https://musicbrainz.org/artist/823869a5-5ded-4f6b-9fb7-2a9344d83c6b";