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