From 16d6586b7d4bba644ade1b97a766ef7cb5dc631e Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Wed, 6 May 2026 19:42:41 -0400 Subject: [PATCH] =?UTF-8?q?exif:=20GET=20/image/exif/full=20=E2=80=94=20ex?= =?UTF-8?q?iftool=20dump=20for=20the=20DETAILS=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The curated `image_exif` columns are a small slice of what exiftool can read (camera/lens/GPS/capture/dates). Apollo's DETAILS modal wants to surface everything — white balance, metering, MakerNotes, IPTC, ICC profile, Composite tags, the lot — for an operator inspecting a photo's provenance. `read_full_exif_via_exiftool(path)` shells out to `exiftool -j -G -n`: JSON output, group-prefixed keys (`EXIF:Make`, `MakerNotes:LensInfo`), numeric values (callers can reformat). Spawned via web::block to keep it off the actix worker — RAW with rich MakerNotes can take a few seconds. The endpoint is on-demand only; the indexer / file watcher does NOT call it. Falls back to 503 with a clear message when exiftool isn't on PATH so Apollo can render an "install exiftool" hint. Multi-library union resolution mirrors set_image_gps / get_file_metadata. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/exif.rs | 47 +++++++++++++++++++++++++++ src/main.rs | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/src/exif.rs b/src/exif.rs index f15ef6f..8061a8f 100644 --- a/src/exif.rs +++ b/src/exif.rs @@ -71,6 +71,53 @@ fn read_jpeg_at_ifd(exif: &exif::Exif, path: &Path, ifd: In) -> Option> Some(buf) } +/// Shell out to `exiftool -j -G -n ` and return the per-file tag map. +/// +/// `-j` requests JSON; the response is always an array of one element per +/// input path. `-G` prefixes each key with the group name (`EXIF:Make`, +/// `MakerNotes:LensInfo`, `File:FileSize`, …) so a UI can group the dump. +/// `-n` returns numeric / raw values rather than exiftool's pretty-printed +/// human strings, which keeps the output stable for clients that want to +/// reformat (e.g. divide a focal-length numerator/denominator). +/// +/// Returns: +/// - `Ok(Some(value))` — the parsed object for this file. +/// - `Ok(None)` — exiftool ran but the array was empty / not an object. +/// - `Err(_)` — exiftool isn't on PATH, the spawn failed, or its stderr +/// indicates an unsupported file. Caller surfaces a 503 / 422. +/// +/// Used by `GET /image/exif/full` to power Apollo's DETAILS modal "FULL +/// EXIF" pane. Per-file shell-out is fine for this on-demand surface; +/// the indexer does NOT call this on the hot path (kamadak-exif covers +/// the indexed columns; exiftool is the slow-path preview helper). +pub fn read_full_exif_via_exiftool(path: &Path) -> Result> { + let output = Command::new("exiftool") + .arg("-j") + .arg("-G") + .arg("-n") + .arg(path) + .output() + .map_err(|e| anyhow!("exiftool spawn failed (is it on PATH?): {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!( + "exiftool exited with {}: {}", + output.status, + stderr.trim() + )); + } + + let parsed: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|e| anyhow!("exiftool returned non-JSON output: {}", e))?; + + // `-j` always wraps the result in an array — pull out the first object. + let arr = parsed + .as_array() + .ok_or_else(|| anyhow!("expected JSON array from exiftool -j"))?; + Ok(arr.first().cloned()) +} + /// Tags exiftool exposes for embedded JPEG previews, in priority order. The /// largest valid JPEG returned by any of them wins. Different camera makers /// stash their largest preview under different names: Nikon's full-res diff --git a/src/main.rs b/src/main.rs index 20b3456..68f8f61 100644 --- a/src/main.rs +++ b/src/main.rs @@ -593,6 +593,97 @@ async fn set_image_gps( } } +/// `GET /image/exif/full?path=&library=` — full per-file EXIF dump via +/// exiftool, for the DETAILS modal's "FULL EXIF" pane. Strictly richer +/// than `/image/metadata`'s curated subset (every group exiftool can +/// see: EXIF, File, MakerNotes, Composite, ICC_Profile, IPTC, …). +/// +/// On-demand only — the watcher / indexer never calls this. Falls back +/// to 503 when exiftool isn't installed (deployer guidance is the same +/// as for the RAW preview pipeline: install exiftool for full coverage). +#[get("/image/exif/full")] +async fn get_full_exif( + _: Claims, + request: HttpRequest, + path: web::Query, + app_state: Data, +) -> impl Responder { + let tracer = global_tracer(); + let context = extract_context_from_request(&request); + let mut span = tracer.start_with_context("get_full_exif", &context); + + let library = libraries::resolve_library_param(&app_state, path.library.as_deref()) + .ok() + .flatten() + .unwrap_or_else(|| app_state.primary_library()); + + // Same union-mode fallback as get_file_metadata — the file may live + // under a sibling library when the requested one's path resolves but + // doesn't actually contain the bytes. + let resolved = is_valid_full_path(&library.root_path, &path.path, false) + .filter(|p| p.exists()) + .map(|p| (library, p)) + .or_else(|| { + app_state.libraries.iter().find_map(|lib| { + if lib.id == library.id { + return None; + } + is_valid_full_path(&lib.root_path, &path.path, false) + .filter(|p| p.exists()) + .map(|p| (lib, p)) + }) + }); + + let (resolved_library, full_path) = match resolved { + Some(v) => v, + None => { + span.set_status(Status::error("file not found")); + return HttpResponse::NotFound().body("File not found"); + } + }; + + // exiftool spawn is blocking — keep it off the actix worker by + // running on the blocking pool. ~50–200 ms typical for a JPEG; + // longer for RAW with rich MakerNotes. + let exif_result = + web::block(move || crate::exif::read_full_exif_via_exiftool(&full_path)).await; + + match exif_result { + Ok(Ok(Some(tags))) => { + span.set_status(Status::Ok); + HttpResponse::Ok().json(serde_json::json!({ + "library_id": resolved_library.id, + "library_name": resolved_library.name, + "tags": tags, + })) + } + Ok(Ok(None)) => { + // exiftool ran but produced no output for this file — treat as + // empty rather than an error so the modal renders "no tags" + // gracefully. + HttpResponse::Ok().json(serde_json::json!({ + "library_id": resolved_library.id, + "library_name": resolved_library.name, + "tags": serde_json::Value::Object(Default::default()), + })) + } + Ok(Err(e)) => { + let msg = format!("exiftool failed: {}", e); + error!("{}", msg); + span.set_status(Status::error(msg.clone())); + // 503 — typically "exiftool isn't on PATH" or a transient spawn + // failure. Apollo surfaces a hint in the modal. + HttpResponse::ServiceUnavailable().body(msg) + } + Err(e) => { + let msg = format!("blocking-pool error: {}", e); + error!("{}", msg); + span.set_status(Status::error(msg.clone())); + HttpResponse::InternalServerError().body(msg) + } + } +} + /// Body for `POST /image/exif/date` — operator-driven date_taken override. /// `date_taken` is unix seconds (matches `image_exif.date_taken`'s convention /// — naive local reinterpreted as UTC, not real UTC; the Apollo client passes @@ -1847,6 +1938,7 @@ fn main() -> std::io::Result<()> { .service(set_image_gps) .service(set_image_date) .service(clear_image_date) + .service(get_full_exif) .service(memories::list_memories) .service(ai::generate_insight_handler) .service(ai::generate_agentic_insight_handler)