diff --git a/src/error.rs b/src/error.rs index 2aaa059..86f19a1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,6 +2,12 @@ use thiserror::Error; +/// User-facing hint for Docker permission errors. Used in error messages and startup checks. +pub const PERMISSION_HINT: &str = "\ +Make sure that the user running the Docker container has access to the config and output folders:\n\ +- In docker-compose.yml add `user: 1000:1000`\n\ +- Then run `chown -R 1000:1000 path/to/output path/to/config`"; + /// Main error type for the application. #[derive(Error, Debug)] pub enum Error { @@ -48,5 +54,46 @@ pub enum Error { Cancelled, } +impl Error { + /// This is for messages shown in the UI. Keep `self.to_string()` for logs. + pub fn user_message(&self) -> String { + match self { + Error::Io(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + format!("Permission denied: {}\n\n{}", e, PERMISSION_HINT) + } + Error::Http(e) => { + let msg = e.to_string(); + if e.is_connect() { + if msg.contains("dns error") || msg.contains("resolve") { + format!( + "Could not resolve hostname. \ + If running in Docker, use the container name \ + (e.g. `http://immich-server:2283`) instead of `localhost`.\n\n\ + Raw error: {}", + msg + ) + } else { + format!( + "Connection refused. Is Immich running at this address?\n\n\ + Raw error: {}", + msg + ) + } + } else if e.is_timeout() { + format!( + "Connection timed out. \ + Check that the URL is reachable from this container.\n\n\ + Raw error: {}", + msg + ) + } else { + msg + } + } + other => other.to_string(), + } + } +} + /// Convenience Result type. pub type Result = std::result::Result; diff --git a/src/job/mod.rs b/src/job/mod.rs index 7f424b6..c203f94 100644 --- a/src/job/mod.rs +++ b/src/job/mod.rs @@ -166,12 +166,13 @@ pub async fn run_job(state: AppState, params: JobParams, cancel_token: Cancellat } Err(e) => { tracing::error!("Job failed: {}", e); + let friendly = e.user_message(); state .update_progress(Progress { - status: JobStatus::Error(e.to_string()), + status: JobStatus::Error(friendly.clone()), completed: final_progress.completed, total: final_progress.total, - message: Some(e.to_string()), + message: Some(friendly), skip_stats: final_progress.skip_stats, person_id: final_progress.person_id, person_name: final_progress.person_name, @@ -201,7 +202,9 @@ async fn run_job_inner( // Create output directories let images_dir = person_dir.join("images"); - tokio::fs::create_dir_all(&images_dir).await?; + tokio::fs::create_dir_all(&images_dir) + .await + .map_err(|e| permission_aware_io_error(e, &images_dir))?; // Create debug directories if enabled let debug = if config.processing.output.keep_intermediates { @@ -516,6 +519,19 @@ async fn run_job_inner( } } +/// Convert a permission-denied I/O error into a Config error with Docker fix instructions. +fn permission_aware_io_error(e: std::io::Error, path: &std::path::Path) -> Error { + if e.kind() == std::io::ErrorKind::PermissionDenied { + Error::Config(format!( + "Permission denied for '{}'. {}", + path.display(), + crate::error::PERMISSION_HINT + )) + } else { + Error::Io(e) + } +} + /// Find the face data for a specific person in an asset. fn find_face_for_person(asset: &Asset, person_id: &str) -> Option { let people = asset.people.as_ref()?; diff --git a/src/main.rs b/src/main.rs index a79eb2c..3436492 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use immich_timelapse::{ config::{Config, CONFIG_PATH}, + error::PERMISSION_HINT, models::DlibLandmarks, web, }; @@ -33,6 +34,21 @@ async fn main() -> anyhow::Result<()> { let config = load_config()?; tracing::info!("Configuration loaded"); + // Check AVX support (required by ONNX models, x86_64 only) + #[cfg(target_arch = "x86_64")] + { + if !std::arch::is_x86_feature_detected!("avx") { + tracing::error!( + "This CPU does not support AVX instructions, which are required by the ML models \ + (dlib landmarks, DMHead pose estimation). The application cannot run on this hardware." + ); + std::process::exit(1); + } + } + + // Check output directory writability + check_output_dir(&config); + // Check ffmpeg availability match immich_timelapse::video::check_ffmpeg().await { Ok(version) => tracing::info!("FFmpeg available: {}", version), @@ -64,6 +80,43 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +fn check_output_dir(config: &Config) { + let dir = &config.output_dir; + if let Err(e) = std::fs::create_dir_all(dir) { + if e.kind() == std::io::ErrorKind::PermissionDenied { + tracing::warn!( + "Cannot create output directory '{}': permission denied. {}", + dir.display(), + PERMISSION_HINT + ); + } else { + tracing::warn!("Cannot create output directory '{}': {}", dir.display(), e); + } + return; + } + + let test_file = dir.join(".writetest"); + match std::fs::write(&test_file, b"ok") { + Ok(()) => { + let _ = std::fs::remove_file(&test_file); + } + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + tracing::warn!( + "Output directory '{}' is not writable: permission denied. {}", + dir.display(), + PERMISSION_HINT + ); + } + Err(e) => { + tracing::warn!( + "Output directory '{}' is not writable: {}", + dir.display(), + e + ); + } + } +} + fn load_config() -> anyhow::Result { let config_path = std::path::Path::new(CONFIG_PATH); @@ -82,10 +135,9 @@ fn load_config() -> anyhow::Result { 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 + {} Continuing with in-memory defaults.", + CONFIG_PATH, + PERMISSION_HINT ); } else { tracing::warn!("Could not write default config to {}: {}", CONFIG_PATH, e);