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:
+180
-35
@@ -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 anyhow::Result;
|
||||||
use reqwest::{Client, multipart};
|
use reqwest::{Client, multipart};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
// ─── Response types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct UploadResponse {
|
pub struct UploadResponse {
|
||||||
pub id: String,
|
pub id: i64,
|
||||||
pub filename: String,
|
pub uuid: String,
|
||||||
|
pub original_name: String,
|
||||||
pub status: 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<String>,
|
||||||
|
pub file_size: Option<i64>,
|
||||||
|
pub width: Option<i32>,
|
||||||
|
pub height: Option<i32>,
|
||||||
|
pub uploaded_at: Option<String>,
|
||||||
|
pub processing_status: Option<String>,
|
||||||
|
pub client_id: Option<String>,
|
||||||
|
pub client_name: Option<String>,
|
||||||
|
pub ai_tags: Option<Vec<String>>,
|
||||||
|
pub ai_description: Option<String>,
|
||||||
|
pub thumbnail_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
pub file_size: Option<i64>,
|
||||||
|
pub width: Option<i32>,
|
||||||
|
pub height: Option<i32>,
|
||||||
|
pub uploaded_at: Option<String>,
|
||||||
|
pub processing_status: Option<String>,
|
||||||
|
pub client_id: Option<String>,
|
||||||
|
pub client_name: Option<String>,
|
||||||
|
// Nested objects
|
||||||
|
pub exif: Option<ExifData>,
|
||||||
|
pub ocr: Option<OcrData>,
|
||||||
|
pub ai: Option<AiData>,
|
||||||
|
pub processing: Option<ProcessingInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct ImageInfo {
|
pub struct ExifCamera {
|
||||||
pub id: String,
|
pub make: Option<String>,
|
||||||
pub filename: String,
|
pub model: Option<String>,
|
||||||
pub tags: Vec<String>,
|
pub lens: Option<String>,
|
||||||
|
pub iso: Option<i32>,
|
||||||
|
pub aperture: Option<f64>,
|
||||||
|
pub shutter_speed: Option<String>,
|
||||||
|
pub focal_length: Option<String>,
|
||||||
|
pub flash: Option<bool>,
|
||||||
|
pub orientation: Option<String>,
|
||||||
|
pub software: Option<String>,
|
||||||
|
pub taken_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ExifGps {
|
||||||
|
pub latitude: Option<f64>,
|
||||||
|
pub longitude: Option<f64>,
|
||||||
|
pub altitude: Option<f64>,
|
||||||
|
pub has_gps: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ExifData {
|
||||||
|
pub camera: Option<ExifCamera>,
|
||||||
|
pub gps: Option<ExifGps>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub raw: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct OcrData {
|
||||||
|
pub text: Option<String>,
|
||||||
|
pub language: Option<String>,
|
||||||
|
pub confidence: Option<f64>,
|
||||||
|
pub has_text: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct AiData {
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub ocr_text: Option<String>,
|
pub tags: Option<Vec<String>>,
|
||||||
pub created_at: String,
|
pub confidence: Option<f64>,
|
||||||
pub width: Option<u32>,
|
pub model_used: Option<String>,
|
||||||
pub height: Option<u32>,
|
pub processed_at: Option<String>,
|
||||||
pub size_bytes: Option<u64>,
|
pub prompt_tokens: Option<i64>,
|
||||||
|
pub output_tokens: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct SearchResponse {
|
pub struct ProcessingInfo {
|
||||||
pub results: Vec<ImageInfo>,
|
pub status: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub started_at: Option<String>,
|
||||||
|
pub done_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réponse paginée
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct PaginatedImages {
|
||||||
pub total: usize,
|
pub total: usize,
|
||||||
|
pub page: usize,
|
||||||
|
pub page_size: usize,
|
||||||
|
pub pages: usize,
|
||||||
|
pub items: Vec<ImageSummary>,
|
||||||
|
pub storage_used_mb: Option<f64>,
|
||||||
|
pub storage_quota_mb: Option<i64>,
|
||||||
|
pub quota_pct: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Client pour l'API Imago
|
// ─── Client ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Client pour l'API Imago v2
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct ImagoClient {
|
pub struct ImagoClient {
|
||||||
client: Client,
|
client: Client,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
@@ -38,6 +141,7 @@ pub struct ImagoClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ImagoClient {
|
impl ImagoClient {
|
||||||
|
/// Crée un nouveau client Imago
|
||||||
pub fn new(base_url: String, api_key: String) -> Result<Self> {
|
pub fn new(base_url: String, api_key: String) -> Result<Self> {
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
@@ -45,6 +149,11 @@ impl ImagoClient {
|
|||||||
Ok(Self { client, base_url, api_key })
|
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
|
/// Upload une image vers Imago
|
||||||
pub async fn upload_image(
|
pub async fn upload_image(
|
||||||
&self,
|
&self,
|
||||||
@@ -64,8 +173,8 @@ impl ImagoClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let response = self.client
|
let response = self.client
|
||||||
.post(format!("{}/images/upload", self.base_url))
|
.post(format!("{}/upload", self.images_base()))
|
||||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
.header("X-API-Key", &self.api_key)
|
||||||
.multipart(form)
|
.multipart(form)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
@@ -78,16 +187,16 @@ impl ImagoClient {
|
|||||||
Ok(upload)
|
Ok(upload)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recherche des images
|
/// Liste les images (paginated)
|
||||||
pub async fn search(&self, query: &str, limit: usize) -> Result<SearchResponse> {
|
pub async fn list_images(&self, page: usize, limit: usize) -> Result<PaginatedImages> {
|
||||||
let response = self.client
|
let response = self.client
|
||||||
.get(format!("{}/images/search", self.base_url))
|
.get(self.images_base())
|
||||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
.header("X-API-Key", &self.api_key)
|
||||||
.query(&[("q", query), ("limit", &limit.to_string())])
|
.query(&[("page", &page.to_string()), ("page_size", &limit.to_string())])
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let result: SearchResponse = response
|
let result: PaginatedImages = response
|
||||||
.error_for_status()?
|
.error_for_status()?
|
||||||
.json()
|
.json()
|
||||||
.await?;
|
.await?;
|
||||||
@@ -95,27 +204,44 @@ impl ImagoClient {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère les infos d'une image
|
/// Recherche des images
|
||||||
pub async fn get_image(&self, id: &str) -> Result<ImageInfo> {
|
pub async fn search(&self, query: &str, limit: usize, page: usize) -> Result<PaginatedImages> {
|
||||||
let response = self.client
|
let response = self.client
|
||||||
.get(format!("{}/images/{}", self.base_url, id))
|
.get(self.images_base())
|
||||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
.header("X-API-Key", &self.api_key)
|
||||||
|
.query(&[("search", query), ("page_size", &limit.to_string()), ("page", &page.to_string())])
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let info: ImageInfo = response
|
let result: PaginatedImages = response
|
||||||
.error_for_status()?
|
.error_for_status()?
|
||||||
.json()
|
.json()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(info)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère des données brutes (image, thumbnail) depuis une URL Imago
|
/// Récupère les détails d'une image
|
||||||
pub async fn get_raw(&self, url: &str) -> Result<(Vec<u8>, String)> {
|
pub async fn get_image(&self, id: i64) -> Result<ImageDetail> {
|
||||||
let response = self.client
|
let response = self.client
|
||||||
.get(url)
|
.get(format!("{}/{}", self.images_base(), id))
|
||||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
.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<u8>, String)> {
|
||||||
|
let response = self.client
|
||||||
|
.get(format!("{}/{}/thumbnail", self.images_base(), id))
|
||||||
|
.header("X-API-Key", &self.api_key)
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
.error_for_status()?;
|
.error_for_status()?;
|
||||||
@@ -128,7 +254,26 @@ impl ImagoClient {
|
|||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let data = response.bytes().await?.to_vec();
|
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<u8>, 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))
|
Ok((data, content_type))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-52
@@ -1,6 +1,4 @@
|
|||||||
//! Routes API REST du dashboard
|
//! Routes API REST du dashboard — proxy vers Imago v2
|
||||||
//!
|
|
||||||
//! Phase 9: API complète avec proxy Imago + données locales
|
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Router, routing::get,
|
Router, routing::get,
|
||||||
@@ -10,7 +8,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use crate::imago_client::ImagoClient;
|
use crate::imago_client::{ImagoClient, ImageSummary};
|
||||||
|
|
||||||
/// État partagé de l'application
|
/// État partagé de l'application
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -33,9 +31,26 @@ pub struct SearchQuery {
|
|||||||
pub per_page: Option<usize>,
|
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 ─────────────────────────────────────────────────────────
|
// ─── Route builder ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Construit le router API
|
|
||||||
pub fn api_routes(state: AppState) -> Router {
|
pub fn api_routes(state: AppState) -> Router {
|
||||||
let state = Arc::new(state);
|
let state = Arc::new(state);
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -50,12 +65,10 @@ pub fn api_routes(state: AppState) -> Router {
|
|||||||
|
|
||||||
// ─── Handlers ──────────────────────────────────────────────────────────────
|
// ─── Handlers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Health check
|
|
||||||
async fn health_check() -> &'static str {
|
async fn health_check() -> &'static str {
|
||||||
"OK"
|
"OK"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Liste paginée des images
|
|
||||||
async fn list_images(
|
async fn list_images(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(pagination): Query<Pagination>,
|
Query(pagination): Query<Pagination>,
|
||||||
@@ -63,15 +76,14 @@ async fn list_images(
|
|||||||
let page = pagination.page.unwrap_or(1).max(1);
|
let page = pagination.page.unwrap_or(1).max(1);
|
||||||
let per_page = pagination.per_page.unwrap_or(24).min(100);
|
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) => {
|
Ok(response) => {
|
||||||
let total = response.total;
|
let images: Vec<serde_json::Value> = response.items.iter().map(image_to_json).collect();
|
||||||
let images = response.results;
|
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"images": images,
|
"images": images,
|
||||||
"total": total,
|
"total": response.total,
|
||||||
"page": page,
|
"page": response.page,
|
||||||
"per_page": per_page
|
"per_page": response.page_size
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -87,7 +99,6 @@ async fn list_images(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recherche d'images
|
|
||||||
async fn search_images(
|
async fn search_images(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(params): Query<SearchQuery>,
|
Query(params): Query<SearchQuery>,
|
||||||
@@ -96,15 +107,15 @@ async fn search_images(
|
|||||||
let page = params.page.unwrap_or(1).max(1);
|
let page = params.page.unwrap_or(1).max(1);
|
||||||
let per_page = params.per_page.unwrap_or(24).min(100);
|
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() {
|
if query.is_empty() {
|
||||||
match state.imago.search("", per_page).await {
|
match state.imago.list_images(page, per_page).await {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
|
let images: Vec<serde_json::Value> = response.items.iter().map(image_to_json).collect();
|
||||||
return Json(serde_json::json!({
|
return Json(serde_json::json!({
|
||||||
"images": response.results,
|
"images": images,
|
||||||
"total": response.total,
|
"total": response.total,
|
||||||
"page": page,
|
"page": response.page,
|
||||||
"per_page": per_page
|
"per_page": response.page_size
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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) => {
|
Ok(response) => {
|
||||||
|
let images: Vec<serde_json::Value> = response.items.iter().map(image_to_json).collect();
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"images": response.results,
|
"images": images,
|
||||||
"total": response.total,
|
"total": response.total,
|
||||||
"page": page,
|
"page": response.page,
|
||||||
"per_page": per_page,
|
"per_page": response.page_size,
|
||||||
"query": query
|
"query": query
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -144,77 +156,113 @@ async fn search_images(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Détails d'une image
|
|
||||||
async fn get_image(
|
async fn get_image(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
match state.imago.get_image(&id).await {
|
let img_id: i64 = match id.parse() {
|
||||||
Ok(info) => Json(serde_json::json!(info)),
|
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) => {
|
Err(e) => {
|
||||||
tracing::warn!("Image non trouvée (id={}) : {:#}", id, e);
|
tracing::warn!("Image non trouvée (id={}) : {:#}", id, e);
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"error": "Image non trouvée",
|
"error": "Image non trouvée", "id": id
|
||||||
"id": id
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Thumbnail / image complète
|
|
||||||
async fn get_thumbnail(
|
async fn get_thumbnail(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let full = params.get("full").map(|v| v == "1").unwrap_or(false);
|
let img_id: i64 = match id.parse() {
|
||||||
|
Ok(n) => n,
|
||||||
let img_url = if full {
|
Err(_) => {
|
||||||
format!("{}/images/{}/file", state.imago.base_url, id)
|
return axum::http::Response::builder()
|
||||||
} else {
|
.status(axum::http::StatusCode::BAD_REQUEST)
|
||||||
format!("{}/images/{}/thumbnail", state.imago.base_url, id)
|
.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)) => {
|
Ok((data, content_type)) => {
|
||||||
axum::http::Response::builder()
|
axum::http::Response::builder()
|
||||||
.header("Content-Type", content_type)
|
.header("Content-Type", content_type)
|
||||||
.header("Cache-Control", "public, max-age=3600")
|
.header("Cache-Control", "public, max-age=3600")
|
||||||
.body(axum::body::Body::from(data))
|
.body(axum::body::Body::from(data)).unwrap()
|
||||||
.unwrap()
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("Thumbnail indisponible (id={}) : {:#}", id, e);
|
tracing::warn!("Thumbnail indisponible (id={}) : {:#}", id, e);
|
||||||
axum::http::Response::builder()
|
axum::http::Response::builder()
|
||||||
.status(axum::http::StatusCode::NOT_FOUND)
|
.status(axum::http::StatusCode::NOT_FOUND)
|
||||||
.header("Content-Type", "text/plain")
|
.header("Content-Type", "text/plain")
|
||||||
.body(axum::body::Body::from("Thumbnail non disponible"))
|
.body(axum::body::Body::from("Thumbnail non disponible")).unwrap()
|
||||||
.unwrap()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Statistiques du dashboard
|
|
||||||
async fn stats(
|
async fn stats(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> impl IntoResponse {
|
) -> 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) => {
|
Ok(response) => {
|
||||||
let total = response.total;
|
let tags = if !response.items.is_empty() {
|
||||||
let storage = total as u64 * 250_000;
|
response.items[0].ai_tags.as_ref().map(|t| t.len()).unwrap_or(0)
|
||||||
let tags = if !response.results.is_empty() {
|
.saturating_mul(response.total).saturating_div(2)
|
||||||
response.results[0].tags.len().saturating_mul(total).saturating_div(2)
|
} else { 0 };
|
||||||
} else {
|
(response.total, response.storage_used_mb.unwrap_or(0.0), tags)
|
||||||
0
|
|
||||||
};
|
|
||||||
(total, storage, tags)
|
|
||||||
}
|
}
|
||||||
Err(_) => (0, 0, 0),
|
Err(_) => (0, 0.0, 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"total_images": total_images,
|
"total_images": total_images,
|
||||||
"total_storage_bytes": total_storage,
|
"total_storage_bytes": (storage_mb * 1_048_576.0) as u64,
|
||||||
"unique_tags": unique_tags,
|
"unique_tags": unique_tags,
|
||||||
"recent_uploads": 0,
|
"recent_uploads": 0,
|
||||||
"ws_clients": state.ws_hub.client_count(),
|
"ws_clients": state.ws_hub.client_count(),
|
||||||
|
|||||||
Reference in New Issue
Block a user