use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::error::{Error, Result};
pub const CONFIG_PATH: &str = "config/config.toml";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub api: ApiConfig,
pub processing: ProcessingConfig,
pub video: VideoConfig,
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"),
}
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ApiConfig {
pub api_key: String,
pub base_url: String,
#[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(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaceResolutionConfig {
pub enabled: bool,
pub min_size: u32,
}
impl Default for FaceResolutionConfig {
fn default() -> Self {
Self {
enabled: true,
min_size: 80,
}
}
}
impl FaceResolutionConfig {
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(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrightnessConfig {
pub enabled: bool,
pub min_brightness: f32,
pub max_brightness: f32,
}
impl Default for BrightnessConfig {
fn default() -> Self {
Self {
enabled: false,
min_brightness: 0.1,
max_brightness: 0.9,
}
}
}
impl BrightnessConfig {
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(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlurConfig {
pub enabled: bool,
pub min_sharpness: f32,
}
impl Default for BlurConfig {
fn default() -> Self {
Self {
enabled: true,
min_sharpness: 75.0,
}
}
}
impl BlurConfig {
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(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CropConfig {
pub enabled: bool,
pub max_padding_percent: f32,
}
impl Default for CropConfig {
fn default() -> Self {
Self {
enabled: true,
max_padding_percent: 30.0,
}
}
}
impl CropConfig {
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(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputConfig {
pub size: u32,
pub keep_intermediates: bool,
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
size: 512,
keep_intermediates: false,
}
}
}
impl OutputConfig {
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(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeadPoseConfig {
pub enabled: bool,
pub max_yaw: f32,
pub max_pitch: f32,
}
impl Default for HeadPoseConfig {
fn default() -> Self {
Self {
enabled: true,
max_yaw: 20.0,
max_pitch: 20.0,
}
}
}
impl HeadPoseConfig {
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(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EyeFilterConfig {
pub enabled: bool,
pub min_ear: f32,
}
impl Default for EyeFilterConfig {
fn default() -> Self {
Self {
enabled: true,
min_ear: 0.12,
}
}
}
impl EyeFilterConfig {
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(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlignmentConfig {
pub eye_distance: f32,
}
impl Default for AlignmentConfig {
fn default() -> Self {
Self { eye_distance: 0.15 }
}
}
impl AlignmentConfig {
const FACE_CENTER_BELOW_EYES: f32 = 0.5;
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(())
}
pub fn left_eye_x(&self) -> f32 {
(1.0 - self.eye_distance) / 2.0
}
pub fn left_eye_y(&self) -> f32 {
0.5 - self.eye_distance * Self::FACE_CENTER_BELOW_EYES
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TextPosition {
#[default]
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimestampConfig {
pub enabled: bool,
pub position: TextPosition,
pub year: bool,
pub month: bool,
pub day: bool,
}
impl Default for TimestampConfig {
fn default() -> Self {
Self {
enabled: true,
position: TextPosition::BottomLeft,
year: true,
month: true,
day: true,
}
}
}
impl TimestampConfig {
pub fn validate(&self) -> Result<()> {
Ok(())
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TimeRange {
#[default]
Day,
Week,
Month,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeIntervalConfig {
pub enabled: bool,
pub max_photos: u32,
pub time_range: TimeRange,
}
impl Default for TimeIntervalConfig {
fn default() -> Self {
Self {
enabled: true,
max_photos: 5,
time_range: TimeRange::Day,
}
}
}
impl TimeIntervalConfig {
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(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ProcessingConfig {
pub max_workers: usize,
#[serde(default = "default_true")]
pub use_preview: bool,
#[serde(default)]
pub face_resolution: FaceResolutionConfig,
#[serde(default)]
pub crop: CropConfig,
#[serde(default)]
pub blur: BlurConfig,
#[serde(default)]
pub brightness: BrightnessConfig,
#[serde(default)]
pub head_pose: HeadPoseConfig,
#[serde(default)]
pub eye_filter: EyeFilterConfig,
#[serde(default)]
pub output: OutputConfig,
#[serde(default)]
pub alignment: AlignmentConfig,
#[serde(default)]
pub timestamp: TimestampConfig,
#[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 {
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
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VideoConfig {
pub enabled: bool,
pub framerate: u32,
pub codec: String,
pub crf: u32,
}
impl Default for VideoConfig {
fn default() -> Self {
Self {
enabled: true,
framerate: 20,
codec: "libx264".to_string(),
crf: 23,
}
}
}
const VALID_CODECS: &[&str] = &["libx264", "libx265", "libvpx", "libvpx-vp9", "libaom-av1"];
impl VideoConfig {
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(())
}
}
#[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(),
}
}
}
impl Config {
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)
}
pub fn from_env() -> Self {
Config::default().with_env()
}
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
}
pub fn validate(&self) -> Result<()> {
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()));
}
self.processing.validate()?;
self.video.validate()?;
Ok(())
}
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)))?;
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(())
}
}
#[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,
};
assert!(config.validate().is_ok());
config.min_brightness = 0.9;
config.max_brightness = 0.1;
assert!(config.validate().is_err());
config.min_brightness = -0.1;
config.max_brightness = 0.9;
assert!(config.validate().is_err());
config.enabled = false;
assert!(config.validate().is_ok());
}
#[test]
fn test_video_validation() {
let mut config = VideoConfig::default();
assert!(config.validate().is_ok());
config.crf = 52;
assert!(config.validate().is_err());
config.crf = 23;
config.framerate = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_output_validation() {
let mut config = OutputConfig::default();
assert!(config.validate().is_ok());
config.size = 32;
assert!(config.validate().is_err());
config.size = 8192;
assert!(config.validate().is_err());
}
#[test]
fn test_save_and_load_config() {
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;
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");
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"));
assert!(!content.contains("api_key"));
assert!(!content.contains("base_url"));
let _ = std::fs::remove_file(&temp_path);
}
}