Add Exif storing and update to Metadata endpoint
This commit is contained in:
343
src/exif.rs
Normal file
343
src/exif.rs
Normal file
@@ -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<String>,
|
||||
pub camera_model: Option<String>,
|
||||
pub lens_model: Option<String>,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
pub orientation: Option<i32>,
|
||||
pub gps_latitude: Option<f64>,
|
||||
pub gps_longitude: Option<f64>,
|
||||
pub gps_altitude: Option<f64>,
|
||||
pub focal_length: Option<f64>,
|
||||
pub aperture: Option<f64>,
|
||||
pub shutter_speed: Option<String>,
|
||||
pub iso: Option<i32>,
|
||||
pub date_taken: Option<i64>,
|
||||
}
|
||||
|
||||
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<ExifData> {
|
||||
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<String> {
|
||||
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<u32> {
|
||||
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<f64> {
|
||||
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<String> {
|
||||
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<i64> {
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user