
- 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
402 lines
15 KiB
Rust
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)
|
|
} |