diff --git a/src/bin/musichoard-edit.rs b/src/bin/musichoard-edit.rs index e534e28..636c8a8 100644 --- a/src/bin/musichoard-edit.rs +++ b/src/bin/musichoard-edit.rs @@ -1,10 +1,12 @@ -use paste::paste; use std::path::PathBuf; + +use paste::paste; use structopt::{clap::AppSettings, StructOpt}; use musichoard::{ + collection::artist::ArtistId, database::json::{backend::JsonDatabaseFileBackend, JsonDatabase}, - ArtistId, MusicHoard, MusicHoardBuilder, NoLibrary, + MusicHoard, MusicHoardBuilder, NoLibrary, }; type MH = MusicHoard>; diff --git a/src/core/collection/album.rs b/src/core/collection/album.rs new file mode 100644 index 0000000..5eb412e --- /dev/null +++ b/src/core/collection/album.rs @@ -0,0 +1,84 @@ +use std::mem; + +use serde::{Deserialize, Serialize}; + +use crate::core::collection::{ + merge::{Merge, MergeSorted}, + track::Track, +}; + +/// 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, +} + +/// The album identifier. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq, Hash)] +pub struct AlbumId { + pub year: u32, + pub title: String, +} + +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(); + } +} + +#[cfg(test)] +mod tests { + use crate::core::testmod::FULL_COLLECTION; + + use super::*; + + #[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); + } +} diff --git a/src/core/collection/artist.rs b/src/core/collection/artist.rs new file mode 100644 index 0000000..bef3684 --- /dev/null +++ b/src/core/collection/artist.rs @@ -0,0 +1,1014 @@ +use std::{ + fmt::{self, Debug, Display}, + mem, +}; + +use paste::paste; +use serde::{Deserialize, Serialize}; +use url::Url; +use uuid::Uuid; + +use crate::core::collection::{ + album::Album, + merge::{Merge, MergeSorted}, + Error, +}; + +/// An artist. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct Artist { + pub id: ArtistId, + pub sort: Option, + pub properties: ArtistProperties, + pub albums: Vec, +} + +/// The artist identifier. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ArtistId { + pub name: String, +} + +/// 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, +} + +macro_rules! artist_unique_url_dispatch { + ($field:ident) => { + paste! { + pub fn []>(&mut self, url: S) -> Result<(), Error> { + Self::add_unique_url(&mut self.properties.$field, url) + } + + pub fn []>(&mut self, url: S) -> Result<(), Error> { + Self::remove_unique_url(&mut self.properties.$field, url) + } + + pub fn []>(&mut self, url: S) -> Result<(), Error> { + Self::set_unique_url(&mut self.properties.$field, url) + } + + pub fn [](&mut self) { + Self::clear_unique_url(&mut self.properties.$field); + } + } + }; +} + +macro_rules! artist_multi_url_dispatch { + ($field:ident) => { + paste! { + pub fn []>( + &mut self, + urls: Vec, + ) -> Result<(), Error> { + Self::add_multi_urls(&mut self.properties.$field, urls) + } + + pub fn []>( + &mut self, + urls: Vec, + ) -> Result<(), Error> { + Self::remove_multi_urls(&mut self.properties.$field, urls) + } + + pub fn []>( + &mut self, + urls: Vec, + ) -> Result<(), Error> { + Self::set_multi_urls(&mut self.properties.$field, urls) + } + + pub 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) + } + + pub fn set_sort_key>(&mut self, sort: SORT) { + self.sort = Some(sort.into()); + } + + pub 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::UrlError(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(); + } +} + +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) + } +} + +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 object with the [`IMbid`] trait contains a [MusicBrainz +/// Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) (MBID). +pub trait IMbid { + fn mbid(&self) -> &str; +} + +/// 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)); + } + + 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)), + }; + + Ok(MusicBrainz(url)) + } + + fn invalid_url_error(url: U) -> Error { + Error::UrlError(format!("invalid MusicBrainz URL: {url}")) + } +} + +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)); + } + + Ok(MusicButler(url)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + fn invalid_url_error(url: U) -> Error { + Error::UrlError(format!("invalid MusicButler URL: {url}")) + } +} + +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)); + } + + Ok(Bandcamp(url)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + fn invalid_url_error(url: U) -> Error { + Error::UrlError(format!("invalid Bandcamp URL: {url}")) + } +} + +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)); + } + + Ok(Qobuz(url)) + } + + fn invalid_url_error(url: U) -> Error { + Error::UrlError(format!("invalid Qobuz URL: {url}")) + } +} + +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) + } +} + +#[cfg(test)] +mod tests { + use crate::core::testmod::FULL_COLLECTION; + + use super::*; + + 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::UrlError(format!("invalid MusicBrainz URL: {url}")); + 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_sort_set_clear() { + let artist_id = ArtistId::new("an artist"); + let sort_id_1 = ArtistId::new("sort id 1"); + let sort_id_2 = ArtistId::new("sort id 2"); + + let mut artist = Artist::new(artist_id.clone()); + + assert_eq!(artist.id, artist_id); + assert_eq!(artist.sort, None); + assert_eq!(artist.get_sort_key(), &artist_id); + assert!(artist < Artist::new(sort_id_1.clone())); + assert!(artist < Artist::new(sort_id_2.clone())); + + artist.set_sort_key(sort_id_1.clone()); + + assert_eq!(artist.id, artist_id); + assert_eq!(artist.sort.as_ref(), Some(&sort_id_1)); + assert_eq!(artist.get_sort_key(), &sort_id_1); + assert!(artist > Artist::new(artist_id.clone())); + assert!(artist < Artist::new(sort_id_2.clone())); + + artist.set_sort_key(sort_id_2.clone()); + + assert_eq!(artist.id, artist_id); + assert_eq!(artist.sort.as_ref(), Some(&sort_id_2)); + assert_eq!(artist.get_sort_key(), &sort_id_2); + assert!(artist > Artist::new(artist_id.clone())); + assert!(artist > Artist::new(sort_id_1.clone())); + + artist.clear_sort_key(); + + assert_eq!(artist.id, artist_id); + assert_eq!(artist.sort, None); + assert_eq!(artist.get_sort_key(), &artist_id); + assert!(artist < Artist::new(sort_id_1.clone())); + assert!(artist < Artist::new(sort_id_2.clone())); + } + + #[test] + fn add_remove_musicbrainz_url() { + let mut artist = Artist::new(ArtistId::new("an artist")); + + let mut expected: Option = None; + assert_eq!(artist.properties.musicbrainz, expected); + + // Adding incorect URL is an error. + assert!(artist.add_musicbrainz_url(MUSICBUTLER).is_err()); + assert!(artist.add_musicbrainz_url(BANDCAMP).is_err()); + assert!(artist.add_musicbrainz_url(QOBUZ).is_err()); + assert_eq!(artist.properties.musicbrainz, expected); + + // Adding URL to artist. + assert!(artist.add_musicbrainz_url(MUSICBRAINZ).is_ok()); + _ = expected.insert(MusicBrainz::new(MUSICBRAINZ).unwrap()); + assert_eq!(artist.properties.musicbrainz, expected); + + // Adding the same URL again is ok, but does not do anything. + assert!(artist.add_musicbrainz_url(MUSICBRAINZ).is_ok()); + assert_eq!(artist.properties.musicbrainz, expected); + + // Adding further URLs is an error. + assert!(artist.add_musicbrainz_url(MUSICBRAINZ_2).is_err()); + assert_eq!(artist.properties.musicbrainz, expected); + + // Removing a URL not in the collection is okay, but does not do anything. + assert!(artist.remove_musicbrainz_url(MUSICBRAINZ_2).is_ok()); + assert_eq!(artist.properties.musicbrainz, expected); + + // Removing a URL in the collection removes it. + assert!(artist.remove_musicbrainz_url(MUSICBRAINZ).is_ok()); + _ = expected.take(); + assert_eq!(artist.properties.musicbrainz, expected); + + assert!(artist.remove_musicbrainz_url(MUSICBRAINZ).is_ok()); + assert_eq!(artist.properties.musicbrainz, expected); + } + + #[test] + fn set_clear_musicbrainz_url() { + let mut artist = Artist::new(ArtistId::new("an artist")); + + let mut expected: Option = None; + assert_eq!(artist.properties.musicbrainz, expected); + + // Setting an incorrect URL is an error. + assert!(artist.set_musicbrainz_url(MUSICBUTLER).is_err()); + assert!(artist.set_musicbrainz_url(BANDCAMP).is_err()); + assert!(artist.set_musicbrainz_url(QOBUZ).is_err()); + assert_eq!(artist.properties.musicbrainz, expected); + + // Setting a URL on an artist. + assert!(artist.set_musicbrainz_url(MUSICBRAINZ).is_ok()); + _ = expected.insert(MusicBrainz::new(MUSICBRAINZ).unwrap()); + assert_eq!(artist.properties.musicbrainz, expected); + + assert!(artist.set_musicbrainz_url(MUSICBRAINZ).is_ok()); + assert_eq!(artist.properties.musicbrainz, expected); + + assert!(artist.set_musicbrainz_url(MUSICBRAINZ_2).is_ok()); + _ = expected.insert(MusicBrainz::new(MUSICBRAINZ_2).unwrap()); + assert_eq!(artist.properties.musicbrainz, expected); + + // Clearing URLs. + artist.clear_musicbrainz_url(); + _ = expected.take(); + assert_eq!(artist.properties.musicbrainz, expected); + } + + #[test] + fn add_remove_musicbutler_urls() { + let mut artist = Artist::new(ArtistId::new("an artist")); + + let mut expected: Vec = vec![]; + assert_eq!(artist.properties.musicbutler, expected); + + // If any URL is incorrect adding URLs is an error. + assert!(artist + .add_musicbutler_urls(vec![MUSICBRAINZ, MUSICBRAINZ_2]) + .is_err()); + assert!(artist + .add_musicbutler_urls(vec![BANDCAMP, BANDCAMP_2]) + .is_err()); + assert!(artist.add_musicbutler_urls(vec![QOBUZ, QOBUZ_2]).is_err()); + assert!(artist + .add_musicbutler_urls(vec![MUSICBRAINZ, MUSICBUTLER, BANDCAMP, QOBUZ]) + .is_err()); + assert_eq!(artist.properties.musicbutler, expected); + + // Adding a single URL. + assert!(artist.add_musicbutler_urls(vec![MUSICBUTLER]).is_ok()); + expected.push(MusicButler::new(MUSICBUTLER).unwrap()); + assert_eq!(artist.properties.musicbutler, expected); + + // Adding a URL that already exists is ok, but does not do anything. + assert!(artist.add_musicbutler_urls(vec![MUSICBUTLER]).is_ok()); + assert_eq!(artist.properties.musicbutler, expected); + + // Adding another single URL. + assert!(artist.add_musicbutler_urls(vec![MUSICBUTLER_2]).is_ok()); + expected.push(MusicButler::new(MUSICBUTLER_2).unwrap()); + assert_eq!(artist.properties.musicbutler, expected); + + assert!(artist.add_musicbutler_urls(vec![MUSICBUTLER_2]).is_ok()); + assert_eq!(artist.properties.musicbutler, expected); + + // Removing a URL. + assert!(artist.remove_musicbutler_urls(vec![MUSICBUTLER]).is_ok()); + expected.retain(|url| url.as_str() != MUSICBUTLER); + assert_eq!(artist.properties.musicbutler, expected); + + // Removing URls that do not exist is okay, they will be ignored. + assert!(artist.remove_musicbutler_urls(vec![MUSICBUTLER]).is_ok()); + assert_eq!(artist.properties.musicbutler, expected); + + // Removing a URL. + assert!(artist.remove_musicbutler_urls(vec![MUSICBUTLER_2]).is_ok()); + expected.retain(|url| url.as_str() != MUSICBUTLER_2); + assert_eq!(artist.properties.musicbutler, expected); + + assert!(artist.remove_musicbutler_urls(vec![MUSICBUTLER_2]).is_ok()); + assert_eq!(artist.properties.musicbutler, expected); + + // Adding URLs if some exist is okay, they will be ignored. + assert!(artist.add_musicbutler_urls(vec![MUSICBUTLER]).is_ok()); + expected.push(MusicButler::new(MUSICBUTLER).unwrap()); + assert_eq!(artist.properties.musicbutler, expected); + + assert!(artist + .add_musicbutler_urls(vec![MUSICBUTLER, MUSICBUTLER_2]) + .is_ok()); + expected.push(MusicButler::new(MUSICBUTLER_2).unwrap()); + assert_eq!(artist.properties.musicbutler, expected); + + // Removing URLs if some do not exist is okay, they will be ignored. + assert!(artist.remove_musicbutler_urls(vec![MUSICBUTLER]).is_ok()); + expected.retain(|url| url.as_str() != MUSICBUTLER); + assert_eq!(artist.properties.musicbutler, expected); + + assert!(artist + .remove_musicbutler_urls(vec![MUSICBUTLER, MUSICBUTLER_2]) + .is_ok()); + expected.retain(|url| url.as_str() != MUSICBUTLER_2); + assert_eq!(artist.properties.musicbutler, expected); + + // Adding mutliple URLs without clashes. + assert!(artist + .add_musicbutler_urls(vec![MUSICBUTLER, MUSICBUTLER_2]) + .is_ok()); + expected.push(MusicButler::new(MUSICBUTLER).unwrap()); + expected.push(MusicButler::new(MUSICBUTLER_2).unwrap()); + assert_eq!(artist.properties.musicbutler, expected); + + // Removing multiple URLs without clashes. + assert!(artist + .remove_musicbutler_urls(vec![MUSICBUTLER, MUSICBUTLER_2]) + .is_ok()); + expected.clear(); + assert_eq!(artist.properties.musicbutler, expected); + } + + #[test] + fn set_clear_musicbutler_urls() { + let mut artist = Artist::new(ArtistId::new("an artist")); + + let mut expected: Vec = vec![]; + assert_eq!(artist.properties.musicbutler, expected); + + // If any URL is incorrect setting URLs is an error. + assert!(artist + .set_musicbutler_urls(vec![MUSICBRAINZ, MUSICBRAINZ_2]) + .is_err()); + assert!(artist + .set_musicbutler_urls(vec![BANDCAMP, BANDCAMP_2]) + .is_err()); + assert!(artist.set_musicbutler_urls(vec![QOBUZ, QOBUZ_2]).is_err()); + assert!(artist + .set_musicbutler_urls(vec![MUSICBRAINZ, MUSICBUTLER, BANDCAMP, QOBUZ]) + .is_err()); + assert_eq!(artist.properties.musicbutler, expected); + + // Set URLs. + assert!(artist.set_musicbutler_urls(vec![MUSICBUTLER]).is_ok()); + expected.push(MusicButler::new(MUSICBUTLER).unwrap()); + assert_eq!(artist.properties.musicbutler, expected); + + assert!(artist.set_musicbutler_urls(vec![MUSICBUTLER_2]).is_ok()); + expected.clear(); + expected.push(MusicButler::new(MUSICBUTLER_2).unwrap()); + assert_eq!(artist.properties.musicbutler, expected); + + assert!(artist + .set_musicbutler_urls(vec![MUSICBUTLER, MUSICBUTLER_2]) + .is_ok()); + expected.clear(); + expected.push(MusicButler::new(MUSICBUTLER).unwrap()); + expected.push(MusicButler::new(MUSICBUTLER_2).unwrap()); + assert_eq!(artist.properties.musicbutler, expected); + + // Clear URLs. + artist.clear_musicbutler_urls(); + expected.clear(); + assert_eq!(artist.properties.musicbutler, expected); + } + + #[test] + fn add_remove_bandcamp_urls() { + let mut artist = Artist::new(ArtistId::new("an artist")); + + let mut expected: Vec = vec![]; + assert_eq!(artist.properties.bandcamp, expected); + + // If any URL is incorrect adding URLs is an error. + assert!(artist + .add_bandcamp_urls(vec![MUSICBRAINZ, MUSICBRAINZ_2]) + .is_err()); + assert!(artist + .add_bandcamp_urls(vec![MUSICBUTLER, MUSICBUTLER_2]) + .is_err()); + assert!(artist.add_bandcamp_urls(vec![QOBUZ, QOBUZ_2]).is_err()); + assert!(artist + .add_bandcamp_urls(vec![MUSICBRAINZ, MUSICBUTLER, BANDCAMP, QOBUZ]) + .is_err()); + assert_eq!(artist.properties.bandcamp, expected); + + // Adding a single URL. + assert!(artist.add_bandcamp_urls(vec![BANDCAMP]).is_ok()); + expected.push(Bandcamp::new(BANDCAMP).unwrap()); + assert_eq!(artist.properties.bandcamp, expected); + + // Adding a URL that already exists is ok, but does not do anything. + assert!(artist.add_bandcamp_urls(vec![BANDCAMP]).is_ok()); + assert_eq!(artist.properties.bandcamp, expected); + + // Adding another single URL. + assert!(artist.add_bandcamp_urls(vec![BANDCAMP_2]).is_ok()); + expected.push(Bandcamp::new(BANDCAMP_2).unwrap()); + assert_eq!(artist.properties.bandcamp, expected); + + assert!(artist.add_bandcamp_urls(vec![BANDCAMP_2]).is_ok()); + assert_eq!(artist.properties.bandcamp, expected); + + // Removing a URL. + assert!(artist.remove_bandcamp_urls(vec![BANDCAMP]).is_ok()); + expected.retain(|url| url.as_str() != BANDCAMP); + assert_eq!(artist.properties.bandcamp, expected); + + // Removing URls that do not exist is okay, they will be ignored. + assert!(artist.remove_bandcamp_urls(vec![BANDCAMP]).is_ok()); + assert_eq!(artist.properties.bandcamp, expected); + + // Removing a URL. + assert!(artist.remove_bandcamp_urls(vec![BANDCAMP_2]).is_ok()); + expected.retain(|url| url.as_str() != BANDCAMP_2); + assert_eq!(artist.properties.bandcamp, expected); + + assert!(artist.remove_bandcamp_urls(vec![BANDCAMP_2]).is_ok()); + assert_eq!(artist.properties.bandcamp, expected); + + // Adding URLs if some exist is okay, they will be ignored. + assert!(artist.add_bandcamp_urls(vec![BANDCAMP]).is_ok()); + expected.push(Bandcamp::new(BANDCAMP).unwrap()); + assert_eq!(artist.properties.bandcamp, expected); + + assert!(artist.add_bandcamp_urls(vec![BANDCAMP, BANDCAMP_2]).is_ok()); + expected.push(Bandcamp::new(BANDCAMP_2).unwrap()); + assert_eq!(artist.properties.bandcamp, expected); + + // Removing URLs if some do not exist is okay, they will be ignored. + assert!(artist.remove_bandcamp_urls(vec![BANDCAMP]).is_ok()); + expected.retain(|url| url.as_str() != BANDCAMP); + assert_eq!(artist.properties.bandcamp, expected); + + assert!(artist + .remove_bandcamp_urls(vec![BANDCAMP, BANDCAMP_2]) + .is_ok()); + expected.retain(|url| url.as_str() != BANDCAMP_2); + assert_eq!(artist.properties.bandcamp, expected); + + // Adding mutliple URLs without clashes. + assert!(artist.add_bandcamp_urls(vec![BANDCAMP, BANDCAMP_2]).is_ok()); + expected.push(Bandcamp::new(BANDCAMP).unwrap()); + expected.push(Bandcamp::new(BANDCAMP_2).unwrap()); + assert_eq!(artist.properties.bandcamp, expected); + + // Removing multiple URLs without clashes. + assert!(artist + .remove_bandcamp_urls(vec![BANDCAMP, BANDCAMP_2]) + .is_ok()); + expected.clear(); + assert_eq!(artist.properties.bandcamp, expected); + } + + #[test] + fn set_clear_bandcamp_urls() { + let mut artist = Artist::new(ArtistId::new("an artist")); + + let mut expected: Vec = vec![]; + assert_eq!(artist.properties.bandcamp, expected); + + // If any URL is incorrect setting URLs is an error. + assert!(artist + .set_bandcamp_urls(vec![MUSICBRAINZ, MUSICBRAINZ_2]) + .is_err()); + assert!(artist + .set_bandcamp_urls(vec![MUSICBUTLER, MUSICBUTLER_2]) + .is_err()); + assert!(artist.set_bandcamp_urls(vec![QOBUZ, QOBUZ_2]).is_err()); + assert!(artist + .set_bandcamp_urls(vec![MUSICBRAINZ, MUSICBUTLER, BANDCAMP, QOBUZ]) + .is_err()); + assert_eq!(artist.properties.bandcamp, expected); + + // Set URLs. + assert!(artist.set_bandcamp_urls(vec![BANDCAMP]).is_ok()); + expected.push(Bandcamp::new(BANDCAMP).unwrap()); + assert_eq!(artist.properties.bandcamp, expected); + + assert!(artist.set_bandcamp_urls(vec![BANDCAMP_2]).is_ok()); + expected.clear(); + expected.push(Bandcamp::new(BANDCAMP_2).unwrap()); + assert_eq!(artist.properties.bandcamp, expected); + + assert!(artist.set_bandcamp_urls(vec![BANDCAMP, BANDCAMP_2]).is_ok()); + expected.clear(); + expected.push(Bandcamp::new(BANDCAMP).unwrap()); + expected.push(Bandcamp::new(BANDCAMP_2).unwrap()); + assert_eq!(artist.properties.bandcamp, expected); + + // Clear URLs. + artist.clear_bandcamp_urls(); + expected.clear(); + assert_eq!(artist.properties.bandcamp, expected); + } + + #[test] + fn add_remove_qobuz_url() { + let mut artist = Artist::new(ArtistId::new("an artist")); + + let mut expected: Option = None; + assert_eq!(artist.properties.qobuz, expected); + + // Adding incorect URL is an error. + assert!(artist.add_qobuz_url(MUSICBRAINZ).is_err()); + assert!(artist.add_qobuz_url(MUSICBUTLER).is_err()); + assert!(artist.add_qobuz_url(BANDCAMP).is_err()); + assert_eq!(artist.properties.qobuz, expected); + + // Adding URL to artist. + assert!(artist.add_qobuz_url(QOBUZ).is_ok()); + _ = expected.insert(Qobuz::new(QOBUZ).unwrap()); + assert_eq!(artist.properties.qobuz, expected); + + // Adding the same URL again is ok, but does not do anything. + assert!(artist.add_qobuz_url(QOBUZ).is_ok()); + assert_eq!(artist.properties.qobuz, expected); + + // Adding further URLs is an error. + assert!(artist.add_qobuz_url(QOBUZ_2).is_err()); + assert_eq!(artist.properties.qobuz, expected); + + // Removing a URL not in the collection is okay, but does not do anything. + assert!(artist.remove_qobuz_url(QOBUZ_2).is_ok()); + assert_eq!(artist.properties.qobuz, expected); + + // Removing a URL in the collection removes it. + assert!(artist.remove_qobuz_url(QOBUZ).is_ok()); + _ = expected.take(); + assert_eq!(artist.properties.qobuz, expected); + + assert!(artist.remove_qobuz_url(QOBUZ).is_ok()); + assert_eq!(artist.properties.qobuz, expected); + } + + #[test] + fn set_clear_qobuz_url() { + let mut artist = Artist::new(ArtistId::new("an artist")); + + let mut expected: Option = None; + assert_eq!(artist.properties.qobuz, expected); + + // Setting an incorrect URL is an error. + assert!(artist.set_qobuz_url(MUSICBUTLER).is_err()); + assert!(artist.set_qobuz_url(BANDCAMP).is_err()); + assert!(artist.set_qobuz_url(MUSICBRAINZ).is_err()); + assert_eq!(artist.properties.qobuz, expected); + + // Setting a URL on an artist. + assert!(artist.set_qobuz_url(QOBUZ).is_ok()); + _ = expected.insert(Qobuz::new(QOBUZ).unwrap()); + assert_eq!(artist.properties.qobuz, expected); + + assert!(artist.set_qobuz_url(QOBUZ).is_ok()); + assert_eq!(artist.properties.qobuz, expected); + + assert!(artist.set_qobuz_url(QOBUZ_2).is_ok()); + _ = expected.insert(Qobuz::new(QOBUZ_2).unwrap()); + assert_eq!(artist.properties.qobuz, expected); + + // Clearing URLs. + artist.clear_qobuz_url(); + _ = expected.take(); + assert_eq!(artist.properties.qobuz, expected); + } + + #[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); + } +} diff --git a/src/core/collection/merge.rs b/src/core/collection/merge.rs new file mode 100644 index 0000000..fcad1b9 --- /dev/null +++ b/src/core/collection/merge.rs @@ -0,0 +1,67 @@ +use std::{cmp::Ordering, iter::Peekable}; + +/// A trait for merging two objects. The merge is asymmetric with the left argument considered to be +/// the primary whose properties are to be kept in case of collisions. +pub 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(); + } +} + +pub struct MergeSorted +where + L: Iterator, + R: Iterator, +{ + left: Peekable, + right: Peekable, +} + +impl MergeSorted +where + L: Iterator, + R: Iterator, +{ + pub 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(), + } + } +} diff --git a/src/core/collection/mod.rs b/src/core/collection/mod.rs new file mode 100644 index 0000000..ea42e15 --- /dev/null +++ b/src/core/collection/mod.rs @@ -0,0 +1,40 @@ +//! The collection module defines the core data types and their relations. + +pub mod album; +pub mod artist; +pub mod track; + +mod merge; +pub use merge::Merge; + +use std::fmt::{self, Display}; + +/// The [`Collection`] alias type for convenience. +pub type Collection = Vec; + +/// Error type for the [`collection`] module. +#[derive(Debug, PartialEq, Eq)] +pub enum Error { + /// An error occurred when processing a URL. + UrlError(String), +} + +impl Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Self::UrlError(ref s) => write!(f, "an error occurred when processing a URL: {s}"), + } + } +} + +impl From for Error { + fn from(err: url::ParseError) -> Error { + Error::UrlError(err.to_string()) + } +} + +impl From for Error { + fn from(err: uuid::Error) -> Error { + Error::UrlError(err.to_string()) + } +} diff --git a/src/core/collection/track.rs b/src/core/collection/track.rs new file mode 100644 index 0000000..3b35cf0 --- /dev/null +++ b/src/core/collection/track.rs @@ -0,0 +1,81 @@ +use serde::{Deserialize, Serialize}; + +use crate::core::collection::merge::Merge; + +/// 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, +} + +/// The track identifier. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct TrackId { + pub number: u32, + pub title: String, +} + +/// 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 file format. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] +pub enum Format { + Flac, + Mp3, +} + +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); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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); + } +} diff --git a/src/database/json/backend.rs b/src/core/database/json/backend.rs similarity index 94% rename from src/database/json/backend.rs rename to src/core/database/json/backend.rs index d644fe4..8c99542 100644 --- a/src/database/json/backend.rs +++ b/src/core/database/json/backend.rs @@ -3,7 +3,7 @@ use std::fs; use std::path::PathBuf; -use super::IJsonDatabaseBackend; +use crate::core::database::json::IJsonDatabaseBackend; /// JSON database backend that uses a local file for persistent storage. pub struct JsonDatabaseFileBackend { diff --git a/src/database/json/mod.rs b/src/core/database/json/mod.rs similarity index 93% rename from src/database/json/mod.rs rename to src/core/database/json/mod.rs index 3bf0015..2836ea2 100644 --- a/src/database/json/mod.rs +++ b/src/core/database/json/mod.rs @@ -1,13 +1,14 @@ //! Module for storing MusicHoard data in a JSON file database. +pub mod backend; + #[cfg(test)] use mockall::automock; -use crate::Collection; - -use super::{IDatabase, LoadError, SaveError}; - -pub mod backend; +use crate::core::{ + collection::Collection, + database::{IDatabase, LoadError, SaveError}, +}; impl From for LoadError { fn from(err: serde_json::Error) -> LoadError { @@ -66,7 +67,13 @@ mod tests { use mockall::predicate; - use crate::{testlib::FULL_COLLECTION, Artist, ArtistId, Collection}; + use crate::core::{ + collection::{ + artist::{Artist, ArtistId}, + Collection, + }, + testmod::FULL_COLLECTION, + }; use super::*; use testmod::DATABASE_JSON; diff --git a/src/database/json/testmod.rs b/src/core/database/json/testmod.rs similarity index 100% rename from src/database/json/testmod.rs rename to src/core/database/json/testmod.rs diff --git a/src/database/mod.rs b/src/core/database/mod.rs similarity index 98% rename from src/database/mod.rs rename to src/core/database/mod.rs index c23aabc..c0de380 100644 --- a/src/database/mod.rs +++ b/src/core/database/mod.rs @@ -1,14 +1,14 @@ //! Module for storing MusicHoard data in a database. +#[cfg(feature = "database-json")] +pub mod json; + use std::fmt; #[cfg(test)] use mockall::automock; -use crate::Collection; - -#[cfg(feature = "database-json")] -pub mod json; +use crate::core::collection::Collection; /// Trait for interacting with the database. #[cfg_attr(test, automock)] diff --git a/src/library/beets/executor.rs b/src/core/library/beets/executor.rs similarity index 98% rename from src/library/beets/executor.rs rename to src/core/library/beets/executor.rs index 1c7e34f..4a9632e 100644 --- a/src/library/beets/executor.rs +++ b/src/core/library/beets/executor.rs @@ -8,7 +8,7 @@ use std::{ str, }; -use super::{Error, IBeetsLibraryExecutor}; +use crate::core::library::{beets::IBeetsLibraryExecutor, Error}; const BEET_DEFAULT: &str = "beet"; diff --git a/src/library/beets/mod.rs b/src/core/library/beets/mod.rs similarity index 98% rename from src/library/beets/mod.rs rename to src/core/library/beets/mod.rs index 202722b..748a3c6 100644 --- a/src/library/beets/mod.rs +++ b/src/core/library/beets/mod.rs @@ -1,14 +1,15 @@ //! Module for interacting with the music library via //! [beets](https://beets.readthedocs.io/en/stable/). +pub mod executor; + #[cfg(test)] use mockall::automock; -use crate::Format; - -use super::{Error, Field, ILibrary, Item, Query}; - -pub mod executor; +use crate::core::{ + collection::track::Format, + library::{Error, Field, ILibrary, Item, Query}, +}; macro_rules! list_format_separator { () => { @@ -176,7 +177,7 @@ mod testmod; mod tests { use mockall::predicate; - use crate::library::testmod::LIBRARY_ITEMS; + use crate::core::library::testmod::LIBRARY_ITEMS; use super::*; use testmod::LIBRARY_BEETS; diff --git a/src/library/beets/testmod.rs b/src/core/library/beets/testmod.rs similarity index 100% rename from src/library/beets/testmod.rs rename to src/core/library/beets/testmod.rs diff --git a/src/library/mod.rs b/src/core/library/mod.rs similarity index 99% rename from src/library/mod.rs rename to src/core/library/mod.rs index a76f3e6..6e0778a 100644 --- a/src/library/mod.rs +++ b/src/core/library/mod.rs @@ -1,14 +1,14 @@ //! Module for interacting with the music library. +#[cfg(feature = "library-beets")] +pub mod beets; + use std::{collections::HashSet, fmt, num::ParseIntError, str::Utf8Error}; #[cfg(test)] use mockall::automock; -use crate::Format; - -#[cfg(feature = "library-beets")] -pub mod beets; +use crate::core::collection::track::Format; /// Trait for interacting with the music library. #[cfg_attr(test, automock)] diff --git a/src/library/testmod.rs b/src/core/library/testmod.rs similarity index 99% rename from src/library/testmod.rs rename to src/core/library/testmod.rs index 87b5dc2..a0a5af4 100644 --- a/src/library/testmod.rs +++ b/src/core/library/testmod.rs @@ -1,6 +1,6 @@ use once_cell::sync::Lazy; -use crate::{library::Item, Format}; +use crate::core::{collection::track::Format, library::Item}; pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { vec![ diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..8739da3 --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,7 @@ +pub mod collection; +pub mod database; +pub mod library; +pub mod musichoard; + +#[cfg(test)] +pub mod testmod; diff --git a/src/core/musichoard/mod.rs b/src/core/musichoard/mod.rs new file mode 100644 index 0000000..d12bc43 --- /dev/null +++ b/src/core/musichoard/mod.rs @@ -0,0 +1,56 @@ +//! The core MusicHoard module. Serves as the main entry-point into the library. + +#![allow(clippy::module_inception)] +pub mod musichoard; +pub mod musichoard_builder; + +use std::fmt::{self, Display}; + +use crate::core::{collection, database, library}; + +/// 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), +} + +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}") + } + } + } +} + +impl From for Error { + fn from(err: collection::Error) -> Self { + Error::CollectionError(err.to_string()) + } +} + +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()) + } +} diff --git a/src/core/musichoard/musichoard.rs b/src/core/musichoard/musichoard.rs new file mode 100644 index 0000000..66a7d6b --- /dev/null +++ b/src/core/musichoard/musichoard.rs @@ -0,0 +1,1053 @@ +use std::{collections::HashMap, mem}; + +use paste::paste; + +use crate::core::{ + collection::{ + album::{Album, AlbumId}, + artist::{Artist, ArtistId}, + track::{Quality, Track, TrackId}, + Collection, Merge, + }, + database::IDatabase, + library::{ILibrary, Item, Query}, + musichoard::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> { + Ok(self.get_artist_mut_or_err(artist_id.as_ref())?.[](url)?) + } + + pub fn [], S: AsRef>( + &mut self, + artist_id: ID, + url: S, + ) -> Result<(), Error> { + Ok(self.get_artist_mut_or_err(artist_id.as_ref())?.[](url)?) + } + + pub fn [], S: AsRef>( + &mut self, + artist_id: ID, + url: S, + ) -> Result<(), Error> { + Ok(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> { + Ok(self.get_artist_mut_or_err(artist_id.as_ref())?.[](urls)?) + } + + pub fn [], S: AsRef>( + &mut self, + artist_id: ID, + urls: Vec, + ) -> Result<(), Error> { + Ok(self.get_artist_mut_or_err(artist_id.as_ref())?.[](urls)?) + } + + pub fn [], S: AsRef>( + &mut self, + artist_id: ID, + urls: Vec, + ) -> Result<(), Error> { + Ok(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 Default for MusicHoard { + /// Create a new [`MusicHoard`] without any library or database. + fn default() -> Self { + MusicHoard::new(NoLibrary, NoDatabase) + } +} + +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(()) + } +} + +#[cfg(test)] +mod tests { + use mockall::predicate; + + use crate::core::{ + collection::artist::{ArtistId, Bandcamp, MusicBrainz, MusicButler, Qobuz}, + database::{self, MockIDatabase}, + library::{self, testmod::LIBRARY_ITEMS, MockILibrary}, + testmod::{FULL_COLLECTION, LIBRARY_COLLECTION}, + }; + + use super::*; + + static MUSICBRAINZ: &str = + "https://musicbrainz.org/artist/d368baa8-21ca-4759-9731-0b2753071ad8"; + 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"; + + #[test] + fn artist_new_delete() { + let artist_id = ArtistId::new("an artist"); + let artist_id_2 = ArtistId::new("another artist"); + let mut music_hoard = MusicHoard::default(); + + 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 = MusicHoard::default(); + + 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 = MusicHoard::default(); + music_hoard.add_artist(artist_id.clone()); + + let actual_err = music_hoard + .add_musicbrainz_url(&artist_id, QOBUZ) + .unwrap_err(); + let expected_err = Error::CollectionError(format!( + "an error occurred when processing a URL: invalid MusicBrainz URL: {QOBUZ}" + )); + 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 = MusicHoard::default(); + + music_hoard.add_artist(artist_id.clone()); + + let mut expected: Option = None; + 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); + + // 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 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); + } + + #[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 = MusicHoard::default(); + + music_hoard.add_artist(artist_id.clone()); + + let mut expected: Option = None; + 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); + + // 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 = MusicHoard::default(); + + music_hoard.add_artist(artist_id.clone()); + + let mut expected: Vec = vec![]; + 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 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 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 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 = MusicHoard::default(); + + music_hoard.add_artist(artist_id.clone()); + + let mut expected: Vec = vec![]; + 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, 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 = MusicHoard::default(); + + music_hoard.add_artist(artist_id.clone()); + + let mut expected: Vec = vec![]; + 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 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 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 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 = MusicHoard::default(); + + music_hoard.add_artist(artist_id.clone()); + + let mut expected: Vec = vec![]; + 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, 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 = MusicHoard::default(); + + music_hoard.add_artist(artist_id.clone()); + + let mut expected: Option = None; + 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); + + // 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 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); + } + + #[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 = MusicHoard::default(); + + music_hoard.add_artist(artist_id.clone()); + + let mut expected: Option = None; + 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); + + // 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_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 = MusicHoard::new(library, database); + + 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 = MusicHoard::new(library, database); + + 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 = MusicHoard::new(library, database); + + 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 = MusicHoard::new(library, database); + + 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 = MusicHoard::new(library, database); + + 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 = MusicHoard::new(library, database); + + 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 = MusicHoard::new(library, database); + + 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 = MusicHoard::new(library, database); + + 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 = MusicHoard::new(library, database); + + 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()); + } +} diff --git a/src/core/musichoard/musichoard_builder.rs b/src/core/musichoard/musichoard_builder.rs new file mode 100644 index 0000000..346a592 --- /dev/null +++ b/src/core/musichoard/musichoard_builder.rs @@ -0,0 +1,92 @@ +use crate::core::{ + database::IDatabase, + library::ILibrary, + musichoard::musichoard::{MusicHoard, NoDatabase, NoLibrary}, +}; + +/// 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)] +mod tests { + use crate::core::{database::NullDatabase, library::NullLibrary}; + + use super::*; + + #[test] + fn no_library_no_database() { + MusicHoardBuilder::default(); + } + + #[test] + fn with_library_no_database() { + let mut mh = MusicHoardBuilder::default() + .set_library(NullLibrary) + .build(); + assert!(mh.rescan_library().is_ok()); + } + + #[test] + fn no_library_with_database() { + let mut mh = MusicHoardBuilder::default() + .set_database(NullDatabase) + .build(); + assert!(mh.load_from_database().is_ok()); + assert!(mh.save_to_database().is_ok()); + } + + #[test] + fn with_library_with_database() { + let mut mh = MusicHoardBuilder::default() + .set_library(NullLibrary) + .set_database(NullDatabase) + .build(); + assert!(mh.rescan_library().is_ok()); + assert!(mh.load_from_database().is_ok()); + assert!(mh.save_to_database().is_ok()); + } +} diff --git a/src/core/testmod.rs b/src/core/testmod.rs new file mode 100644 index 0000000..4527446 --- /dev/null +++ b/src/core/testmod.rs @@ -0,0 +1,11 @@ +use once_cell::sync::Lazy; + +use crate::core::collection::{ + album::{Album, AlbumId}, + artist::{Artist, ArtistId, ArtistProperties, Bandcamp, MusicBrainz, MusicButler, Qobuz}, + track::{Format, Quality, Track, TrackId}, +}; +use crate::tests::*; + +pub static LIBRARY_COLLECTION: Lazy> = Lazy::new(|| library_collection!()); +pub static FULL_COLLECTION: Lazy> = Lazy::new(|| full_collection!()); diff --git a/src/lib.rs b/src/lib.rs index 1efded8..6277808 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2293 +1,17 @@ //! MusicHoard - a music collection manager. -pub mod database; -pub mod library; +mod core; -use std::{ - cmp::Ordering, - collections::HashMap, - fmt::{self, Debug, Display}, - iter::Peekable, - mem, +pub use core::collection; +pub use core::database; +pub use core::library; + +pub use core::musichoard::{ + musichoard::{MusicHoard, NoDatabase, NoLibrary}, + musichoard_builder::MusicHoardBuilder, + Error, }; -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()); - } -} +mod tests; diff --git a/src/main.rs b/src/main.rs index ce20e33..94207ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +mod tui; + use std::{ffi::OsString, fs::OpenOptions, io, path::PathBuf}; use ratatui::{backend::CrosstermBackend, Terminal}; @@ -18,7 +20,6 @@ use musichoard::{ MusicHoardBuilder, NoDatabase, NoLibrary, }; -mod tui; use tui::{event::EventChannel, handler::EventHandler, listener::EventListener, ui::Ui, Tui}; #[derive(StructOpt)] @@ -128,7 +129,4 @@ fn main() { #[cfg(test)] #[macro_use] -mod testmacros; - -#[cfg(test)] -mod testbin; +mod tests; diff --git a/src/testbin.rs b/src/testbin.rs deleted file mode 100644 index 541464a..0000000 --- a/src/testbin.rs +++ /dev/null @@ -1,7 +0,0 @@ -use musichoard::{ - Album, AlbumId, Artist, ArtistId, ArtistProperties, Bandcamp, Format, MusicBrainz, MusicButler, - Qobuz, Quality, Track, TrackId, -}; -use once_cell::sync::Lazy; - -pub static COLLECTION: Lazy> = Lazy::new(|| full_collection!()); diff --git a/src/testlib.rs b/src/testlib.rs deleted file mode 100644 index 377b724..0000000 --- a/src/testlib.rs +++ /dev/null @@ -1,8 +0,0 @@ -use crate::{ - Album, AlbumId, Artist, ArtistId, ArtistProperties, Bandcamp, Format, MusicBrainz, MusicButler, - Qobuz, Quality, Track, TrackId, -}; -use once_cell::sync::Lazy; - -pub static LIBRARY_COLLECTION: Lazy> = Lazy::new(|| library_collection!()); -pub static FULL_COLLECTION: Lazy> = Lazy::new(|| full_collection!()); diff --git a/src/testmacros.rs b/src/tests.rs similarity index 99% rename from src/testmacros.rs rename to src/tests.rs index 0aab77d..84b1372 100644 --- a/src/testmacros.rs +++ b/src/tests.rs @@ -321,3 +321,6 @@ macro_rules! full_collection { collection }}; } + +pub(crate) use full_collection; +pub(crate) use library_collection; diff --git a/src/tui/event.rs b/src/tui/event.rs index a99d610..b05da5f 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -2,7 +2,7 @@ use crossterm::event::{KeyEvent, MouseEvent}; use std::fmt; use std::sync::mpsc; -use super::ui::UiError; +use crate::tui::ui::UiError; #[derive(Debug)] pub enum EventError { @@ -104,7 +104,7 @@ mod tests { use crate::tui::ui::UiError; - use super::{Event, EventChannel, EventError}; + use super::*; #[test] fn event_sender() { diff --git a/src/tui/handler.rs b/src/tui/handler.rs index 657c226..2c65098 100644 --- a/src/tui/handler.rs +++ b/src/tui/handler.rs @@ -3,7 +3,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; #[cfg(test)] use mockall::automock; -use super::{ +use crate::tui::{ event::{Event, EventError, EventReceiver}, ui::IUi, }; diff --git a/src/tui/lib.rs b/src/tui/lib.rs index 6e0facd..23349ce 100644 --- a/src/tui/lib.rs +++ b/src/tui/lib.rs @@ -1,4 +1,4 @@ -use musichoard::{database::IDatabase, library::ILibrary, Collection, MusicHoard}; +use musichoard::{collection::Collection, database::IDatabase, library::ILibrary, MusicHoard}; #[cfg(test)] use mockall::automock; diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 4ccbe29..c4584da 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -157,24 +157,24 @@ impl Tui { // GRCOV_EXCL_STOP } +#[cfg(test)] +mod testmod; + #[cfg(test)] mod tests { use std::{io, thread}; - use musichoard::Collection; use ratatui::{backend::TestBackend, Terminal}; - use crate::testbin::COLLECTION; + use musichoard::collection::Collection; - use super::{ - event::EventError, - handler::MockIEventHandler, - lib::MockIMusicHoard, - listener::MockIEventListener, - ui::{IUi, Ui}, - Error, Tui, + use crate::tui::{ + handler::MockIEventHandler, lib::MockIMusicHoard, listener::MockIEventListener, ui::Ui, }; + use super::*; + use testmod::COLLECTION; + pub fn terminal() -> Terminal { let backend = TestBackend::new(150, 30); Terminal::new(backend).unwrap() diff --git a/src/tui/testmod.rs b/src/tui/testmod.rs new file mode 100644 index 0000000..f5d1bcf --- /dev/null +++ b/src/tui/testmod.rs @@ -0,0 +1,10 @@ +use musichoard::collection::{ + album::{Album, AlbumId}, + artist::{Artist, ArtistId, ArtistProperties, Bandcamp, MusicBrainz, MusicButler, Qobuz}, + track::{Format, Quality, Track, TrackId}, +}; +use once_cell::sync::Lazy; + +use crate::tests::*; + +pub static COLLECTION: Lazy> = Lazy::new(|| full_collection!()); diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 091297a..826a184 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1,6 +1,11 @@ use std::fmt; -use musichoard::{Album, Artist, Collection, Format, Track}; +use musichoard::collection::{ + album::Album, + artist::Artist, + track::{Format, Track}, + Collection, +}; use ratatui::{ backend::Backend, layout::{Alignment, Rect}, @@ -9,7 +14,7 @@ use ratatui::{ Frame, }; -use super::{lib::IMusicHoard, Error}; +use crate::tui::{lib::IMusicHoard, Error}; #[derive(Debug)] pub enum UiError { @@ -733,8 +738,8 @@ impl IUi for Ui { #[cfg(test)] mod tests { - use crate::testbin::COLLECTION; use crate::tui::lib::MockIMusicHoard; + use crate::tui::testmod::COLLECTION; use crate::tui::tests::{terminal, ui}; use super::*; diff --git a/tests/database/json.rs b/tests/database/json.rs index fd8f2b7..e7e18ac 100644 --- a/tests/database/json.rs +++ b/tests/database/json.rs @@ -1,14 +1,15 @@ use std::{fs, path::PathBuf}; +use once_cell::sync::Lazy; +use tempfile::NamedTempFile; + use musichoard::{ + collection::artist::Artist, database::{ json::{backend::JsonDatabaseFileBackend, JsonDatabase}, IDatabase, }, - Artist, }; -use once_cell::sync::Lazy; -use tempfile::NamedTempFile; use crate::testlib::COLLECTION; diff --git a/tests/lib.rs b/tests/lib.rs index c1e74bd..867941d 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -5,14 +5,14 @@ mod testlib; use musichoard::MusicHoard; -use crate::testlib::COLLECTION; - #[cfg(feature = "database-json")] use musichoard::database::json::{backend::JsonDatabaseFileBackend, JsonDatabase}; #[cfg(feature = "library-beets")] use musichoard::library::beets::{executor::BeetsLibraryProcessExecutor, BeetsLibrary}; +use crate::testlib::COLLECTION; + #[test] #[cfg(feature = "database-json")] #[cfg(feature = "library-beets")] diff --git a/tests/library/beets.rs b/tests/library/beets.rs index aa562a1..74dcd12 100644 --- a/tests/library/beets.rs +++ b/tests/library/beets.rs @@ -12,7 +12,7 @@ use musichoard::library::{ Field, ILibrary, Item, Query, }; -use super::testmod::LIBRARY_ITEMS; +use crate::library::testmod::LIBRARY_ITEMS; pub static BEETS_TEST_CONFIG_PATH: Lazy = Lazy::new(|| fs::canonicalize("./tests/files/library/config.yml").unwrap()); diff --git a/tests/library/mod.rs b/tests/library/mod.rs index 45a5cdb..54227e8 100644 --- a/tests/library/mod.rs +++ b/tests/library/mod.rs @@ -1,4 +1,4 @@ -mod testmod; - #[cfg(feature = "library-beets")] pub mod beets; + +mod testmod; diff --git a/tests/library/testmod.rs b/tests/library/testmod.rs index fbf645a..bfd1658 100644 --- a/tests/library/testmod.rs +++ b/tests/library/testmod.rs @@ -1,6 +1,6 @@ use once_cell::sync::Lazy; -use musichoard::{library::Item, Format}; +use musichoard::{collection::track::Format, library::Item}; pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { vec![ diff --git a/tests/testlib.rs b/tests/testlib.rs index 8df1677..76bff31 100644 --- a/tests/testlib.rs +++ b/tests/testlib.rs @@ -1,9 +1,12 @@ -use musichoard::{ - Album, AlbumId, Artist, ArtistId, ArtistProperties, Bandcamp, Collection, Format, MusicBrainz, - MusicButler, Qobuz, Quality, Track, TrackId, -}; use once_cell::sync::Lazy; +use musichoard::collection::{ + album::{Album, AlbumId}, + artist::{Artist, ArtistId, ArtistProperties, Bandcamp, MusicBrainz, MusicButler, Qobuz}, + track::{Format, Quality, Track, TrackId}, + Collection, +}; + pub static COLLECTION: Lazy> = Lazy::new(|| -> Collection { vec![ Artist {