From 37f7a15dd077f9a2332b0d4eb90e4f0b843764fe Mon Sep 17 00:00:00 2001 From: Wojciech Kozlowski Date: Thu, 30 Mar 2023 21:41:25 +0900 Subject: [PATCH] Implement beets library --- src/database/json.rs | 100 +++---- src/lib.rs | 37 +-- src/library/beets.rs | 417 ++++++++++++++++++++++++++++ src/library/mod.rs | 55 +++- tests/files/database_json_test.json | 2 +- 5 files changed, 508 insertions(+), 103 deletions(-) create mode 100644 src/library/beets.rs diff --git a/src/database/json.rs b/src/database/json.rs index 7807a90..a625eef 100644 --- a/src/database/json.rs +++ b/src/database/json.rs @@ -5,7 +5,7 @@ use std::path::Path; use serde::de::DeserializeOwned; use serde::Serialize; -use crate::database::{DatabaseRead, DatabaseWrite}; +use super::{DatabaseRead, DatabaseWrite}; /// A JSON file database. pub struct DatabaseJson { @@ -63,80 +63,48 @@ mod tests { use super::*; - use crate::{Artist, Release, ReleaseGroup, ReleaseGroupType, Track}; + use crate::{Album, AlbumArtist, Track}; const TEST_FILENAME: &str = "tests/files/database_json_test.json"; - fn test_data() -> Vec { + fn test_data() -> Vec { vec![ - ReleaseGroup { - r#type: ReleaseGroupType::Album, - title: String::from("Release group A"), - artist: vec![Artist { + Album { + artist: AlbumArtist { name: String::from("Artist A"), mbid: Some(uuid!("f7769831-746b-4a12-8124-0123d7fe17c9")), - }], + }, year: 1998, - mbid: Some(uuid!("89efbf43-3395-4f6e-ac11-32c1ce514bb0")), - releases: vec![Release { - tracks: vec![ - Track { - number: 1, - title: String::from("Track A.1"), - artist: vec![Artist { - name: String::from("Artist A.A"), - mbid: Some(uuid!("b7f7163d-61d5-4c96-b305-005df54fb999")), - }], - mbid: None, - }, - Track { - number: 2, - title: String::from("Track A.2"), - artist: vec![Artist { - name: String::from("Artist A.A"), - mbid: Some(uuid!("b7f7163d-61d5-4c96-b305-005df54fb999")), - }], - mbid: None, - }, - Track { - number: 3, - title: String::from("Track A.3"), - artist: vec![ - Artist { - name: String::from("Artist A.A"), - mbid: Some(uuid!("b7f7163d-61d5-4c96-b305-005df54fb999")), - }, - Artist { - name: String::from("Artist A.B"), - mbid: Some(uuid!("6f6b46f2-4bb5-47e7-a8c8-03ebde30164f")), - }, - ], - mbid: None, - }, - ], - mbid: None, - }], + title: String::from("Release group A"), + tracks: vec![ + Track { + number: 1, + title: String::from("Track A.1"), + artist: vec![String::from("Artist A.A")], + }, + Track { + number: 2, + title: String::from("Track A.2"), + artist: vec![String::from("Artist A.A")], + }, + Track { + number: 3, + title: String::from("Track A.3"), + artist: vec![String::from("Artist A.A"), String::from("Artist A.B")], + }, + ], }, - ReleaseGroup { - r#type: ReleaseGroupType::Single, - title: String::from("Release group B"), - artist: vec![Artist { + Album { + artist: AlbumArtist { name: String::from("Artist B"), mbid: None, - }], + }, year: 2008, - mbid: None, - releases: vec![Release { - tracks: vec![Track { - number: 1, - title: String::from("Track B.1"), - artist: vec![Artist { - name: String::from("Artist B.A"), - mbid: Some(uuid!("d927e216-2e63-415c-acec-bf9f1abd3e3c")), - }], - mbid: Some(uuid!("dacc9ce4-118c-4c92-aed7-1ebe4c7543b5")), - }], - mbid: Some(uuid!("ac7b642d-8b71-4588-a694-e5ae43fac873")), + title: String::from("Release group B"), + tracks: vec![Track { + number: 1, + title: String::from("Track B.1"), + artist: vec![String::from("Artist B.A")], }], }, ] @@ -168,7 +136,7 @@ mod tests { #[test] fn read() { - let mut read_data: Vec = vec![]; + let mut read_data: Vec = vec![]; DatabaseJson::reader(Path::new(TEST_FILENAME)) .unwrap() .read(&mut read_data) @@ -179,7 +147,7 @@ mod tests { #[test] fn reverse() { let write_data = test_data(); - let mut read_data: Vec = vec![]; + let mut read_data: Vec = vec![]; let temp_file = NamedTempFile::new().unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 78af023..30de129 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,45 +9,26 @@ pub mod library; /// [MusicBrainz Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) (MBID). pub type Mbid = Uuid; -/// [Artist](https://musicbrainz.org/doc/Artist). +/// An album artist. Carries a MBID to facilitate discography access. #[derive(Debug, Deserialize, Serialize, PartialEq)] -pub struct Artist { +pub struct AlbumArtist { pub name: String, pub mbid: Option, } -/// [Track](https://musicbrainz.org/doc/Track). +/// A single track on an album. #[derive(Debug, Deserialize, Serialize, PartialEq)] pub struct Track { pub number: u32, pub title: String, - pub artist: Vec, - pub mbid: Option, + pub artist: Vec, } -/// [Release](https://musicbrainz.org/doc/Release). +/// An album is a collection of tracks that were released together. #[derive(Debug, Deserialize, Serialize, PartialEq)] -pub struct Release { - pub tracks: Vec, - pub mbid: Option, -} - -/// [Release group primary type](https://musicbrainz.org/doc/Release_Group/Type). -#[derive(Debug, Deserialize, Serialize, PartialEq)] -pub enum ReleaseGroupType { - Album, - Ep, - Single, - Other, -} - -/// [Release group](https://musicbrainz.org/doc/Release_Group). -#[derive(Debug, Deserialize, Serialize, PartialEq)] -pub struct ReleaseGroup { - pub r#type: ReleaseGroupType, - pub title: String, - pub artist: Vec, +pub struct Album { + pub artist: AlbumArtist, pub year: u32, - pub mbid: Option, - pub releases: Vec, + pub title: String, + pub tracks: Vec, } diff --git a/src/library/beets.rs b/src/library/beets.rs new file mode 100644 index 0000000..bb44b60 --- /dev/null +++ b/src/library/beets.rs @@ -0,0 +1,417 @@ +use std::{collections::HashSet, fmt::Display, process::Command}; + +use crate::{Album, AlbumArtist, Track}; + +use super::{Error, Library, Query, QueryValue}; + +impl QueryValue { + fn to_string(&self, option_name: &str) -> String { + let negate = if self.negate { "^" } else { "" }; + format!("{}{}:{}", negate, option_name, self.value) + } +} + +impl Query { + fn to_args(&self) -> Vec { + let mut arguments: Vec = vec![]; + + if let Some(ref albumartist) = self.albumartist { + arguments.push(albumartist.to_string("albumartist")); + }; + + if let Some(ref album) = self.album { + arguments.push(album.to_string("album")); + }; + + if let Some(ref track) = self.track { + arguments.push(track.to_string("track")); + }; + + if let Some(ref title) = self.title { + arguments.push(title.to_string("title")); + }; + + if let Some(ref artist) = self.artist { + arguments.push(artist.to_string("artist")); + }; + + arguments + } +} + +pub trait BeetsExecutor { + fn exec(&mut self, arguments: Vec) -> Result, Error>; +} + +pub struct Beets { + executor: Box, +} + +pub struct SystemExecutor { + bin: String, +} + +impl SystemExecutor { + pub fn new(bin: &str) -> SystemExecutor { + SystemExecutor { + bin: bin.to_string(), + } + } +} + +impl Default for SystemExecutor { + fn default() -> Self { + SystemExecutor::new("beet") + } +} + +impl BeetsExecutor for SystemExecutor { + fn exec(&mut self, arguments: Vec) -> Result, Error> { + let output = Command::new(&self.bin).args(arguments).output()?; + let output = std::str::from_utf8(&output.stdout)?; + Ok(output.split('\n').map(|s| s.to_string()).collect()) + } +} + +#[derive(Debug, Hash, Eq, PartialEq)] +struct AlbumId { + artist: String, + year: u32, + title: String, +} + +impl AlbumId { + fn matches(&self, album: &Album) -> bool { + (self.artist == album.artist.name) + && (self.year == album.year) + && (self.title == album.title) + } +} + +macro_rules! separator { + () => { + "-*^-" + }; +} + +impl Beets { + const SEPARATOR: &str = separator!(); + const LIST_FORMAT: &str = concat!( + "--format=", + "$albumartist", + separator!(), + "$year", + separator!(), + "$album", + separator!(), + "$track", + separator!(), + "$title", + separator!(), + "$artist" + ); + + pub fn new(executor: Box) -> Beets { + Beets { executor } + } + + fn list_to_albums(list_output: Vec) -> Result, Error> { + let mut albums: Vec = vec![]; + let mut album_ids = HashSet::::new(); + + for line in list_output.iter() { + let split: Vec<&str> = line.split(Self::SEPARATOR).collect(); + + if split.len() != 6 { + return Err(Error::InvalidData(line.to_string())); + } + + let album_artist = split[0].to_string(); + let album_year = split[1].parse::()?; + let album_title = split[2].to_string(); + let track_number = split[3].parse::()?; + let track_title = split[4].to_string(); + let track_artist = split[5].to_string(); + + let track = Track { + number: track_number, + title: track_title, + artist: track_artist.split("; ").map(|s| s.to_owned()).collect(), + }; + + let aid = AlbumId { + artist: album_artist, + year: album_year, + title: album_title, + }; + + if album_ids.contains(&aid) { + // Beets returns results in order so we look from the back. + let album = albums.iter_mut().rev().find(|a| aid.matches(a)).unwrap(); + album.tracks.push(track); + } else { + let album_artist = AlbumArtist { + name: aid.artist.to_string(), + mbid: None, + }; + let album_title = aid.title.to_string(); + album_ids.insert(aid); + albums.push(Album { + artist: album_artist, + year: album_year, + title: album_title, + tracks: vec![track], + }); + } + } + Ok(albums) + } +} + +impl Library for Beets { + fn list(&mut self, query: &Query) -> Result, Error> { + let mut arguments: Vec = vec![String::from("ls")]; + arguments.push(Self::LIST_FORMAT.to_string()); + arguments.append(&mut query.to_args()); + + let output = self.executor.exec(arguments)?; + + Self::list_to_albums(output) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestExecutor { + arguments: Option>, + output: Option, Error>>, + } + + impl BeetsExecutor for TestExecutor { + fn exec(&mut self, arguments: Vec) -> Result, Error> { + if self.arguments.is_some() { + assert_eq!(self.arguments.take().unwrap(), arguments); + } + self.output.take().unwrap() + } + } + + fn test_data() -> Vec { + vec![ + Album { + artist: AlbumArtist { + name: "album_artist.a".to_string(), + mbid: None, + }, + year: 1998, + title: "album_title.a".to_string(), + tracks: vec![ + Track { + number: 1, + title: "track.a.1".to_string(), + artist: vec!["artist.a.1".to_string()], + }, + Track { + number: 2, + title: "track.a.2".to_string(), + artist: vec!["artist.a.2.1".to_string(), "artist.a.2.2".to_string()], + }, + Track { + number: 3, + title: "track.a.3".to_string(), + artist: vec!["artist.a.3".to_string()], + }, + ], + }, + Album { + artist: AlbumArtist { + name: "album_artist.b".to_string(), + mbid: None, + }, + year: 2003, + title: "album_title.b".to_string(), + tracks: vec![ + Track { + number: 1, + title: "track.b.1".to_string(), + artist: vec!["artist.b.1".to_string()], + }, + Track { + number: 2, + title: "track.b.2".to_string(), + artist: vec!["artist.b.2.1".to_string(), "artist.b.2.2".to_string()], + }, + ], + }, + ] + } + + fn album_to_beets_string(album: &Album) -> Vec { + let album_artist = &album.artist.name; + let album_year = &album.year; + let album_title = &album.title; + + let mut strings = vec![]; + for track in album.tracks.iter() { + let track_number = &track.number; + let track_title = &track.title; + let track_artist = &track.artist.join("; "); + + strings.push(format!( + "{album_artist}{0}{album_year}{0}{album_title}{0}\ + {track_number}{0}{track_title}{0}{track_artist}", + Beets::SEPARATOR, + )); + } + + strings + } + + #[test] + fn test_query() { + let query = Query { + albumartist: None, + album: Some(QueryValue { + negate: true, + value: String::from("some.album"), + }), + track: Some(QueryValue { + negate: false, + value: 5, + }), + title: None, + artist: Some(QueryValue { + negate: false, + value: String::from("some.artist"), + }), + }; + + assert_eq!( + query.to_args(), + vec![ + String::from("^album:some.album"), + String::from("track:5"), + String::from("artist:some.artist") + ] + ); + } + + #[test] + fn test_list_empty() { + let executor = TestExecutor { + arguments: Some(vec!["ls".to_string(), Beets::LIST_FORMAT.to_string()]), + output: Some(Ok(vec![])), + }; + let mut beets = Beets::new(Box::new(executor)); + let output = beets.list(&Query::default()).unwrap(); + + let expected: Vec = vec![]; + assert_eq!(output, expected); + } + + #[test] + fn test_list_ordered() { + let expected = test_data(); + let mut output = vec![]; + for album in expected.iter() { + output.append(&mut album_to_beets_string(album)); + } + + let executor = TestExecutor { + arguments: Some(vec!["ls".to_string(), Beets::LIST_FORMAT.to_string()]), + output: Some(Ok(output)), + }; + let mut beets = Beets::new(Box::new(executor)); + let output = beets.list(&Query::default()).unwrap(); + + assert_eq!(output, expected); + } + + #[test] + fn test_list_unordered() { + let mut expected = test_data(); + let mut output = vec![]; + for album in expected.iter() { + output.append(&mut album_to_beets_string(album)); + } + let last = output.len() - 1; + output.swap(0, last); + + // Putting the last track first will make its entire album come first in the output. + expected.rotate_right(1); + + // Same applies to that album's tracks. + expected[0].tracks.rotate_right(1); + + // And the (now) second album's tracks first track comes last. + expected[1].tracks.rotate_left(1); + + let executor = TestExecutor { + arguments: Some(vec!["ls".to_string(), Beets::LIST_FORMAT.to_string()]), + output: Some(Ok(output)), + }; + let mut beets = Beets::new(Box::new(executor)); + let output = beets.list(&Query::default()).unwrap(); + + assert_eq!(output, expected); + } + + #[test] + fn test_list_album_title_year_clash() { + let mut expected = test_data(); + expected[1].year = expected[0].year; + expected[1].title = expected[0].title.clone(); + + let mut output = vec![]; + for album in expected.iter() { + output.append(&mut album_to_beets_string(album)); + } + + let executor = TestExecutor { + arguments: Some(vec!["ls".to_string(), Beets::LIST_FORMAT.to_string()]), + output: Some(Ok(output)), + }; + let mut beets = Beets::new(Box::new(executor)); + let output = beets.list(&Query::default()).unwrap(); + + assert_eq!(output, expected); + } + + #[test] + fn test_list_query() { + let query = Query { + albumartist: None, + album: Some(QueryValue { + negate: true, + value: String::from("some.album"), + }), + track: Some(QueryValue { + negate: false, + value: 5, + }), + title: None, + artist: Some(QueryValue { + negate: false, + value: String::from("some.artist"), + }), + }; + + let executor = TestExecutor { + arguments: Some(vec![ + "ls".to_string(), + Beets::LIST_FORMAT.to_string(), + String::from("^album:some.album"), + String::from("track:5"), + String::from("artist:some.artist"), + ]), + output: Some(Ok(vec![])), + }; + let mut beets = Beets::new(Box::new(executor)); + let output = beets.list(&query).unwrap(); + + let expected: Vec = vec![]; + assert_eq!(output, expected); + } +} diff --git a/src/library/mod.rs b/src/library/mod.rs index 3183450..c6cfde9 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -1,17 +1,56 @@ -use std::path::Path; +use std::{num::ParseIntError, str::Utf8Error}; -use crate::ReleaseGroup; +use crate::Album; + +pub mod beets; + +pub struct QueryValue { + negate: bool, + value: T, +} /// Options for refining library queries. +#[derive(Default)] pub struct Query { - albumartist: Option, - artist: Option, - album: Option, - track: Option, - title: Option, + albumartist: Option>, + album: Option>, + track: Option>, + title: Option>, + artist: Option>, +} + +/// Error type for library calls. +#[derive(Debug)] +pub enum Error { + /// The underlying library returned invalid data. + InvalidData(String), + /// The underlying library experienced an I/O error. + IoError(String), + /// The underlying library failed to parse an integer. + ParseIntError(String), + /// The underlying library failed to parse a UTF-8 string. + Utf8Error(String), +} + +impl From for Error { + fn from(err: std::io::Error) -> Error { + Error::IoError(err.to_string()) + } +} + +impl From for Error { + fn from(err: ParseIntError) -> Error { + Error::ParseIntError(err.to_string()) + } +} + +impl From for Error { + fn from(err: Utf8Error) -> Error { + Error::Utf8Error(err.to_string()) + } } /// Trait for interacting with the music library. pub trait Library { - fn list(&self, album: bool, path: Path, format: &str, query: Query) -> Vec; + fn list(&mut self, query: &Query) -> Result, Error>; } diff --git a/tests/files/database_json_test.json b/tests/files/database_json_test.json index 4e3f7fc..5f2899a 100644 --- a/tests/files/database_json_test.json +++ b/tests/files/database_json_test.json @@ -1 +1 @@ -[{"type":"Album","title":"Release group A","artist":[{"name":"Artist A","mbid":"f7769831-746b-4a12-8124-0123d7fe17c9"}],"year":1998,"mbid":"89efbf43-3395-4f6e-ac11-32c1ce514bb0","releases":[{"tracks":[{"number":1,"title":"Track A.1","artist":[{"name":"Artist A.A","mbid":"b7f7163d-61d5-4c96-b305-005df54fb999"}],"mbid":null},{"number":2,"title":"Track A.2","artist":[{"name":"Artist A.A","mbid":"b7f7163d-61d5-4c96-b305-005df54fb999"}],"mbid":null},{"number":3,"title":"Track A.3","artist":[{"name":"Artist A.A","mbid":"b7f7163d-61d5-4c96-b305-005df54fb999"},{"name":"Artist A.B","mbid":"6f6b46f2-4bb5-47e7-a8c8-03ebde30164f"}],"mbid":null}],"mbid":null}]},{"type":"Single","title":"Release group B","artist":[{"name":"Artist B","mbid":null}],"year":2008,"mbid":null,"releases":[{"tracks":[{"number":1,"title":"Track B.1","artist":[{"name":"Artist B.A","mbid":"d927e216-2e63-415c-acec-bf9f1abd3e3c"}],"mbid":"dacc9ce4-118c-4c92-aed7-1ebe4c7543b5"}],"mbid":"ac7b642d-8b71-4588-a694-e5ae43fac873"}]}] \ No newline at end of file +[{"artist":{"name":"Artist A","mbid":"f7769831-746b-4a12-8124-0123d7fe17c9"},"year":1998,"title":"Release group A","tracks":[{"number":1,"title":"Track A.1","artist":["Artist A.A"]},{"number":2,"title":"Track A.2","artist":["Artist A.A"]},{"number":3,"title":"Track A.3","artist":["Artist A.A","Artist A.B"]}]},{"artist":{"name":"Artist B","mbid":null},"year":2008,"title":"Release group B","tracks":[{"number":1,"title":"Track B.1","artist":["Artist B.A"]}]}] \ No newline at end of file