Clip counts on listings, so clients find photos with only clips #210
No files matched your search
@@ -364,7 +364,8 @@ to the original, as `<dir>/aiedits/<original name>.<kind>[.<n>].png`. A
|
|||||||
**derived clip** (kind `animated`, `CLIP_KINDS`) is a video made from a photo
|
**derived clip** (kind `animated`, `CLIP_KINDS`) is a video made from a photo
|
||||||
(Enhance "Animate", `<name>.animated[.<n>].mp4`): linked, hidden and lifecycled
|
(Enhance "Animate", `<name>.animated[.<n>].mp4`): linked, hidden and lifecycled
|
||||||
exactly like a version, but never counted as one (`photo_version_counts`,
|
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
|
`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
|
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.
|
**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")
|
POST /enhance ... (Enhance on demand; see "Enhance on demand")
|
||||||
PUT /image/versions/display (body { path, display_hash|null, library? }; global choice)
|
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
|
// /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
|
// parallel arrays photo_display_paths / photo_version_counts / photo_clip_counts; memories items,
|
||||||
// /photos/exif rows carry display_path / version_count. All skipped when there are none.
|
// search hits and /photos/exif rows carry display_path / version_count / clip_count. All skipped
|
||||||
|
// when there are none.
|
||||||
|
|
||||||
// Memories (week-based grouping)
|
// Memories (week-based grouping)
|
||||||
GET /memories?path=...&recursive=true
|
GET /memories?path=...&recursive=true
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ pub struct PhotosResponse {
|
|||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub photo_version_counts: Vec<u32>,
|
pub photo_version_counts: Vec<u32>,
|
||||||
|
|
||||||
|
/// 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<u32>,
|
||||||
|
|
||||||
// Pagination metadata (only present when limit is set)
|
// Pagination metadata (only present when limit is set)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub total_count: Option<i64>,
|
pub total_count: Option<i64>,
|
||||||
|
|||||||
@@ -34,8 +34,17 @@ pub fn is_clip_kind(kind: &str) -> bool {
|
|||||||
|
|
||||||
/// `pv.kind NOT IN (<clip kinds>)`, for queries about versions proper.
|
/// `pv.kind NOT IN (<clip kinds>)`, for queries about versions proper.
|
||||||
fn not_a_clip_sql(alias: &str) -> String {
|
fn not_a_clip_sql(alias: &str) -> String {
|
||||||
|
format!("{alias}.kind NOT IN ({})", clip_kinds_sql())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pv.kind IN (<clip kinds>)`.
|
||||||
|
fn is_a_clip_sql(alias: &str) -> String {
|
||||||
|
format!("{alias}.kind IN ({})", clip_kinds_sql())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clip_kinds_sql() -> String {
|
||||||
let kinds: Vec<String> = CLIP_KINDS.iter().map(|k| format!("'{k}'")).collect();
|
let kinds: Vec<String> = 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
|
/// `date_taken_source` on a version row whose date, GPS and camera fields
|
||||||
@@ -181,11 +190,14 @@ pub struct HashedFile {
|
|||||||
pub rel_path: String,
|
pub rel_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What `/photos` adds next to an original: how many live versions it has
|
/// What `/photos` adds next to an original: how many live versions and
|
||||||
/// and, if the global choice isn't the original, the file to display.
|
/// derived clips it has and, if the global choice isn't the original, the
|
||||||
|
/// file to display.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub struct PhotoVersionSummary {
|
pub struct PhotoVersionSummary {
|
||||||
pub version_count: u32,
|
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
|
/// `(library_id, rel_path)` of the chosen version file; the library is
|
||||||
/// always the original's own.
|
/// always the original's own.
|
||||||
pub display: Option<(i32, String)>,
|
pub display: Option<(i32, String)>,
|
||||||
@@ -735,6 +747,8 @@ impl VersionDao for SqliteVersionDao {
|
|||||||
rel_path: String,
|
rel_path: String,
|
||||||
#[diesel(sql_type = BigInt)]
|
#[diesel(sql_type = BigInt)]
|
||||||
version_count: i64,
|
version_count: i64,
|
||||||
|
#[diesel(sql_type = BigInt)]
|
||||||
|
clip_count: i64,
|
||||||
}
|
}
|
||||||
#[derive(QueryableByName)]
|
#[derive(QueryableByName)]
|
||||||
struct DisplayRow {
|
struct DisplayRow {
|
||||||
@@ -754,10 +768,16 @@ impl VersionDao for SqliteVersionDao {
|
|||||||
WHERE pv.original_hash = o.content_hash \
|
WHERE pv.original_hash = o.content_hash \
|
||||||
AND {not_clip} \
|
AND {not_clip} \
|
||||||
AND EXISTS (SELECT 1 FROM image_exif v \
|
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 \
|
FROM image_exif o \
|
||||||
WHERE o.content_hash IN (SELECT original_hash FROM photo_versions)",
|
WHERE o.content_hash IN (SELECT original_hash FROM photo_versions)",
|
||||||
not_clip = not_a_clip_sql("pv"),
|
not_clip = not_a_clip_sql("pv"),
|
||||||
|
is_clip = is_a_clip_sql("pv"),
|
||||||
))
|
))
|
||||||
.load::<OriginalRow>(&mut *conn)
|
.load::<OriginalRow>(&mut *conn)
|
||||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?;
|
||||||
@@ -790,7 +810,7 @@ impl VersionDao for SqliteVersionDao {
|
|||||||
|
|
||||||
let mut out = HashMap::new();
|
let mut out = HashMap::new();
|
||||||
for o in originals {
|
for o in originals {
|
||||||
if o.version_count == 0 {
|
if o.version_count == 0 && o.clip_count == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Clients fetch the display file through the original's
|
// Clients fetch the display file through the original's
|
||||||
@@ -803,6 +823,7 @@ impl VersionDao for SqliteVersionDao {
|
|||||||
(o.library_id, o.rel_path),
|
(o.library_id, o.rel_path),
|
||||||
PhotoVersionSummary {
|
PhotoVersionSummary {
|
||||||
version_count: o.version_count as u32,
|
version_count: o.version_count as u32,
|
||||||
|
clip_count: o.clip_count as u32,
|
||||||
display,
|
display,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+15
-8
@@ -574,7 +574,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
|||||||
span.set_attribute("total_count", total_count.to_string());
|
span.set_attribute("total_count", total_count.to_string());
|
||||||
span.set_status(Status::Ok);
|
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);
|
version_arrays(&app_state, &tagged_files, &photo_libraries);
|
||||||
HttpResponse::Ok().json(PhotosResponse {
|
HttpResponse::Ok().json(PhotosResponse {
|
||||||
photos: tagged_files,
|
photos: tagged_files,
|
||||||
@@ -582,6 +582,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
|||||||
photo_libraries,
|
photo_libraries,
|
||||||
photo_display_paths,
|
photo_display_paths,
|
||||||
photo_version_counts,
|
photo_version_counts,
|
||||||
|
photo_clip_counts,
|
||||||
total_count: pagination_metadata.0,
|
total_count: pagination_metadata.0,
|
||||||
has_more: pagination_metadata.1,
|
has_more: pagination_metadata.1,
|
||||||
next_offset: pagination_metadata.2,
|
next_offset: pagination_metadata.2,
|
||||||
@@ -936,7 +937,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
|||||||
span.set_attribute("total_count", total_count.to_string());
|
span.set_attribute("total_count", total_count.to_string());
|
||||||
span.set_status(Status::Ok);
|
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);
|
version_arrays(&app_state, &response_files, &response_libraries);
|
||||||
HttpResponse::Ok().json(PhotosResponse {
|
HttpResponse::Ok().json(PhotosResponse {
|
||||||
photos: response_files,
|
photos: response_files,
|
||||||
@@ -944,6 +945,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
|||||||
photo_libraries: response_libraries,
|
photo_libraries: response_libraries,
|
||||||
photo_display_paths,
|
photo_display_paths,
|
||||||
photo_version_counts,
|
photo_version_counts,
|
||||||
|
photo_clip_counts,
|
||||||
total_count: pagination_metadata.0,
|
total_count: pagination_metadata.0,
|
||||||
has_more: pagination_metadata.1,
|
has_more: pagination_metadata.1,
|
||||||
next_offset: pagination_metadata.2,
|
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
|
/// The `/photos` parallel arrays for a page: `photo_display_paths` (the file
|
||||||
/// to show when the global choice isn't the original) and
|
/// to show when the global choice isn't the original),
|
||||||
/// `photo_version_counts`. Each is empty, so skipped on the wire, when no
|
/// `photo_version_counts` and `photo_clip_counts`. Each is empty, so skipped
|
||||||
/// photo on the page has anything to report.
|
/// on the wire, when no photo on the page has anything to report.
|
||||||
fn version_arrays(
|
fn version_arrays(
|
||||||
app_state: &AppState,
|
app_state: &AppState,
|
||||||
photos: &[String],
|
photos: &[String],
|
||||||
libraries: &[i32],
|
libraries: &[i32],
|
||||||
) -> (Vec<Option<String>>, Vec<u32>) {
|
) -> (Vec<Option<String>>, Vec<u32>, Vec<u32>) {
|
||||||
let summaries = crate::versions::load_summaries(&app_state.version_dao);
|
let summaries = crate::versions::load_summaries(&app_state.version_dao);
|
||||||
if summaries.is_empty() {
|
if summaries.is_empty() {
|
||||||
return (Vec::new(), Vec::new());
|
return (Vec::new(), Vec::new(), Vec::new());
|
||||||
}
|
}
|
||||||
let mut display: Vec<Option<String>> = Vec::with_capacity(photos.len());
|
let mut display: Vec<Option<String>> = Vec::with_capacity(photos.len());
|
||||||
let mut counts: Vec<u32> = Vec::with_capacity(photos.len());
|
let mut counts: Vec<u32> = Vec::with_capacity(photos.len());
|
||||||
|
let mut clips: Vec<u32> = Vec::with_capacity(photos.len());
|
||||||
for (path, lib) in photos.iter().zip(libraries) {
|
for (path, lib) in photos.iter().zip(libraries) {
|
||||||
let s = summaries.get(&(*lib, path.clone()));
|
let s = summaries.get(&(*lib, path.clone()));
|
||||||
display.push(s.and_then(|s| s.display.as_ref().map(|(_, p)| p.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));
|
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) {
|
if display.iter().all(Option::is_none) {
|
||||||
display.clear();
|
display.clear();
|
||||||
@@ -995,7 +999,10 @@ fn version_arrays(
|
|||||||
if counts.iter().all(|c| *c == 0) {
|
if counts.iter().all(|c| *c == 0) {
|
||||||
counts.clear();
|
counts.clear();
|
||||||
}
|
}
|
||||||
(display, counts)
|
if clips.iter().all(|c| *c == 0) {
|
||||||
|
clips.clear();
|
||||||
|
}
|
||||||
|
(display, counts, clips)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sort(
|
fn sort(
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ pub async fn favorites(
|
|||||||
photo_libraries: Vec::new(),
|
photo_libraries: Vec::new(),
|
||||||
photo_display_paths: Vec::new(),
|
photo_display_paths: Vec::new(),
|
||||||
photo_version_counts: Vec::new(),
|
photo_version_counts: Vec::new(),
|
||||||
|
photo_clip_counts: Vec::new(),
|
||||||
total_count: None,
|
total_count: None,
|
||||||
has_more: None,
|
has_more: None,
|
||||||
next_offset: None,
|
next_offset: None,
|
||||||
|
|||||||
@@ -798,10 +798,12 @@ async fn an_animated_clip_links_hides_and_is_listed_apart_from_versions() {
|
|||||||
);
|
);
|
||||||
let app = photos_app!(fx);
|
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");
|
let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc");
|
||||||
assert_eq!(body["photos"], serde_json::json!(["2014/IMG_1.JPG"]));
|
assert_eq!(body["photos"], serde_json::json!(["2014/IMG_1.JPG"]));
|
||||||
assert!(body.get("photo_version_counts").is_none(), "{body}");
|
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.
|
// Listed under `clips`, from the photo's path or the clip's own.
|
||||||
for path in ["2014/IMG_1.JPG", rel] {
|
for path in ["2014/IMG_1.JPG", rel] {
|
||||||
|
|||||||
+29
-8
@@ -363,25 +363,34 @@ pub fn link_pending(
|
|||||||
/// responses stay byte-identical for libraries that have none.
|
/// responses stay byte-identical for libraries that have none.
|
||||||
///
|
///
|
||||||
/// `display_path` is `null` when the original is displayed, a rel_path when a
|
/// `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
|
/// version is. `clip_count` counts derived clips (videos made from the
|
||||||
/// `photo_display_paths` / `photo_version_counts` instead.
|
/// 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)]
|
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||||
pub struct VersionFields {
|
pub struct VersionFields {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub display_path: Option<Option<String>>,
|
pub display_path: Option<Option<String>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub version_count: Option<u32>,
|
pub version_count: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub clip_count: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VersionFields {
|
impl VersionFields {
|
||||||
pub fn from_summary(summary: Option<&version_dao::PhotoVersionSummary>) -> Self {
|
pub fn from_summary(summary: Option<&version_dao::PhotoVersionSummary>) -> Self {
|
||||||
match summary {
|
let Some(s) = summary else {
|
||||||
Some(s) if s.version_count > 0 => VersionFields {
|
return VersionFields::default();
|
||||||
display_path: Some(s.display.as_ref().map(|(_, p)| p.clone())),
|
};
|
||||||
version_count: Some(s.version_count),
|
let mut fields = VersionFields::default();
|
||||||
},
|
if s.version_count > 0 {
|
||||||
_ => VersionFields::default(),
|
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"}));
|
assert_eq!(json(None), serde_json::json!({"path": "a.jpg"}));
|
||||||
let plain = version_dao::PhotoVersionSummary {
|
let plain = version_dao::PhotoVersionSummary {
|
||||||
version_count: 2,
|
version_count: 2,
|
||||||
|
clip_count: 0,
|
||||||
display: None,
|
display: None,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -459,6 +469,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let shown = version_dao::PhotoVersionSummary {
|
let shown = version_dao::PhotoVersionSummary {
|
||||||
version_count: 1,
|
version_count: 1,
|
||||||
|
clip_count: 0,
|
||||||
display: Some((1, "aiedits/a.jpg.restored.png".into())),
|
display: Some((1, "aiedits/a.jpg.restored.png".into())),
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -469,6 +480,16 @@ mod tests {
|
|||||||
"version_count": 1
|
"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]
|
#[test]
|
||||||
|
|||||||
Reference in new issue
Block a user