feature/exif-batch-endpoint for Apollo #58
@@ -390,6 +390,36 @@ pub struct GpsPhotosResponse {
|
|||||||
pub total: usize,
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct PreviewClipRequest {
|
pub struct PreviewClipRequest {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
|||||||
87
src/files.rs
87
src/files.rs
@@ -10,7 +10,10 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::SystemTime;
|
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::database::ExifDao;
|
||||||
use crate::file_types;
|
use crate::file_types;
|
||||||
use crate::geo::{gps_bounding_box, haversine_distance};
|
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<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(),
|
||||||
|
));
|
||||||
|
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()
|
||||||
|
.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>(
|
pub async fn move_file<FS: FileSystemAccess>(
|
||||||
_: Claims,
|
_: Claims,
|
||||||
file_system: Data<FS>,
|
file_system: Data<FS>,
|
||||||
|
|||||||
@@ -1374,6 +1374,9 @@ fn main() -> std::io::Result<()> {
|
|||||||
web::resource("/photos/gps-summary")
|
web::resource("/photos/gps-summary")
|
||||||
.route(web::get().to(files::get_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(web::resource("/file/move").post(move_file::<RealFileSystem>))
|
||||||
.service(get_image)
|
.service(get_image)
|
||||||
.service(upload_image)
|
.service(upload_image)
|
||||||
|
|||||||
Reference in New Issue
Block a user