More useful error messages
This commit is contained in:
parent
7121d4fa2f
commit
024a11c1de
7 changed files with 104 additions and 72 deletions
|
|
@ -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.*
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue