diff --git a/src/collection/album.rs b/src/collection/album.rs new file mode 100644 index 0000000..40da9a1 --- /dev/null +++ b/src/collection/album.rs @@ -0,0 +1,42 @@ +use core::mem; + +use serde::{Deserialize, Serialize}; + +use super::track::Track; + +// FIXME: check direction of import. +use super::{Merge, MergeSorted}; + +/// 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(); + } +} diff --git a/src/collection/artist.rs b/src/collection/artist.rs new file mode 100644 index 0000000..2d99592 --- /dev/null +++ b/src/collection/artist.rs @@ -0,0 +1,527 @@ +use std::{ + fmt::{self, Debug, Display}, + mem, +}; + +use paste::paste; +use serde::{Deserialize, Serialize}; +use url::Url; +use uuid::Uuid; + +use super::album::Album; + +// FIXME: check direction of import. +use super::{Merge, MergeSorted}; + +// FIXME: these imports are not acceptable +use crate::Error; + +/// 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. +// FIXME: should not be public (or at least not in this form) +pub 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 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! { + 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::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(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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()); + } +} diff --git a/src/collection/mod.rs b/src/collection/mod.rs index 25e6876..b10883d 100644 --- a/src/collection/mod.rs +++ b/src/collection/mod.rs @@ -1,572 +1,8 @@ -use std::{ - cmp::Ordering, - fmt::{self, Debug, Display}, - iter::Peekable, - mem, -}; +pub mod album; +pub mod artist; +pub mod track; -use paste::paste; -use serde::{Deserialize, Serialize}; -use url::Url; -use uuid::Uuid; - -// FIXME: these imports are not acceptable -use crate::Error; - -/// 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. -// FIXME: should not be public (or at least not in this form) -pub 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! { - 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::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(); - } -} +use std::{cmp::Ordering, iter::Peekable}; // FIXME: should not be public pub trait Merge { @@ -633,41 +69,5 @@ where } } -/// The collection type. Currently, a collection is a list of artists. -pub type Collection = Vec; - -#[cfg(test)] -mod tests { - use super::*; - - #[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()); - } -} +/// The collection alias type for convenience. +pub type Collection = Vec; diff --git a/src/collection/track.rs b/src/collection/track.rs new file mode 100644 index 0000000..f4279fd --- /dev/null +++ b/src/collection/track.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +// FIXME: check direction of import. +use super::Merge; + +/// 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); + } +} diff --git a/src/lib.rs b/src/lib.rs index aeee122..aaddaea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,15 +10,17 @@ use std::{ mem, }; -use collection::{InvalidUrlError, Merge}; +use collection::{artist::InvalidUrlError, Merge}; use database::IDatabase; use library::{ILibrary, Item, Query}; use paste::paste; -// TODO: validate the re-exports. +// FIXME: validate the re-exports. pub use collection::{ - Album, AlbumId, Artist, ArtistId, ArtistProperties, Bandcamp, Collection, Format, MusicBrainz, - MusicButler, Qobuz, Quality, Track, TrackId, + album::{Album, AlbumId}, + artist::{Artist, ArtistId, ArtistProperties, Bandcamp, MusicBrainz, MusicButler, Qobuz}, + track::{Format, Quality, Track, TrackId}, + Collection, }; /// Error type for `musichoard`. diff --git a/src/library/beets/mod.rs b/src/library/beets/mod.rs index ffddae5..0c6cee1 100644 --- a/src/library/beets/mod.rs +++ b/src/library/beets/mod.rs @@ -4,7 +4,7 @@ #[cfg(test)] use mockall::automock; -use crate::collection::Format; +use crate::collection::track::Format; use super::{Error, Field, ILibrary, Item, Query}; diff --git a/src/library/mod.rs b/src/library/mod.rs index 3d2ae87..645081a 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -5,7 +5,7 @@ use std::{collections::HashSet, fmt, num::ParseIntError, str::Utf8Error}; #[cfg(test)] use mockall::automock; -use crate::collection::Format; +use crate::collection::track::Format; #[cfg(feature = "library-beets")] pub mod beets; diff --git a/src/library/testmod.rs b/src/library/testmod.rs index 284ec54..638aeb2 100644 --- a/src/library/testmod.rs +++ b/src/library/testmod.rs @@ -1,6 +1,6 @@ use once_cell::sync::Lazy; -use crate::{collection::Format, library::Item}; +use crate::{collection::track::Format, library::Item}; pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { vec![ diff --git a/src/testlib.rs b/src/testlib.rs index 4015bef..519757d 100644 --- a/src/testlib.rs +++ b/src/testlib.rs @@ -1,6 +1,7 @@ use crate::collection::{ - Album, AlbumId, Artist, ArtistId, ArtistProperties, Bandcamp, Format, MusicBrainz, MusicButler, - Qobuz, Quality, Track, TrackId, + album::{Album, AlbumId}, + artist::{Artist, ArtistId, ArtistProperties, Bandcamp, MusicBrainz, MusicButler, Qobuz}, + track::{Format, Quality, Track, TrackId}, }; use once_cell::sync::Lazy; diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 14e31b0..93589e3 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -1,7 +1,11 @@ use mockall::predicate; use super::*; -use collection::{Bandcamp, Format, Merge, MusicBrainz, MusicButler, Qobuz}; +use collection::{ + artist::{Bandcamp, MusicBrainz, MusicButler, Qobuz}, + track::Format, + Merge, +}; use database::MockIDatabase; use library::{testmod::LIBRARY_ITEMS, MockILibrary}; use testlib::{FULL_COLLECTION, LIBRARY_COLLECTION};