exif: GET /image/exif/full — exiftool dump for the DETAILS modal

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) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-05-06 19:42:41 -04:00
parent 832b50d587
commit 16d6586b7d
2 changed files with 139 additions and 0 deletions

View File

@@ -71,6 +71,53 @@ fn read_jpeg_at_ifd(exif: &exif::Exif, path: &Path, ifd: In) -> Option<Vec<u8>>
Some(buf)
}
/// Shell out to `exiftool -j -G -n <path>` 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<Option<serde_json::Value>> {
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

View File

@@ -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<ThumbnailRequest>,
app_state: Data<AppState>,
) -> 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. ~50200 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)