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; // --- Library browsing --- pub async fn list_artists(State(state): State) -> 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, Path(slug): Path) -> 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, Path(slug): Path) -> 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, Path(slug): Path) -> 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, Path(slug): Path) -> 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, Path(slug): Path) -> 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, Path(slug): Path, 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, Path(slug): Path) -> 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, Path(slug): Path) -> 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, 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, Query(q): Query) -> 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() }