From c6f82ebabac3aa648fb26b93c319bf2ece0c0a72 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Mon, 27 Apr 2026 16:38:53 -0400 Subject: [PATCH] Batch EXIF endpoint: GET /photos/exif MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a single round-trip projection of `image_exif` for every photo whose `date_taken` falls in `[date_from, date_to]`. Wraps the existing `ExifDao::query_by_exif` DAO method which already handles the SQL filter in one query against the covering index — the only missing piece was HTTP plumbing. Designed for window-scoped consumers like Apollo's photo-to-track matcher, which currently does N+1 (one `/photos` listing + one `/image/metadata` per photo). Because `/image/metadata` serializes on `Data>`, that pattern can take 10s+ for windows with hundreds of photos. The new endpoint takes one mutex acquisition for the whole batch. Response shape: { photos: [ { file_path, library_id, library_name, camera_model, width, height, gps_latitude, gps_longitude, date_taken } ], total: N } Two notes on scope: - Photos with NULL `date_taken` are excluded by `query_by_exif`'s semantics. Filename-extracted dates are not synthesized here; rare callers that need that fallback can still hit `/image/metadata`. - GPS columns are stored as f32 in image_exif to keep row size small; the JSON shape widens to f64 so clients don't have to know about the on-disk precision. Library names are pre-mapped from `app_state.libraries` once and stamped on each row, avoiding an O(rows × libraries) linear scan. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/data/mod.rs | 30 +++++++++++++++++ src/files.rs | 87 ++++++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 3 ++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/src/data/mod.rs b/src/data/mod.rs index fe5e183..7d3c064 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -390,6 +390,36 @@ pub struct GpsPhotosResponse { pub total: usize, } +/// Single-row projection of `image_exif` rich enough to drive Apollo's +/// photo-to-track matcher (and any similar window-scoped consumer) without +/// a per-file `/image/metadata` round-trip. Returned by `/photos/exif`. +#[derive(Debug, Serialize)] +pub struct ExifSummary { + pub file_path: String, + pub library_id: i32, + pub library_name: Option, + pub camera_model: Option, + pub width: Option, + pub height: Option, + pub gps_latitude: Option, + pub gps_longitude: Option, + pub date_taken: Option, +} + +#[derive(Debug, Serialize)] +pub struct ExifBatchResponse { + pub photos: Vec, + pub total: usize, +} + +#[derive(Debug, Deserialize)] +pub struct ExifBatchRequest { + /// Lower bound (inclusive) for `image_exif.date_taken`, unix seconds. + pub date_from: Option, + /// Upper bound (inclusive). Same semantics as `date_to` on `/photos`. + pub date_to: Option, +} + #[derive(Deserialize)] pub struct PreviewClipRequest { pub path: String, diff --git a/src/files.rs b/src/files.rs index 75343b1..bd7d56d 100644 --- a/src/files.rs +++ b/src/files.rs @@ -10,7 +10,10 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::SystemTime; -use crate::data::{Claims, FilesRequest, FilterMode, MediaType, PhotosResponse, SortType}; +use crate::data::{ + Claims, ExifBatchRequest, ExifBatchResponse, ExifSummary, FilesRequest, FilterMode, MediaType, + PhotosResponse, SortType, +}; use crate::database::ExifDao; use crate::file_types; use crate::geo::{gps_bounding_box, haversine_distance}; @@ -1180,6 +1183,88 @@ pub async fn get_gps_summary( } } +/// Handler for the batch EXIF endpoint at `GET /photos/exif`. +/// +/// Returns a single-row projection of `image_exif` for every photo whose +/// `date_taken` falls in `[date_from, date_to]`, across all libraries. +/// Designed to replace the N+1 pattern of `/photos` + per-file +/// `/image/metadata` for window-scoped consumers like Apollo's photo-to- +/// track matcher: one DB query, one HTTP round-trip, one mutex acquisition. +/// +/// Photos with no `date_taken` are excluded by construction (the underlying +/// `query_by_exif` filter requires a non-null timestamp once a range is +/// supplied). Filename-extracted dates are not synthesized here; if a +/// caller needs that fallback, fetch the row separately via +/// `/image/metadata` (rare path). +pub async fn list_exif_summary( + _: Claims, + request: HttpRequest, + req: Query, + exif_dao: Data>>, + app_state: Data, +) -> Result { + let parent_cx = extract_context_from_request(&request); + let tracer = global_tracer(); + let mut span = tracer + .span_builder("list_exif_summary") + .start_with_context(&tracer, &parent_cx); + + span.set_attribute(KeyValue::new( + "date_from", + req.date_from.map(|v| v.to_string()).unwrap_or_default(), + )); + span.set_attribute(KeyValue::new( + "date_to", + req.date_to.map(|v| v.to_string()).unwrap_or_default(), + )); + let cx = opentelemetry::Context::current_with_span(span); + + // Pre-build an id → name map so we don't linear-scan libraries per row. + let library_names: std::collections::HashMap = app_state + .libraries + .iter() + .map(|lib| (lib.id, lib.name.clone())) + .collect(); + + let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); + match exif_dao_guard.query_by_exif(&cx, None, None, None, None, req.date_from, req.date_to) { + Ok(rows) => { + let photos: Vec = rows + .into_iter() + .map(|r| ExifSummary { + library_name: library_names.get(&r.library_id).cloned(), + file_path: r.file_path, + library_id: r.library_id, + camera_model: r.camera_model, + width: r.width, + height: r.height, + // image_exif stores GPS as f32 to keep row size small; + // widen for the JSON shape so clients don't need to + // know about the on-disk precision. + gps_latitude: r.gps_latitude.map(f64::from), + gps_longitude: r.gps_longitude.map(f64::from), + date_taken: r.date_taken, + }) + .collect(); + + let total = photos.len(); + cx.span() + .set_attribute(KeyValue::new("result_count", total as i64)); + cx.span().set_status(Status::Ok); + + Ok(HttpResponse::Ok().json(ExifBatchResponse { photos, total })) + } + Err(e) => { + error!("Error querying EXIF batch: {:?}", e); + cx.span() + .set_status(Status::error(format!("Database error: {:?}", e))); + Ok(HttpResponse::InternalServerError().json(serde_json::json!({ + "error": "Failed to query EXIF data" + }))) + } + } +} + pub async fn move_file( _: Claims, file_system: Data, diff --git a/src/main.rs b/src/main.rs index db63ef0..543a262 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1374,6 +1374,9 @@ fn main() -> std::io::Result<()> { web::resource("/photos/gps-summary") .route(web::get().to(files::get_gps_summary)), ) + .service( + web::resource("/photos/exif").route(web::get().to(files::list_exif_summary)), + ) .service(web::resource("/file/move").post(move_file::)) .service(get_image) .service(upload_image)