diff --git a/README.md b/README.md index 4fb576f..4ace034 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,11 @@ To access your immich library, this project requires an Immich API key. Follow this guide to create one: https://immich.app/docs/features/command-line-interface#obtain-the-api-key Here are the features that need to be enabled: -- server.about -- asset.download -- asset.read -- person.read +- `asset.download` +- `asset.read` +- `asset.view` +- `person.read` +- `server.about` *Note: It is advised to store this API key in a `.env` file adjacent to your `docker-compose.yml` rather than in plain text.* diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 7dabf5a..55569d2 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -268,6 +268,12 @@

Connect to your Immich server to get started.

Make sure the backend is running and configured with your Immich API credentials.

+

+ You need an Immich API key with the following permissions: + asset.download, asset.read, asset.view, person.read, server.about. +
+ See the Immich docs to create one. +

{/if} diff --git a/frontend/src/lib/errorHandler.js b/frontend/src/lib/errorHandler.js index d319f3b..7cff6af 100644 --- a/frontend/src/lib/errorHandler.js +++ b/frontend/src/lib/errorHandler.js @@ -9,11 +9,18 @@ * @returns {Promise} Error message */ export async function parseError(error) { - // If it's a Response object, try to extract JSON error message + // If it's a Response object, try to extract the error message if (error instanceof Response) { try { - const data = await error.json(); - return data.message || `Request failed with status ${error.status}`; + const text = await error.text(); + // Try parsing as JSON first (for structured error responses) + try { + const data = JSON.parse(text); + return data.message || text || `Request failed with status ${error.status}`; + } catch { + // Plain text response from the backend + return text || `Request failed with status ${error.status}`; + } } catch { return `Request failed with status ${error.status}`; } diff --git a/src/immich_api/mod.rs b/src/immich_api/mod.rs index 869c3e3..1fc637b 100644 --- a/src/immich_api/mod.rs +++ b/src/immich_api/mod.rs @@ -108,6 +108,32 @@ impl ImmichClient { }) } + /// Check an HTTP response for authentication/permission errors and return + /// a descriptive error. Call this before checking `is_success()`. + async fn check_response_error( + response: reqwest::Response, + operation: &str, + ) -> std::result::Result { + let status = response.status(); + if status.is_success() { + return Ok(response); + } + + let body = response.text().await.unwrap_or_default(); + + match status { + reqwest::StatusCode::UNAUTHORIZED => Err(Error::ImmichApi(format!( + "{operation}: invalid or expired API key (401)" + ))), + reqwest::StatusCode::FORBIDDEN => Err(Error::ImmichApi(format!( + "{operation}: missing permission (403). {body}" + ))), + _ => Err(Error::ImmichApi(format!( + "{operation}: HTTP {status}. {body}" + ))), + } + } + /// Sanitize the base URL to ensure it ends with /api. fn sanitize_base_url(url: &str) -> String { let trimmed = url.trim_end_matches('/'); @@ -130,16 +156,7 @@ impl ImmichClient { .send() .await?; - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - return Err(Error::ImmichApi("Invalid API key".to_string())); - } - - if !response.status().is_success() { - return Err(Error::ImmichApi(format!( - "Server returned status {}", - response.status() - ))); - } + let response = Self::check_response_error(response, "Validate connection").await?; let info: ServerInfo = response.json().await?; Ok(info) @@ -175,14 +192,7 @@ impl ImmichClient { .send() .await?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(Error::ImmichApi(format!( - "Search failed with status {}: {}", - status, body - ))); - } + let response = Self::check_response_error(response, "Search assets by person").await?; let search_response: SearchResponse = response.json().await?; all_assets.extend(search_response.assets.items); @@ -197,7 +207,7 @@ impl ImmichClient { } } - tracing::info!("Found {} assets for person {}", all_assets.len(), person_id); + tracing::debug!("Found {} assets for person {}", all_assets.len(), person_id); Ok(all_assets) } @@ -216,13 +226,7 @@ impl ImmichClient { .send() .await?; - if !response.status().is_success() { - return Err(Error::ImmichApi(format!( - "Failed to download asset {}: {}", - asset_id, - response.status() - ))); - } + let response = Self::check_response_error(response, "Download asset").await?; // Check Content-Length before downloading to reject unexpectedly large files if let Some(content_length) = response.content_length() { @@ -258,13 +262,7 @@ impl ImmichClient { .send() .await?; - if !response.status().is_success() { - return Err(Error::ImmichApi(format!( - "Failed to download preview for asset {}: {}", - asset_id, - response.status() - ))); - } + let response = Self::check_response_error(response, "Download asset preview").await?; let bytes = response.bytes().await?; Ok(bytes) @@ -281,12 +279,7 @@ impl ImmichClient { .send() .await?; - if !response.status().is_success() { - return Err(Error::ImmichApi(format!( - "Failed to get people: {}", - response.status() - ))); - } + let response = Self::check_response_error(response, "Get people").await?; #[derive(Deserialize)] struct PeopleResponse { @@ -311,13 +304,7 @@ impl ImmichClient { .send() .await?; - if !response.status().is_success() { - return Err(Error::ImmichApi(format!( - "Failed to get thumbnail for person {}: {}", - person_id, - response.status() - ))); - } + let response = Self::check_response_error(response, "Get person thumbnail").await?; let content_type = response .headers() diff --git a/src/job/mod.rs b/src/job/mod.rs index a4b6ce3..d4d4c08 100644 --- a/src/job/mod.rs +++ b/src/job/mod.rs @@ -401,15 +401,44 @@ async fn run_job_inner( // Get final skip statistics from atomic counters let final_skip_stats = skip_stats.snapshot(); - // Count results - let successful = results - .iter() - .filter(|r| matches!(r, AssetProcessResult::Success { .. })) - .count(); - let errors = results - .iter() - .filter(|r| matches!(r, AssetProcessResult::Error { .. })) - .count(); + // Count results and log errors (deduplicated) + let mut successful = 0usize; + let mut errors = 0usize; + let mut first_error: Option = None; + let mut all_same_error = true; + for result in &results { + match result { + AssetProcessResult::Success { .. } => successful += 1, + AssetProcessResult::Error { error, .. } => { + errors += 1; + match &first_error { + None => first_error = Some(error.clone()), + Some(first) if first != error => all_same_error = false, + _ => {} + } + } + _ => {} + } + } + if let Some(ref err) = first_error { + if all_same_error { + tracing::error!("{} assets failed with: {}", errors, err); + } else { + // Log distinct errors individually (up to 3) + let mut logged = 0; + for result in &results { + if let AssetProcessResult::Error { asset_id, error } = result { + if logged < 3 { + tracing::error!("Asset {} failed: {}", asset_id, error); + logged += 1; + } + } + } + if errors > 3 { + tracing::error!("... and {} more errors", errors - 3); + } + } + } let skipped = final_skip_stats.total(); tracing::info!( @@ -433,9 +462,13 @@ async fn run_job_inner( .await; if successful == 0 { - return Err(Error::ImageProcessing( - "No images were successfully processed".to_string(), - )); + let detail = first_error + .map(|e| format!(" First error: {}", e)) + .unwrap_or_default(); + return Err(Error::ImageProcessing(format!( + "No images were successfully processed ({} errors).{}", + errors, detail + ))); } // Check for cancellation before video compilation diff --git a/src/job/processing.rs b/src/job/processing.rs index 22dab1e..05743f9 100644 --- a/src/job/processing.rs +++ b/src/job/processing.rs @@ -120,7 +120,7 @@ pub async fn process_single_asset( skip_stats.increment("download_failed"); return AssetProcessResult::Error { asset_id: asset_id.clone(), - error: format!("Download failed: {}", e), + error: e.to_string(), }; } }; diff --git a/src/web/handlers/people.rs b/src/web/handlers/people.rs index 07eb97b..7e31d9d 100644 --- a/src/web/handlers/people.rs +++ b/src/web/handlers/people.rs @@ -29,12 +29,10 @@ pub async fn get_people( ) })?; - let people = client.get_people().await.map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to get people: {}", e), - ) - })?; + let people = client + .get_people() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let people_info: Vec = people .into_iter()