Sort by <field>_sort from tags if it is available (#107)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m3s
Cargo CI / Lint (push) Successful in 44s

Closes #73

Reviewed-on: #107
This commit is contained in:
Wojciech Kozlowski 2024-01-13 15:42:04 +01:00
parent 83675c25e6
commit 3109e576e3
11 changed files with 472 additions and 262 deletions

View File

@ -44,6 +44,8 @@ enum ArtistCommand {
Add(ArtistValue), Add(ArtistValue),
#[structopt(about = "Remove an artist from the collection")] #[structopt(about = "Remove an artist from the collection")]
Remove(ArtistValue), Remove(ArtistValue),
#[structopt(about = "Edit the artist's sort name")]
Sort(SortCommand),
#[structopt(name = "musicbrainz", about = "Edit the MusicBrainz URL of an artist")] #[structopt(name = "musicbrainz", about = "Edit the MusicBrainz URL of an artist")]
MusicBrainz(UrlCommand<SingleUrlValue>), MusicBrainz(UrlCommand<SingleUrlValue>),
#[structopt( #[structopt(
@ -57,12 +59,28 @@ enum ArtistCommand {
Qobuz(UrlCommand<SingleUrlValue>), Qobuz(UrlCommand<SingleUrlValue>),
} }
#[derive(StructOpt, Debug)]
enum SortCommand {
#[structopt(help = "Set the provided name as the artist's sort name")]
Set(ArtistSortValue),
#[structopt(help = "Clear the artist's sort name")]
Clear(ArtistValue),
}
#[derive(StructOpt, Debug)] #[derive(StructOpt, Debug)]
struct ArtistValue { struct ArtistValue {
#[structopt(help = "The name of the artist")] #[structopt(help = "The name of the artist")]
artist: String, artist: String,
} }
#[derive(StructOpt, Debug)]
struct ArtistSortValue {
#[structopt(help = "The name of the artist")]
artist: String,
#[structopt(help = "The sort name of the artist")]
sort: String,
}
#[derive(StructOpt, Debug)] #[derive(StructOpt, Debug)]
enum UrlCommand<T: StructOpt> { enum UrlCommand<T: StructOpt> {
#[structopt(about = "Add the provided URL(s) without overwriting existing values")] #[structopt(about = "Add the provided URL(s) without overwriting existing values")]
@ -137,6 +155,9 @@ impl ArtistCommand {
ArtistCommand::Remove(artist_value) => { ArtistCommand::Remove(artist_value) => {
music_hoard.remove_artist(ArtistId::new(artist_value.artist)); music_hoard.remove_artist(ArtistId::new(artist_value.artist));
} }
ArtistCommand::Sort(sort_command) => {
sort_command.handle(music_hoard);
}
ArtistCommand::MusicBrainz(url_command) => { ArtistCommand::MusicBrainz(url_command) => {
single_url_command_dispatch!(url_command, music_hoard, musicbrainz) single_url_command_dispatch!(url_command, music_hoard, musicbrainz)
} }
@ -153,6 +174,22 @@ impl ArtistCommand {
} }
} }
impl SortCommand {
fn handle(self, music_hoard: &mut MH) {
match self {
SortCommand::Set(artist_sort_value) => music_hoard
.set_artist_sort(
ArtistId::new(artist_sort_value.artist),
ArtistId::new(artist_sort_value.sort),
)
.expect("faild to set artist sort name"),
SortCommand::Clear(artist_value) => music_hoard
.clear_artist_sort(ArtistId::new(artist_value.artist))
.expect("failed to clear artist sort name"),
}
}
}
fn main() { fn main() {
let opt = Opt::from_args(); let opt = Opt::from_args();

View File

@ -65,26 +65,28 @@ mod tests {
use super::*; use super::*;
use crate::{tests::COLLECTION, Artist, ArtistId, Collection, Format, IUrl}; use crate::{tests::COLLECTION, Artist, ArtistId, Collection, Format};
fn opt_to_url<U: IUrl>(opt: &Option<U>) -> String { fn opt_to_str<S: AsRef<str>>(opt: &Option<S>) -> String {
match opt { match opt {
Some(mb) => format!("\"{}\"", mb.url()), Some(val) => format!("\"{}\"", val.as_ref()),
None => String::from("null"), None => String::from("null"),
} }
} }
fn vec_to_urls<U: IUrl>(vec: &[U]) -> String { fn vec_to_str<S: AsRef<str>>(vec: &[S]) -> String {
let mut urls: Vec<String> = vec![]; let mut urls: Vec<String> = vec![];
for item in vec.iter() { for item in vec.iter() {
urls.push(format!("\"{}\"", item.url())); urls.push(format!("\"{}\"", item.as_ref()));
} }
format!("[{}]", urls.join(",")) format!("[{}]", urls.join(","))
} }
fn artist_to_json(artist: &Artist) -> String { fn artist_id_to_str(id: &ArtistId) -> String {
let album_artist = &artist.id.name; format!("{{\"name\":\"{}\"}}", id.name)
}
fn artist_to_json(artist: &Artist) -> String {
let mut albums: Vec<String> = vec![]; let mut albums: Vec<String> = vec![];
for album in artist.albums.iter() { for album in artist.albums.iter() {
let album_year = album.id.year; let album_year = album.id.year;
@ -127,10 +129,10 @@ mod tests {
} }
let albums = albums.join(","); let albums = albums.join(",");
let musicbrainz = opt_to_url(&artist.properties.musicbrainz); let musicbrainz = opt_to_str(&artist.properties.musicbrainz);
let musicbutler = vec_to_urls(&artist.properties.musicbutler); let musicbutler = vec_to_str(&artist.properties.musicbutler);
let bandcamp = vec_to_urls(&artist.properties.bandcamp); let bandcamp = vec_to_str(&artist.properties.bandcamp);
let qobuz = opt_to_url(&artist.properties.qobuz); let qobuz = opt_to_str(&artist.properties.qobuz);
let properties = format!( let properties = format!(
"{{\ "{{\
@ -141,9 +143,17 @@ mod tests {
}}" }}"
); );
let album_artist = artist_id_to_str(&artist.id);
let album_artist_sort = artist
.sort
.as_ref()
.map(artist_id_to_str)
.unwrap_or_else(|| "null".to_string());
format!( format!(
"{{\ "{{\
\"id\":{{\"name\":\"{album_artist}\"}},\ \"id\":{album_artist},\
\"sort\":{album_artist_sort},\
\"properties\":{properties},\ \"properties\":{properties},\
\"albums\":[{albums}]\ \"albums\":[{albums}]\
}}" }}"
@ -225,9 +235,7 @@ mod tests {
fn save_errors() { fn save_errors() {
let mut object = HashMap::<ArtistId, String>::new(); let mut object = HashMap::<ArtistId, String>::new();
object.insert( object.insert(
ArtistId { ArtistId::new(String::from("artist")),
name: String::from("artist"),
},
String::from("string"), String::from("string"),
); );
let serde_err = serde_json::to_string(&object); let serde_err = serde_json::to_string(&object);

View File

@ -18,11 +18,6 @@ use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
use uuid::Uuid; 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 /// An object with the [`IMbid`] trait contains a [MusicBrainz
/// Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) (MBID). /// Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) (MBID).
pub trait IMbid { pub trait IMbid {
@ -83,6 +78,12 @@ impl MusicBrainz {
} }
} }
impl AsRef<str> for MusicBrainz {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
impl TryFrom<&str> for MusicBrainz { impl TryFrom<&str> for MusicBrainz {
type Error = Error; type Error = Error;
@ -97,12 +98,6 @@ impl Display for MusicBrainz {
} }
} }
impl IUrl for MusicBrainz {
fn url(&self) -> &str {
self.0.as_str()
}
}
impl IMbid for MusicBrainz { impl IMbid for MusicBrainz {
fn mbid(&self) -> &str { fn mbid(&self) -> &str {
// The URL is assumed to have been validated. // The URL is assumed to have been validated.
@ -142,6 +137,12 @@ impl MusicButler {
} }
} }
impl AsRef<str> for MusicButler {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
impl TryFrom<&str> for MusicButler { impl TryFrom<&str> for MusicButler {
type Error = Error; type Error = Error;
@ -150,12 +151,6 @@ impl TryFrom<&str> for MusicButler {
} }
} }
impl IUrl for MusicButler {
fn url(&self) -> &str {
self.0.as_str()
}
}
/// Bandcamp reference. /// Bandcamp reference.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Bandcamp(Url); pub struct Bandcamp(Url);
@ -188,6 +183,12 @@ impl Bandcamp {
} }
} }
impl AsRef<str> for Bandcamp {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
impl TryFrom<&str> for Bandcamp { impl TryFrom<&str> for Bandcamp {
type Error = Error; type Error = Error;
@ -196,12 +197,6 @@ impl TryFrom<&str> for Bandcamp {
} }
} }
impl IUrl for Bandcamp {
fn url(&self) -> &str {
self.0.as_str()
}
}
/// Qobuz reference. /// Qobuz reference.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Qobuz(Url); pub struct Qobuz(Url);
@ -230,6 +225,12 @@ impl Qobuz {
} }
} }
impl AsRef<str> for Qobuz {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
impl TryFrom<&str> for Qobuz { impl TryFrom<&str> for Qobuz {
type Error = Error; type Error = Error;
@ -244,12 +245,6 @@ impl Display for Qobuz {
} }
} }
impl IUrl for Qobuz {
fn url(&self) -> &str {
self.0.as_str()
}
}
/// The track file format. /// The track file format.
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
pub enum Format { pub enum Format {
@ -380,6 +375,7 @@ impl Merge for ArtistProperties {
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct Artist { pub struct Artist {
pub id: ArtistId, pub id: ArtistId,
pub sort: Option<ArtistId>,
pub properties: ArtistProperties, pub properties: ArtistProperties,
pub albums: Vec<Album>, pub albums: Vec<Album>,
} }
@ -433,11 +429,24 @@ impl Artist {
pub fn new<ID: Into<ArtistId>>(id: ID) -> Self { pub fn new<ID: Into<ArtistId>>(id: ID) -> Self {
Artist { Artist {
id: id.into(), id: id.into(),
sort: None,
properties: ArtistProperties::default(), properties: ArtistProperties::default(),
albums: vec![], albums: vec![],
} }
} }
fn get_sort_key(&self) -> &ArtistId {
self.sort.as_ref().unwrap_or(&self.id)
}
fn set_sort_key<SORT: Into<ArtistId>>(&mut self, sort: SORT) {
self.sort = Some(sort.into());
}
fn clear_sort_key(&mut self) {
_ = self.sort.take();
}
fn add_unique_url<S: AsRef<str>, T: for<'a> TryFrom<&'a str, Error = Error> + Eq + Display>( fn add_unique_url<S: AsRef<str>, T: for<'a> TryFrom<&'a str, Error = Error> + Eq + Display>(
container: &mut Option<T>, container: &mut Option<T>,
url: S, url: S,
@ -552,13 +561,14 @@ impl PartialOrd for Artist {
impl Ord for Artist { impl Ord for Artist {
fn cmp(&self, other: &Self) -> std::cmp::Ordering { fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.id.cmp(&other.id) self.get_sort_key().cmp(other.get_sort_key())
} }
} }
impl Merge for Artist { impl Merge for Artist {
fn merge(mut self, other: Self) -> Self { fn merge(mut self, other: Self) -> Self {
assert_eq!(self.id, other.id); assert_eq!(self.id, other.id);
self.sort = Self::merge_opts(self.sort, other.sort);
self.properties = self.properties.merge(other.properties); self.properties = self.properties.merge(other.properties);
self.albums = MergeSorted::new(self.albums.into_iter(), other.albums.into_iter()).collect(); self.albums = MergeSorted::new(self.albums.into_iter(), other.albums.into_iter()).collect();
self self
@ -719,7 +729,7 @@ macro_rules! music_hoard_unique_url_dispatch {
artist_id: ID, artist_id: ID,
url: S, url: S,
) -> Result<(), Error> { ) -> Result<(), Error> {
self.get_artist_or_err(artist_id.as_ref())?.[<add_ $field _url>](url) self.get_artist_mut_or_err(artist_id.as_ref())?.[<add_ $field _url>](url)
} }
pub fn [<remove_ $field _url>]<ID: AsRef<ArtistId>, S: AsRef<str>>( pub fn [<remove_ $field _url>]<ID: AsRef<ArtistId>, S: AsRef<str>>(
@ -727,7 +737,7 @@ macro_rules! music_hoard_unique_url_dispatch {
artist_id: ID, artist_id: ID,
url: S, url: S,
) -> Result<(), Error> { ) -> Result<(), Error> {
self.get_artist_or_err(artist_id.as_ref())?.[<remove_ $field _url>](url) self.get_artist_mut_or_err(artist_id.as_ref())?.[<remove_ $field _url>](url)
} }
pub fn [<set_ $field _url>]<ID: AsRef<ArtistId>, S: AsRef<str>>( pub fn [<set_ $field _url>]<ID: AsRef<ArtistId>, S: AsRef<str>>(
@ -735,14 +745,14 @@ macro_rules! music_hoard_unique_url_dispatch {
artist_id: ID, artist_id: ID,
url: S, url: S,
) -> Result<(), Error> { ) -> Result<(), Error> {
self.get_artist_or_err(artist_id.as_ref())?.[<set_ $field _url>](url) self.get_artist_mut_or_err(artist_id.as_ref())?.[<set_ $field _url>](url)
} }
pub fn [<clear_ $field _url>]<ID: AsRef<ArtistId>>( pub fn [<clear_ $field _url>]<ID: AsRef<ArtistId>>(
&mut self, &mut self,
artist_id: ID, artist_id: ID,
) -> Result<(), Error> { ) -> Result<(), Error> {
self.get_artist_or_err(artist_id.as_ref())?.[<clear_ $field _url>](); self.get_artist_mut_or_err(artist_id.as_ref())?.[<clear_ $field _url>]();
Ok(()) Ok(())
} }
} }
@ -757,7 +767,7 @@ macro_rules! music_hoard_multi_url_dispatch {
artist_id: ID, artist_id: ID,
urls: Vec<S>, urls: Vec<S>,
) -> Result<(), Error> { ) -> Result<(), Error> {
self.get_artist_or_err(artist_id.as_ref())?.[<add_ $field _urls>](urls) self.get_artist_mut_or_err(artist_id.as_ref())?.[<add_ $field _urls>](urls)
} }
pub fn [<remove_ $field _urls>]<ID: AsRef<ArtistId>, S: AsRef<str>>( pub fn [<remove_ $field _urls>]<ID: AsRef<ArtistId>, S: AsRef<str>>(
@ -765,7 +775,7 @@ macro_rules! music_hoard_multi_url_dispatch {
artist_id: ID, artist_id: ID,
urls: Vec<S>, urls: Vec<S>,
) -> Result<(), Error> { ) -> Result<(), Error> {
self.get_artist_or_err(artist_id.as_ref())?.[<remove_ $field _urls>](urls) self.get_artist_mut_or_err(artist_id.as_ref())?.[<remove_ $field _urls>](urls)
} }
pub fn [<set_ $field _urls>]<ID: AsRef<ArtistId>, S: AsRef<str>>( pub fn [<set_ $field _urls>]<ID: AsRef<ArtistId>, S: AsRef<str>>(
@ -773,13 +783,13 @@ macro_rules! music_hoard_multi_url_dispatch {
artist_id: ID, artist_id: ID,
urls: Vec<S>, urls: Vec<S>,
) -> Result<(), Error> { ) -> Result<(), Error> {
self.get_artist_or_err(artist_id.as_ref())?.[<set_ $field _urls>](urls) self.get_artist_mut_or_err(artist_id.as_ref())?.[<set_ $field _urls>](urls)
} }
pub fn [<clear_ $field _urls>]<ID: AsRef<ArtistId>>( pub fn [<clear_ $field _urls>]<ID: AsRef<ArtistId>>(
&mut self, artist_id: ID, &mut self, artist_id: ID,
) -> Result<(), Error> { ) -> Result<(), Error> {
self.get_artist_or_err(artist_id.as_ref())?.[<clear_ $field _urls>](); self.get_artist_mut_or_err(artist_id.as_ref())?.[<clear_ $field _urls>]();
Ok(()) Ok(())
} }
} }
@ -823,6 +833,24 @@ impl<LIB, DB> MusicHoard<LIB, DB> {
} }
} }
pub fn set_artist_sort<ID: AsRef<ArtistId>, SORT: Into<ArtistId>>(
&mut self,
artist_id: ID,
artist_sort: SORT,
) -> Result<(), Error> {
self.get_artist_mut_or_err(artist_id.as_ref())?
.set_sort_key(artist_sort);
Self::sort(&mut self.collection);
Ok(())
}
pub fn clear_artist_sort<ID: AsRef<ArtistId>>(&mut self, artist_id: ID) -> Result<(), Error> {
self.get_artist_mut_or_err(artist_id.as_ref())?
.clear_sort_key();
Self::sort(&mut self.collection);
Ok(())
}
music_hoard_unique_url_dispatch!(musicbrainz); music_hoard_unique_url_dispatch!(musicbrainz);
music_hoard_multi_url_dispatch!(musicbutler); music_hoard_multi_url_dispatch!(musicbutler);
@ -845,7 +873,7 @@ impl<LIB, DB> MusicHoard<LIB, DB> {
MergeSorted::new(primary.into_iter(), secondary.into_iter()).collect() MergeSorted::new(primary.into_iter(), secondary.into_iter()).collect()
} }
fn items_to_artists(items: Vec<Item>) -> Vec<Artist> { fn items_to_artists(items: Vec<Item>) -> Result<Vec<Artist>, Error> {
let mut artists: Vec<Artist> = vec![]; let mut artists: Vec<Artist> = vec![];
let mut album_ids = HashMap::<ArtistId, HashSet<AlbumId>>::new(); let mut album_ids = HashMap::<ArtistId, HashSet<AlbumId>>::new();
@ -854,6 +882,8 @@ impl<LIB, DB> MusicHoard<LIB, DB> {
name: item.album_artist, name: item.album_artist,
}; };
let artist_sort = item.album_artist_sort.map(|s| ArtistId { name: s });
let album_id = AlbumId { let album_id = AlbumId {
year: item.album_year, year: item.album_year,
title: item.album_title, title: item.album_title,
@ -886,6 +916,19 @@ impl<LIB, DB> MusicHoard<LIB, DB> {
artists.last_mut().unwrap() artists.last_mut().unwrap()
}; };
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;
}
if album_ids[&artist_id].contains(&album_id) { if album_ids[&artist_id].contains(&album_id) {
// Assume results are in some order which means they will likely be grouped by // 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 // album. Therefore, we look from the back since the last inserted album is most
@ -909,15 +952,19 @@ impl<LIB, DB> MusicHoard<LIB, DB> {
} }
} }
artists Ok(artists)
} }
fn get_artist(&mut self, artist_id: &ArtistId) -> Option<&mut Artist> { fn get_artist(&self, artist_id: &ArtistId) -> Option<&Artist> {
self.collection.iter().find(|a| &a.id == artist_id)
}
fn get_artist_mut(&mut self, artist_id: &ArtistId) -> Option<&mut Artist> {
self.collection.iter_mut().find(|a| &a.id == artist_id) self.collection.iter_mut().find(|a| &a.id == artist_id)
} }
fn get_artist_or_err(&mut self, artist_id: &ArtistId) -> Result<&mut Artist, Error> { fn get_artist_mut_or_err(&mut self, artist_id: &ArtistId) -> Result<&mut Artist, Error> {
self.get_artist(artist_id).ok_or_else(|| { self.get_artist_mut(artist_id).ok_or_else(|| {
Error::CollectionError(format!("artist '{}' is not in the collection", artist_id)) Error::CollectionError(format!("artist '{}' is not in the collection", artist_id))
}) })
} }
@ -927,7 +974,7 @@ impl<LIB: ILibrary, DB> MusicHoard<LIB, DB> {
/// Rescan the library and merge with the in-memory collection. /// Rescan the library and merge with the in-memory collection.
pub fn rescan_library(&mut self) -> Result<(), Error> { pub fn rescan_library(&mut self) -> Result<(), Error> {
let items = self.library.list(&Query::new())?; let items = self.library.list(&Query::new())?;
let mut library_collection = Self::items_to_artists(items); let mut library_collection = Self::items_to_artists(items)?;
Self::sort(&mut library_collection); Self::sort(&mut library_collection);
let collection = mem::take(&mut self.collection); let collection = mem::take(&mut self.collection);
@ -1036,6 +1083,7 @@ mod tests {
for track in album.tracks.iter() { for track in album.tracks.iter() {
items.push(Item { items.push(Item {
album_artist: artist.id.name.clone(), album_artist: artist.id.name.clone(),
album_artist_sort: artist.sort.as_ref().map(|s| s.name.clone()),
album_year: album.id.year, album_year: album.id.year,
album_title: album.id.title.clone(), album_title: album.id.title.clone(),
track_number: track.id.number, track_number: track.id.number,
@ -1070,7 +1118,7 @@ mod tests {
let uuid = "d368baa8-21ca-4759-9731-0b2753071ad8"; let uuid = "d368baa8-21ca-4759-9731-0b2753071ad8";
let url = format!("https://musicbrainz.org/artist/{uuid}"); let url = format!("https://musicbrainz.org/artist/{uuid}");
let mb = MusicBrainz::new(&url).unwrap(); let mb = MusicBrainz::new(&url).unwrap();
assert_eq!(url, mb.url()); assert_eq!(url, mb.as_ref());
assert_eq!(uuid, mb.mbid()); assert_eq!(uuid, mb.mbid());
let url = "not a url at all".to_string(); let url = "not a url at all".to_string();
@ -1142,6 +1190,53 @@ mod tests {
assert_eq!(music_hoard.collection, expected); assert_eq!(music_hoard.collection, expected);
} }
#[test]
fn artist_sort_set_clear() {
let mut music_hoard = MusicHoardBuilder::default().build();
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);
music_hoard.add_artist(artist_1_id.clone());
music_hoard.add_artist(artist_2_id.clone());
let artist_1: &Artist = music_hoard.get_artist(&artist_1_id).unwrap();
let artist_2: &Artist = music_hoard.get_artist(&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 = music_hoard.get_artist(&artist_1_id).unwrap();
let artist_2: &Artist = music_hoard.get_artist(&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 = music_hoard.get_artist(&artist_1_id).unwrap();
let artist_2: &Artist = music_hoard.get_artist(&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] #[test]
fn collection_error() { fn collection_error() {
let artist_id = ArtistId::new("an artist"); let artist_id = ArtistId::new("an artist");
@ -1963,6 +2058,42 @@ mod tests {
assert_eq!(music_hoard.get_collection(), &expected); assert_eq!(music_hoard.get_collection(), &expected);
} }
#[test]
fn rescan_library_album_artist_sort_clash() {
let mut library = MockILibrary::new();
let database = MockIDatabase::new();
let expected = clean_collection(COLLECTION.to_owned());
let library_input = Query::new();
let mut library_items = artists_to_items(&expected);
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 = MusicHoardBuilder::default()
.set_library(library)
.set_database(database)
.build();
assert!(music_hoard.rescan_library().is_err());
}
#[test] #[test]
fn load_database() { fn load_database() {
let library = MockILibrary::new(); let library = MockILibrary::new();

View File

@ -22,6 +22,8 @@ const LIST_FORMAT_ARG: &str = concat!(
"--format=", "--format=",
"$albumartist", "$albumartist",
list_format_separator!(), list_format_separator!(),
"$albumartist_sort",
list_format_separator!(),
"$year", "$year",
list_format_separator!(), list_format_separator!(),
"$album", "$album",
@ -52,6 +54,7 @@ impl ToBeetsArg for Field {
let negate = if include { "" } else { "^" }; let negate = if include { "" } else { "^" };
match self { match self {
Field::AlbumArtist(ref s) => format!("{negate}albumartist:{s}"), Field::AlbumArtist(ref s) => format!("{negate}albumartist:{s}"),
Field::AlbumArtistSort(ref s) => format!("{negate}albumartist_sort:{s}"),
Field::AlbumYear(ref u) => format!("{negate}year:{u}"), Field::AlbumYear(ref u) => format!("{negate}year:{u}"),
Field::AlbumTitle(ref s) => format!("{negate}album:{s}"), Field::AlbumTitle(ref s) => format!("{negate}album:{s}"),
Field::TrackNumber(ref u) => format!("{negate}track:{u}"), Field::TrackNumber(ref u) => format!("{negate}track:{u}"),
@ -123,29 +126,35 @@ impl<BLE: IBeetsLibraryExecutor> ILibraryPrivate for BeetsLibrary<BLE> {
} }
let split: Vec<&str> = line.split(LIST_FORMAT_SEPARATOR).collect(); let split: Vec<&str> = line.split(LIST_FORMAT_SEPARATOR).collect();
if split.len() != 8 { if split.len() != 9 {
return Err(Error::Invalid(line.to_string())); return Err(Error::Invalid(line.to_string()));
} }
let album_artist = split[0].to_string(); let album_artist = split[0].to_string();
let album_year = split[1].parse::<u32>()?; let album_artist_sort = if !split[1].is_empty() {
let album_title = split[2].to_string(); Some(split[1].to_string())
let track_number = split[3].parse::<u32>()?; } else {
let track_title = split[4].to_string(); None
let track_artist = split[5] };
let album_year = split[2].parse::<u32>()?;
let album_title = split[3].to_string();
let track_number = split[4].parse::<u32>()?;
let track_title = split[5].to_string();
let track_artist = split[6]
.to_string() .to_string()
.split("; ") .split("; ")
.map(|s| s.to_owned()) .map(|s| s.to_owned())
.collect(); .collect();
let track_format = match split[6].to_string().as_str() { let track_format = match split[7].to_string().as_str() {
TRACK_FORMAT_FLAC => Format::Flac, TRACK_FORMAT_FLAC => Format::Flac,
TRACK_FORMAT_MP3 => Format::Mp3, TRACK_FORMAT_MP3 => Format::Mp3,
_ => return Err(Error::Invalid(line.to_string())), _ => return Err(Error::Invalid(line.to_string())),
}; };
let track_bitrate = split[7].trim_end_matches("kbps").parse::<u32>()?; let track_bitrate = split[8].trim_end_matches("kbps").parse::<u32>()?;
items.push(Item { items.push(Item {
album_artist, album_artist,
album_artist_sort,
album_year, album_year,
album_title, album_title,
track_number, track_number,
@ -170,10 +179,15 @@ mod tests {
fn item_to_beets_string(item: &Item) -> String { fn item_to_beets_string(item: &Item) -> String {
format!( format!(
"{album_artist}{sep}{album_year}{sep}{album_title}{sep}\ "{album_artist}{sep}{album_artist_sort}{sep}\
{album_year}{sep}{album_title}{sep}\
{track_number}{sep}{track_title}{sep}\ {track_number}{sep}{track_title}{sep}\
{track_artist}{sep}{track_format}{sep}{track_bitrate}kbps", {track_artist}{sep}{track_format}{sep}{track_bitrate}kbps",
album_artist = item.album_artist, album_artist = item.album_artist,
album_artist_sort = match item.album_artist_sort {
Some(ref album_artist_sort) => album_artist_sort,
None => "",
},
album_year = item.album_year, album_year = item.album_year,
album_title = item.album_title, album_title = item.album_title,
track_number = item.track_number, track_number = item.track_number,
@ -221,6 +235,7 @@ mod tests {
let mut query = Query::default() let mut query = Query::default()
.exclude(Field::AlbumArtist(String::from("some.albumartist"))) .exclude(Field::AlbumArtist(String::from("some.albumartist")))
.exclude(Field::AlbumArtistSort(String::from("some.albumartist")))
.include(Field::AlbumYear(3030)) .include(Field::AlbumYear(3030))
.include(Field::TrackTitle(String::from("some.track"))) .include(Field::TrackTitle(String::from("some.track")))
.exclude(Field::TrackArtist(vec![ .exclude(Field::TrackArtist(vec![
@ -234,6 +249,7 @@ mod tests {
query, query,
vec![ vec![
String::from("^albumartist:some.albumartist"), String::from("^albumartist:some.albumartist"),
String::from("^albumartist_sort:some.albumartist"),
String::from("^artist:some.artist.1; some.artist.2"), String::from("^artist:some.artist.1; some.artist.2"),
String::from("title:some.track"), String::from("title:some.track"),
String::from("year:3030"), String::from("year:3030"),
@ -349,8 +365,8 @@ mod tests {
.split(LIST_FORMAT_SEPARATOR) .split(LIST_FORMAT_SEPARATOR)
.map(|s| s.to_owned()) .map(|s| s.to_owned())
.collect::<Vec<String>>(); .collect::<Vec<String>>();
invalid_string[6].clear(); invalid_string[7].clear();
invalid_string[6].push_str("invalid format"); invalid_string[7].push_str("invalid format");
let invalid_string = invalid_string.join(LIST_FORMAT_SEPARATOR); let invalid_string = invalid_string.join(LIST_FORMAT_SEPARATOR);
output[2] = invalid_string.clone(); output[2] = invalid_string.clone();
let result = Ok(output); let result = Ok(output);

View File

@ -30,6 +30,7 @@ impl ILibrary for NullLibrary {
#[derive(Debug, PartialEq, Eq, Hash)] #[derive(Debug, PartialEq, Eq, Hash)]
pub struct Item { pub struct Item {
pub album_artist: String, pub album_artist: String,
pub album_artist_sort: Option<String>,
pub album_year: u32, pub album_year: u32,
pub album_title: String, pub album_title: String,
pub track_number: u32, pub track_number: u32,
@ -43,6 +44,7 @@ pub struct Item {
#[derive(Debug, Hash, PartialEq, Eq)] #[derive(Debug, Hash, PartialEq, Eq)]
pub enum Field { pub enum Field {
AlbumArtist(String), AlbumArtist(String),
AlbumArtistSort(String),
AlbumYear(u32), AlbumYear(u32),
AlbumTitle(String), AlbumTitle(String),
TrackNumber(u32), TrackNumber(u32),

View File

@ -5,6 +5,7 @@ macro_rules! collection {
id: ArtistId { id: ArtistId {
name: "album_artist a".to_string(), name: "album_artist a".to_string(),
}, },
sort: None,
properties: ArtistProperties { properties: ArtistProperties {
musicbrainz: Some(MusicBrainz::new( musicbrainz: Some(MusicBrainz::new(
"https://musicbrainz.org/artist/00000000-0000-0000-0000-000000000000", "https://musicbrainz.org/artist/00000000-0000-0000-0000-000000000000",
@ -100,6 +101,7 @@ macro_rules! collection {
id: ArtistId { id: ArtistId {
name: "album_artist b".to_string(), name: "album_artist b".to_string(),
}, },
sort: None,
properties: ArtistProperties { properties: ArtistProperties {
musicbrainz: Some(MusicBrainz::new( musicbrainz: Some(MusicBrainz::new(
"https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111", "https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111",
@ -192,6 +194,7 @@ macro_rules! collection {
id: ArtistId { id: ArtistId {
name: "album_artist c".to_string(), name: "album_artist c".to_string(),
}, },
sort: None,
properties: ArtistProperties { properties: ArtistProperties {
musicbrainz: Some(MusicBrainz::new( musicbrainz: Some(MusicBrainz::new(
"https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111", "https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111",

View File

@ -1,6 +1,6 @@
use std::fmt; use std::fmt;
use musichoard::{Album, Artist, Collection, Format, IUrl, Track}; use musichoard::{Album, Artist, Collection, Format, Track};
use ratatui::{ use ratatui::{
backend::Backend, backend::Backend,
layout::{Alignment, Rect}, layout::{Alignment, Rect},
@ -409,20 +409,23 @@ struct ArtistOverlay<'a> {
} }
impl<'a> ArtistOverlay<'a> { impl<'a> ArtistOverlay<'a> {
fn opt_opt_to_str<U: IUrl>(opt: Option<Option<&U>>) -> &str { fn opt_opt_to_str<S: AsRef<str>>(opt: Option<Option<&S>>) -> &str {
opt.flatten().map(|item| item.url()).unwrap_or("") opt.flatten().map(|item| item.as_ref()).unwrap_or("")
} }
fn opt_vec_to_string<U: IUrl>(opt_vec: Option<&Vec<U>>, indent: &str) -> String { fn opt_vec_to_string<S: AsRef<str>>(opt_vec: Option<&Vec<S>>, indent: &str) -> String {
opt_vec opt_vec
.map(|vec| { .map(|vec| {
if vec.len() < 2 { if vec.len() < 2 {
vec.first().map(|item| item.url()).unwrap_or("").to_string() vec.first()
.map(|item| item.as_ref())
.unwrap_or("")
.to_string()
} else { } else {
let indent = format!("\n{indent}"); let indent = format!("\n{indent}");
let list = vec let list = vec
.iter() .iter()
.map(|item| item.url()) .map(|item| item.as_ref())
.collect::<Vec<&str>>() .collect::<Vec<&str>>()
.join(&indent); .join(&indent);
format!("{indent}{list}") format!("{indent}{list}")

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -41,6 +41,7 @@ fn artist_to_items(artist: &Artist) -> Vec<Item> {
for track in album.tracks.iter() { for track in album.tracks.iter() {
items.push(Item { items.push(Item {
album_artist: artist.id.name.clone(), album_artist: artist.id.name.clone(),
album_artist_sort: artist.sort.as_ref().map(|s| s.name.clone()),
album_year: album.id.year, album_year: album.id.year,
album_title: album.id.title.clone(), album_title: album.id.title.clone(),
track_number: track.id.number, track_number: track.id.number,
@ -107,7 +108,7 @@ fn test_album_artist_query() {
.list(Query::new().include(Field::AlbumArtist(String::from("Аркона")))) .list(Query::new().include(Field::AlbumArtist(String::from("Аркона"))))
.unwrap(); .unwrap();
let expected: Vec<Item> = artists_to_items(&COLLECTION[4..5]); let expected: Vec<Item> = artists_to_items(&COLLECTION[0..1]);
assert_eq!(output, expected); assert_eq!(output, expected);
} }
@ -120,7 +121,7 @@ fn test_album_title_query() {
.list(Query::new().include(Field::AlbumTitle(String::from("Slovo")))) .list(Query::new().include(Field::AlbumTitle(String::from("Slovo"))))
.unwrap(); .unwrap();
let expected: Vec<Item> = artists_to_items(&COLLECTION[4..5]); let expected: Vec<Item> = artists_to_items(&COLLECTION[0..1]);
assert_eq!(output, expected); assert_eq!(output, expected);
} }
@ -132,7 +133,7 @@ fn test_exclude_query() {
let output = beets let output = beets
.list(Query::new().exclude(Field::AlbumArtist(String::from("Аркона")))) .list(Query::new().exclude(Field::AlbumArtist(String::from("Аркона"))))
.unwrap(); .unwrap();
let expected: Vec<Item> = artists_to_items(&COLLECTION[..4]); let expected: Vec<Item> = artists_to_items(&COLLECTION[1..]);
let output: HashSet<_> = output.iter().collect(); let output: HashSet<_> = output.iter().collect();
let expected: HashSet<_> = expected.iter().collect(); let expected: HashSet<_> = expected.iter().collect();

View File

@ -6,10 +6,195 @@ use once_cell::sync::Lazy;
pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| -> Collection { pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| -> Collection {
vec![ vec![
Artist {
id: ArtistId {
name: String::from("Аркона"),
},
sort: Some(ArtistId{
name: String::from("Arkona")
}),
properties: ArtistProperties {
musicbrainz: Some(MusicBrainz::new(
"https://musicbrainz.org/artist/baad262d-55ef-427a-83c7-f7530964f212",
).unwrap()),
musicbutler: vec![
MusicButler::new("https://www.musicbutler.io/artist-page/283448581").unwrap(),
],
bandcamp: vec![
Bandcamp::new("https://arkonamoscow.bandcamp.com/").unwrap(),
],
qobuz: Some(Qobuz::new(
"https://www.qobuz.com/nl-nl/interpreter/arkona/download-streaming-albums",
).unwrap()),
},
albums: vec![Album {
id: AlbumId {
year: 2011,
title: String::from("Slovo"),
},
tracks: vec![
Track {
id: TrackId {
number: 1,
title: String::from("Az"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 992,
},
},
Track {
id: TrackId {
number: 2,
title: String::from("Arkaim"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1061,
},
},
Track {
id: TrackId {
number: 3,
title: String::from("Bolno mne"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1004,
},
},
Track {
id: TrackId {
number: 4,
title: String::from("Leshiy"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1077,
},
},
Track {
id: TrackId {
number: 5,
title: String::from("Zakliatie"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1041,
},
},
Track {
id: TrackId {
number: 6,
title: String::from("Predok"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 756,
},
},
Track {
id: TrackId {
number: 7,
title: String::from("Nikogda"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1059,
},
},
Track {
id: TrackId {
number: 8,
title: String::from("Tam za tumanami"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1023,
},
},
Track {
id: TrackId {
number: 9,
title: String::from("Potomok"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 838,
},
},
Track {
id: TrackId {
number: 10,
title: String::from("Slovo"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1028,
},
},
Track {
id: TrackId {
number: 11,
title: String::from("Odna"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 991,
},
},
Track {
id: TrackId {
number: 12,
title: String::from("Vo moiom sadochke…"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 919,
},
},
Track {
id: TrackId {
number: 13,
title: String::from("Stenka na stenku"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1039,
},
},
Track {
id: TrackId {
number: 14,
title: String::from("Zimushka"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 974,
},
},
],
}],
},
Artist { Artist {
id: ArtistId { id: ArtistId {
name: String::from("Eluveitie"), name: String::from("Eluveitie"),
}, },
sort: None,
properties: ArtistProperties { properties: ArtistProperties {
musicbrainz: Some(MusicBrainz::new( musicbrainz: Some(MusicBrainz::new(
"https://musicbrainz.org/artist/8000598a-5edb-401c-8e6d-36b167feaf38", "https://musicbrainz.org/artist/8000598a-5edb-401c-8e6d-36b167feaf38",
@ -243,6 +428,7 @@ pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| -> Collection {
id: ArtistId { id: ArtistId {
name: String::from("Frontside"), name: String::from("Frontside"),
}, },
sort: None,
properties: ArtistProperties { properties: ArtistProperties {
musicbrainz: Some(MusicBrainz::new( musicbrainz: Some(MusicBrainz::new(
"https://musicbrainz.org/artist/3a901353-fccd-4afd-ad01-9c03f451b490", "https://musicbrainz.org/artist/3a901353-fccd-4afd-ad01-9c03f451b490",
@ -389,6 +575,9 @@ pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| -> Collection {
id: ArtistId { id: ArtistId {
name: String::from("Heavens Basement"), name: String::from("Heavens Basement"),
}, },
sort: Some(ArtistId {
name: String::from("Heavens Basement"),
}),
properties: ArtistProperties { properties: ArtistProperties {
musicbrainz: Some(MusicBrainz::new( musicbrainz: Some(MusicBrainz::new(
"https://musicbrainz.org/artist/c2c4d56a-d599-4a18-bd2f-ae644e2198cc", "https://musicbrainz.org/artist/c2c4d56a-d599-4a18-bd2f-ae644e2198cc",
@ -509,6 +698,7 @@ pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| -> Collection {
id: ArtistId { id: ArtistId {
name: String::from("Metallica"), name: String::from("Metallica"),
}, },
sort: None,
properties: ArtistProperties { properties: ArtistProperties {
musicbrainz: Some(MusicBrainz::new( musicbrainz: Some(MusicBrainz::new(
"https://musicbrainz.org/artist/65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab", "https://musicbrainz.org/artist/65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab",
@ -859,186 +1049,5 @@ pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| -> Collection {
}, },
], ],
}, },
Artist {
id: ArtistId {
name: String::from("Аркона"),
},
properties: ArtistProperties {
musicbrainz: Some(MusicBrainz::new(
"https://musicbrainz.org/artist/baad262d-55ef-427a-83c7-f7530964f212",
).unwrap()),
musicbutler: vec![
MusicButler::new("https://www.musicbutler.io/artist-page/283448581").unwrap(),
],
bandcamp: vec![
Bandcamp::new("https://arkonamoscow.bandcamp.com/").unwrap(),
],
qobuz: Some(Qobuz::new(
"https://www.qobuz.com/nl-nl/interpreter/arkona/download-streaming-albums",
).unwrap()),
},
albums: vec![Album {
id: AlbumId {
year: 2011,
title: String::from("Slovo"),
},
tracks: vec![
Track {
id: TrackId {
number: 1,
title: String::from("Az"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 992,
},
},
Track {
id: TrackId {
number: 2,
title: String::from("Arkaim"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1061,
},
},
Track {
id: TrackId {
number: 3,
title: String::from("Bolno mne"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1004,
},
},
Track {
id: TrackId {
number: 4,
title: String::from("Leshiy"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1077,
},
},
Track {
id: TrackId {
number: 5,
title: String::from("Zakliatie"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1041,
},
},
Track {
id: TrackId {
number: 6,
title: String::from("Predok"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 756,
},
},
Track {
id: TrackId {
number: 7,
title: String::from("Nikogda"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1059,
},
},
Track {
id: TrackId {
number: 8,
title: String::from("Tam za tumanami"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1023,
},
},
Track {
id: TrackId {
number: 9,
title: String::from("Potomok"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 838,
},
},
Track {
id: TrackId {
number: 10,
title: String::from("Slovo"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1028,
},
},
Track {
id: TrackId {
number: 11,
title: String::from("Odna"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 991,
},
},
Track {
id: TrackId {
number: 12,
title: String::from("Vo moiom sadochke…"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 919,
},
},
Track {
id: TrackId {
number: 13,
title: String::from("Stenka na stenku"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 1039,
},
},
Track {
id: TrackId {
number: 14,
title: String::from("Zimushka"),
},
artist: vec![String::from("Аркона")],
quality: Quality {
format: Format::Flac,
bitrate: 974,
},
},
],
}],
},
] ]
}); });