immich-automated-selfie-tim.../src/config.rs
2026-02-15 20:59:45 +01:00

917 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Application configuration.
//!
//! Configuration can be loaded from:
//! 1. TOML file (config.toml)
//! 2. Environment variables (prefixed with IMMICH_)
//! 3. Programmatic overrides
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::error::{Error, Result};
/// Default path to the config file, relative to the working directory.
pub const CONFIG_PATH: &str = "config/config.toml";
/// Main configuration struct.
///
/// This is the single source of truth for all configuration.
/// Pass this explicitly to functions that need it - no globals.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// Immich API configuration.
pub api: ApiConfig,
/// Face processing parameters.
pub processing: ProcessingConfig,
/// Video output settings.
pub video: VideoConfig,
/// Output directory for processed images and video.
pub output_dir: PathBuf,
}
impl Default for Config {
fn default() -> Self {
Self {
api: ApiConfig::default(),
processing: ProcessingConfig::default(),
video: VideoConfig::default(),
output_dir: PathBuf::from("output"),
}
}
}
/// Immich API connection settings.
#[derive(Clone, Serialize, Deserialize)]
pub struct ApiConfig {
/// API key for authentication.
pub api_key: String,
/// Base URL of the Immich instance (e.g., "http://192.168.1.94:2283/api").
pub base_url: String,
/// Request timeout in seconds.
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
impl std::fmt::Debug for ApiConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApiConfig")
.field("api_key", &"[REDACTED]")
.field("base_url", &self.base_url)
.field("timeout_secs", &self.timeout_secs)
.finish()
}
}
fn default_timeout() -> u64 {
30
}
impl Default for ApiConfig {
fn default() -> Self {
Self {
api_key: String::new(),
base_url: String::new(),
timeout_secs: default_timeout(),
}
}
}
// ============================================================================
// Step Configurations
// ============================================================================
/// Face resolution validation configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaceResolutionConfig {
/// Whether face resolution validation is enabled.
pub enabled: bool,
/// Minimum face width/height in pixels.
/// Faces smaller than this will be skipped.
pub min_size: u32,
}
impl Default for FaceResolutionConfig {
fn default() -> Self {
Self {
enabled: true,
min_size: 80,
}
}
}
impl FaceResolutionConfig {
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
if self.enabled && self.min_size == 0 {
return Err(Error::Config(
"Face resolution min_size must be greater than 0".to_string(),
));
}
Ok(())
}
}
/// Brightness validation configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrightnessConfig {
/// Whether brightness validation is enabled.
pub enabled: bool,
/// Minimum acceptable brightness (0.0-1.0).
/// Images darker than this will be skipped.
pub min_brightness: f32,
/// Maximum acceptable brightness (0.0-1.0).
/// Images brighter than this will be skipped.
pub max_brightness: f32,
}
impl Default for BrightnessConfig {
fn default() -> Self {
Self {
enabled: false,
min_brightness: 0.1,
max_brightness: 0.9,
}
}
}
impl BrightnessConfig {
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
if !self.enabled {
return Ok(());
}
if self.min_brightness < 0.0 || self.min_brightness > 1.0 {
return Err(Error::Config(
"Brightness min_brightness must be between 0.0 and 1.0".to_string(),
));
}
if self.max_brightness < 0.0 || self.max_brightness > 1.0 {
return Err(Error::Config(
"Brightness max_brightness must be between 0.0 and 1.0".to_string(),
));
}
if self.min_brightness >= self.max_brightness {
return Err(Error::Config(
"Brightness min_brightness must be less than max_brightness".to_string(),
));
}
Ok(())
}
}
/// Blur detection configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlurConfig {
/// Whether blur detection is enabled.
pub enabled: bool,
/// Minimum Laplacian variance threshold.
/// Images with Laplacian variance below this are considered blurry.
/// Typical values: < 100 = blurry, 100500 = borderline, > 500 = sharp.
pub min_sharpness: f32,
}
impl Default for BlurConfig {
fn default() -> Self {
Self {
enabled: true,
min_sharpness: 100.0,
}
}
}
impl BlurConfig {
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
if self.enabled && self.min_sharpness < 0.0 {
return Err(Error::Config(
"Blur min_sharpness must be non-negative".to_string(),
));
}
Ok(())
}
}
/// Crop padding filter configuration.
///
/// Skips images where too much of the crop region falls outside the original
/// image bounds (filled by edge-pixel replication).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CropConfig {
/// Whether crop padding filtering is enabled.
pub enabled: bool,
/// Maximum allowed padding percentage (0.0-100.0).
/// Images where the replicated-fill area exceeds this will be skipped.
pub max_padding_percent: f32,
}
impl Default for CropConfig {
fn default() -> Self {
Self {
enabled: false,
max_padding_percent: 30.0,
}
}
}
impl CropConfig {
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
if self.enabled && !(0.0..=100.0).contains(&self.max_padding_percent) {
return Err(Error::Config(
"Crop max_padding_percent must be between 0.0 and 100.0".to_string(),
));
}
Ok(())
}
}
/// Output/resize configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputConfig {
/// Output image size (width and height in pixels).
pub size: u32,
/// Keep intermediate images (original, cropped) for inspection.
pub keep_intermediates: bool,
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
size: 512,
keep_intermediates: false,
}
}
}
impl OutputConfig {
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
if self.size < 64 {
return Err(Error::Config(
"Output size must be at least 64 pixels".to_string(),
));
}
if self.size > 4096 {
return Err(Error::Config(
"Output size must be at most 4096 pixels".to_string(),
));
}
Ok(())
}
}
/// Head pose estimation configuration.
///
/// Uses DMHead ONNX model to estimate head pose angles and filter
/// non-front-facing faces.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeadPoseConfig {
/// Whether head pose filtering is enabled.
pub enabled: bool,
/// Maximum allowed yaw angle (left/right turn) in degrees.
pub max_yaw: f32,
/// Maximum allowed pitch angle (up/down tilt) in degrees.
pub max_pitch: f32,
}
impl Default for HeadPoseConfig {
fn default() -> Self {
Self {
enabled: true,
max_yaw: 20.0,
max_pitch: 20.0,
}
}
}
impl HeadPoseConfig {
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
if self.enabled {
if self.max_yaw < 0.0 || self.max_yaw > 90.0 {
return Err(Error::Config(
"Head pose max_yaw must be between 0 and 90 degrees".to_string(),
));
}
if self.max_pitch < 0.0 || self.max_pitch > 90.0 {
return Err(Error::Config(
"Head pose max_pitch must be between 0 and 90 degrees".to_string(),
));
}
}
Ok(())
}
}
/// Eye filter configuration for blink detection.
///
/// Uses Eye Aspect Ratio (EAR) computed from facial landmarks to detect
/// closed eyes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EyeFilterConfig {
/// Whether eye filtering is enabled.
pub enabled: bool,
/// Minimum Eye Aspect Ratio (EAR) threshold.
/// Eyes with EAR below this are considered closed.
pub min_ear: f32,
}
impl Default for EyeFilterConfig {
fn default() -> Self {
Self {
enabled: true,
min_ear: 0.12,
}
}
}
impl EyeFilterConfig {
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
if self.enabled && !(0.0..=0.5).contains(&self.min_ear) {
return Err(Error::Config(
"Eye filter min_ear must be between 0.0 and 0.5".to_string(),
));
}
Ok(())
}
}
/// Face alignment configuration for landmark-based alignment.
///
/// Aligns faces based on eye positions detected from facial landmarks,
/// ensuring consistent eye placement across all images for smoother timelapses.
///
/// Uses a single `eye_distance` parameter to control framing. Eye positions are derived by
/// estimating the visual face center (see `FACE_CENTER_OFFSET_IN_IPD`) from standard face
/// proportions, then placing that point at the image center with a small downward offset to
/// show more neck.
///
/// Note: Face alignment is always enabled and is a core part of the pipeline.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlignmentConfig {
/// Distance between left and right eye centers as a fraction of output
/// image width (0.0-1.0). Default 0.15 means the eyes span 15% of the
/// output width. Larger values zoom in (bigger face), smaller values
/// zoom out (more background).
pub eye_distance: f32,
}
impl Default for AlignmentConfig {
fn default() -> Self {
Self { eye_distance: 0.15 }
}
}
impl AlignmentConfig {
/// How far below the eye line the face center sits, in units of
/// inter-pupillary distance (IPD). Value of 0.5 means the face center
/// is 0.5 × IPD below the eyes.
///
/// From face proportions: the vertical midpoint between hairline and chin
/// is ~0.4× IPD below the eyes; adding a small neck bias brings it to
/// 0.5× IPD. The alignment formula places this point at image center (y=0.5):
///
/// eye_y = 0.5 - eye_distance * FACE_CENTER_OFFSET_IN_IPD
const FACE_CENTER_BELOW_EYES: f32 = 0.5;
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
if self.eye_distance <= 0.0 || self.eye_distance >= 1.0 {
return Err(Error::Config(
"Alignment eye_distance must be between 0.0 and 1.0 (exclusive)".to_string(),
));
}
Ok(())
}
/// Target X position for the left eye as a fraction of output width.
/// Eyes are centered horizontally: `left_eye_x = (1 - eye_distance) / 2`.
pub fn left_eye_x(&self) -> f32 {
(1.0 - self.eye_distance) / 2.0
}
/// Target Y position for both eyes as a fraction of output height.
/// Derived by placing the estimated face center at the image center:
/// `eye_y = 0.5 - eye_distance * FACE_CENTER_BELOW_EYES`.
pub fn left_eye_y(&self) -> f32 {
0.5 - self.eye_distance * Self::FACE_CENTER_BELOW_EYES
}
}
/// Position for timestamp text overlay.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TextPosition {
#[default]
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
/// Timestamp overlay configuration.
///
/// Overlays date information on the processed image.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimestampConfig {
/// Whether timestamp overlay is enabled.
pub enabled: bool,
/// Position of the timestamp text.
pub position: TextPosition,
/// Whether to display the year.
pub year: bool,
/// Whether to display the month.
pub month: bool,
/// Whether to display the day.
pub day: bool,
}
impl Default for TimestampConfig {
fn default() -> Self {
Self {
enabled: true,
position: TextPosition::BottomLeft,
year: true,
month: true,
day: true,
}
}
}
impl TimestampConfig {
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
// No validation needed for booleans and enum
Ok(())
}
}
/// Time range granularity for photo limiting.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TimeRange {
#[default]
Day,
Week,
Month,
}
/// Time interval configuration.
///
/// Limits the number of successfully processed photos per time range
/// (day/week/month) to avoid over-representation of busy periods.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeIntervalConfig {
/// Whether photo limiting is enabled.
pub enabled: bool,
/// Maximum number of photos per time range.
pub max_photos: u32,
/// Time range granularity.
pub time_range: TimeRange,
}
impl Default for TimeIntervalConfig {
fn default() -> Self {
Self {
enabled: true,
max_photos: 5,
time_range: TimeRange::Day,
}
}
}
impl TimeIntervalConfig {
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
if self.enabled && self.max_photos == 0 {
return Err(Error::Config(
"Time interval max_photos must be greater than 0".to_string(),
));
}
if self.enabled && self.max_photos > 100 {
return Err(Error::Config(
"Time interval max_photos must be at most 100".to_string(),
));
}
Ok(())
}
}
// ============================================================================
// Main Processing Configuration
// ============================================================================
/// Face processing parameters.
///
/// Each pipeline step has its own sub-configuration with an `enabled` flag.
/// This allows fine-grained control over which steps run and their parameters.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ProcessingConfig {
/// Number of parallel workers for processing.
pub max_workers: usize,
/// Whether to download Immich preview images (1440p JPEG) instead of originals.
/// Preview images are much faster to decode and sufficient for most use cases.
/// Disable only if you need full original resolution (e.g., very small faces in high-res photos).
#[serde(default = "default_true")]
pub use_preview: bool,
/// Face resolution validation settings.
#[serde(default)]
pub face_resolution: FaceResolutionConfig,
/// Crop padding filter settings.
#[serde(default)]
pub crop: CropConfig,
/// Blur detection settings.
#[serde(default)]
pub blur: BlurConfig,
/// Brightness validation settings.
#[serde(default)]
pub brightness: BrightnessConfig,
/// Head pose estimation settings.
#[serde(default)]
pub head_pose: HeadPoseConfig,
/// Eye filter settings (blink detection).
#[serde(default)]
pub eye_filter: EyeFilterConfig,
/// Output image settings.
#[serde(default)]
pub output: OutputConfig,
/// Face alignment settings (landmark-based).
#[serde(default)]
pub alignment: AlignmentConfig,
/// Timestamp overlay settings.
#[serde(default)]
pub timestamp: TimestampConfig,
/// Time interval settings.
#[serde(default)]
pub time_interval: TimeIntervalConfig,
}
impl Default for ProcessingConfig {
fn default() -> Self {
Self {
max_workers: 1,
use_preview: true,
face_resolution: FaceResolutionConfig::default(),
crop: CropConfig::default(),
blur: BlurConfig::default(),
brightness: BrightnessConfig::default(),
head_pose: HeadPoseConfig::default(),
eye_filter: EyeFilterConfig::default(),
output: OutputConfig::default(),
alignment: AlignmentConfig::default(),
timestamp: TimestampConfig::default(),
time_interval: TimeIntervalConfig::default(),
}
}
}
impl ProcessingConfig {
/// Validate all step configurations.
pub fn validate(&self) -> Result<()> {
if self.max_workers == 0 {
return Err(Error::Config(
"max_workers must be greater than 0".to_string(),
));
}
self.face_resolution.validate()?;
self.crop.validate()?;
self.blur.validate()?;
self.brightness.validate()?;
self.head_pose.validate()?;
self.eye_filter.validate()?;
self.output.validate()?;
self.alignment.validate()?;
self.timestamp.validate()?;
self.time_interval.validate()?;
Ok(())
}
}
fn default_true() -> bool {
true
}
// ============================================================================
// Video Configuration
// ============================================================================
/// Video compilation settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VideoConfig {
/// Whether to compile video after processing.
pub enabled: bool,
/// Output video framerate.
pub framerate: u32,
/// Video codec (e.g., "libx264").
pub codec: String,
/// Constant Rate Factor for quality (0-51, lower = better, 18-28 recommended).
pub crf: u32,
}
impl Default for VideoConfig {
fn default() -> Self {
Self {
enabled: true,
framerate: 20,
codec: "libx264".to_string(),
crf: 23,
}
}
}
/// Valid video codecs that can be used with FFmpeg.
const VALID_CODECS: &[&str] = &["libx264", "libx265", "libvpx", "libvpx-vp9", "libaom-av1"];
impl VideoConfig {
/// Validate the configuration values.
pub fn validate(&self) -> Result<()> {
if self.framerate == 0 {
return Err(Error::Config(
"Video framerate must be greater than 0".to_string(),
));
}
if self.framerate > 120 {
return Err(Error::Config(
"Video framerate must be at most 120".to_string(),
));
}
if self.crf > 51 {
return Err(Error::Config(
"Video CRF must be between 0 and 51".to_string(),
));
}
if !VALID_CODECS.contains(&self.codec.as_str()) {
return Err(Error::Config(format!(
"Video codec must be one of: {}, got '{}'",
VALID_CODECS.join(", "),
self.codec
)));
}
Ok(())
}
}
// ============================================================================
// Persistence
// ============================================================================
/// Persistable configuration (excludes sensitive API credentials).
///
/// This struct contains only the settings that can safely be written to disk.
/// API credentials should always come from environment variables.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistableConfig {
pub processing: ProcessingConfig,
pub video: VideoConfig,
}
impl From<&Config> for PersistableConfig {
fn from(config: &Config) -> Self {
Self {
processing: config.processing.clone(),
video: config.video.clone(),
}
}
}
// ============================================================================
// Config Loading and Validation
// ============================================================================
impl Config {
/// Load configuration from a TOML file.
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&content).map_err(|e| Error::Config(e.to_string()))?;
Ok(config)
}
/// Load configuration from environment variables.
///
/// Recognized variables:
/// - IMMICH_API_KEY - API key for Immich
/// - IMMICH_BASE_URL - Base URL of Immich instance
pub fn from_env() -> Self {
Config::default().with_env()
}
/// Merge environment variables into an existing config.
pub fn with_env(mut self) -> Self {
if let Ok(key) = std::env::var("IMMICH_API_KEY") {
self.api.api_key = key;
}
if let Ok(url) = std::env::var("IMMICH_BASE_URL") {
self.api.base_url = url;
}
self
}
/// Validate the entire configuration.
pub fn validate(&self) -> Result<()> {
// API validation
if self.api.api_key.is_empty() {
return Err(Error::Config("API key is required".to_string()));
}
if self.api.base_url.is_empty() {
return Err(Error::Config("Base URL is required".to_string()));
}
// Delegate to sub-config validation
self.processing.validate()?;
self.video.validate()?;
Ok(())
}
/// Save processing and video configuration to a TOML file.
///
/// Only saves `processing` and `video` sections - API credentials are
/// intentionally excluded as they should come from environment variables.
pub fn save_to_file(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
let path = path.as_ref();
let persistable = PersistableConfig::from(self);
let content = toml::to_string_pretty(&persistable)
.map_err(|e| Error::Config(format!("Failed to serialize config: {}", e)))?;
// Write to a temp file then atomically rename to avoid partial writes
let tmp_path = path.with_extension("toml.tmp");
std::fs::write(&tmp_path, &content).map_err(|e| {
if e.kind() == std::io::ErrorKind::PermissionDenied {
Error::Config(format!(
"Permission denied writing config to '{}'. \
If running in Docker, mount a writable volume at the config directory \
(e.g. -v /host/config:/app/config).",
tmp_path.display()
))
} else {
Error::Io(e)
}
})?;
std::fs::rename(&tmp_path, path).map_err(|e| {
if e.kind() == std::io::ErrorKind::PermissionDenied {
Error::Config(format!(
"Permission denied saving config to '{}'. \
If running in Docker, mount a writable volume at the config directory \
(e.g. -v /host/config:/app/config).",
path.display()
))
} else {
Error::Io(e)
}
})?;
Ok(())
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.processing.output.size, 512);
assert_eq!(config.processing.face_resolution.min_size, 80);
assert_eq!(config.video.framerate, 20);
}
#[test]
fn test_validation_missing_api_key() {
let config = Config::default();
let result = config.validate();
assert!(result.is_err());
}
#[test]
fn test_brightness_validation() {
let mut config = BrightnessConfig {
enabled: true,
min_brightness: 0.1,
max_brightness: 0.9,
};
// Valid config
assert!(config.validate().is_ok());
// Invalid: min >= max
config.min_brightness = 0.9;
config.max_brightness = 0.1;
assert!(config.validate().is_err());
// Invalid: out of range
config.min_brightness = -0.1;
config.max_brightness = 0.9;
assert!(config.validate().is_err());
// Disabled config skips validation
config.enabled = false;
assert!(config.validate().is_ok());
}
#[test]
fn test_video_validation() {
let mut config = VideoConfig::default();
// Valid
assert!(config.validate().is_ok());
// Invalid CRF
config.crf = 52;
assert!(config.validate().is_err());
// Invalid framerate
config.crf = 23;
config.framerate = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_output_validation() {
let mut config = OutputConfig::default();
// Valid
assert!(config.validate().is_ok());
// Too small
config.size = 32;
assert!(config.validate().is_err());
// Too large
config.size = 8192;
assert!(config.validate().is_err());
}
#[test]
fn test_save_and_load_config() {
// Create a config with custom values
let mut config = Config::default();
config.processing.output.size = 256;
config.processing.face_resolution.min_size = 100;
config.video.framerate = 30;
config.video.crf = 18;
// Save to a temp file
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join("test_config_new.toml");
config
.save_to_file(&temp_path)
.expect("Failed to save config");
// Verify file was created and contains expected content
let content = std::fs::read_to_string(&temp_path).expect("Failed to read config");
assert!(content.contains("size = 256"));
assert!(content.contains("min_size = 100"));
assert!(content.contains("framerate = 30"));
assert!(content.contains("crf = 18"));
// Verify API credentials are NOT in the file
assert!(!content.contains("api_key"));
assert!(!content.contains("base_url"));
// Clean up
let _ = std::fs::remove_file(&temp_path);
}
}