feat(versions): link at ingest, hide in listings, version endpoints
XMP (PNG iTXt DerivedFrom, native chunk reader) then file-name convention links at ingest; unlinked name candidates are retried each tick. Versions are hidden from /photos (SQL for the recursive listing, hidden-row set for the walk and tag branches), /memories, reels, /photos/exif, the GPS summary, unified search and the media gauges while their original is reachable in the view; include_versions=true lists them. /photos gains the parallel arrays photo_display_paths / photo_version_counts, memories / search hits / /photos/exif rows gain display_path / version_count, all skipped when empty. GET /image/versions and PUT /image/versions/display (library optional). Tags and favorites on a version path bind to the original; faces, CLIP and duplicate grouping skip version hashes; insight generation redirects to the original. Orphan GC and the back-ref refresh cover the new tables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
044240037a
commit
080a75c336
24 files changed
+1614
-47
No files matched your search
@@ -0,0 +1,252 @@
|
||||
//! `GET /image/versions` and `PUT /image/versions/display`.
|
||||
//!
|
||||
//! Both take the path of the original *or* of any version, and answer about
|
||||
//! the whole set. The display choice is global (not per user).
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use actix_web::{HttpResponse, Responder, get, put, web};
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::data::Claims;
|
||||
use crate::database::ExifDao;
|
||||
use crate::database::version_dao::VersionDao;
|
||||
use crate::libraries;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::normalize_path;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VersionsQuery {
|
||||
pub path: String,
|
||||
pub library: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetDisplayBody {
|
||||
pub path: String,
|
||||
/// Content hash of the version to display; `null` / absent = the original.
|
||||
#[serde(default)]
|
||||
pub display_hash: Option<String>,
|
||||
pub library: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct FileRef {
|
||||
pub path: String,
|
||||
pub library_id: i32,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct ReferenceEntry {
|
||||
pub hash: String,
|
||||
/// Where the reference photo lives, if it has been ingested.
|
||||
pub path: Option<String>,
|
||||
pub library_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct VersionEntry {
|
||||
pub path: String,
|
||||
pub library_id: i32,
|
||||
pub hash: String,
|
||||
pub kind: String,
|
||||
pub source: String,
|
||||
pub created_at: i64,
|
||||
pub references: Vec<ReferenceEntry>,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct VersionsResponse {
|
||||
/// `null` when the original's file is gone (its versions then show up as
|
||||
/// standalone photos).
|
||||
pub original: Option<FileRef>,
|
||||
pub versions: Vec<VersionEntry>,
|
||||
/// The globally displayed version; `null` = the original.
|
||||
pub display_hash: Option<String>,
|
||||
}
|
||||
|
||||
/// The photo a request path belongs to.
|
||||
struct Resolved {
|
||||
original_hash: String,
|
||||
/// Library of the row the path named; preferred when picking files.
|
||||
library_id: i32,
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
app_state: &AppState,
|
||||
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
||||
path: &str,
|
||||
library: Option<&str>,
|
||||
) -> Result<Resolved, HttpResponse> {
|
||||
let library_id = match libraries::resolve_library_param_state(app_state, library) {
|
||||
Ok(lib) => lib.map(|l| l.id),
|
||||
Err(msg) => return Err(HttpResponse::BadRequest().body(msg)),
|
||||
};
|
||||
let path = normalize_path(path);
|
||||
let mut rows = exif_dao
|
||||
.lock()
|
||||
.expect("Unable to lock ExifDao")
|
||||
.get_exif_batch(library_id, std::slice::from_ref(&path))
|
||||
.map_err(|_| HttpResponse::InternalServerError().finish())?;
|
||||
// Without `library`, a path can name rows in several libraries. Take the
|
||||
// earliest hashed one; it then resolves to its original below, so a
|
||||
// version in one library and its original in another agree.
|
||||
rows.sort_by_key(|r| r.id);
|
||||
let Some((row, hash)) = rows
|
||||
.iter()
|
||||
.find_map(|r| r.content_hash.clone().map(|h| (r, h)))
|
||||
else {
|
||||
return Err(HttpResponse::NotFound().body("no indexed file at that path"));
|
||||
};
|
||||
let link = app_state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao")
|
||||
.get_link(&hash)
|
||||
.map_err(|_| HttpResponse::InternalServerError().finish())?;
|
||||
Ok(Resolved {
|
||||
original_hash: link.map(|l| l.original_hash).unwrap_or(hash),
|
||||
library_id: row.library_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Everything about the set around `original_hash`. Reads fall back to the
|
||||
/// original when the stored display choice has no live row.
|
||||
pub fn build_response(
|
||||
dao: &mut dyn VersionDao,
|
||||
original_hash: &str,
|
||||
prefer_library: i32,
|
||||
) -> Result<VersionsResponse, crate::database::DbError> {
|
||||
let original = dao
|
||||
.files_for_hashes(&[original_hash.to_string()], Some(prefer_library))?
|
||||
.remove(original_hash)
|
||||
.map(|(library_id, path)| FileRef {
|
||||
path,
|
||||
library_id,
|
||||
hash: original_hash.to_string(),
|
||||
});
|
||||
|
||||
let files = dao.list_versions(original_hash)?;
|
||||
let mut versions = Vec::with_capacity(files.len());
|
||||
for f in files {
|
||||
let reference_hashes = dao.list_references(&f.version_hash)?;
|
||||
let located = dao.files_for_hashes(&reference_hashes, Some(f.library_id))?;
|
||||
let references = reference_hashes
|
||||
.into_iter()
|
||||
.map(|hash| {
|
||||
let found = located.get(&hash);
|
||||
ReferenceEntry {
|
||||
path: found.map(|(_, p)| p.clone()),
|
||||
library_id: found.map(|(l, _)| *l),
|
||||
hash,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
versions.push(VersionEntry {
|
||||
path: f.rel_path,
|
||||
library_id: f.library_id,
|
||||
hash: f.version_hash,
|
||||
kind: f.kind,
|
||||
source: f.source,
|
||||
created_at: f.created_at,
|
||||
references,
|
||||
width: f.width,
|
||||
height: f.height,
|
||||
});
|
||||
}
|
||||
|
||||
let display_hash = dao
|
||||
.get_display(original_hash)?
|
||||
.filter(|d| versions.iter().any(|v| &v.hash == d));
|
||||
Ok(VersionsResponse {
|
||||
original,
|
||||
versions,
|
||||
display_hash,
|
||||
})
|
||||
}
|
||||
|
||||
#[get("/image/versions")]
|
||||
#[tracing::instrument(name = "get_versions", skip_all)]
|
||||
pub async fn get_versions(
|
||||
_: Claims,
|
||||
q: web::Query<VersionsQuery>,
|
||||
app_state: web::Data<AppState>,
|
||||
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
|
||||
) -> impl Responder {
|
||||
let resolved = match resolve(&app_state, &exif_dao, &q.path, q.library.as_deref()) {
|
||||
Ok(r) => r,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let mut dao = app_state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao");
|
||||
match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) {
|
||||
Ok(body) => HttpResponse::Ok().json(body),
|
||||
Err(e) => {
|
||||
warn!("get_versions failed: {:?}", e);
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[put("/image/versions/display")]
|
||||
#[tracing::instrument(name = "put_version_display", skip_all)]
|
||||
pub async fn put_display(
|
||||
_: Claims,
|
||||
// The client also sends `library` as a query parameter, like its other
|
||||
// path-keyed calls; the body field wins when both are given.
|
||||
query: web::Query<LibraryQuery>,
|
||||
body: web::Json<SetDisplayBody>,
|
||||
app_state: web::Data<AppState>,
|
||||
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
|
||||
) -> impl Responder {
|
||||
let library = body.library.as_deref().or(query.library.as_deref());
|
||||
let resolved = match resolve(&app_state, &exif_dao, &body.path, library) {
|
||||
Ok(r) => r,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let mut dao = app_state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao");
|
||||
|
||||
// Choosing the original is the same as clearing the choice.
|
||||
let wanted = body
|
||||
.display_hash
|
||||
.as_deref()
|
||||
.filter(|h| *h != resolved.original_hash);
|
||||
if let Some(hash) = wanted {
|
||||
let current =
|
||||
match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("put_display lookup failed: {:?}", e);
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
};
|
||||
if !current.versions.iter().any(|v| v.hash == hash) {
|
||||
return HttpResponse::BadRequest().body("display_hash is not a version of this photo");
|
||||
}
|
||||
}
|
||||
if let Err(e) = dao.set_display(&resolved.original_hash, wanted) {
|
||||
warn!("put_display failed: {:?}", e);
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) {
|
||||
Ok(body) => HttpResponse::Ok().json(body),
|
||||
Err(e) => {
|
||||
warn!("put_display response failed: {:?}", e);
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LibraryQuery {
|
||||
pub library: Option<String>,
|
||||
}
|
||||
Reference in new issue
Block a user