diff --git a/Dockerfile b/Dockerfile index a121fcb..5dd1cd6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,7 +60,7 @@ COPY --from=frontend-build /app/frontend/dist/ ./frontend/dist/ # Create non-root user RUN useradd --create-home --shell /bin/bash timelapse && \ - mkdir -p output && \ + mkdir -p output config && \ chown -R timelapse:timelapse /app USER timelapse diff --git a/src/config.rs b/src/config.rs index fecf87b..aa6793a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,9 @@ 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. @@ -735,8 +738,30 @@ impl Config { // 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)?; - std::fs::rename(&tmp_path, path)?; + 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(()) } } diff --git a/src/main.rs b/src/main.rs index 7c9d68f..aff682b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ //! //! Web server for creating selfie timelapses from Immich. -use immich_timelapse::{config::Config, models::DlibLandmarks, web}; +use immich_timelapse::{config::{Config, CONFIG_PATH}, models::DlibLandmarks, web}; use std::net::SocketAddr; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -61,13 +61,34 @@ async fn main() -> anyhow::Result<()> { } fn load_config() -> anyhow::Result { - // Try loading from config.toml first - let config = if std::path::Path::new("config.toml").exists() { - tracing::info!("Loading configuration from config.toml"); - Config::from_file("config.toml")?.with_env() + let config_path = std::path::Path::new(CONFIG_PATH); + + // Try loading from config/config.toml, or create a default one + let config = if config_path.exists() { + tracing::info!("Loading configuration from {}", CONFIG_PATH); + Config::from_file(config_path)?.with_env() } else { - tracing::info!("No config.toml found, using environment variables"); - Config::from_env() + tracing::info!("No {} found, creating default config file", CONFIG_PATH); + let default_config = Config::from_env(); + // Write default config so it can be customized via volume mount + match default_config.save_to_file(config_path) { + Ok(()) => tracing::info!("Default config written to {}", CONFIG_PATH), + Err(e) => { + let msg = e.to_string(); + if msg.contains("permission denied") || msg.contains("Permission denied") { + tracing::warn!( + "Could not write default config to {}: permission denied. \ + If running in Docker, ensure the config directory is writable \ + (e.g. mount a volume with the correct permissions: \ + -v /host/config:/app/config). Continuing with in-memory defaults.", + CONFIG_PATH + ); + } else { + tracing::warn!("Could not write default config to {}: {}", CONFIG_PATH, e); + } + } + } + default_config }; // Warn but don't abort if config is incomplete - the server can still start diff --git a/src/web/handlers/config.rs b/src/web/handlers/config.rs index 34b7d0b..95b7370 100644 --- a/src/web/handlers/config.rs +++ b/src/web/handlers/config.rs @@ -3,7 +3,7 @@ use crate::config::{ AlignmentConfig, BlurConfig, BrightnessConfig, EyeFilterConfig, FaceResolutionConfig, HeadPoseConfig, OutputConfig, ProcessingConfig, TimeIntervalConfig, TimestampConfig, - VideoConfig, + VideoConfig, CONFIG_PATH, }; use crate::web::state::AppState; use axum::{extract::State, http::StatusCode, response::Json}; @@ -314,10 +314,10 @@ pub async fn update_config( // Persist to file outside the lock using spawn_blocking for the fs write let save_result = - tokio::task::spawn_blocking(move || config_snapshot.save_to_file("config.toml")).await; + tokio::task::spawn_blocking(move || config_snapshot.save_to_file(CONFIG_PATH)).await; match save_result { - Ok(Ok(())) => tracing::info!("Configuration updated and persisted to config.toml"), + Ok(Ok(())) => tracing::info!("Configuration updated and persisted to {}", CONFIG_PATH), Ok(Err(e)) => tracing::warn!("Failed to persist config to file: {}", e), Err(e) => tracing::warn!("Config save task panicked: {}", e), }