From 80fc4f6bceda8081326b26b944a2948dbc72c3a7 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 23 Jun 2026 09:16:07 -0400 Subject: [PATCH] fix: adaptation structs Imago API v2 - ExifCamera/ExifGps/AiData/OcrData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/imago_client.rs | 215 ++++++++++++++++++++++++++++++++++++------- src/server/routes.rs | 152 +++++++++++++++++++----------- 2 files changed, 280 insertions(+), 87 deletions(-) diff --git a/src/imago_client.rs b/src/imago_client.rs index 2425998..4dbef79 100644 --- a/src/imago_client.rs +++ b/src/imago_client.rs @@ -1,36 +1,139 @@ -//! Client HTTP pour l'API Imago +//! Client HTTP pour l'API Imago v2 +//! +//! API base: /api/v1/images +//! Auth: X-API-Key header use anyhow::Result; use reqwest::{Client, multipart}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Deserialize)] +// ─── Response types ──────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize, Serialize)] pub struct UploadResponse { - pub id: String, - pub filename: String, + pub id: i64, + pub uuid: String, + pub original_name: String, pub status: String, + pub message: String, +} + +/// Vue allégée pour les listes (ImageSummary) +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ImageSummary { + pub id: i64, + pub uuid: String, + pub original_name: String, + pub mime_type: Option, + pub file_size: Option, + pub width: Option, + pub height: Option, + pub uploaded_at: Option, + pub processing_status: Option, + pub client_id: Option, + pub client_name: Option, + pub ai_tags: Option>, + pub ai_description: Option, + pub thumbnail_path: Option, +} + +/// Vue détaillée (ImageDetail) +#[derive(Debug, Deserialize, Serialize)] +pub struct ImageDetail { + // ImageBase + pub id: i64, + pub uuid: String, + pub original_name: String, + pub mime_type: Option, + pub file_size: Option, + pub width: Option, + pub height: Option, + pub uploaded_at: Option, + pub processing_status: Option, + pub client_id: Option, + pub client_name: Option, + // Nested objects + pub exif: Option, + pub ocr: Option, + pub ai: Option, + pub processing: Option, } #[derive(Debug, Deserialize, Serialize)] -pub struct ImageInfo { - pub id: String, - pub filename: String, - pub tags: Vec, +pub struct ExifCamera { + pub make: Option, + pub model: Option, + pub lens: Option, + pub iso: Option, + pub aperture: Option, + pub shutter_speed: Option, + pub focal_length: Option, + pub flash: Option, + pub orientation: Option, + pub software: Option, + pub taken_at: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ExifGps { + pub latitude: Option, + pub longitude: Option, + pub altitude: Option, + pub has_gps: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ExifData { + pub camera: Option, + pub gps: Option, + #[serde(default)] + pub raw: serde_json::Value, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct OcrData { + pub text: Option, + pub language: Option, + pub confidence: Option, + pub has_text: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct AiData { pub description: Option, - pub ocr_text: Option, - pub created_at: String, - pub width: Option, - pub height: Option, - pub size_bytes: Option, + pub tags: Option>, + pub confidence: Option, + pub model_used: Option, + pub processed_at: Option, + pub prompt_tokens: Option, + pub output_tokens: Option, } -#[derive(Debug, Deserialize)] -pub struct SearchResponse { - pub results: Vec, +#[derive(Debug, Deserialize, Serialize)] +pub struct ProcessingInfo { + pub status: Option, + pub error: Option, + pub started_at: Option, + pub done_at: Option, +} + +/// Réponse paginée +#[derive(Debug, Deserialize, Serialize)] +pub struct PaginatedImages { pub total: usize, + pub page: usize, + pub page_size: usize, + pub pages: usize, + pub items: Vec, + pub storage_used_mb: Option, + pub storage_quota_mb: Option, + pub quota_pct: Option, } -/// Client pour l'API Imago +// ─── Client ──────────────────────────────────────────────────────────────── + +/// Client pour l'API Imago v2 +#[derive(Clone)] pub struct ImagoClient { client: Client, pub base_url: String, @@ -38,6 +141,7 @@ pub struct ImagoClient { } impl ImagoClient { + /// Crée un nouveau client Imago pub fn new(base_url: String, api_key: String) -> Result { let client = Client::builder() .timeout(std::time::Duration::from_secs(30)) @@ -45,6 +149,11 @@ impl ImagoClient { Ok(Self { client, base_url, api_key }) } + /// Construit l'URL de base pour l'API images + fn images_base(&self) -> String { + format!("{}/api/v1/images", self.base_url.trim_end_matches('/')) + } + /// Upload une image vers Imago pub async fn upload_image( &self, @@ -64,8 +173,8 @@ impl ImagoClient { } let response = self.client - .post(format!("{}/images/upload", self.base_url)) - .header("Authorization", format!("Bearer {}", self.api_key)) + .post(format!("{}/upload", self.images_base())) + .header("X-API-Key", &self.api_key) .multipart(form) .send() .await?; @@ -78,16 +187,16 @@ impl ImagoClient { Ok(upload) } - /// Recherche des images - pub async fn search(&self, query: &str, limit: usize) -> Result { + /// Liste les images (paginated) + pub async fn list_images(&self, page: usize, limit: usize) -> Result { let response = self.client - .get(format!("{}/images/search", self.base_url)) - .header("Authorization", format!("Bearer {}", self.api_key)) - .query(&[("q", query), ("limit", &limit.to_string())]) + .get(self.images_base()) + .header("X-API-Key", &self.api_key) + .query(&[("page", &page.to_string()), ("page_size", &limit.to_string())]) .send() .await?; - let result: SearchResponse = response + let result: PaginatedImages = response .error_for_status()? .json() .await?; @@ -95,27 +204,44 @@ impl ImagoClient { Ok(result) } - /// Récupère les infos d'une image - pub async fn get_image(&self, id: &str) -> Result { + /// Recherche des images + pub async fn search(&self, query: &str, limit: usize, page: usize) -> Result { let response = self.client - .get(format!("{}/images/{}", self.base_url, id)) - .header("Authorization", format!("Bearer {}", self.api_key)) + .get(self.images_base()) + .header("X-API-Key", &self.api_key) + .query(&[("search", query), ("page_size", &limit.to_string()), ("page", &page.to_string())]) .send() .await?; - let info: ImageInfo = response + let result: PaginatedImages = response .error_for_status()? .json() .await?; - Ok(info) + Ok(result) } - /// Récupère des données brutes (image, thumbnail) depuis une URL Imago - pub async fn get_raw(&self, url: &str) -> Result<(Vec, String)> { + /// Récupère les détails d'une image + pub async fn get_image(&self, id: i64) -> Result { let response = self.client - .get(url) - .header("Authorization", format!("Bearer {}", self.api_key)) + .get(format!("{}/{}", self.images_base(), id)) + .header("X-API-Key", &self.api_key) + .send() + .await?; + + let detail: ImageDetail = response + .error_for_status()? + .json() + .await?; + + Ok(detail) + } + + /// Récupère le thumbnail d'une image (raw bytes) + pub async fn get_thumbnail(&self, id: i64) -> Result<(Vec, String)> { + let response = self.client + .get(format!("{}/{}/thumbnail", self.images_base(), id)) + .header("X-API-Key", &self.api_key) .send() .await? .error_for_status()?; @@ -128,7 +254,26 @@ impl ImagoClient { .to_string(); let data = response.bytes().await?.to_vec(); + Ok((data, content_type)) + } + /// Récupère le fichier original (raw bytes) + pub async fn get_file(&self, id: i64) -> Result<(Vec, String)> { + let response = self.client + .get(format!("{}/{}/file", self.images_base(), id)) + .header("X-API-Key", &self.api_key) + .send() + .await? + .error_for_status()?; + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("image/png") + .to_string(); + + let data = response.bytes().await?.to_vec(); Ok((data, content_type)) } } diff --git a/src/server/routes.rs b/src/server/routes.rs index fb7cc3a..0f9a633 100644 --- a/src/server/routes.rs +++ b/src/server/routes.rs @@ -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, } +/// 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>, Query(pagination): Query, @@ -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 = 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>, Query(params): Query, @@ -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 = 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 = 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>, Path(id): Path, ) -> 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>, Path(id): Path, Query(params): Query>, ) -> 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>, ) -> 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(),