Code cleanup : split web handler module
This commit is contained in:
parent
499094c25a
commit
41506d6a3a
9 changed files with 686 additions and 555 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
74
src/utils.rs
Normal 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
117
src/web/handlers/config.rs
Normal 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)
|
||||
}
|
||||
47
src/web/handlers/health.rs
Normal file
47
src/web/handlers/health.rs
Normal 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
118
src/web/handlers/mod.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -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
134
src/web/handlers/people.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
159
src/web/handlers/processing.rs
Normal file
159
src/web/handlers/processing.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue