Split lib.rs into smaller files #115
42
src/collection/album.rs
Normal file
42
src/collection/album.rs
Normal file
@ -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<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();
|
||||||
|
}
|
||||||
|
}
|
527
src/collection/artist.rs
Normal file
527
src/collection/artist.rs
Normal file
@ -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<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 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
}
|
@ -1,572 +1,8 @@
|
|||||||
use std::{
|
pub mod album;
|
||||||
cmp::Ordering,
|
pub mod artist;
|
||||||
fmt::{self, Debug, Display},
|
pub mod track;
|
||||||
iter::Peekable,
|
|
||||||
mem,
|
|
||||||
};
|
|
||||||
|
|
||||||
use paste::paste;
|
use std::{cmp::Ordering, iter::Peekable};
|
||||||
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
|
// FIXME: should not be public
|
||||||
pub trait Merge {
|
pub trait Merge {
|
||||||
@ -633,41 +69,5 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The collection type. Currently, a collection is a list of artists.
|
/// The collection alias type for convenience.
|
||||||
pub type Collection = Vec<Artist>;
|
pub type Collection = Vec<artist::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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
51
src/collection/track.rs
Normal file
51
src/collection/track.rs
Normal file
@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
10
src/lib.rs
10
src/lib.rs
@ -10,15 +10,17 @@ use std::{
|
|||||||
mem,
|
mem,
|
||||||
};
|
};
|
||||||
|
|
||||||
use collection::{InvalidUrlError, Merge};
|
use collection::{artist::InvalidUrlError, Merge};
|
||||||
use database::IDatabase;
|
use database::IDatabase;
|
||||||
use library::{ILibrary, Item, Query};
|
use library::{ILibrary, Item, Query};
|
||||||
use paste::paste;
|
use paste::paste;
|
||||||
|
|
||||||
// TODO: validate the re-exports.
|
// FIXME: validate the re-exports.
|
||||||
pub use collection::{
|
pub use collection::{
|
||||||
Album, AlbumId, Artist, ArtistId, ArtistProperties, Bandcamp, Collection, Format, MusicBrainz,
|
album::{Album, AlbumId},
|
||||||
MusicButler, Qobuz, Quality, Track, TrackId,
|
artist::{Artist, ArtistId, ArtistProperties, Bandcamp, MusicBrainz, MusicButler, Qobuz},
|
||||||
|
track::{Format, Quality, Track, TrackId},
|
||||||
|
Collection,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Error type for `musichoard`.
|
/// Error type for `musichoard`.
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use mockall::automock;
|
use mockall::automock;
|
||||||
|
|
||||||
use crate::collection::Format;
|
use crate::collection::track::Format;
|
||||||
|
|
||||||
use super::{Error, Field, ILibrary, Item, Query};
|
use super::{Error, Field, ILibrary, Item, Query};
|
||||||
|
|
||||||
|
@ -5,7 +5,7 @@ use std::{collections::HashSet, fmt, num::ParseIntError, str::Utf8Error};
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use mockall::automock;
|
use mockall::automock;
|
||||||
|
|
||||||
use crate::collection::Format;
|
use crate::collection::track::Format;
|
||||||
|
|
||||||
#[cfg(feature = "library-beets")]
|
#[cfg(feature = "library-beets")]
|
||||||
pub mod beets;
|
pub mod beets;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
|
||||||
use crate::{collection::Format, library::Item};
|
use crate::{collection::track::Format, library::Item};
|
||||||
|
|
||||||
pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
|
pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
|
||||||
vec![
|
vec![
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
use crate::collection::{
|
use crate::collection::{
|
||||||
Album, AlbumId, Artist, ArtistId, ArtistProperties, Bandcamp, Format, MusicBrainz, MusicButler,
|
album::{Album, AlbumId},
|
||||||
Qobuz, Quality, Track, TrackId,
|
artist::{Artist, ArtistId, ArtistProperties, Bandcamp, MusicBrainz, MusicButler, Qobuz},
|
||||||
|
track::{Format, Quality, Track, TrackId},
|
||||||
};
|
};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
use mockall::predicate;
|
use mockall::predicate;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use collection::{Bandcamp, Format, Merge, MusicBrainz, MusicButler, Qobuz};
|
use collection::{
|
||||||
|
artist::{Bandcamp, MusicBrainz, MusicButler, Qobuz},
|
||||||
|
track::Format,
|
||||||
|
Merge,
|
||||||
|
};
|
||||||
use database::MockIDatabase;
|
use database::MockIDatabase;
|
||||||
use library::{testmod::LIBRARY_ITEMS, MockILibrary};
|
use library::{testmod::LIBRARY_ITEMS, MockILibrary};
|
||||||
use testlib::{FULL_COLLECTION, LIBRARY_COLLECTION};
|
use testlib::{FULL_COLLECTION, LIBRARY_COLLECTION};
|
||||||
|
Loading…
Reference in New Issue
Block a user