Rip MusicHoard apart from internal structures
All checks were successful
Cargo CI / Build and Test (pull_request) Successful in 1m8s
Cargo CI / Lint (pull_request) Successful in 42s

This commit is contained in:
Wojciech Kozlowski 2024-01-21 18:06:22 +01:00
parent 6e9249e265
commit 0e5d02d73e
7 changed files with 1871 additions and 1851 deletions

673
src/collection/mod.rs Normal file
View File

@ -0,0 +1,673 @@
use std::{
cmp::Ordering,
fmt::{self, Debug, Display},
iter::Peekable,
mem,
};
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<S: AsRef<str>>(url: S) -> Result<Self, Error> {
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<S: Into<String>>(url: S) -> InvalidUrlError {
InvalidUrlError {
url_type: UrlType::MusicBrainz,
url: url.into(),
}
}
}
impl AsRef<str> 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<Self, Self::Error> {
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<S: AsRef<str>>(url: S) -> Result<Self, Error> {
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<S: Into<String>>(url: S) -> InvalidUrlError {
InvalidUrlError {
url_type: UrlType::MusicButler,
url: url.into(),
}
}
}
impl AsRef<str> 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<Self, Self::Error> {
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<S: AsRef<str>>(url: S) -> Result<Self, Error> {
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<S: Into<String>>(url: S) -> InvalidUrlError {
InvalidUrlError {
url_type: UrlType::Bandcamp,
url: url.into(),
}
}
}
impl AsRef<str> 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<Self, Self::Error> {
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<S: AsRef<str>>(url: S) -> Result<Self, Error> {
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<S: Into<String>>(url: S) -> InvalidUrlError {
InvalidUrlError {
url_type: UrlType::Qobuz,
url: url.into(),
}
}
}
impl AsRef<str> 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<Self, Self::Error> {
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<String>,
pub quality: Quality,
}
impl PartialOrd for Track {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
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<Track>,
}
impl PartialOrd for Album {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
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<ArtistId> for ArtistId {
fn as_ref(&self) -> &ArtistId {
self
}
}
impl ArtistId {
pub fn new<S: Into<String>>(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<MusicBrainz>,
pub musicbutler: Vec<MusicButler>,
pub bandcamp: Vec<Bandcamp>,
pub qobuz: Option<Qobuz>,
}
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<ArtistId>,
pub properties: ArtistProperties,
pub albums: Vec<Album>,
}
macro_rules! artist_unique_url_dispatch {
($field:ident) => {
paste! {
pub fn [<add_ $field _url>]<S: AsRef<str>>(&mut self, url: S) -> Result<(), Error> {
Self::add_unique_url(&mut self.properties.$field, url)
}
pub fn [<remove_ $field _url>]<S: AsRef<str>>(&mut self, url: S) -> Result<(), Error> {
Self::remove_unique_url(&mut self.properties.$field, url)
}
pub fn [<set_ $field _url>]<S: AsRef<str>>(&mut self, url: S) -> Result<(), Error> {
Self::set_unique_url(&mut self.properties.$field, url)
}
pub fn [<clear_ $field _url>](&mut self) {
Self::clear_unique_url(&mut self.properties.$field);
}
}
};
}
macro_rules! artist_multi_url_dispatch {
($field:ident) => {
paste! {
pub fn [<add_ $field _urls>]<S: AsRef<str>>(&mut self, urls: Vec<S>) -> Result<(), Error> {
Self::add_multi_urls(&mut self.properties.$field, urls)
}
pub fn [<remove_ $field _urls>]<S: AsRef<str>>(&mut self, urls: Vec<S>) -> Result<(), Error> {
Self::remove_multi_urls(&mut self.properties.$field, urls)
}
pub fn [<set_ $field _urls>]<S: AsRef<str>>(&mut self, urls: Vec<S>) -> Result<(), Error> {
Self::set_multi_urls(&mut self.properties.$field, urls)
}
pub fn [<clear_ $field _urls>](&mut self) {
Self::clear_multi_urls(&mut self.properties.$field);
}
}
};
}
impl Artist {
/// Create new [`Artist`] with the given [`ArtistId`].
pub fn new<ID: Into<ArtistId>>(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<SORT: Into<ArtistId>>(&mut self, sort: SORT) {
self.sort = Some(sort.into());
}
pub fn clear_sort_key(&mut self) {
_ = self.sort.take();
}
fn add_unique_url<S: AsRef<str>, T: for<'a> TryFrom<&'a str, Error = Error> + Eq + Display>(
container: &mut Option<T>,
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<S: AsRef<str>, T: for<'a> TryFrom<&'a str, Error = Error> + Eq>(
container: &mut Option<T>,
url: S,
) -> Result<(), Error> {
let url: T = url.as_ref().try_into()?;
if container == &Some(url) {
_ = container.take();
}
Ok(())
}
fn set_unique_url<S: AsRef<str>, T: for<'a> TryFrom<&'a str, Error = Error>>(
container: &mut Option<T>,
url: S,
) -> Result<(), Error> {
_ = container.insert(url.as_ref().try_into()?);
Ok(())
}
fn clear_unique_url<T>(container: &mut Option<T>) {
_ = container.take();
}
fn add_multi_urls<S: AsRef<str>, T: for<'a> TryFrom<&'a str, Error = Error> + Eq>(
container: &mut Vec<T>,
urls: Vec<S>,
) -> 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::<Result<Vec<T>, Error>>()?;
container.append(&mut new_urls);
Ok(())
}
fn remove_multi_urls<S: AsRef<str>, T: for<'a> TryFrom<&'a str, Error = Error> + Eq>(
container: &mut Vec<T>,
urls: Vec<S>,
) -> Result<(), Error> {
let urls = urls
.iter()
.map(|url| url.as_ref().try_into())
.collect::<Result<Vec<T>, Error>>()?;
container.retain(|url| !urls.contains(url));
Ok(())
}
fn set_multi_urls<S: AsRef<str>, T: for<'a> TryFrom<&'a str, Error = Error>>(
container: &mut Vec<T>,
urls: Vec<S>,
) -> Result<(), Error> {
let mut urls = urls
.iter()
.map(|url| url.as_ref().try_into())
.collect::<Result<Vec<T>, Error>>()?;
container.clear();
container.append(&mut urls);
Ok(())
}
fn clear_multi_urls<T>(container: &mut Vec<T>) {
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<std::cmp::Ordering> {
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();
}
}
// FIXME: should not be public
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<T: Ord + Eq>(this: &mut Vec<T>, mut other: Vec<T>) {
this.append(&mut other);
this.sort_unstable();
this.dedup();
}
}
struct MergeSorted<L, R>
where
L: Iterator<Item = R::Item>,
R: Iterator,
{
left: Peekable<L>,
right: Peekable<R>,
}
impl<L, R> MergeSorted<L, R>
where
L: Iterator<Item = R::Item>,
R: Iterator,
{
fn new(left: L, right: R) -> MergeSorted<L, R> {
MergeSorted {
left: left.peekable(),
right: right.peekable(),
}
}
}
impl<L, R> Iterator for MergeSorted<L, R>
where
L: Iterator<Item = R::Item>,
R: Iterator,
L::Item: Ord + Merge,
{
type Item = L::Item;
fn next(&mut self) -> Option<L::Item> {
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(),
}
}
}
/// The collection type. Currently, a collection is a list of artists.
pub type Collection = Vec<Artist>;
#[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());
}
}

1854
src/lib.rs

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
#[cfg(test)]
use mockall::automock;
use crate::Format;
use crate::collection::Format;
use super::{Error, Field, ILibrary, Item, Query};

View File

@ -5,7 +5,7 @@ use std::{collections::HashSet, fmt, num::ParseIntError, str::Utf8Error};
#[cfg(test)]
use mockall::automock;
use crate::Format;
use crate::collection::Format;
#[cfg(feature = "library-beets")]
pub mod beets;

View File

@ -1,6 +1,6 @@
use once_cell::sync::Lazy;
use crate::{library::Item, Format};
use crate::{collection::Format, library::Item};
pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
vec![

View File

@ -1,4 +1,4 @@
use crate::{
use crate::collection::{
Album, AlbumId, Artist, ArtistId, ArtistProperties, Bandcamp, Format, MusicBrainz, MusicButler,
Qobuz, Quality, Track, TrackId,
};

1185
src/tests/mod.rs Normal file

File diff suppressed because it is too large Load Diff