diff --git a/CLAUDE.md b/CLAUDE.md index 4766653..b0faf80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -364,7 +364,8 @@ to the original, as `/aiedits/.[.].png`. A **derived clip** (kind `animated`, `CLIP_KINDS`) is a video made from a photo (Enhance "Animate", `.animated[.].mp4`): linked, hidden and lifecycled exactly like a version, but never counted as one (`photo_version_counts`, -`version_count`), never a display choice (`PUT /image/versions/display` answers 400 +`version_count`; they have their own `photo_clip_counts` / `clip_count`, so clients +know to fetch `/image/versions` for a photo with only clips), never a display choice (`PUT /image/versions/display` answers 400 `clip_not_displayable`), and listed apart, as `clips` in `GET /image/versions`. ImageApi only detects it (library roots stay read-only) and shows original + versions as **one photo**; a *global* choice (not per user) says which file is displayed. @@ -751,8 +752,9 @@ GET /image/versions?path=...&library=... (path of the original or any version POST /enhance ... (Enhance on demand; see "Enhance on demand") PUT /image/versions/display (body { path, display_hash|null, library? }; global choice) // /photos hides versions behind their original (include_versions=true lists them) and adds -// parallel arrays photo_display_paths / photo_version_counts; memories items, search hits and -// /photos/exif rows carry display_path / version_count. All skipped when there are none. +// parallel arrays photo_display_paths / photo_version_counts / photo_clip_counts; memories items, +// search hits and /photos/exif rows carry display_path / version_count / clip_count. All skipped +// when there are none. // Memories (week-based grouping) GET /memories?path=...&recursive=true diff --git a/src/data/mod.rs b/src/data/mod.rs index 62646ef..147c700 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -125,6 +125,11 @@ pub struct PhotosResponse { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub photo_version_counts: Vec, + /// How many derived clips (videos made from the photo, e.g. Animate) + /// stand behind `photos[i]`; skipped when the page has none. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub photo_clip_counts: Vec, + // Pagination metadata (only present when limit is set) #[serde(skip_serializing_if = "Option::is_none")] pub total_count: Option, diff --git a/src/database/version_dao.rs b/src/database/version_dao.rs index 2f8e2f0..bc8be50 100644 --- a/src/database/version_dao.rs +++ b/src/database/version_dao.rs @@ -34,8 +34,17 @@ pub fn is_clip_kind(kind: &str) -> bool { /// `pv.kind NOT IN ()`, for queries about versions proper. fn not_a_clip_sql(alias: &str) -> String { + format!("{alias}.kind NOT IN ({})", clip_kinds_sql()) +} + +/// `pv.kind IN ()`. +fn is_a_clip_sql(alias: &str) -> String { + format!("{alias}.kind IN ({})", clip_kinds_sql()) +} + +fn clip_kinds_sql() -> String { let kinds: Vec = CLIP_KINDS.iter().map(|k| format!("'{k}'")).collect(); - format!("{alias}.kind NOT IN ({})", kinds.join(", ")) + kinds.join(", ") } /// `date_taken_source` on a version row whose date, GPS and camera fields @@ -181,11 +190,14 @@ pub struct HashedFile { pub rel_path: String, } -/// What `/photos` adds next to an original: how many live versions it has -/// and, if the global choice isn't the original, the file to display. +/// What `/photos` adds next to an original: how many live versions and +/// derived clips it has and, if the global choice isn't the original, the +/// file to display. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct PhotoVersionSummary { pub version_count: u32, + /// Live derived clips (`CLIP_KINDS`); never part of `version_count`. + pub clip_count: u32, /// `(library_id, rel_path)` of the chosen version file; the library is /// always the original's own. pub display: Option<(i32, String)>, @@ -735,6 +747,8 @@ impl VersionDao for SqliteVersionDao { rel_path: String, #[diesel(sql_type = BigInt)] version_count: i64, + #[diesel(sql_type = BigInt)] + clip_count: i64, } #[derive(QueryableByName)] struct DisplayRow { @@ -754,10 +768,16 @@ impl VersionDao for SqliteVersionDao { WHERE pv.original_hash = o.content_hash \ AND {not_clip} \ AND EXISTS (SELECT 1 FROM image_exif v \ - WHERE v.content_hash = pv.version_hash)) AS version_count \ + WHERE v.content_hash = pv.version_hash)) AS version_count, \ + (SELECT COUNT(*) FROM photo_versions pv \ + WHERE pv.original_hash = o.content_hash \ + AND {is_clip} \ + AND EXISTS (SELECT 1 FROM image_exif v \ + WHERE v.content_hash = pv.version_hash)) AS clip_count \ FROM image_exif o \ WHERE o.content_hash IN (SELECT original_hash FROM photo_versions)", not_clip = not_a_clip_sql("pv"), + is_clip = is_a_clip_sql("pv"), )) .load::(&mut *conn) .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; @@ -790,7 +810,7 @@ impl VersionDao for SqliteVersionDao { let mut out = HashMap::new(); for o in originals { - if o.version_count == 0 { + if o.version_count == 0 && o.clip_count == 0 { continue; } // Clients fetch the display file through the original's @@ -803,6 +823,7 @@ impl VersionDao for SqliteVersionDao { (o.library_id, o.rel_path), PhotoVersionSummary { version_count: o.version_count as u32, + clip_count: o.clip_count as u32, display, }, ); diff --git a/src/files.rs b/src/files.rs index db93d66..d4538e5 100644 --- a/src/files.rs +++ b/src/files.rs @@ -574,7 +574,7 @@ pub async fn list_photos( span.set_attribute("total_count", total_count.to_string()); span.set_status(Status::Ok); - let (photo_display_paths, photo_version_counts) = + let (photo_display_paths, photo_version_counts, photo_clip_counts) = version_arrays(&app_state, &tagged_files, &photo_libraries); HttpResponse::Ok().json(PhotosResponse { photos: tagged_files, @@ -582,6 +582,7 @@ pub async fn list_photos( photo_libraries, photo_display_paths, photo_version_counts, + photo_clip_counts, total_count: pagination_metadata.0, has_more: pagination_metadata.1, next_offset: pagination_metadata.2, @@ -936,7 +937,7 @@ pub async fn list_photos( span.set_attribute("total_count", total_count.to_string()); span.set_status(Status::Ok); - let (photo_display_paths, photo_version_counts) = + let (photo_display_paths, photo_version_counts, photo_clip_counts) = version_arrays(&app_state, &response_files, &response_libraries); HttpResponse::Ok().json(PhotosResponse { photos: response_files, @@ -944,6 +945,7 @@ pub async fn list_photos( photo_libraries: response_libraries, photo_display_paths, photo_version_counts, + photo_clip_counts, total_count: pagination_metadata.0, has_more: pagination_metadata.1, next_offset: pagination_metadata.2, @@ -970,24 +972,26 @@ fn hidden_version_rows( } /// The `/photos` parallel arrays for a page: `photo_display_paths` (the file -/// to show when the global choice isn't the original) and -/// `photo_version_counts`. Each is empty, so skipped on the wire, when no -/// photo on the page has anything to report. +/// to show when the global choice isn't the original), +/// `photo_version_counts` and `photo_clip_counts`. Each is empty, so skipped +/// on the wire, when no photo on the page has anything to report. fn version_arrays( app_state: &AppState, photos: &[String], libraries: &[i32], -) -> (Vec>, Vec) { +) -> (Vec>, Vec, Vec) { let summaries = crate::versions::load_summaries(&app_state.version_dao); if summaries.is_empty() { - return (Vec::new(), Vec::new()); + return (Vec::new(), Vec::new(), Vec::new()); } let mut display: Vec> = Vec::with_capacity(photos.len()); let mut counts: Vec = Vec::with_capacity(photos.len()); + let mut clips: Vec = Vec::with_capacity(photos.len()); for (path, lib) in photos.iter().zip(libraries) { let s = summaries.get(&(*lib, path.clone())); display.push(s.and_then(|s| s.display.as_ref().map(|(_, p)| p.clone()))); counts.push(s.map_or(0, |s| s.version_count)); + clips.push(s.map_or(0, |s| s.clip_count)); } if display.iter().all(Option::is_none) { display.clear(); @@ -995,7 +999,10 @@ fn version_arrays( if counts.iter().all(|c| *c == 0) { counts.clear(); } - (display, counts) + if clips.iter().all(|c| *c == 0) { + clips.clear(); + } + (display, counts, clips) } fn sort( diff --git a/src/handlers/favorites.rs b/src/handlers/favorites.rs index afd5a48..12cab87 100644 --- a/src/handlers/favorites.rs +++ b/src/handlers/favorites.rs @@ -47,6 +47,7 @@ pub async fn favorites( photo_libraries: Vec::new(), photo_display_paths: Vec::new(), photo_version_counts: Vec::new(), + photo_clip_counts: Vec::new(), total_count: None, has_more: None, next_offset: None, diff --git a/src/versions/integration_tests.rs b/src/versions/integration_tests.rs index f9230dc..eaff862 100644 --- a/src/versions/integration_tests.rs +++ b/src/versions/integration_tests.rs @@ -798,10 +798,12 @@ async fn an_animated_clip_links_hides_and_is_listed_apart_from_versions() { ); let app = photos_app!(fx); - // Hidden behind the photo, like a version, and not counted as one. + // Hidden behind the photo, like a version, and counted as a clip, not a + // version (so the viewer knows to fetch /image/versions for it). let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc"); assert_eq!(body["photos"], serde_json::json!(["2014/IMG_1.JPG"])); assert!(body.get("photo_version_counts").is_none(), "{body}"); + assert_eq!(body["photo_clip_counts"], serde_json::json!([1])); // Listed under `clips`, from the photo's path or the clip's own. for path in ["2014/IMG_1.JPG", rel] { diff --git a/src/versions/mod.rs b/src/versions/mod.rs index 45d30e3..8741f99 100644 --- a/src/versions/mod.rs +++ b/src/versions/mod.rs @@ -363,25 +363,34 @@ pub fn link_pending( /// responses stay byte-identical for libraries that have none. /// /// `display_path` is `null` when the original is displayed, a rel_path when a -/// version is. `/photos` carries the same facts as the parallel arrays -/// `photo_display_paths` / `photo_version_counts` instead. +/// version is. `clip_count` counts derived clips (videos made from the +/// photo), which are not versions. `/photos` carries the same facts as the +/// parallel arrays `photo_display_paths` / `photo_version_counts` / +/// `photo_clip_counts` instead. #[derive(Debug, Clone, Default, serde::Serialize)] pub struct VersionFields { #[serde(skip_serializing_if = "Option::is_none")] pub display_path: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub version_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub clip_count: Option, } impl VersionFields { pub fn from_summary(summary: Option<&version_dao::PhotoVersionSummary>) -> Self { - match summary { - Some(s) if s.version_count > 0 => VersionFields { - display_path: Some(s.display.as_ref().map(|(_, p)| p.clone())), - version_count: Some(s.version_count), - }, - _ => VersionFields::default(), + let Some(s) = summary else { + return VersionFields::default(); + }; + let mut fields = VersionFields::default(); + if s.version_count > 0 { + fields.display_path = Some(s.display.as_ref().map(|(_, p)| p.clone())); + fields.version_count = Some(s.version_count); } + if s.clip_count > 0 { + fields.clip_count = Some(s.clip_count); + } + fields } } @@ -451,6 +460,7 @@ mod tests { assert_eq!(json(None), serde_json::json!({"path": "a.jpg"})); let plain = version_dao::PhotoVersionSummary { version_count: 2, + clip_count: 0, display: None, }; assert_eq!( @@ -459,6 +469,7 @@ mod tests { ); let shown = version_dao::PhotoVersionSummary { version_count: 1, + clip_count: 0, display: Some((1, "aiedits/a.jpg.restored.png".into())), }; assert_eq!( @@ -469,6 +480,16 @@ mod tests { "version_count": 1 }) ); + // Clips only: no display/version fields, just the clip count. + let clips_only = version_dao::PhotoVersionSummary { + version_count: 0, + clip_count: 2, + display: None, + }; + assert_eq!( + json(Some(&clips_only)), + serde_json::json!({"path": "a.jpg", "clip_count": 2}) + ); } #[test]