diff --git a/Cargo.lock b/Cargo.lock index 491d365..890b6ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -273,6 +273,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25db6b064527c5d482d0423354fcd07a89a2dfe07b67892e62411946db7f07b0" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.6" @@ -366,6 +375,7 @@ name = "musichoard" version = "0.1.0" dependencies = [ "crossterm", + "itertools 0.12.0", "mockall", "once_cell", "openssh", @@ -476,7 +486,7 @@ checksum = "59230a63c37f3e18569bdb90e4a89cbf5bf8b06fea0b84e65ea10cc4df47addd" dependencies = [ "difflib", "float-cmp", - "itertools", + "itertools 0.10.5", "normalize-line-endings", "predicates-core", "regex", diff --git a/Cargo.toml b/Cargo.toml index 395c326..e9d6add 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" [dependencies] crossterm = { version = "0.26.1", optional = true} +itertools = { version = "0.12.0" } openssh = { version = "0.9.9", features = ["native-mux"], default-features = false, optional = true} ratatui = { version = "0.20.1", optional = true} serde = { version = "1.0.159", features = ["derive"] } diff --git a/src/bin/mh-edit.rs b/src/bin/mh-edit.rs index 5d0bb2e..60d9bcc 100644 --- a/src/bin/mh-edit.rs +++ b/src/bin/mh-edit.rs @@ -126,38 +126,24 @@ impl ArtistCommand { clear_musicbutler_urls, urls ), - ArtistCommand::Bandcamp(url_command) => { - match url_command { - UrlCommand::Add(_) => { - // Add URL. - } - UrlCommand::Remove(_) => { - // Remove URL if it exists. - } - UrlCommand::Set(_) => { - // Set the URLs regardless of previous (if any) value. - } - UrlCommand::Clear(_) => { - // Remove the URLs. - } - } - } - ArtistCommand::Qobuz(url_command) => { - match url_command { - UrlCommand::Add(_) => { - // Add URL or return error if one already existss. - } - UrlCommand::Remove(_) => { - // Remove URL if it exists. - } - UrlCommand::Set(_) => { - // Set the URL regardless of previous (if any) value. - } - UrlCommand::Clear(_) => { - // Remove the URL. - } - } - } + ArtistCommand::Bandcamp(url_command) => url_command_dispatch!( + url_command, + music_hoard, + add_bandcamp_urls, + remove_bandcamp_urls, + set_bandcamp_urls, + clear_bandcamp_urls, + urls + ), + ArtistCommand::Qobuz(url_command) => url_command_dispatch!( + url_command, + music_hoard, + add_qobuz_url, + remove_qobuz_url, + set_qobuz_url, + clear_qobuz_url, + url + ), } } } diff --git a/src/lib.rs b/src/lib.rs index 02f93be..52b86a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,12 +6,13 @@ pub mod library; use std::{ cmp::Ordering, collections::{HashMap, HashSet}, - fmt, + fmt::{self, Debug}, iter::Peekable, mem, }; use database::IDatabase; +use itertools::Itertools; use library::{ILibrary, Item, Query}; use serde::{Deserialize, Serialize}; use url::Url; @@ -79,6 +80,20 @@ impl MusicBrainz { } } +impl TryFrom<&str> for MusicBrainz { + type Error = Error; + + fn try_from(value: &str) -> Result { + MusicBrainz::new(value) + } +} + +impl AsRef for MusicBrainz { + fn as_ref(&self) -> &str { + self.0.as_str() + } +} + impl IUrl for MusicBrainz { fn url(&self) -> &str { self.0.as_str() @@ -119,6 +134,20 @@ impl MusicButler { } } +impl TryFrom<&str> for MusicButler { + type Error = Error; + + fn try_from(value: &str) -> Result { + MusicButler::new(value) + } +} + +impl AsRef for MusicButler { + fn as_ref(&self) -> &str { + self.0.as_str() + } +} + impl IUrl for MusicButler { fn url(&self) -> &str { self.0.as_str() @@ -152,6 +181,20 @@ impl Bandcamp { } } +impl TryFrom<&str> for Bandcamp { + type Error = Error; + + fn try_from(value: &str) -> Result { + Bandcamp::new(value) + } +} + +impl AsRef for Bandcamp { + fn as_ref(&self) -> &str { + self.0.as_str() + } +} + impl IUrl for Bandcamp { fn url(&self) -> &str { self.0.as_str() @@ -185,6 +228,20 @@ impl Qobuz { } } +impl TryFrom<&str> for Qobuz { + type Error = Error; + + fn try_from(value: &str) -> Result { + Qobuz::new(value) + } +} + +impl AsRef for Qobuz { + fn as_ref(&self) -> &str { + self.0.as_str() + } +} + impl IUrl for Qobuz { fn url(&self) -> &str { self.0.as_str() @@ -325,6 +382,46 @@ pub struct Artist { pub albums: Vec, } +macro_rules! artist_unique_url_dispatch { + ($add:ident, $remove:ident, $set:ident, $clear:ident, $label:literal, $field:ident) => { + fn $add>(&mut self, url: S) -> Result<(), Error> { + Self::add_unique_url(&self.id, $label, &mut self.properties.$field, url) + } + + fn $remove>(&mut self, url: S) -> Result<(), Error> { + Self::remove_unique_url(&self.id, $label, &mut self.properties.$field, url) + } + + fn $set>(&mut self, url: S) -> Result<(), Error> { + Self::set_unique_url(&mut self.properties.$field, url) + } + + fn $clear(&mut self) { + Self::clear_unique_url(&mut self.properties.$field); + } + }; +} + +macro_rules! artist_multi_url_dispatch { + ($add:ident, $remove:ident, $set:ident, $clear:ident, $label:literal, $field:ident) => { + fn $add>(&mut self, urls: Vec) -> Result<(), Error> { + Self::add_multi_urls(&self.id, $label, &mut self.properties.$field, urls) + } + + fn $remove>(&mut self, urls: Vec) -> Result<(), Error> { + Self::remove_multi_urls(&self.id, $label, &mut self.properties.$field, urls) + } + + fn $set>(&mut self, urls: Vec) -> Result<(), Error> { + Self::set_multi_urls(&mut self.properties.$field, urls) + } + + fn $clear(&mut self) { + Self::clear_multi_urls(&mut self.properties.$field); + } + }; +} + impl Artist { pub fn new(id: ArtistId) -> Self { Artist { @@ -334,106 +431,189 @@ impl Artist { } } - fn add_musicbrainz_url>(&mut self, url: S) -> Result<(), Error> { - if self.properties.musicbrainz.is_some() { - return Err(Error::CollectionError(format!( - "artist '{}' already has a MusicBrainz URL", - self.id - ))); + fn add_unique_url< + S: AsRef, + T: for<'a> TryFrom<&'a str, Error = Error> + Eq + AsRef, + >( + artist_id: &ArtistId, + label: &'static str, + container: &mut Option, + url: S, + ) -> Result<(), Error> { + let url: T = url.as_ref().try_into()?; + + if let Some(current) = container { + if current == &url { + return Err(Error::CollectionError(format!( + "artist '{}' already has this {} URL: {}", + artist_id, + label, + current.as_ref() + ))); + } else { + return Err(Error::CollectionError(format!( + "artist '{}' already has a different {} URL: {}", + artist_id, + label, + current.as_ref() + ))); + } } - self.properties.musicbrainz = Some(MusicBrainz::new(url)?); + _ = container.insert(url); Ok(()) } - fn remove_musicbrainz_url>(&mut self, url: S) -> Result<(), Error> { - if self.properties.musicbrainz.is_none() { + fn remove_unique_url< + S: AsRef, + T: for<'a> TryFrom<&'a str, Error = Error> + Eq + AsRef, + >( + artist_id: &ArtistId, + label: &'static str, + container: &mut Option, + url: S, + ) -> Result<(), Error> { + if container.is_none() { return Err(Error::CollectionError(format!( - "artist '{}' does not have a MusicBrainz URL", - self.id + "artist '{}' does not have a {} URL", + artist_id, label ))); } - if self.properties.musicbrainz.as_ref().unwrap().0.as_str() == url.as_ref() { - self.properties.musicbrainz = None; + let url: T = url.as_ref().try_into()?; + if container.as_ref().unwrap() == &url { + _ = container.take(); Ok(()) } else { Err(Error::CollectionError(format!( - "artist '{}' does not have this MusicBrainz URL {}", - self.id, + "artist '{}' does not have this {} URL: {}", + artist_id, + label, url.as_ref(), ))) } } - fn set_musicbrainz_url>(&mut self, url: S) -> Result<(), Error> { - self.properties.musicbrainz = Some(MusicBrainz::new(url)?); + 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_musicbrainz_url(&mut self) -> Result<(), Error> { - self.properties.musicbrainz = None; - Ok(()) + fn clear_unique_url(container: &mut Option) { + _ = container.take(); } - fn add_musicbutler_urls>(&mut self, urls: Vec) -> Result<(), Error> { + fn add_multi_urls< + S: AsRef, + T: for<'a> TryFrom<&'a str, Error = Error> + Eq + AsRef, + >( + artist_id: &ArtistId, + label: &'static str, + container: &mut Vec, + urls: Vec, + ) -> Result<(), Error> { // Convert into URLs first to facilitate later comparison. - let urls: Result, Error> = urls.iter().map(MusicButler::new).collect(); + let urls: Result, Error> = urls.iter().map(|url| url.as_ref().try_into()).collect(); let mut urls = urls?; // Do not check and insert. First check if any of the provided URLs already exist so that // the vector remains unchanged in case of failure. - let overlap: Vec<&MusicButler> = urls - .iter() - .filter(|url| self.properties.musicbutler.contains(url)) - .collect(); + let overlap: Vec<&T> = urls.iter().filter(|url| container.contains(url)).collect(); if !overlap.is_empty() { return Err(Error::CollectionError(format!( - "artist '{}' already has these MusicButler URL(s): {:?}", - self.id, overlap + "artist '{}' already has these {} URL(s): {}", + artist_id, + label, + overlap.iter().map(|url| url.as_ref()).format(", ") ))); } - self.properties.musicbutler.append(&mut urls); + container.append(&mut urls); Ok(()) } - fn remove_musicbutler_urls>(&mut self, urls: Vec) -> Result<(), Error> { + fn remove_multi_urls< + S: AsRef, + T: for<'a> TryFrom<&'a str, Error = Error> + Eq + AsRef, + >( + artist_id: &ArtistId, + label: &'static str, + container: &mut Vec, + urls: Vec, + ) -> Result<(), Error> { // Convert into URLs first to facilitate later comparison. - let urls: Result, Error> = urls.iter().map(MusicButler::new).collect(); + let urls: Result, Error> = urls.iter().map(|url| url.as_ref().try_into()).collect(); let urls = urls?; // Do not check and insert. First check if any of the provided URLs already exist so that // the vector remains unchanged in case of failure. - let difference: Vec<&MusicButler> = urls - .iter() - .filter(|url| !self.properties.musicbutler.contains(url)) - .collect(); + let difference: Vec<&T> = urls.iter().filter(|url| !container.contains(url)).collect(); if !difference.is_empty() { return Err(Error::CollectionError(format!( - "artist '{}' does not have these MusicButler URL(s): {:?}", - self.id, difference + "artist '{}' does not have these {} URL(s): {}", + artist_id, + label, + difference.iter().map(|url| url.as_ref()).format(", ") ))); } - let musicbutler = mem::take(&mut self.properties.musicbutler); - self.properties.musicbutler = musicbutler - .into_iter() - .filter(|url| !urls.contains(url)) - .collect(); + container.retain(|url| !urls.contains(url)); Ok(()) } - fn set_musicbutler_urls>(&mut self, urls: Vec) -> Result<(), Error> { - let urls: Result, Error> = urls.iter().map(MusicButler::new).collect(); - self.properties.musicbutler = urls?; + fn set_multi_urls, T: for<'a> TryFrom<&'a str, Error = Error>>( + container: &mut Vec, + urls: Vec, + ) -> Result<(), Error> { + let urls: Result, Error> = urls.iter().map(|url| url.as_ref().try_into()).collect(); + let mut urls = urls?; + container.clear(); + container.append(&mut urls); Ok(()) } - fn clear_musicbutler_urls(&mut self) -> Result<(), Error> { - self.properties.musicbutler.clear(); - Ok(()) + fn clear_multi_urls(container: &mut Vec) { + container.clear(); } + + artist_unique_url_dispatch!( + add_musicbrainz_url, + remove_musicbrainz_url, + set_musicbrainz_url, + clear_musicbrainz_url, + "MusicBrainz", + musicbrainz + ); + + artist_multi_url_dispatch!( + add_musicbutler_urls, + remove_musicbutler_urls, + set_musicbutler_urls, + clear_musicbutler_urls, + "MusicButler", + musicbutler + ); + + artist_multi_url_dispatch!( + add_bandcamp_urls, + remove_bandcamp_urls, + set_bandcamp_urls, + clear_bandcamp_urls, + "Bandcamp", + bandcamp + ); + + artist_unique_url_dispatch!( + add_qobuz_url, + remove_qobuz_url, + set_qobuz_url, + clear_qobuz_url, + "Qobuz", + qobuz + ); } impl PartialOrd for Artist { @@ -597,6 +777,72 @@ pub struct MusicHoard { collection: Collection, } +macro_rules! music_hoard_unique_url_dispatch { + ($add:ident, $remove:ident, $set:ident, $clear:ident) => { + pub fn $add, S: AsRef>( + &mut self, + artist_id: ID, + url: S, + ) -> Result<(), Error> { + self.get_artist_or_err(artist_id.as_ref())?.$add(url) + } + + pub fn $remove, S: AsRef>( + &mut self, + artist_id: ID, + url: S, + ) -> Result<(), Error> { + self.get_artist_or_err(artist_id.as_ref())?.$remove(url) + } + + pub fn $set, S: AsRef>( + &mut self, + artist_id: ID, + url: S, + ) -> Result<(), Error> { + self.get_artist_or_err(artist_id.as_ref())?.$set(url) + } + + pub fn $clear>(&mut self, artist_id: ID) -> Result<(), Error> { + self.get_artist_or_err(artist_id.as_ref())?.$clear(); + Ok(()) + } + }; +} + +macro_rules! music_hoard_multi_url_dispatch { + ($add:ident, $remove:ident, $set:ident, $clear:ident) => { + pub fn $add, S: AsRef>( + &mut self, + artist_id: ID, + urls: Vec, + ) -> Result<(), Error> { + self.get_artist_or_err(artist_id.as_ref())?.$add(urls) + } + + pub fn $remove, S: AsRef>( + &mut self, + artist_id: ID, + urls: Vec, + ) -> Result<(), Error> { + self.get_artist_or_err(artist_id.as_ref())?.$remove(urls) + } + + pub fn $set, S: AsRef>( + &mut self, + artist_id: ID, + urls: Vec, + ) -> Result<(), Error> { + self.get_artist_or_err(artist_id.as_ref())?.$set(urls) + } + + pub fn $clear>(&mut self, artist_id: ID) -> Result<(), Error> { + self.get_artist_or_err(artist_id.as_ref())?.$clear(); + Ok(()) + } + }; +} + impl MusicHoard { /// Create a new [`MusicHoard`] with the provided [`ILibrary`] and [`IDatabase`]. pub fn new(library: Option, database: Option) -> Self { @@ -687,75 +933,33 @@ impl MusicHoard { } } - pub fn add_musicbrainz_url, S: AsRef>( - &mut self, - artist_id: ID, - url: S, - ) -> Result<(), Error> { - self.get_artist_or_err(artist_id.as_ref())? - .add_musicbrainz_url(url) - } + music_hoard_unique_url_dispatch!( + add_musicbrainz_url, + remove_musicbrainz_url, + set_musicbrainz_url, + clear_musicbrainz_url + ); - pub fn remove_musicbrainz_url, S: AsRef>( - &mut self, - artist_id: ID, - url: S, - ) -> Result<(), Error> { - self.get_artist_or_err(artist_id.as_ref())? - .remove_musicbrainz_url(url) - } + music_hoard_multi_url_dispatch!( + add_musicbutler_urls, + remove_musicbutler_urls, + set_musicbutler_urls, + clear_musicbutler_urls + ); - pub fn set_musicbrainz_url, S: AsRef>( - &mut self, - artist_id: ID, - url: S, - ) -> Result<(), Error> { - self.get_artist_or_err(artist_id.as_ref())? - .set_musicbrainz_url(url) - } + music_hoard_multi_url_dispatch!( + add_bandcamp_urls, + remove_bandcamp_urls, + set_bandcamp_urls, + clear_bandcamp_urls + ); - pub fn clear_musicbrainz_url>( - &mut self, - artist_id: ID, - ) -> Result<(), Error> { - self.get_artist_or_err(artist_id.as_ref())? - .clear_musicbrainz_url() - } - - pub fn add_musicbutler_urls, S: AsRef>( - &mut self, - artist_id: ID, - urls: Vec, - ) -> Result<(), Error> { - self.get_artist_or_err(artist_id.as_ref())? - .add_musicbutler_urls(urls) - } - - pub fn remove_musicbutler_urls, S: AsRef>( - &mut self, - artist_id: ID, - urls: Vec, - ) -> Result<(), Error> { - self.get_artist_or_err(artist_id.as_ref())? - .remove_musicbutler_urls(urls) - } - - pub fn set_musicbutler_urls, S: AsRef>( - &mut self, - artist_id: ID, - urls: Vec, - ) -> Result<(), Error> { - self.get_artist_or_err(artist_id.as_ref())? - .set_musicbutler_urls(urls) - } - - pub fn clear_musicbutler_urls>( - &mut self, - artist_id: ID, - ) -> Result<(), Error> { - self.get_artist_or_err(artist_id.as_ref())? - .clear_musicbutler_urls() - } + music_hoard_unique_url_dispatch!( + add_qobuz_url, + remove_qobuz_url, + set_qobuz_url, + clear_qobuz_url + ); fn sort(collection: &mut [Artist]) { collection.sort_unstable();