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:
47
src/exif.rs
47
src/exif.rs
@@ -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
|
||||
|
||||
92
src/main.rs
92
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<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. ~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)
|
||||
|
||||
Reference in New Issue
Block a user