immich-automated-selfie-tim.../src/web/handlers/config.rs
2026-02-02 21:12:05 +01:00

247 lines
7.5 KiB
Rust

//! Configuration endpoints.
use crate::config::{
AlignmentConfig, BrightnessConfig, FaceResolutionConfig, OutputConfig, ProcessingConfig,
VideoConfig,
};
use crate::web::state::AppState;
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.
///
/// Uses the same nested structure as the config itself for consistency.
#[derive(Deserialize)]
pub struct ConfigUpdateRequest {
pub processing: Option<ProcessingConfigUpdate>,
pub video: Option<VideoConfigUpdate>,
}
/// Processing configuration update fields.
#[derive(Deserialize)]
pub struct ProcessingConfigUpdate {
pub max_workers: Option<usize>,
pub face_resolution: Option<FaceResolutionConfig>,
pub brightness: Option<BrightnessConfig>,
pub output: Option<OutputConfig>,
pub alignment: Option<AlignmentConfig>,
}
/// Video configuration update fields.
#[derive(Deserialize)]
pub struct VideoConfigUpdate {
pub enabled: Option<bool>,
pub framerate: Option<u32>,
pub codec: Option<String>,
pub crf: Option<u32>,
}
/// Validation error with field name and message.
struct ValidationError {
field: &'static str,
message: String,
}
impl ValidationError {
fn new(field: &'static str, message: impl Into<String>) -> Self {
Self {
field,
message: message.into(),
}
}
}
/// Validate processing configuration update values.
fn validate_processing_config(proc: &ProcessingConfigUpdate) -> Result<(), ValidationError> {
if let Some(v) = proc.max_workers {
if v == 0 || v > 64 {
return Err(ValidationError::new(
"processing.max_workers",
format!("must be between 1 and 64, got {}", v),
));
}
}
if let Some(ref fr) = proc.face_resolution {
if fr.enabled && fr.min_size == 0 {
return Err(ValidationError::new(
"processing.face_resolution.min_size",
"must be greater than 0 when enabled",
));
}
if fr.min_size > 1000 {
return Err(ValidationError::new(
"processing.face_resolution.min_size",
format!("must be at most 1000, got {}", fr.min_size),
));
}
}
if let Some(ref br) = proc.brightness {
if br.enabled {
if br.min_brightness < 0.0 || br.min_brightness > 1.0 {
return Err(ValidationError::new(
"processing.brightness.min_brightness",
format!("must be between 0.0 and 1.0, got {}", br.min_brightness),
));
}
if br.max_brightness < 0.0 || br.max_brightness > 1.0 {
return Err(ValidationError::new(
"processing.brightness.max_brightness",
format!("must be between 0.0 and 1.0, got {}", br.max_brightness),
));
}
if br.min_brightness >= br.max_brightness {
return Err(ValidationError::new(
"processing.brightness",
"min_brightness must be less than max_brightness",
));
}
}
}
if let Some(ref out) = proc.output {
if out.size < 64 {
return Err(ValidationError::new(
"processing.output.size",
format!("must be at least 64, got {}", out.size),
));
}
if out.size > 4096 {
return Err(ValidationError::new(
"processing.output.size",
format!("must be at most 4096, got {}", out.size),
));
}
}
Ok(())
}
/// Validate video configuration update values.
fn validate_video_config(vid: &VideoConfigUpdate) -> Result<(), ValidationError> {
if let Some(v) = vid.framerate {
if v == 0 || v > 120 {
return Err(ValidationError::new(
"video.framerate",
format!("must be between 1 and 120, got {}", v),
));
}
}
if let Some(v) = vid.crf {
if v > 51 {
return Err(ValidationError::new(
"video.crf",
format!("must be between 0 and 51, got {}", v),
));
}
}
if let Some(ref v) = vid.codec {
let valid_codecs = ["libx264", "libx265", "libvpx", "libvpx-vp9", "libaom-av1"];
if !valid_codecs.contains(&v.as_str()) {
return Err(ValidationError::new(
"video.codec",
format!(
"must be one of: {}, got '{}'",
valid_codecs.join(", "),
v
),
));
}
}
Ok(())
}
/// Update configuration.
pub async fn update_config(
State(state): State<AppState>,
Json(update): Json<ConfigUpdateRequest>,
) -> Result<Json<ConfigResponse>, (StatusCode, String)> {
state
.ensure_no_job_running()
.await
.map_err(|e| (StatusCode::CONFLICT, e.to_string()))?;
// Validate input before updating
if let Some(ref proc) = update.processing {
validate_processing_config(proc).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid {}: {}", e.field, e.message),
)
})?;
}
if let Some(ref vid) = update.video {
validate_video_config(vid).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid {}: {}", e.field, e.message),
)
})?;
}
// Update config
{
let mut config = state.config.write().await;
if let Some(proc) = update.processing {
if let Some(v) = proc.max_workers {
config.processing.max_workers = v;
}
if let Some(v) = proc.face_resolution {
config.processing.face_resolution = v;
}
if let Some(v) = proc.brightness {
config.processing.brightness = v;
}
if let Some(v) = proc.output {
config.processing.output = v;
}
if let Some(v) = proc.alignment {
config.processing.alignment = v;
}
}
if let Some(vid) = update.video {
if let Some(v) = vid.enabled {
config.video.enabled = v;
}
if let Some(v) = vid.framerate {
config.video.framerate = v;
}
if let Some(v) = vid.codec {
config.video.codec = v;
}
if let Some(v) = vid.crf {
config.video.crf = v;
}
}
// Persist to file (non-blocking, best-effort)
if let Err(e) = config.save_to_file("config.toml") {
tracing::warn!("Failed to persist config to file: {}", e);
} else {
tracing::info!("Configuration updated and persisted to config.toml");
}
}
// Return updated config
Ok(get_config(State(state)).await)
}