Code cleanup : split web handler module

This commit is contained in:
Arnaud_Cayrol 2026-01-31 09:16:52 +01:00
parent 499094c25a
commit 41506d6a3a
9 changed files with 686 additions and 555 deletions

View file

@ -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<PathBuf>,
}
/// 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.

View file

@ -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;

74
src/utils.rs Normal file
View file

@ -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);
}
}

117
src/web/handlers/config.rs Normal file
View file

@ -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<AppState>) -> Json<ConfigResponse> {
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<ProcessingConfigUpdate>,
pub video: Option<VideoConfigUpdate>,
}
/// Processing configuration update fields.
#[derive(Deserialize)]
pub struct ProcessingConfigUpdate {
pub resize_size: Option<u32>,
pub face_resolution_threshold: Option<u32>,
pub pose_threshold: Option<f32>,
pub ear_threshold: Option<f32>,
pub max_workers: Option<usize>,
pub keep_intermediates: Option<bool>,
}
/// Video configuration update fields.
#[derive(Deserialize)]
pub struct VideoConfigUpdate {
pub framerate: Option<u32>,
pub enabled: Option<bool>,
pub codec: Option<String>,
pub crf: Option<u32>,
}
/// Update configuration.
pub async fn update_config(
State(state): State<AppState>,
Json(update): Json<ConfigUpdateRequest>,
) -> Result<Json<ConfigResponse>, (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)
}

View file

@ -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<String>,
pub error: Option<String>,
}
/// Check connection to Immich.
pub async fn check_connection(State(state): State<AppState>) -> Json<ConnectionStatus> {
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()),
}),
}
}

118
src/web/handlers/mod.rs Normal file
View file

@ -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#"<!DOCTYPE html>
<html>
<head><title>Immich Timelapse</title></head>
<body style="font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0f0f0f; color: #e0e0e0;">
<div style="text-align: center;">
<h1>Frontend not built</h1>
<p>Run <code style="background: #333; padding: 0.25rem 0.5rem; border-radius: 4px;">cd frontend && npm install && npm run build</code></p>
<p style="margin-top: 1rem; color: #888;">Or use <code style="background: #333; padding: 0.25rem 0.5rem; border-radius: 4px;">npm run dev</code> for development</p>
</div>
</body>
</html>"#,
)
.into_response(),
}
}

View file

@ -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#"<!DOCTYPE html>
<html>
<head><title>Immich Timelapse</title></head>
<body style="font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0f0f0f; color: #e0e0e0;">
<div style="text-align: center;">
<h1>Frontend not built</h1>
<p>Run <code style="background: #333; padding: 0.25rem 0.5rem; border-radius: 4px;">cd frontend && npm install && npm run build</code></p>
<p style="margin-top: 1rem; color: #888;">Or use <code style="background: #333; padding: 0.25rem 0.5rem; border-radius: 4px;">npm run dev</code> for development</p>
</div>
</body>
</html>"#,
)
.into_response(),
}
}
/// Health check endpoint.
async fn health_check() -> &'static str {
"OK"
}
/// Check connection to Immich.
#[derive(Serialize)]
struct ConnectionStatus {
connected: bool,
version: Option<String>,
error: Option<String>,
}
async fn check_connection(State(state): State<AppState>) -> Json<ConnectionStatus> {
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<String>,
}
async fn get_people(
State(state): State<AppState>,
) -> Result<Json<Vec<PersonInfo>>, (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<PersonInfo> = 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<AppState>,
Path(person_id): Path<String>,
) -> Result<Response<Body>, (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<AppState>,
Path(person_id): Path<String>,
) -> Result<Json<AssetCountResponse>, (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<String>,
skip_stats: SkipStatsResponse,
person_id: Option<String>,
person_name: Option<String>,
}
async fn get_progress(State(state): State<AppState>) -> Json<ProgressResponse> {
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<String>,
date_from: Option<String>,
date_to: Option<String>,
}
#[derive(Serialize)]
struct StartResponse {
success: bool,
message: String,
}
async fn start_processing(
State(state): State<AppState>,
Json(request): Json<StartRequest>,
) -> Result<Json<StartResponse>, (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<AppState>) -> Json<StartResponse> {
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<ImageInfo>,
total_count: u32,
total_size_bytes: u64,
video_exists: bool,
pub struct FolderImagesResponse {
pub folder_name: String,
pub images: Vec<ImageInfo>,
pub total_count: u32,
pub total_size_bytes: u64,
pub video_exists: bool,
}
/// Request for bulk deleting images.
#[derive(Deserialize)]
struct BulkDeleteRequest {
filenames: Vec<String>,
pub struct BulkDeleteRequest {
pub filenames: Vec<String>,
}
/// 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<AppState>,
) -> Result<Json<Vec<OutputFolderInfo>>, (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<AppState>,
) -> Result<Json<StartResponse>, (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<AppState>,
Path(folder_name): Path<String>,
) -> Result<Json<StartResponse>, (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<AppState>,
Path(folder_name): Path<String>,
) -> Result<Json<FolderImagesResponse>, (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<AppState>,
Path((folder_name, filename)): Path<(String, String)>,
) -> Result<Json<StartResponse>, (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<AppState>,
Path(folder_name): Path<String>,
Json(request): Json<BulkDeleteRequest>,
@ -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<AppState>,
Path(folder_name): Path<String>,
) -> Result<Json<StartResponse>, (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<AppState>) -> Json<ConfigResponse> {
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<ProcessingConfigUpdate>,
video: Option<VideoConfigUpdate>,
}
#[derive(Deserialize)]
struct ProcessingConfigUpdate {
resize_size: Option<u32>,
face_resolution_threshold: Option<u32>,
pose_threshold: Option<f32>,
ear_threshold: Option<f32>,
max_workers: Option<usize>,
keep_intermediates: Option<bool>,
}
#[derive(Deserialize)]
struct VideoConfigUpdate {
framerate: Option<u32>,
enabled: Option<bool>,
codec: Option<String>,
crf: Option<u32>,
}
/// Update configuration.
async fn update_config(
State(state): State<AppState>,
Json(update): Json<ConfigUpdateRequest>,
) -> Result<Json<ConfigResponse>, (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)
}

134
src/web/handlers/people.rs Normal file
View file

@ -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<String>,
}
/// Get list of people from Immich.
pub async fn get_people(
State(state): State<AppState>,
) -> Result<Json<Vec<PersonInfo>>, (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<PersonInfo> = 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<AppState>,
Path(person_id): Path<String>,
) -> Result<Response<Body>, (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<AppState>,
Path(person_id): Path<String>,
) -> Result<Json<AssetCountResponse>, (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,
}))
}

View file

@ -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<String>,
pub skip_stats: SkipStatsResponse,
pub person_id: Option<String>,
pub person_name: Option<String>,
}
/// Get current progress.
pub async fn get_progress(State(state): State<AppState>) -> Json<ProgressResponse> {
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<String>,
pub date_from: Option<String>,
pub date_to: Option<String>,
}
/// Start processing for a person.
pub async fn start_processing(
State(state): State<AppState>,
Json(request): Json<StartRequest>,
) -> Result<Json<StartResponse>, (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<AppState>) -> Json<StartResponse> {
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(),
})
}
}