Implement beets library

This commit is contained in:
Wojciech Kozlowski 2023-03-30 21:41:25 +09:00
parent 5ee8afc4c4
commit 37f7a15dd0
5 changed files with 508 additions and 103 deletions

View File

@ -5,7 +5,7 @@ use std::path::Path;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::Serialize; use serde::Serialize;
use crate::database::{DatabaseRead, DatabaseWrite}; use super::{DatabaseRead, DatabaseWrite};
/// A JSON file database. /// A JSON file database.
pub struct DatabaseJson { pub struct DatabaseJson {
@ -63,80 +63,48 @@ mod tests {
use super::*; use super::*;
use crate::{Artist, Release, ReleaseGroup, ReleaseGroupType, Track}; use crate::{Album, AlbumArtist, Track};
const TEST_FILENAME: &str = "tests/files/database_json_test.json"; const TEST_FILENAME: &str = "tests/files/database_json_test.json";
fn test_data() -> Vec<ReleaseGroup> { fn test_data() -> Vec<Album> {
vec![ vec![
ReleaseGroup { Album {
r#type: ReleaseGroupType::Album, artist: AlbumArtist {
title: String::from("Release group A"),
artist: vec![Artist {
name: String::from("Artist A"), name: String::from("Artist A"),
mbid: Some(uuid!("f7769831-746b-4a12-8124-0123d7fe17c9")), mbid: Some(uuid!("f7769831-746b-4a12-8124-0123d7fe17c9")),
}], },
year: 1998, year: 1998,
mbid: Some(uuid!("89efbf43-3395-4f6e-ac11-32c1ce514bb0")), title: String::from("Release group A"),
releases: vec![Release { tracks: vec![
tracks: vec![ Track {
Track { number: 1,
number: 1, title: String::from("Track A.1"),
title: String::from("Track A.1"), artist: vec![String::from("Artist A.A")],
artist: vec![Artist { },
name: String::from("Artist A.A"), Track {
mbid: Some(uuid!("b7f7163d-61d5-4c96-b305-005df54fb999")), number: 2,
}], title: String::from("Track A.2"),
mbid: None, artist: vec![String::from("Artist A.A")],
}, },
Track { Track {
number: 2, number: 3,
title: String::from("Track A.2"), title: String::from("Track A.3"),
artist: vec![Artist { artist: vec![String::from("Artist A.A"), String::from("Artist A.B")],
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,
}],
}, },
ReleaseGroup { Album {
r#type: ReleaseGroupType::Single, artist: AlbumArtist {
title: String::from("Release group B"),
artist: vec![Artist {
name: String::from("Artist B"), name: String::from("Artist B"),
mbid: None, mbid: None,
}], },
year: 2008, year: 2008,
mbid: None, title: String::from("Release group B"),
releases: vec![Release { tracks: vec![Track {
tracks: vec![Track { number: 1,
number: 1, title: String::from("Track B.1"),
title: String::from("Track B.1"), artist: vec![String::from("Artist B.A")],
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")),
}], }],
}, },
] ]
@ -168,7 +136,7 @@ mod tests {
#[test] #[test]
fn read() { fn read() {
let mut read_data: Vec<ReleaseGroup> = vec![]; let mut read_data: Vec<Album> = vec![];
DatabaseJson::reader(Path::new(TEST_FILENAME)) DatabaseJson::reader(Path::new(TEST_FILENAME))
.unwrap() .unwrap()
.read(&mut read_data) .read(&mut read_data)
@ -179,7 +147,7 @@ mod tests {
#[test] #[test]
fn reverse() { fn reverse() {
let write_data = test_data(); let write_data = test_data();
let mut read_data: Vec<ReleaseGroup> = vec![]; let mut read_data: Vec<Album> = vec![];
let temp_file = NamedTempFile::new().unwrap(); let temp_file = NamedTempFile::new().unwrap();

View File

@ -9,45 +9,26 @@ pub mod library;
/// [MusicBrainz Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) (MBID). /// [MusicBrainz Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) (MBID).
pub type Mbid = Uuid; 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)] #[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct Artist { pub struct AlbumArtist {
pub name: String, pub name: String,
pub mbid: Option<Mbid>, pub mbid: Option<Mbid>,
} }
/// [Track](https://musicbrainz.org/doc/Track). /// A single track on an album.
#[derive(Debug, Deserialize, Serialize, PartialEq)] #[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct Track { pub struct Track {
pub number: u32, pub number: u32,
pub title: String, pub title: String,
pub artist: Vec<Artist>, pub artist: Vec<String>,
pub mbid: Option<Mbid>,
} }
/// [Release](https://musicbrainz.org/doc/Release). /// An album is a collection of tracks that were released together.
#[derive(Debug, Deserialize, Serialize, PartialEq)] #[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct Release { pub struct Album {
pub tracks: Vec<Track>, pub artist: AlbumArtist,
pub mbid: Option<Mbid>,
}
/// [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<Artist>,
pub year: u32, pub year: u32,
pub mbid: Option<Mbid>, pub title: String,
pub releases: Vec<Release>, pub tracks: Vec<Track>,
} }

417
src/library/beets.rs Normal file
View File

@ -0,0 +1,417 @@
use std::{collections::HashSet, fmt::Display, process::Command};
use crate::{Album, AlbumArtist, Track};
use super::{Error, Library, Query, QueryValue};
impl<T: Display> QueryValue<T> {
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<String> {
let mut arguments: Vec<String> = 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<String>) -> Result<Vec<String>, Error>;
}
pub struct Beets {
executor: Box<dyn BeetsExecutor>,
}
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<String>) -> Result<Vec<String>, 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<dyn BeetsExecutor>) -> Beets {
Beets { executor }
}
fn list_to_albums(list_output: Vec<String>) -> Result<Vec<Album>, Error> {
let mut albums: Vec<Album> = vec![];
let mut album_ids = HashSet::<AlbumId>::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::<u32>()?;
let album_title = split[2].to_string();
let track_number = split[3].parse::<u32>()?;
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<Vec<Album>, Error> {
let mut arguments: Vec<String> = 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<Vec<String>>,
output: Option<Result<Vec<String>, Error>>,
}
impl BeetsExecutor for TestExecutor {
fn exec(&mut self, arguments: Vec<String>) -> Result<Vec<String>, Error> {
if self.arguments.is_some() {
assert_eq!(self.arguments.take().unwrap(), arguments);
}
self.output.take().unwrap()
}
}
fn test_data() -> Vec<Album> {
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<String> {
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<Album> = 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<Album> = vec![];
assert_eq!(output, expected);
}
}

View File

@ -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<T> {
negate: bool,
value: T,
}
/// Options for refining library queries. /// Options for refining library queries.
#[derive(Default)]
pub struct Query { pub struct Query {
albumartist: Option<String>, albumartist: Option<QueryValue<String>>,
artist: Option<String>, album: Option<QueryValue<String>>,
album: Option<String>, track: Option<QueryValue<u32>>,
track: Option<u32>, title: Option<QueryValue<String>>,
title: Option<String>, artist: Option<QueryValue<String>>,
}
/// 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<std::io::Error> for Error {
fn from(err: std::io::Error) -> Error {
Error::IoError(err.to_string())
}
}
impl From<ParseIntError> for Error {
fn from(err: ParseIntError) -> Error {
Error::ParseIntError(err.to_string())
}
}
impl From<Utf8Error> for Error {
fn from(err: Utf8Error) -> Error {
Error::Utf8Error(err.to_string())
}
} }
/// Trait for interacting with the music library. /// Trait for interacting with the music library.
pub trait Library { pub trait Library {
fn list(&self, album: bool, path: Path, format: &str, query: Query) -> Vec<ReleaseGroup>; fn list(&mut self, query: &Query) -> Result<Vec<Album>, Error>;
} }

View File

@ -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"}]}] [{"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"]}]}]