church-api/src/handlers/media.rs
Benjamin Slingo 24d389cdf0 Initial cleanup: remove backup files, fix major hymnal KISS violation
- Remove 13 backup/unused files cluttering src/
- Fix hymnal search: 200+ line complex SQL → shared sql::hymnal functions
- Fix DRY violation: duplicate bulletin lookup in media handler
- Add systematic 5-phase cleanup plan for remaining violations
- Note: This is just initial cleanup - significant DRY/KISS work remains
2025-08-29 09:23:07 -04:00

402 lines
15 KiB
Rust

use axum::extract::{State, Path};
use axum::response::{Json as ResponseJson, Response, IntoResponse};
use axum::http::{HeaderMap, header};
use tokio_util::io::ReaderStream;
use tokio::fs::File;
use crate::error::{Result, ApiError};
use crate::models::media::{MediaItem, MediaItemResponse};
use crate::models::ApiResponse;
// TranscodingJob import removed - never released transcoding nightmare eliminated
use crate::utils::response::success_response;
use crate::{AppState, sql};
/// Extract the base URL from request headers
fn get_base_url(headers: &HeaderMap) -> String {
// Try to get Host header first
if let Some(host) = headers.get("host").and_then(|h| h.to_str().ok()) {
// Check if we're behind a reverse proxy (X-Forwarded-Proto)
let scheme = if headers.get("x-forwarded-proto")
.and_then(|h| h.to_str().ok())
.map(|s| s == "https")
.unwrap_or(false) {
"https"
} else {
"http"
};
format!("{}://{}", scheme, host)
} else {
// Fallback to localhost for development
"http://localhost:3002".to_string()
}
}
/// Get all media items from database
pub async fn list_media_items(
headers: HeaderMap,
State(state): State<AppState>,
) -> Result<ResponseJson<ApiResponse<Vec<MediaItemResponse>>>> {
let media_items = sqlx::query_as!(
MediaItem,
r#"
SELECT id, title, speaker, date, description, scripture_reading,
file_path, file_size, duration_seconds, video_codec, audio_codec,
resolution, bitrate, thumbnail_path, thumbnail_generated_at,
nfo_path, last_scanned, created_at, updated_at
FROM media_items
ORDER BY date DESC NULLS LAST, title ASC
"#
)
.fetch_all(&state.pool)
.await
.map_err(|e| crate::error::ApiError::Database(e.to_string()))?;
let base_url = get_base_url(&headers);
let responses: Vec<MediaItemResponse> = media_items
.into_iter()
.map(|item| item.to_response(&base_url))
.collect();
Ok(success_response(responses))
}
/// Get a specific media item by ID
pub async fn get_media_item(
headers: HeaderMap,
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> Result<ResponseJson<ApiResponse<MediaItemResponse>>> {
let media_item = sqlx::query_as!(
MediaItem,
r#"
SELECT id, title, speaker, date, description, scripture_reading,
file_path, file_size, duration_seconds, video_codec, audio_codec,
resolution, bitrate, thumbnail_path, thumbnail_generated_at,
nfo_path, last_scanned, created_at, updated_at
FROM media_items
WHERE id = $1
"#,
id
)
.fetch_optional(&state.pool)
.await
.map_err(|e| crate::error::ApiError::Database(e.to_string()))?;
match media_item {
Some(mut item) => {
// If scripture_reading is null and this is a sermon (has a date),
// try to get scripture reading from corresponding bulletin using shared SQL
if item.scripture_reading.is_none() && item.date.is_some() {
if let Ok(Some(bulletin_data)) = sql::bulletins::get_by_date_for_scripture(&state.pool, item.date.unwrap()).await {
item.scripture_reading = bulletin_data.scripture_reading;
}
}
let base_url = get_base_url(&headers);
Ok(success_response(item.to_response(&base_url)))
}
None => Err(crate::error::ApiError::NotFound("Media item not found".to_string())),
}
}
/// New sermons endpoint - replaces Jellyfin
pub async fn list_sermons(
headers: HeaderMap,
State(state): State<AppState>,
) -> Result<ResponseJson<ApiResponse<Vec<MediaItemResponse>>>> {
// Get all sermon media items (from sermons directory), ordered by date descending
let mut media_items = sqlx::query_as!(
MediaItem,
r#"
SELECT id, title, speaker, date, description, scripture_reading,
file_path, file_size, duration_seconds, video_codec, audio_codec,
resolution, bitrate, thumbnail_path, thumbnail_generated_at,
nfo_path, last_scanned, created_at, updated_at
FROM media_items
WHERE video_codec IS NOT NULL
AND file_path LIKE '%/sermons/%'
ORDER BY date DESC NULLS LAST, title ASC
LIMIT 100
"#
)
.fetch_all(&state.pool)
.await
.map_err(|e| crate::error::ApiError::Database(e.to_string()))?;
// Link sermons to bulletins for scripture readings using shared SQL
for item in &mut media_items {
if item.scripture_reading.is_none() && item.date.is_some() {
if let Ok(Some(bulletin_data)) = sql::bulletins::get_by_date_for_scripture(&state.pool, item.date.unwrap()).await {
item.scripture_reading = bulletin_data.scripture_reading;
}
}
}
let base_url = get_base_url(&headers);
let responses: Vec<MediaItemResponse> = media_items
.into_iter()
.map(|item| item.to_response(&base_url))
.collect();
Ok(success_response(responses))
}
/// List livestreams - replaces Jellyfin livestreams endpoint
pub async fn list_livestreams(
headers: HeaderMap,
State(state): State<AppState>,
) -> Result<ResponseJson<ApiResponse<Vec<MediaItemResponse>>>> {
// Get all livestream media items (from livestreams directory), ordered by date descending
let media_items = sqlx::query_as!(
MediaItem,
r#"
SELECT id, title, speaker, date, description, scripture_reading,
file_path, file_size, duration_seconds, video_codec, audio_codec,
resolution, bitrate, thumbnail_path, thumbnail_generated_at,
nfo_path, last_scanned, created_at, updated_at
FROM media_items
WHERE video_codec IS NOT NULL
AND file_path LIKE '%/livestreams/%'
ORDER BY date DESC NULLS LAST, title ASC
LIMIT 100
"#
)
.fetch_all(&state.pool)
.await
.map_err(|e| crate::error::ApiError::Database(e.to_string()))?;
let base_url = get_base_url(&headers);
let responses: Vec<MediaItemResponse> = media_items
.into_iter()
.map(|item| item.to_response(&base_url))
.collect();
Ok(success_response(responses))
}
/// Legacy streaming function removed - replaced by smart_streaming system
/*
pub async fn stream_media(
State(state): State<AppState>,
Path(media_id): Path<uuid::Uuid>,
Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> Result<impl IntoResponse> {
// Get the media item
let media_item = sqlx::query_as!(
MediaItem,
r#"
SELECT id, title, speaker, date, description, scripture_reading,
file_path, file_size, duration_seconds, video_codec, audio_codec,
resolution, bitrate, thumbnail_path, thumbnail_generated_at,
nfo_path, last_scanned, created_at, updated_at
FROM media_items
WHERE id = $1
"#,
media_id
)
.fetch_optional(&state.pool)
.await
.map_err(|e| ApiError::Database(e.to_string()))?
.ok_or_else(|| ApiError::NotFound("Media item not found".to_string()))?;
// Detect client capabilities
let client_caps = ClientCapabilities::detect_from_headers(&headers);
let source_codec = media_item.video_codec.as_deref().unwrap_or("h264");
// Use the unified transcoding service from app state
let transcoding_service = &state.transcoding_service;
// Check if transcoding is needed
let needs_transcoding = transcoding_service.needs_transcoding(source_codec, &client_caps);
let file_path = if needs_transcoding {
tracing::info!("Client requires transcoding from {} for device {}", source_codec, client_caps.device_type);
// Create transcoding job
let job = TranscodingJob {
media_item_id: media_item.id,
source_path: media_item.file_path.clone(),
target_codec: "h264".to_string(),
target_resolution: params.get("max_width").map(|w| {
let width = w.parse::<u32>().unwrap_or(1920);
let height = width * 9 / 16; // 16:9 aspect ratio
format!("{}x{}", width, height)
}),
target_bitrate: params.get("video_bit_rate").and_then(|b| b.parse::<i32>().ok()),
client_capabilities: client_caps.clone(),
};
// Check if transcoded version already exists
let transcoded = transcoding_service.get_or_create_transcoded(job).await?;
match transcoded.status {
crate::models::media::TranscodingStatus::Completed => {
tracing::info!("Serving transcoded file");
transcoded.file_path
},
crate::models::media::TranscodingStatus::Processing | crate::models::media::TranscodingStatus::Pending => {
// For incompatible codecs like AV1, return 202 to indicate transcoding in progress
if source_codec == "av1" || source_codec == "hevc" || source_codec == "h265" {
tracing::info!("Transcoding {} file for {} - returning 202", source_codec, client_caps.device_type);
let response = Response::builder()
.status(StatusCode::ACCEPTED)
.header(header::CONTENT_TYPE, "application/json")
.header(header::RETRY_AFTER, "30") // Suggest retry in 30 seconds
.body(axum::body::Body::from(r#"{"message":"Transcoding in progress","retry_after":30}"#))
.map_err(|e| ApiError::Internal(format!("Failed to build response: {}", e)))?;
return Ok(response);
} else {
// For other codecs, serve original while transcoding
tracing::info!("Transcoding in progress, serving original file");
media_item.file_path
}
},
crate::models::media::TranscodingStatus::Failed => {
tracing::warn!("Transcoding failed, serving original file");
media_item.file_path
}
}
} else {
tracing::info!("No transcoding needed, serving original file");
media_item.file_path
};
// Check if file exists
if !std::path::Path::new(&file_path).exists() {
return Err(ApiError::NotFound(format!("Media file not found: {}", file_path)));
}
// Open the file
let file = File::open(&file_path).await
.map_err(|e| ApiError::Internal(format!("Failed to open media file: {}", e)))?;
// Get file metadata
let metadata = file.metadata().await
.map_err(|e| ApiError::Internal(format!("Failed to get file metadata: {}", e)))?;
let file_size = metadata.len();
// Handle range requests for video streaming
let (start, end, content_length) = if let Some(range) = headers.get("range") {
let range_str = range.to_str().unwrap_or("");
if let Some(range_bytes) = range_str.strip_prefix("bytes=") {
let parts: Vec<&str> = range_bytes.split('-').collect();
let start = parts[0].parse::<u64>().unwrap_or(0);
let end = if parts.len() > 1 && !parts[1].is_empty() {
parts[1].parse::<u64>().unwrap_or(file_size - 1)
} else {
file_size - 1
};
let content_length = end - start + 1;
(start, end, content_length)
} else {
(0, file_size - 1, file_size)
}
} else {
// For video files > 10MB, serve only first 1MB initially to encourage range requests
if file_size > 10 * 1024 * 1024 {
let chunk_size = 1024 * 1024; // 1MB
let end = std::cmp::min(chunk_size - 1, file_size - 1);
(0, end, end + 1)
} else {
(0, file_size - 1, file_size)
}
};
// Create the response
let mut response_builder = Response::builder();
// Set content type based on file extension
let content_type = if file_path.ends_with(".mp4") {
"video/mp4"
} else if file_path.ends_with(".mkv") {
"video/x-matroska"
} else if file_path.ends_with(".webm") {
"video/webm"
} else {
"application/octet-stream"
};
response_builder = response_builder
.header(header::CONTENT_TYPE, content_type)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, content_length.to_string())
.header(header::CACHE_CONTROL, "public, max-age=3600");
// Set status code based on range request
let status = if start > 0 || end < file_size - 1 {
response_builder = response_builder
.header(header::CONTENT_RANGE, format!("bytes {}-{}/{}", start, end, file_size));
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
};
// Seek to start position if needed
let reader = if start > 0 {
use tokio::io::{AsyncSeekExt, AsyncReadExt};
let mut file = file;
file.seek(std::io::SeekFrom::Start(start)).await
.map_err(|e| ApiError::Internal(format!("Failed to seek in file: {}", e)))?;
// Take only the requested range
file.take(content_length)
} else {
file.take(content_length)
};
let stream = ReaderStream::new(reader);
let body = axum::body::Body::from_stream(stream);
let response = response_builder
.status(status)
.body(body)
.map_err(|e| ApiError::Internal(format!("Failed to build response: {}", e)))?;
Ok(response)
}
*/
/// Get thumbnail for media item
pub async fn get_thumbnail(
State(state): State<AppState>,
Path(media_id): Path<uuid::Uuid>,
) -> Result<impl IntoResponse> {
// Get the media item
let media_item = sqlx::query!(
"SELECT thumbnail_path FROM media_items WHERE id = $1",
media_id
)
.fetch_optional(&state.pool)
.await
.map_err(|e| ApiError::Database(e.to_string()))?
.ok_or_else(|| ApiError::NotFound("Media item not found".to_string()))?;
let thumbnail_path = media_item.thumbnail_path
.ok_or_else(|| ApiError::NotFound("Thumbnail not available".to_string()))?;
// Check if thumbnail file exists
if !std::path::Path::new(&thumbnail_path).exists() {
return Err(ApiError::NotFound("Thumbnail file not found".to_string()));
}
// Open the thumbnail file
let file = File::open(&thumbnail_path).await
.map_err(|e| ApiError::Internal(format!("Failed to open thumbnail: {}", e)))?;
let stream = ReaderStream::new(file);
let body = axum::body::Body::from_stream(stream);
let response = Response::builder()
.header(header::CONTENT_TYPE, "image/webp")
.header(header::CACHE_CONTROL, "public, max-age=86400") // Cache for 24 hours
.body(body)
.map_err(|e| ApiError::Internal(format!("Failed to build response: {}", e)))?;
Ok(response)
}