diff --git a/src/data/mod.rs b/src/data/mod.rs index 7d3c064..ac91ad9 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -418,6 +418,10 @@ pub struct ExifBatchRequest { pub date_from: Option, /// Upper bound (inclusive). Same semantics as `date_to` on `/photos`. pub date_to: Option, + /// Restrict results to a single library by id. Omit (or "" / "all") for + /// union mode — the default. Filtered post-query in the handler so the + /// existing `query_by_exif` DAO trait stays untouched. + pub library: Option, } #[derive(Deserialize)] diff --git a/src/exif.rs b/src/exif.rs index 0cd29d9..eca6eb7 100644 --- a/src/exif.rs +++ b/src/exif.rs @@ -4,6 +4,7 @@ use std::path::Path; use anyhow::{Result, anyhow}; use exif::{In, Reader, Tag, Value}; +use image::DynamicImage; use log::debug; use serde::{Deserialize, Serialize}; @@ -177,6 +178,37 @@ pub fn extract_exif_from_path(path: &Path) -> Result { Ok(data) } +/// Read just the EXIF Orientation tag (1..=8) from a file. Cheaper than a +/// full `extract_exif_from_path` when the caller only needs orientation — +/// e.g. the thumbnail pipeline, which has to bake the rotation into the +/// resized pixels because the saved thumb has no EXIF chunk for the browser +/// to apply. +pub fn read_orientation(path: &Path) -> Option { + let file = File::open(path).ok()?; + let mut reader = BufReader::new(file); + let exif = Reader::new().read_from_container(&mut reader).ok()?; + let field = exif.get_field(Tag::Orientation, In::PRIMARY)?; + get_u32_value(field).map(|v| v as i32) +} + +/// Apply an EXIF Orientation (1..=8) to a `DynamicImage`, returning a +/// canonically-oriented copy. Orientations: +/// 1 → as-is, 2 → flipH, 3 → rot180, 4 → flipV, +/// 5 → rot90CW + flipH, 6 → rot90CW, 7 → rot270CW + flipH, 8 → rot270CW. +/// Anything else (missing tag, garbage values) is returned unchanged. +pub fn apply_orientation(img: DynamicImage, orientation: i32) -> DynamicImage { + match orientation { + 2 => img.fliph(), + 3 => img.rotate180(), + 4 => img.flipv(), + 5 => img.rotate90().fliph(), + 6 => img.rotate90(), + 7 => img.rotate270().fliph(), + 8 => img.rotate270(), + _ => img, + } +} + fn get_string_value(field: &exif::Field) -> Option { match &field.value { Value::Ascii(vec) => { diff --git a/src/files.rs b/src/files.rs index bd7d56d..b4458c9 100644 --- a/src/files.rs +++ b/src/files.rs @@ -1217,6 +1217,21 @@ pub async fn list_exif_summary( "date_to", req.date_to.map(|v| v.to_string()).unwrap_or_default(), )); + span.set_attribute(KeyValue::new( + "library", + req.library.clone().unwrap_or_default(), + )); + + // Resolve the library filter up front so a bad id/name 400s before we + // ever take the DAO mutex. None == union across all libraries. + let library_filter = + match crate::libraries::resolve_library_param(&app_state, req.library.as_deref()) { + Ok(lib) => lib.map(|l| l.id), + Err(msg) => { + span.set_status(Status::error(msg.clone())); + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))); + } + }; let cx = opentelemetry::Context::current_with_span(span); // Pre-build an id → name map so we don't linear-scan libraries per row. @@ -1231,6 +1246,11 @@ pub async fn list_exif_summary( Ok(rows) => { let photos: Vec = rows .into_iter() + // Library filter post-query: keeps the DAO trait (and its + // mocks) unchanged. For typical 2–3 library setups the in- + // memory pass over a date-bounded result set is negligible; + // can be pushed into SQL later if it ever isn't. + .filter(|r| library_filter.is_none_or(|id| r.library_id == id)) .map(|r| ExifSummary { library_name: library_names.get(&r.library_id).cloned(), file_path: r.file_path, diff --git a/src/main.rs b/src/main.rs index 543a262..5e87129 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1071,6 +1071,13 @@ pub fn unsupported_thumbnail_sentinel(thumb_path: &Path) -> PathBuf { } fn generate_image_thumbnail(src: &Path, thumb_path: &Path) -> std::io::Result<()> { + // The `image` crate doesn't auto-apply EXIF Orientation on load, and + // saving back out as JPEG drops EXIF entirely — so without baking the + // rotation into the pixels here, browsers see the raw landscape buffer + // of a portrait phone shot and render it sideways. Read once up front + // and apply to whichever decode branch we end up taking. + let orientation = exif::read_orientation(src).unwrap_or(1); + // RAW formats (ARW/NEF/CR2/etc): try the file's embedded JPEG preview // first. Avoids ffmpeg choking on proprietary RAW compression (Sony ARW // in particular), and is faster than decoding RAW pixels anyway. @@ -1081,6 +1088,7 @@ fn generate_image_thumbnail(src: &Path, thumb_path: &Path) -> std::io::Result<() format!("decode embedded preview {:?}: {}", src, e), ) })?; + let img = exif::apply_orientation(img, orientation); let scaled = img.thumbnail(200, u32::MAX); scaled .save_with_format(thumb_path, image::ImageFormat::Jpeg) @@ -1095,6 +1103,7 @@ fn generate_image_thumbnail(src: &Path, thumb_path: &Path) -> std::io::Result<() let img = image::open(src).map_err(|e| { std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{:?}: {}", src, e)) })?; + let img = exif::apply_orientation(img, orientation); let scaled = img.thumbnail(200, u32::MAX); scaled .save(thumb_path)