image_exif: manual date_taken override (set/clear endpoints)
Add `POST /image/exif/date` and `POST /image/exif/date/clear` so an operator can correct a row whose canonical-date waterfall landed on the wrong value (camera clock reset, fs_time fallback for a copied-from- backup file, etc). New `original_date_taken` / `original_date_taken_source` columns snapshot the prior value on first override so revert is lossless. The waterfall source set is now `'exif' | 'exiftool' | 'filename' | 'fs_time' | 'manual'`. The existing `idx_image_exif_date_backfill` partial index already filters to `date_taken IS NULL OR date_taken_source = 'fs_time'`, so manual rows are naturally excluded from the per-tick drain — no index change needed. `ExifMetadata` now exposes `date_taken_source` + originals so a UI can render "manually set; was X via filename". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
152
src/main.rs
152
src/main.rs
@@ -41,7 +41,7 @@ use urlencoding::decode;
|
||||
use crate::ai::InsightGenerator;
|
||||
use crate::auth::login;
|
||||
use crate::data::*;
|
||||
use crate::database::models::InsertImageExif;
|
||||
use crate::database::models::{ImageExif, InsertImageExif};
|
||||
use crate::database::*;
|
||||
use crate::files::{
|
||||
RealFileSystem, RefreshThumbnailsMessage, is_image_or_video, is_valid_full_path, move_file,
|
||||
@@ -593,6 +593,154 @@ async fn set_image_gps(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// through the same value the photo carousel rendered before edit).
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SetDateRequest {
|
||||
path: String,
|
||||
library: Option<String>,
|
||||
date_taken: i64,
|
||||
}
|
||||
|
||||
/// Body for `POST /image/exif/date/clear` — revert a manual override and
|
||||
/// restore the resolver-derived `(date_taken, date_taken_source)` pair from
|
||||
/// the snapshot.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ClearDateRequest {
|
||||
path: String,
|
||||
library: Option<String>,
|
||||
}
|
||||
|
||||
/// Build a `MetadataResponse` for the date endpoints. Mirrors
|
||||
/// `get_file_metadata`'s shape so the client gets a single source of truth
|
||||
/// after every mutation. Filesystem metadata is best-effort: if the file is
|
||||
/// on a stale mount or moved, the DB-side override still succeeds and the
|
||||
/// response carries `created=None, modified=None, size=0`. The DB row's
|
||||
/// updated EXIF is what matters here.
|
||||
fn build_metadata_response_for_date_mutation(
|
||||
library: &libraries::Library,
|
||||
rel_path: &str,
|
||||
exif: ImageExif,
|
||||
) -> MetadataResponse {
|
||||
let full_path = is_valid_full_path(&library.root_path, &rel_path.to_string(), false);
|
||||
let fs_meta = full_path
|
||||
.as_ref()
|
||||
.filter(|p| p.exists())
|
||||
.and_then(|p| std::fs::metadata(p).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(exif.into());
|
||||
response.library_id = Some(library.id);
|
||||
response.library_name = Some(library.name.clone());
|
||||
response.filename_date =
|
||||
memories::extract_date_from_filename(rel_path).map(|dt| dt.timestamp());
|
||||
response
|
||||
}
|
||||
|
||||
#[post("/image/exif/date")]
|
||||
async fn set_image_date(
|
||||
_: Claims,
|
||||
request: HttpRequest,
|
||||
body: web::Json<SetDateRequest>,
|
||||
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_date", &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());
|
||||
|
||||
// Path normalization matches set_image_gps so a Windows-import client
|
||||
// doesn't end up with a backslash variant that misses the row.
|
||||
let normalized_path = body.path.replace('\\', "/");
|
||||
|
||||
let updated = {
|
||||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||||
dao.set_manual_date_taken(&span_context, library.id, &normalized_path, body.date_taken)
|
||||
};
|
||||
|
||||
match updated {
|
||||
Ok(row) => {
|
||||
span.set_status(Status::Ok);
|
||||
HttpResponse::Ok().json(build_metadata_response_for_date_mutation(
|
||||
&library,
|
||||
&normalized_path,
|
||||
row,
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("set_manual_date_taken failed: {:?}", e);
|
||||
error!("{}", msg);
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
// Likely "row not found" — the file isn't indexed under this
|
||||
// (library, path). 404 lets the client distinguish from a 5xx.
|
||||
HttpResponse::NotFound().body(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/image/exif/date/clear")]
|
||||
async fn clear_image_date(
|
||||
_: Claims,
|
||||
request: HttpRequest,
|
||||
body: web::Json<ClearDateRequest>,
|
||||
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("clear_image_date", &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());
|
||||
|
||||
let normalized_path = body.path.replace('\\', "/");
|
||||
|
||||
let updated = {
|
||||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||||
dao.clear_manual_date_taken(&span_context, library.id, &normalized_path)
|
||||
};
|
||||
|
||||
match updated {
|
||||
Ok(row) => {
|
||||
span.set_status(Status::Ok);
|
||||
HttpResponse::Ok().json(build_metadata_response_for_date_mutation(
|
||||
&library,
|
||||
&normalized_path,
|
||||
row,
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("clear_manual_date_taken failed: {:?}", e);
|
||||
error!("{}", msg);
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
HttpResponse::NotFound().body(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct UploadQuery {
|
||||
library: Option<String>,
|
||||
@@ -1697,6 +1845,8 @@ fn main() -> std::io::Result<()> {
|
||||
.service(delete_favorite)
|
||||
.service(get_file_metadata)
|
||||
.service(set_image_gps)
|
||||
.service(set_image_date)
|
||||
.service(clear_image_date)
|
||||
.service(memories::list_memories)
|
||||
.service(ai::generate_insight_handler)
|
||||
.service(ai::generate_agentic_insight_handler)
|
||||
|
||||
Reference in New Issue
Block a user