//! PostgreSQL repository for image/video EXIF metadata. use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::PgPool; use std::collections::HashMap; use std::sync::Arc; use tracing::error; use crate::common::errors::DomainError; use crate::infrastructure::services::exif_service::ExifMetadata; /// Row shape returned by metadata queries (avoids `clippy::type_complexity`). type MetadataRow = ( String, Option>, Option, Option, Option, Option, Option, Option, Option, ); /// Metadata as stored/retrieved from the database. #[derive(Debug, Clone, Serialize)] pub struct StoredMetadata { pub file_id: String, pub captured_at: Option>, pub latitude: Option, pub longitude: Option, pub camera_make: Option, pub camera_model: Option, pub orientation: Option, pub width: Option, pub height: Option, } /// Repository for `storage.file_metadata` table operations. pub struct FileMetadataRepository { pool: Arc, } impl FileMetadataRepository { pub fn new(pool: Arc) -> Self { Self { pool } } /// Insert or update EXIF metadata for a file. pub async fn upsert(&self, file_id: &str, meta: &ExifMetadata) -> Result<(), DomainError> { sqlx::query( r#" INSERT INTO storage.file_metadata (file_id, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (file_id) DO UPDATE SET captured_at = EXCLUDED.captured_at, latitude = EXCLUDED.latitude, longitude = EXCLUDED.longitude, camera_make = EXCLUDED.camera_make, camera_model = EXCLUDED.camera_model, orientation = EXCLUDED.orientation, width = EXCLUDED.width, height = EXCLUDED.height "#, ) .bind(file_id) .bind(meta.captured_at) .bind(meta.latitude) .bind(meta.longitude) .bind(&meta.camera_make) .bind(&meta.camera_model) .bind(meta.orientation.map(|o| o as i16)) .bind(meta.width.map(|w| w as i32)) .bind(meta.height.map(|h| h as i32)) .execute(self.pool.as_ref()) .await .map_err(|e| { error!("Failed to upsert file metadata: {}", e); DomainError::internal_error("FileMetadata", format!("upsert: {e}")) })?; Ok(()) } /// Get metadata for a single file. pub async fn get(&self, file_id: &str) -> Result, DomainError> { let row: Option = sqlx::query_as( r#" SELECT file_id::text, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height FROM storage.file_metadata WHERE file_id = $1::uuid "#, ) .bind(file_id) .fetch_optional(self.pool.as_ref()) .await .map_err(|e| { error!("Failed to get file metadata: {}", e); DomainError::internal_error("FileMetadata", format!("get: {e}")) })?; Ok(row.map( |( file_id, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height, )| { StoredMetadata { file_id, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height, } }, )) } /// Get metadata for multiple files in a single query. pub async fn get_batch( &self, file_ids: &[String], ) -> Result, DomainError> { if file_ids.is_empty() { return Ok(HashMap::new()); } let rows: Vec = sqlx::query_as( r#" SELECT file_id::text, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height FROM storage.file_metadata WHERE file_id = ANY($1::uuid[]) "#, ) .bind(file_ids) .fetch_all(self.pool.as_ref()) .await .map_err(|e| { error!("Failed to batch get file metadata: {}", e); DomainError::internal_error("FileMetadata", format!("get_batch: {e}")) })?; let mut map = HashMap::with_capacity(rows.len()); for ( file_id, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height, ) in rows { map.insert( file_id.clone(), StoredMetadata { file_id, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height, }, ); } Ok(map) } }