From 4082f1fdb827f41ff43d20497ea2058eca624527 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 17 Dec 2025 16:55:48 -0500 Subject: [PATCH] Add Exif storing and update to Metadata endpoint --- .idea/sqldialects.xml | 7 + Cargo.lock | 16 + Cargo.toml | 3 +- .../down.sql | 2 + .../up.sql | 32 ++ src/bin/migrate_exif.rs | 120 ++++++ src/data/mod.rs | 91 +++++ src/database/mod.rs | 95 ++++- src/database/models.rs | 46 ++- src/database/schema.rs | 25 +- src/exif.rs | 343 ++++++++++++++++++ src/lib.rs | 5 + src/main.rs | 74 +++- 13 files changed, 851 insertions(+), 8 deletions(-) create mode 100644 .idea/sqldialects.xml create mode 100644 migrations/2025-12-17-000000_create_image_exif/down.sql create mode 100644 migrations/2025-12-17-000000_create_image_exif/up.sql create mode 100644 src/bin/migrate_exif.rs create mode 100644 src/exif.rs create mode 100644 src/lib.rs diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..4e3aa16 --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 5accb47..7c0a533 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1552,6 +1552,7 @@ dependencies = [ "futures", "image", "jsonwebtoken", + "kamadak-exif", "lazy_static", "log", "notify", @@ -1750,6 +1751,15 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -1976,6 +1986,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + [[package]] name = "new_debug_unreachable" version = "1.0.6" diff --git a/Cargo.toml b/Cargo.toml index 736dfa5..f184eb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,4 +43,5 @@ opentelemetry-otlp = { version = "0.30.0", features = ["default", "metrics", "tr opentelemetry-stdout = "0.30.0" opentelemetry-appender-log = "0.30.0" tempfile = "3.20.0" -regex = "1.11.1" \ No newline at end of file +regex = "1.11.1" +exif = { package = "kamadak-exif", version = "0.6.1" } \ No newline at end of file diff --git a/migrations/2025-12-17-000000_create_image_exif/down.sql b/migrations/2025-12-17-000000_create_image_exif/down.sql new file mode 100644 index 0000000..9baca92 --- /dev/null +++ b/migrations/2025-12-17-000000_create_image_exif/down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_image_exif_file_path; +DROP TABLE IF EXISTS image_exif; diff --git a/migrations/2025-12-17-000000_create_image_exif/up.sql b/migrations/2025-12-17-000000_create_image_exif/up.sql new file mode 100644 index 0000000..8041d06 --- /dev/null +++ b/migrations/2025-12-17-000000_create_image_exif/up.sql @@ -0,0 +1,32 @@ +CREATE TABLE image_exif ( + id INTEGER PRIMARY KEY NOT NULL, + file_path TEXT NOT NULL UNIQUE, + + -- Camera Information + camera_make TEXT, + camera_model TEXT, + lens_model TEXT, + + -- Image Properties + width INTEGER, + height INTEGER, + orientation INTEGER, + + -- GPS Coordinates + gps_latitude REAL, + gps_longitude REAL, + gps_altitude REAL, + + -- Capture Settings + focal_length REAL, + aperture REAL, + shutter_speed TEXT, + iso INTEGER, + date_taken BIGINT, + + -- Housekeeping + created_time BIGINT NOT NULL, + last_modified BIGINT NOT NULL +); + +CREATE INDEX idx_image_exif_file_path ON image_exif(file_path); diff --git a/src/bin/migrate_exif.rs b/src/bin/migrate_exif.rs new file mode 100644 index 0000000..5abd111 --- /dev/null +++ b/src/bin/migrate_exif.rs @@ -0,0 +1,120 @@ +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use chrono::Utc; +use walkdir::WalkDir; +use rayon::prelude::*; + +use image_api::database::{ExifDao, SqliteExifDao}; +use image_api::database::models::InsertImageExif; +use image_api::exif; + +fn main() -> anyhow::Result<()> { + env_logger::init(); + dotenv::dotenv()?; + + let base_path = dotenv::var("BASE_PATH")?; + let base = PathBuf::from(&base_path); + + println!("EXIF Migration Tool"); + println!("==================="); + println!("Base path: {}", base.display()); + println!(); + + // Collect all image files that support EXIF + println!("Scanning for images..."); + let image_files: Vec = WalkDir::new(&base) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .filter(|e| exif::supports_exif(&e.path())) + .map(|e| e.path().to_path_buf()) + .collect(); + + println!("Found {} images to process", image_files.len()); + + if image_files.is_empty() { + println!("No EXIF-supporting images found. Exiting."); + return Ok(()); + } + + println!(); + println!("Extracting EXIF data..."); + + // Create a thread-safe DAO + let dao = Arc::new(Mutex::new(SqliteExifDao::new())); + + // Process in parallel using rayon + let results: Vec<_> = image_files + .par_iter() + .map(|path| { + let relative_path = match path.strip_prefix(&base) { + Ok(p) => p.to_str().unwrap().to_string(), + Err(_) => { + eprintln!("Error: Could not create relative path for {}", path.display()); + return Err(anyhow::anyhow!("Path error")); + } + }; + + match exif::extract_exif_from_path(path) { + Ok(exif_data) => { + let timestamp = Utc::now().timestamp(); + let insert_exif = InsertImageExif { + file_path: relative_path.clone(), + camera_make: exif_data.camera_make, + camera_model: exif_data.camera_model, + lens_model: exif_data.lens_model, + width: exif_data.width, + height: exif_data.height, + orientation: exif_data.orientation, + gps_latitude: exif_data.gps_latitude, + gps_longitude: exif_data.gps_longitude, + gps_altitude: exif_data.gps_altitude, + focal_length: exif_data.focal_length, + aperture: exif_data.aperture, + shutter_speed: exif_data.shutter_speed, + iso: exif_data.iso, + date_taken: exif_data.date_taken, + created_time: timestamp, + last_modified: timestamp, + }; + + // Store in database + if let Ok(mut dao_lock) = dao.lock() { + match dao_lock.store_exif(insert_exif) { + Ok(_) => { + println!("✓ {}", relative_path); + Ok(relative_path) + } + Err(e) => { + eprintln!("✗ {} - Database error: {:?}", relative_path, e); + Err(anyhow::anyhow!("Database error")) + } + } + } else { + eprintln!("✗ {} - Failed to acquire database lock", relative_path); + Err(anyhow::anyhow!("Lock error")) + } + } + Err(e) => { + eprintln!("✗ {} - No EXIF data: {:?}", relative_path, e); + Err(e) + } + } + }) + .collect(); + + let success_count = results.iter().filter(|r| r.is_ok()).count(); + let error_count = results.len() - success_count; + + println!(); + println!("==================="); + println!("Migration complete!"); + println!("Successfully extracted EXIF from {}/{} images", success_count, image_files.len()); + + if error_count > 0 { + println!("{} images had no EXIF data or encountered errors", error_count); + } + + Ok(()) +} diff --git a/src/data/mod.rs b/src/data/mod.rs index 59abcbe..fb62877 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -1,6 +1,7 @@ use std::{fs, str::FromStr}; use anyhow::{Context, anyhow}; +use crate::database::models::ImageExif; use chrono::{DateTime, Utc}; use log::error; @@ -173,6 +174,7 @@ pub struct MetadataResponse { pub created: Option, pub modified: Option, pub size: u64, + pub exif: Option, } impl From for MetadataResponse { @@ -187,6 +189,95 @@ impl From for MetadataResponse { utc.timestamp() }), size: metadata.len(), + exif: None, + } + } +} + +#[derive(Debug, Serialize)] +pub struct ExifMetadata { + pub camera: Option, + pub image_properties: Option, + pub gps: Option, + pub capture_settings: Option, + pub date_taken: Option, +} + +#[derive(Debug, Serialize)] +pub struct CameraInfo { + pub make: Option, + pub model: Option, + pub lens: Option, +} + +#[derive(Debug, Serialize)] +pub struct ImageProperties { + pub width: Option, + pub height: Option, + pub orientation: Option, +} + +#[derive(Debug, Serialize)] +pub struct GpsCoordinates { + pub latitude: Option, + pub longitude: Option, + pub altitude: Option, +} + +#[derive(Debug, Serialize)] +pub struct CaptureSettings { + pub focal_length: Option, + pub aperture: Option, + pub shutter_speed: Option, + pub iso: Option, +} + +impl From for ExifMetadata { + fn from(exif: ImageExif) -> Self { + let has_camera_info = exif.camera_make.is_some() || exif.camera_model.is_some() || exif.lens_model.is_some(); + let has_image_properties = exif.width.is_some() || exif.height.is_some() || exif.orientation.is_some(); + let has_gps = exif.gps_latitude.is_some() || exif.gps_longitude.is_some() || exif.gps_altitude.is_some(); + let has_capture_settings = exif.focal_length.is_some() || exif.aperture.is_some() || exif.shutter_speed.is_some() || exif.iso.is_some(); + + ExifMetadata { + camera: if has_camera_info { + Some(CameraInfo { + make: exif.camera_make, + model: exif.camera_model, + lens: exif.lens_model, + }) + } else { + None + }, + image_properties: if has_image_properties { + Some(ImageProperties { + width: exif.width, + height: exif.height, + orientation: exif.orientation, + }) + } else { + None + }, + gps: if has_gps { + Some(GpsCoordinates { + latitude: exif.gps_latitude, + longitude: exif.gps_longitude, + altitude: exif.gps_altitude, + }) + } else { + None + }, + capture_settings: if has_capture_settings { + Some(CaptureSettings { + focal_length: exif.focal_length, + aperture: exif.aperture, + shutter_speed: exif.shutter_speed, + iso: exif.iso, + }) + } else { + None + }, + date_taken: exif.date_taken, } } } diff --git a/src/database/mod.rs b/src/database/mod.rs index ed6f884..b57302a 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -4,7 +4,7 @@ use diesel::sqlite::SqliteConnection; use std::ops::DerefMut; use std::sync::{Arc, Mutex}; -use crate::database::models::{Favorite, InsertFavorite, InsertUser, User}; +use crate::database::models::{Favorite, ImageExif, InsertFavorite, InsertImageExif, InsertUser, User}; pub mod models; pub mod schema; @@ -181,3 +181,96 @@ impl FavoriteDao for SqliteFavoriteDao { .map_err(|_| DbError::new(DbErrorKind::QueryError)) } } + +pub trait ExifDao: Sync + Send { + fn store_exif(&mut self, exif_data: InsertImageExif) -> Result; + fn get_exif(&mut self, file_path: &str) -> Result, DbError>; + fn update_exif(&mut self, exif_data: InsertImageExif) -> Result; + fn delete_exif(&mut self, file_path: &str) -> Result<(), DbError>; +} + +pub struct SqliteExifDao { + connection: Arc>, +} + +impl SqliteExifDao { + pub fn new() -> Self { + SqliteExifDao { + connection: Arc::new(Mutex::new(connect())), + } + } +} + +impl ExifDao for SqliteExifDao { + fn store_exif(&mut self, exif_data: InsertImageExif) -> Result { + use schema::image_exif::dsl::*; + + let mut connection = self.connection.lock().expect("Unable to get ExifDao"); + + diesel::insert_into(image_exif) + .values(&exif_data) + .execute(connection.deref_mut()) + .map_err(|_| DbError::new(DbErrorKind::InsertError))?; + + image_exif + .filter(file_path.eq(&exif_data.file_path)) + .first::(connection.deref_mut()) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn get_exif(&mut self, path: &str) -> Result, DbError> { + use schema::image_exif::dsl::*; + + let mut connection = self.connection.lock().expect("Unable to get ExifDao"); + + match image_exif + .filter(file_path.eq(path)) + .first::(connection.deref_mut()) + { + Ok(exif) => Ok(Some(exif)), + Err(diesel::result::Error::NotFound) => Ok(None), + Err(_) => Err(DbError::new(DbErrorKind::QueryError)), + } + } + + fn update_exif(&mut self, exif_data: InsertImageExif) -> Result { + use schema::image_exif::dsl::*; + + let mut connection = self.connection.lock().expect("Unable to get ExifDao"); + + diesel::update(image_exif.filter(file_path.eq(&exif_data.file_path))) + .set(( + camera_make.eq(&exif_data.camera_make), + camera_model.eq(&exif_data.camera_model), + lens_model.eq(&exif_data.lens_model), + width.eq(&exif_data.width), + height.eq(&exif_data.height), + orientation.eq(&exif_data.orientation), + gps_latitude.eq(&exif_data.gps_latitude), + gps_longitude.eq(&exif_data.gps_longitude), + gps_altitude.eq(&exif_data.gps_altitude), + focal_length.eq(&exif_data.focal_length), + aperture.eq(&exif_data.aperture), + shutter_speed.eq(&exif_data.shutter_speed), + iso.eq(&exif_data.iso), + date_taken.eq(&exif_data.date_taken), + last_modified.eq(&exif_data.last_modified), + )) + .execute(connection.deref_mut()) + .map_err(|_| DbError::new(DbErrorKind::InsertError))?; + + image_exif + .filter(file_path.eq(&exif_data.file_path)) + .first::(connection.deref_mut()) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn delete_exif(&mut self, path: &str) -> Result<(), DbError> { + use schema::image_exif::dsl::*; + + diesel::delete(image_exif.filter(file_path.eq(path))) + .execute(self.connection.lock().unwrap().deref_mut()) + .map(|_| ()) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } +} diff --git a/src/database/models.rs b/src/database/models.rs index d4f3dcb..1d36206 100644 --- a/src/database/models.rs +++ b/src/database/models.rs @@ -1,4 +1,4 @@ -use crate::database::schema::{favorites, users}; +use crate::database::schema::{favorites, image_exif, users}; use serde::Serialize; #[derive(Insertable)] @@ -29,3 +29,47 @@ pub struct Favorite { pub userid: i32, pub path: String, } + +#[derive(Insertable)] +#[diesel(table_name = image_exif)] +pub struct InsertImageExif { + pub file_path: String, + pub camera_make: Option, + pub camera_model: Option, + pub lens_model: Option, + pub width: Option, + pub height: Option, + pub orientation: Option, + pub gps_latitude: Option, + pub gps_longitude: Option, + pub gps_altitude: Option, + pub focal_length: Option, + pub aperture: Option, + pub shutter_speed: Option, + pub iso: Option, + pub date_taken: Option, + pub created_time: i64, + pub last_modified: i64, +} + +#[derive(Serialize, Queryable, Clone, Debug)] +pub struct ImageExif { + pub id: i32, + pub file_path: String, + pub camera_make: Option, + pub camera_model: Option, + pub lens_model: Option, + pub width: Option, + pub height: Option, + pub orientation: Option, + pub gps_latitude: Option, + pub gps_longitude: Option, + pub gps_altitude: Option, + pub focal_length: Option, + pub aperture: Option, + pub shutter_speed: Option, + pub iso: Option, + pub date_taken: Option, + pub created_time: i64, + pub last_modified: i64, +} diff --git a/src/database/schema.rs b/src/database/schema.rs index 25a217e..c0ca44c 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -6,6 +6,29 @@ table! { } } +table! { + image_exif (id) { + id -> Integer, + file_path -> Text, + camera_make -> Nullable, + camera_model -> Nullable, + lens_model -> Nullable, + width -> Nullable, + height -> Nullable, + orientation -> Nullable, + gps_latitude -> Nullable, + gps_longitude -> Nullable, + gps_altitude -> Nullable, + focal_length -> Nullable, + aperture -> Nullable, + shutter_speed -> Nullable, + iso -> Nullable, + date_taken -> Nullable, + created_time -> BigInt, + last_modified -> BigInt, + } +} + table! { tagged_photo (id) { id -> Integer, @@ -33,4 +56,4 @@ table! { joinable!(tagged_photo -> tags (tag_id)); -allow_tables_to_appear_in_same_query!(favorites, tagged_photo, tags, users,); +allow_tables_to_appear_in_same_query!(favorites, image_exif, tagged_photo, tags, users,); diff --git a/src/exif.rs b/src/exif.rs new file mode 100644 index 0000000..6a1ab2f --- /dev/null +++ b/src/exif.rs @@ -0,0 +1,343 @@ +use std::fs::File; +use std::io::BufReader; +use std::path::Path; + +use anyhow::{Result, anyhow}; +use exif::{In, Reader, Tag, Value}; +use log::debug; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExifData { + pub camera_make: Option, + pub camera_model: Option, + pub lens_model: Option, + pub width: Option, + pub height: Option, + pub orientation: Option, + pub gps_latitude: Option, + pub gps_longitude: Option, + pub gps_altitude: Option, + pub focal_length: Option, + pub aperture: Option, + pub shutter_speed: Option, + pub iso: Option, + pub date_taken: Option, +} + +impl Default for ExifData { + fn default() -> Self { + ExifData { + camera_make: None, + camera_model: None, + lens_model: None, + width: None, + height: None, + orientation: None, + gps_latitude: None, + gps_longitude: None, + gps_altitude: None, + focal_length: None, + aperture: None, + shutter_speed: None, + iso: None, + date_taken: None, + } + } +} + +pub fn supports_exif(path: &Path) -> bool { + if let Some(ext) = path.extension() { + let ext_lower = ext.to_string_lossy().to_lowercase(); + matches!( + ext_lower.as_str(), + // JPEG formats + "jpg" | "jpeg" | + // TIFF and RAW formats based on TIFF + "tiff" | "tif" | "nef" | "cr2" | "cr3" | "arw" | "dng" | "raf" | "orf" | "rw2" | "pef" | "srw" | + // HEIF and variants + "heif" | "heic" | "avif" | + // PNG + "png" | + // WebP + "webp" + ) + } else { + false + } +} + +pub fn extract_exif_from_path(path: &Path) -> Result { + debug!("Extracting EXIF from: {:?}", path); + + if !supports_exif(path) { + return Err(anyhow!("File type does not support EXIF")); + } + + let file = File::open(path)?; + let mut bufreader = BufReader::new(file); + + let exifreader = Reader::new(); + let exif = exifreader.read_from_container(&mut bufreader)?; + + let mut data = ExifData::default(); + + for field in exif.fields() { + match field.tag { + Tag::Make => { + data.camera_make = get_string_value(field); + } + Tag::Model => { + data.camera_model = get_string_value(field); + } + Tag::LensModel => { + data.lens_model = get_string_value(field); + } + Tag::PixelXDimension | Tag::ImageWidth => { + if data.width.is_none() { + data.width = get_u32_value(field).map(|v| v as i32); + } + } + Tag::PixelYDimension | Tag::ImageLength => { + if data.height.is_none() { + data.height = get_u32_value(field).map(|v| v as i32); + } + } + Tag::Orientation => { + data.orientation = get_u32_value(field).map(|v| v as i32); + } + Tag::FocalLength => { + data.focal_length = get_rational_value(field); + } + Tag::FNumber => { + data.aperture = get_rational_value(field); + } + Tag::ExposureTime => { + data.shutter_speed = get_rational_string(field); + } + Tag::PhotographicSensitivity | Tag::ISOSpeed => { + if data.iso.is_none() { + data.iso = get_u32_value(field).map(|v| v as i32); + } + } + Tag::DateTime | Tag::DateTimeOriginal => { + if data.date_taken.is_none() { + data.date_taken = parse_exif_datetime(field); + } + } + _ => {} + } + } + + // Extract GPS coordinates + if let Some(lat) = extract_gps_coordinate(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef) { + data.gps_latitude = Some(lat); + } + if let Some(lon) = extract_gps_coordinate(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef) { + data.gps_longitude = Some(lon); + } + if let Some(alt) = extract_gps_altitude(&exif) { + data.gps_altitude = Some(alt); + } + + debug!("Extracted EXIF data: {:?}", data); + Ok(data) +} + +fn get_string_value(field: &exif::Field) -> Option { + match &field.value { + Value::Ascii(vec) => { + if let Some(bytes) = vec.first() { + String::from_utf8(bytes.to_vec()) + .ok() + .map(|s| s.trim_end_matches('\0').to_string()) + } else { + None + } + } + _ => { + let display = field.display_value().to_string(); + if display.is_empty() { + None + } else { + Some(display) + } + } + } +} + +fn get_u32_value(field: &exif::Field) -> Option { + match &field.value { + Value::Short(vec) => vec.first().map(|&v| v as u32), + Value::Long(vec) => vec.first().copied(), + _ => None, + } +} + +fn get_rational_value(field: &exif::Field) -> Option { + match &field.value { + Value::Rational(vec) => { + if let Some(rational) = vec.first() { + if rational.denom == 0 { + None + } else { + Some(rational.num as f64 / rational.denom as f64) + } + } else { + None + } + } + _ => None, + } +} + +fn get_rational_string(field: &exif::Field) -> Option { + match &field.value { + Value::Rational(vec) => { + if let Some(rational) = vec.first() { + if rational.denom == 0 { + None + } else if rational.num < rational.denom { + Some(format!("{}/{}", rational.num, rational.denom)) + } else { + let value = rational.num as f64 / rational.denom as f64; + Some(format!("{:.2}", value)) + } + } else { + None + } + } + _ => None, + } +} + +fn parse_exif_datetime(field: &exif::Field) -> Option { + if let Some(datetime_str) = get_string_value(field) { + use chrono::NaiveDateTime; + + // EXIF datetime format: "YYYY:MM:DD HH:MM:SS" + NaiveDateTime::parse_from_str(&datetime_str, "%Y:%m:%d %H:%M:%S") + .ok() + .map(|dt| dt.and_utc().timestamp()) + } else { + None + } +} + +fn extract_gps_coordinate( + exif: &exif::Exif, + coord_tag: Tag, + ref_tag: Tag, +) -> Option { + let coord_field = exif.get_field(coord_tag, In::PRIMARY)?; + let ref_field = exif.get_field(ref_tag, In::PRIMARY)?; + + let coordinates = match &coord_field.value { + Value::Rational(vec) => { + if vec.len() < 3 { + return None; + } + let degrees = vec[0].num as f64 / vec[0].denom as f64; + let minutes = vec[1].num as f64 / vec[1].denom as f64; + let seconds = vec[2].num as f64 / vec[2].denom as f64; + degrees + (minutes / 60.0) + (seconds / 3600.0) + } + _ => return None, + }; + + let reference = get_string_value(ref_field)?; + let sign = if reference.starts_with('S') || reference.starts_with('W') { + -1.0 + } else { + 1.0 + }; + + Some(coordinates * sign) +} + +fn extract_gps_altitude(exif: &exif::Exif) -> Option { + let alt_field = exif.get_field(Tag::GPSAltitude, In::PRIMARY)?; + + match &alt_field.value { + Value::Rational(vec) => { + if let Some(rational) = vec.first() { + if rational.denom == 0 { + None + } else { + let altitude = rational.num as f64 / rational.denom as f64; + + // Check if below sea level + if let Some(ref_field) = exif.get_field(Tag::GPSAltitudeRef, In::PRIMARY) { + if let Some(ref_val) = get_u32_value(ref_field) { + if ref_val == 1 { + return Some(-altitude); + } + } + } + + Some(altitude) + } + } else { + None + } + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_supports_exif_jpeg() { + assert!(supports_exif(Path::new("test.jpg"))); + assert!(supports_exif(Path::new("test.jpeg"))); + assert!(supports_exif(Path::new("test.JPG"))); + } + + #[test] + fn test_supports_exif_raw_formats() { + assert!(supports_exif(Path::new("test.nef"))); // Nikon + assert!(supports_exif(Path::new("test.NEF"))); + assert!(supports_exif(Path::new("test.cr2"))); // Canon + assert!(supports_exif(Path::new("test.cr3"))); // Canon + assert!(supports_exif(Path::new("test.arw"))); // Sony + assert!(supports_exif(Path::new("test.dng"))); // Adobe DNG + } + + #[test] + fn test_supports_exif_tiff() { + assert!(supports_exif(Path::new("test.tiff"))); + assert!(supports_exif(Path::new("test.tif"))); + assert!(supports_exif(Path::new("test.TIFF"))); + } + + #[test] + fn test_supports_exif_heif() { + assert!(supports_exif(Path::new("test.heif"))); + assert!(supports_exif(Path::new("test.heic"))); + assert!(supports_exif(Path::new("test.avif"))); + } + + #[test] + fn test_supports_exif_png_webp() { + assert!(supports_exif(Path::new("test.png"))); + assert!(supports_exif(Path::new("test.PNG"))); + assert!(supports_exif(Path::new("test.webp"))); + assert!(supports_exif(Path::new("test.WEBP"))); + } + + #[test] + fn test_supports_exif_unsupported() { + assert!(!supports_exif(Path::new("test.mp4"))); + assert!(!supports_exif(Path::new("test.mov"))); + assert!(!supports_exif(Path::new("test.txt"))); + assert!(!supports_exif(Path::new("test.gif"))); + } + + #[test] + fn test_supports_exif_no_extension() { + assert!(!supports_exif(Path::new("test"))); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..627a1e6 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +#[macro_use] +extern crate diesel; + +pub mod database; +pub mod exif; diff --git a/src/main.rs b/src/main.rs index 1cc859e..db7a840 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,7 @@ use rayon::prelude::*; use crate::auth::login; use crate::data::*; use crate::database::*; +use crate::database::models::InsertImageExif; use crate::files::{ RealFileSystem, RefreshThumbnailsMessage, is_image_or_video, is_valid_full_path, move_file, }; @@ -53,6 +54,7 @@ mod auth; mod data; mod database; mod error; +mod exif; mod files; mod state; mod tags; @@ -146,17 +148,29 @@ async fn get_file_metadata( request: HttpRequest, path: web::Query, app_state: Data, + exif_dao: Data>>, ) -> impl Responder { let tracer = global_tracer(); let context = extract_context_from_request(&request); let mut span = tracer.start_with_context("get_file_metadata", &context); - match is_valid_full_path(&app_state.base_path, &path.path, false) + + let full_path = is_valid_full_path(&app_state.base_path, &path.path, false); + + match full_path .ok_or_else(|| ErrorKind::InvalidData.into()) .and_then(File::open) .and_then(|file| file.metadata()) { Ok(metadata) => { - let response: MetadataResponse = metadata.into(); + let mut response: MetadataResponse = metadata.into(); + + // Query EXIF data if available + if let Ok(mut dao) = exif_dao.lock() { + if let Ok(Some(exif)) = dao.get_exif(&path.path) { + response.exif = Some(exif.into()); + } + } + span.add_event( "Metadata fetched", vec![KeyValue::new("file", path.path.clone())], @@ -181,6 +195,7 @@ async fn upload_image( request: HttpRequest, mut payload: mp::Multipart, app_state: Data, + exif_dao: Data>>, ) -> impl Responder { let tracer = global_tracer(); let context = extract_context_from_request(&request); @@ -224,11 +239,12 @@ async fn upload_image( .span_builder("file write") .start_with_context(&tracer, &context); - if !full_path.is_file() && is_image_or_video(&full_path) { + let uploaded_path = if !full_path.is_file() && is_image_or_video(&full_path) { let mut file = File::create(&full_path).unwrap(); file.write_all(&file_content).unwrap(); info!("Uploaded: {:?}", full_path); + full_path } else { warn!("File already exists: {:?}", full_path); @@ -245,8 +261,56 @@ async fn upload_image( ); info!("Uploaded: {}", new_path); - let mut file = File::create(new_path).unwrap(); + let new_path_buf = PathBuf::from(&new_path); + let mut file = File::create(&new_path_buf).unwrap(); file.write_all(&file_content).unwrap(); + new_path_buf + }; + + // Extract and store EXIF data if file supports it + if exif::supports_exif(&uploaded_path) { + let relative_path = uploaded_path + .strip_prefix(&app_state.base_path) + .expect("Error stripping base path prefix") + .to_str() + .unwrap() + .to_string(); + + match exif::extract_exif_from_path(&uploaded_path) { + Ok(exif_data) => { + let timestamp = Utc::now().timestamp(); + let insert_exif = InsertImageExif { + file_path: relative_path.clone(), + camera_make: exif_data.camera_make, + camera_model: exif_data.camera_model, + lens_model: exif_data.lens_model, + width: exif_data.width, + height: exif_data.height, + orientation: exif_data.orientation, + gps_latitude: exif_data.gps_latitude, + gps_longitude: exif_data.gps_longitude, + gps_altitude: exif_data.gps_altitude, + focal_length: exif_data.focal_length, + aperture: exif_data.aperture, + shutter_speed: exif_data.shutter_speed, + iso: exif_data.iso, + date_taken: exif_data.date_taken, + created_time: timestamp, + last_modified: timestamp, + }; + + if let Ok(mut dao) = exif_dao.lock() { + if let Err(e) = dao.store_exif(insert_exif) { + error!("Failed to store EXIF data for {}: {:?}", relative_path, e); + } else { + debug!("EXIF data stored for {}", relative_path); + } + } + } + Err(e) => { + debug!("No EXIF data or error extracting from {}: {:?}", uploaded_path.display(), e); + } + } } } else { error!("Invalid path for upload: {:?}", full_path); @@ -645,6 +709,7 @@ fn main() -> std::io::Result<()> { let user_dao = SqliteUserDao::new(); let favorites_dao = SqliteFavoriteDao::new(); let tag_dao = SqliteTagDao::default(); + let exif_dao = SqliteExifDao::new(); App::new() .wrap(middleware::Logger::default()) .service(web::resource("/login").route(web::post().to(login::))) @@ -673,6 +738,7 @@ fn main() -> std::io::Result<()> { favorites_dao, )))) .app_data::>>(Data::new(Mutex::new(tag_dao))) + .app_data::>>>(Data::new(Mutex::new(Box::new(exif_dao)))) .wrap(prometheus.clone()) }) .bind(dotenv::var("BIND_URL").unwrap())?