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:
Jared Wolff
2026-03-05 12:48:47 -05:00
parent cea7665a43
commit 69fe3a8b07
11 changed files with 483 additions and 2 deletions
Generated
+16
View File
@@ -1477,6 +1477,15 @@ dependencies = [
"simple_asn1",
]
[[package]]
name = "kamadak-exif"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077"
dependencies = [
"mutate_once",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1718,6 +1727,12 @@ dependencies = [
"version_check",
]
[[package]]
name = "mutate_once"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -1820,6 +1835,7 @@ dependencies = [
"image",
"infer",
"jsonwebtoken",
"kamadak-exif",
"md-5",
"mimalloc",
"mime_guess",
+1
View File
@@ -37,6 +37,7 @@ dotenvy = "0.15.7"
moka = { version = "0.12", features = ["future", "sync"] }
http-range-header = "0.4"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
kamadak-exif = "0.5"
md-5 = "0.10"
sha2 = "0.10.9"
blake3 = { version = "1.8.3", features = ["rayon"] }
+22
View File
@@ -646,6 +646,28 @@ CREATE INDEX IF NOT EXISTS idx_shares_created_by ON storage.shares(created_by);
COMMENT ON TABLE storage.shares IS 'Shared links for files and folders with token-based access';
-- ── EXIF / media metadata for image and video files ─────────────────────
-- Separate table keeps storage.files lean (most files aren't images).
-- Populated at upload time by the ExifService.
CREATE TABLE IF NOT EXISTS storage.file_metadata (
file_id UUID PRIMARY KEY REFERENCES storage.files(id) ON DELETE CASCADE,
captured_at TIMESTAMP WITH TIME ZONE, -- EXIF DateTimeOriginal
latitude DOUBLE PRECISION, -- GPS latitude (decimal degrees)
longitude DOUBLE PRECISION, -- GPS longitude (decimal degrees)
camera_make TEXT, -- EXIF Make
camera_model TEXT, -- EXIF Model
orientation SMALLINT, -- EXIF Orientation (1-8)
width INTEGER, -- Original pixel width
height INTEGER, -- Original pixel height
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- For the Photos timeline: ORDER BY captured_at DESC with cursor pagination
CREATE INDEX IF NOT EXISTS idx_file_metadata_captured
ON storage.file_metadata(captured_at DESC) WHERE captured_at IS NOT NULL;
COMMENT ON TABLE storage.file_metadata IS 'EXIF and media metadata extracted at upload time';
-- ── Atomic recursive folder copy (WebDAV COPY Depth: infinity) ──────────
--
-- Copies the entire subtree rooted at `p_source_id` under `p_target_parent_id`.
+6 -1
View File
@@ -24,7 +24,7 @@ use crate::common::config::AppConfig;
use crate::common::errors::DomainError;
use crate::infrastructure::repositories::pg::SharePgRepository;
use crate::infrastructure::repositories::pg::{
FileBlobReadRepository, FileBlobWriteRepository, FolderDbRepository, TrashDbRepository,
FileBlobReadRepository, FileBlobWriteRepository, FileMetadataRepository, FolderDbRepository, TrashDbRepository,
};
use crate::infrastructure::services::file_content_cache::{
FileContentCache, FileContentCacheConfig,
@@ -214,6 +214,9 @@ impl AppServiceFactory {
None
};
// File metadata repository — EXIF/media metadata for images
let file_metadata_repository = Arc::new(FileMetadataRepository::new(db_pool.clone()));
tracing::info!(
"Repository services initialized with 100% blob storage model (PG metadata + DedupService blobs)"
);
@@ -223,6 +226,7 @@ impl AppServiceFactory {
folder_repo_concrete,
file_read_repository,
file_write_repository,
file_metadata_repository,
i18n_repository,
trash_repository,
}
@@ -815,6 +819,7 @@ pub struct RepositoryServices {
pub folder_repo_concrete: Arc<FolderDbRepository>,
pub file_read_repository: Arc<FileBlobReadRepository>,
pub file_write_repository: Arc<FileBlobWriteRepository>,
pub file_metadata_repository: Arc<FileMetadataRepository>,
pub i18n_repository: Arc<FileSystemI18nService>,
pub trash_repository: Option<Arc<TrashDbRepository>>,
}
@@ -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;
+180
View File
@@ -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
View File
@@ -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()
+62 -1
View File
@@ -605,7 +605,7 @@ impl FileHandler {
Err(response) => return response.into_response(),
};
// Generate thumbnails for supported images in background
// Generate thumbnails and extract EXIF metadata for supported images in background
if state
.core
.thumbnail_service
@@ -615,6 +615,7 @@ impl FileHandler {
let thumbnail_service = state.core.thumbnail_service.clone();
let dedup_service = state.core.dedup_service.clone();
let file_read = state.repositories.file_read_repository.clone();
let metadata_repo = state.repositories.file_metadata_repository.clone();
tokio::spawn(async move {
// Resolve the actual blob path on disk (not the logical file path,
@@ -627,6 +628,25 @@ impl FileHandler {
}
};
let file_path = dedup_service.blob_path(&blob_hash);
// Extract EXIF metadata (reads only header bytes, very fast).
// Runs before thumbnail generation so the OS page cache is primed.
{
use crate::infrastructure::services::exif_service::ExifService;
match tokio::fs::read(&file_path).await {
Ok(data) => {
if let Some(meta) = ExifService::extract(&data) {
if let Err(e) = metadata_repo.upsert(&file_id, &meta).await {
tracing::warn!("Failed to store EXIF for {}: {}", file_id, e);
}
}
}
Err(e) => {
tracing::warn!("Failed to read file for EXIF extraction {}: {}", file_id, e);
}
}
}
tracing::info!("🖼️ Generating thumbnails for: {}", file_id);
thumbnail_service.generate_all_sizes_background(file_id, file_path);
});
@@ -635,6 +655,47 @@ impl FileHandler {
Self::created_json_response(&file).into_response()
}
// ═══════════════════════════════════════════════════════════════════════
// METADATA
// ═══════════════════════════════════════════════════════════════════════
/// Returns EXIF/media metadata for a file.
///
/// Used by the Photos lightbox and for testing EXIF extraction.
pub async fn get_file_metadata(
State(state): State<GlobalState>,
auth_user: AuthUser,
Path(file_id): Path<String>,
) -> impl IntoResponse {
// Verify ownership
let file_read = &state.repositories.file_read_repository;
if let Err(e) = file_read.verify_file_owner(&file_id, &auth_user.id).await {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": e.to_string() })),
)
.into_response();
}
let metadata_repo = &state.repositories.file_metadata_repository;
match metadata_repo.get(&file_id).await {
Ok(Some(meta)) => (StatusCode::OK, Json(meta)).into_response(),
Ok(None) => (
StatusCode::OK,
Json(serde_json::json!({
"file_id": file_id,
"message": "No EXIF metadata available"
})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
.into_response(),
}
}
// ═══════════════════════════════════════════════════════════════════════
// DELETE
// ═══════════════════════════════════════════════════════════════════════
+1
View File
@@ -146,6 +146,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
.route("/upload", post(FileHandler::upload_file_with_thumbnails))
.route("/{id}", get(FileHandler::download_file))
.route("/{id}/thumbnail/{size}", get(FileHandler::get_thumbnail))
.route("/{id}/metadata", get(FileHandler::get_file_metadata))
.layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)) // 10 GB for file uploads
.with_state(app_state.clone());