From 9a3006597117fc87fef762fd383081ea0b4da366 Mon Sep 17 00:00:00 2001 From: Antoine Gersant Date: Mon, 2 Sep 2024 13:27:46 -0700 Subject: [PATCH] Adds new endpoint to generate audio waveforms --- .gitignore | 1 + Cargo.lock | 261 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 5 + src/app.rs | 25 ++++ src/app/peaks.rs | 166 +++++++++++++++++++++++ src/server/axum.rs | 6 + src/server/axum/api.rs | 20 ++- src/server/axum/error.rs | 2 + src/server/dto/v8.rs | 10 +- src/server/error.rs | 13 ++ src/server/test/media.rs | 45 +++++++ src/server/test/protocol.rs | 10 ++ 12 files changed, 562 insertions(+), 2 deletions(-) create mode 100644 src/app/peaks.rs diff --git a/.gitignore b/.gitignore index 21990c47..7cdb8a8f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ TestConfig.toml polaris.log polaris.pid profile.json +/peaks /thumbnails # Release process artifacts (usually runs on CI) diff --git a/Cargo.lock b/Cargo.lock index 72aab898..74244d5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -612,6 +612,15 @@ dependencies = [ "winreg", ] +[[package]] +name = "encoding_rs" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -650,6 +659,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fastrand" version = "2.1.0" @@ -1370,6 +1385,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1655,6 +1679,7 @@ dependencies = [ "serde_json", "simplelog", "sqlx", + "symphonia", "thiserror", "tinyvec", "tokio", @@ -1692,6 +1717,15 @@ dependencies = [ "yansi", ] +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "proc-macro-crate" version = "0.1.5" @@ -1941,6 +1975,21 @@ dependencies = [ "semver 1.0.23", ] +[[package]] +name = "rustfft" +version = "6.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43806561bc506d0c5d160643ad742e3161049ac01027b5e6d7524091fd401d86" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", + "version_check", +] + [[package]] name = "rustfm-scrobble" version = "1.1.1" @@ -2506,6 +2555,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "stringprep" version = "0.1.5" @@ -2523,6 +2578,202 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symphonia" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-caf", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72e34f34298a7308d4397a6c7fbf5b84c5d491231ce3dd379707ba673ab3bd97" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c01c2aae70f0f1fb096b6f0ff112a930b1fb3626178fba3ae68b09dce71706d4" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbf25b545ad0d3ee3e891ea643ad115aff4ca92f6aec472086b957a58522f70" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94e1feac3327cd616e973d5be69ad36b3945f16b06f19c6773fc3ac0b426a0f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d8a6666649a08412906476a8b0efd9b9733e241180189e9f92b09c08d0e38f3" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f395a67057c2ebc5e84d7bb1be71cce1a7ba99f64e0f0f0e303a03f79116f89b" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a98765fb46a0a6732b007f7e2870c2129b6f78d87db7987e6533c8f164a9f30" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "798306779e3dc7d5231bd5691f5a813496dc79d3f56bf82e25789f2094e022c3" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", + "rustfft", +] + +[[package]] +name = "symphonia-format-caf" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e43c99c696a388295a29fe71b133079f5d8b18041cf734c5459c35ad9097af50" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abfdf178d697e50ce1e5d9b982ba1b94c47218e03ec35022d9f0e071a16dc844" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bb43471a100f7882dc9937395bd5ebee8329298e766250b15b3875652fe3d6f" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada3505789516bcf00fc1157c67729eded428b455c27ca370e41f4d785bfa931" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f7be232f962f937f4b7115cbe62c330929345434c834359425e043bfd15f50" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc622b9841a10089c5b18e99eb904f4341615d5aa55bbf4eedde1be721a4023c" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484472580fa49991afda5f6550ece662237b00c6f562c7d9638d1b086ed010fe" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -2867,6 +3118,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "trie-rs" version = "0.4.2" diff --git a/Cargo.toml b/Cargo.toml index 2e835256..fa685bc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,11 @@ serde = { version = "1.0.147", features = ["derive"] } serde_derive = "1.0.147" serde_json = "1.0.122" simplelog = "0.12.2" +symphonia = { version = "0.5.4", features = [ + "all-codecs", + "all-formats", + "opt-simd", +] } tinyvec = { version = "1.8.0", features = ["serde"] } thiserror = "1.0.62" tokio = { version = "1.39", features = ["macros", "rt-multi-thread"] } diff --git a/src/app.rs b/src/app.rs index 8fea9718..d32b468a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,6 +9,7 @@ pub mod ddns; pub mod formats; pub mod index; pub mod lastfm; +pub mod peaks; pub mod playlist; pub mod scanner; pub mod settings; @@ -47,6 +48,22 @@ pub enum Error { #[error("This file format is not supported: {0}")] UnsupportedFormat(&'static str), + #[error("No tracks found in audio file: {0}")] + MediaEmpty(PathBuf), + #[error(transparent)] + MediaDecodeError(symphonia::core::errors::Error), + #[error(transparent)] + MediaDecoderError(symphonia::core::errors::Error), + #[error(transparent)] + MediaPacketError(symphonia::core::errors::Error), + #[error(transparent)] + MediaProbeError(symphonia::core::errors::Error), + + #[error(transparent)] + PeaksSerialization(bitcode::Error), + #[error(transparent)] + PeaksDeserialization(bitcode::Error), + #[error(transparent)] Database(#[from] sqlx::Error), #[error("Could not initialize database connection pool")] @@ -133,6 +150,7 @@ pub struct App { pub config_manager: config::Manager, pub ddns_manager: ddns::Manager, pub lastfm_manager: lastfm::Manager, + pub peaks_manager: peaks::Manager, pub playlist_manager: playlist::Manager, pub settings_manager: settings::Manager, pub thumbnail_manager: thumbnail::Manager, @@ -143,11 +161,16 @@ pub struct App { impl App { pub async fn new(port: u16, paths: Paths) -> Result { let db = DB::new(&paths.db_file_path).await?; + fs::create_dir_all(&paths.web_dir_path) .map_err(|e| Error::Io(paths.web_dir_path.clone(), e))?; + fs::create_dir_all(&paths.swagger_dir_path) .map_err(|e| Error::Io(paths.swagger_dir_path.clone(), e))?; + let peaks_dir_path = paths.cache_dir_path.join("peaks"); + fs::create_dir_all(&peaks_dir_path).map_err(|e| Error::Io(peaks_dir_path.clone(), e))?; + let thumbnails_dir_path = paths.cache_dir_path.join("thumbnails"); fs::create_dir_all(&thumbnails_dir_path) .map_err(|e| Error::Io(thumbnails_dir_path.clone(), e))?; @@ -170,6 +193,7 @@ impl App { vfs_manager.clone(), ddns_manager.clone(), ); + let peaks_manager = peaks::Manager::new(peaks_dir_path); let playlist_manager = playlist::Manager::new(db.clone()); let thumbnail_manager = thumbnail::Manager::new(thumbnails_dir_path); let lastfm_manager = lastfm::Manager::new(index_manager.clone(), user_manager.clone()); @@ -188,6 +212,7 @@ impl App { config_manager, ddns_manager, lastfm_manager, + peaks_manager, playlist_manager, settings_manager, thumbnail_manager, diff --git a/src/app/peaks.rs b/src/app/peaks.rs new file mode 100644 index 00000000..43289053 --- /dev/null +++ b/src/app/peaks.rs @@ -0,0 +1,166 @@ +use std::{ + fs::{self, File}, + hash::{DefaultHasher, Hash, Hasher}, + io::{self, Write}, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; +use symphonia::core::{ + audio::SampleBuffer, + codecs::{DecoderOptions, CODEC_TYPE_NULL}, + formats::FormatOptions, + io::{MediaSourceStream, MediaSourceStreamOptions}, + meta::MetadataOptions, + probe::Hint, +}; + +use crate::app::Error; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Peaks { + pub interleaved: Vec, +} + +#[derive(Clone)] +pub struct Manager { + peaks_dir_path: PathBuf, +} + +impl Manager { + pub fn new(peaks_dir_path: PathBuf) -> Self { + Self { peaks_dir_path } + } + + pub fn get_peaks(&self, audio_path: &Path) -> Result { + match self.read_from_cache(audio_path) { + Ok(Some(peaks)) => Ok(peaks), + _ => self.read_from_audio_file(audio_path), + } + } + + fn get_peaks_path(&self, audio_path: &Path) -> PathBuf { + let hash = Manager::hash(audio_path); + let mut peaks_path = self.peaks_dir_path.clone(); + peaks_path.push(format!("{}.peaks", hash)); + peaks_path + } + + fn read_from_cache(&self, audio_path: &Path) -> Result, Error> { + let peaks_path = self.get_peaks_path(audio_path); + if peaks_path.exists() { + let serialized = fs::read(&peaks_path).map_err(|e| Error::Io(peaks_path.clone(), e))?; + let peaks = + bitcode::deserialize::(&serialized).map_err(Error::PeaksDeserialization)?; + Ok(Some(peaks)) + } else { + Ok(None) + } + } + + fn read_from_audio_file(&self, audio_path: &Path) -> Result { + let peaks = compute_peaks(audio_path)?; + let serialized = bitcode::serialize(&peaks).map_err(Error::PeaksSerialization)?; + + fs::create_dir_all(&self.peaks_dir_path) + .map_err(|e| Error::Io(self.peaks_dir_path.clone(), e))?; + let path = self.get_peaks_path(audio_path); + let mut out_file = File::create(&path).map_err(|e| Error::Io(path.clone(), e))?; + out_file + .write_all(&serialized) + .map_err(|e| Error::Io(path.clone(), e))?; + + Ok(peaks) + } + + fn hash(path: &Path) -> u64 { + let mut hasher = DefaultHasher::new(); + path.hash(&mut hasher); + hasher.finish() + } +} + +fn compute_peaks(audio_path: &Path) -> Result { + let peaks_per_minute = 4000; + + let file = File::open(&audio_path).or_else(|e| Err(Error::Io(audio_path.to_owned(), e)))?; + let media_source = MediaSourceStream::new(Box::new(file), MediaSourceStreamOptions::default()); + + let mut peaks = Peaks::default(); + peaks.interleaved.reserve(5 * peaks_per_minute); + + let mut format = symphonia::default::get_probe() + .format( + &Hint::new(), + media_source, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .map_err(Error::MediaProbeError)? + .format; + + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or_else(|| Error::MediaEmpty(audio_path.to_owned()))?; + + let track_id = track.id; + + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + .map_err(Error::MediaDecoderError)?; + + let (mut min, mut max) = (u8::MAX, u8::MIN); + let mut num_ingested = 0; + + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(symphonia::core::errors::Error::IoError(e)) + if e.kind() == io::ErrorKind::UnexpectedEof => + { + break; + } + Err(e) => return Err(Error::MediaPacketError(e)), + }; + + if packet.track_id() != track_id { + continue; + } + + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + Err(_) => continue, + }; + + let num_channels = decoded.spec().channels.count(); + let sample_rate = decoded.spec().rate; + let num_samples_per_peak = + ((sample_rate as f32) * 60.0 / (peaks_per_minute as f32)).round() as usize; + + let mut buffer = SampleBuffer::::new(decoded.capacity() as u64, *decoded.spec()); + buffer.copy_interleaved_ref(decoded); + for samples in buffer.samples().chunks_exact(num_channels) { + // Merge channels into mono signal + let mut mono: u32 = 0; + for sample in samples { + mono += *sample as u32; + } + mono /= samples.len() as u32; + + min = u8::min(min, mono as u8); + max = u8::max(max, mono as u8); + num_ingested += 1; + + if num_ingested >= num_samples_per_peak { + peaks.interleaved.push(min); + peaks.interleaved.push(max); + (min, max) = (u8::MAX, u8::MIN); + num_ingested = 0; + } + } + } + + Ok(peaks) +} diff --git a/src/server/axum.rs b/src/server/axum.rs index 1ef97159..eac3c8de 100644 --- a/src/server/axum.rs +++ b/src/server/axum.rs @@ -63,6 +63,12 @@ impl FromRef for app::lastfm::Manager { } } +impl FromRef for app::peaks::Manager { + fn from_ref(app: &App) -> Self { + app.peaks_manager.clone() + } +} + impl FromRef for app::playlist::Manager { fn from_ref(app: &App) -> Self { app.playlist_manager.clone() diff --git a/src/server/axum/api.rs b/src/server/axum/api.rs index 62a3ac2e..78987f01 100644 --- a/src/server/axum/api.rs +++ b/src/server/axum/api.rs @@ -11,10 +11,13 @@ use axum_extra::TypedHeader; use axum_range::{KnownSize, Ranged}; use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine}; use percent_encoding::percent_decode_str; +use tokio::task::spawn_blocking; use tower_http::{compression::CompressionLayer, CompressionLevel}; use crate::{ - app::{config, ddns, index, lastfm, playlist, scanner, settings, thumbnail, user, vfs, App}, + app::{ + config, ddns, index, lastfm, peaks, playlist, scanner, settings, thumbnail, user, vfs, App, + }, server::{ dto, error::APIError, APIMajorVersion, API_ARRAY_SEPARATOR, API_MAJOR_VERSION, API_MINOR_VERSION, @@ -72,6 +75,7 @@ pub fn router() -> Router { .route("/lastfm/link", delete(delete_lastfm_link)) // Media .route("/songs", post(get_songs)) // post because of https://github.com/whatwg/fetch/issues/551 + .route("/peaks/*path", get(get_peaks)) .route("/thumbnail/*path", get(get_thumbnail)) // Workarounds // TODO figure out NormalizePathLayer and remove this @@ -450,6 +454,20 @@ async fn get_songs( Ok(Json(output)) } +async fn get_peaks( + _auth: Auth, + State(vfs_manager): State, + State(peaks_manager): State, + Path(path): Path, +) -> Result { + let vfs = vfs_manager.get_vfs().await?; + let audio_path = vfs.virtual_to_real(&path)?; + let peaks = spawn_blocking(move || peaks_manager.get_peaks(&audio_path)) + .await + .or(Err(APIError::Internal))?; + Ok(peaks?.interleaved) +} + async fn get_random( _auth: Auth, api_version: APIMajorVersion, diff --git a/src/server/axum/error.rs b/src/server/axum/error.rs index 79a733f5..c3f74ed3 100644 --- a/src/server/axum/error.rs +++ b/src/server/axum/error.rs @@ -45,6 +45,8 @@ impl IntoResponse for APIError { APIError::ThumbnailImageDecoding(_, _) => StatusCode::INTERNAL_SERVER_ERROR, APIError::ThumbnailMp4Decoding(_, _) => StatusCode::INTERNAL_SERVER_ERROR, APIError::UnsupportedThumbnailFormat(_) => StatusCode::INTERNAL_SERVER_ERROR, + APIError::AudioEmpty(_) => StatusCode::INTERNAL_SERVER_ERROR, + APIError::AudioDecoding(_) => StatusCode::INTERNAL_SERVER_ERROR, APIError::UserNotFound => StatusCode::NOT_FOUND, APIError::VFSPathNotFound => StatusCode::NOT_FOUND, }; diff --git a/src/server/dto/v8.rs b/src/server/dto/v8.rs index 7b5e4158..97a18f97 100644 --- a/src/server/dto/v8.rs +++ b/src/server/dto/v8.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::app::{config, ddns, index, settings, thumbnail, user, vfs}; +use crate::app::{config, ddns, index, peaks, settings, thumbnail, user, vfs}; use std::{convert::From, path::PathBuf}; #[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] @@ -68,6 +68,14 @@ impl Into> for ThumbnailSize { } } +pub type Peaks = Vec; + +impl From for Peaks { + fn from(p: peaks::Peaks) -> Self { + p.interleaved + } +} + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ListPlaylistsEntry { pub name: String, diff --git a/src/server/error.rs b/src/server/error.rs index 444ccd67..e98af657 100644 --- a/src/server/error.rs +++ b/src/server/error.rs @@ -77,6 +77,10 @@ pub enum APIError { ThumbnailMp4Decoding(PathBuf, mp4ameta::Error), #[error("Unsupported thumbnail format: `{0}`")] UnsupportedThumbnailFormat(&'static str), + #[error("Audio decoding error: `{0}`")] + AudioDecoding(symphonia::core::errors::Error), + #[error("Empty audio file: `{0}`")] + AudioEmpty(PathBuf), #[error("User not found")] UserNotFound, #[error("Path not found in virtual filesystem")] @@ -100,6 +104,15 @@ impl From for APIError { app::Error::Image(p, e) => APIError::ThumbnailImageDecoding(p, e), app::Error::UnsupportedFormat(f) => APIError::UnsupportedThumbnailFormat(f), + app::Error::MediaEmpty(p) => APIError::AudioEmpty(p), + app::Error::MediaDecodeError(e) => APIError::AudioDecoding(e), + app::Error::MediaDecoderError(e) => APIError::AudioDecoding(e), + app::Error::MediaPacketError(e) => APIError::AudioDecoding(e), + app::Error::MediaProbeError(e) => APIError::AudioDecoding(e), + + app::Error::PeaksSerialization(_) => APIError::Internal, + app::Error::PeaksDeserialization(_) => APIError::Internal, + app::Error::Database(e) => APIError::Database(e), app::Error::ConnectionPoolBuild => APIError::Internal, app::Error::ConnectionPool => APIError::Internal, diff --git a/src/server/test/media.rs b/src/server/test/media.rs index 161bbce1..6b87004c 100644 --- a/src/server/test/media.rs +++ b/src/server/test/media.rs @@ -144,6 +144,51 @@ async fn audio_bad_path_returns_not_found() { assert_eq!(response.status(), StatusCode::NOT_FOUND); } +#[tokio::test] +async fn peaks_requires_auth() { + let mut service = ServiceType::new(&test_name!()).await; + + let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"] + .iter() + .collect(); + + let request = protocol::peaks(&path); + let response = service.fetch(&request).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn peaks_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 path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"] + .iter() + .collect(); + + let request = protocol::peaks(&path); + let response = service.fetch_bytes(&request).await; + assert_eq!(response.status(), StatusCode::OK); + assert!(response.body().len() % 2 == 0); + assert!(response.body().len() > 0); +} + +#[tokio::test] +async fn peaks_bad_path_returns_not_found() { + let mut service = ServiceType::new(&test_name!()).await; + service.complete_initial_setup().await; + service.login().await; + + let path: PathBuf = ["not_my_collection"].iter().collect(); + + let request = protocol::peaks(&path); + let response = service.fetch(&request).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + #[tokio::test] async fn thumbnail_requires_auth() { let mut service = ServiceType::new(&test_name!()).await; diff --git a/src/server/test/protocol.rs b/src/server/test/protocol.rs index 8910310a..0da3ffeb 100644 --- a/src/server/test/protocol.rs +++ b/src/server/test/protocol.rs @@ -232,6 +232,16 @@ pub fn audio(path: &Path) -> Request<()> { .unwrap() } +pub fn peaks(path: &Path) -> Request<()> { + let path = path.to_string_lossy(); + let endpoint = format!("/api/peaks/{}", url_encode(path.as_ref())); + Request::builder() + .method(Method::GET) + .uri(&endpoint) + .body(()) + .unwrap() +} + pub fn thumbnail(path: &Path, size: Option, pad: Option) -> Request<()> { let path = path.to_string_lossy(); let mut params = String::new();