feat(photos): add EXIF metadata extraction and storage
Extract EXIF orientation, GPS coordinates, camera info, and timestamps
from uploaded images using kamadak-exif. Store metadata in a new
file_metadata PG table. Apply EXIF orientation to thumbnail generation
so images display correctly. Add /api/files/{id}/metadata endpoint.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
//! 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;
|
||||
|
||||
/// Metadata as stored/retrieved from the database.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StoredMetadata {
|
||||
pub file_id: String,
|
||||
pub captured_at: Option<DateTime<Utc>>,
|
||||
pub latitude: Option<f64>,
|
||||
pub longitude: Option<f64>,
|
||||
pub camera_make: Option<String>,
|
||||
pub camera_model: Option<String>,
|
||||
pub orientation: Option<i16>,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
}
|
||||
|
||||
/// Repository for `storage.file_metadata` table operations.
|
||||
pub struct FileMetadataRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl FileMetadataRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> 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<Option<StoredMetadata>, DomainError> {
|
||||
let row: Option<(
|
||||
String,
|
||||
Option<DateTime<Utc>>,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i16>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
)> = 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<HashMap<String, StoredMetadata>, DomainError> {
|
||||
if file_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let rows: Vec<(
|
||||
String,
|
||||
Option<DateTime<Utc>>,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i16>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
)> = 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)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod contact_persistence_dto;
|
||||
mod contact_pg_repository;
|
||||
mod device_code_pg_repository;
|
||||
mod favorites_pg_repository;
|
||||
pub mod file_metadata_repository;
|
||||
mod nextcloud_object_id_repository;
|
||||
mod recent_items_pg_repository;
|
||||
mod session_pg_repository;
|
||||
@@ -30,6 +31,7 @@ pub use contact_persistence_dto::*;
|
||||
pub use contact_pg_repository::ContactPgRepository;
|
||||
pub use device_code_pg_repository::DeviceCodePgRepository;
|
||||
pub use favorites_pg_repository::FavoritesPgRepository;
|
||||
pub use file_metadata_repository::FileMetadataRepository;
|
||||
pub use file_blob_read_repository::FileBlobReadRepository;
|
||||
pub use file_blob_write_repository::FileBlobWriteRepository;
|
||||
pub use folder_db_repository::FolderDbRepository;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! EXIF metadata extraction from image files.
|
||||
//!
|
||||
//! Uses `kamadak-exif` to parse EXIF headers from JPEG/TIFF/HEIF images.
|
||||
//! Extraction is cheap — only the header bytes are read, not the full image.
|
||||
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use exif::{In, Reader, Tag};
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Extracted EXIF metadata fields.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ExifMetadata {
|
||||
/// Photo capture time (EXIF DateTimeOriginal)
|
||||
pub captured_at: Option<DateTime<Utc>>,
|
||||
/// GPS latitude in decimal degrees (positive = North)
|
||||
pub latitude: Option<f64>,
|
||||
/// GPS longitude in decimal degrees (positive = East)
|
||||
pub longitude: Option<f64>,
|
||||
/// Camera manufacturer (EXIF Make)
|
||||
pub camera_make: Option<String>,
|
||||
/// Camera model (EXIF Model)
|
||||
pub camera_model: Option<String>,
|
||||
/// EXIF Orientation tag (1-8)
|
||||
pub orientation: Option<u16>,
|
||||
/// Original image width in pixels
|
||||
pub width: Option<u32>,
|
||||
/// Original image height in pixels
|
||||
pub height: Option<u32>,
|
||||
}
|
||||
|
||||
/// Stateless service for extracting EXIF metadata from image bytes.
|
||||
pub struct ExifService;
|
||||
|
||||
impl ExifService {
|
||||
/// Extract EXIF metadata from raw image bytes.
|
||||
///
|
||||
/// Returns `None` if the file has no EXIF data (e.g. PNG, GIF, WebP)
|
||||
/// or if parsing fails entirely. Individual fields may be `None` even
|
||||
/// when the EXIF block exists (not all cameras populate every tag).
|
||||
pub fn extract(data: &[u8]) -> Option<ExifMetadata> {
|
||||
let exif = Reader::new()
|
||||
.read_from_container(&mut Cursor::new(data))
|
||||
.ok()?;
|
||||
|
||||
let mut meta = ExifMetadata::default();
|
||||
|
||||
// ── Capture date ──
|
||||
if let Some(field) = exif.get_field(Tag::DateTimeOriginal, In::PRIMARY) {
|
||||
meta.captured_at = parse_exif_datetime(&field.display_value().to_string());
|
||||
}
|
||||
// Fallback to DateTimeDigitized if DateTimeOriginal is missing
|
||||
if meta.captured_at.is_none() {
|
||||
if let Some(field) = exif.get_field(Tag::DateTimeDigitized, In::PRIMARY) {
|
||||
meta.captured_at = parse_exif_datetime(&field.display_value().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// ── GPS coordinates ──
|
||||
meta.latitude = parse_gps_coord(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef);
|
||||
meta.longitude = parse_gps_coord(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef);
|
||||
|
||||
// ── Camera info ──
|
||||
if let Some(field) = exif.get_field(Tag::Make, In::PRIMARY) {
|
||||
let val = field.display_value().to_string().trim_matches('"').trim().to_string();
|
||||
if !val.is_empty() {
|
||||
meta.camera_make = Some(val);
|
||||
}
|
||||
}
|
||||
if let Some(field) = exif.get_field(Tag::Model, In::PRIMARY) {
|
||||
let val = field.display_value().to_string().trim_matches('"').trim().to_string();
|
||||
if !val.is_empty() {
|
||||
meta.camera_model = Some(val);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Orientation ──
|
||||
if let Some(field) = exif.get_field(Tag::Orientation, In::PRIMARY) {
|
||||
if let exif::Value::Short(ref v) = field.value {
|
||||
if let Some(&o) = v.first() {
|
||||
if (1..=8).contains(&o) {
|
||||
meta.orientation = Some(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dimensions ──
|
||||
if let Some(field) = exif.get_field(Tag::PixelXDimension, In::PRIMARY) {
|
||||
meta.width = parse_u32_value(&field.value);
|
||||
}
|
||||
if let Some(field) = exif.get_field(Tag::PixelYDimension, In::PRIMARY) {
|
||||
meta.height = parse_u32_value(&field.value);
|
||||
}
|
||||
// Fallback to ImageWidth/ImageLength if PixelXDimension is missing
|
||||
if meta.width.is_none() {
|
||||
if let Some(field) = exif.get_field(Tag::ImageWidth, In::PRIMARY) {
|
||||
meta.width = parse_u32_value(&field.value);
|
||||
}
|
||||
}
|
||||
if meta.height.is_none() {
|
||||
if let Some(field) = exif.get_field(Tag::ImageLength, In::PRIMARY) {
|
||||
meta.height = parse_u32_value(&field.value);
|
||||
}
|
||||
}
|
||||
|
||||
Some(meta)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse EXIF datetime string "YYYY:MM:DD HH:MM:SS" into DateTime<Utc>.
|
||||
fn parse_exif_datetime(s: &str) -> Option<DateTime<Utc>> {
|
||||
// EXIF dates use ":" as separator for date parts
|
||||
let s = s.trim().trim_matches('"');
|
||||
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
|
||||
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y:%m:%d %H:%M:%S"))
|
||||
.ok()
|
||||
.map(|ndt| ndt.and_utc())
|
||||
}
|
||||
|
||||
/// Parse GPS coordinate from EXIF rational values + reference (N/S or E/W).
|
||||
fn parse_gps_coord(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option<f64> {
|
||||
let field = exif.get_field(coord_tag, In::PRIMARY)?;
|
||||
let ref_field = exif.get_field(ref_tag, In::PRIMARY)?;
|
||||
|
||||
let rationals = match &field.value {
|
||||
exif::Value::Rational(v) if v.len() >= 3 => v,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let degrees = rationals[0].to_f64();
|
||||
let minutes = rationals[1].to_f64();
|
||||
let seconds = rationals[2].to_f64();
|
||||
|
||||
let mut decimal = degrees + minutes / 60.0 + seconds / 3600.0;
|
||||
|
||||
// Apply hemisphere sign
|
||||
let reference = ref_field.display_value().to_string();
|
||||
let reference = reference.trim().trim_matches('"');
|
||||
if reference == "S" || reference == "W" {
|
||||
decimal = -decimal;
|
||||
}
|
||||
|
||||
Some(decimal)
|
||||
}
|
||||
|
||||
/// Extract a u32 from various EXIF value types (Short, Long).
|
||||
fn parse_u32_value(value: &exif::Value) -> Option<u32> {
|
||||
match value {
|
||||
exif::Value::Short(v) => v.first().map(|&x| x as u32),
|
||||
exif::Value::Long(v) => v.first().copied(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply EXIF orientation to a `DynamicImage`.
|
||||
///
|
||||
/// EXIF orientation values 1-8 describe how the stored pixels relate to
|
||||
/// the intended display orientation. This function transforms the image
|
||||
/// to match the intended orientation.
|
||||
pub fn apply_orientation(img: image::DynamicImage, orientation: u16) -> image::DynamicImage {
|
||||
match orientation {
|
||||
1 => img, // Normal
|
||||
2 => image::DynamicImage::from(image::imageops::flip_horizontal(&img)), // Mirror horizontal
|
||||
3 => image::DynamicImage::from(image::imageops::rotate180(&img)), // Rotate 180°
|
||||
4 => image::DynamicImage::from(image::imageops::flip_vertical(&img)), // Mirror vertical
|
||||
5 => {
|
||||
// Transpose: flip horizontal then rotate 270° (= rotate 90° CW then flip horizontal)
|
||||
let flipped = image::imageops::flip_horizontal(&img);
|
||||
image::DynamicImage::from(image::imageops::rotate270(&flipped))
|
||||
}
|
||||
6 => image::DynamicImage::from(image::imageops::rotate90(&img)), // Rotate 90° CW
|
||||
7 => {
|
||||
// Transverse: flip horizontal then rotate 90°
|
||||
let flipped = image::imageops::flip_horizontal(&img);
|
||||
image::DynamicImage::from(image::imageops::rotate90(&flipped))
|
||||
}
|
||||
8 => image::DynamicImage::from(image::imageops::rotate270(&img)), // Rotate 270° CW
|
||||
_ => img,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod chunked_upload_service;
|
||||
pub mod compression_service;
|
||||
pub mod dedup_service;
|
||||
pub mod exif_service;
|
||||
pub mod file_content_cache;
|
||||
pub mod file_system_i18n_service;
|
||||
pub mod image_transcode_service;
|
||||
|
||||
@@ -269,6 +269,15 @@ impl ThumbnailService {
|
||||
let img = image::load_from_memory(&data)
|
||||
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
||||
|
||||
// Apply EXIF orientation so thumbnails display correctly
|
||||
let img = {
|
||||
use crate::infrastructure::services::exif_service::{ExifService, apply_orientation};
|
||||
let orientation = ExifService::extract(&data)
|
||||
.and_then(|m| m.orientation)
|
||||
.unwrap_or(1);
|
||||
apply_orientation(img, orientation)
|
||||
};
|
||||
|
||||
// Calculate new dimensions preserving aspect ratio
|
||||
let (orig_width, orig_height) = (img.width(), img.height());
|
||||
let (new_width, new_height) = if orig_width > orig_height {
|
||||
@@ -349,6 +358,15 @@ impl ThumbnailService {
|
||||
let img = image::load_from_memory(&data)
|
||||
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
||||
|
||||
// Apply EXIF orientation so thumbnails display correctly
|
||||
let img = {
|
||||
use crate::infrastructure::services::exif_service::{ExifService, apply_orientation};
|
||||
let orientation = ExifService::extract(&data)
|
||||
.and_then(|m| m.orientation)
|
||||
.unwrap_or(1);
|
||||
apply_orientation(img, orientation)
|
||||
};
|
||||
|
||||
let (orig_w, orig_h) = (img.width(), img.height());
|
||||
|
||||
ThumbnailSize::all()
|
||||
|
||||
Reference in New Issue
Block a user