All checks were successful
Publish Server Image / build-and-push-image (push) Successful in 2m7s
299 lines
11 KiB
Rust
299 lines
11 KiB
Rust
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
body::Body,
|
|
extract::{Path, Query, State},
|
|
http::{HeaderMap, StatusCode, header},
|
|
response::{IntoResponse, Json, Response},
|
|
};
|
|
use serde::Deserialize;
|
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
|
|
|
use crate::db;
|
|
use super::AppState;
|
|
|
|
type S = Arc<AppState>;
|
|
|
|
// --- Library browsing ---
|
|
|
|
pub async fn list_artists(State(state): State<S>) -> impl IntoResponse {
|
|
match db::list_artists(&state.pool).await {
|
|
Ok(artists) => (StatusCode::OK, Json(serde_json::to_value(artists).unwrap())).into_response(),
|
|
Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
}
|
|
}
|
|
|
|
pub async fn get_artist(State(state): State<S>, Path(slug): Path<String>) -> impl IntoResponse {
|
|
match db::get_artist(&state.pool, &slug).await {
|
|
Ok(Some(artist)) => (StatusCode::OK, Json(serde_json::to_value(artist).unwrap())).into_response(),
|
|
Ok(None) => error_json(StatusCode::NOT_FOUND, "artist not found"),
|
|
Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
}
|
|
}
|
|
|
|
pub async fn list_artist_albums(State(state): State<S>, Path(slug): Path<String>) -> impl IntoResponse {
|
|
match db::list_albums_by_artist(&state.pool, &slug).await {
|
|
Ok(albums) => (StatusCode::OK, Json(serde_json::to_value(albums).unwrap())).into_response(),
|
|
Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
}
|
|
}
|
|
|
|
pub async fn list_artist_all_tracks(State(state): State<S>, Path(slug): Path<String>) -> impl IntoResponse {
|
|
match db::list_all_tracks_by_artist(&state.pool, &slug).await {
|
|
Ok(tracks) => (StatusCode::OK, Json(serde_json::to_value(tracks).unwrap())).into_response(),
|
|
Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
}
|
|
}
|
|
|
|
pub async fn get_track_detail(State(state): State<S>, Path(slug): Path<String>) -> impl IntoResponse {
|
|
match db::get_track(&state.pool, &slug).await {
|
|
Ok(Some(track)) => (StatusCode::OK, Json(serde_json::to_value(track).unwrap())).into_response(),
|
|
Ok(None) => error_json(StatusCode::NOT_FOUND, "track not found"),
|
|
Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
}
|
|
}
|
|
|
|
pub async fn get_album_tracks(State(state): State<S>, Path(slug): Path<String>) -> impl IntoResponse {
|
|
match db::list_tracks_by_album(&state.pool, &slug).await {
|
|
Ok(tracks) => (StatusCode::OK, Json(serde_json::to_value(tracks).unwrap())).into_response(),
|
|
Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
}
|
|
}
|
|
|
|
// --- Stream ---
|
|
|
|
pub async fn stream_track(
|
|
State(state): State<S>,
|
|
Path(slug): Path<String>,
|
|
headers: HeaderMap,
|
|
) -> impl IntoResponse {
|
|
let track = match db::get_track(&state.pool, &slug).await {
|
|
Ok(Some(t)) => t,
|
|
Ok(None) => return error_json(StatusCode::NOT_FOUND, "track not found"),
|
|
Err(e) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
};
|
|
|
|
let file_path = std::path::Path::new(&track.storage_path);
|
|
if !file_path.exists() {
|
|
return error_json(StatusCode::NOT_FOUND, "file not found on disk");
|
|
}
|
|
|
|
let file_size = match tokio::fs::metadata(file_path).await {
|
|
Ok(m) => m.len(),
|
|
Err(e) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
};
|
|
|
|
let content_type = mime_guess::from_path(file_path)
|
|
.first_or_octet_stream()
|
|
.to_string();
|
|
|
|
// Parse Range header
|
|
let range = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
|
|
|
|
if let Some(range_str) = range {
|
|
stream_range(file_path, file_size, &content_type, range_str).await
|
|
} else {
|
|
stream_full(file_path, file_size, &content_type).await
|
|
}
|
|
}
|
|
|
|
async fn stream_full(path: &std::path::Path, size: u64, content_type: &str) -> Response {
|
|
let file = match tokio::fs::File::open(path).await {
|
|
Ok(f) => f,
|
|
Err(e) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
};
|
|
|
|
let stream = tokio_util::io::ReaderStream::new(file);
|
|
let body = Body::from_stream(stream);
|
|
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, content_type)
|
|
.header(header::CONTENT_LENGTH, size)
|
|
.header(header::ACCEPT_RANGES, "bytes")
|
|
.body(body)
|
|
.unwrap()
|
|
}
|
|
|
|
async fn stream_range(path: &std::path::Path, size: u64, content_type: &str, range_str: &str) -> Response {
|
|
// Parse "bytes=START-END"
|
|
let range = range_str.strip_prefix("bytes=").unwrap_or("");
|
|
let parts: Vec<&str> = range.split('-').collect();
|
|
|
|
let start: u64 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
let end: u64 = parts.get(1).and_then(|s| if s.is_empty() { None } else { s.parse().ok() }).unwrap_or(size - 1);
|
|
|
|
if start >= size || end >= size || start > end {
|
|
return Response::builder()
|
|
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
|
.header(header::CONTENT_RANGE, format!("bytes */{}", size))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
}
|
|
|
|
let length = end - start + 1;
|
|
|
|
let mut file = match tokio::fs::File::open(path).await {
|
|
Ok(f) => f,
|
|
Err(e) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
};
|
|
|
|
if start > 0 {
|
|
if let Err(e) = file.seek(std::io::SeekFrom::Start(start)).await {
|
|
return error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string());
|
|
}
|
|
}
|
|
|
|
let limited = file.take(length);
|
|
let stream = tokio_util::io::ReaderStream::new(limited);
|
|
let body = Body::from_stream(stream);
|
|
|
|
Response::builder()
|
|
.status(StatusCode::PARTIAL_CONTENT)
|
|
.header(header::CONTENT_TYPE, content_type)
|
|
.header(header::CONTENT_LENGTH, length)
|
|
.header(header::CONTENT_RANGE, format!("bytes {}-{}/{}", start, end, size))
|
|
.header(header::ACCEPT_RANGES, "bytes")
|
|
.body(body)
|
|
.unwrap()
|
|
}
|
|
|
|
// --- Cover art ---
|
|
|
|
pub async fn album_cover(State(state): State<S>, Path(slug): Path<String>) -> impl IntoResponse {
|
|
serve_album_cover_by_slug(&state, &slug).await
|
|
}
|
|
|
|
/// Cover for a specific track: album_images → embedded in file → 404
|
|
pub async fn track_cover(State(state): State<S>, Path(slug): Path<String>) -> impl IntoResponse {
|
|
let lookup = match db::get_track_cover_lookup(&state.pool, &slug).await {
|
|
Ok(Some(l)) => l,
|
|
Ok(None) => return error_json(StatusCode::NOT_FOUND, "track not found"),
|
|
Err(e) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
};
|
|
|
|
// 1) Try album cover from DB
|
|
if let Some(album_id) = lookup.album_id {
|
|
if let Ok(Some(cover)) = db::get_album_cover_by_id(&state.pool, album_id).await {
|
|
let path = std::path::Path::new(&cover.file_path);
|
|
if path.exists() {
|
|
if let Ok(data) = tokio::fs::read(path).await {
|
|
return Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, &cover.mime_type)
|
|
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
|
.body(Body::from(data))
|
|
.unwrap();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2) Try extracting embedded cover from the audio file
|
|
let file_path = std::path::PathBuf::from(&lookup.storage_path);
|
|
if file_path.exists() {
|
|
let result = tokio::task::spawn_blocking(move || extract_embedded_cover(&file_path)).await;
|
|
if let Ok(Some((data, mime))) = result {
|
|
return Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, mime)
|
|
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
|
.body(Body::from(data))
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
error_json(StatusCode::NOT_FOUND, "no cover art available")
|
|
}
|
|
|
|
/// Extract embedded cover art from an audio file using Symphonia.
|
|
fn extract_embedded_cover(path: &std::path::Path) -> Option<(Vec<u8>, String)> {
|
|
use symphonia::core::{
|
|
formats::FormatOptions,
|
|
io::MediaSourceStream,
|
|
meta::MetadataOptions,
|
|
probe::Hint,
|
|
};
|
|
|
|
let file = std::fs::File::open(path).ok()?;
|
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
|
|
|
let mut hint = Hint::new();
|
|
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
|
hint.with_extension(ext);
|
|
}
|
|
|
|
let mut probed = symphonia::default::get_probe().format(
|
|
&hint,
|
|
mss,
|
|
&FormatOptions { enable_gapless: false, ..Default::default() },
|
|
&MetadataOptions::default(),
|
|
).ok()?;
|
|
|
|
// Check metadata side-data
|
|
if let Some(rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) {
|
|
if let Some(visual) = rev.visuals().first() {
|
|
return Some((visual.data.to_vec(), visual.media_type.clone()));
|
|
}
|
|
}
|
|
|
|
// Check format-embedded metadata
|
|
if let Some(rev) = probed.format.metadata().current() {
|
|
if let Some(visual) = rev.visuals().first() {
|
|
return Some((visual.data.to_vec(), visual.media_type.clone()));
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
async fn serve_album_cover_by_slug(state: &AppState, slug: &str) -> Response {
|
|
let cover = match db::get_album_cover(&state.pool, slug).await {
|
|
Ok(Some(c)) => c,
|
|
Ok(None) => return error_json(StatusCode::NOT_FOUND, "no cover"),
|
|
Err(e) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
};
|
|
|
|
let path = std::path::Path::new(&cover.file_path);
|
|
if !path.exists() {
|
|
return error_json(StatusCode::NOT_FOUND, "cover file missing");
|
|
}
|
|
|
|
match tokio::fs::read(path).await {
|
|
Ok(data) => Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, &cover.mime_type)
|
|
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
|
.body(Body::from(data))
|
|
.unwrap(),
|
|
Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
}
|
|
}
|
|
|
|
// --- Search ---
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SearchQuery {
|
|
pub q: String,
|
|
#[serde(default = "default_limit")]
|
|
pub limit: i32,
|
|
}
|
|
|
|
fn default_limit() -> i32 { 20 }
|
|
|
|
pub async fn search(State(state): State<S>, Query(q): Query<SearchQuery>) -> impl IntoResponse {
|
|
if q.q.is_empty() {
|
|
return (StatusCode::OK, Json(serde_json::json!([]))).into_response();
|
|
}
|
|
match db::search(&state.pool, &q.q, q.limit).await {
|
|
Ok(results) => (StatusCode::OK, Json(serde_json::to_value(results).unwrap())).into_response(),
|
|
Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
}
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
fn error_json(status: StatusCode, message: &str) -> Response {
|
|
(status, Json(serde_json::json!({"error": message}))).into_response()
|
|
}
|