New player
All checks were successful
Publish Server Image / build-and-push-image (push) Successful in 2m7s
All checks were successful
Publish Server Image / build-and-push-image (push) Successful in 2m7s
This commit is contained in:
298
furumi-web-player/src/web/api.rs
Normal file
298
furumi-web-player/src/web/api.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user