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