use std::{fmt, mem}; use url::Url; use uuid::Uuid; use crate::core::collection::Error; const MB_DOMAIN: &str = "musicbrainz.org"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct Mbid(Uuid); impl Mbid { pub fn uuid(&self) -> &Uuid { &self.0 } } impl From for Mbid { fn from(value: Uuid) -> Self { Mbid(value) } } macro_rules! try_from_impl_for_mbid { ($from:ty) => { impl TryFrom<$from> for Mbid { type Error = Error; fn try_from(value: $from) -> Result { Ok(Uuid::parse_str(value.as_ref())?.into()) } } }; } try_from_impl_for_mbid!(&str); try_from_impl_for_mbid!(&String); try_from_impl_for_mbid!(String); #[derive(Clone, Debug, PartialEq, Eq)] pub enum MbRefOption { Some(T), CannotHaveMbid, None, } impl MbRefOption { pub fn or(self, optb: MbRefOption) -> MbRefOption { match (&self, &optb) { (MbRefOption::Some(_), _) | (MbRefOption::CannotHaveMbid, MbRefOption::None) => self, _ => optb, } } pub fn replace(&mut self, value: T) -> MbRefOption { mem::replace(self, MbRefOption::Some(value)) } pub fn take(&mut self) -> MbRefOption { mem::replace(self, MbRefOption::None) } } #[derive(Clone, Debug, PartialEq, Eq)] struct MusicBrainzRef { mbid: Mbid, url: Url, } pub trait IMusicBrainzRef { fn mbid(&self) -> &Mbid; fn url(&self) -> &Url; fn entity() -> &'static str; } #[derive(Clone, Debug, PartialEq, Eq)] pub struct MbArtistRef(MusicBrainzRef); #[derive(Clone, Debug, PartialEq, Eq)] pub struct MbAlbumRef(MusicBrainzRef); macro_rules! impl_imusicbrainzref { ($mbref:ident, $entity:literal) => { impl IMusicBrainzRef for $mbref { fn mbid(&self) -> &Mbid { &self.0.mbid } fn url(&self) -> &Url { &self.0.url } fn entity() -> &'static str { $entity } } impl TryFrom for $mbref { type Error = Error; fn try_from(url: Url) -> Result { Ok($mbref(MusicBrainzRef::from_url(url, $mbref::entity())?)) } } impl From for $mbref { fn from(uuid: Uuid) -> Self { $mbref(MusicBrainzRef::from_mbid(uuid, $mbref::entity())) } } impl From for $mbref { fn from(mbid: Mbid) -> Self { $mbref(MusicBrainzRef::from_mbid(mbid, $mbref::entity())) } } impl $mbref { pub fn from_url_str>(url: S) -> Result { let url: Url = url.as_ref().try_into()?; url.try_into() } } impl $mbref { pub fn from_uuid_str>(uuid: S) -> Result { let uuid: Uuid = uuid.as_ref().try_into()?; Ok(uuid.into()) } } }; } impl_imusicbrainzref!(MbArtistRef, "artist"); impl_imusicbrainzref!(MbAlbumRef, "release-group"); impl MusicBrainzRef { fn from_url(url: Url, entity: &'static str) -> Result { if !url .domain() .map(|u| u.ends_with(MB_DOMAIN)) .unwrap_or(false) { return Err(Self::invalid_url_error(url, entity)); } // path_segments only returns an empty iterator if the URL cannot-be-a-base. However, if the // URL cannot-be-a-base then it will fail the check above already as it won't have a domain. if url.path_segments().and_then(|mut ps| ps.nth(0)).unwrap() != entity { return Err(Self::invalid_url_error(url, entity)); } let mbid = match url.path_segments().and_then(|mut ps| ps.nth(1)) { Some(segment) => Uuid::try_parse(segment)?.into(), None => return Err(Self::invalid_url_error(url, entity)), }; Ok(MusicBrainzRef { mbid, url }) } fn from_mbid>(id: ID, entity: &'static str) -> Self { let mbid = id.into(); let uuid_str = mbid.uuid().to_string(); let url = Url::parse(&format!("https://{MB_DOMAIN}/{entity}/{uuid_str}")).unwrap(); MusicBrainzRef { mbid, url } } fn invalid_url_error(url: U, entity: &'static str) -> Error { Error::UrlError(format!("invalid {entity} MusicBrainz URL: {url}")) } } #[cfg(test)] mod tests { use super::*; #[test] fn artist() { let uuid = "d368baa8-21ca-4759-9731-0b2753071ad8"; let url_str = format!("https://musicbrainz.org/artist/{uuid}"); let mb = MbArtistRef::from_url_str(&url_str).unwrap(); assert_eq!(url_str, mb.url().as_ref()); assert_eq!(uuid, mb.mbid().uuid().to_string()); let mb = MbArtistRef::from_uuid_str(uuid).unwrap(); assert_eq!(url_str, mb.url().as_ref()); assert_eq!(uuid, mb.mbid().uuid().to_string()); let mbid: Mbid = TryInto::::try_into(uuid).unwrap().into(); let mb: MbArtistRef = mbid.into(); assert_eq!(url_str, mb.url().as_ref()); assert_eq!(uuid, mb.mbid().uuid().to_string()); let url: Url = url_str.as_str().try_into().unwrap(); let mb: MbArtistRef = url.try_into().unwrap(); assert_eq!(url_str, mb.url().as_ref()); assert_eq!(uuid, mb.mbid().uuid().to_string()); } #[test] fn album() { let uuid = "d368baa8-21ca-4759-9731-0b2753071ad8"; let url_str = format!("https://musicbrainz.org/release-group/{uuid}"); let mb = MbAlbumRef::from_url_str(&url_str).unwrap(); assert_eq!(url_str, mb.url().as_ref()); assert_eq!(uuid, mb.mbid().uuid().to_string()); let mb = MbAlbumRef::from_uuid_str(uuid).unwrap(); assert_eq!(url_str, mb.url().as_ref()); assert_eq!(uuid, mb.mbid().uuid().to_string()); let mbid: Mbid = TryInto::::try_into(uuid).unwrap().into(); let mb: MbAlbumRef = mbid.into(); assert_eq!(url_str, mb.url().as_ref()); assert_eq!(uuid, mb.mbid().uuid().to_string()); let url: Url = url_str.as_str().try_into().unwrap(); let mb: MbAlbumRef = url.try_into().unwrap(); assert_eq!(url_str, mb.url().as_ref()); assert_eq!(uuid, mb.mbid().uuid().to_string()); } #[test] fn not_a_url() { let url = "not a url at all"; let expected_error: Error = url::ParseError::RelativeUrlWithoutBase.into(); let actual_error = MbArtistRef::from_url_str(url).unwrap_err(); assert_eq!(actual_error, expected_error); assert_eq!(actual_error.to_string(), expected_error.to_string()); } #[test] fn invalid_url() { let url = "https://www.musicbutler.io/artist-page/483340948"; let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}")); let actual_error = MbArtistRef::from_url_str(url).unwrap_err(); assert_eq!(actual_error, expected_error); assert_eq!(actual_error.to_string(), expected_error.to_string()); } #[test] fn artist_invalid_type() { let url = "https://musicbrainz.org/release-group/i-am-not-a-uuid"; let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}")); let actual_error = MbArtistRef::from_url_str(url).unwrap_err(); assert_eq!(actual_error, expected_error); assert_eq!(actual_error.to_string(), expected_error.to_string()); } #[test] fn album_invalid_type() { let url = "https://musicbrainz.org/artist/i-am-not-a-uuid"; let expected_error = Error::UrlError(format!("invalid release-group MusicBrainz URL: {url}")); let actual_error = MbAlbumRef::from_url_str(url).unwrap_err(); assert_eq!(actual_error, expected_error); assert_eq!(actual_error.to_string(), expected_error.to_string()); } #[test] fn invalid_uuid() { let url = "https://musicbrainz.org/artist/i-am-not-a-uuid"; let expected_error: Error = Uuid::try_parse("i-am-not-a-uuid").unwrap_err().into(); let actual_error = MbArtistRef::from_url_str(url).unwrap_err(); assert_eq!(actual_error, expected_error); assert_eq!(actual_error.to_string(), expected_error.to_string()); } #[test] fn missing_type() { let url = "https://musicbrainz.org"; let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}/")); let actual_error = MbArtistRef::from_url_str(url).unwrap_err(); assert_eq!(actual_error, expected_error); assert_eq!(actual_error.to_string(), expected_error.to_string()); } #[test] fn missing_uuid() { let url = "https://musicbrainz.org/artist"; let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}")); let actual_error = MbArtistRef::from_url_str(url).unwrap_err(); assert_eq!(actual_error, expected_error); assert_eq!(actual_error.to_string(), expected_error.to_string()); } }