fix: adaptation structs Imago API v2 - ExifCamera/ExifGps/AiData/OcrData

- Corrige ExifData pour matcher l'API réelle (camera/gps/raw nestés)
- Corrige OcrData (has_text ajouté)
- Corrige AiData (confidence, model_used, processed_at, prompt/output_tokens)
- Supprime les champs obsolètes (category, url_summary, window_title)
- get_image expose maintenant les champs EXIF/GPS complets
- Plus d'erreur 'missing field tags' sur le list/detail
This commit is contained in:
2026-06-23 09:16:07 -04:00
parent e92da02067
commit 80fc4f6bce
2 changed files with 280 additions and 87 deletions
+100 -52
View File
@@ -1,6 +1,4 @@
//! Routes API REST du dashboard
//!
//! Phase 9: API complète avec proxy Imago + données locales
//! Routes API REST du dashboard — proxy vers Imago v2
use axum::{
Router, routing::get,
@@ -10,7 +8,7 @@ use axum::{
};
use serde::Deserialize;
use std::sync::Arc;
use crate::imago_client::ImagoClient;
use crate::imago_client::{ImagoClient, ImageSummary};
/// État partagé de l'application
pub struct AppState {
@@ -33,9 +31,26 @@ pub struct SearchQuery {
pub per_page: Option<usize>,
}
/// Convertit ImageSummary en JSON frontend-friendly
fn image_to_json(img: &ImageSummary) -> serde_json::Value {
serde_json::json!({
"id": img.id,
"uuid": img.uuid,
"filename": img.original_name,
"mime_type": img.mime_type,
"width": img.width,
"height": img.height,
"size_bytes": img.file_size,
"tags": img.ai_tags,
"description": img.ai_description,
"created_at": img.uploaded_at,
"status": img.processing_status,
"thumbnail_url": img.thumbnail_path,
})
}
// ─── Route builder ─────────────────────────────────────────────────────────
/// Construit le router API
pub fn api_routes(state: AppState) -> Router {
let state = Arc::new(state);
Router::new()
@@ -50,12 +65,10 @@ pub fn api_routes(state: AppState) -> Router {
// ─── Handlers ──────────────────────────────────────────────────────────────
/// Health check
async fn health_check() -> &'static str {
"OK"
}
/// Liste paginée des images
async fn list_images(
State(state): State<Arc<AppState>>,
Query(pagination): Query<Pagination>,
@@ -63,15 +76,14 @@ async fn list_images(
let page = pagination.page.unwrap_or(1).max(1);
let per_page = pagination.per_page.unwrap_or(24).min(100);
match state.imago.search("", per_page).await {
match state.imago.list_images(page, per_page).await {
Ok(response) => {
let total = response.total;
let images = response.results;
let images: Vec<serde_json::Value> = response.items.iter().map(image_to_json).collect();
Json(serde_json::json!({
"images": images,
"total": total,
"page": page,
"per_page": per_page
"total": response.total,
"page": response.page,
"per_page": response.page_size
}))
}
Err(e) => {
@@ -87,7 +99,6 @@ async fn list_images(
}
}
/// Recherche d'images
async fn search_images(
State(state): State<Arc<AppState>>,
Query(params): Query<SearchQuery>,
@@ -96,15 +107,15 @@ async fn search_images(
let page = params.page.unwrap_or(1).max(1);
let per_page = params.per_page.unwrap_or(24).min(100);
// Si la requête est vide, retourne toutes les images
if query.is_empty() {
match state.imago.search("", per_page).await {
match state.imago.list_images(page, per_page).await {
Ok(response) => {
let images: Vec<serde_json::Value> = response.items.iter().map(image_to_json).collect();
return Json(serde_json::json!({
"images": response.results,
"images": images,
"total": response.total,
"page": page,
"per_page": per_page
"page": response.page,
"per_page": response.page_size
}));
}
Err(e) => {
@@ -120,13 +131,14 @@ async fn search_images(
}
}
match state.imago.search(&query, per_page).await {
match state.imago.search(&query, per_page, page).await {
Ok(response) => {
let images: Vec<serde_json::Value> = response.items.iter().map(image_to_json).collect();
Json(serde_json::json!({
"images": response.results,
"images": images,
"total": response.total,
"page": page,
"per_page": per_page,
"page": response.page,
"per_page": response.page_size,
"query": query
}))
}
@@ -144,77 +156,113 @@ async fn search_images(
}
}
/// Détails d'une image
async fn get_image(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
match state.imago.get_image(&id).await {
Ok(info) => Json(serde_json::json!(info)),
let img_id: i64 = match id.parse() {
Ok(n) => n,
Err(_) => {
return Json(serde_json::json!({
"error": "ID invalide", "id": id
}));
}
};
match state.imago.get_image(img_id).await {
Ok(detail) => {
Json(serde_json::json!({
"id": detail.id,
"uuid": detail.uuid,
"filename": detail.original_name,
"status": detail.processing_status,
"width": detail.width,
"height": detail.height,
"size_bytes": detail.file_size,
"tags": detail.ai.as_ref().and_then(|a| a.tags.clone()),
"description": detail.ai.as_ref().and_then(|a| a.description.clone()),
"ocr_text": detail.ocr.as_ref().and_then(|o| o.text.clone()),
"exif": {
"camera": detail.exif.as_ref().and_then(|e| e.camera.as_ref().map(|c| c.model.clone())).flatten(),
"lens": detail.exif.as_ref().and_then(|e| e.camera.as_ref().and_then(|c| c.lens.clone())),
"iso": detail.exif.as_ref().and_then(|e| e.camera.as_ref().and_then(|c| c.iso)),
"aperture": detail.exif.as_ref().and_then(|e| e.camera.as_ref().and_then(|c| c.aperture)),
"shutter": detail.exif.as_ref().and_then(|e| e.camera.as_ref().and_then(|c| c.shutter_speed.clone())),
"focal_length": detail.exif.as_ref().and_then(|e| e.camera.as_ref().and_then(|c| c.focal_length.clone())),
"gps": detail.exif.as_ref().and_then(|e| e.gps.as_ref().map(|g| {
serde_json::json!({"lat": g.latitude, "lon": g.longitude})
})),
"taken_at": detail.exif.as_ref().and_then(|e| e.camera.as_ref().and_then(|c| c.taken_at.clone()))
},
"created_at": detail.uploaded_at,
}))
}
Err(e) => {
tracing::warn!("Image non trouvée (id={}) : {:#}", id, e);
Json(serde_json::json!({
"error": "Image non trouvée",
"id": id
"error": "Image non trouvée", "id": id
}))
}
}
}
/// Thumbnail / image complète
async fn get_thumbnail(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
let full = params.get("full").map(|v| v == "1").unwrap_or(false);
let img_url = if full {
format!("{}/images/{}/file", state.imago.base_url, id)
} else {
format!("{}/images/{}/thumbnail", state.imago.base_url, id)
let img_id: i64 = match id.parse() {
Ok(n) => n,
Err(_) => {
return axum::http::Response::builder()
.status(axum::http::StatusCode::BAD_REQUEST)
.header("Content-Type", "text/plain")
.body(axum::body::Body::from("ID invalide")).unwrap();
}
};
match state.imago.get_raw(&img_url).await {
let full = params.get("full").map(|v| v == "1").unwrap_or(false);
let result = if full {
state.imago.get_file(img_id).await
} else {
state.imago.get_thumbnail(img_id).await
};
match result {
Ok((data, content_type)) => {
axum::http::Response::builder()
.header("Content-Type", content_type)
.header("Cache-Control", "public, max-age=3600")
.body(axum::body::Body::from(data))
.unwrap()
.body(axum::body::Body::from(data)).unwrap()
}
Err(e) => {
tracing::warn!("Thumbnail indisponible (id={}) : {:#}", id, e);
axum::http::Response::builder()
.status(axum::http::StatusCode::NOT_FOUND)
.header("Content-Type", "text/plain")
.body(axum::body::Body::from("Thumbnail non disponible"))
.unwrap()
.body(axum::body::Body::from("Thumbnail non disponible")).unwrap()
}
}
}
/// Statistiques du dashboard
async fn stats(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let (total_images, total_storage, unique_tags) = match state.imago.search("", 1).await {
let (total_images, storage_mb, unique_tags) = match state.imago.list_images(1, 1).await {
Ok(response) => {
let total = response.total;
let storage = total as u64 * 250_000;
let tags = if !response.results.is_empty() {
response.results[0].tags.len().saturating_mul(total).saturating_div(2)
} else {
0
};
(total, storage, tags)
let tags = if !response.items.is_empty() {
response.items[0].ai_tags.as_ref().map(|t| t.len()).unwrap_or(0)
.saturating_mul(response.total).saturating_div(2)
} else { 0 };
(response.total, response.storage_used_mb.unwrap_or(0.0), tags)
}
Err(_) => (0, 0, 0),
Err(_) => (0, 0.0, 0),
};
Json(serde_json::json!({
"total_images": total_images,
"total_storage_bytes": total_storage,
"total_storage_bytes": (storage_mb * 1_048_576.0) as u64,
"unique_tags": unique_tags,
"recent_uploads": 0,
"ws_clients": state.ws_hub.client_count(),