Adds endpoint to retrieve song metata in bulk
This commit is contained in:
parent
6837994433
commit
5444285327
7 changed files with 129 additions and 31 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -1628,7 +1628,6 @@ dependencies = [
|
|||
"bytes",
|
||||
"daemonize",
|
||||
"embed-resource",
|
||||
"futures-util",
|
||||
"getopts",
|
||||
"headers",
|
||||
"http 1.1.0",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()));
|
||||
|
|
Loading…
Add table
Reference in a new issue