From 41506d6a3a09ad46443f1a557f7bd67094693801 Mon Sep 17 00:00:00 2001 From: Arnaud_Cayrol Date: Sat, 31 Jan 2026 09:16:52 +0100 Subject: [PATCH] Code cleanup : split web handler module --- src/job/mod.rs | 26 +- src/lib.rs | 1 + src/utils.rs | 74 +++ src/web/handlers/config.rs | 117 ++++ src/web/handlers/health.rs | 47 ++ src/web/handlers/mod.rs | 118 ++++ src/web/{handlers.rs => handlers/output.rs} | 565 ++------------------ src/web/handlers/people.rs | 134 +++++ src/web/handlers/processing.rs | 159 ++++++ 9 files changed, 686 insertions(+), 555 deletions(-) create mode 100644 src/utils.rs create mode 100644 src/web/handlers/config.rs create mode 100644 src/web/handlers/health.rs create mode 100644 src/web/handlers/mod.rs rename src/web/{handlers.rs => handlers/output.rs} (53%) create mode 100644 src/web/handlers/people.rs create mode 100644 src/web/handlers/processing.rs diff --git a/src/job/mod.rs b/src/job/mod.rs index bf4ceed..c0172b7 100644 --- a/src/job/mod.rs +++ b/src/job/mod.rs @@ -13,6 +13,7 @@ use crate::face_processing::debug::draw_crop_debug; use crate::face_processing::load_image_with_orientation; use crate::face_processing::{AssetResult, ProcessedFace, SkipReason}; use crate::immich_api::{Asset, FaceData, ImmichClient}; +use crate::utils::sanitize_folder_name; use crate::video::compile_timelapse; use crate::web::{AppState, JobStatus, Progress, SkipStats}; @@ -62,31 +63,6 @@ struct DebugDirs { alignment: Option, } -/// Create a safe folder name from person name or ID. -fn sanitize_folder_name(name: Option<&str>, id: &str) -> String { - let base = name.filter(|n| !n.is_empty()).unwrap_or(id); - - // Replace unsafe characters with underscores - let sanitized: String = base - .chars() - .map(|c| { - if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' { - c - } else { - '_' - } - }) - .collect(); - - // Trim whitespace and limit length - let trimmed = sanitized.trim(); - if trimmed.len() > 50 { - trimmed[..50].to_string() - } else { - trimmed.to_string() - } -} - /// Run the complete processing pipeline. /// /// This is the main entry point for background job processing. diff --git a/src/lib.rs b/src/lib.rs index f1d9735..adeb73c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod error; pub mod face_processing; pub mod immich_api; pub mod job; +pub mod utils; pub mod video; pub mod web; diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..e927443 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,74 @@ +//! Shared utility functions. + +/// Create a safe folder name from person name or ID. +/// +/// This sanitizes user input to create safe filesystem paths by: +/// - Using the person name if provided and non-empty, otherwise falling back to ID +/// - Replacing unsafe characters with underscores +/// - Trimming whitespace +/// - Limiting length to 50 characters +/// +/// Note: The frontend has a JavaScript version of this function that must be +/// kept in sync. See `frontend/src/lib/components/App.svelte`. +pub fn sanitize_folder_name(name: Option<&str>, id: &str) -> String { + let base = name.filter(|n| !n.is_empty()).unwrap_or(id); + + // Replace unsafe characters with underscores + let sanitized: String = base + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' { + c + } else { + '_' + } + }) + .collect(); + + // Trim whitespace and limit length + let trimmed = sanitized.trim(); + if trimmed.len() > 50 { + trimmed[..50].to_string() + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_folder_name_with_name() { + assert_eq!(sanitize_folder_name(Some("John Doe"), "abc123"), "John Doe"); + } + + #[test] + fn test_sanitize_folder_name_falls_back_to_id() { + assert_eq!(sanitize_folder_name(None, "abc123"), "abc123"); + assert_eq!(sanitize_folder_name(Some(""), "abc123"), "abc123"); + } + + #[test] + fn test_sanitize_folder_name_replaces_unsafe_chars() { + assert_eq!( + sanitize_folder_name(Some("John/Doe:Test"), "id"), + "John_Doe_Test" + ); + } + + #[test] + fn test_sanitize_folder_name_trims_whitespace() { + assert_eq!( + sanitize_folder_name(Some(" John Doe "), "id"), + "John Doe" + ); + } + + #[test] + fn test_sanitize_folder_name_limits_length() { + let long_name = "A".repeat(100); + let result = sanitize_folder_name(Some(&long_name), "id"); + assert_eq!(result.len(), 50); + } +} diff --git a/src/web/handlers/config.rs b/src/web/handlers/config.rs new file mode 100644 index 0000000..04990d8 --- /dev/null +++ b/src/web/handlers/config.rs @@ -0,0 +1,117 @@ +//! Configuration endpoints. + +use crate::config::{ProcessingConfig, VideoConfig}; +use crate::web::state::{AppState, JobStatus}; +use axum::{ + extract::State, + http::StatusCode, + response::Json, +}; +use serde::{Deserialize, Serialize}; + +/// Configuration response (excludes sensitive API credentials). +#[derive(Serialize)] +pub struct ConfigResponse { + pub processing: ProcessingConfig, + pub video: VideoConfig, +} + +/// Get current configuration (excluding sensitive data). +pub async fn get_config(State(state): State) -> Json { + let config = state.config.read().await; + + Json(ConfigResponse { + processing: config.processing.clone(), + video: config.video.clone(), + }) +} + +/// Configuration update request. +#[derive(Deserialize)] +pub struct ConfigUpdateRequest { + pub processing: Option, + pub video: Option, +} + +/// Processing configuration update fields. +#[derive(Deserialize)] +pub struct ProcessingConfigUpdate { + pub resize_size: Option, + pub face_resolution_threshold: Option, + pub pose_threshold: Option, + pub ear_threshold: Option, + pub max_workers: Option, + pub keep_intermediates: Option, +} + +/// Video configuration update fields. +#[derive(Deserialize)] +pub struct VideoConfigUpdate { + pub framerate: Option, + pub enabled: Option, + pub codec: Option, + pub crf: Option, +} + +/// Update configuration. +pub async fn update_config( + State(state): State, + Json(update): 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 update config while a job is running".to_string(), + )); + } + } + + // Update config + { + let mut config = state.config.write().await; + + if let Some(proc) = update.processing { + if let Some(v) = proc.resize_size { + config.processing.resize_size = v; + } + if let Some(v) = proc.face_resolution_threshold { + config.processing.face_resolution_threshold = v; + } + if let Some(v) = proc.pose_threshold { + config.processing.pose_threshold = v; + } + if let Some(v) = proc.ear_threshold { + config.processing.ear_threshold = v; + } + if let Some(v) = proc.max_workers { + config.processing.max_workers = v; + } + if let Some(v) = proc.keep_intermediates { + config.processing.keep_intermediates = v; + } + } + + if let Some(vid) = update.video { + if let Some(v) = vid.framerate { + config.video.framerate = v; + } + if let Some(v) = vid.enabled { + config.video.enabled = v; + } + if let Some(v) = vid.codec { + config.video.codec = v; + } + if let Some(v) = vid.crf { + config.video.crf = v; + } + } + + tracing::info!("Configuration updated"); + } + + // Return updated config + Ok(get_config(State(state)).await) +} diff --git a/src/web/handlers/health.rs b/src/web/handlers/health.rs new file mode 100644 index 0000000..6ed5c54 --- /dev/null +++ b/src/web/handlers/health.rs @@ -0,0 +1,47 @@ +//! Health check and connection status endpoints. + +use crate::immich_api::ImmichClient; +use crate::web::state::AppState; +use axum::{extract::State, response::Json}; +use serde::Serialize; + +/// Health check endpoint. +pub async fn health_check() -> &'static str { + "OK" +} + +/// Connection status response. +#[derive(Serialize)] +pub struct ConnectionStatus { + pub connected: bool, + pub version: Option, + pub error: Option, +} + +/// Check connection to Immich. +pub async fn check_connection(State(state): State) -> Json { + let config = state.config.read().await; + let client = match ImmichClient::new(&config.api) { + Ok(c) => c, + Err(e) => { + return Json(ConnectionStatus { + connected: false, + version: None, + error: Some(e.to_string()), + }); + } + }; + + match client.validate_connection().await { + Ok(info) => Json(ConnectionStatus { + connected: true, + version: Some(info.version), + error: None, + }), + Err(e) => Json(ConnectionStatus { + connected: false, + version: None, + error: Some(e.to_string()), + }), + } +} diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs new file mode 100644 index 0000000..f09dc72 --- /dev/null +++ b/src/web/handlers/mod.rs @@ -0,0 +1,118 @@ +//! HTTP route handlers. +//! +//! This module is organized into submodules by domain: +//! - `health`: Health check and connection status +//! - `people`: People listing, thumbnails, asset counts +//! - `processing`: Job control (progress, start, cancel) +//! - `output`: Output folder and image management +//! - `config`: Configuration get/update + +mod config; +mod health; +mod output; +mod people; +mod processing; + +use crate::web::state::AppState; +use axum::{ + response::{Html, IntoResponse}, + routing::{delete, get, post, put}, + Router, +}; +use serde::Serialize; +use tower_http::services::ServeDir; + +// Re-export handler functions for use in router +use config::{get_config, update_config}; +use health::{check_connection, health_check}; +use output::{ + cleanup_all_output, cleanup_output_folder, compile_folder_video, delete_images_bulk, + delete_single_image, list_folder_images, list_output_folders, +}; +use people::{get_people, get_person_asset_count, get_person_thumbnail}; +use processing::{cancel_processing, get_progress, start_processing}; + +// Re-export types that may be needed by other modules +pub use config::ConfigResponse; +pub use health::ConnectionStatus; +pub use output::{BulkDeleteResponse, FolderImagesResponse, ImageInfo, OutputFolderInfo}; +pub use people::{AssetCountResponse, PersonInfo}; +pub use processing::{ProgressResponse, SkipStatsResponse}; + +/// Common response type for simple success/failure operations. +#[derive(Serialize)] +pub struct StartResponse { + pub success: bool, + pub message: String, +} + +/// Create the router with all routes. +pub fn create_router(state: AppState) -> Router { + // Get output directory from config for serving results + let output_dir = state + .config + .try_read() + .map(|c| c.output_dir.clone()) + .unwrap_or_else(|_| std::path::PathBuf::from("output")); + + Router::new() + // API routes + .route("/api/health", get(health_check)) + .route("/api/connection", get(check_connection)) + .route("/api/people", get(get_people)) + .route( + "/api/people/{person_id}/thumbnail", + get(get_person_thumbnail), + ) + .route( + "/api/people/{person_id}/asset-count", + get(get_person_asset_count), + ) + .route("/api/progress", get(get_progress)) + .route("/api/start", post(start_processing)) + .route("/api/cancel", post(cancel_processing)) + .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) + .nest_service("/output", ServeDir::new(output_dir)) + // Serve frontend static files (fallback to index.html for SPA routing) + .fallback_service(ServeDir::new("frontend/dist").fallback(get(serve_index))) + .with_state(state) +} + +/// Serve index.html for SPA routing. +async fn serve_index() -> impl IntoResponse { + match tokio::fs::read_to_string("frontend/dist/index.html").await { + Ok(html) => Html(html).into_response(), + Err(_) => Html( + r#" + +Immich Timelapse + +
+

Frontend not built

+

Run cd frontend && npm install && npm run build

+

Or use npm run dev for development

+
+ +"#, + ) + .into_response(), + } +} diff --git a/src/web/handlers.rs b/src/web/handlers/output.rs similarity index 53% rename from src/web/handlers.rs rename to src/web/handlers/output.rs index 68c8794..66977ba 100644 --- a/src/web/handlers.rs +++ b/src/web/handlers/output.rs @@ -1,446 +1,57 @@ -//! HTTP route handlers. +//! Output folder and image management endpoints. -use crate::config::{ProcessingConfig, VideoConfig}; -use crate::immich_api::ImmichClient; -use crate::job::{run_job, JobParams}; use crate::web::state::{AppState, JobStatus, Progress, SkipStats}; use axum::{ - body::Body, extract::{Path, State}, - http::{header, StatusCode}, - response::{Html, IntoResponse, Json, Response}, - routing::{delete, get, post, put}, - Router, + http::StatusCode, + response::Json, }; use serde::{Deserialize, Serialize}; -use tower_http::services::ServeDir; -/// Create the router with all routes. -pub fn create_router(state: AppState) -> Router { - // Get output directory from config for serving results - let output_dir = state - .config - .try_read() - .map(|c| c.output_dir.clone()) - .unwrap_or_else(|_| std::path::PathBuf::from("output")); - - Router::new() - // API routes - .route("/api/health", get(health_check)) - .route("/api/connection", get(check_connection)) - .route("/api/people", get(get_people)) - .route( - "/api/people/{person_id}/thumbnail", - get(get_person_thumbnail), - ) - .route( - "/api/people/{person_id}/asset-count", - get(get_person_asset_count), - ) - .route("/api/progress", get(get_progress)) - .route("/api/start", post(start_processing)) - .route("/api/cancel", post(cancel_processing)) - .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) - .nest_service("/output", ServeDir::new(output_dir)) - // Serve frontend static files (fallback to index.html for SPA routing) - .fallback_service(ServeDir::new("frontend/dist").fallback(get(serve_index))) - .with_state(state) -} - -/// Serve index.html for SPA routing. -async fn serve_index() -> impl IntoResponse { - match tokio::fs::read_to_string("frontend/dist/index.html").await { - Ok(html) => Html(html).into_response(), - Err(_) => Html( - r#" - -Immich Timelapse - -
-

Frontend not built

-

Run cd frontend && npm install && npm run build

-

Or use npm run dev for development

-
- -"#, - ) - .into_response(), - } -} - -/// Health check endpoint. -async fn health_check() -> &'static str { - "OK" -} - -/// Check connection to Immich. -#[derive(Serialize)] -struct ConnectionStatus { - connected: bool, - version: Option, - error: Option, -} - -async fn check_connection(State(state): State) -> Json { - let config = state.config.read().await; - let client = match ImmichClient::new(&config.api) { - Ok(c) => c, - Err(e) => { - return Json(ConnectionStatus { - connected: false, - version: None, - error: Some(e.to_string()), - }); - } - }; - - match client.validate_connection().await { - Ok(info) => Json(ConnectionStatus { - connected: true, - version: Some(info.version), - error: None, - }), - Err(e) => Json(ConnectionStatus { - connected: false, - version: None, - error: Some(e.to_string()), - }), - } -} - -/// Get list of people from Immich. -#[derive(Serialize)] -struct PersonInfo { - id: String, - name: Option, -} - -async fn get_people( - State(state): State, -) -> Result>, (StatusCode, String)> { - let config = state.config.read().await; - let client = ImmichClient::new(&config.api).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to create client: {}", e), - ) - })?; - - let people = client.get_people().await.map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to get people: {}", e), - ) - })?; - - let people_info: Vec = people - .into_iter() - .map(|p| PersonInfo { - id: p.id, - name: p.name, - }) - .collect(); - - Ok(Json(people_info)) -} - -/// Get a person's thumbnail image. -async fn get_person_thumbnail( - State(state): State, - Path(person_id): Path, -) -> Result, (StatusCode, String)> { - let config = state.config.read().await; - let client = ImmichClient::new(&config.api).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to create client: {}", e), - ) - })?; - - let (bytes, content_type) = client.get_person_thumbnail(&person_id).await.map_err(|e| { - tracing::error!("Thumbnail fetch failed for {}: {}", person_id, e); - ( - StatusCode::NOT_FOUND, - format!("Failed to get thumbnail: {}", e), - ) - })?; - - // Return the image with appropriate headers - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, content_type) - .header(header::CACHE_CONTROL, "public, max-age=3600") - .body(Body::from(bytes)) - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to build response: {}", e), - ) - }) -} - -/// Asset count response for a person. -#[derive(Serialize)] -struct AssetCountResponse { - total_assets: u32, - assets_with_faces: u32, -} - -/// Get the count of assets for a person. -async fn get_person_asset_count( - State(state): State, - Path(person_id): Path, -) -> Result, (StatusCode, String)> { - let config = state.config.read().await; - let client = ImmichClient::new(&config.api).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to create client: {}", e), - ) - })?; - - // Fetch assets for this person - let assets = client - .get_assets_with_person(&person_id, None, None) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to get assets: {}", e), - ) - })?; - - let total_assets = assets.len() as u32; - - // Count assets that have face data for the target person - let assets_with_faces = assets - .iter() - .filter(|asset| { - asset.people.as_ref().map_or(false, |people| { - people.iter().any(|p| { - p.id == person_id && p.faces.as_ref().map_or(false, |faces| !faces.is_empty()) - }) - }) - }) - .count() as u32; - - Ok(Json(AssetCountResponse { - total_assets, - assets_with_faces, - })) -} - -/// Skip statistics for API response. -#[derive(Serialize)] -struct SkipStatsResponse { - face_too_small: u32, - eyes_closed: u32, - head_turned: u32, - too_dark: u32, - too_bright: u32, - no_face_detected: u32, - download_failed: u32, - decode_failed: u32, - crop_failed: u32, - total: u32, -} - -/// Get current progress. -#[derive(Serialize)] -struct ProgressResponse { - status: String, - completed: u32, - total: u32, - message: Option, - skip_stats: SkipStatsResponse, - person_id: Option, - person_name: Option, -} - -async fn get_progress(State(state): State) -> Json { - let progress = state.progress.read().await; - - let status_str = match &progress.status { - JobStatus::Idle => "idle", - JobStatus::Running => "running", - JobStatus::Cancelling => "cancelling", - JobStatus::CompilingVideo => "compiling_video", - JobStatus::Completed => "completed", - JobStatus::Cancelled => "cancelled", - JobStatus::Error(_) => "error", - }; - - let skip_stats = &progress.skip_stats; - - Json(ProgressResponse { - status: status_str.to_string(), - completed: progress.completed, - total: progress.total, - message: progress.message.clone(), - skip_stats: SkipStatsResponse { - face_too_small: skip_stats.face_too_small, - eyes_closed: skip_stats.eyes_closed, - head_turned: skip_stats.head_turned, - too_dark: skip_stats.too_dark, - too_bright: skip_stats.too_bright, - no_face_detected: skip_stats.no_face_detected, - download_failed: skip_stats.download_failed, - decode_failed: skip_stats.decode_failed, - crop_failed: skip_stats.crop_failed, - total: skip_stats.total(), - }, - person_id: progress.person_id.clone(), - person_name: progress.person_name.clone(), - }) -} - -/// Start processing request. -#[derive(Deserialize)] -struct StartRequest { - person_id: String, - person_name: Option, - date_from: Option, - date_to: Option, -} - -#[derive(Serialize)] -struct StartResponse { - success: bool, - message: String, -} - -async fn start_processing( - State(state): State, - Json(request): Json, -) -> Result, (StatusCode, String)> { - // Check if already 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())); - } - } - - // Reset progress with person info (bypasses terminal state check) - state - .reset_progress(Progress { - status: JobStatus::Running, - completed: 0, - total: 0, - message: Some("Starting...".to_string()), - skip_stats: SkipStats::default(), - person_id: Some(request.person_id.clone()), - person_name: request.person_name.clone(), - }) - .await; - - // Create cancellation token - let cancel_token = state.create_cancel_token().await; - - tracing::info!( - "Starting processing for person {} (date range: {:?} - {:?})", - request.person_id, - request.date_from, - request.date_to - ); - - // Spawn the processing job in the background - let job_params = JobParams { - person_id: request.person_id, - person_name: request.person_name, - date_from: request.date_from, - date_to: request.date_to, - }; - - let job_state = state.clone(); - tokio::spawn(async move { - run_job(job_state, job_params, cancel_token).await; - }); - - Ok(Json(StartResponse { - success: true, - message: "Processing started".to_string(), - })) -} - -/// Cancel the current processing job. -async fn cancel_processing(State(state): State) -> Json { - let cancelled = state.request_cancel().await; - - if cancelled { - // The job will update its own status when it detects cancellation - Json(StartResponse { - success: true, - message: "Cancellation requested".to_string(), - }) - } else { - Json(StartResponse { - success: false, - message: "No job running to cancel".to_string(), - }) - } -} +use super::StartResponse; /// Output folder information. #[derive(Serialize)] -struct OutputFolderInfo { - name: String, - image_count: u32, - size_bytes: u64, - has_video: bool, +pub struct OutputFolderInfo { + pub name: String, + pub image_count: u32, + pub size_bytes: u64, + pub has_video: bool, } /// Image information for gallery view. #[derive(Serialize)] -struct ImageInfo { - filename: String, - size_bytes: u64, +pub struct ImageInfo { + pub filename: String, + pub 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, +pub struct FolderImagesResponse { + pub folder_name: String, + pub images: Vec, + pub total_count: u32, + pub total_size_bytes: u64, + pub video_exists: bool, } /// Request for bulk deleting images. #[derive(Deserialize)] -struct BulkDeleteRequest { - filenames: Vec, +pub struct BulkDeleteRequest { + pub filenames: Vec, } /// Response for bulk delete operations. #[derive(Serialize)] -struct BulkDeleteResponse { - deleted_count: u32, - failed_count: u32, - remaining_images: u32, +pub struct BulkDeleteResponse { + pub deleted_count: u32, + pub failed_count: u32, + pub remaining_images: u32, } /// List all output folders with their stats. -async fn list_output_folders( +pub async fn list_output_folders( State(state): State, ) -> Result>, (StatusCode, String)> { let config = state.config.read().await; @@ -475,7 +86,7 @@ async fn list_output_folders( if let Ok(mut img_entries) = tokio::fs::read_dir(&images_dir).await { while let Ok(Some(img_entry)) = img_entries.next_entry().await { let img_path = img_entry.path(); - if img_path.extension().map_or(false, |ext| ext == "jpg") { + if img_path.extension().is_some_and(|ext| ext == "jpg") { image_count += 1; if let Ok(metadata) = tokio::fs::metadata(&img_path).await { size_bytes += metadata.len(); @@ -506,7 +117,7 @@ async fn list_output_folders( } /// Clean up all output folders. -async fn cleanup_all_output( +pub async fn cleanup_all_output( State(state): State, ) -> Result, (StatusCode, String)> { let config = state.config.read().await; @@ -566,7 +177,7 @@ async fn cleanup_all_output( } /// Clean up a specific output folder by name. -async fn cleanup_output_folder( +pub async fn cleanup_output_folder( State(state): State, Path(folder_name): Path, ) -> Result, (StatusCode, String)> { @@ -620,7 +231,7 @@ async fn cleanup_output_folder( } /// List images in a specific output folder. -async fn list_folder_images( +pub async fn list_folder_images( State(state): State, Path(folder_name): Path, ) -> Result, (StatusCode, String)> { @@ -657,7 +268,7 @@ async fn list_folder_images( ) })? { let path = entry.path(); - if path.extension().map_or(false, |ext| ext == "jpg") { + if path.extension().is_some_and(|ext| ext == "jpg") { let filename = entry.file_name().to_string_lossy().to_string(); let metadata = tokio::fs::metadata(&path).await.map_err(|e| { ( @@ -696,7 +307,7 @@ async fn list_folder_images( } /// Delete a single image from an output folder. -async fn delete_single_image( +pub async fn delete_single_image( State(state): State, Path((folder_name, filename)): Path<(String, String)>, ) -> Result, (StatusCode, String)> { @@ -756,7 +367,7 @@ async fn delete_single_image( } /// Bulk delete images from an output folder. -async fn delete_images_bulk( +pub async fn delete_images_bulk( State(state): State, Path(folder_name): Path, Json(request): Json, @@ -820,7 +431,7 @@ async fn delete_images_bulk( if entry .path() .extension() - .map_or(false, |ext| ext == "jpg") + .is_some_and(|ext| ext == "jpg") { remaining_images += 1; } @@ -842,7 +453,7 @@ async fn delete_images_bulk( } /// Compile video for a specific output folder. -async fn compile_folder_video( +pub async fn compile_folder_video( State(state): State, Path(folder_name): Path, ) -> Result, (StatusCode, String)> { @@ -878,7 +489,7 @@ async fn compile_folder_video( if entry .path() .extension() - .map_or(false, |ext| ext == "jpg") + .is_some_and(|ext| ext == "jpg") { image_count += 1; } @@ -995,109 +606,3 @@ async fn compile_folder_video( message: format!("Video compilation started for '{}'", folder_name), })) } - -/// Configuration response (excludes sensitive API credentials). -/// Reuses config types which already derive Serialize. -#[derive(Serialize)] -struct ConfigResponse { - processing: ProcessingConfig, - video: VideoConfig, -} - -/// Get current configuration (excluding sensitive data). -async fn get_config(State(state): State) -> Json { - let config = state.config.read().await; - - Json(ConfigResponse { - processing: config.processing.clone(), - video: config.video.clone(), - }) -} - -/// Configuration update request. -#[derive(Deserialize)] -struct ConfigUpdateRequest { - processing: Option, - video: Option, -} - -#[derive(Deserialize)] -struct ProcessingConfigUpdate { - resize_size: Option, - face_resolution_threshold: Option, - pose_threshold: Option, - ear_threshold: Option, - max_workers: Option, - keep_intermediates: Option, -} - -#[derive(Deserialize)] -struct VideoConfigUpdate { - framerate: Option, - enabled: Option, - codec: Option, - crf: Option, -} - -/// Update configuration. -async fn update_config( - State(state): State, - Json(update): 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 update config while a job is running".to_string(), - )); - } - } - - // Update config - { - let mut config = state.config.write().await; - - if let Some(proc) = update.processing { - if let Some(v) = proc.resize_size { - config.processing.resize_size = v; - } - if let Some(v) = proc.face_resolution_threshold { - config.processing.face_resolution_threshold = v; - } - if let Some(v) = proc.pose_threshold { - config.processing.pose_threshold = v; - } - if let Some(v) = proc.ear_threshold { - config.processing.ear_threshold = v; - } - if let Some(v) = proc.max_workers { - config.processing.max_workers = v; - } - if let Some(v) = proc.keep_intermediates { - config.processing.keep_intermediates = v; - } - } - - if let Some(vid) = update.video { - if let Some(v) = vid.framerate { - config.video.framerate = v; - } - if let Some(v) = vid.enabled { - config.video.enabled = v; - } - if let Some(v) = vid.codec { - config.video.codec = v; - } - if let Some(v) = vid.crf { - config.video.crf = v; - } - } - - tracing::info!("Configuration updated"); - } - - // Return updated config - Ok(get_config(State(state)).await) -} diff --git a/src/web/handlers/people.rs b/src/web/handlers/people.rs new file mode 100644 index 0000000..07eb97b --- /dev/null +++ b/src/web/handlers/people.rs @@ -0,0 +1,134 @@ +//! People-related endpoints (list, thumbnails, asset counts). + +use crate::immich_api::ImmichClient; +use crate::web::state::AppState; +use axum::{ + body::Body, + extract::{Path, State}, + http::{header, StatusCode}, + response::{Json, Response}, +}; +use serde::Serialize; + +/// Basic person info for listing. +#[derive(Serialize)] +pub struct PersonInfo { + pub id: String, + pub name: Option, +} + +/// Get list of people from Immich. +pub async fn get_people( + State(state): State, +) -> Result>, (StatusCode, String)> { + let config = state.config.read().await; + let client = ImmichClient::new(&config.api).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create client: {}", e), + ) + })?; + + let people = client.get_people().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to get people: {}", e), + ) + })?; + + let people_info: Vec = people + .into_iter() + .map(|p| PersonInfo { + id: p.id, + name: p.name, + }) + .collect(); + + Ok(Json(people_info)) +} + +/// Get a person's thumbnail image. +pub async fn get_person_thumbnail( + State(state): State, + Path(person_id): Path, +) -> Result, (StatusCode, String)> { + let config = state.config.read().await; + let client = ImmichClient::new(&config.api).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create client: {}", e), + ) + })?; + + let (bytes, content_type) = client.get_person_thumbnail(&person_id).await.map_err(|e| { + tracing::error!("Thumbnail fetch failed for {}: {}", person_id, e); + ( + StatusCode::NOT_FOUND, + format!("Failed to get thumbnail: {}", e), + ) + })?; + + // Return the image with appropriate headers + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CACHE_CONTROL, "public, max-age=3600") + .body(Body::from(bytes)) + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to build response: {}", e), + ) + }) +} + +/// Asset count response for a person. +#[derive(Serialize)] +pub struct AssetCountResponse { + pub total_assets: u32, + pub assets_with_faces: u32, +} + +/// Get the count of assets for a person. +pub async fn get_person_asset_count( + State(state): State, + Path(person_id): Path, +) -> Result, (StatusCode, String)> { + let config = state.config.read().await; + let client = ImmichClient::new(&config.api).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create client: {}", e), + ) + })?; + + // Fetch assets for this person + let assets = client + .get_assets_with_person(&person_id, None, None) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to get assets: {}", e), + ) + })?; + + let total_assets = assets.len() as u32; + + // Count assets that have face data for the target person + let assets_with_faces = assets + .iter() + .filter(|asset| { + asset.people.as_ref().is_some_and(|people| { + people.iter().any(|p| { + p.id == person_id && p.faces.as_ref().is_some_and(|faces| !faces.is_empty()) + }) + }) + }) + .count() as u32; + + Ok(Json(AssetCountResponse { + total_assets, + assets_with_faces, + })) +} diff --git a/src/web/handlers/processing.rs b/src/web/handlers/processing.rs new file mode 100644 index 0000000..ee0b93f --- /dev/null +++ b/src/web/handlers/processing.rs @@ -0,0 +1,159 @@ +//! Processing job control endpoints (progress, start, cancel). + +use crate::job::{run_job, JobParams}; +use crate::web::state::{AppState, JobStatus, Progress, SkipStats}; +use axum::{ + extract::State, + http::StatusCode, + response::Json, +}; +use serde::{Deserialize, Serialize}; + +use super::StartResponse; + +/// Skip statistics for API response. +#[derive(Serialize)] +pub struct SkipStatsResponse { + pub face_too_small: u32, + pub eyes_closed: u32, + pub head_turned: u32, + pub too_dark: u32, + pub too_bright: u32, + pub no_face_detected: u32, + pub download_failed: u32, + pub decode_failed: u32, + pub crop_failed: u32, + pub total: u32, +} + +/// Progress response. +#[derive(Serialize)] +pub struct ProgressResponse { + pub status: String, + pub completed: u32, + pub total: u32, + pub message: Option, + pub skip_stats: SkipStatsResponse, + pub person_id: Option, + pub person_name: Option, +} + +/// Get current progress. +pub async fn get_progress(State(state): State) -> Json { + let progress = state.progress.read().await; + + let status_str = match &progress.status { + JobStatus::Idle => "idle", + JobStatus::Running => "running", + JobStatus::Cancelling => "cancelling", + JobStatus::CompilingVideo => "compiling_video", + JobStatus::Completed => "completed", + JobStatus::Cancelled => "cancelled", + JobStatus::Error(_) => "error", + }; + + let skip_stats = &progress.skip_stats; + + Json(ProgressResponse { + status: status_str.to_string(), + completed: progress.completed, + total: progress.total, + message: progress.message.clone(), + skip_stats: SkipStatsResponse { + face_too_small: skip_stats.face_too_small, + eyes_closed: skip_stats.eyes_closed, + head_turned: skip_stats.head_turned, + too_dark: skip_stats.too_dark, + too_bright: skip_stats.too_bright, + no_face_detected: skip_stats.no_face_detected, + download_failed: skip_stats.download_failed, + decode_failed: skip_stats.decode_failed, + crop_failed: skip_stats.crop_failed, + total: skip_stats.total(), + }, + person_id: progress.person_id.clone(), + person_name: progress.person_name.clone(), + }) +} + +/// Start processing request. +#[derive(Deserialize)] +pub struct StartRequest { + pub person_id: String, + pub person_name: Option, + pub date_from: Option, + pub date_to: Option, +} + +/// Start processing for a person. +pub async fn start_processing( + State(state): State, + Json(request): Json, +) -> Result, (StatusCode, String)> { + // Check if already 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())); + } + } + + // Reset progress with person info (bypasses terminal state check) + state + .reset_progress(Progress { + status: JobStatus::Running, + completed: 0, + total: 0, + message: Some("Starting...".to_string()), + skip_stats: SkipStats::default(), + person_id: Some(request.person_id.clone()), + person_name: request.person_name.clone(), + }) + .await; + + // Create cancellation token + let cancel_token = state.create_cancel_token().await; + + tracing::info!( + "Starting processing for person {} (date range: {:?} - {:?})", + request.person_id, + request.date_from, + request.date_to + ); + + // Spawn the processing job in the background + let job_params = JobParams { + person_id: request.person_id, + person_name: request.person_name, + date_from: request.date_from, + date_to: request.date_to, + }; + + let job_state = state.clone(); + tokio::spawn(async move { + run_job(job_state, job_params, cancel_token).await; + }); + + Ok(Json(StartResponse { + success: true, + message: "Processing started".to_string(), + })) +} + +/// Cancel the current processing job. +pub async fn cancel_processing(State(state): State) -> Json { + let cancelled = state.request_cancel().await; + + if cancelled { + // The job will update its own status when it detects cancellation + Json(StartResponse { + success: true, + message: "Cancellation requested".to_string(), + }) + } else { + Json(StartResponse { + success: false, + message: "No job running to cancel".to_string(), + }) + } +}