diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 2a63698..937b456 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,6 +1,7 @@ + + + + diff --git a/frontend/src/lib/components/OutputManager.svelte b/frontend/src/lib/components/OutputManager.svelte index aa746d1..ce6c562 100644 --- a/frontend/src/lib/components/OutputManager.svelte +++ b/frontend/src/lib/components/OutputManager.svelte @@ -1,13 +1,20 @@
@@ -113,7 +155,7 @@
-
@@ -228,7 +270,12 @@ color: #fff; } - .start-btn:hover { + .start-btn:hover:not(:disabled) { background: #4338ca; } + + .start-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } diff --git a/src/web/handlers.rs b/src/web/handlers.rs index b84de5b..3e806f5 100644 --- a/src/web/handlers.rs +++ b/src/web/handlers.rs @@ -43,6 +43,19 @@ pub fn create_router(state: AppState) -> Router { .route("/api/output", get(list_output_folders)) .route("/api/output", delete(cleanup_all_output)) .route("/api/output/{folder_name}", delete(cleanup_output_folder)) + .route("/api/output/{folder_name}/images", get(list_folder_images)) + .route( + "/api/output/{folder_name}/images", + delete(delete_images_bulk), + ) + .route( + "/api/output/{folder_name}/images/{filename}", + delete(delete_single_image), + ) + .route( + "/api/output/{folder_name}/compile", + post(compile_folder_video), + ) .route("/api/config", get(get_config)) .route("/api/config", put(update_config)) // Serve output files (video, images) @@ -395,6 +408,37 @@ struct OutputFolderInfo { has_video: bool, } +/// Image information for gallery view. +#[derive(Serialize)] +struct ImageInfo { + filename: String, + size_bytes: u64, +} + +/// Response for listing images in a folder. +#[derive(Serialize)] +struct FolderImagesResponse { + folder_name: String, + images: Vec, + total_count: u32, + total_size_bytes: u64, + video_exists: bool, +} + +/// Request for bulk deleting images. +#[derive(Deserialize)] +struct BulkDeleteRequest { + filenames: Vec, +} + +/// Response for bulk delete operations. +#[derive(Serialize)] +struct BulkDeleteResponse { + deleted_count: u32, + failed_count: u32, + remaining_images: u32, +} + /// List all output folders with their stats. async fn list_output_folders( State(state): State, @@ -574,6 +618,382 @@ async fn cleanup_output_folder( })) } +/// List images in a specific output folder. +async fn list_folder_images( + State(state): State, + Path(folder_name): Path, +) -> Result, (StatusCode, String)> { + let config = state.config.read().await; + + // Sanitize folder name to prevent path traversal + if folder_name.contains("..") || folder_name.contains('/') || folder_name.contains('\\') { + return Err((StatusCode::BAD_REQUEST, "Invalid folder name".to_string())); + } + + let images_dir = config.output_dir.join(&folder_name).join("images"); + + if !images_dir.exists() { + return Err(( + StatusCode::NOT_FOUND, + format!("Folder '{}' not found or has no images", folder_name), + )); + } + + let mut images = Vec::new(); + let mut total_size_bytes = 0u64; + + let mut entries = tokio::fs::read_dir(&images_dir).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to read images directory: {}", e), + ) + })?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to read directory entry: {}", e), + ) + })? { + let path = entry.path(); + if path.extension().map_or(false, |ext| ext == "jpg") { + let filename = entry.file_name().to_string_lossy().to_string(); + let metadata = tokio::fs::metadata(&path).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to read file metadata: {}", e), + ) + })?; + let size_bytes = metadata.len(); + total_size_bytes += size_bytes; + + images.push(ImageInfo { + filename, + size_bytes, + }); + } + } + + // Sort by filename (chronological since filenames are timestamp-based) + images.sort_by(|a, b| a.filename.cmp(&b.filename)); + + let total_count = images.len() as u32; + let video_exists = config + .output_dir + .join(&folder_name) + .join("timelapse.mp4") + .exists(); + + Ok(Json(FolderImagesResponse { + folder_name, + images, + total_count, + total_size_bytes, + video_exists, + })) +} + +/// Delete a single image from an output folder. +async fn delete_single_image( + State(state): State, + Path((folder_name, filename)): Path<(String, String)>, +) -> Result, (StatusCode, String)> { + // Check if a job is running + { + let progress = state.progress.read().await; + if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo { + return Err(( + StatusCode::CONFLICT, + "Cannot delete images while a job is running".to_string(), + )); + } + } + + let config = state.config.read().await; + + // Sanitize folder name and filename to prevent path traversal + if folder_name.contains("..") || folder_name.contains('/') || folder_name.contains('\\') { + return Err((StatusCode::BAD_REQUEST, "Invalid folder name".to_string())); + } + if filename.contains("..") || filename.contains('/') || filename.contains('\\') { + return Err((StatusCode::BAD_REQUEST, "Invalid filename".to_string())); + } + if !filename.ends_with(".jpg") { + return Err(( + StatusCode::BAD_REQUEST, + "Only .jpg files can be deleted".to_string(), + )); + } + + let file_path = config + .output_dir + .join(&folder_name) + .join("images") + .join(&filename); + + if !file_path.exists() { + return Err(( + StatusCode::NOT_FOUND, + format!("Image '{}' not found in folder '{}'", filename, folder_name), + )); + } + + tokio::fs::remove_file(&file_path).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to delete image: {}", e), + ) + })?; + + tracing::info!("Deleted image: {}/{}", folder_name, filename); + + Ok(Json(StartResponse { + success: true, + message: format!("Deleted image '{}'", filename), + })) +} + +/// Bulk delete images from an output folder. +async fn delete_images_bulk( + State(state): State, + Path(folder_name): Path, + Json(request): Json, +) -> Result, (StatusCode, String)> { + // Check if a job is running + { + let progress = state.progress.read().await; + if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo { + return Err(( + StatusCode::CONFLICT, + "Cannot delete images while a job is running".to_string(), + )); + } + } + + let config = state.config.read().await; + + // Sanitize folder name + if folder_name.contains("..") || folder_name.contains('/') || folder_name.contains('\\') { + return Err((StatusCode::BAD_REQUEST, "Invalid folder name".to_string())); + } + + let images_dir = config.output_dir.join(&folder_name).join("images"); + + if !images_dir.exists() { + return Err(( + StatusCode::NOT_FOUND, + format!("Folder '{}' not found or has no images", folder_name), + )); + } + + let mut deleted_count = 0u32; + let mut failed_count = 0u32; + + for filename in &request.filenames { + // Sanitize each filename + if filename.contains("..") || filename.contains('/') || filename.contains('\\') { + failed_count += 1; + continue; + } + if !filename.ends_with(".jpg") { + failed_count += 1; + continue; + } + + let file_path = images_dir.join(filename); + if file_path.exists() { + match tokio::fs::remove_file(&file_path).await { + Ok(_) => deleted_count += 1, + Err(_) => failed_count += 1, + } + } else { + failed_count += 1; + } + } + + // Count remaining images + let mut remaining_images = 0u32; + if let Ok(mut entries) = tokio::fs::read_dir(&images_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + if entry + .path() + .extension() + .map_or(false, |ext| ext == "jpg") + { + remaining_images += 1; + } + } + } + + tracing::info!( + "Bulk deleted {} images from folder {} ({} failed)", + deleted_count, + folder_name, + failed_count + ); + + Ok(Json(BulkDeleteResponse { + deleted_count, + failed_count, + remaining_images, + })) +} + +/// Compile video for a specific output folder. +async fn compile_folder_video( + State(state): State, + Path(folder_name): Path, +) -> Result, (StatusCode, String)> { + // Check if a job is running + { + let progress = state.progress.read().await; + if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo { + return Err((StatusCode::CONFLICT, "A job is already running".to_string())); + } + } + + let config = state.config.read().await; + + // Sanitize folder name + if folder_name.contains("..") || folder_name.contains('/') || folder_name.contains('\\') { + return Err((StatusCode::BAD_REQUEST, "Invalid folder name".to_string())); + } + + let folder_path = config.output_dir.join(&folder_name); + let images_dir = folder_path.join("images"); + + if !images_dir.exists() { + return Err(( + StatusCode::NOT_FOUND, + format!("Folder '{}' not found or has no images", folder_name), + )); + } + + // Count images to verify there are some + let mut image_count = 0u32; + if let Ok(mut entries) = tokio::fs::read_dir(&images_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + if entry + .path() + .extension() + .map_or(false, |ext| ext == "jpg") + { + image_count += 1; + } + } + } + + if image_count == 0 { + return Err(( + StatusCode::BAD_REQUEST, + "No images found in folder to compile".to_string(), + )); + } + + // Set status to compiling video + state + .update_progress(Progress { + status: JobStatus::CompilingVideo, + completed: 0, + total: image_count, + message: Some(format!("Compiling video for {}...", folder_name)), + skip_stats: SkipStats::default(), + person_id: None, + person_name: Some(folder_name.clone()), + }) + .await; + + // Create cancellation token + let cancel_token = state.create_cancel_token().await; + + // Clone values needed for the async task + let video_config = config.video.clone(); + let output_path = folder_path.join("timelapse.mp4"); + let job_state = state.clone(); + let folder_name_clone = folder_name.clone(); + + // Spawn the compilation job in the background + tokio::spawn(async move { + let result = crate::video::compile_timelapse(&images_dir, &output_path, &video_config, |current, total| { + // Check for cancellation + if cancel_token.is_cancelled() { + return; + } + + // Update progress (fire and forget since we're in sync callback) + let state_clone = job_state.clone(); + let folder_clone = folder_name_clone.clone(); + tokio::spawn(async move { + state_clone + .update_progress(Progress { + status: JobStatus::CompilingVideo, + completed: current, + total, + message: Some(format!("Compiling video for {}...", folder_clone)), + skip_stats: SkipStats::default(), + person_id: None, + person_name: Some(folder_clone), + }) + .await; + }); + }) + .await; + + // Update final status + match result { + Ok(_) => { + tracing::info!("Video compilation complete for folder: {}", folder_name_clone); + job_state + .update_progress(Progress { + status: JobStatus::Completed, + completed: image_count, + total: image_count, + message: Some("Video compilation complete".to_string()), + skip_stats: SkipStats::default(), + person_id: None, + person_name: Some(folder_name_clone), + }) + .await; + } + Err(e) => { + if cancel_token.is_cancelled() { + job_state + .update_progress(Progress { + status: JobStatus::Cancelled, + completed: 0, + total: image_count, + message: Some("Video compilation cancelled".to_string()), + skip_stats: SkipStats::default(), + person_id: None, + person_name: Some(folder_name_clone), + }) + .await; + } else { + tracing::error!("Video compilation failed: {}", e); + job_state + .update_progress(Progress { + status: JobStatus::Error(e.to_string()), + completed: 0, + total: image_count, + message: Some(format!("Video compilation failed: {}", e)), + skip_stats: SkipStats::default(), + person_id: None, + person_name: Some(folder_name_clone), + }) + .await; + } + } + } + + job_state.clear_cancel_token().await; + }); + + Ok(Json(StartResponse { + success: true, + message: format!("Video compilation started for '{}'", folder_name), + })) +} + /// Configuration response (excludes sensitive API credentials). /// Reuses config types which already derive Serialize. #[derive(Serialize)]