New endpoint accepts {path, library, latitude, longitude} and shells
out to exiftool to write GPSLatitude/GPSLongitude (with N/S, E/W refs)
into the file's EXIF in place. After the write, the handler
re-extracts EXIF and updates the image_exif row so the DB stays in
sync — the response carries the updated metadata block in one
round-trip. Falls through to store_exif if the row is missing.
`exif::write_gps` is the small helper. `-overwrite_original` so no
.orig sidecar is left behind. Validates lat/lon range + supports_exif
before spawning exiftool. Format support matches the existing read
path (JPEG / TIFF / RAW / HEIF / PNG / WebP) — videos still need a
different writer and aren't covered.
Apollo's "+ PIN" carousel button (separate commit on the Apollo side)
calls this through /api/photos/exif/gps. Drive-by: cargo fmt one-line
collapse on apollo_client.rs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
111 lines
3.9 KiB
Rust
111 lines
3.9 KiB
Rust
//! Thin async HTTP client for Apollo's `/api/places/*` endpoints.
|
|
//!
|
|
//! Apollo (the personal location-history viewer at the sibling repo) owns
|
|
//! user-defined Places: `name + lat/lon + radius_m + description (+ optional
|
|
//! category)`. We consume them in two places:
|
|
//!
|
|
//! 1. Automatic enrichment in [`crate::ai::insight_generator`] — the always-on
|
|
//! path that folds the most-specific containing Place into the location
|
|
//! string fed to the LLM.
|
|
//! 2. The agentic `get_personal_place_at` tool — lets the LLM ask "what
|
|
//! user-defined place contains this lat/lon" during chat continuation.
|
|
//!
|
|
//! Apollo does the haversine. This client is plumbing only — no geometry,
|
|
//! no caching at the moment. If insight throughput ever makes per-photo
|
|
//! HTTP latency a problem, swap to a small `Mutex<HashMap>` TTL cache here.
|
|
//!
|
|
//! Configured via `APOLLO_API_BASE_URL`. When unset, the client constructs
|
|
//! to a no-op shell: every method returns empty / `None`, the enrichment
|
|
//! path silently falls through to the legacy Nominatim-only output, and the
|
|
//! tool registration in `insight_generator` reports "integration disabled."
|
|
|
|
use anyhow::Result;
|
|
use reqwest::Client;
|
|
use serde::Deserialize;
|
|
use std::time::Duration;
|
|
|
|
// Public fields — `id`, `lat`, `lon` aren't read from the current tool
|
|
// output but are part of the wire model and useful for future tool
|
|
// extensions / debugging.
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ApolloPlace {
|
|
pub id: i32,
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub description: String,
|
|
pub lat: f64,
|
|
pub lon: f64,
|
|
pub radius_m: i32,
|
|
#[serde(default)]
|
|
pub category: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct PlacesResponse {
|
|
places: Vec<ApolloPlace>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct ApolloClient {
|
|
client: Client,
|
|
/// `None` means the integration is disabled — every method returns
|
|
/// empty so the rest of insight generation runs unchanged.
|
|
base_url: Option<String>,
|
|
}
|
|
|
|
impl ApolloClient {
|
|
pub fn new(base_url: Option<String>) -> Self {
|
|
// 5 s timeout: Apollo runs on the LAN. If it doesn't answer in
|
|
// five seconds, treat the call as failed and fall back to the
|
|
// legacy Nominatim path rather than block the whole insight.
|
|
let client = Client::builder()
|
|
.timeout(Duration::from_secs(5))
|
|
.build()
|
|
.expect("reqwest client build");
|
|
Self { client, base_url }
|
|
}
|
|
|
|
/// Convenience for callers that need to know whether to register the
|
|
/// `get_personal_place_at` tool (or to short-circuit enrichment).
|
|
pub fn is_enabled(&self) -> bool {
|
|
self.base_url.is_some()
|
|
}
|
|
|
|
/// Server-side haversine: returns places whose radius contains
|
|
/// (lat, lon), already sorted smallest-radius-first by Apollo. The
|
|
/// caller can take `[0]` for the most-specific match (matches
|
|
/// Apollo's `primaryPlaceFor` rule on the frontend, so the carousel
|
|
/// badge and the LLM prompt always agree).
|
|
pub async fn places_containing(&self, lat: f64, lon: f64) -> Vec<ApolloPlace> {
|
|
let Some(base) = self.base_url.as_deref() else {
|
|
return Vec::new();
|
|
};
|
|
match self.fetch_places_containing(base, lat, lon).await {
|
|
Ok(places) => places,
|
|
Err(err) => {
|
|
log::warn!("apollo_client: places_containing({lat:.4}, {lon:.4}) failed: {err}");
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn fetch_places_containing(
|
|
&self,
|
|
base: &str,
|
|
lat: f64,
|
|
lon: f64,
|
|
) -> Result<Vec<ApolloPlace>> {
|
|
let url = format!("{}/api/places/contains", base.trim_end_matches('/'));
|
|
let resp = self
|
|
.client
|
|
.get(&url)
|
|
.query(&[("lat", lat), ("lon", lon)])
|
|
.send()
|
|
.await?
|
|
.error_for_status()?;
|
|
let body: PlacesResponse = resp.json().await?;
|
|
Ok(body.places)
|
|
}
|
|
}
|