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:
Cameron CordesandClaude Opus 5.5 committed 2026-10-30 11:28:34 -04:00
1 parent 044240037a
commit 080a75c336
24 files changed
+1614 -47

No files matched your search

+252
View File
@@ -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>,
}