musichoard/src/core/collection/musicbrainz.rs
Wojciech Kozlowski 5d510ff787
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m0s
Cargo CI / Lint (push) Successful in 1m7s
Integrate browse API into TUI MB daemon (#230)
Part 2 of #160

Reviewed-on: #230
2024-10-06 15:32:46 +02:00

290 lines
9.1 KiB
Rust

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<Uuid> 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<Self, Self::Error> {
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<T> {
Some(T),
CannotHaveMbid,
None,
}
impl<T> MbRefOption<T> {
pub fn or(self, optb: MbRefOption<T>) -> MbRefOption<T> {
match (&self, &optb) {
(MbRefOption::Some(_), _) | (MbRefOption::CannotHaveMbid, MbRefOption::None) => self,
_ => optb,
}
}
pub fn replace(&mut self, value: T) -> MbRefOption<T> {
mem::replace(self, MbRefOption::Some(value))
}
pub fn take(&mut self) -> MbRefOption<T> {
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<Url> for $mbref {
type Error = Error;
fn try_from(url: Url) -> Result<Self, Self::Error> {
Ok($mbref(MusicBrainzRef::from_url(url, $mbref::entity())?))
}
}
impl From<Uuid> for $mbref {
fn from(uuid: Uuid) -> Self {
$mbref(MusicBrainzRef::from_mbid(uuid, $mbref::entity()))
}
}
impl From<Mbid> for $mbref {
fn from(mbid: Mbid) -> Self {
$mbref(MusicBrainzRef::from_mbid(mbid, $mbref::entity()))
}
}
impl $mbref {
pub fn from_url_str<S: AsRef<str>>(url: S) -> Result<Self, Error> {
let url: Url = url.as_ref().try_into()?;
url.try_into()
}
}
impl $mbref {
pub fn from_uuid_str<S: AsRef<str>>(uuid: S) -> Result<Self, Error> {
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<Self, Error> {
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: Into<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<U: fmt::Display>(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::<Uuid>::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::<Uuid>::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());
}
}