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, ) -> Result>>> { 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 = 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, Path(id): Path, ) -> Result>> { 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, ) -> Result>>> { // 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 = 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, ) -> Result>>> { // 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 = 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, Path(media_id): Path, Query(params): Query>, headers: HeaderMap, ) -> Result { // 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::().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::().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::().unwrap_or(0); let end = if parts.len() > 1 && !parts[1].is_empty() { parts[1].parse::().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, Path(media_id): Path, ) -> Result { // 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) }