refactor: Phase B — fallback CBR factorisé, erreurs DB typées, logging silent errors

- Extrait is_not_rar_error() + open_cbr_listing() dans parsers, simplifie 4 fonctions CBR
- Harmonise extract_cbr_page pour vérifier l'erreur comme les 3 autres (était incohérent)
- Améliore From<sqlx::Error>: RowNotFound→404, PoolTimedOut→503, contraintes→400
- Remplace 5 let _ = par if let Err(e) avec warn! dans analyzer.rs (status/progress updates)
- 15 nouveaux tests (parsers: is_not_rar, detect_format, is_image_name; API: sqlx error mapping)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 12:08:10 +02:00
parent 38a0f56328
commit 4133d406e1
3 changed files with 170 additions and 40 deletions

View File

@@ -74,7 +74,25 @@ impl IntoResponse for ApiError {
impl From<sqlx::Error> for ApiError {
fn from(err: sqlx::Error) -> Self {
Self::internal(format!("database error: {err}"))
match &err {
sqlx::Error::RowNotFound => Self::not_found("resource not found"),
sqlx::Error::PoolTimedOut => Self {
status: StatusCode::SERVICE_UNAVAILABLE,
message: "database pool timed out".to_string(),
},
sqlx::Error::Database(db_err) => {
// PostgreSQL unique_violation = 23505, foreign_key_violation = 23503
let code = db_err.code().unwrap_or_default();
if code == "23505" {
Self::bad_request(format!("duplicate entry: {}", db_err.message()))
} else if code == "23503" {
Self::bad_request(format!("foreign key violation: {}", db_err.message()))
} else {
Self::internal(format!("database error: {err}"))
}
}
_ => Self::internal(format!("database error: {err}")),
}
}
}
@@ -89,3 +107,36 @@ impl From<reqwest::Error> for ApiError {
Self::internal(format!("HTTP client error: {err}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sqlx_row_not_found_maps_to_404() {
let err: ApiError = sqlx::Error::RowNotFound.into();
assert_eq!(err.status, StatusCode::NOT_FOUND);
}
#[test]
fn sqlx_pool_timeout_maps_to_503() {
let err: ApiError = sqlx::Error::PoolTimedOut.into();
assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn sqlx_other_error_maps_to_500() {
let err: ApiError = sqlx::Error::PoolClosed.into();
assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn api_error_constructors() {
assert_eq!(ApiError::bad_request("x").status, StatusCode::BAD_REQUEST);
assert_eq!(ApiError::not_found("x").status, StatusCode::NOT_FOUND);
assert_eq!(ApiError::unauthorized("x").status, StatusCode::UNAUTHORIZED);
assert_eq!(ApiError::forbidden("x").status, StatusCode::FORBIDDEN);
assert_eq!(ApiError::internal("x").status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(ApiError::unprocessable_entity("x").status, StatusCode::UNPROCESSABLE_ENTITY);
}
}

View File

@@ -396,13 +396,15 @@ pub async fn analyze_library_books(
const BATCH_SIZE: usize = 200;
let phase_a_start = std::time::Instant::now();
let _ = sqlx::query(
if let Err(e) = sqlx::query(
"UPDATE index_jobs SET status = 'extracting_pages', total_files = $2, processed_files = 0, current_file = NULL WHERE id = $1",
)
.bind(job_id)
.bind(total)
.execute(&state.pool)
.await;
.await {
warn!("[ANALYZER] Failed to update job status to extracting_pages: {e}");
}
let extracted_count = Arc::new(AtomicI32::new(0));
let mut all_extracted: Vec<(Uuid, String, i32)> = Vec::new();
@@ -538,14 +540,16 @@ pub async fn analyze_library_books(
}
let processed = extracted_count.fetch_add(1, Ordering::Relaxed) + 1;
let percent = (processed as f64 / total as f64 * 50.0) as i32;
let _ = sqlx::query(
if let Err(e) = sqlx::query(
"UPDATE index_jobs SET processed_files = $2, progress_percent = $3 WHERE id = $1",
)
.bind(job_id)
.bind(processed)
.bind(percent)
.execute(&pool)
.await;
.await {
warn!("[ANALYZER] Failed to update job progress: {e}");
}
if processed % 25 == 0 || processed == total {
info!(
@@ -588,14 +592,16 @@ pub async fn analyze_library_books(
let processed = extracted_count.fetch_add(1, Ordering::Relaxed) + 1;
let percent = (processed as f64 / total as f64 * 50.0) as i32; // first 50%
let _ = sqlx::query(
if let Err(e) = sqlx::query(
"UPDATE index_jobs SET processed_files = $2, progress_percent = $3 WHERE id = $1",
)
.bind(job_id)
.bind(processed)
.bind(percent)
.execute(&pool)
.await;
.await {
warn!("[ANALYZER] Failed to update job progress: {e}");
}
if processed % 25 == 0 || processed == total {
info!(
@@ -649,13 +655,15 @@ pub async fn analyze_library_books(
// CPU bound — can run at higher concurrency than I/O phase
// -------------------------------------------------------------------------
let phase_b_start = std::time::Instant::now();
let _ = sqlx::query(
if let Err(e) = sqlx::query(
"UPDATE index_jobs SET status = 'generating_thumbnails', generating_thumbnails_started_at = NOW(), total_files = $2, processed_files = 0, current_file = NULL WHERE id = $1",
)
.bind(job_id)
.bind(extracted_total)
.execute(&state.pool)
.await;
.await {
warn!("[ANALYZER] Failed to update job status to generating_thumbnails: {e}");
}
let resize_count = Arc::new(AtomicI32::new(0));
@@ -706,14 +714,16 @@ pub async fn analyze_library_books(
let processed = resize_count.fetch_add(1, Ordering::Relaxed) + 1;
let percent =
50 + (processed as f64 / extracted_total as f64 * 50.0) as i32; // last 50%
let _ = sqlx::query(
if let Err(e) = sqlx::query(
"UPDATE index_jobs SET processed_files = $2, progress_percent = $3 WHERE id = $1",
)
.bind(job_id)
.bind(processed)
.bind(percent)
.execute(&pool)
.await;
.await {
warn!("[ANALYZER] Failed to update job progress: {e}");
}
if processed % 25 == 0 || processed == extracted_total {
info!(