feature/exif-batch-endpoint for Apollo #58
@@ -390,6 +390,40 @@ 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<String>,
|
||||
pub camera_model: Option<String>,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
pub gps_latitude: Option<f64>,
|
||||
pub gps_longitude: Option<f64>,
|
||||
pub date_taken: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExifBatchResponse {
|
||||
pub photos: Vec<ExifSummary>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExifBatchRequest {
|
||||
/// Lower bound (inclusive) for `image_exif.date_taken`, unix seconds.
|
||||
pub date_from: Option<i64>,
|
||||
/// Upper bound (inclusive). Same semantics as `date_to` on `/photos`.
|
||||
pub date_to: Option<i64>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PreviewClipRequest {
|
||||
pub path: String,
|
||||
|
||||
32
src/exif.rs
32
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<ExifData> {
|
||||
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<i32> {
|
||||
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<String> {
|
||||
match &field.value {
|
||||
Value::Ascii(vec) => {
|
||||
|
||||
107
src/files.rs
107
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,108 @@ 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<ExifBatchRequest>,
|
||||
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||||
app_state: Data<AppState>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
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(),
|
||||
));
|
||||
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.
|
||||
let library_names: std::collections::HashMap<i32, String> = 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<ExifSummary> = 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,
|
||||
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<FS: FileSystemAccess>(
|
||||
_: Claims,
|
||||
file_system: Data<FS>,
|
||||
|
||||
12
src/main.rs
12
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)
|
||||
@@ -1374,6 +1383,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::<RealFileSystem>))
|
||||
.service(get_image)
|
||||
.service(upload_image)
|
||||
|
||||
Reference in New Issue
Block a user