More useful error messages

This commit is contained in:
Arnaud_Cayrol 2026-02-14 15:09:37 +01:00
parent 7121d4fa2f
commit 024a11c1de
7 changed files with 104 additions and 72 deletions

View file

@ -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.*

View file

@ -268,6 +268,12 @@
<section class="not-connected">
<p>Connect to your Immich server to get started.</p>
<p class="hint">Make sure the backend is running and configured with your Immich API credentials.</p>
<p class="hint">
You need an Immich API key with the following permissions:
<code>asset.download</code>, <code>asset.read</code>, <code>asset.view</code>, <code>person.read</code>, <code>server.about</code>.
<br />
See the <a href="https://immich.app/docs/features/command-line-interface#obtain-the-api-key" target="_blank" rel="noopener noreferrer">Immich docs</a> to create one.
</p>
</section>
{/if}
</main>

View file

@ -9,11 +9,18 @@
* @returns {Promise<string>} 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}`;
}

View file

@ -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<reqwest::Response, Error> {
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()

View file

@ -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<String> = 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

View file

@ -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(),
};
}
};

View file

@ -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<PersonInfo> = people
.into_iter()