Break down the musichoard files #165

Merged
wojtek merged 9 commits from 164---break-down-the-musichoard-files into main 2024-03-09 22:52:04 +01:00
20 changed files with 1524 additions and 1356 deletions

View File

@ -5,10 +5,10 @@ use structopt::{clap::AppSettings, StructOpt};
use musichoard::{
collection::{album::AlbumId, artist::ArtistId},
database::json::{backend::JsonDatabaseFileBackend, JsonDatabase},
MusicHoard, MusicHoardBuilder, NoLibrary,
IMusicHoardDatabase, MusicHoard, MusicHoardBuilder, NoLibrary,
};
type MH = MusicHoard<NoLibrary, JsonDatabase<JsonDatabaseFileBackend>>;
type MH = MusicHoard<JsonDatabase<JsonDatabaseFileBackend>, NoLibrary>;
#[derive(StructOpt, Debug)]
#[structopt(about = "musichoard-edit: edit the MusicHoard database",

View File

@ -5,7 +5,7 @@ use std::{
use crate::core::collection::{
merge::{Merge, MergeSorted, WithId},
musicbrainz::MusicBrainz,
musicbrainz::MusicBrainzUrl,
track::{Track, TrackFormat},
};
@ -15,7 +15,7 @@ pub struct Album {
pub id: AlbumId,
pub date: AlbumDate,
pub seq: AlbumSeq,
pub musicbrainz: Option<MusicBrainz>,
pub musicbrainz: Option<MusicBrainzUrl>,
pub tracks: Vec<Track>,
}

View File

@ -7,7 +7,7 @@ use std::{
use crate::core::collection::{
album::Album,
merge::{Merge, MergeCollections, WithId},
musicbrainz::MusicBrainz,
musicbrainz::MusicBrainzUrl,
};
/// An artist.
@ -15,7 +15,7 @@ use crate::core::collection::{
pub struct Artist {
pub id: ArtistId,
pub sort: Option<ArtistId>,
pub musicbrainz: Option<MusicBrainz>,
pub musicbrainz: Option<MusicBrainzUrl>,
pub properties: HashMap<String, Vec<String>>,
pub albums: Vec<Album>,
}
@ -58,7 +58,7 @@ impl Artist {
_ = self.sort.take();
}
pub fn set_musicbrainz_url(&mut self, url: MusicBrainz) {
pub fn set_musicbrainz_url(&mut self, url: MusicBrainzUrl) {
_ = self.musicbrainz.insert(url);
}
@ -216,19 +216,19 @@ mod tests {
fn set_clear_musicbrainz_url() {
let mut artist = Artist::new(ArtistId::new("an artist"));
let mut expected: Option<MusicBrainz> = None;
let mut expected: Option<MusicBrainzUrl> = None;
assert_eq!(artist.musicbrainz, expected);
// Setting a URL on an artist.
artist.set_musicbrainz_url(MusicBrainz::artist_from_str(MUSICBRAINZ).unwrap());
_ = expected.insert(MusicBrainz::artist_from_str(MUSICBRAINZ).unwrap());
artist.set_musicbrainz_url(MusicBrainzUrl::artist_from_str(MUSICBRAINZ).unwrap());
_ = expected.insert(MusicBrainzUrl::artist_from_str(MUSICBRAINZ).unwrap());
assert_eq!(artist.musicbrainz, expected);
artist.set_musicbrainz_url(MusicBrainz::artist_from_str(MUSICBRAINZ).unwrap());
artist.set_musicbrainz_url(MusicBrainzUrl::artist_from_str(MUSICBRAINZ).unwrap());
assert_eq!(artist.musicbrainz, expected);
artist.set_musicbrainz_url(MusicBrainz::artist_from_str(MUSICBRAINZ_2).unwrap());
_ = expected.insert(MusicBrainz::artist_from_str(MUSICBRAINZ_2).unwrap());
artist.set_musicbrainz_url(MusicBrainzUrl::artist_from_str(MUSICBRAINZ_2).unwrap());
_ = expected.insert(MusicBrainzUrl::artist_from_str(MUSICBRAINZ_2).unwrap());
assert_eq!(artist.musicbrainz, expected);
// Clearing URLs.

View File

@ -5,17 +5,16 @@ use uuid::Uuid;
use crate::core::collection::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;
}
/// MusicBrainz reference.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct MusicBrainz(Url);
pub struct MusicBrainzUrl(Url);
impl MusicBrainzUrl {
pub fn mbid(&self) -> &str {
// The URL is assumed to have been validated.
self.0.path_segments().and_then(|mut ps| ps.nth(1)).unwrap()
}
impl MusicBrainz {
pub fn artist_from_str<S: AsRef<str>>(url: S) -> Result<Self, Error> {
Self::artist_from_url(url.as_ref().try_into()?)
}
@ -52,7 +51,7 @@ impl MusicBrainz {
None => return Err(Self::invalid_url_error(url, mb_type)),
};
Ok(MusicBrainz(url))
Ok(MusicBrainzUrl(url))
}
fn invalid_url_error<U: Display>(url: U, mb_type: &str) -> Error {
@ -60,19 +59,12 @@ impl MusicBrainz {
}
}
impl AsRef<str> for MusicBrainz {
impl AsRef<str> for MusicBrainzUrl {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
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()
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -82,12 +74,12 @@ mod tests {
let uuid = "d368baa8-21ca-4759-9731-0b2753071ad8";
let url_str = format!("https://musicbrainz.org/artist/{uuid}");
let mb = MusicBrainz::artist_from_str(&url_str).unwrap();
let mb = MusicBrainzUrl::artist_from_str(&url_str).unwrap();
assert_eq!(url_str, mb.as_ref());
assert_eq!(uuid, mb.mbid());
let url: Url = url_str.as_str().try_into().unwrap();
let mb = MusicBrainz::artist_from_url(url).unwrap();
let mb = MusicBrainzUrl::artist_from_url(url).unwrap();
assert_eq!(url_str, mb.as_ref());
assert_eq!(uuid, mb.mbid());
}
@ -97,12 +89,12 @@ mod tests {
let uuid = "d368baa8-21ca-4759-9731-0b2753071ad8";
let url_str = format!("https://musicbrainz.org/release-group/{uuid}");
let mb = MusicBrainz::album_from_str(&url_str).unwrap();
let mb = MusicBrainzUrl::album_from_str(&url_str).unwrap();
assert_eq!(url_str, mb.as_ref());
assert_eq!(uuid, mb.mbid());
let url: Url = url_str.as_str().try_into().unwrap();
let mb = MusicBrainz::album_from_url(url).unwrap();
let mb = MusicBrainzUrl::album_from_url(url).unwrap();
assert_eq!(url_str, mb.as_ref());
assert_eq!(uuid, mb.mbid());
}
@ -111,7 +103,7 @@ mod tests {
fn not_a_url() {
let url = "not a url at all";
let expected_error: Error = url::ParseError::RelativeUrlWithoutBase.into();
let actual_error = MusicBrainz::artist_from_str(url).unwrap_err();
let actual_error = MusicBrainzUrl::artist_from_str(url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
}
@ -120,7 +112,7 @@ mod tests {
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 = MusicBrainz::artist_from_str(url).unwrap_err();
let actual_error = MusicBrainzUrl::artist_from_str(url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
}
@ -129,7 +121,7 @@ mod tests {
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 = MusicBrainz::artist_from_str(url).unwrap_err();
let actual_error = MusicBrainzUrl::artist_from_str(url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
}
@ -139,7 +131,7 @@ mod tests {
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 = MusicBrainz::album_from_str(url).unwrap_err();
let actual_error = MusicBrainzUrl::album_from_str(url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
}
@ -148,7 +140,7 @@ mod tests {
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 = MusicBrainz::artist_from_str(url).unwrap_err();
let actual_error = MusicBrainzUrl::artist_from_str(url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
}
@ -157,7 +149,7 @@ mod tests {
fn missing_type() {
let url = "https://musicbrainz.org";
let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}/"));
let actual_error = MusicBrainz::artist_from_str(url).unwrap_err();
let actual_error = MusicBrainzUrl::artist_from_str(url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
}
@ -166,7 +158,7 @@ mod tests {
fn missing_uuid() {
let url = "https://musicbrainz.org/artist";
let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}"));
let actual_error = MusicBrainz::artist_from_str(url).unwrap_err();
let actual_error = MusicBrainzUrl::artist_from_str(url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
}

230
src/core/musichoard/base.rs Normal file
View File

@ -0,0 +1,230 @@
use crate::core::{
collection::{
album::{Album, AlbumId},
artist::{Artist, ArtistId},
Collection, MergeCollections,
},
musichoard::{Error, MusicHoard},
};
pub trait IMusicHoardBase {
fn get_collection(&self) -> &Collection;
}
impl<Database, Library> IMusicHoardBase for MusicHoard<Database, Library> {
fn get_collection(&self) -> &Collection {
&self.collection
}
}
pub trait IMusicHoardBasePrivate {
fn sort_artists(collection: &mut [Artist]);
fn sort_albums_and_tracks<'a, C: Iterator<Item = &'a mut Artist>>(collection: C);
fn merge_collections(&self) -> Collection;
fn get_artist<'a>(collection: &'a Collection, artist_id: &ArtistId) -> Option<&'a Artist>;
fn get_artist_mut<'a>(
collection: &'a mut Collection,
artist_id: &ArtistId,
) -> Option<&'a mut Artist>;
fn get_artist_mut_or_err<'a>(
collection: &'a mut Collection,
artist_id: &ArtistId,
) -> Result<&'a mut Artist, Error>;
fn get_album_mut<'a>(artist: &'a mut Artist, album_id: &AlbumId) -> Option<&'a mut Album>;
fn get_album_mut_or_err<'a>(
artist: &'a mut Artist,
album_id: &AlbumId,
) -> Result<&'a mut Album, Error>;
}
impl<Database, Library> IMusicHoardBasePrivate for MusicHoard<Database, Library> {
fn sort_artists(collection: &mut [Artist]) {
collection.sort_unstable();
}
fn sort_albums_and_tracks<'a, COL: Iterator<Item = &'a mut Artist>>(collection: COL) {
for artist in collection {
artist.albums.sort_unstable();
for album in artist.albums.iter_mut() {
album.tracks.sort_unstable();
}
}
}
fn merge_collections(&self) -> Collection {
MergeCollections::merge(self.library_cache.clone(), self.database_cache.clone())
}
fn get_artist<'a>(collection: &'a Collection, artist_id: &ArtistId) -> Option<&'a Artist> {
collection.iter().find(|a| &a.id == artist_id)
}
fn get_artist_mut<'a>(
collection: &'a mut Collection,
artist_id: &ArtistId,
) -> Option<&'a mut Artist> {
collection.iter_mut().find(|a| &a.id == artist_id)
}
fn get_artist_mut_or_err<'a>(
collection: &'a mut Collection,
artist_id: &ArtistId,
) -> Result<&'a mut Artist, Error> {
Self::get_artist_mut(collection, artist_id).ok_or_else(|| {
Error::CollectionError(format!("artist '{}' is not in the collection", artist_id))
})
}
fn get_album_mut<'a>(artist: &'a mut Artist, album_id: &AlbumId) -> Option<&'a mut Album> {
artist.albums.iter_mut().find(|a| &a.id == album_id)
}
fn get_album_mut_or_err<'a>(
artist: &'a mut Artist,
album_id: &AlbumId,
) -> Result<&'a mut Album, Error> {
Self::get_album_mut(artist, album_id).ok_or_else(|| {
Error::CollectionError(format!(
"album '{}' does not belong to the artist",
album_id
))
})
}
}
#[cfg(test)]
mod tests {
use crate::core::{collection::artist::ArtistId, testmod::FULL_COLLECTION};
use super::*;
#[test]
fn merge_collection_no_overlap() {
let half: usize = FULL_COLLECTION.len() / 2;
let left = FULL_COLLECTION[..half].to_owned();
let right = FULL_COLLECTION[half..].to_owned();
let mut expected = FULL_COLLECTION.to_owned();
expected.sort_unstable();
let mut mh = MusicHoard {
library_cache: left
.clone()
.into_iter()
.map(|a| (a.id.clone(), a))
.collect(),
database_cache: right.clone(),
..Default::default()
};
mh.collection = mh.merge_collections();
assert_eq!(expected, mh.collection);
// The merge is completely non-overlapping so it should be commutative.
let mut mh = MusicHoard {
library_cache: right
.clone()
.into_iter()
.map(|a| (a.id.clone(), a))
.collect(),
database_cache: left.clone(),
..Default::default()
};
mh.collection = mh.merge_collections();
assert_eq!(expected, mh.collection);
}
#[test]
fn merge_collection_overlap() {
let half: usize = FULL_COLLECTION.len() / 2;
let left = FULL_COLLECTION[..(half + 1)].to_owned();
let right = FULL_COLLECTION[half..].to_owned();
let mut expected = FULL_COLLECTION.to_owned();
expected.sort_unstable();
let mut mh = MusicHoard {
library_cache: left
.clone()
.into_iter()
.map(|a| (a.id.clone(), a))
.collect(),
database_cache: right.clone(),
..Default::default()
};
mh.collection = mh.merge_collections();
assert_eq!(expected, mh.collection);
// The merge does not overwrite any data so it should be commutative.
let mut mh = MusicHoard {
library_cache: right
.clone()
.into_iter()
.map(|a| (a.id.clone(), a))
.collect(),
database_cache: left.clone(),
..Default::default()
};
mh.collection = mh.merge_collections();
assert_eq!(expected, mh.collection);
}
#[test]
fn merge_collection_incompatible_sorting() {
// It may be that the same artist in one collection has a "sort" field defined while the
// same artist in the other collection does not. This means that the two collections are not
// sorted consistently. If the merge assumes they are sorted consistently this will lead to
// the same artist appearing twice in the final list. This should not be the case.
// We will mimic this situation by taking the last artist from FULL_COLLECTION and giving it
// a sorting name that would place it in the beginning.
let left = FULL_COLLECTION.to_owned();
let mut right: Vec<Artist> = vec![left.last().unwrap().clone()];
assert!(right.first().unwrap() > left.first().unwrap());
let artist_sort = Some(ArtistId::new("Album_Artist 0"));
right[0].sort = artist_sort.clone();
assert!(right.first().unwrap() < left.first().unwrap());
// The result of the merge should be the same list of artists, but with the last artist now
// in first place.
let mut expected = left.to_owned();
expected.last_mut().as_mut().unwrap().sort = artist_sort.clone();
expected.rotate_right(1);
let mut mh = MusicHoard {
library_cache: left
.clone()
.into_iter()
.map(|a| (a.id.clone(), a))
.collect(),
database_cache: right.clone(),
..Default::default()
};
mh.collection = mh.merge_collections();
assert_eq!(expected, mh.collection);
// The merge overwrites the sort data, but no data is erased so it should be commutative.
let mut mh = MusicHoard {
library_cache: right
.clone()
.into_iter()
.map(|a| (a.id.clone(), a))
.collect(),
database_cache: left.clone(),
..Default::default()
};
mh.collection = mh.merge_collections();
assert_eq!(expected, mh.collection);
}
}

View File

@ -0,0 +1,194 @@
use std::collections::HashMap;
use crate::{
core::{
interface::{database::IDatabase, library::ILibrary},
musichoard::{database::IMusicHoardDatabase, MusicHoard, NoDatabase, NoLibrary},
},
Error,
};
/// Builder for [`MusicHoard`]. Its purpose is to make it easier to set various combinations of
/// library/database or their absence.
pub struct MusicHoardBuilder<Database, Library> {
database: Database,
library: Library,
}
impl Default for MusicHoardBuilder<NoDatabase, NoLibrary> {
/// Create a [`MusicHoardBuilder`].
fn default() -> Self {
Self::new()
}
}
impl MusicHoardBuilder<NoDatabase, NoLibrary> {
/// Create a [`MusicHoardBuilder`].
pub fn new() -> Self {
MusicHoardBuilder {
database: NoDatabase,
library: NoLibrary,
}
}
}
impl<Database, Library> MusicHoardBuilder<Database, Library> {
/// Set a library for [`MusicHoard`].
pub fn set_library<NewLibrary: ILibrary>(
self,
library: NewLibrary,
) -> MusicHoardBuilder<Database, NewLibrary> {
MusicHoardBuilder {
database: self.database,
library,
}
}
/// Set a database for [`MusicHoard`].
pub fn set_database<NewDatabase: IDatabase>(
self,
database: NewDatabase,
) -> MusicHoardBuilder<NewDatabase, Library> {
MusicHoardBuilder {
database,
library: self.library,
}
}
}
impl MusicHoardBuilder<NoDatabase, NoLibrary> {
/// Build [`MusicHoard`] with the currently set library and database.
pub fn build(self) -> MusicHoard<NoDatabase, NoLibrary> {
MusicHoard::empty()
}
}
impl MusicHoard<NoDatabase, NoLibrary> {
/// Create a new [`MusicHoard`] without any library or database.
pub fn empty() -> Self {
MusicHoard {
collection: vec![],
pre_commit: vec![],
database: NoDatabase,
database_cache: vec![],
library: NoLibrary,
library_cache: HashMap::new(),
}
}
}
impl<Library: ILibrary> MusicHoardBuilder<NoDatabase, Library> {
/// Build [`MusicHoard`] with the currently set library and database.
pub fn build(self) -> MusicHoard<NoDatabase, Library> {
MusicHoard::library(self.library)
}
}
impl<Library: ILibrary> MusicHoard<NoDatabase, Library> {
/// Create a new [`MusicHoard`] with the provided [`ILibrary`] and no database.
pub fn library(library: Library) -> Self {
MusicHoard {
collection: vec![],
pre_commit: vec![],
database: NoDatabase,
database_cache: vec![],
library,
library_cache: HashMap::new(),
}
}
}
impl<Database: IDatabase> MusicHoardBuilder<Database, NoLibrary> {
/// Build [`MusicHoard`] with the currently set library and database.
pub fn build(self) -> Result<MusicHoard<Database, NoLibrary>, Error> {
MusicHoard::database(self.database)
}
}
impl<Database: IDatabase> MusicHoard<Database, NoLibrary> {
/// Create a new [`MusicHoard`] with the provided [`IDatabase`] and no library.
pub fn database(database: Database) -> Result<Self, Error> {
let mut mh = MusicHoard {
collection: vec![],
pre_commit: vec![],
database,
database_cache: vec![],
library: NoLibrary,
library_cache: HashMap::new(),
};
mh.reload_database()?;
Ok(mh)
}
}
impl<Database: IDatabase, Library: ILibrary> MusicHoardBuilder<Database, Library> {
/// Build [`MusicHoard`] with the currently set library and database.
pub fn build(self) -> Result<MusicHoard<Database, Library>, Error> {
MusicHoard::new(self.database, self.library)
}
}
impl<Database: IDatabase, Library: ILibrary> MusicHoard<Database, Library> {
/// Create a new [`MusicHoard`] with the provided [`ILibrary`] and [`IDatabase`].
pub fn new(database: Database, library: Library) -> Result<Self, Error> {
let mut mh = MusicHoard {
collection: vec![],
pre_commit: vec![],
database,
database_cache: vec![],
library,
library_cache: HashMap::new(),
};
mh.reload_database()?;
Ok(mh)
}
}
#[cfg(test)]
mod tests {
use crate::core::{
interface::{database::NullDatabase, library::NullLibrary},
musichoard::library::IMusicHoardLibrary,
};
use super::*;
#[test]
fn no_library_no_database() {
MusicHoardBuilder::default().build();
}
#[test]
fn empty() {
let music_hoard = MusicHoard::empty();
assert!(!format!("{music_hoard:?}").is_empty());
}
#[test]
fn with_library_no_database() {
let mut mh = MusicHoardBuilder::default()
.set_library(NullLibrary)
.build();
assert!(mh.rescan_library().is_ok());
}
#[test]
fn no_library_with_database() {
let mut mh = MusicHoardBuilder::default()
.set_database(NullDatabase)
.build()
.unwrap();
assert!(mh.reload_database().is_ok());
}
#[test]
fn with_library_with_database() {
let mut mh = MusicHoardBuilder::default()
.set_library(NullLibrary)
.set_database(NullDatabase)
.build()
.unwrap();
assert!(mh.rescan_library().is_ok());
assert!(mh.reload_database().is_ok());
}
}

View File

@ -0,0 +1,661 @@
use crate::core::{
collection::{
album::{Album, AlbumId, AlbumSeq},
artist::{Artist, ArtistId},
musicbrainz::MusicBrainzUrl,
Collection,
},
interface::database::IDatabase,
musichoard::{base::IMusicHoardBasePrivate, Error, MusicHoard, NoDatabase},
};
pub trait IMusicHoardDatabase {
fn reload_database(&mut self) -> Result<(), Error>;
fn add_artist<IntoId: Into<ArtistId>>(&mut self, artist_id: IntoId) -> Result<(), Error>;
fn remove_artist<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error>;
fn set_artist_sort<Id: AsRef<ArtistId>, IntoId: Into<ArtistId>>(
&mut self,
artist_id: Id,
artist_sort: IntoId,
) -> Result<(), Error>;
fn clear_artist_sort<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error>;
fn set_artist_musicbrainz<Id: AsRef<ArtistId>, S: AsRef<str>>(
&mut self,
artist_id: Id,
url: S,
) -> Result<(), Error>;
fn clear_artist_musicbrainz<Id: AsRef<ArtistId>>(&mut self, artist_id: Id)
-> Result<(), Error>;
fn add_to_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
&mut self,
artist_id: Id,
property: S,
values: Vec<S>,
) -> Result<(), Error>;
fn remove_from_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
&mut self,
artist_id: Id,
property: S,
values: Vec<S>,
) -> Result<(), Error>;
fn set_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
&mut self,
artist_id: Id,
property: S,
values: Vec<S>,
) -> Result<(), Error>;
fn clear_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
&mut self,
artist_id: Id,
property: S,
) -> Result<(), Error>;
fn set_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self,
artist_id: ArtistIdRef,
album_id: AlbumIdRef,
seq: u8,
) -> Result<(), Error>;
fn clear_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self,
artist_id: ArtistIdRef,
album_id: AlbumIdRef,
) -> Result<(), Error>;
}
impl<Database: IDatabase, Library> IMusicHoardDatabase for MusicHoard<Database, Library> {
fn reload_database(&mut self) -> Result<(), Error> {
self.database_cache = self.database.load()?;
Self::sort_albums_and_tracks(self.database_cache.iter_mut());
self.collection = self.merge_collections();
self.pre_commit = self.collection.clone();
Ok(())
}
fn add_artist<IntoId: Into<ArtistId>>(&mut self, artist_id: IntoId) -> Result<(), Error> {
let artist_id: ArtistId = artist_id.into();
self.update_collection(|collection| {
if Self::get_artist(collection, &artist_id).is_none() {
collection.push(Artist::new(artist_id));
Self::sort_artists(collection);
}
})
}
fn remove_artist<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error> {
self.update_collection(|collection| {
let index_opt = collection.iter().position(|a| &a.id == artist_id.as_ref());
if let Some(index) = index_opt {
collection.remove(index);
}
})
}
fn set_artist_sort<Id: AsRef<ArtistId>, IntoId: Into<ArtistId>>(
&mut self,
artist_id: Id,
artist_sort: IntoId,
) -> Result<(), Error> {
self.update_artist_and(
artist_id.as_ref(),
|artist| artist.set_sort_key(artist_sort),
|collection| Self::sort_artists(collection),
)
}
fn clear_artist_sort<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error> {
self.update_artist_and(
artist_id.as_ref(),
|artist| artist.clear_sort_key(),
|collection| Self::sort_artists(collection),
)
}
fn set_artist_musicbrainz<Id: AsRef<ArtistId>, S: AsRef<str>>(
&mut self,
artist_id: Id,
url: S,
) -> Result<(), Error> {
let mb = MusicBrainzUrl::artist_from_str(url)?;
self.update_artist(artist_id.as_ref(), |artist| artist.set_musicbrainz_url(mb))
}
fn clear_artist_musicbrainz<Id: AsRef<ArtistId>>(
&mut self,
artist_id: Id,
) -> Result<(), Error> {
self.update_artist(artist_id.as_ref(), |artist| artist.clear_musicbrainz_url())
}
fn add_to_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
&mut self,
artist_id: Id,
property: S,
values: Vec<S>,
) -> Result<(), Error> {
self.update_artist(artist_id.as_ref(), |artist| {
artist.add_to_property(property, values)
})
}
fn remove_from_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
&mut self,
artist_id: Id,
property: S,
values: Vec<S>,
) -> Result<(), Error> {
self.update_artist(artist_id.as_ref(), |artist| {
artist.remove_from_property(property, values)
})
}
fn set_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
&mut self,
artist_id: Id,
property: S,
values: Vec<S>,
) -> Result<(), Error> {
self.update_artist(artist_id.as_ref(), |artist| {
artist.set_property(property, values)
})
}
fn clear_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
&mut self,
artist_id: Id,
property: S,
) -> Result<(), Error> {
self.update_artist(artist_id.as_ref(), |artist| artist.clear_property(property))
}
fn set_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self,
artist_id: ArtistIdRef,
album_id: AlbumIdRef,
seq: u8,
) -> Result<(), Error> {
self.update_album_and(
artist_id.as_ref(),
album_id.as_ref(),
|album| album.set_seq(AlbumSeq(seq)),
|artist| artist.albums.sort_unstable(),
|_| {},
)
}
fn clear_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self,
artist_id: ArtistIdRef,
album_id: AlbumIdRef,
) -> Result<(), Error> {
self.update_album_and(
artist_id.as_ref(),
album_id.as_ref(),
|album| album.clear_seq(),
|artist| artist.albums.sort_unstable(),
|_| {},
)
}
}
pub trait IMusicHoardDatabasePrivate {
fn commit(&mut self) -> Result<(), Error>;
}
impl<Library> IMusicHoardDatabasePrivate for MusicHoard<NoDatabase, Library> {
fn commit(&mut self) -> Result<(), Error> {
self.collection = self.pre_commit.clone();
Ok(())
}
}
impl<Database: IDatabase, Library> IMusicHoardDatabasePrivate for MusicHoard<Database, Library> {
fn commit(&mut self) -> Result<(), Error> {
if self.collection != self.pre_commit {
if let Err(err) = self.database.save(&self.pre_commit) {
self.pre_commit = self.collection.clone();
return Err(err.into());
}
self.collection = self.pre_commit.clone();
}
Ok(())
}
}
impl<Database: IDatabase, Library> MusicHoard<Database, Library> {
fn update_collection<FnColl>(&mut self, fn_coll: FnColl) -> Result<(), Error>
where
FnColl: FnOnce(&mut Collection),
{
fn_coll(&mut self.pre_commit);
self.commit()
}
fn update_artist_and<FnArtist, FnColl>(
&mut self,
artist_id: &ArtistId,
fn_artist: FnArtist,
fn_coll: FnColl,
) -> Result<(), Error>
where
FnArtist: FnOnce(&mut Artist),
FnColl: FnOnce(&mut Collection),
{
let artist = Self::get_artist_mut_or_err(&mut self.pre_commit, artist_id)?;
fn_artist(artist);
self.update_collection(fn_coll)
}
fn update_artist<FnArtist>(
&mut self,
artist_id: &ArtistId,
fn_artist: FnArtist,
) -> Result<(), Error>
where
FnArtist: FnOnce(&mut Artist),
{
self.update_artist_and(artist_id, fn_artist, |_| {})
}
fn update_album_and<FnAlbum, FnArtist, FnColl>(
&mut self,
artist_id: &ArtistId,
album_id: &AlbumId,
fn_album: FnAlbum,
fn_artist: FnArtist,
fn_coll: FnColl,
) -> Result<(), Error>
where
FnAlbum: FnOnce(&mut Album),
FnArtist: FnOnce(&mut Artist),
FnColl: FnOnce(&mut Collection),
{
let artist = Self::get_artist_mut_or_err(&mut self.pre_commit, artist_id)?;
let album = Self::get_album_mut_or_err(artist, album_id)?;
fn_album(album);
fn_artist(artist);
self.update_collection(fn_coll)
}
}
#[cfg(test)]
mod tests {
use mockall::{predicate, Sequence};
use crate::core::{
collection::{album::AlbumDate, artist::ArtistId, musicbrainz::MusicBrainzUrl},
interface::database::{self, MockIDatabase},
musichoard::{base::IMusicHoardBase, NoLibrary},
testmod::FULL_COLLECTION,
};
use super::*;
static MUSICBRAINZ: &str =
"https://musicbrainz.org/artist/d368baa8-21ca-4759-9731-0b2753071ad8";
static MUSICBUTLER: &str = "https://www.musicbutler.io/artist-page/483340948";
static MUSICBUTLER_2: &str = "https://www.musicbutler.io/artist-page/658903042/";
#[test]
fn artist_new_delete() {
let artist_id = ArtistId::new("an artist");
let artist_id_2 = ArtistId::new("another artist");
let collection = FULL_COLLECTION.to_owned();
let mut with_artist = collection.clone();
with_artist.push(Artist::new(artist_id.clone()));
let mut database = MockIDatabase::new();
let mut seq = Sequence::new();
database
.expect_load()
.times(1)
.times(1)
.in_sequence(&mut seq)
.returning(|| Ok(FULL_COLLECTION.to_owned()));
database
.expect_save()
.times(1)
.in_sequence(&mut seq)
.with(predicate::eq(with_artist.clone()))
.returning(|_| Ok(()));
database
.expect_save()
.times(1)
.in_sequence(&mut seq)
.with(predicate::eq(collection.clone()))
.returning(|_| Ok(()));
let mut music_hoard = MusicHoard::database(database).unwrap();
assert_eq!(music_hoard.collection, collection);
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
assert_eq!(music_hoard.collection, with_artist);
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
assert_eq!(music_hoard.collection, with_artist);
assert!(music_hoard.remove_artist(&artist_id_2).is_ok());
assert_eq!(music_hoard.collection, with_artist);
assert!(music_hoard.remove_artist(&artist_id).is_ok());
assert_eq!(music_hoard.collection, collection);
}
#[test]
fn artist_sort_set_clear() {
let mut database = MockIDatabase::new();
database.expect_load().times(1).returning(|| Ok(vec![]));
database.expect_save().times(4).returning(|_| Ok(()));
type MH = MusicHoard<MockIDatabase, NoLibrary>;
let mut music_hoard: MH = MusicHoard::database(database).unwrap();
let artist_1_id = ArtistId::new("the artist");
let artist_1_sort = ArtistId::new("artist, the");
// Must be after "artist, the", but before "the artist"
let artist_2_id = ArtistId::new("b-artist");
assert!(artist_1_sort < artist_2_id);
assert!(artist_2_id < artist_1_id);
assert!(music_hoard.add_artist(artist_1_id.clone()).is_ok());
assert!(music_hoard.add_artist(artist_2_id.clone()).is_ok());
let artist_1: &Artist = MH::get_artist(&music_hoard.collection, &artist_1_id).unwrap();
let artist_2: &Artist = MH::get_artist(&music_hoard.collection, &artist_2_id).unwrap();
assert!(artist_2 < artist_1);
assert_eq!(artist_1, &music_hoard.collection[1]);
assert_eq!(artist_2, &music_hoard.collection[0]);
music_hoard
.set_artist_sort(artist_1_id.as_ref(), artist_1_sort.clone())
.unwrap();
let artist_1: &Artist = MH::get_artist(&music_hoard.collection, &artist_1_id).unwrap();
let artist_2: &Artist = MH::get_artist(&music_hoard.collection, &artist_2_id).unwrap();
assert!(artist_1 < artist_2);
assert_eq!(artist_1, &music_hoard.collection[0]);
assert_eq!(artist_2, &music_hoard.collection[1]);
music_hoard.clear_artist_sort(artist_1_id.as_ref()).unwrap();
let artist_1: &Artist = MH::get_artist(&music_hoard.collection, &artist_1_id).unwrap();
let artist_2: &Artist = MH::get_artist(&music_hoard.collection, &artist_2_id).unwrap();
assert!(artist_2 < artist_1);
assert_eq!(artist_1, &music_hoard.collection[1]);
assert_eq!(artist_2, &music_hoard.collection[0]);
}
#[test]
fn collection_error() {
let mut database = MockIDatabase::new();
database.expect_load().times(1).returning(|| Ok(vec![]));
database.expect_save().times(1).returning(|_| Ok(()));
let artist_id = ArtistId::new("an artist");
let mut music_hoard = MusicHoard::database(database).unwrap();
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
let actual_err = music_hoard
.set_artist_musicbrainz(&artist_id, MUSICBUTLER)
.unwrap_err();
let expected_err = Error::CollectionError(format!(
"an error occurred when processing a URL: invalid artist MusicBrainz URL: {MUSICBUTLER}"
));
assert_eq!(actual_err, expected_err);
assert_eq!(actual_err.to_string(), expected_err.to_string());
}
#[test]
fn set_clear_musicbrainz_url() {
let mut database = MockIDatabase::new();
database.expect_load().times(1).returning(|| Ok(vec![]));
database.expect_save().times(3).returning(|_| Ok(()));
let artist_id = ArtistId::new("an artist");
let artist_id_2 = ArtistId::new("another artist");
let mut music_hoard = MusicHoard::database(database).unwrap();
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
let mut expected: Option<MusicBrainzUrl> = None;
assert_eq!(music_hoard.collection[0].musicbrainz, expected);
// Setting a URL on an artist not in the collection is an error.
assert!(music_hoard
.set_artist_musicbrainz(&artist_id_2, MUSICBRAINZ)
.is_err());
assert_eq!(music_hoard.collection[0].musicbrainz, expected);
// Setting a URL on an artist.
assert!(music_hoard
.set_artist_musicbrainz(&artist_id, MUSICBRAINZ)
.is_ok());
_ = expected.insert(MusicBrainzUrl::artist_from_str(MUSICBRAINZ).unwrap());
assert_eq!(music_hoard.collection[0].musicbrainz, expected);
// Clearing URLs on an artist that does not exist is an error.
assert!(music_hoard.clear_artist_musicbrainz(&artist_id_2).is_err());
assert_eq!(music_hoard.collection[0].musicbrainz, expected);
// Clearing URLs.
assert!(music_hoard.clear_artist_musicbrainz(&artist_id).is_ok());
_ = expected.take();
assert_eq!(music_hoard.collection[0].musicbrainz, expected);
}
#[test]
fn add_to_remove_from_property() {
let mut database = MockIDatabase::new();
database.expect_load().times(1).returning(|| Ok(vec![]));
database.expect_save().times(3).returning(|_| Ok(()));
let artist_id = ArtistId::new("an artist");
let artist_id_2 = ArtistId::new("another artist");
let mut music_hoard = MusicHoard::database(database).unwrap();
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
let mut expected: Vec<String> = vec![];
assert!(music_hoard.collection[0].properties.is_empty());
// Adding URLs to an artist not in the collection is an error.
assert!(music_hoard
.add_to_artist_property(&artist_id_2, "MusicButler", vec![MUSICBUTLER])
.is_err());
assert!(music_hoard.collection[0].properties.is_empty());
// Adding mutliple URLs without clashes.
assert!(music_hoard
.add_to_artist_property(&artist_id, "MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2])
.is_ok());
expected.push(MUSICBUTLER.to_owned());
expected.push(MUSICBUTLER_2.to_owned());
assert_eq!(
music_hoard.collection[0].properties.get("MusicButler"),
Some(&expected)
);
// Removing URLs from an artist not in the collection is an error.
assert!(music_hoard
.remove_from_artist_property(&artist_id_2, "MusicButler", vec![MUSICBUTLER])
.is_err());
assert_eq!(
music_hoard.collection[0].properties.get("MusicButler"),
Some(&expected)
);
// Removing multiple URLs without clashes.
assert!(music_hoard
.remove_from_artist_property(
&artist_id,
"MusicButler",
vec![MUSICBUTLER, MUSICBUTLER_2]
)
.is_ok());
expected.clear();
assert!(music_hoard.collection[0].properties.is_empty());
}
#[test]
fn set_clear_property() {
let mut database = MockIDatabase::new();
database.expect_load().times(1).returning(|| Ok(vec![]));
database.expect_save().times(3).returning(|_| Ok(()));
let artist_id = ArtistId::new("an artist");
let artist_id_2 = ArtistId::new("another artist");
let mut music_hoard = MusicHoard::database(database).unwrap();
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
let mut expected: Vec<String> = vec![];
assert!(music_hoard.collection[0].properties.is_empty());
// Seting URL on an artist not in the collection is an error.
assert!(music_hoard
.set_artist_property(&artist_id_2, "MusicButler", vec![MUSICBUTLER])
.is_err());
assert!(music_hoard.collection[0].properties.is_empty());
// Set URLs.
assert!(music_hoard
.set_artist_property(&artist_id, "MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2])
.is_ok());
expected.clear();
expected.push(MUSICBUTLER.to_owned());
expected.push(MUSICBUTLER_2.to_owned());
assert_eq!(
music_hoard.collection[0].properties.get("MusicButler"),
Some(&expected)
);
// Clearing URLs on an artist that does not exist is an error.
assert!(music_hoard
.clear_artist_property(&artist_id_2, "MusicButler")
.is_err());
// Clear URLs.
assert!(music_hoard
.clear_artist_property(&artist_id, "MusicButler")
.is_ok());
expected.clear();
assert!(music_hoard.collection[0].properties.is_empty());
}
#[test]
fn set_clear_album_seq() {
let mut database = MockIDatabase::new();
let artist_id = ArtistId::new("an artist");
let album_id = AlbumId::new("an album");
let album_id_2 = AlbumId::new("another album");
let mut database_result = vec![Artist::new(artist_id.clone())];
database_result[0]
.albums
.push(Album::new(album_id.clone(), AlbumDate::default()));
database
.expect_load()
.times(1)
.return_once(|| Ok(database_result));
database.expect_save().times(2).returning(|_| Ok(()));
let mut music_hoard = MusicHoard::database(database).unwrap();
assert_eq!(music_hoard.collection[0].albums[0].seq, AlbumSeq(0));
// Seting seq on an album not belonging to the artist is an error.
assert!(music_hoard
.set_album_seq(&artist_id, &album_id_2, 6)
.is_err());
assert_eq!(music_hoard.collection[0].albums[0].seq, AlbumSeq(0));
// Set seq.
assert!(music_hoard.set_album_seq(&artist_id, &album_id, 6).is_ok());
assert_eq!(music_hoard.collection[0].albums[0].seq, AlbumSeq(6));
// Clearing seq on an album that does not exist is an error.
assert!(music_hoard
.clear_album_seq(&artist_id, &album_id_2)
.is_err());
// Clear seq.
assert!(music_hoard.clear_album_seq(&artist_id, &album_id).is_ok());
assert_eq!(music_hoard.collection[0].albums[0].seq, AlbumSeq(0));
}
#[test]
fn load_database() {
let mut database = MockIDatabase::new();
database
.expect_load()
.times(1)
.return_once(|| Ok(FULL_COLLECTION.to_owned()));
let music_hoard = MusicHoard::database(database).unwrap();
assert_eq!(music_hoard.get_collection(), &*FULL_COLLECTION);
}
#[test]
fn database_load_error() {
let mut database = MockIDatabase::new();
let database_result = Err(database::LoadError::IoError(String::from("I/O error")));
database
.expect_load()
.times(1)
.return_once(|| database_result);
let actual_err = MusicHoard::database(database).unwrap_err();
let expected_err = Error::DatabaseError(
database::LoadError::IoError(String::from("I/O error")).to_string(),
);
assert_eq!(actual_err, expected_err);
assert_eq!(actual_err.to_string(), expected_err.to_string());
}
#[test]
fn database_save_error() {
let mut database = MockIDatabase::new();
let database_result = Err(database::SaveError::IoError(String::from("I/O error")));
database.expect_load().return_once(|| Ok(vec![]));
database
.expect_save()
.times(1)
.return_once(|_: &Collection| database_result);
let mut music_hoard = MusicHoard::database(database).unwrap();
let actual_err = music_hoard
.add_artist(ArtistId::new("an artist"))
.unwrap_err();
let expected_err = Error::DatabaseError(
database::SaveError::IoError(String::from("I/O error")).to_string(),
);
assert_eq!(actual_err, expected_err);
assert_eq!(actual_err.to_string(), expected_err.to_string());
}
}

View File

@ -0,0 +1,317 @@
use std::collections::HashMap;
use crate::core::{
collection::{
album::{Album, AlbumDate, AlbumId},
artist::{Artist, ArtistId},
track::{Track, TrackId, TrackNum, TrackQuality},
Collection,
},
interface::{
database::IDatabase,
library::{ILibrary, Item, Query},
},
musichoard::{
base::IMusicHoardBasePrivate, database::IMusicHoardDatabasePrivate, Error, MusicHoard,
NoDatabase,
},
};
pub trait IMusicHoardLibrary {
fn rescan_library(&mut self) -> Result<(), Error>;
}
impl<Library: ILibrary> IMusicHoardLibrary for MusicHoard<NoDatabase, Library> {
fn rescan_library(&mut self) -> Result<(), Error> {
self.pre_commit = self.rescan_library_inner()?;
self.commit()
}
}
impl<Database: IDatabase, Library: ILibrary> IMusicHoardLibrary for MusicHoard<Database, Library> {
fn rescan_library(&mut self) -> Result<(), Error> {
self.pre_commit = self.rescan_library_inner()?;
self.commit()
}
}
impl<Database, Library: ILibrary> MusicHoard<Database, Library> {
fn rescan_library_inner(&mut self) -> Result<Collection, Error> {
let items = self.library.list(&Query::new())?;
self.library_cache = Self::items_to_artists(items)?;
Self::sort_albums_and_tracks(self.library_cache.values_mut());
Ok(self.merge_collections())
}
fn items_to_artists(items: Vec<Item>) -> Result<HashMap<ArtistId, Artist>, Error> {
let mut collection = HashMap::<ArtistId, Artist>::new();
for item in items.into_iter() {
let artist_id = ArtistId {
name: item.album_artist,
};
let artist_sort = item.album_artist_sort.map(|s| ArtistId { name: s });
let album_id = AlbumId {
title: item.album_title,
};
let album_date = AlbumDate {
year: item.album_year,
month: item.album_month,
day: item.album_day,
};
let track = Track {
id: TrackId {
title: item.track_title,
},
number: TrackNum(item.track_number),
artist: item.track_artist,
quality: TrackQuality {
format: item.track_format,
bitrate: item.track_bitrate,
},
};
// There are usually many entries per artist. Therefore, we avoid simply calling
// .entry(artist_id.clone()).or_insert_with(..), because of the clone. The flipside is
// that insertions will thus do an additional lookup.
let artist = match collection.get_mut(&artist_id) {
Some(artist) => artist,
None => collection
.entry(artist_id.clone())
.or_insert_with(|| Artist::new(artist_id)),
};
if artist.sort.is_some() {
if artist_sort.is_some() && (artist.sort != artist_sort) {
return Err(Error::CollectionError(format!(
"multiple album_artist_sort found for artist '{}': '{}' != '{}'",
artist.id,
artist.sort.as_ref().unwrap(),
artist_sort.as_ref().unwrap()
)));
}
} else if artist_sort.is_some() {
artist.sort = artist_sort;
}
// Do a linear search as few artists have more than a handful of albums. Search from the
// back as the original items vector is usually already sorted.
match artist
.albums
.iter_mut()
.rev()
.find(|album| album.id == album_id)
{
Some(album) => album.tracks.push(track),
None => {
let mut album = Album::new(album_id, album_date);
album.tracks.push(track);
artist.albums.push(album);
}
}
}
Ok(collection)
}
}
#[cfg(test)]
mod tests {
use mockall::{predicate, Sequence};
use crate::core::{
interface::{
database::MockIDatabase,
library::{self, testmod::LIBRARY_ITEMS, MockILibrary},
},
musichoard::base::IMusicHoardBase,
testmod::LIBRARY_COLLECTION,
};
use super::*;
#[test]
fn rescan_library_ordered() {
let mut library = MockILibrary::new();
let mut database = MockIDatabase::new();
let library_input = Query::new();
let library_result = Ok(LIBRARY_ITEMS.to_owned());
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.return_once(|_| library_result);
database.expect_load().times(1).returning(|| Ok(vec![]));
database
.expect_save()
.with(predicate::eq(&*LIBRARY_COLLECTION))
.times(1)
.return_once(|_| Ok(()));
let mut music_hoard = MusicHoard::new(database, library).unwrap();
music_hoard.rescan_library().unwrap();
assert_eq!(music_hoard.get_collection(), &*LIBRARY_COLLECTION);
}
#[test]
fn rescan_library_changed() {
let mut library = MockILibrary::new();
let mut seq = Sequence::new();
let library_input = Query::new();
let library_result = Ok(LIBRARY_ITEMS.to_owned());
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.in_sequence(&mut seq)
.return_once(|_| library_result);
let library_input = Query::new();
let library_result = Ok(LIBRARY_ITEMS
.iter()
.filter(|item| item.album_title != "album_title a.a")
.cloned()
.collect());
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.in_sequence(&mut seq)
.return_once(|_| library_result);
let mut music_hoard = MusicHoard::library(library);
music_hoard.rescan_library().unwrap();
assert!(music_hoard.get_collection()[0]
.albums
.iter()
.any(|album| album.id.title == "album_title a.a"));
music_hoard.rescan_library().unwrap();
assert!(!music_hoard.get_collection()[0]
.albums
.iter()
.any(|album| album.id.title == "album_title a.a"));
}
#[test]
fn rescan_library_unordered() {
let mut library = MockILibrary::new();
let library_input = Query::new();
let mut library_result = Ok(LIBRARY_ITEMS.to_owned());
// Swap the last item with the first.
let last = library_result.as_ref().unwrap().len() - 1;
library_result.as_mut().unwrap().swap(0, last);
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.return_once(|_| library_result);
let mut music_hoard = MusicHoard::library(library);
music_hoard.rescan_library().unwrap();
assert_eq!(music_hoard.get_collection(), &*LIBRARY_COLLECTION);
}
#[test]
fn rescan_library_album_id_clash() {
let mut library = MockILibrary::new();
let mut expected = LIBRARY_COLLECTION.to_owned();
let removed_album_id = expected[0].albums[0].id.clone();
let clashed_album_id = &expected[1].albums[0].id;
let mut items = LIBRARY_ITEMS.to_owned();
for item in items
.iter_mut()
.filter(|it| it.album_title == removed_album_id.title)
{
item.album_title = clashed_album_id.title.clone();
}
expected[0].albums[0].id = clashed_album_id.clone();
let library_input = Query::new();
let library_result = Ok(items);
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.return_once(|_| library_result);
let mut music_hoard = MusicHoard::library(library);
music_hoard.rescan_library().unwrap();
assert_eq!(music_hoard.get_collection()[0], expected[0]);
assert_eq!(music_hoard.get_collection(), &expected);
}
#[test]
fn rescan_library_album_artist_sort_clash() {
let mut library = MockILibrary::new();
let library_input = Query::new();
let mut library_items = LIBRARY_ITEMS.to_owned();
assert_eq!(library_items[0].album_artist, library_items[1].album_artist);
library_items[0].album_artist_sort = Some(library_items[0].album_artist.clone());
library_items[1].album_artist_sort = Some(
library_items[1]
.album_artist
.clone()
.chars()
.rev()
.collect(),
);
let library_result = Ok(library_items);
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.return_once(|_| library_result);
let mut music_hoard = MusicHoard::library(library);
assert!(music_hoard.rescan_library().is_err());
}
#[test]
fn library_error() {
let mut library = MockILibrary::new();
let library_result = Err(library::Error::Invalid(String::from("invalid data")));
library
.expect_list()
.times(1)
.return_once(|_| library_result);
let mut music_hoard = MusicHoard::library(library);
let actual_err = music_hoard.rescan_library().unwrap_err();
let expected_err =
Error::LibraryError(library::Error::Invalid(String::from("invalid data")).to_string());
assert_eq!(actual_err, expected_err);
assert_eq!(actual_err.to_string(), expected_err.to_string());
}
}

View File

@ -1,16 +1,61 @@
//! The core MusicHoard module. Serves as the main entry-point into the library.
#![allow(clippy::module_inception)]
pub mod musichoard;
pub mod musichoard_builder;
mod base;
mod database;
mod library;
use std::fmt::{self, Display};
pub mod builder;
pub use base::IMusicHoardBase;
pub use database::IMusicHoardDatabase;
pub use library::IMusicHoardLibrary;
use std::{
collections::HashMap,
fmt::{self, Display},
};
use crate::core::collection::{
artist::{Artist, ArtistId},
Collection,
};
use crate::core::{
collection,
interface::{database, library},
interface::{
database::{LoadError as DatabaseLoadError, SaveError as DatabaseSaveError},
library::Error as LibraryError,
},
};
/// The Music Hoard. It is responsible for pulling information from both the library and the
/// database, ensuring its consistent and writing back any changes.
// TODO: Split into inner and external/interfaces to facilitate building.
#[derive(Debug)]
pub struct MusicHoard<Database, Library> {
collection: Collection,
pre_commit: Collection,
database: Database,
database_cache: Collection,
library: Library,
library_cache: HashMap<ArtistId, Artist>,
}
/// Phantom type for when a library implementation is not needed.
#[derive(Debug)]
pub struct NoLibrary;
/// Phantom type for when a database implementation is not needed.
#[derive(Debug)]
pub struct NoDatabase;
impl Default for MusicHoard<NoDatabase, NoLibrary> {
/// Create a new [`MusicHoard`] without any library or database.
fn default() -> Self {
MusicHoard::empty()
}
}
/// Error type for `musichoard`.
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
@ -40,20 +85,20 @@ impl From<collection::Error> for Error {
}
}
impl From<library::Error> for Error {
fn from(err: library::Error) -> Error {
impl From<LibraryError> for Error {
fn from(err: LibraryError) -> Error {
Error::LibraryError(err.to_string())
}
}
impl From<database::LoadError> for Error {
fn from(err: database::LoadError) -> Error {
impl From<DatabaseLoadError> for Error {
fn from(err: DatabaseLoadError) -> Error {
Error::DatabaseError(err.to_string())
}
}
impl From<database::SaveError> for Error {
fn from(err: database::SaveError) -> Error {
impl From<DatabaseSaveError> for Error {
fn from(err: DatabaseSaveError) -> Error {
Error::DatabaseError(err.to_string())
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,117 +0,0 @@
use crate::{
core::{
interface::{database::IDatabase, library::ILibrary},
musichoard::musichoard::{MusicHoard, NoDatabase, NoLibrary},
},
Error,
};
/// Builder for [`MusicHoard`]. Its purpose is to make it easier to set various combinations of
/// library/database or their absence.
pub struct MusicHoardBuilder<LIB, DB> {
library: LIB,
database: DB,
}
impl Default for MusicHoardBuilder<NoLibrary, NoDatabase> {
/// Create a [`MusicHoardBuilder`].
fn default() -> Self {
Self::new()
}
}
impl MusicHoardBuilder<NoLibrary, NoDatabase> {
/// Create a [`MusicHoardBuilder`].
pub fn new() -> Self {
MusicHoardBuilder {
library: NoLibrary,
database: NoDatabase,
}
}
}
impl<LIB, DB> MusicHoardBuilder<LIB, DB> {
/// Set a library for [`MusicHoard`].
pub fn set_library<NEWLIB: ILibrary>(self, library: NEWLIB) -> MusicHoardBuilder<NEWLIB, DB> {
MusicHoardBuilder {
library,
database: self.database,
}
}
/// Set a database for [`MusicHoard`].
pub fn set_database<NEWDB: IDatabase>(self, database: NEWDB) -> MusicHoardBuilder<LIB, NEWDB> {
MusicHoardBuilder {
library: self.library,
database,
}
}
}
impl MusicHoardBuilder<NoLibrary, NoDatabase> {
/// Build [`MusicHoard`] with the currently set library and database.
pub fn build(self) -> MusicHoard<NoLibrary, NoDatabase> {
MusicHoard::empty()
}
}
impl<LIB: ILibrary> MusicHoardBuilder<LIB, NoDatabase> {
/// Build [`MusicHoard`] with the currently set library and database.
pub fn build(self) -> MusicHoard<LIB, NoDatabase> {
MusicHoard::library(self.library)
}
}
impl<DB: IDatabase> MusicHoardBuilder<NoLibrary, DB> {
/// Build [`MusicHoard`] with the currently set library and database.
pub fn build(self) -> Result<MusicHoard<NoLibrary, DB>, Error> {
MusicHoard::database(self.database)
}
}
impl<LIB: ILibrary, DB: IDatabase> MusicHoardBuilder<LIB, DB> {
/// Build [`MusicHoard`] with the currently set library and database.
pub fn build(self) -> Result<MusicHoard<LIB, DB>, Error> {
MusicHoard::new(self.library, self.database)
}
}
#[cfg(test)]
mod tests {
use crate::core::interface::{database::NullDatabase, library::NullLibrary};
use super::*;
#[test]
fn no_library_no_database() {
MusicHoardBuilder::default().build();
}
#[test]
fn with_library_no_database() {
let mut mh = MusicHoardBuilder::default()
.set_library(NullLibrary)
.build();
assert!(mh.rescan_library().is_ok());
}
#[test]
fn no_library_with_database() {
let mut mh = MusicHoardBuilder::default()
.set_database(NullDatabase)
.build()
.unwrap();
assert!(mh.reload_database().is_ok());
}
#[test]
fn with_library_with_database() {
let mut mh = MusicHoardBuilder::default()
.set_library(NullLibrary)
.set_database(NullDatabase)
.build()
.unwrap();
assert!(mh.rescan_library().is_ok());
assert!(mh.reload_database().is_ok());
}
}

View File

@ -4,7 +4,7 @@ use std::collections::HashMap;
use crate::core::collection::{
album::{Album, AlbumDate, AlbumId, AlbumMonth, AlbumSeq},
artist::{Artist, ArtistId},
musicbrainz::MusicBrainz,
musicbrainz::MusicBrainzUrl,
track::{Track, TrackFormat, TrackId, TrackNum, TrackQuality},
};
use crate::tests::*;

View File

@ -6,7 +6,7 @@ use crate::core::{
collection::{
album::{Album, AlbumDate, AlbumId, AlbumSeq},
artist::{Artist, ArtistId},
musicbrainz::MusicBrainz,
musicbrainz::MusicBrainzUrl,
Collection,
},
interface::database::LoadError,
@ -55,7 +55,7 @@ impl TryFrom<DeserializeArtist> for Artist {
sort: artist.sort.map(ArtistId::new),
musicbrainz: artist
.musicbrainz
.map(MusicBrainz::artist_from_str)
.map(MusicBrainzUrl::artist_from_str)
.transpose()?,
properties: artist.properties,
albums: artist
@ -77,7 +77,7 @@ impl TryFrom<DeserializeAlbum> for Album {
seq: AlbumSeq(album.seq),
musicbrainz: album
.musicbrainz
.map(MusicBrainz::album_from_str)
.map(MusicBrainzUrl::album_from_str)
.transpose()?,
tracks: vec![],
})

View File

@ -8,9 +8,8 @@ pub use core::collection;
pub use core::interface;
pub use core::musichoard::{
musichoard::{MusicHoard, NoDatabase, NoLibrary},
musichoard_builder::MusicHoardBuilder,
Error,
builder::MusicHoardBuilder, Error, IMusicHoardBase, IMusicHoardDatabase, IMusicHoardLibrary,
MusicHoard, NoDatabase, NoLibrary,
};
#[cfg(test)]

View File

@ -58,7 +58,7 @@ struct DbOpt {
no_database: bool,
}
fn with<LIB: ILibrary, DB: IDatabase>(builder: MusicHoardBuilder<LIB, DB>) {
fn with<Database: IDatabase, Library: ILibrary>(builder: MusicHoardBuilder<Database, Library>) {
let music_hoard = builder.build().expect("failed to initialise MusicHoard");
// Initialize the terminal user interface.
@ -76,7 +76,10 @@ fn with<LIB: ILibrary, DB: IDatabase>(builder: MusicHoardBuilder<LIB, DB>) {
Tui::run(terminal, app, ui, handler, listener).expect("failed to run tui");
}
fn with_database<LIB: ILibrary>(db_opt: DbOpt, builder: MusicHoardBuilder<LIB, NoDatabase>) {
fn with_database<Library: ILibrary>(
db_opt: DbOpt,
builder: MusicHoardBuilder<NoDatabase, Library>,
) {
if db_opt.no_database {
with(builder.set_database(NullDatabase));
} else {
@ -103,7 +106,7 @@ fn with_database<LIB: ILibrary>(db_opt: DbOpt, builder: MusicHoardBuilder<LIB, N
};
}
fn with_library(lib_opt: LibOpt, db_opt: DbOpt, builder: MusicHoardBuilder<NoLibrary, NoDatabase>) {
fn with_library(lib_opt: LibOpt, db_opt: DbOpt, builder: MusicHoardBuilder<NoDatabase, NoLibrary>) {
if lib_opt.no_library {
with_database(db_opt, builder.set_library(NullLibrary));
} else if let Some(uri) = lib_opt.beets_ssh_uri {

View File

@ -464,7 +464,7 @@ macro_rules! full_collection {
let artist_a = iter.next().unwrap();
assert_eq!(artist_a.id.name, "Album_Artist A");
artist_a.musicbrainz = Some(MusicBrainz::artist_from_str(
artist_a.musicbrainz = Some(MusicBrainzUrl::artist_from_str(
"https://musicbrainz.org/artist/00000000-0000-0000-0000-000000000000",
).unwrap());
@ -482,14 +482,14 @@ macro_rules! full_collection {
artist_a.albums[0].seq = AlbumSeq(1);
artist_a.albums[1].seq = AlbumSeq(1);
artist_a.albums[0].musicbrainz = Some(MusicBrainz::album_from_str(
artist_a.albums[0].musicbrainz = Some(MusicBrainzUrl::album_from_str(
"https://musicbrainz.org/release-group/00000000-0000-0000-0000-000000000000"
).unwrap());
let artist_b = iter.next().unwrap();
assert_eq!(artist_b.id.name, "Album_Artist B");
artist_b.musicbrainz = Some(MusicBrainz::artist_from_str(
artist_b.musicbrainz = Some(MusicBrainzUrl::artist_from_str(
"https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111",
).unwrap());
@ -511,18 +511,18 @@ macro_rules! full_collection {
artist_b.albums[2].seq = AlbumSeq(2);
artist_b.albums[3].seq = AlbumSeq(4);
artist_b.albums[1].musicbrainz = Some(MusicBrainz::album_from_str(
artist_b.albums[1].musicbrainz = Some(MusicBrainzUrl::album_from_str(
"https://musicbrainz.org/release-group/11111111-1111-1111-1111-111111111111"
).unwrap());
artist_b.albums[2].musicbrainz = Some(MusicBrainz::album_from_str(
artist_b.albums[2].musicbrainz = Some(MusicBrainzUrl::album_from_str(
"https://musicbrainz.org/release-group/11111111-1111-1111-1111-111111111112"
).unwrap());
let artist_c = iter.next().unwrap();
assert_eq!(artist_c.id.name, "The Album_Artist C");
artist_c.musicbrainz = Some(MusicBrainz::artist_from_str(
artist_c.musicbrainz = Some(MusicBrainzUrl::artist_from_str(
"https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111",
).unwrap());

View File

@ -1,6 +1,6 @@
use musichoard::{
collection::Collection, interface::database::IDatabase, interface::library::ILibrary,
MusicHoard,
IMusicHoardBase, IMusicHoardDatabase, IMusicHoardLibrary, MusicHoard,
};
#[cfg(test)]
@ -14,17 +14,17 @@ pub trait IMusicHoard {
}
// GRCOV_EXCL_START
impl<LIB: ILibrary, DB: IDatabase> IMusicHoard for MusicHoard<LIB, DB> {
impl<Database: IDatabase, Library: ILibrary> IMusicHoard for MusicHoard<Database, Library> {
fn rescan_library(&mut self) -> Result<(), musichoard::Error> {
MusicHoard::<LIB, DB>::rescan_library(self)
<Self as IMusicHoardLibrary>::rescan_library(self)
}
fn reload_database(&mut self) -> Result<(), musichoard::Error> {
MusicHoard::reload_database(self)
<Self as IMusicHoardDatabase>::reload_database(self)
}
fn get_collection(&self) -> &Collection {
MusicHoard::get_collection(self)
<Self as IMusicHoardBase>::get_collection(self)
}
}
// GRCOV_EXCL_STOP

View File

@ -3,7 +3,7 @@ use std::collections::HashMap;
use musichoard::collection::{
album::{Album, AlbumDate, AlbumId, AlbumMonth, AlbumSeq},
artist::{Artist, ArtistId},
musicbrainz::MusicBrainz,
musicbrainz::MusicBrainzUrl,
track::{Track, TrackFormat, TrackId, TrackNum, TrackQuality},
};
use once_cell::sync::Lazy;

View File

@ -9,7 +9,7 @@ mod testlib;
use musichoard::{
database::json::{backend::JsonDatabaseFileBackend, JsonDatabase},
library::beets::{executor::BeetsLibraryProcessExecutor, BeetsLibrary},
MusicHoard,
IMusicHoardBase, IMusicHoardDatabase, IMusicHoardLibrary, MusicHoard,
};
use crate::testlib::COLLECTION;
@ -29,7 +29,7 @@ fn merge_library_then_database() {
let backend = JsonDatabaseFileBackend::new(&*database::json::DATABASE_TEST_FILE);
let database = JsonDatabase::new(backend);
let mut music_hoard = MusicHoard::new(library, database).unwrap();
let mut music_hoard = MusicHoard::new(database, library).unwrap();
music_hoard.rescan_library().unwrap();
music_hoard.reload_database().unwrap();
@ -52,7 +52,7 @@ fn merge_database_then_library() {
let backend = JsonDatabaseFileBackend::new(&*database::json::DATABASE_TEST_FILE);
let database = JsonDatabase::new(backend);
let mut music_hoard = MusicHoard::new(library, database).unwrap();
let mut music_hoard = MusicHoard::new(database, library).unwrap();
music_hoard.reload_database().unwrap();
music_hoard.rescan_library().unwrap();

View File

@ -4,7 +4,7 @@ use std::collections::HashMap;
use musichoard::collection::{
album::{Album, AlbumDate, AlbumId, AlbumMonth, AlbumSeq},
artist::{Artist, ArtistId},
musicbrainz::MusicBrainz,
musicbrainz::MusicBrainzUrl,
track::{Track, TrackFormat, TrackId, TrackNum, TrackQuality},
Collection,
};
@ -18,7 +18,7 @@ pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| -> Collection {
sort: Some(ArtistId{
name: String::from("Arkona")
}),
musicbrainz: Some(MusicBrainz::artist_from_str(
musicbrainz: Some(MusicBrainzUrl::artist_from_str(
"https://musicbrainz.org/artist/baad262d-55ef-427a-83c7-f7530964f212"
).unwrap()),
properties: HashMap::from([
@ -206,7 +206,7 @@ pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| -> Collection {
name: String::from("Eluveitie"),
},
sort: None,
musicbrainz: Some(MusicBrainz::artist_from_str(
musicbrainz: Some(MusicBrainzUrl::artist_from_str(
"https://musicbrainz.org/artist/8000598a-5edb-401c-8e6d-36b167feaf38",
).unwrap()),
properties: HashMap::from([
@ -451,7 +451,7 @@ pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| -> Collection {
name: String::from("Frontside"),
},
sort: None,
musicbrainz: Some(MusicBrainz::artist_from_str(
musicbrainz: Some(MusicBrainzUrl::artist_from_str(
"https://musicbrainz.org/artist/3a901353-fccd-4afd-ad01-9c03f451b490",
).unwrap()),
properties: HashMap::from([
@ -605,7 +605,7 @@ pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| -> Collection {
sort: Some(ArtistId {
name: String::from("Heavens Basement"),
}),
musicbrainz: Some(MusicBrainz::artist_from_str(
musicbrainz: Some(MusicBrainzUrl::artist_from_str(
"https://musicbrainz.org/artist/c2c4d56a-d599-4a18-bd2f-ae644e2198cc",
).unwrap()),
properties: HashMap::from([
@ -737,7 +737,7 @@ pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| -> Collection {
name: String::from("Metallica"),
},
sort: None,
musicbrainz: Some(MusicBrainz::artist_from_str(
musicbrainz: Some(MusicBrainzUrl::artist_from_str(
"https://musicbrainz.org/artist/65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab",
).unwrap()),
properties: HashMap::from([