//! MusicHoard - a music collection manager. pub mod database; pub mod library; use std::{ cmp::Ordering, collections::HashMap, fmt::{self, Debug, Display}, iter::Peekable, mem, }; use database::IDatabase; use library::{ILibrary, Item, Query}; use paste::paste; use serde::{Deserialize, Serialize}; use url::Url; use uuid::Uuid; /// An object with the [`IMbid`] trait contains a [MusicBrainz /// Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) (MBID). pub trait IMbid { fn mbid(&self) -> &str; } /// The different URL types supported by MusicHoard. #[derive(Debug)] enum UrlType { MusicBrainz, MusicButler, Bandcamp, Qobuz, } /// Invalid URL error. struct InvalidUrlError { url_type: UrlType, url: String, } impl Display for InvalidUrlError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "invalid url of type {:?}: {}", self.url_type, self.url) } } /// MusicBrainz reference. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] pub struct MusicBrainz(Url); impl MusicBrainz { /// Validate and wrap a MusicBrainz URL. pub fn new>(url: S) -> Result { let url = Url::parse(url.as_ref())?; if !url .domain() .map(|u| u.ends_with("musicbrainz.org")) .unwrap_or(false) { return Err(Self::invalid_url_error(url).into()); } match url.path_segments().and_then(|mut ps| ps.nth(1)) { Some(segment) => Uuid::try_parse(segment)?, None => return Err(Self::invalid_url_error(url).into()), }; Ok(MusicBrainz(url)) } fn invalid_url_error>(url: S) -> InvalidUrlError { InvalidUrlError { url_type: UrlType::MusicBrainz, url: url.into(), } } } impl AsRef for MusicBrainz { fn as_ref(&self) -> &str { self.0.as_ref() } } impl TryFrom<&str> for MusicBrainz { type Error = Error; fn try_from(value: &str) -> Result { MusicBrainz::new(value) } } impl Display for MusicBrainz { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.0) } } impl IMbid for MusicBrainz { fn mbid(&self) -> &str { // The URL is assumed to have been validated. self.0.path_segments().and_then(|mut ps| ps.nth(1)).unwrap() } } /// MusicButler reference. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] pub struct MusicButler(Url); impl MusicButler { /// Validate and wrap a MusicButler URL. pub fn new>(url: S) -> Result { let url = Url::parse(url.as_ref())?; if !url .domain() .map(|u| u.ends_with("musicbutler.io")) .unwrap_or(false) { return Err(Self::invalid_url_error(url).into()); } Ok(MusicButler(url)) } pub fn as_str(&self) -> &str { self.0.as_str() } fn invalid_url_error>(url: S) -> InvalidUrlError { InvalidUrlError { url_type: UrlType::MusicButler, url: url.into(), } } } impl AsRef for MusicButler { fn as_ref(&self) -> &str { self.0.as_ref() } } impl TryFrom<&str> for MusicButler { type Error = Error; fn try_from(value: &str) -> Result { MusicButler::new(value) } } /// Bandcamp reference. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] pub struct Bandcamp(Url); impl Bandcamp { /// Validate and wrap a Bandcamp URL. pub fn new>(url: S) -> Result { let url = Url::parse(url.as_ref())?; if !url .domain() .map(|u| u.ends_with("bandcamp.com")) .unwrap_or(false) { return Err(Self::invalid_url_error(url).into()); } Ok(Bandcamp(url)) } pub fn as_str(&self) -> &str { self.0.as_str() } fn invalid_url_error>(url: S) -> InvalidUrlError { InvalidUrlError { url_type: UrlType::Bandcamp, url: url.into(), } } } impl AsRef for Bandcamp { fn as_ref(&self) -> &str { self.0.as_ref() } } impl TryFrom<&str> for Bandcamp { type Error = Error; fn try_from(value: &str) -> Result { Bandcamp::new(value) } } /// Qobuz reference. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] pub struct Qobuz(Url); impl Qobuz { /// Validate and wrap a Qobuz URL. pub fn new>(url: S) -> Result { let url = Url::parse(url.as_ref())?; if !url .domain() .map(|u| u.ends_with("qobuz.com")) .unwrap_or(false) { return Err(Self::invalid_url_error(url).into()); } Ok(Qobuz(url)) } fn invalid_url_error>(url: S) -> InvalidUrlError { InvalidUrlError { url_type: UrlType::Qobuz, url: url.into(), } } } impl AsRef for Qobuz { fn as_ref(&self) -> &str { self.0.as_ref() } } impl TryFrom<&str> for Qobuz { type Error = Error; fn try_from(value: &str) -> Result { Qobuz::new(value) } } impl Display for Qobuz { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.0) } } /// The track file format. #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] pub enum Format { Flac, Mp3, } /// The track quality. Combines format and bitrate information. #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct Quality { pub format: Format, pub bitrate: u32, } /// The track identifier. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct TrackId { pub number: u32, pub title: String, } /// A single track on an album. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct Track { pub id: TrackId, pub artist: Vec, pub quality: Quality, } impl PartialOrd for Track { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for Track { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.id.cmp(&other.id) } } impl Merge for Track { fn merge_in_place(&mut self, other: Self) { assert_eq!(self.id, other.id); } } /// The album identifier. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq, Hash)] pub struct AlbumId { pub year: u32, pub title: String, } /// An album is a collection of tracks that were released together. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct Album { pub id: AlbumId, pub tracks: Vec, } impl PartialOrd for Album { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for Album { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.id.cmp(&other.id) } } impl Merge for Album { fn merge_in_place(&mut self, other: Self) { assert_eq!(self.id, other.id); let tracks = mem::take(&mut self.tracks); self.tracks = MergeSorted::new(tracks.into_iter(), other.tracks.into_iter()).collect(); } } /// The artist identifier. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ArtistId { pub name: String, } impl AsRef for ArtistId { fn as_ref(&self) -> &ArtistId { self } } impl ArtistId { pub fn new>(name: S) -> ArtistId { ArtistId { name: name.into() } } } impl Display for ArtistId { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.name) } } /// The artist properties. #[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] pub struct ArtistProperties { pub musicbrainz: Option, pub musicbutler: Vec, pub bandcamp: Vec, pub qobuz: Option, } impl Merge for ArtistProperties { fn merge_in_place(&mut self, other: Self) { self.musicbrainz = self.musicbrainz.take().or(other.musicbrainz); Self::merge_vecs(&mut self.musicbutler, other.musicbutler); Self::merge_vecs(&mut self.bandcamp, other.bandcamp); self.qobuz = self.qobuz.take().or(other.qobuz); } } /// An artist. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct Artist { pub id: ArtistId, pub sort: Option, pub properties: ArtistProperties, pub albums: Vec, } macro_rules! artist_unique_url_dispatch { ($field:ident) => { paste! { fn []>(&mut self, url: S) -> Result<(), Error> { Self::add_unique_url(&mut self.properties.$field, url) } fn []>(&mut self, url: S) -> Result<(), Error> { Self::remove_unique_url(&mut self.properties.$field, url) } fn []>(&mut self, url: S) -> Result<(), Error> { Self::set_unique_url(&mut self.properties.$field, url) } fn [](&mut self) { Self::clear_unique_url(&mut self.properties.$field); } } }; } macro_rules! artist_multi_url_dispatch { ($field:ident) => { paste! { fn []>(&mut self, urls: Vec) -> Result<(), Error> { Self::add_multi_urls(&mut self.properties.$field, urls) } fn []>(&mut self, urls: Vec) -> Result<(), Error> { Self::remove_multi_urls(&mut self.properties.$field, urls) } fn []>(&mut self, urls: Vec) -> Result<(), Error> { Self::set_multi_urls(&mut self.properties.$field, urls) } fn [](&mut self) { Self::clear_multi_urls(&mut self.properties.$field); } } }; } impl Artist { /// Create new [`Artist`] with the given [`ArtistId`]. pub fn new>(id: ID) -> Self { Artist { id: id.into(), sort: None, properties: ArtistProperties::default(), albums: vec![], } } fn get_sort_key(&self) -> &ArtistId { self.sort.as_ref().unwrap_or(&self.id) } fn set_sort_key>(&mut self, sort: SORT) { self.sort = Some(sort.into()); } fn clear_sort_key(&mut self) { _ = self.sort.take(); } fn add_unique_url, T: for<'a> TryFrom<&'a str, Error = Error> + Eq + Display>( container: &mut Option, url: S, ) -> Result<(), Error> { let url: T = url.as_ref().try_into()?; match container { Some(current) => { if current != &url { return Err(Error::CollectionError(format!( "artist already has a different URL: {}", current ))); } } None => { _ = container.insert(url); } } Ok(()) } fn remove_unique_url, T: for<'a> TryFrom<&'a str, Error = Error> + Eq>( container: &mut Option, url: S, ) -> Result<(), Error> { let url: T = url.as_ref().try_into()?; if container == &Some(url) { _ = container.take(); } Ok(()) } fn set_unique_url, T: for<'a> TryFrom<&'a str, Error = Error>>( container: &mut Option, url: S, ) -> Result<(), Error> { _ = container.insert(url.as_ref().try_into()?); Ok(()) } fn clear_unique_url(container: &mut Option) { _ = container.take(); } fn add_multi_urls, T: for<'a> TryFrom<&'a str, Error = Error> + Eq>( container: &mut Vec, urls: Vec, ) -> Result<(), Error> { let mut new_urls = urls .iter() .map(|url| url.as_ref().try_into()) .filter(|res| { res.as_ref() .map(|url| !container.contains(url)) .unwrap_or(true) // Propagate errors. }) .collect::, Error>>()?; container.append(&mut new_urls); Ok(()) } fn remove_multi_urls, T: for<'a> TryFrom<&'a str, Error = Error> + Eq>( container: &mut Vec, urls: Vec, ) -> Result<(), Error> { let urls = urls .iter() .map(|url| url.as_ref().try_into()) .collect::, Error>>()?; container.retain(|url| !urls.contains(url)); Ok(()) } fn set_multi_urls, T: for<'a> TryFrom<&'a str, Error = Error>>( container: &mut Vec, urls: Vec, ) -> Result<(), Error> { let mut urls = urls .iter() .map(|url| url.as_ref().try_into()) .collect::, Error>>()?; container.clear(); container.append(&mut urls); Ok(()) } fn clear_multi_urls(container: &mut Vec) { container.clear(); } artist_unique_url_dispatch!(musicbrainz); artist_multi_url_dispatch!(musicbutler); artist_multi_url_dispatch!(bandcamp); artist_unique_url_dispatch!(qobuz); } impl PartialOrd for Artist { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for Artist { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.get_sort_key().cmp(other.get_sort_key()) } } impl Merge for Artist { fn merge_in_place(&mut self, other: Self) { assert_eq!(self.id, other.id); self.sort = self.sort.take().or(other.sort); self.properties.merge_in_place(other.properties); let albums = mem::take(&mut self.albums); self.albums = MergeSorted::new(albums.into_iter(), other.albums.into_iter()).collect(); } } /// The collection type. Currently, a collection is a list of artists. pub type Collection = Vec; trait Merge { fn merge_in_place(&mut self, other: Self); fn merge(mut self, other: Self) -> Self where Self: Sized, { self.merge_in_place(other); self } fn merge_vecs(this: &mut Vec, mut other: Vec) { this.append(&mut other); this.sort_unstable(); this.dedup(); } } struct MergeSorted where L: Iterator, R: Iterator, { left: Peekable, right: Peekable, } impl MergeSorted where L: Iterator, R: Iterator, { fn new(left: L, right: R) -> MergeSorted { MergeSorted { left: left.peekable(), right: right.peekable(), } } } impl Iterator for MergeSorted where L: Iterator, R: Iterator, L::Item: Ord + Merge, { type Item = L::Item; fn next(&mut self) -> Option { let which = match (self.left.peek(), self.right.peek()) { (Some(l), Some(r)) => l.cmp(r), (Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater, (None, None) => return None, }; match which { Ordering::Less => self.left.next(), Ordering::Equal => Some(self.left.next().unwrap().merge(self.right.next().unwrap())), Ordering::Greater => self.right.next(), } } } /// Error type for `musichoard`. #[derive(Debug, PartialEq, Eq)] pub enum Error { /// The [`MusicHoard`] is not able to read/write its in-memory collection. CollectionError(String), /// The [`MusicHoard`] failed to read/write from/to the library. LibraryError(String), /// The [`MusicHoard`] failed to read/write from/to the database. DatabaseError(String), /// The [`MusicHoard`] failed to parse a user-provided URL. UrlParseError(String), /// The user-provided URL is not valid. InvalidUrlError(String), } impl Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { Self::CollectionError(ref s) => write!(f, "failed to read/write the collection: {s}"), Self::LibraryError(ref s) => write!(f, "failed to read/write from/to the library: {s}"), Self::DatabaseError(ref s) => { write!(f, "failed to read/write from/to the database: {s}") } Self::UrlParseError(ref s) => write!(f, "failed to parse a user-provided URL: {s}"), Self::InvalidUrlError(ref s) => write!(f, "user-provided URL is invalid: {s}"), } } } impl From for Error { fn from(err: library::Error) -> Error { Error::LibraryError(err.to_string()) } } impl From for Error { fn from(err: database::LoadError) -> Error { Error::DatabaseError(err.to_string()) } } impl From for Error { fn from(err: database::SaveError) -> Error { Error::DatabaseError(err.to_string()) } } impl From for Error { fn from(err: url::ParseError) -> Error { Error::UrlParseError(err.to_string()) } } impl From for Error { fn from(err: uuid::Error) -> Error { Error::UrlParseError(err.to_string()) } } impl From for Error { fn from(err: InvalidUrlError) -> Error { Error::InvalidUrlError(err.to_string()) } } /// 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 { use mockall::predicate; use super::*; use database::MockIDatabase; use library::{testmod::LIBRARY_ITEMS, MockILibrary}; use 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"; static MUSICBUTLER: &str = "https://www.musicbutler.io/artist-page/483340948"; static MUSICBUTLER_2: &str = "https://www.musicbutler.io/artist-page/658903042/"; static BANDCAMP: &str = "https://thelasthangmen.bandcamp.com/"; static BANDCAMP_2: &str = "https://viciouscrusade.bandcamp.com/"; static QOBUZ: &str = "https://www.qobuz.com/nl-nl/interpreter/the-last-hangmen/1244413"; static QOBUZ_2: &str = "https://www.qobuz.com/nl-nl/interpreter/vicious-crusade/7522386"; #[test] fn musicbrainz() { let uuid = "d368baa8-21ca-4759-9731-0b2753071ad8"; let url = format!("https://musicbrainz.org/artist/{uuid}"); let mb = MusicBrainz::new(&url).unwrap(); assert_eq!(url, mb.as_ref()); assert_eq!(uuid, mb.mbid()); let url = "not a url at all".to_string(); let expected_error: Error = url::ParseError::RelativeUrlWithoutBase.into(); let actual_error = MusicBrainz::new(url).unwrap_err(); assert_eq!(actual_error, expected_error); assert_eq!(actual_error.to_string(), expected_error.to_string()); let url = "https://musicbrainz.org/artist/i-am-not-a-uuid".to_string(); let expected_error: Error = Uuid::try_parse("i-am-not-a-uuid").unwrap_err().into(); let actual_error = MusicBrainz::new(url).unwrap_err(); assert_eq!(actual_error, expected_error); assert_eq!(actual_error.to_string(), expected_error.to_string()); let url = "https://musicbrainz.org/artist".to_string(); let expected_error: Error = InvalidUrlError { url_type: UrlType::MusicBrainz, url: url.clone(), } .into(); let actual_error = MusicBrainz::new(&url).unwrap_err(); assert_eq!(actual_error, expected_error); assert_eq!(actual_error.to_string(), expected_error.to_string()); } #[test] fn urls() { assert!(MusicBrainz::new(MUSICBRAINZ).is_ok()); assert!(MusicBrainz::new(MUSICBUTLER).is_err()); assert!(MusicBrainz::new(BANDCAMP).is_err()); assert!(MusicBrainz::new(QOBUZ).is_err()); assert!(MusicButler::new(MUSICBRAINZ).is_err()); assert!(MusicButler::new(MUSICBUTLER).is_ok()); assert!(MusicButler::new(BANDCAMP).is_err()); assert!(MusicButler::new(QOBUZ).is_err()); assert!(Bandcamp::new(MUSICBRAINZ).is_err()); assert!(Bandcamp::new(MUSICBUTLER).is_err()); assert!(Bandcamp::new(BANDCAMP).is_ok()); assert!(Bandcamp::new(QOBUZ).is_err()); assert!(Qobuz::new(MUSICBRAINZ).is_err()); assert!(Qobuz::new(MUSICBUTLER).is_err()); assert!(Qobuz::new(BANDCAMP).is_err()); assert!(Qobuz::new(QOBUZ).is_ok()); } #[test] fn artist_new_delete() { let artist_id = ArtistId::new("an artist"); let artist_id_2 = ArtistId::new("another artist"); let mut music_hoard = MusicHoardBuilder::default().build(); let mut expected: Vec = vec![]; music_hoard.add_artist(artist_id.clone()); expected.push(Artist::new(artist_id.clone())); assert_eq!(music_hoard.collection, expected); music_hoard.add_artist(artist_id.clone()); assert_eq!(music_hoard.collection, expected); music_hoard.remove_artist(&artist_id_2); assert_eq!(music_hoard.collection, expected); music_hoard.remove_artist(&artist_id); _ = expected.pop(); assert_eq!(music_hoard.collection, expected); } #[test] fn artist_sort_set_clear() { let mut music_hoard = MusicHoardBuilder::default().build(); let artist_1_id = ArtistId::new("the artist"); let artist_1_sort = ArtistId::new("artist, the"); // Must be after "artist, the", but before "the artist" let artist_2_id = ArtistId::new("b-artist"); assert!(artist_1_sort < artist_2_id); assert!(artist_2_id < artist_1_id); music_hoard.add_artist(artist_1_id.clone()); music_hoard.add_artist(artist_2_id.clone()); let artist_1: &Artist = music_hoard.get_artist(&artist_1_id).unwrap(); let artist_2: &Artist = music_hoard.get_artist(&artist_2_id).unwrap(); assert!(artist_2 < artist_1); assert_eq!(artist_1, &music_hoard.collection[1]); assert_eq!(artist_2, &music_hoard.collection[0]); music_hoard .set_artist_sort(artist_1_id.as_ref(), artist_1_sort.clone()) .unwrap(); let artist_1: &Artist = music_hoard.get_artist(&artist_1_id).unwrap(); let artist_2: &Artist = music_hoard.get_artist(&artist_2_id).unwrap(); assert!(artist_1 < artist_2); assert_eq!(artist_1, &music_hoard.collection[0]); assert_eq!(artist_2, &music_hoard.collection[1]); music_hoard.clear_artist_sort(artist_1_id.as_ref()).unwrap(); let artist_1: &Artist = music_hoard.get_artist(&artist_1_id).unwrap(); let artist_2: &Artist = music_hoard.get_artist(&artist_2_id).unwrap(); assert!(artist_2 < artist_1); assert_eq!(artist_1, &music_hoard.collection[1]); assert_eq!(artist_2, &music_hoard.collection[0]); } #[test] fn collection_error() { let artist_id = ArtistId::new("an artist"); let mut music_hoard = MusicHoardBuilder::default().build(); let actual_err = music_hoard .add_musicbrainz_url(&artist_id, QOBUZ) .unwrap_err(); let expected_err = Error::CollectionError(String::from("artist 'an artist' is not in the collection")); assert_eq!(actual_err, expected_err); assert_eq!(actual_err.to_string(), expected_err.to_string()); } #[test] fn add_remove_musicbrainz_url() { let artist_id = ArtistId::new("an artist"); let artist_id_2 = ArtistId::new("another artist"); let mut music_hoard = MusicHoardBuilder::default().build(); music_hoard.add_artist(artist_id.clone()); let mut expected: Option = None; assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); // Adding incorect URL is an error. assert!(music_hoard .add_musicbrainz_url(&artist_id, MUSICBUTLER) .is_err()); assert!(music_hoard .add_musicbrainz_url(&artist_id, BANDCAMP) .is_err()); assert!(music_hoard.add_musicbrainz_url(&artist_id, QOBUZ).is_err()); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); // Adding URL to an artist not in the collection is an error. assert!(music_hoard .add_musicbrainz_url(&artist_id_2, MUSICBRAINZ) .is_err()); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); // Adding URL to artist. assert!(music_hoard .add_musicbrainz_url(&artist_id, MUSICBRAINZ) .is_ok()); _ = expected.insert(MusicBrainz::new(MUSICBRAINZ).unwrap()); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); // Adding the same URL again is ok, but does not do anything. assert!(music_hoard .add_musicbrainz_url(&artist_id, MUSICBRAINZ) .is_ok()); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); // Adding further URLs is an error. assert!(music_hoard .add_musicbrainz_url(&artist_id, MUSICBRAINZ_2) .is_err()); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); // Removing a URL from an artist not in the collection is an error. assert!(music_hoard .remove_musicbrainz_url(&artist_id_2, MUSICBRAINZ) .is_err()); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); // Removing a URL not in the collection is okay, but does not do anything. assert!(music_hoard .remove_musicbrainz_url(&artist_id, MUSICBRAINZ_2) .is_ok()); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); // Removing a URL in the collection removes it. assert!(music_hoard .remove_musicbrainz_url(&artist_id, MUSICBRAINZ) .is_ok()); _ = expected.take(); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); assert!(music_hoard .remove_musicbrainz_url(&artist_id, MUSICBRAINZ) .is_ok()); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); } #[test] fn set_clear_musicbrainz_url() { let artist_id = ArtistId::new("an artist"); let artist_id_2 = ArtistId::new("another artist"); let mut music_hoard = MusicHoardBuilder::default().build(); music_hoard.add_artist(artist_id.clone()); let mut expected: Option = None; assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); // Setting an incorrect URL is an error. assert!(music_hoard .set_musicbrainz_url(&artist_id, MUSICBUTLER) .is_err()); assert!(music_hoard .set_musicbrainz_url(&artist_id, BANDCAMP) .is_err()); assert!(music_hoard.set_musicbrainz_url(&artist_id, QOBUZ).is_err()); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); // Setting a URL on an artist not in the collection is an error. assert!(music_hoard .set_musicbrainz_url(&artist_id_2, MUSICBRAINZ) .is_err()); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); // Setting a URL on an artist. assert!(music_hoard .set_musicbrainz_url(&artist_id, MUSICBRAINZ) .is_ok()); _ = expected.insert(MusicBrainz::new(MUSICBRAINZ).unwrap()); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); assert!(music_hoard .set_musicbrainz_url(&artist_id, MUSICBRAINZ) .is_ok()); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); assert!(music_hoard .set_musicbrainz_url(&artist_id, MUSICBRAINZ_2) .is_ok()); _ = expected.insert(MusicBrainz::new(MUSICBRAINZ_2).unwrap()); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); // Clearing URLs on an artist that does not exist is an error. assert!(music_hoard.clear_musicbrainz_url(&artist_id_2).is_err()); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); // Clearing URLs. assert!(music_hoard.clear_musicbrainz_url(&artist_id).is_ok()); _ = expected.take(); assert_eq!(music_hoard.collection[0].properties.musicbrainz, expected); } #[test] fn add_remove_musicbutler_urls() { let artist_id = ArtistId::new("an artist"); let artist_id_2 = ArtistId::new("another artist"); let mut music_hoard = MusicHoardBuilder::default().build(); music_hoard.add_artist(artist_id.clone()); let mut expected: Vec = vec![]; assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // If any URL is incorrect adding URLs is an error. assert!(music_hoard .add_musicbutler_urls(&artist_id, vec![MUSICBRAINZ, MUSICBRAINZ_2]) .is_err()); assert!(music_hoard .add_musicbutler_urls(&artist_id, vec![BANDCAMP, BANDCAMP_2]) .is_err()); assert!(music_hoard .add_musicbutler_urls(&artist_id, vec![QOBUZ, QOBUZ_2]) .is_err()); assert!(music_hoard .add_musicbutler_urls(&artist_id, vec![MUSICBRAINZ, MUSICBUTLER, BANDCAMP, QOBUZ]) .is_err()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // Adding URLs to an artist not in the collection is an error. assert!(music_hoard .add_musicbutler_urls(&artist_id_2, vec![MUSICBUTLER]) .is_err()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // Adding a single URL. assert!(music_hoard .add_musicbutler_urls(&artist_id, vec![MUSICBUTLER]) .is_ok()); expected.push(MusicButler::new(MUSICBUTLER).unwrap()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // Adding a URL that already exists is ok, but does not do anything. assert!(music_hoard .add_musicbutler_urls(&artist_id, vec![MUSICBUTLER]) .is_ok()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // Adding another single URL. assert!(music_hoard .add_musicbutler_urls(&artist_id, vec![MUSICBUTLER_2]) .is_ok()); expected.push(MusicButler::new(MUSICBUTLER_2).unwrap()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); assert!(music_hoard .add_musicbutler_urls(&artist_id, vec![MUSICBUTLER_2]) .is_ok()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // Removing URLs from an artist not in the collection is an error. assert!(music_hoard .remove_musicbutler_urls(&artist_id_2, vec![MUSICBUTLER]) .is_err()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // Removing a URL. assert!(music_hoard .remove_musicbutler_urls(&artist_id, vec![MUSICBUTLER]) .is_ok()); expected.retain(|url| url.as_str() != MUSICBUTLER); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // Removing URls that do not exist is okay, they will be ignored. assert!(music_hoard .remove_musicbutler_urls(&artist_id, vec![MUSICBUTLER]) .is_ok()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // Removing a URL. assert!(music_hoard .remove_musicbutler_urls(&artist_id, vec![MUSICBUTLER_2]) .is_ok()); expected.retain(|url| url.as_str() != MUSICBUTLER_2); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); assert!(music_hoard .remove_musicbutler_urls(&artist_id, vec![MUSICBUTLER_2]) .is_ok()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // Adding URLs if some exist is okay, they will be ignored. assert!(music_hoard .add_musicbutler_urls(&artist_id, vec![MUSICBUTLER]) .is_ok()); expected.push(MusicButler::new(MUSICBUTLER).unwrap()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); assert!(music_hoard .add_musicbutler_urls(&artist_id, vec![MUSICBUTLER, MUSICBUTLER_2]) .is_ok()); expected.push(MusicButler::new(MUSICBUTLER_2).unwrap()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // Removing URLs if some do not exist is okay, they will be ignored. assert!(music_hoard .remove_musicbutler_urls(&artist_id, vec![MUSICBUTLER]) .is_ok()); expected.retain(|url| url.as_str() != MUSICBUTLER); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); assert!(music_hoard .remove_musicbutler_urls(&artist_id, vec![MUSICBUTLER, MUSICBUTLER_2]) .is_ok()); expected.retain(|url| url.as_str() != MUSICBUTLER_2); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // Adding mutliple URLs without clashes. assert!(music_hoard .add_musicbutler_urls(&artist_id, vec![MUSICBUTLER, MUSICBUTLER_2]) .is_ok()); expected.push(MusicButler::new(MUSICBUTLER).unwrap()); expected.push(MusicButler::new(MUSICBUTLER_2).unwrap()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // Removing multiple URLs without clashes. assert!(music_hoard .remove_musicbutler_urls(&artist_id, vec![MUSICBUTLER, MUSICBUTLER_2]) .is_ok()); expected.clear(); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); } #[test] fn set_clear_musicbutler_urls() { let artist_id = ArtistId::new("an artist"); let artist_id_2 = ArtistId::new("another artist"); let mut music_hoard = MusicHoardBuilder::default().build(); music_hoard.add_artist(artist_id.clone()); let mut expected: Vec = vec![]; assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // If any URL is incorrect setting URLs is an error. assert!(music_hoard .set_musicbutler_urls(&artist_id, vec![MUSICBRAINZ, MUSICBRAINZ_2]) .is_err()); assert!(music_hoard .set_musicbutler_urls(&artist_id, vec![BANDCAMP, BANDCAMP_2]) .is_err()); assert!(music_hoard .set_musicbutler_urls(&artist_id, vec![QOBUZ, QOBUZ_2]) .is_err()); assert!(music_hoard .set_musicbutler_urls(&artist_id, vec![MUSICBRAINZ, MUSICBUTLER, BANDCAMP, QOBUZ]) .is_err()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // Seting URL on an artist not in the collection is an error. assert!(music_hoard .set_musicbutler_urls(&artist_id_2, vec![MUSICBUTLER]) .is_err()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // Set URLs. assert!(music_hoard .set_musicbutler_urls(&artist_id, vec![MUSICBUTLER]) .is_ok()); expected.push(MusicButler::new(MUSICBUTLER).unwrap()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); assert!(music_hoard .set_musicbutler_urls(&artist_id, vec![MUSICBUTLER_2]) .is_ok()); expected.clear(); expected.push(MusicButler::new(MUSICBUTLER_2).unwrap()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); assert!(music_hoard .set_musicbutler_urls(&artist_id, vec![MUSICBUTLER, MUSICBUTLER_2]) .is_ok()); expected.clear(); expected.push(MusicButler::new(MUSICBUTLER).unwrap()); expected.push(MusicButler::new(MUSICBUTLER_2).unwrap()); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); // Clearing URLs on an artist that does not exist is an error. assert!(music_hoard.clear_musicbutler_urls(&artist_id_2).is_err()); // Clear URLs. assert!(music_hoard.clear_musicbutler_urls(&artist_id).is_ok()); expected.clear(); assert_eq!(music_hoard.collection[0].properties.musicbutler, expected); } #[test] fn add_remove_bandcamp_urls() { let artist_id = ArtistId::new("an artist"); let artist_id_2 = ArtistId::new("another artist"); let mut music_hoard = MusicHoardBuilder::default().build(); music_hoard.add_artist(artist_id.clone()); let mut expected: Vec = vec![]; assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // If any URL is incorrect adding URLs is an error. assert!(music_hoard .add_bandcamp_urls(&artist_id, vec![MUSICBRAINZ, MUSICBRAINZ_2]) .is_err()); assert!(music_hoard .add_bandcamp_urls(&artist_id, vec![MUSICBUTLER, MUSICBUTLER_2]) .is_err()); assert!(music_hoard .add_bandcamp_urls(&artist_id, vec![QOBUZ, QOBUZ_2]) .is_err()); assert!(music_hoard .add_bandcamp_urls(&artist_id, vec![MUSICBRAINZ, MUSICBUTLER, BANDCAMP, QOBUZ]) .is_err()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // Adding URLs to an artist not in the collection is an error. assert!(music_hoard .add_bandcamp_urls(&artist_id_2, vec![BANDCAMP]) .is_err()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // Adding a single URL. assert!(music_hoard .add_bandcamp_urls(&artist_id, vec![BANDCAMP]) .is_ok()); expected.push(Bandcamp::new(BANDCAMP).unwrap()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // Adding a URL that already exists is ok, but does not do anything. assert!(music_hoard .add_bandcamp_urls(&artist_id, vec![BANDCAMP]) .is_ok()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // Adding another single URL. assert!(music_hoard .add_bandcamp_urls(&artist_id, vec![BANDCAMP_2]) .is_ok()); expected.push(Bandcamp::new(BANDCAMP_2).unwrap()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); assert!(music_hoard .add_bandcamp_urls(&artist_id, vec![BANDCAMP_2]) .is_ok()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // Removing URLs from an artist not in the collection is an error. assert!(music_hoard .remove_bandcamp_urls(&artist_id_2, vec![BANDCAMP]) .is_err()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // Removing a URL. assert!(music_hoard .remove_bandcamp_urls(&artist_id, vec![BANDCAMP]) .is_ok()); expected.retain(|url| url.as_str() != BANDCAMP); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // Removing URls that do not exist is okay, they will be ignored. assert!(music_hoard .remove_bandcamp_urls(&artist_id, vec![BANDCAMP]) .is_ok()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // Removing a URL. assert!(music_hoard .remove_bandcamp_urls(&artist_id, vec![BANDCAMP_2]) .is_ok()); expected.retain(|url| url.as_str() != BANDCAMP_2); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); assert!(music_hoard .remove_bandcamp_urls(&artist_id, vec![BANDCAMP_2]) .is_ok()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // Adding URLs if some exist is okay, they will be ignored. assert!(music_hoard .add_bandcamp_urls(&artist_id, vec![BANDCAMP]) .is_ok()); expected.push(Bandcamp::new(BANDCAMP).unwrap()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); assert!(music_hoard .add_bandcamp_urls(&artist_id, vec![BANDCAMP, BANDCAMP_2]) .is_ok()); expected.push(Bandcamp::new(BANDCAMP_2).unwrap()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // Removing URLs if some do not exist is okay, they will be ignored. assert!(music_hoard .remove_bandcamp_urls(&artist_id, vec![BANDCAMP]) .is_ok()); expected.retain(|url| url.as_str() != BANDCAMP); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); assert!(music_hoard .remove_bandcamp_urls(&artist_id, vec![BANDCAMP, BANDCAMP_2]) .is_ok()); expected.retain(|url| url.as_str() != BANDCAMP_2); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // Adding mutliple URLs without clashes. assert!(music_hoard .add_bandcamp_urls(&artist_id, vec![BANDCAMP, BANDCAMP_2]) .is_ok()); expected.push(Bandcamp::new(BANDCAMP).unwrap()); expected.push(Bandcamp::new(BANDCAMP_2).unwrap()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // Removing multiple URLs without clashes. assert!(music_hoard .remove_bandcamp_urls(&artist_id, vec![BANDCAMP, BANDCAMP_2]) .is_ok()); expected.clear(); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); } #[test] fn set_clear_bandcamp_urls() { let artist_id = ArtistId::new("an artist"); let artist_id_2 = ArtistId::new("another artist"); let mut music_hoard = MusicHoardBuilder::default().build(); music_hoard.add_artist(artist_id.clone()); let mut expected: Vec = vec![]; assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // If any URL is incorrect setting URLs is an error. assert!(music_hoard .set_bandcamp_urls(&artist_id, vec![MUSICBRAINZ, MUSICBRAINZ_2]) .is_err()); assert!(music_hoard .set_bandcamp_urls(&artist_id, vec![MUSICBUTLER, MUSICBUTLER_2]) .is_err()); assert!(music_hoard .set_bandcamp_urls(&artist_id, vec![QOBUZ, QOBUZ_2]) .is_err()); assert!(music_hoard .set_bandcamp_urls(&artist_id, vec![MUSICBRAINZ, MUSICBUTLER, BANDCAMP, QOBUZ]) .is_err()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // Seting URL on an artist not in the collection is an error. assert!(music_hoard .set_bandcamp_urls(&artist_id_2, vec![BANDCAMP]) .is_err()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // Set URLs. assert!(music_hoard .set_bandcamp_urls(&artist_id, vec![BANDCAMP]) .is_ok()); expected.push(Bandcamp::new(BANDCAMP).unwrap()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); assert!(music_hoard .set_bandcamp_urls(&artist_id, vec![BANDCAMP_2]) .is_ok()); expected.clear(); expected.push(Bandcamp::new(BANDCAMP_2).unwrap()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); assert!(music_hoard .set_bandcamp_urls(&artist_id, vec![BANDCAMP, BANDCAMP_2]) .is_ok()); expected.clear(); expected.push(Bandcamp::new(BANDCAMP).unwrap()); expected.push(Bandcamp::new(BANDCAMP_2).unwrap()); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); // Clearing URLs on an artist that does not exist is an error. assert!(music_hoard.clear_bandcamp_urls(&artist_id_2).is_err()); // Clear URLs. assert!(music_hoard.clear_bandcamp_urls(&artist_id).is_ok()); expected.clear(); assert_eq!(music_hoard.collection[0].properties.bandcamp, expected); } #[test] fn add_remove_qobuz_url() { let artist_id = ArtistId::new("an artist"); let artist_id_2 = ArtistId::new("another artist"); let mut music_hoard = MusicHoardBuilder::default().build(); music_hoard.add_artist(artist_id.clone()); let mut expected: Option = None; assert_eq!(music_hoard.collection[0].properties.qobuz, expected); // Adding incorect URL is an error. assert!(music_hoard.add_qobuz_url(&artist_id, MUSICBRAINZ).is_err()); assert!(music_hoard.add_qobuz_url(&artist_id, MUSICBUTLER).is_err()); assert!(music_hoard.add_qobuz_url(&artist_id, BANDCAMP).is_err()); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); // Adding URL to an artist not in the collection is an error. assert!(music_hoard.add_qobuz_url(&artist_id_2, QOBUZ).is_err()); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); // Adding URL to artist. assert!(music_hoard.add_qobuz_url(&artist_id, QOBUZ).is_ok()); _ = expected.insert(Qobuz::new(QOBUZ).unwrap()); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); // Adding the same URL again is ok, but does not do anything. assert!(music_hoard.add_qobuz_url(&artist_id, QOBUZ).is_ok()); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); // Adding further URLs is an error. assert!(music_hoard.add_qobuz_url(&artist_id, QOBUZ_2).is_err()); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); // Removing a URL from an artist not in the collection is an error. assert!(music_hoard.remove_qobuz_url(&artist_id_2, QOBUZ).is_err()); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); // Removing a URL not in the collection is okay, but does not do anything. assert!(music_hoard.remove_qobuz_url(&artist_id, QOBUZ_2).is_ok()); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); // Removing a URL in the collection removes it. assert!(music_hoard.remove_qobuz_url(&artist_id, QOBUZ).is_ok()); _ = expected.take(); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); assert!(music_hoard.remove_qobuz_url(&artist_id, QOBUZ).is_ok()); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); } #[test] fn set_clear_qobuz_url() { let artist_id = ArtistId::new("an artist"); let artist_id_2 = ArtistId::new("another artist"); let mut music_hoard = MusicHoardBuilder::default().build(); music_hoard.add_artist(artist_id.clone()); let mut expected: Option = None; assert_eq!(music_hoard.collection[0].properties.qobuz, expected); // Setting an incorrect URL is an error. assert!(music_hoard.set_qobuz_url(&artist_id, MUSICBUTLER).is_err()); assert!(music_hoard.set_qobuz_url(&artist_id, BANDCAMP).is_err()); assert!(music_hoard.set_qobuz_url(&artist_id, MUSICBRAINZ).is_err()); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); // Setting a URL on an artist not in the collection is an error. assert!(music_hoard.set_qobuz_url(&artist_id_2, QOBUZ).is_err()); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); // Setting a URL on an artist. assert!(music_hoard.set_qobuz_url(&artist_id, QOBUZ).is_ok()); _ = expected.insert(Qobuz::new(QOBUZ).unwrap()); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); assert!(music_hoard.set_qobuz_url(&artist_id, QOBUZ).is_ok()); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); assert!(music_hoard.set_qobuz_url(&artist_id, QOBUZ_2).is_ok()); _ = expected.insert(Qobuz::new(QOBUZ_2).unwrap()); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); // Clearing URLs on an artist that does not exist is an error. assert!(music_hoard.clear_qobuz_url(&artist_id_2).is_err()); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); // Clearing URLs. assert!(music_hoard.clear_qobuz_url(&artist_id).is_ok()); _ = expected.take(); assert_eq!(music_hoard.collection[0].properties.qobuz, expected); } #[test] fn merge_track() { let left = Track { id: TrackId { number: 4, title: String::from("a title"), }, artist: vec![String::from("left artist")], quality: Quality { format: Format::Flac, bitrate: 1411, }, }; let right = Track { id: left.id.clone(), artist: vec![String::from("right artist")], quality: Quality { format: Format::Mp3, bitrate: 320, }, }; let merged = left.clone().merge(right); assert_eq!(left, merged); } #[test] fn merge_album_no_overlap() { let left = FULL_COLLECTION[0].albums[0].to_owned(); let mut right = FULL_COLLECTION[0].albums[1].to_owned(); right.id = left.id.clone(); let mut expected = left.clone(); expected.tracks.append(&mut right.tracks.clone()); expected.tracks.sort_unstable(); let merged = left.clone().merge(right.clone()); assert_eq!(expected, merged); // Non-overlapping merge should be commutative. let merged = right.clone().merge(left.clone()); assert_eq!(expected, merged); } #[test] fn merge_album_overlap() { let mut left = FULL_COLLECTION[0].albums[0].to_owned(); let mut right = FULL_COLLECTION[0].albums[1].to_owned(); right.id = left.id.clone(); left.tracks.push(right.tracks[0].clone()); left.tracks.sort_unstable(); let mut expected = left.clone(); expected.tracks.append(&mut right.tracks.clone()); expected.tracks.sort_unstable(); expected.tracks.dedup(); let merged = left.clone().merge(right); assert_eq!(expected, merged); } #[test] fn merge_artist_no_overlap() { let left = FULL_COLLECTION[0].to_owned(); let mut right = FULL_COLLECTION[1].to_owned(); right.id = left.id.clone(); right.properties = ArtistProperties::default(); let mut expected = left.clone(); expected.properties = expected.properties.merge(right.clone().properties); expected.albums.append(&mut right.albums.clone()); expected.albums.sort_unstable(); let merged = left.clone().merge(right.clone()); assert_eq!(expected, merged); // Non-overlapping merge should be commutative. let merged = right.clone().merge(left.clone()); assert_eq!(expected, merged); } #[test] fn merge_artist_overlap() { let mut left = FULL_COLLECTION[0].to_owned(); let mut right = FULL_COLLECTION[1].to_owned(); right.id = left.id.clone(); left.albums.push(right.albums[0].clone()); left.albums.sort_unstable(); let mut expected = left.clone(); expected.properties = expected.properties.merge(right.clone().properties); expected.albums.append(&mut right.albums.clone()); expected.albums.sort_unstable(); expected.albums.dedup(); let merged = left.clone().merge(right); assert_eq!(expected, merged); } #[test] fn merge_collection_no_overlap() { let half: usize = FULL_COLLECTION.len() / 2; let left = FULL_COLLECTION[..half].to_owned(); let right = FULL_COLLECTION[half..].to_owned(); let mut expected = FULL_COLLECTION.to_owned(); expected.sort_unstable(); let merged = MusicHoard::::merge_collections( left.clone() .into_iter() .map(|a| (a.id.clone(), a)) .collect(), right.clone(), ); assert_eq!(expected, merged); // The merge is completely non-overlapping so it should be commutative. let merged = MusicHoard::::merge_collections( right .clone() .into_iter() .map(|a| (a.id.clone(), a)) .collect(), left.clone(), ); assert_eq!(expected, merged); } #[test] fn merge_collection_overlap() { let half: usize = FULL_COLLECTION.len() / 2; let left = FULL_COLLECTION[..(half + 1)].to_owned(); let right = FULL_COLLECTION[half..].to_owned(); let mut expected = FULL_COLLECTION.to_owned(); expected.sort_unstable(); let merged = MusicHoard::::merge_collections( left.clone() .into_iter() .map(|a| (a.id.clone(), a)) .collect(), right.clone(), ); assert_eq!(expected, merged); // The merge does not overwrite any data so it should be commutative. let merged = MusicHoard::::merge_collections( right .clone() .into_iter() .map(|a| (a.id.clone(), a)) .collect(), left.clone(), ); assert_eq!(expected, merged); } #[test] fn merge_collection_incompatible_sorting() { // It may be that the same artist in one collection has a "sort" field defined while the // same artist in the other collection does not. This means that the two collections are not // sorted consistently. If the merge assumes they are sorted consistently this will lead to // the same artist appearing twice in the final list. This should not be the case. // We will mimic this situation by taking the last artist from FULL_COLLECTION and giving it a // sorting name that would place it in the beginning. let left = FULL_COLLECTION.to_owned(); let mut right: Vec = vec![left.last().unwrap().clone()]; assert!(right.first().unwrap() > left.first().unwrap()); let artist_sort = Some(ArtistId::new("album_artist 0")); right[0].sort = artist_sort.clone(); assert!(right.first().unwrap() < left.first().unwrap()); // The result of the merge should be the same list of artists, but with the last artist now // in first place. let mut expected = left.to_owned(); expected.last_mut().as_mut().unwrap().sort = artist_sort.clone(); expected.rotate_right(1); let merged = MusicHoard::::merge_collections( left.clone() .into_iter() .map(|a| (a.id.clone(), a)) .collect(), right.clone(), ); assert_eq!(expected.len(), merged.len()); assert_eq!(expected, merged); // The merge overwrites the sort data, but no data is erased so it should be commutative. let merged = MusicHoard::::merge_collections( right .clone() .into_iter() .map(|a| (a.id.clone(), a)) .collect(), left.clone(), ); assert_eq!(expected.len(), merged.len()); assert_eq!(expected, merged); } #[test] fn rescan_library_ordered() { let mut library = MockILibrary::new(); let database = MockIDatabase::new(); let library_input = Query::new(); let library_result = Ok(LIBRARY_ITEMS.to_owned()); library .expect_list() .with(predicate::eq(library_input)) .times(1) .return_once(|_| library_result); let mut music_hoard = MusicHoardBuilder::default() .set_library(library) .set_database(database) .build(); music_hoard.rescan_library().unwrap(); assert_eq!(music_hoard.get_collection(), &*LIBRARY_COLLECTION); } #[test] fn rescan_library_unordered() { let mut library = MockILibrary::new(); let database = MockIDatabase::new(); let library_input = Query::new(); let mut library_result = Ok(LIBRARY_ITEMS.to_owned()); // Swap the last item with the first. let last = library_result.as_ref().unwrap().len() - 1; library_result.as_mut().unwrap().swap(0, last); library .expect_list() .with(predicate::eq(library_input)) .times(1) .return_once(|_| library_result); let mut music_hoard = MusicHoardBuilder::default() .set_library(library) .set_database(database) .build(); music_hoard.rescan_library().unwrap(); assert_eq!(music_hoard.get_collection(), &*LIBRARY_COLLECTION); } #[test] fn rescan_library_album_title_year_clash() { let mut library = MockILibrary::new(); let database = MockIDatabase::new(); let mut expected = LIBRARY_COLLECTION.to_owned(); let removed_album_id = expected[0].albums[0].id.clone(); let clashed_album_id = &expected[1].albums[0].id; let mut items = LIBRARY_ITEMS.to_owned(); for item in items.iter_mut().filter(|it| { (it.album_year == removed_album_id.year) && (it.album_title == removed_album_id.title) }) { item.album_year = clashed_album_id.year; item.album_title = clashed_album_id.title.clone(); } expected[0].albums[0].id = clashed_album_id.clone(); let library_input = Query::new(); let library_result = Ok(items); library .expect_list() .with(predicate::eq(library_input)) .times(1) .return_once(|_| library_result); let mut music_hoard = MusicHoardBuilder::default() .set_library(library) .set_database(database) .build(); music_hoard.rescan_library().unwrap(); assert_eq!(music_hoard.get_collection(), &expected); } #[test] fn rescan_library_album_artist_sort_clash() { let mut library = MockILibrary::new(); let database = MockIDatabase::new(); let library_input = Query::new(); let mut library_items = LIBRARY_ITEMS.to_owned(); assert_eq!(library_items[0].album_artist, library_items[1].album_artist); library_items[0].album_artist_sort = Some(library_items[0].album_artist.clone()); library_items[1].album_artist_sort = Some( library_items[1] .album_artist .clone() .chars() .rev() .collect(), ); let library_result = Ok(library_items); library .expect_list() .with(predicate::eq(library_input)) .times(1) .return_once(|_| library_result); let mut music_hoard = MusicHoardBuilder::default() .set_library(library) .set_database(database) .build(); assert!(music_hoard.rescan_library().is_err()); } #[test] fn load_database() { let library = MockILibrary::new(); let mut database = MockIDatabase::new(); database .expect_load() .times(1) .return_once(|| Ok(FULL_COLLECTION.to_owned())); let mut music_hoard = MusicHoardBuilder::default() .set_library(library) .set_database(database) .build(); music_hoard.load_from_database().unwrap(); assert_eq!(music_hoard.get_collection(), &*FULL_COLLECTION); } #[test] fn rescan_get_save() { let mut library = MockILibrary::new(); let mut database = MockIDatabase::new(); let library_input = Query::new(); let library_result = Ok(LIBRARY_ITEMS.to_owned()); let database_input = LIBRARY_COLLECTION.to_owned(); let database_result = Ok(()); library .expect_list() .with(predicate::eq(library_input)) .times(1) .return_once(|_| library_result); database .expect_save() .with(predicate::eq(database_input)) .times(1) .return_once(|_: &Collection| database_result); let mut music_hoard = MusicHoardBuilder::default() .set_library(library) .set_database(database) .build(); music_hoard.rescan_library().unwrap(); assert_eq!(music_hoard.get_collection(), &*LIBRARY_COLLECTION); music_hoard.save_to_database().unwrap(); } #[test] fn library_error() { let mut library = MockILibrary::new(); let database = MockIDatabase::new(); let library_result = Err(library::Error::Invalid(String::from("invalid data"))); library .expect_list() .times(1) .return_once(|_| library_result); let mut music_hoard = MusicHoardBuilder::default() .set_library(library) .set_database(database) .build(); let actual_err = music_hoard.rescan_library().unwrap_err(); let expected_err = Error::LibraryError(library::Error::Invalid(String::from("invalid data")).to_string()); assert_eq!(actual_err, expected_err); assert_eq!(actual_err.to_string(), expected_err.to_string()); } #[test] fn database_load_error() { let library = MockILibrary::new(); let mut database = MockIDatabase::new(); let database_result = Err(database::LoadError::IoError(String::from("I/O error"))); database .expect_load() .times(1) .return_once(|| database_result); let mut music_hoard = MusicHoardBuilder::default() .set_library(library) .set_database(database) .build(); let actual_err = music_hoard.load_from_database().unwrap_err(); let expected_err = Error::DatabaseError( database::LoadError::IoError(String::from("I/O error")).to_string(), ); assert_eq!(actual_err, expected_err); assert_eq!(actual_err.to_string(), expected_err.to_string()); } #[test] fn database_save_error() { let library = MockILibrary::new(); let mut database = MockIDatabase::new(); let database_result = Err(database::SaveError::IoError(String::from("I/O error"))); database .expect_save() .times(1) .return_once(|_: &Collection| database_result); let mut music_hoard = MusicHoardBuilder::default() .set_library(library) .set_database(database) .build(); let actual_err = music_hoard.save_to_database().unwrap_err(); let expected_err = Error::DatabaseError( database::SaveError::IoError(String::from("I/O error")).to_string(), ); assert_eq!(actual_err, expected_err); assert_eq!(actual_err.to_string(), expected_err.to_string()); } }