Implements artists/ endoint

This commit is contained in:
Antoine Gersant 2024-08-10 10:30:21 -07:00
parent bc3ed59382
commit 0afab8d634
7 changed files with 166 additions and 90 deletions

1
Cargo.lock generated
View file

@ -1649,6 +1649,7 @@ dependencies = [
"toml 0.8.19",
"tower-http",
"trie-rs",
"unicase",
"ureq 2.10.0",
"winres",
]

View file

@ -44,6 +44,7 @@ tokio-util = { version = "0.7.11", features = ["io"] }
toml = "0.8.19"
tower-http = { version = "0.5.2", features = ["fs"] }
trie-rs = { version = "0.4.2", features = ["serde"] }
unicase = "2.7.0"
ureq = { version = "2.10.0", default-features = false, features = ["tls"] }
[dependencies.axum]

View file

@ -17,7 +17,7 @@ mod search;
mod storage;
pub use browser::File;
pub use collection::{Album, Artist, Song};
pub use collection::{Album, Artist, ArtistHeader, Song};
use storage::{AlbumKey, ArtistKey, InternPath, SongKey};
#[derive(Clone)]
@ -105,6 +105,18 @@ impl Manager {
.unwrap()
}
pub async fn get_artists(&self) -> Vec<ArtistHeader> {
spawn_blocking({
let index_manager = self.clone();
move || {
let index = index_manager.index.read().unwrap();
index.collection.get_artists(&index.strings)
}
})
.await
.unwrap()
}
pub async fn get_artist(&self, name: String) -> Result<Artist, Error> {
spawn_blocking({
let index_manager = self.clone();

View file

@ -1,5 +1,6 @@
use std::{
borrow::BorrowMut,
cmp::Ordering,
collections::{HashMap, HashSet},
path::PathBuf,
};
@ -7,6 +8,7 @@ use std::{
use lasso2::{Rodeo, RodeoReader};
use rand::{rngs::ThreadRng, seq::IteratorRandom};
use serde::{Deserialize, Serialize};
use unicase::UniCase;
use crate::app::index::storage::{self, store_song, AlbumKey, ArtistKey, SongKey};
use crate::app::scanner;
@ -14,12 +16,16 @@ use crate::app::scanner;
use super::storage::fetch_song;
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Artist {
pub struct ArtistHeader {
pub name: Option<String>,
pub num_albums: u32,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Artist {
pub header: ArtistHeader,
pub albums: Vec<Album>,
pub song_credits: Vec<Album>, // Albums where this artist shows up as `artist` without being `album_artist`
pub composer_credits: Vec<Album>, // Albums where this artist shows up as `composer` without being `artist` or `album_artist`
pub lyricist_credits: Vec<Album>, // Albums where this artist shows up as `lyricist` without being `artist` or `album_artist`
pub featured_on: Vec<Album>, // Albums where this artist shows up as `artist` without being `album_artist`
}
#[derive(Debug, Default, PartialEq, Eq)]
@ -61,44 +67,39 @@ pub struct Collection {
}
impl Collection {
pub fn get_artists(&self, strings: &RodeoReader) -> Vec<ArtistHeader> {
let mut artists = self
.artists
.values()
.filter(|a| a.albums.len() > 0 || a.featured_on.len() > 1)
.map(|a| make_artist_header(a, strings))
.collect::<Vec<_>>();
artists.sort_by(|a, b| match (&a.name, &b.name) {
(Some(a), Some(b)) => UniCase::new(a).cmp(&UniCase::new(b)),
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Less,
(Some(_), None) => Ordering::Greater,
});
artists
}
pub fn get_artist(&self, strings: &RodeoReader, artist_key: ArtistKey) -> Option<Artist> {
self.artists.get(&artist_key).map(|a| {
let sort_albums = |a: &Album, b: &Album| (&a.year, &a.name).cmp(&(&b.year, &b.name));
let albums = {
let mut albums = a
.albums
.iter()
.filter_map(|key| self.get_album(strings, key.clone()))
.collect::<Vec<_>>();
albums.sort_by(|a, b| (a.year, &a.name).partial_cmp(&(b.year, &b.name)).unwrap());
albums.sort_by(sort_albums);
albums
};
let sort_albums =
|a: &Album, b: &Album| (&a.year, &a.name).partial_cmp(&(&b.year, &b.name)).unwrap();
let song_credits = {
let featured_on = {
let mut albums = a
.song_credits
.iter()
.filter_map(|key| self.get_album(strings, key.clone()))
.collect::<Vec<_>>();
albums.sort_by(&sort_albums);
albums
};
let composer_credits = {
let mut albums = a
.composer_credits
.iter()
.filter_map(|key| self.get_album(strings, key.clone()))
.collect::<Vec<_>>();
albums.sort_by(&sort_albums);
albums
};
let lyricist_credits = {
let mut albums = a
.lyricist_credits
.featured_on
.iter()
.filter_map(|key| self.get_album(strings, key.clone()))
.collect::<Vec<_>>();
@ -107,11 +108,9 @@ impl Collection {
};
Artist {
name: a.name.map(|s| strings.resolve(&s).to_string()),
header: make_artist_header(a, strings),
albums,
song_credits,
composer_credits,
lyricist_credits,
featured_on,
}
})
}
@ -174,6 +173,13 @@ impl Collection {
}
}
fn make_artist_header(artist: &storage::Artist, strings: &RodeoReader) -> ArtistHeader {
ArtistHeader {
name: artist.name.map(|n| strings.resolve(&n).to_owned()),
num_albums: artist.albums.len() as u32 + artist.featured_on.len() as u32,
}
}
#[derive(Default)]
pub struct Builder {
artists: HashMap<ArtistKey, storage::Artist>,
@ -228,25 +234,7 @@ impl Builder {
if song.album_artists.is_empty() {
artist.albums.insert(album_key.clone());
} else if !song.album_artists.contains(name) {
artist.song_credits.insert(album_key.clone());
}
}
for name in &song.composers {
let is_also_artist = song.artists.contains(name);
let is_also_album_artist = song.artists.contains(name);
if !is_also_artist && !is_also_album_artist {
let artist = self.get_or_create_artist(*name);
artist.composer_credits.insert(album_key.clone());
}
}
for name in &song.lyricists {
let is_also_artist = song.artists.contains(name);
let is_also_album_artist = song.artists.contains(name);
if !is_also_artist && !is_also_album_artist {
let artist = self.get_or_create_artist(*name);
artist.lyricist_credits.insert(album_key.clone());
artist.featured_on.insert(album_key.clone());
}
}
}
@ -258,9 +246,7 @@ impl Builder {
.or_insert_with(|| storage::Artist {
name: Some(name),
albums: HashSet::new(),
song_credits: HashSet::new(),
composer_credits: HashSet::new(),
lyricist_credits: HashSet::new(),
featured_on: HashSet::new(),
})
.borrow_mut()
}
@ -317,6 +303,61 @@ mod test {
(browser, strings)
}
#[tokio::test]
async fn can_list_artists() {
let (collection, strings) = setup_test(Vec::from([
scanner::Song {
virtual_path: PathBuf::from("Kai.mp3"),
title: Some("Kai".to_owned()),
artists: vec!["FSOL".to_owned()],
..Default::default()
},
scanner::Song {
virtual_path: PathBuf::from("Fantasy.mp3"),
title: Some("Fantasy".to_owned()),
artists: vec!["Stratovarius".to_owned()],
..Default::default()
},
]));
let artists = collection
.get_artists(&strings)
.into_iter()
.map(|a| a.name.unwrap())
.collect::<Vec<_>>();
assert_eq!(artists, vec!["FSOL".to_owned(), "Stratovarius".to_owned()]);
}
#[tokio::test]
async fn artist_list_is_sorted() {
let (collection, strings) = setup_test(Vec::from([
scanner::Song {
virtual_path: PathBuf::from("Destiny.mp3"),
title: Some("Destiny".to_owned()),
artists: vec!["Heavenly".to_owned()],
..Default::default()
},
scanner::Song {
virtual_path: PathBuf::from("Renegade.mp3"),
title: Some("Renegade".to_owned()),
artists: vec!["hammerfall".to_owned()], // Lower-case `h` to validate sorting is case-insensitive
..Default::default()
},
]));
let artists = collection
.get_artists(&strings)
.into_iter()
.map(|a| a.name.unwrap())
.collect::<Vec<_>>();
assert_eq!(
artists,
vec!["hammerfall".to_owned(), "Heavenly".to_owned()]
);
}
#[tokio::test]
async fn can_get_random_albums() {
let (collection, strings) = setup_test(Vec::from([
@ -381,10 +422,9 @@ mod test {
artists: Vec<String>,
composers: Vec<String>,
lyricists: Vec<String>,
expect_unlisted: bool,
expect_albums: bool,
expect_song_credits: bool,
expect_composer_credits: bool,
expect_lyricist_credits: bool,
expect_featured_on: bool,
}
let test_cases = vec![
@ -407,7 +447,7 @@ mod test {
TestCase {
album_artists: vec![other_artist_name.to_string()],
artists: vec![artist_name.to_string()],
expect_song_credits: true,
expect_featured_on: true,
..Default::default()
},
// Tagged as artist and within album artists
@ -421,14 +461,14 @@ mod test {
TestCase {
artists: vec![other_artist_name.to_string()],
composers: vec![artist_name.to_string()],
expect_composer_credits: true,
expect_unlisted: true,
..Default::default()
},
// Only tagged as lyricist
TestCase {
artists: vec![other_artist_name.to_string()],
lyricists: vec![artist_name.to_string()],
expect_lyricist_credits: true,
expect_unlisted: true,
..Default::default()
},
];
@ -447,7 +487,15 @@ mod test {
let artist_key = ArtistKey {
name: strings.get(artist_name),
};
let artist = collection.get_artist(&strings, artist_key).unwrap();
let artist = collection.get_artist(&strings, artist_key);
let artist = match test.expect_unlisted {
true => {
assert!(artist.is_none());
continue;
}
false => artist.unwrap(),
};
let names = |a: &Vec<Album>| {
a.iter()
@ -461,22 +509,10 @@ mod test {
assert!(names(&artist.albums).is_empty());
}
if test.expect_song_credits {
assert_eq!(names(&artist.song_credits), vec![album_name]);
if test.expect_featured_on {
assert_eq!(names(&artist.featured_on), vec![album_name]);
} else {
assert!(names(&artist.song_credits).is_empty());
}
if test.expect_composer_credits {
assert_eq!(names(&artist.composer_credits), vec![album_name]);
} else {
assert!(names(&artist.composer_credits).is_empty());
}
if test.expect_lyricist_credits {
assert_eq!(names(&artist.lyricist_credits), vec![album_name]);
} else {
assert!(names(&artist.lyricist_credits).is_empty());
assert!(names(&artist.featured_on).is_empty());
}
}
}

View file

@ -20,9 +20,7 @@ pub enum File {
pub struct Artist {
pub name: Option<lasso2::Spur>,
pub albums: HashSet<AlbumKey>,
pub song_credits: HashSet<AlbumKey>,
pub composer_credits: HashSet<AlbumKey>,
pub lyricist_credits: HashSet<AlbumKey>,
pub featured_on: HashSet<AlbumKey>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]

View file

@ -45,6 +45,7 @@ pub fn router() -> Router<App> {
.route("/browse/*path", get(get_browse))
.route("/flatten", get(get_flatten_root))
.route("/flatten/*path", get(get_flatten))
.route("/artists", get(get_artists))
.route("/artists/:artist", get(get_artist))
.route("/artists/:artists/albums/:name", get(get_album))
.route("/random", get(get_random))
@ -364,6 +365,21 @@ async fn get_flatten(
songs_to_response(songs, api_version)
}
async fn get_artists(
_auth: Auth,
State(index_manager): State<index::Manager>,
) -> Result<Json<Vec<dto::ArtistHeader>>, APIError> {
Ok(Json(
index_manager
.get_artists()
.await
.into_iter()
.map(|a| a.into())
.collect::<Vec<_>>()
.into(),
))
}
async fn get_artist(
_auth: Auth,
State(index_manager): State<index::Manager>,

View file

@ -307,22 +307,34 @@ impl From<index::File> for BrowserEntry {
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Artist {
pub struct ArtistHeader {
pub name: Option<String>,
pub num_albums: u32,
}
impl From<index::ArtistHeader> for ArtistHeader {
fn from(a: index::ArtistHeader) -> Self {
Self {
name: a.name,
num_albums: a.num_albums,
}
}
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Artist {
#[serde(flatten)]
pub header: ArtistHeader,
pub albums: Vec<AlbumHeader>,
pub song_credits: Vec<AlbumHeader>,
pub composer_credits: Vec<AlbumHeader>,
pub lyricist_credits: Vec<AlbumHeader>,
pub featured_on: Vec<AlbumHeader>,
}
impl From<index::Artist> for Artist {
fn from(a: index::Artist) -> Self {
Self {
name: a.name,
header: a.header.into(),
albums: a.albums.into_iter().map(|a| a.into()).collect(),
song_credits: a.song_credits.into_iter().map(|a| a.into()).collect(),
composer_credits: a.composer_credits.into_iter().map(|a| a.into()).collect(),
lyricist_credits: a.lyricist_credits.into_iter().map(|a| a.into()).collect(),
featured_on: a.featured_on.into_iter().map(|a| a.into()).collect(),
}
}
}