Adds new endpoint to generate audio waveforms

This commit is contained in:
Antoine Gersant 2024-09-02 13:27:46 -07:00
parent f4b0cb9eb7
commit 9a30065971
12 changed files with 562 additions and 2 deletions

1
.gitignore vendored
View file

@ -17,6 +17,7 @@ TestConfig.toml
polaris.log
polaris.pid
profile.json
/peaks
/thumbnails
# Release process artifacts (usually runs on CI)

261
Cargo.lock generated
View file

@ -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"

View file

@ -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"] }

View file

@ -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<Self, Error> {
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,

166
src/app/peaks.rs Normal file
View file

@ -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<u8>,
}
#[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<Peaks, Error> {
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<Option<Peaks>, 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::<Peaks>(&serialized).map_err(Error::PeaksDeserialization)?;
Ok(Some(peaks))
} else {
Ok(None)
}
}
fn read_from_audio_file(&self, audio_path: &Path) -> Result<Peaks, Error> {
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<Peaks, Error> {
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::<u8>::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)
}

View file

@ -63,6 +63,12 @@ impl FromRef<App> for app::lastfm::Manager {
}
}
impl FromRef<App> for app::peaks::Manager {
fn from_ref(app: &App) -> Self {
app.peaks_manager.clone()
}
}
impl FromRef<App> for app::playlist::Manager {
fn from_ref(app: &App) -> Self {
app.playlist_manager.clone()

View file

@ -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<App> {
.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<vfs::Manager>,
State(peaks_manager): State<peaks::Manager>,
Path(path): Path<PathBuf>,
) -> Result<dto::Peaks, APIError> {
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,

View file

@ -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,
};

View file

@ -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<Option<u32>> for ThumbnailSize {
}
}
pub type Peaks = Vec<u8>;
impl From<peaks::Peaks> for Peaks {
fn from(p: peaks::Peaks) -> Self {
p.interleaved
}
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListPlaylistsEntry {
pub name: String,

View file

@ -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<app::Error> 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,

View file

@ -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;

View file

@ -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<ThumbnailSize>, pad: Option<bool>) -> Request<()> {
let path = path.to_string_lossy();
let mut params = String::new();