Files
furumi-ng/furumi-web-player/src/web/api.rs
AB-UK ff3ad15b95
All checks were successful
Publish Server Image / build-and-push-image (push) Successful in 2m7s
New player
2026-03-18 02:44:59 +00:00

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()
}