//! 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}; // ─── Response types ──────────────────────────────────────────────────────── #[derive(Debug, Deserialize, Serialize)] pub struct UploadResponse { 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 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 tags: Option>, pub confidence: Option, pub model_used: Option, pub processed_at: Option, pub prompt_tokens: Option, pub output_tokens: Option, } #[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 ──────────────────────────────────────────────────────────────── /// Client pour l'API Imago v2 #[derive(Clone)] pub struct ImagoClient { client: Client, pub base_url: String, api_key: String, } 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)) .build()?; 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, data: Vec, filename: &str, window_title: Option<&str>, ) -> Result { let part = multipart::Part::bytes(data) .file_name(filename.to_string()) .mime_str("image/png")?; let mut form = multipart::Form::new() .part("file", part); if let Some(title) = window_title { form = form.text("window_title", title.to_string()); } let response = self.client .post(format!("{}/upload", self.images_base())) .header("X-API-Key", &self.api_key) .multipart(form) .send() .await?; let upload: UploadResponse = response .error_for_status()? .json() .await?; Ok(upload) } /// Liste les images (paginated) pub async fn list_images(&self, page: usize, limit: usize) -> Result { let response = self.client .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: PaginatedImages = response .error_for_status()? .json() .await?; Ok(result) } /// Recherche des images pub async fn search(&self, query: &str, limit: usize, page: usize) -> Result { let response = self.client .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 result: PaginatedImages = response .error_for_status()? .json() .await?; Ok(result) } /// Récupère les détails d'une image pub async fn get_image(&self, id: i64) -> Result { let response = self.client .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 via URL signée pub async fn get_thumbnail(&self, id: i64) -> Result<(Vec, String)> { // Étape 1 : obtenir l'URL signée let sign_response: serde_json::Value = self.client .get(format!("{}/{}/thumbnail-url", self.images_base(), id)) .header("X-API-Key", &self.api_key) .send() .await? .error_for_status()? .json() .await?; let signed_url = sign_response["url"] .as_str() .ok_or_else(|| anyhow::anyhow!("URL signée manquante dans la réponse"))?; // Si c'est un placeholder (pas de thumbnail), retourner une erreur propre if signed_url.contains("placeholder") { return Err(anyhow::anyhow!("Thumbnail non disponible (placeholder)")); } // Étape 2 : télécharger depuis l'URL signée let full_url = format!("{}{}", self.base_url.trim_end_matches('/'), signed_url); let response = self.client .get(&full_url) .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)) } /// Récupère le fichier original via URL signée pub async fn get_file(&self, id: i64) -> Result<(Vec, String)> { // Étape 1 : obtenir l'URL signée let sign_response: serde_json::Value = self.client .get(format!("{}/{}/download-url", self.images_base(), id)) .header("X-API-Key", &self.api_key) .send() .await? .error_for_status()? .json() .await?; let signed_url = sign_response["url"] .as_str() .ok_or_else(|| anyhow::anyhow!("URL signée manquante dans la réponse"))?; // Étape 2 : télécharger depuis l'URL signée let full_url = format!("{}{}", self.base_url.trim_end_matches('/'), signed_url); let response = self.client .get(&full_url) .send() .await? .error_for_status()?; let content_type = response .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or("application/octet-stream") .to_string(); let data = response.bytes().await?.to_vec(); Ok((data, content_type)) } /// Supprime une image (nécessite le scope images:delete) pub async fn delete_image(&self, id: i64) -> Result<()> { self.client .delete(format!("{}/{}", self.images_base(), id)) .header("X-API-Key", &self.api_key) .send() .await? .error_for_status()?; Ok(()) } /// Met à jour les tags d'une image pub async fn update_tags(&self, id: i64, tags: Vec) -> Result<()> { self.client .put(format!("{}/{}/tags", self.images_base(), id)) .header("X-API-Key", &self.api_key) .json(&serde_json::json!({"tags": tags})) .send() .await? .error_for_status()?; Ok(()) } }