//! MusicHoard - a music collection manager. pub mod database; pub mod library; use std::{ cmp::Ordering, collections::{HashMap, HashSet}, fmt, iter::Peekable, mem, }; use database::IDatabase; use library::{ILibrary, Item, Query}; use serde::{Deserialize, Serialize}; use url::Url; use uuid::Uuid; /// An object with the [`IUrl`] trait contains a valid URL. pub trait IUrl { fn url(&self) -> &str; } /// An object with the [`IMbid`] trait contains a [MusicBrainz /// Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) (MBID). pub trait IMbid { fn mbid(&self) -> &str; } #[derive(Debug)] enum UrlType { MusicBrainz, MusicButler, Bandcamp, Qobuz, } struct InvalidUrlError { url_type: UrlType, url: String, } impl fmt::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)] pub struct MusicBrainz(Url); impl MusicBrainz { pub fn new>(url: S) -> Result { 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>(url: S) -> InvalidUrlError { InvalidUrlError { url_type: UrlType::MusicBrainz, url: url.into(), } } } impl IUrl for MusicBrainz { fn url(&self) -> &str { self.0.as_str() } } 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)] pub struct MusicButler(Url); impl MusicButler { pub fn new>(url: S) -> Result { 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)) } fn invalid_url_error>(url: S) -> InvalidUrlError { InvalidUrlError { url_type: UrlType::MusicButler, url: url.into(), } } } impl IUrl for MusicButler { fn url(&self) -> &str { self.0.as_str() } } /// Bandcamp reference. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct Bandcamp(Url); impl Bandcamp { pub fn new>(url: S) -> Result { 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)) } fn invalid_url_error>(url: S) -> InvalidUrlError { InvalidUrlError { url_type: UrlType::Bandcamp, url: url.into(), } } } impl IUrl for Bandcamp { fn url(&self) -> &str { self.0.as_str() } } /// Qobuz reference. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct Qobuz(Url); impl Qobuz { pub fn new>(url: S) -> Result { 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>(url: S) -> InvalidUrlError { InvalidUrlError { url_type: UrlType::Qobuz, url: url.into(), } } } impl IUrl for Qobuz { fn url(&self) -> &str { self.0.as_str() } } /// The track file format. #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] 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, pub quality: Quality, } impl PartialOrd for Track { fn partial_cmp(&self, other: &Self) -> Option { self.id.partial_cmp(&other.id) } } impl Ord for Track { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.id.cmp(&other.id) } } impl Merge for Track { fn merge(self, other: Self) -> Self { assert_eq!(self.id, other.id); self } } /// 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, } impl PartialOrd for Album { fn partial_cmp(&self, other: &Self) -> Option { self.id.partial_cmp(&other.id) } } impl Ord for Album { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.id.cmp(&other.id) } } impl Merge for Album { fn merge(mut self, other: Self) -> Self { assert_eq!(self.id, other.id); self.tracks = MergeSorted::new(self.tracks.into_iter(), other.tracks.into_iter()).collect(); self } } /// The artist identifier. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ArtistId { pub name: String, } /// The artist properties. #[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] pub struct ArtistProperties { pub musicbrainz: Option, pub musicbutler: Vec, pub bandcamp: Vec, pub qobuz: Option, } /// An artist. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct Artist { pub id: ArtistId, pub properties: ArtistProperties, pub albums: Vec, } impl PartialOrd for Artist { fn partial_cmp(&self, other: &Self) -> Option { self.id.partial_cmp(&other.id) } } impl Ord for Artist { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.id.cmp(&other.id) } } impl Merge for Artist { fn merge(mut self, other: Self) -> Self { assert_eq!(self.id, other.id); self.albums = MergeSorted::new(self.albums.into_iter(), other.albums.into_iter()).collect(); self } } /// The collection type. Currently, a collection is a list of artists. pub type Collection = Vec; trait Merge { fn merge(self, other: Self) -> Self; } struct MergeSorted where L: Iterator, R: Iterator, { left: Peekable, right: Peekable, } impl MergeSorted where L: Iterator, R: Iterator, { fn new(left: L, right: R) -> MergeSorted { MergeSorted { left: left.peekable(), right: right.peekable(), } } } impl Iterator for MergeSorted where L: Iterator, R: Iterator, L::Item: Ord + Merge, { type Item = L::Item; fn next(&mut self) -> Option { let which = match (self.left.peek(), self.right.peek()) { (Some(l), Some(r)) => l.cmp(r), (Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater, (None, None) => return None, }; match which { Ordering::Less => self.left.next(), Ordering::Equal => Some(self.left.next().unwrap().merge(self.right.next().unwrap())), Ordering::Greater => self.right.next(), } } } /// Error type for `musichoard`. #[derive(Debug, PartialEq, Eq)] pub enum Error { /// The [`MusicHoard`] failed to read/write from/to the library. LibraryError(String), /// The [`MusicHoard`] failed to read/write from/to the database. DatabaseError(String), /// The [`MusicHoard`] failed to parse a user-provided URL. UrlParseError(String), /// The user-provided URL is not valid. InvalidUrlError(String), } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { Self::LibraryError(ref s) => write!(f, "failed to read/write from/to the library: {s}"), Self::DatabaseError(ref s) => { write!(f, "failed to read/write from/to the database: {s}") } Self::UrlParseError(ref s) => write!(f, "failed to parse a user-provided URL: {s}"), Self::InvalidUrlError(ref s) => write!(f, "user-provided URL is invalid: {s}"), } } } impl From for Error { fn from(err: library::Error) -> Error { Error::LibraryError(err.to_string()) } } impl From for Error { fn from(err: database::LoadError) -> Error { Error::DatabaseError(err.to_string()) } } impl From for Error { fn from(err: database::SaveError) -> Error { Error::DatabaseError(err.to_string()) } } impl From for Error { fn from(err: url::ParseError) -> Error { Error::UrlParseError(err.to_string()) } } impl From for Error { fn from(err: uuid::Error) -> Error { Error::UrlParseError(err.to_string()) } } impl From for Error { fn from(err: InvalidUrlError) -> Error { Error::InvalidUrlError(err.to_string()) } } /// The Music Hoard. It is responsible for pulling information from both the library and the /// database, ensuring its consistent and writing back any changes. pub struct MusicHoard { library: LIB, database: DB, collection: Collection, } impl MusicHoard { /// Create a new [`MusicHoard`] with the provided [`ILibrary`] and [`IDatabase`]. pub fn new(library: LIB, database: DB) -> Self { MusicHoard { library, database, collection: vec![], } } pub fn rescan_library(&mut self) -> Result<(), Error> { let items = self.library.list(&Query::new())?; let mut library = Self::items_to_artists(items); Self::sort(&mut library); let collection = mem::take(&mut self.collection); self.collection = Self::merge(library, collection); Ok(()) } pub fn load_from_database(&mut self) -> Result<(), Error> { let mut database: Collection = vec![]; self.database.load(&mut database)?; Self::sort(&mut database); let collection = mem::take(&mut self.collection); self.collection = Self::merge(collection, database); Ok(()) } pub fn save_to_database(&mut self) -> Result<(), Error> { self.database.save(&self.collection)?; Ok(()) } pub fn get_collection(&self) -> &Collection { &self.collection } fn sort(collection: &mut [Artist]) { collection.sort_unstable(); for artist in collection.iter_mut() { artist.albums.sort_unstable(); for album in artist.albums.iter_mut() { album.tracks.sort_unstable(); } } } fn merge(primary: Vec, secondary: Vec) -> Vec { MergeSorted::new(primary.into_iter(), secondary.into_iter()).collect() } fn items_to_artists(items: Vec) -> Vec { let mut artists: Vec = vec![]; let mut album_ids = HashMap::>::new(); for item in items.into_iter() { let artist_id = ArtistId { name: item.album_artist, }; let album_id = AlbumId { year: item.album_year, title: item.album_title, }; let track = Track { id: TrackId { number: item.track_number, title: item.track_title, }, artist: item.track_artist, quality: Quality { format: item.track_format, bitrate: item.track_bitrate, }, }; let artist = if album_ids.contains_key(&artist_id) { // Assume results are in some order which means they will likely be grouped by // artist. Therefore, we look from the back since the last inserted artist is most // likely the one we are looking for. artists .iter_mut() .rev() .find(|a| a.id == artist_id) .unwrap() } else { album_ids.insert(artist_id.clone(), HashSet::::new()); artists.push(Artist { id: artist_id.clone(), properties: ArtistProperties::default(), albums: vec![], }); artists.last_mut().unwrap() }; if album_ids[&artist_id].contains(&album_id) { // Assume results are in some order which means they will likely be grouped by // album. Therefore, we look from the back since the last inserted album is most // likely the one we are looking for. let album = artist .albums .iter_mut() .rev() .find(|a| a.id == album_id) .unwrap(); album.tracks.push(track); } else { album_ids .get_mut(&artist_id) .unwrap() .insert(album_id.clone()); artist.albums.push(Album { id: album_id, tracks: vec![track], }); } } artists } } #[cfg(test)] #[macro_use] mod testlib; #[cfg(test)] mod tests { use mockall::predicate; use once_cell::sync::Lazy; use crate::{database::MockIDatabase, library::MockILibrary}; use super::*; pub static COLLECTION: Lazy> = Lazy::new(|| collection!()); pub fn artist_to_items(artist: &Artist) -> Vec { let mut items = vec![]; for album in artist.albums.iter() { for track in album.tracks.iter() { items.push(Item { album_artist: artist.id.name.clone(), album_year: album.id.year, album_title: album.id.title.clone(), track_number: track.id.number, track_title: track.id.title.clone(), track_artist: track.artist.clone(), track_format: track.quality.format, track_bitrate: track.quality.bitrate, }); } } items } pub fn artists_to_items(artists: &[Artist]) -> Vec { let mut items = vec![]; for artist in artists.iter() { items.append(&mut artist_to_items(artist)); } items } fn clean_collection(mut collection: Collection) -> Collection { for artist in collection.iter_mut() { artist.properties = ArtistProperties::default(); } collection } #[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.url()); assert_eq!(uuid, mb.mbid()); let url = format!("not a url at all"); 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 = format!("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::new(&url).unwrap_err(); assert_eq!(actual_error, expected_error); assert_eq!(actual_error.to_string(), expected_error.to_string()); let url = format!("https://musicbrainz.org/artist"); 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()); } #[test] fn urls() { let musicbrainz = "https://musicbrainz.org/artist/d368baa8-21ca-4759-9731-0b2753071ad8"; let musicbutler = "https://www.musicbutler.io/artist-page/483340948"; let bandcamp = "https://thelasthangmen.bandcamp.com/"; let qobuz = "https://www.qobuz.com/nl-nl/interpreter/the-last-hangmen/1244413"; assert!(MusicBrainz::new(&musicbrainz).is_ok()); assert!(MusicBrainz::new(&musicbutler).is_err()); assert!(MusicBrainz::new(&bandcamp).is_err()); assert!(MusicBrainz::new(&qobuz).is_err()); assert!(MusicButler::new(&musicbrainz).is_err()); assert!(MusicButler::new(&musicbutler).is_ok()); assert!(MusicButler::new(&bandcamp).is_err()); assert!(MusicButler::new(&qobuz).is_err()); assert!(Bandcamp::new(&musicbrainz).is_err()); assert!(Bandcamp::new(&musicbutler).is_err()); assert!(Bandcamp::new(&bandcamp).is_ok()); assert!(Bandcamp::new(&qobuz).is_err()); assert!(Qobuz::new(&musicbrainz).is_err()); assert!(Qobuz::new(&musicbutler).is_err()); assert!(Qobuz::new(&bandcamp).is_err()); assert!(Qobuz::new(&qobuz).is_ok()); } #[test] fn merge_track() { let left = Track { id: TrackId { number: 04, title: String::from("a title"), }, artist: vec![String::from("left artist")], quality: Quality { format: Format::Flac, bitrate: 1411, }, }; let right = Track { id: left.id.clone(), artist: vec![String::from("right artist")], quality: Quality { format: Format::Mp3, bitrate: 320, }, }; let merged = left.clone().merge(right); assert_eq!(left, merged); } #[test] fn merge_album_no_overlap() { let left = COLLECTION[0].albums[0].to_owned(); let mut right = COLLECTION[0].albums[1].to_owned(); right.id = left.id.clone(); let mut expected = left.clone(); expected.tracks.append(&mut right.tracks.clone()); expected.tracks.sort_unstable(); let merged = left.clone().merge(right); assert_eq!(expected, merged); } #[test] fn merge_album_overlap() { let mut left = COLLECTION[0].albums[0].to_owned(); let mut right = COLLECTION[0].albums[1].to_owned(); right.id = left.id.clone(); left.tracks.push(right.tracks[0].clone()); left.tracks.sort_unstable(); let mut expected = left.clone(); expected.tracks.append(&mut right.tracks.clone()); expected.tracks.sort_unstable(); expected.tracks.dedup(); let merged = left.clone().merge(right); assert_eq!(expected, merged); } #[test] fn merge_artist_no_overlap() { let left = COLLECTION[0].to_owned(); let mut right = COLLECTION[1].to_owned(); right.id = left.id.clone(); let mut expected = left.clone(); expected.albums.append(&mut right.albums.clone()); expected.albums.sort_unstable(); let merged = left.clone().merge(right); assert_eq!(expected, merged); } #[test] fn merge_artist_overlap() { let mut left = COLLECTION[0].to_owned(); let mut right = COLLECTION[1].to_owned(); right.id = left.id.clone(); left.albums.push(right.albums[0].clone()); left.albums.sort_unstable(); let mut expected = left.clone(); expected.albums.append(&mut right.albums.clone()); expected.albums.sort_unstable(); expected.albums.dedup(); let merged = left.clone().merge(right); assert_eq!(expected, merged); } #[test] fn merge_collection_no_overlap() { let half: usize = COLLECTION.len() / 2; let left = COLLECTION[..half].to_owned(); let right = COLLECTION[half..].to_owned(); let mut expected = COLLECTION.to_owned(); expected.sort_unstable(); let merged = MusicHoard::::merge(left.clone(), right); assert_eq!(expected, merged); } #[test] fn merge_collection_overlap() { let half: usize = COLLECTION.len() / 2; let left = COLLECTION[..(half + 1)].to_owned(); let right = COLLECTION[half..].to_owned(); let mut expected = COLLECTION.to_owned(); expected.sort_unstable(); let merged = MusicHoard::::merge(left.clone(), right); assert_eq!(expected, merged); } #[test] fn rescan_library_ordered() { let mut library = MockILibrary::new(); let database = MockIDatabase::new(); let library_input = Query::new(); let library_result = Ok(artists_to_items(&COLLECTION)); library .expect_list() .with(predicate::eq(library_input)) .times(1) .return_once(|_| library_result); let mut music_hoard = MusicHoard::new(library, database); music_hoard.rescan_library().unwrap(); assert_eq!( music_hoard.get_collection(), &clean_collection(COLLECTION.to_owned()) ); } #[test] fn rescan_library_unordered() { let mut library = MockILibrary::new(); let database = MockIDatabase::new(); let library_input = Query::new(); let mut library_result = Ok(artists_to_items(&COLLECTION)); // 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::new(library, database); music_hoard.rescan_library().unwrap(); assert_eq!( music_hoard.get_collection(), &clean_collection(COLLECTION.to_owned()) ); } #[test] fn rescan_library_album_title_year_clash() { let mut library = MockILibrary::new(); let database = MockIDatabase::new(); let mut expected = clean_collection(COLLECTION.to_owned()); expected[0].albums[0].id.year = expected[1].albums[0].id.year; expected[0].albums[0].id.title = expected[1].albums[0].id.title.clone(); let library_input = Query::new(); let library_result = Ok(artists_to_items(&expected)); library .expect_list() .with(predicate::eq(library_input)) .times(1) .return_once(|_| library_result); let mut music_hoard = MusicHoard::new(library, database); music_hoard.rescan_library().unwrap(); assert_eq!(music_hoard.get_collection(), &expected); } #[test] fn load_database() { let library = MockILibrary::new(); let mut database = MockIDatabase::new(); database .expect_load() .times(1) .return_once(|coll: &mut Collection| { *coll = COLLECTION.to_owned(); Ok(()) }); let mut music_hoard = MusicHoard::new(library, database); music_hoard.load_from_database().unwrap(); assert_eq!(music_hoard.get_collection(), &*COLLECTION); } #[test] fn rescan_get_save() { let mut library = MockILibrary::new(); let mut database = MockIDatabase::new(); let library_input = Query::new(); let library_result = Ok(artists_to_items(&COLLECTION)); let database_input = clean_collection(COLLECTION.to_owned()); let database_result = Ok(()); library .expect_list() .with(predicate::eq(library_input)) .times(1) .return_once(|_| library_result); database .expect_save() .with(predicate::eq(database_input)) .times(1) .return_once(|_: &Collection| database_result); let mut music_hoard = MusicHoard::new(library, database); music_hoard.rescan_library().unwrap(); assert_eq!( music_hoard.get_collection(), &clean_collection(COLLECTION.to_owned()) ); music_hoard.save_to_database().unwrap(); } #[test] fn library_error() { let mut library = MockILibrary::new(); let database = MockIDatabase::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::new(library, database); 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()); } #[test] fn database_load_error() { let library = MockILibrary::new(); let mut database = MockIDatabase::new(); let database_result = Err(database::LoadError::IoError(String::from("I/O error"))); database .expect_load() .times(1) .return_once(|_: &mut Collection| database_result); let mut music_hoard = MusicHoard::new(library, database); let actual_err = music_hoard.load_from_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 library = MockILibrary::new(); let mut database = MockIDatabase::new(); let database_result = Err(database::SaveError::IoError(String::from("I/O error"))); database .expect_save() .times(1) .return_once(|_: &Collection| database_result); let mut music_hoard = MusicHoard::new(library, database); let actual_err = music_hoard.save_to_database().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()); } }