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
|
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:
|
Here are the features that need to be enabled:
|
||||||
- server.about
|
- `asset.download`
|
||||||
- asset.download
|
- `asset.read`
|
||||||
- asset.read
|
- `asset.view`
|
||||||
- person.read
|
- `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.*
|
*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">
|
<section class="not-connected">
|
||||||
<p>Connect to your Immich server to get started.</p>
|
<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">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>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,18 @@
|
||||||
* @returns {Promise<string>} Error message
|
* @returns {Promise<string>} Error message
|
||||||
*/
|
*/
|
||||||
export async function parseError(error) {
|
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) {
|
if (error instanceof Response) {
|
||||||
try {
|
try {
|
||||||
const data = await error.json();
|
const text = await error.text();
|
||||||
return data.message || `Request failed with status ${error.status}`;
|
// 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 {
|
} catch {
|
||||||
return `Request failed with status ${error.status}`;
|
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.
|
/// Sanitize the base URL to ensure it ends with /api.
|
||||||
fn sanitize_base_url(url: &str) -> String {
|
fn sanitize_base_url(url: &str) -> String {
|
||||||
let trimmed = url.trim_end_matches('/');
|
let trimmed = url.trim_end_matches('/');
|
||||||
|
|
@ -130,16 +156,7 @@ impl ImmichClient {
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
let response = Self::check_response_error(response, "Validate connection").await?;
|
||||||
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 info: ServerInfo = response.json().await?;
|
let info: ServerInfo = response.json().await?;
|
||||||
Ok(info)
|
Ok(info)
|
||||||
|
|
@ -175,14 +192,7 @@ impl ImmichClient {
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
let response = Self::check_response_error(response, "Search assets by person").await?;
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
return Err(Error::ImmichApi(format!(
|
|
||||||
"Search failed with status {}: {}",
|
|
||||||
status, body
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let search_response: SearchResponse = response.json().await?;
|
let search_response: SearchResponse = response.json().await?;
|
||||||
all_assets.extend(search_response.assets.items);
|
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)
|
Ok(all_assets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,13 +226,7 @@ impl ImmichClient {
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
let response = Self::check_response_error(response, "Download asset").await?;
|
||||||
return Err(Error::ImmichApi(format!(
|
|
||||||
"Failed to download asset {}: {}",
|
|
||||||
asset_id,
|
|
||||||
response.status()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Content-Length before downloading to reject unexpectedly large files
|
// Check Content-Length before downloading to reject unexpectedly large files
|
||||||
if let Some(content_length) = response.content_length() {
|
if let Some(content_length) = response.content_length() {
|
||||||
|
|
@ -258,13 +262,7 @@ impl ImmichClient {
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
let response = Self::check_response_error(response, "Download asset preview").await?;
|
||||||
return Err(Error::ImmichApi(format!(
|
|
||||||
"Failed to download preview for asset {}: {}",
|
|
||||||
asset_id,
|
|
||||||
response.status()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = response.bytes().await?;
|
let bytes = response.bytes().await?;
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
|
|
@ -281,12 +279,7 @@ impl ImmichClient {
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
let response = Self::check_response_error(response, "Get people").await?;
|
||||||
return Err(Error::ImmichApi(format!(
|
|
||||||
"Failed to get people: {}",
|
|
||||||
response.status()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct PeopleResponse {
|
struct PeopleResponse {
|
||||||
|
|
@ -311,13 +304,7 @@ impl ImmichClient {
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
let response = Self::check_response_error(response, "Get person thumbnail").await?;
|
||||||
return Err(Error::ImmichApi(format!(
|
|
||||||
"Failed to get thumbnail for person {}: {}",
|
|
||||||
person_id,
|
|
||||||
response.status()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let content_type = response
|
let content_type = response
|
||||||
.headers()
|
.headers()
|
||||||
|
|
|
||||||
|
|
@ -401,15 +401,44 @@ async fn run_job_inner(
|
||||||
// Get final skip statistics from atomic counters
|
// Get final skip statistics from atomic counters
|
||||||
let final_skip_stats = skip_stats.snapshot();
|
let final_skip_stats = skip_stats.snapshot();
|
||||||
|
|
||||||
// Count results
|
// Count results and log errors (deduplicated)
|
||||||
let successful = results
|
let mut successful = 0usize;
|
||||||
.iter()
|
let mut errors = 0usize;
|
||||||
.filter(|r| matches!(r, AssetProcessResult::Success { .. }))
|
let mut first_error: Option<String> = None;
|
||||||
.count();
|
let mut all_same_error = true;
|
||||||
let errors = results
|
for result in &results {
|
||||||
.iter()
|
match result {
|
||||||
.filter(|r| matches!(r, AssetProcessResult::Error { .. }))
|
AssetProcessResult::Success { .. } => successful += 1,
|
||||||
.count();
|
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();
|
let skipped = final_skip_stats.total();
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|
@ -433,9 +462,13 @@ async fn run_job_inner(
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if successful == 0 {
|
if successful == 0 {
|
||||||
return Err(Error::ImageProcessing(
|
let detail = first_error
|
||||||
"No images were successfully processed".to_string(),
|
.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
|
// Check for cancellation before video compilation
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ pub async fn process_single_asset(
|
||||||
skip_stats.increment("download_failed");
|
skip_stats.increment("download_failed");
|
||||||
return AssetProcessResult::Error {
|
return AssetProcessResult::Error {
|
||||||
asset_id: asset_id.clone(),
|
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| {
|
let people = client
|
||||||
(
|
.get_people()
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
.await
|
||||||
format!("Failed to get people: {}", e),
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let people_info: Vec<PersonInfo> = people
|
let people_info: Vec<PersonInfo> = people
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue