use crate::ExifData;
use crate::MediaAnalyzerError;
use crate::features::gps::get_gps_info;
use crate::features::hashing::hash_file;
use crate::features::metadata::get_metadata;
use crate::features::pano::should_use_pano_viewer;
use crate::features::weather::get_weather_info;
use crate::structs::MediaMetadata;
use crate::tags::logic::extract_features;
use crate::time::get_time_info;
use bon::bon;
use exiftool::ExifTool;
use meteostat::Meteostat;
use reverse_geocoder::ReverseGeocoder;
use std::path::{Path, PathBuf};
pub struct MediaAnalyzer {
geocoder: ReverseGeocoder,
exiftool: ExifTool,
meteostat: Meteostat,
weather_search_radius_km: f64,
}
#[bon]
impl MediaAnalyzer {
#[builder]
pub async fn new(
exiftool_path: Option<&Path>,
cache_folder: Option<PathBuf>,
#[builder(default = 100.0)] weather_search_radius_km: f64,
) -> Result<Self, MediaAnalyzerError> {
let exiftool = match exiftool_path {
Some(path) => ExifTool::with_executable(path)?,
None => ExifTool::new()?,
};
let meteostat = match cache_folder {
Some(path) => Meteostat::with_cache_folder(path).await?,
None => Meteostat::new().await?,
};
let geocoder = ReverseGeocoder::new();
Ok(Self {
geocoder,
exiftool,
meteostat,
weather_search_radius_km,
})
}
pub async fn analyze_media(
&self,
media_file: &Path,
) -> Result<MediaMetadata, MediaAnalyzerError> {
let (hash, exif_value) = rayon::join(
|| hash_file(media_file),
|| self.exiftool.json(media_file, &["-n", "-g2"]),
);
let hash = hash?;
let exif_value = exif_value?;
let exif = ExifData::new(exif_value.clone());
let (basic, camera) = get_metadata(&exif)?;
let features = extract_features(media_file, &exif);
let gps = get_gps_info(&self.geocoder, &exif);
let use_panorama_viewer = should_use_pano_viewer(&exif);
let time = get_time_info(&exif, gps.as_ref())?;
let weather = if let (Some(gps), Some(utc_time)) = (gps.as_ref(), time.datetime_utc) {
get_weather_info(
&self.meteostat,
gps,
utc_time,
self.weather_search_radius_km,
)
.await
.ok()
} else {
None
};
Ok(MediaMetadata {
hash,
exif: exif_value,
features,
time,
gps,
use_panorama_viewer,
basic,
camera,
weather,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::MediaAnalyzerError;
use std::path::{Path, PathBuf};
fn asset_path(relative: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets")
.join(relative)
}
#[tokio::test(flavor = "multi_thread")]
async fn test_full_analysis_on_standard_jpg() -> Result<(), MediaAnalyzerError> {
let analyzer = MediaAnalyzer::builder().build().await?;
let media_file = asset_path("sunset.jpg");
let result = analyzer.analyze_media(&media_file).await?;
assert_eq!(result.basic.width, 5312);
assert!(!result.features.is_video);
assert!(!result.features.is_hdr, "sunset.jpg is not hdr");
assert!(result.gps.is_some(), "Should have GPS info");
assert!(!result.features.is_burst);
assert!(!result.use_panorama_viewer);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_on_hdr() -> Result<(), MediaAnalyzerError> {
let analyzer = MediaAnalyzer::builder().build().await?;
let media_file = asset_path("hdr.jpg");
let result = analyzer.analyze_media(&media_file).await?;
assert_eq!(result.basic.width, 4032);
assert!(!result.features.is_video);
assert!(result.features.is_hdr, "hdr.jpg is hdr");
assert!(result.gps.is_some(), "Should have GPS info");
assert!(!result.features.is_burst);
assert!(!result.use_panorama_viewer);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_on_heic() -> Result<(), MediaAnalyzerError> {
let analyzer = MediaAnalyzer::builder().build().await?;
let media_file = asset_path("iphone.HEIC");
let result = analyzer.analyze_media(&media_file).await?;
assert_eq!(result.basic.width, 3024);
assert_eq!(result.basic.orientation, Some(6));
assert!(!result.features.is_video);
assert!(!result.features.is_hdr);
assert!(result.gps.is_some(), "Should have GPS info");
assert!(!result.features.is_burst);
assert!(!result.use_panorama_viewer);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_full_analysis_on_standard_video() -> Result<(), MediaAnalyzerError> {
let analyzer = MediaAnalyzer::builder().build().await?;
let media_file = asset_path("video/car.webm");
let result = analyzer.analyze_media(&media_file).await?;
assert!(result.features.is_video);
assert!(result.basic.duration.is_some());
assert!(result.features.video_fps.is_some());
assert!(!result.features.is_slowmotion);
assert!(!result.features.is_timelapse);
assert!(!result.features.is_motion_photo);
assert!(!result.features.is_hdr);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_motion_photo_is_correctly_identified() -> Result<(), MediaAnalyzerError> {
let analyzer = MediaAnalyzer::builder().build().await?;
let media_file = asset_path("motion/PXL_20250103_180944831.MP.jpg");
let result = analyzer.analyze_media(&media_file).await?;
assert!(
!result.features.is_video,
"Motion Photo is not a primary video file"
);
assert!(result.features.is_motion_photo);
assert!(
result
.features
.motion_photo_presentation_timestamp
.is_some()
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_photosphere_is_correctly_identified() -> Result<(), MediaAnalyzerError> {
let analyzer = MediaAnalyzer::builder().build().await?;
let media_file = asset_path("photosphere.jpg");
let result = analyzer.analyze_media(&media_file).await?;
assert!(result.use_panorama_viewer);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_night_sight_is_correctly_identified() -> Result<(), MediaAnalyzerError> {
let analyzer = MediaAnalyzer::builder().build().await?;
let media_file = asset_path("night_sight/PXL_20250104_170020532.NIGHT.jpg");
let result = analyzer.analyze_media(&media_file).await?;
assert!(result.features.is_night_sight);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_slow_motion_video_is_correctly_identified() -> Result<(), MediaAnalyzerError> {
let analyzer = MediaAnalyzer::builder().build().await?;
let media_file = asset_path("slowmotion.mp4");
let result = analyzer.analyze_media(&media_file).await?;
assert!(result.features.is_video);
assert!(result.features.is_slowmotion);
assert!(!result.features.is_timelapse);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_timelapse_video_is_correctly_identified() -> Result<(), MediaAnalyzerError> {
let analyzer = MediaAnalyzer::builder().build().await?;
let media_file = asset_path("timelapse.mp4");
let result = analyzer.analyze_media(&media_file).await?;
assert!(result.features.is_video);
assert!(result.features.is_timelapse);
assert!(!result.features.is_slowmotion);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_timezone_fail() -> Result<(), MediaAnalyzerError> {
let analyzer = MediaAnalyzer::builder().build().await?;
let media_file = asset_path("timezone_fail/small_20150714_212836.mp4");
let result = analyzer.analyze_media(&media_file).await?;
assert!(result.features.is_video);
assert_eq!(
result.time.datetime_local.to_string(),
"2015-07-14 21:28:36"
);
if let Some(tz) = &result.time.timezone {
assert_ne!(tz.source, "CreateDate (Video UTC)");
assert!(
tz.offset_seconds.abs() <= 15 * 3600,
"Offset should be sane"
);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_video_timezone_datetime_extraction() -> Result<(), MediaAnalyzerError> {
let analyzer = MediaAnalyzer::builder().build().await?;
let media_file = asset_path("PXL_20260412_192436467.mp4");
let result = analyzer.analyze_media(&media_file).await?;
assert_eq!(
result.time.datetime_local.to_string(),
"2026-04-12 22:28:01"
);
assert_eq!(
result.time.datetime_utc.unwrap().naive_utc().to_string(),
"2026-04-12 19:28:01"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_gps_altitude() -> Result<(), MediaAnalyzerError> {
let analyzer = MediaAnalyzer::builder().build().await?;
let buggy_case = asset_path("gps_altitude/bad-altitude-ref.jpg");
let high_alt_1 = asset_path("gps_altitude/high-alt-2.jpg");
let high_alt_2 = asset_path("gps_altitude/high-altitude-1.jpg");
let neg_alt_correct = asset_path("gps_altitude/negative-alt-correct.jpg");
let buggy_case_result = analyzer.analyze_media(&buggy_case).await?;
let high_alt_1_result = analyzer.analyze_media(&high_alt_1).await?;
let high_alt_2_result = analyzer.analyze_media(&high_alt_2).await?;
let neg_alt_correcte_result = analyzer.analyze_media(&neg_alt_correct).await?;
dbg!(buggy_case_result.gps.unwrap().altitude);
dbg!(high_alt_1_result.gps.unwrap().altitude);
dbg!(high_alt_2_result.gps.unwrap().altitude);
dbg!(neg_alt_correcte_result.gps.unwrap().altitude);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_analysis_fails_gracefully_for_non_media_file() -> Result<(), MediaAnalyzerError> {
let analyzer = MediaAnalyzer::builder().build().await?;
let media_file = asset_path("text_file.txt");
let result = analyzer.analyze_media(&media_file).await;
assert!(result.is_err(), "Analysis should fail for a non-media file");
assert!(
matches!(result.unwrap_err(), MediaAnalyzerError::Metadata(_)),
"The error should be a MetadataError"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_detailed_gps_time() -> Result<(), MediaAnalyzerError> {
let analyzer = MediaAnalyzer::builder().build().await?;
let media_file = asset_path("sunset.jpg");
let result = analyzer.analyze_media(&media_file).await?;
let gps_info = result
.gps
.as_ref()
.expect("GPS info should be extracted for sunset.jpg");
assert!((gps_info.latitude - 40.820_887_527_777_8).abs() < 0.001);
assert!((gps_info.longitude - 14.422_816_666_666_7).abs() < 0.001);
assert_eq!(gps_info.location.name, "Massa di Somma");
assert_eq!(gps_info.location.admin1, "Campania");
assert_eq!(gps_info.location.country_code, "IT");
assert_eq!(gps_info.location.country_name, Some("Italy".to_string()));
let time_info = result.time;
assert_eq!(time_info.source_details.confidence, "High");
assert_eq!(
time_info.source_details.time_source,
"SubSecDateTimeOriginal: Parsed SubSeconds"
);
assert!(
time_info.datetime_utc.is_some(),
"UTC datetime should be calculated from naive and GPS"
);
assert!(
time_info.timezone.is_some(),
"Timezone should be determined from GPS"
);
let timezone = time_info.timezone.as_ref().unwrap();
assert_eq!(timezone.name, "Europe/Rome");
assert_eq!(
timezone.offset_seconds, 7200,
"Offset should be +2 hour for the photo's date"
);
let weather_info = result
.weather
.as_ref()
.expect("Weather info should be retrieved for a photo with GPS and UTC time");
let sun_info = &weather_info.sun_info;
assert!(!sun_info.is_daytime, "The sun is gone in this photo.");
if let Some(sunset) = sun_info.sunset {
let time_from_sunset = time_info.datetime_utc.unwrap() - sunset;
assert!(time_from_sunset.num_minutes() < 60);
}
assert!(
weather_info.hourly.is_some(),
"Hourly weather data should be present for this date"
);
let hourly_data = weather_info.hourly.as_ref().unwrap();
assert_eq!(hourly_data.temperature, Some(26.0));
assert_eq!(hourly_data.relative_humidity, Some(70));
Ok(())
}
}