feat/apollo-places-tool and Geo Tagging Exif #60
@@ -84,9 +84,7 @@ impl ApolloClient {
|
|||||||
match self.fetch_places_containing(base, lat, lon).await {
|
match self.fetch_places_containing(base, lat, lon).await {
|
||||||
Ok(places) => places,
|
Ok(places) => places,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::warn!(
|
log::warn!("apollo_client: places_containing({lat:.4}, {lon:.4}) failed: {err}");
|
||||||
"apollo_client: places_containing({lat:.4}, {lon:.4}) failed: {err}"
|
|
||||||
);
|
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2120,7 +2120,10 @@ Return ONLY the summary, nothing else."#,
|
|||||||
} else {
|
} else {
|
||||||
p.description.clone()
|
p.description.clone()
|
||||||
};
|
};
|
||||||
format!("- {}{}: {} (radius {} m)", p.name, category, desc, p.radius_m)
|
format!(
|
||||||
|
"- {}{}: {} (radius {} m)",
|
||||||
|
p.name, category, desc, p.radius_m
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n")
|
.join("\n")
|
||||||
@@ -3367,7 +3370,8 @@ Return ONLY the summary, nothing else."#,
|
|||||||
// 10. Define tools. Hybrid mode omits `describe_photo` since the
|
// 10. Define tools. Hybrid mode omits `describe_photo` since the
|
||||||
// chat model receives the visual description inline.
|
// chat model receives the visual description inline.
|
||||||
let offer_describe_tool = has_vision && !is_hybrid;
|
let offer_describe_tool = has_vision && !is_hybrid;
|
||||||
let tools = Self::build_tool_definitions(offer_describe_tool, self.apollo_client.is_enabled());
|
let tools =
|
||||||
|
Self::build_tool_definitions(offer_describe_tool, self.apollo_client.is_enabled());
|
||||||
|
|
||||||
// 11. Build initial messages. In hybrid mode images are never
|
// 11. Build initial messages. In hybrid mode images are never
|
||||||
// attached to the wire message — the description is part of
|
// attached to the wire message — the description is part of
|
||||||
|
|||||||
46
src/exif.rs
46
src/exif.rs
@@ -160,6 +160,52 @@ pub fn extract_embedded_jpeg_preview(path: &Path) -> Option<Vec<u8>> {
|
|||||||
.max_by_key(|v| v.len())
|
.max_by_key(|v| v.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write GPS lat/lon into the file's EXIF in place via exiftool. Touches
|
||||||
|
/// nothing else — camera, dates, MakerNote, etc. all stay as-is. Uses
|
||||||
|
/// `-overwrite_original` so no `.orig` sidecar is left behind (the
|
||||||
|
/// caller's responsibility to back up the file system if they want
|
||||||
|
/// rollback). Returns Err if exiftool isn't on PATH, the file format
|
||||||
|
/// doesn't support EXIF, lat/lon are out of range, or exiftool prints
|
||||||
|
/// to stderr.
|
||||||
|
///
|
||||||
|
/// We pass lat/lon as positive decimal numbers and let the *Ref tags
|
||||||
|
/// carry the sign (N/S, E/W). exiftool happily accepts signed decimals
|
||||||
|
/// too, but the explicit ref form is unambiguous across exiftool
|
||||||
|
/// versions and matches what cameras write.
|
||||||
|
pub fn write_gps(path: &Path, lat: f64, lon: f64) -> Result<()> {
|
||||||
|
if !supports_exif(path) {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Format does not support EXIF GPS write: {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
|
||||||
|
return Err(anyhow!("GPS coordinates out of range: {}, {}", lat, lon));
|
||||||
|
}
|
||||||
|
let lat_ref = if lat >= 0.0 { "N" } else { "S" };
|
||||||
|
let lon_ref = if lon >= 0.0 { "E" } else { "W" };
|
||||||
|
let lat_abs = lat.abs();
|
||||||
|
let lon_abs = lon.abs();
|
||||||
|
let output = Command::new("exiftool")
|
||||||
|
.arg("-overwrite_original")
|
||||||
|
.arg(format!("-GPSLatitude={}", lat_abs))
|
||||||
|
.arg(format!("-GPSLatitudeRef={}", lat_ref))
|
||||||
|
.arg(format!("-GPSLongitude={}", lon_abs))
|
||||||
|
.arg(format!("-GPSLongitudeRef={}", lon_ref))
|
||||||
|
.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 failed (exit {}): {}",
|
||||||
|
output.status.code().unwrap_or(-1),
|
||||||
|
stderr.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn supports_exif(path: &Path) -> bool {
|
pub fn supports_exif(path: &Path) -> bool {
|
||||||
if let Some(ext) = path.extension() {
|
if let Some(ext) = path.extension() {
|
||||||
let ext_lower = ext.to_string_lossy().to_lowercase();
|
let ext_lower = ext.to_string_lossy().to_lowercase();
|
||||||
|
|||||||
163
src/main.rs
163
src/main.rs
@@ -394,6 +394,168 @@ async fn get_file_metadata(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Body for `POST /image/exif/gps` — write GPS coordinates into a file's
|
||||||
|
/// EXIF in place. Only `path` + `latitude` + `longitude` are required.
|
||||||
|
/// `library` is optional (falls back to the primary library) and matches
|
||||||
|
/// the convention of the other path-keyed routes.
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct SetGpsRequest {
|
||||||
|
path: String,
|
||||||
|
library: Option<String>,
|
||||||
|
latitude: f64,
|
||||||
|
longitude: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/image/exif/gps")]
|
||||||
|
async fn set_image_gps(
|
||||||
|
_: Claims,
|
||||||
|
request: HttpRequest,
|
||||||
|
body: web::Json<SetGpsRequest>,
|
||||||
|
app_state: Data<AppState>,
|
||||||
|
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let tracer = global_tracer();
|
||||||
|
let context = extract_context_from_request(&request);
|
||||||
|
let mut span = tracer.start_with_context("set_image_gps", &context);
|
||||||
|
let span_context =
|
||||||
|
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
|
||||||
|
|
||||||
|
let library = libraries::resolve_library_param(&app_state, body.library.as_deref())
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| app_state.primary_library());
|
||||||
|
|
||||||
|
// Same fallback as get_file_metadata: union-mode means a file may
|
||||||
|
// resolve under a sibling library.
|
||||||
|
let resolved = is_valid_full_path(&library.root_path, &body.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, &body.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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !exif::supports_exif(&full_path) {
|
||||||
|
return HttpResponse::BadRequest().body("File format does not support EXIF GPS write");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = exif::write_gps(&full_path, body.latitude, body.longitude) {
|
||||||
|
let msg = format!("exiftool write failed: {}", e);
|
||||||
|
error!("{}", msg);
|
||||||
|
span.set_status(Status::error(msg.clone()));
|
||||||
|
return HttpResponse::InternalServerError().body(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read EXIF from disk (the write path doesn't tell us the rest of
|
||||||
|
// the parsed fields back, and we want the DB row to match what
|
||||||
|
// extract_exif_from_path would now produce). Update the existing row
|
||||||
|
// rather than insert — this endpoint is invoked on already-indexed
|
||||||
|
// files only.
|
||||||
|
let extracted = match exif::extract_exif_from_path(&full_path) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
// GPS was written successfully but re-extraction failed; surface
|
||||||
|
// a 500 because the DB will now disagree with disk until the
|
||||||
|
// next file scan rewrites it.
|
||||||
|
let msg = format!("EXIF re-read failed after write: {}", e);
|
||||||
|
error!("{}", msg);
|
||||||
|
return HttpResponse::InternalServerError().body(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
let normalized_path = body.path.replace('\\', "/");
|
||||||
|
let insert_exif = InsertImageExif {
|
||||||
|
library_id: resolved_library.id,
|
||||||
|
file_path: normalized_path.clone(),
|
||||||
|
camera_make: extracted.camera_make,
|
||||||
|
camera_model: extracted.camera_model,
|
||||||
|
lens_model: extracted.lens_model,
|
||||||
|
width: extracted.width,
|
||||||
|
height: extracted.height,
|
||||||
|
orientation: extracted.orientation,
|
||||||
|
gps_latitude: extracted.gps_latitude.map(|v| v as f32),
|
||||||
|
gps_longitude: extracted.gps_longitude.map(|v| v as f32),
|
||||||
|
gps_altitude: extracted.gps_altitude.map(|v| v as f32),
|
||||||
|
focal_length: extracted.focal_length.map(|v| v as f32),
|
||||||
|
aperture: extracted.aperture.map(|v| v as f32),
|
||||||
|
shutter_speed: extracted.shutter_speed,
|
||||||
|
iso: extracted.iso,
|
||||||
|
date_taken: extracted.date_taken,
|
||||||
|
// Created_time is preserved by update_exif (it doesn't touch the
|
||||||
|
// column); pass any int — it's ignored in the UPDATE statement.
|
||||||
|
created_time: now,
|
||||||
|
last_modified: now,
|
||||||
|
// Hash + size aren't touched in update_exif either, but the file
|
||||||
|
// bytes did change — best-effort recompute so the new hash lands
|
||||||
|
// on the next call to get_exif. Failure here just leaves the old
|
||||||
|
// values in place.
|
||||||
|
content_hash: content_hash::compute(&full_path)
|
||||||
|
.ok()
|
||||||
|
.map(|c| c.content_hash),
|
||||||
|
size_bytes: content_hash::compute(&full_path).ok().map(|c| c.size_bytes),
|
||||||
|
};
|
||||||
|
|
||||||
|
let updated = {
|
||||||
|
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||||||
|
// If the row doesn't exist yet (file isn't indexed for some reason),
|
||||||
|
// insert instead so the GPS write is at least visible the moment
|
||||||
|
// the watcher catches up.
|
||||||
|
match dao.get_exif(&span_context, &normalized_path) {
|
||||||
|
Ok(Some(_)) => dao.update_exif(&span_context, insert_exif),
|
||||||
|
Ok(None) => dao.store_exif(&span_context, insert_exif),
|
||||||
|
Err(_) => dao.update_exif(&span_context, insert_exif),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match updated {
|
||||||
|
Ok(row) => {
|
||||||
|
// Mirror the file metadata so the client gets the new size /
|
||||||
|
// mtime in the same response and can refresh its cached
|
||||||
|
// metadata block in one round-trip.
|
||||||
|
let fs_meta = std::fs::metadata(&full_path).ok();
|
||||||
|
let mut response: MetadataResponse = match fs_meta {
|
||||||
|
Some(m) => m.into(),
|
||||||
|
None => MetadataResponse {
|
||||||
|
created: None,
|
||||||
|
modified: None,
|
||||||
|
size: 0,
|
||||||
|
exif: None,
|
||||||
|
filename_date: None,
|
||||||
|
library_id: None,
|
||||||
|
library_name: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
response.exif = Some(row.into());
|
||||||
|
response.library_id = Some(resolved_library.id);
|
||||||
|
response.library_name = Some(resolved_library.name.clone());
|
||||||
|
response.filename_date =
|
||||||
|
memories::extract_date_from_filename(&body.path).map(|dt| dt.timestamp());
|
||||||
|
span.set_status(Status::Ok);
|
||||||
|
HttpResponse::Ok().json(response)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = format!("EXIF DB update failed: {:?}", e);
|
||||||
|
error!("{}", msg);
|
||||||
|
span.set_status(Status::error(msg.clone()));
|
||||||
|
HttpResponse::InternalServerError().body(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct UploadQuery {
|
struct UploadQuery {
|
||||||
library: Option<String>,
|
library: Option<String>,
|
||||||
@@ -1415,6 +1577,7 @@ fn main() -> std::io::Result<()> {
|
|||||||
.service(put_add_favorite)
|
.service(put_add_favorite)
|
||||||
.service(delete_favorite)
|
.service(delete_favorite)
|
||||||
.service(get_file_metadata)
|
.service(get_file_metadata)
|
||||||
|
.service(set_image_gps)
|
||||||
.service(memories::list_memories)
|
.service(memories::list_memories)
|
||||||
.service(ai::generate_insight_handler)
|
.service(ai::generate_insight_handler)
|
||||||
.service(ai::generate_agentic_insight_handler)
|
.service(ai::generate_agentic_insight_handler)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
use crate::ai::apollo_client::ApolloClient;
|
||||||
use crate::ai::insight_chat::{ChatLockMap, InsightChatService};
|
use crate::ai::insight_chat::{ChatLockMap, InsightChatService};
|
||||||
use crate::ai::openrouter::OpenRouterClient;
|
use crate::ai::openrouter::OpenRouterClient;
|
||||||
use crate::ai::apollo_client::ApolloClient;
|
|
||||||
use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient};
|
use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient};
|
||||||
use crate::database::{
|
use crate::database::{
|
||||||
CalendarEventDao, DailySummaryDao, ExifDao, InsightDao, KnowledgeDao, LocationHistoryDao,
|
CalendarEventDao, DailySummaryDao, ExifDao, InsightDao, KnowledgeDao, LocationHistoryDao,
|
||||||
|
|||||||
Reference in New Issue
Block a user