Adds endpoint to retrieve song metata in bulk

This commit is contained in:
Antoine Gersant 2024-08-25 15:28:16 -07:00
parent 6837994433
commit 5444285327
7 changed files with 129 additions and 31 deletions

1
Cargo.lock generated
View file

@ -1628,7 +1628,6 @@ dependencies = [
"bytes",
"daemonize",
"embed-resource",
"futures-util",
"getopts",
"headers",
"http 1.1.0",

View file

@ -15,7 +15,6 @@ axum-range = "0.4.0"
base64 = "0.22.1"
bitcode = { version = "0.6.3", features = ["serde"] }
branca = "0.10.1"
futures-util = { version = "0.3.30", default-features = false }
getopts = "0.2.21"
headers = "0.4"
http = "1.1.0"

View file

@ -1,4 +1,5 @@
use std::{
borrow::Borrow,
path::PathBuf,
sync::{Arc, RwLock},
};
@ -188,19 +189,38 @@ impl Manager {
.unwrap()
}
fn get_song_internal(virtual_path: &PathBuf, index: &Index) -> Result<Song, Error> {
let Some(virtual_path) = virtual_path.get(&index.strings) else {
return Err(Error::SongNotFound);
};
let song_key = SongKey { virtual_path };
index
.collection
.get_song(&index.strings, song_key)
.ok_or_else(|| Error::SongNotFound)
}
pub async fn get_song(&self, virtual_path: PathBuf) -> Result<Song, Error> {
spawn_blocking({
let index_manager = self.clone();
move || {
let index = index_manager.index.read().unwrap();
let Some(virtual_path) = virtual_path.get(&index.strings) else {
return Err(Error::SongNotFound);
};
let song_key = SongKey { virtual_path };
index
.collection
.get_song(&index.strings, song_key)
.ok_or_else(|| Error::SongNotFound)
Self::get_song_internal(&virtual_path, index.borrow())
}
})
.await
.unwrap()
}
pub async fn get_songs(&self, virtual_paths: Vec<PathBuf>) -> Vec<Result<Song, Error>> {
spawn_blocking({
let index_manager = self.clone();
move || {
let index = index_manager.index.read().unwrap();
virtual_paths
.into_iter()
.map(|path| Self::get_song_internal(&path, index.borrow()))
.collect()
}
})
.await

View file

@ -10,7 +10,6 @@ use axum_extra::headers::Range;
use axum_extra::TypedHeader;
use axum_range::{KnownSize, Ranged};
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use futures_util::future::join_all;
use percent_encoding::percent_decode_str;
use tower_http::{compression::CompressionLayer, CompressionLevel};
@ -26,8 +25,11 @@ use super::auth::{AdminRights, Auth};
pub fn router() -> Router<App> {
Router::new()
// Basic
.route("/version", get(get_version))
.route("/initial_setup", get(get_initial_setup))
.route("/auth", post(post_auth))
// Configuration
.route("/config", put(put_config))
.route("/settings", get(get_settings))
.route("/settings", put(put_settings))
@ -35,35 +37,43 @@ pub fn router() -> Router<App> {
.route("/mount_dirs", put(put_mount_dirs))
.route("/ddns", get(get_ddns))
.route("/ddns", put(put_ddns))
.route("/auth", post(post_auth))
.route("/trigger_index", post(post_trigger_index))
// User management
.route("/user", post(post_user))
.route("/user/:name", delete(delete_user))
.route("/user/:name", put(put_user))
.route("/users", get(get_users))
.route("/preferences", get(get_preferences))
.route("/preferences", put(put_preferences))
.route("/trigger_index", post(post_trigger_index))
// File browser
.route("/browse", get(get_browse_root))
.route("/browse/*path", get(get_browse))
.route("/flatten", get(get_flatten_root))
.route("/flatten/*path", get(get_flatten))
// Semantic
.route("/artists", get(get_artists))
.route("/artists/:artist", get(get_artist))
.route("/artists/:artists/albums/:name", get(get_album))
.route("/random", get(get_random))
.route("/recent", get(get_recent))
// Search
.route("/search", get(get_search_root))
.route("/search/*query", get(get_search))
// Playlist management
.route("/playlists", get(get_playlists))
.route("/playlist/:name", put(put_playlist))
.route("/playlist/:name", get(get_playlist))
.route("/playlist/:name", delete(delete_playlist))
.route("/thumbnail/*path", get(get_thumbnail))
// LastFM
.route("/lastfm/now_playing/*path", put(put_lastfm_now_playing))
.route("/lastfm/scrobble/*path", post(post_lastfm_scrobble))
.route("/lastfm/link_token", get(get_lastfm_link_token))
.route("/lastfm/link", get(get_lastfm_link))
.route("/lastfm/link", delete(delete_lastfm_link))
// Media
.route("/songs", post(get_songs)) // post because of https://github.com/whatwg/fetch/issues/551
.route("/thumbnail/*path", get(get_thumbnail))
// Workarounds
// TODO figure out NormalizePathLayer and remove this
// See https://github.com/tokio-rs/axum/discussions/2833
.route("/browse/", get(get_browse_root))
@ -73,6 +83,7 @@ pub fn router() -> Router<App> {
.route("/search/", get(get_search_root))
.layer(CompressionLayer::new().quality(CompressionLevel::Fastest))
.layer(DefaultBodyLimit::max(10 * 1024 * 1024)) // 10MB
// Uncompressed
.route("/audio/*path", get(get_audio))
}
@ -284,21 +295,15 @@ fn index_files_to_response(files: Vec<index::File>, api_version: APIMajorVersion
}
async fn make_song_list(paths: Vec<PathBuf>, index_manager: &index::Manager) -> dto::SongList {
let songs: Vec<index::Song> = join_all(
paths
.iter()
.take(200)
.map(|f| index_manager.get_song(f.clone())),
)
.await
.into_iter()
.filter_map(|r| r.ok())
.collect();
dto::SongList {
paths,
first_songs: songs.into_iter().map(|s| s.into()).collect(),
}
let first_paths = paths.iter().take(200).cloned().collect();
let first_songs = index_manager
.get_songs(first_paths)
.await
.into_iter()
.filter_map(Result::ok)
.map(dto::Song::from)
.collect();
dto::SongList { paths, first_songs }
}
fn song_list_to_response(song_list: dto::SongList, api_version: APIMajorVersion) -> Response {
@ -423,6 +428,28 @@ async fn get_album(
))
}
async fn get_songs(
_auth: Auth,
State(index_manager): State<index::Manager>,
songs: Json<dto::GetSongsBulkInput>,
) -> Result<Json<dto::GetSongsBulkOutput>, APIError> {
let results = index_manager
.get_songs(songs.0.paths.clone())
.await
.into_iter()
.collect::<Vec<_>>();
let mut output = dto::GetSongsBulkOutput::default();
for (i, r) in results.into_iter().enumerate() {
match r {
Ok(s) => output.songs.push(s.into()),
Err(_) => output.not_found.push(songs.0.paths[i].clone()),
}
}
Ok(Json(output))
}
async fn get_random(
_auth: Auth,
api_version: APIMajorVersion,

View file

@ -73,7 +73,7 @@ pub struct ListPlaylistsEntry {
#[derive(Clone, Serialize, Deserialize)]
pub struct SavePlaylistInput {
pub tracks: Vec<std::path::PathBuf>,
pub tracks: Vec<PathBuf>,
}
#[derive(Serialize, Deserialize)]
@ -380,4 +380,15 @@ impl From<index::Album> for Album {
}
}
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct GetSongsBulkInput {
pub paths: Vec<PathBuf>,
}
#[derive(Default, Serialize, Deserialize)]
pub struct GetSongsBulkOutput {
pub songs: Vec<Song>,
pub not_found: Vec<PathBuf>,
}
// TODO: Preferences should have dto types

View file

@ -1,10 +1,44 @@
use http::{header, HeaderValue, StatusCode};
use std::path::PathBuf;
use crate::server::dto::ThumbnailSize;
use crate::server::dto::{self, ThumbnailSize};
use crate::server::test::{constants::*, protocol, ServiceType, TestService};
use crate::test_name;
#[tokio::test]
async fn songs_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::songs(dto::GetSongsBulkInput::default());
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn songs_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let valid_path =
PathBuf::from_iter([TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]);
let invalid_path = PathBuf::from_iter(["oink.mp3"]);
let request = protocol::songs(dto::GetSongsBulkInput {
paths: vec![valid_path.clone(), invalid_path.clone()],
});
let response = service
.fetch_json::<_, dto::GetSongsBulkOutput>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
let payload = response.body();
assert_eq!(payload.songs[0].path, valid_path);
assert_eq!(payload.not_found, vec![invalid_path]);
}
#[tokio::test]
async fn audio_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;

View file

@ -214,6 +214,14 @@ pub fn search<VERSION: ProtocolVersion>(query: &str) -> Request<()> {
.unwrap()
}
pub fn songs(songs: dto::GetSongsBulkInput) -> Request<dto::GetSongsBulkInput> {
Request::builder()
.method(Method::POST)
.uri("/api/songs")
.body(songs)
.unwrap()
}
pub fn audio(path: &Path) -> Request<()> {
let path = path.to_string_lossy();
let endpoint = format!("/api/audio/{}", url_encode(path.as_ref()));