//! `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 crate::error::ApiError; 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, is_clip_kind}; use crate::libraries; use crate::state::AppState; use crate::utils::normalize_path; #[derive(Debug, Deserialize)] pub struct VersionsQuery { pub path: String, pub library: Option, } #[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, pub library: Option, } #[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, pub library_id: Option, } #[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, pub width: Option, pub height: Option, } #[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, pub versions: Vec, /// Derived clips: videos made from the photo (kind `animated`). Same /// shape as `versions`, but never a display choice. pub clips: Vec, /// The globally displayed version; `null` = the original. pub display_hash: Option, } /// 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>, path: &str, library: Option<&str>, ) -> Result { let library_id = match libraries::resolve_library_param_state(app_state, library) { Ok(lib) => lib.map(|l| l.id), Err(msg) => return Err(libraries::unknown_library(msg).into_response()), }; 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(|_| ApiError::internal("internal error").into_response())?; // 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(ApiError::not_found( "photo_not_indexed", "This photo isn't indexed yet. Try again in a minute.", ) .into_response()); }; let link = app_state .version_dao .lock() .expect("Unable to lock VersionDao") .get_link(&hash) .map_err(|_| ApiError::internal("internal error").into_response())?; 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 { 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()); let mut clips = Vec::new(); 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(); let list = if is_clip_kind(&f.kind) { &mut clips } else { &mut versions }; list.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, }); } // Clients fetch the display file through the original's library, so a // choice with no row there reads as the original (as `/photos` does). let display_hash = dao.get_display(original_hash)?.filter(|d| { versions.iter().any(|v| { &v.hash == d && original .as_ref() .is_none_or(|o| o.library_id == v.library_id) }) }); Ok(VersionsResponse { original, versions, clips, display_hash, }) } #[get("/image/versions")] #[tracing::instrument(name = "get_versions", skip_all)] pub async fn get_versions( _: Claims, q: web::Query, app_state: web::Data, exif_dao: web::Data>>, ) -> 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); ApiError::internal("internal error").into_response() } } } #[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, body: web::Json, app_state: web::Data, exif_dao: web::Data>>, ) -> 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 ApiError::internal("internal error").into_response(); } }; if current.clips.iter().any(|c| c.hash == hash) { return ApiError::bad_request( "clip_not_displayable", "A clip made from this photo can't be shown in its place", ) .into_response(); } if !current.versions.iter().any(|v| v.hash == hash) { return ApiError::bad_request( "not_a_version", "That file isn't a version of this photo", ) .into_response(); } // The display file is fetched through the original's library. let original_lib = current.original.as_ref().map(|o| o.library_id); if !current .versions .iter() .any(|v| v.hash == hash && original_lib.is_none_or(|l| l == v.library_id)) { return ApiError::bad_request( "version_in_other_library", "That version isn't in the same library as the original", ) .into_response(); } } if let Err(e) = dao.set_display(&resolved.original_hash, wanted) { warn!("put_display failed: {:?}", e); return ApiError::internal("internal error").into_response(); } 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); ApiError::internal("internal error").into_response() } } } #[derive(Debug, Deserialize)] pub struct LibraryQuery { pub library: Option, }