Resolve "Local collection trait and beets implementation" #9
@ -1,3 +1,5 @@
|
|||||||
|
//! Module for storing MusicHoard data in a JSON file database.
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@ -5,7 +7,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 {
|
||||||
@ -59,84 +61,49 @@ impl DatabaseWrite for DatabaseJson {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
use uuid::uuid;
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use crate::{Artist, Release, ReleaseGroup, ReleaseGroupType, Track};
|
use crate::{Album, AlbumId, 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,
|
id: AlbumId {
|
||||||
title: String::from("Release group A"),
|
artist: String::from("Artist A"),
|
||||||
artist: vec![Artist {
|
year: 1998,
|
||||||
name: String::from("Artist A"),
|
title: String::from("Release group A"),
|
||||||
mbid: Some(uuid!("f7769831-746b-4a12-8124-0123d7fe17c9")),
|
},
|
||||||
}],
|
tracks: vec![
|
||||||
year: 1998,
|
Track {
|
||||||
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,
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
ReleaseGroup {
|
|
||||||
r#type: ReleaseGroupType::Single,
|
|
||||||
title: String::from("Release group B"),
|
|
||||||
artist: vec![Artist {
|
|
||||||
name: String::from("Artist B"),
|
|
||||||
mbid: None,
|
|
||||||
}],
|
|
||||||
year: 2008,
|
|
||||||
mbid: None,
|
|
||||||
releases: vec![Release {
|
|
||||||
tracks: vec![Track {
|
|
||||||
number: 1,
|
number: 1,
|
||||||
title: String::from("Track B.1"),
|
title: String::from("Track A.1"),
|
||||||
artist: vec![Artist {
|
artist: vec![String::from("Artist A.A")],
|
||||||
name: String::from("Artist B.A"),
|
},
|
||||||
mbid: Some(uuid!("d927e216-2e63-415c-acec-bf9f1abd3e3c")),
|
Track {
|
||||||
}],
|
number: 2,
|
||||||
mbid: Some(uuid!("dacc9ce4-118c-4c92-aed7-1ebe4c7543b5")),
|
title: String::from("Track A.2"),
|
||||||
}],
|
artist: vec![String::from("Artist A.A")],
|
||||||
mbid: Some(uuid!("ac7b642d-8b71-4588-a694-e5ae43fac873")),
|
},
|
||||||
|
Track {
|
||||||
|
number: 3,
|
||||||
|
title: String::from("Track A.3"),
|
||||||
|
artist: vec![String::from("Artist A.A"), String::from("Artist A.B")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Album {
|
||||||
|
id: AlbumId {
|
||||||
|
artist: String::from("Artist B"),
|
||||||
|
year: 2008,
|
||||||
|
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 +135,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 +146,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();
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
//! Module for storing MusicHoard data in a database.
|
||||||
|
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
@ -5,6 +7,7 @@ pub mod json;
|
|||||||
|
|
||||||
/// Trait for database reads.
|
/// Trait for database reads.
|
||||||
pub trait DatabaseRead {
|
pub trait DatabaseRead {
|
||||||
|
/// Read collection from the database.
|
||||||
fn read<D>(&mut self, collection: &mut D) -> Result<(), std::io::Error>
|
fn read<D>(&mut self, collection: &mut D) -> Result<(), std::io::Error>
|
||||||
where
|
where
|
||||||
D: DeserializeOwned;
|
D: DeserializeOwned;
|
||||||
@ -12,6 +15,7 @@ pub trait DatabaseRead {
|
|||||||
|
|
||||||
/// Trait for database writes.
|
/// Trait for database writes.
|
||||||
pub trait DatabaseWrite {
|
pub trait DatabaseWrite {
|
||||||
|
/// Write collection to the database.
|
||||||
fn write<S>(&mut self, collection: &S) -> Result<(), std::io::Error>
|
fn write<S>(&mut self, collection: &S) -> Result<(), std::io::Error>
|
||||||
where
|
where
|
||||||
S: Serialize;
|
S: Serialize;
|
||||||
|
51
src/lib.rs
51
src/lib.rs
@ -4,49 +4,30 @@ use serde::{Deserialize, Serialize};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub mod database;
|
pub mod database;
|
||||||
|
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).
|
/// A single track on an album.
|
||||||
#[derive(Debug, Deserialize, Serialize, PartialEq)]
|
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
pub struct Artist {
|
|
||||||
pub name: String,
|
|
||||||
pub mbid: Option<Mbid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [Track](https://musicbrainz.org/doc/Track).
|
|
||||||
#[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).
|
/// The album identifier.
|
||||||
#[derive(Debug, Deserialize, Serialize, PartialEq)]
|
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone, Hash)]
|
||||||
pub struct Release {
|
pub struct AlbumId {
|
||||||
pub tracks: Vec<Track>,
|
pub artist: String,
|
||||||
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>,
|
}
|
||||||
|
|
||||||
|
/// An album is a collection of tracks that were released together.
|
||||||
|
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
pub struct Album {
|
||||||
|
pub id: AlbumId,
|
||||||
|
pub tracks: Vec<Track>,
|
||||||
}
|
}
|
||||||
|
432
src/library/beets.rs
Normal file
432
src/library/beets.rs
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
//! Module for interacting with the music library via
|
||||||
|
//! [beets](https://beets.readthedocs.io/en/stable/).
|
||||||
|
|
||||||
|
use std::{collections::HashSet, fmt::Display, process::Command};
|
||||||
|
|
||||||
|
use crate::{Album, AlbumId, Track};
|
||||||
|
|
||||||
|
use super::{Error, Library, Query, QueryOption};
|
||||||
|
|
||||||
|
trait QueryOptionArgBeets {
|
||||||
|
fn to_arg(&self, option_name: &str) -> Option<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
trait QueryArgsBeets {
|
||||||
|
fn to_args(&self) -> Vec<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
trait SimpleOption {}
|
||||||
|
impl SimpleOption for String {}
|
||||||
|
impl SimpleOption for u32 {}
|
||||||
|
|
||||||
|
impl<T: SimpleOption + Display> QueryOptionArgBeets for QueryOption<T> {
|
||||||
|
fn to_arg(&self, option_name: &str) -> Option<String> {
|
||||||
|
let (negate, value) = match self {
|
||||||
|
Self::Include(value) => ("", value),
|
||||||
|
Self::Exclude(value) => ("^", value),
|
||||||
|
Self::None => return None,
|
||||||
|
};
|
||||||
|
Some(format!("{}{}{}", negate, option_name, value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueryOptionArgBeets for QueryOption<Vec<String>> {
|
||||||
|
fn to_arg(&self, option_name: &str) -> Option<String> {
|
||||||
|
let (negate, vec) = match self {
|
||||||
|
Self::Include(value) => ("", value),
|
||||||
|
Self::Exclude(value) => ("^", value),
|
||||||
|
Self::None => return None,
|
||||||
|
};
|
||||||
|
Some(format!("{}{}{}", negate, option_name, vec.join("; ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueryArgsBeets for Query {
|
||||||
|
fn to_args(&self) -> Vec<String> {
|
||||||
|
let mut arguments: Vec<String> = vec![];
|
||||||
|
|
||||||
|
if let Some(album_artist) = self.album_artist.to_arg("albumartist:") {
|
||||||
|
arguments.push(album_artist);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(album_year) = self.album_year.to_arg("year:") {
|
||||||
|
arguments.push(album_year);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(album_title) = self.album_title.to_arg("album:") {
|
||||||
|
arguments.push(album_title);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(track_number) = self.track_number.to_arg("track:") {
|
||||||
|
arguments.push(track_number);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(track_title) = self.track_title.to_arg("title:") {
|
||||||
|
arguments.push(track_title);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(track_artist) = self.track_artist.to_arg("artist:") {
|
||||||
|
arguments.push(track_artist);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(all) = self.all.to_arg("") {
|
||||||
|
arguments.push(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
arguments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trait for invoking beets commands.
|
||||||
|
pub trait BeetsExecutor {
|
||||||
|
/// Invoke beets with the provided arguments.
|
||||||
|
fn exec(&mut self, arguments: Vec<String>) -> Result<Vec<String>, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Struct for interacting with the music library via beets.
|
||||||
|
pub struct Beets {
|
||||||
|
executor: Box<dyn BeetsExecutor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
trait LibraryPrivate {
|
||||||
|
const CMD_LIST: &'static str;
|
||||||
|
const LIST_FORMAT_SEPARATOR: &'static str;
|
||||||
|
const LIST_FORMAT_ARG: &'static str;
|
||||||
|
|
||||||
|
fn list_cmd_and_args(query: &Query) -> Vec<String>;
|
||||||
|
fn list_to_albums(list_output: Vec<String>) -> Result<Vec<Album>, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Beets {
|
||||||
|
pub fn new(executor: Box<dyn BeetsExecutor>) -> Beets {
|
||||||
|
Beets { executor }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Library for Beets {
|
||||||
|
fn list(&mut self, query: &Query) -> Result<Vec<Album>, Error> {
|
||||||
|
let cmd = Self::list_cmd_and_args(query);
|
||||||
|
let output = self.executor.exec(cmd)?;
|
||||||
|
Self::list_to_albums(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! list_format_separator {
|
||||||
|
() => {
|
||||||
|
"-*^-"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LibraryPrivate for Beets {
|
||||||
|
const CMD_LIST: &'static str = "ls";
|
||||||
|
const LIST_FORMAT_SEPARATOR: &'static str = list_format_separator!();
|
||||||
|
const LIST_FORMAT_ARG: &'static str = concat!(
|
||||||
|
"--format=",
|
||||||
|
"$albumartist",
|
||||||
|
list_format_separator!(),
|
||||||
|
"$year",
|
||||||
|
list_format_separator!(),
|
||||||
|
"$album",
|
||||||
|
list_format_separator!(),
|
||||||
|
"$track",
|
||||||
|
list_format_separator!(),
|
||||||
|
"$title",
|
||||||
|
list_format_separator!(),
|
||||||
|
"$artist"
|
||||||
|
);
|
||||||
|
|
||||||
|
fn list_cmd_and_args(query: &Query) -> Vec<String> {
|
||||||
|
let mut cmd: Vec<String> = vec![String::from(Self::CMD_LIST)];
|
||||||
|
cmd.push(Self::LIST_FORMAT_ARG.to_string());
|
||||||
|
cmd.append(&mut query.to_args());
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
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::LIST_FORMAT_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 aid = AlbumId {
|
||||||
|
artist: album_artist,
|
||||||
|
year: album_year,
|
||||||
|
title: album_title,
|
||||||
|
};
|
||||||
|
|
||||||
|
let track = Track {
|
||||||
|
number: track_number,
|
||||||
|
title: track_title,
|
||||||
|
artist: track_artist.split("; ").map(|s| s.to_owned()).collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if album_ids.contains(&aid) {
|
||||||
|
// Beets returns results in order so we look from the back.
|
||||||
|
let album = albums.iter_mut().rev().find(|a| a.id == aid).unwrap();
|
||||||
|
album.tracks.push(track);
|
||||||
|
} else {
|
||||||
|
album_ids.insert(aid.clone());
|
||||||
|
albums.push(Album {
|
||||||
|
id: aid,
|
||||||
|
tracks: vec![track],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(albums)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executor for executing beets commands on the local system.
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
id: AlbumId {
|
||||||
|
artist: "album_artist.a".to_string(),
|
||||||
|
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 {
|
||||||
|
id: AlbumId {
|
||||||
|
artist: "album_artist.b".to_string(),
|
||||||
|
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.id.artist;
|
||||||
|
let album_year = &album.id.year;
|
||||||
|
let album_title = &album.id.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::LIST_FORMAT_SEPARATOR,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
strings
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_query() {
|
||||||
|
let query = Query::new()
|
||||||
|
.album_title(QueryOption::Exclude(String::from("some.album")))
|
||||||
|
.track_number(QueryOption::Include(5))
|
||||||
|
.track_artist(QueryOption::Include(vec![
|
||||||
|
String::from("some.artist.1"),
|
||||||
|
String::from("some.artist.2"),
|
||||||
|
]))
|
||||||
|
.all(QueryOption::Exclude(String::from("some.all")));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
query.to_args(),
|
||||||
|
vec![
|
||||||
|
String::from("^album:some.album"),
|
||||||
|
String::from("track:5"),
|
||||||
|
String::from("artist:some.artist.1; some.artist.2"),
|
||||||
|
String::from("^some.all"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_empty() {
|
||||||
|
let executor = TestExecutor {
|
||||||
|
arguments: Some(vec!["ls".to_string(), Beets::LIST_FORMAT_ARG.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_ARG.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_ARG.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].id.year = expected[0].id.year;
|
||||||
|
expected[1].id.title = expected[0].id.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_ARG.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::new()
|
||||||
|
.album_title(QueryOption::Exclude(String::from("some.album")))
|
||||||
|
.track_number(QueryOption::Include(5))
|
||||||
|
.track_artist(QueryOption::Include(vec![String::from("some.artist")]));
|
||||||
|
|
||||||
|
let executor = TestExecutor {
|
||||||
|
arguments: Some(vec![
|
||||||
|
"ls".to_string(),
|
||||||
|
Beets::LIST_FORMAT_ARG.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);
|
||||||
|
}
|
||||||
|
}
|
133
src/library/mod.rs
Normal file
133
src/library/mod.rs
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
//! Module for interacting with the music library.
|
||||||
|
|
||||||
|
use std::{num::ParseIntError, str::Utf8Error};
|
||||||
|
|
||||||
|
use crate::Album;
|
||||||
|
|
||||||
|
pub mod beets;
|
||||||
|
|
||||||
|
/// A single query option.
|
||||||
|
pub enum QueryOption<T> {
|
||||||
|
/// Inclusive query.
|
||||||
|
Include(T),
|
||||||
|
/// Exclusive query.
|
||||||
|
Exclude(T),
|
||||||
|
/// No query.
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> QueryOption<T> {
|
||||||
|
/// Return `true` if [QueryOption] is not [QueryOption::None].
|
||||||
|
pub fn is_some(&self) -> bool {
|
||||||
|
!matches!(self, QueryOption::None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return `true` if [QueryOption] is [QueryOption::None].
|
||||||
|
pub fn is_none(&self) -> bool {
|
||||||
|
matches!(self, QueryOption::None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Default for QueryOption<T> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Options for refining library queries.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Query {
|
||||||
|
album_artist: QueryOption<String>,
|
||||||
|
album_year: QueryOption<u32>,
|
||||||
|
album_title: QueryOption<String>,
|
||||||
|
track_number: QueryOption<u32>,
|
||||||
|
track_title: QueryOption<String>,
|
||||||
|
track_artist: QueryOption<Vec<String>>,
|
||||||
|
all: QueryOption<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Query {
|
||||||
|
/// Create an empty query.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Query::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refine the query to a specific album artist.
|
||||||
|
pub fn album_artist(mut self, album_artist: QueryOption<String>) -> Self {
|
||||||
|
self.album_artist = album_artist;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refine the query to a specific album year.
|
||||||
|
pub fn album_year(mut self, album_year: QueryOption<u32>) -> Self {
|
||||||
|
self.album_year = album_year;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refine the query to a specific album title.
|
||||||
|
pub fn album_title(mut self, album_title: QueryOption<String>) -> Self {
|
||||||
|
self.album_title = album_title;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refine the query to a specific track number.
|
||||||
|
pub fn track_number(mut self, track_number: QueryOption<u32>) -> Self {
|
||||||
|
self.track_number = track_number;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refine the query to a specific track title.
|
||||||
|
pub fn track_title(mut self, track_title: QueryOption<String>) -> Self {
|
||||||
|
self.track_title = track_title;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refine the query to a specific set of track artists.
|
||||||
|
pub fn track_artist(mut self, track_artist: QueryOption<Vec<String>>) -> Self {
|
||||||
|
self.track_artist = track_artist;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refine the query for all fields.
|
||||||
|
pub fn all(mut self, all: QueryOption<String>) -> Self {
|
||||||
|
self.all = all;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub trait Library {
|
||||||
|
/// List lirbary items that match the a specific query.
|
||||||
|
fn list(&mut self, query: &Query) -> Result<Vec<Album>, Error>;
|
||||||
|
}
|
@ -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"}]}]
|
[{"id":{"artist":"Artist A","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"]}]},{"id":{"artist":"Artist B","year":2008,"title":"Release group B"},"tracks":[{"number":1,"title":"Track B.1","artist":["Artist B.A"]}]}]
|
Loading…
Reference in New Issue
Block a user