feat(photos): expose image width/height on the /api/photos timeline

list_media_files now LEFT JOINs storage.file_metadata and returns each
photo's pixel dimensions next to the sort date. The endpoint wraps FileDto
in a flattened PhotoDto carrying width/height, so the gallery can lay tiles
out at their true aspect ratio (justified layout) without a second per-file
metadata round-trip and without layout shift. FileItem gains optional
width/height. No change to FileDto or its other construction sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
This commit is contained in:
Claude
2026-06-19 10:15:20 +00:00
parent 824df4d421
commit 8d09589588
3 changed files with 37 additions and 10 deletions
@@ -20,6 +20,8 @@ type MediaFileRow = (
String, // blob_hash String, // blob_hash
Option<Uuid>, // user_id Option<Uuid>, // user_id
i64, // sort_date i64, // sort_date
Option<i32>, // width
Option<i32>, // height
); );
use bytes::Bytes; use bytes::Bytes;
@@ -370,7 +372,7 @@ impl FileBlobReadRepository {
owner_id: Uuid, owner_id: Uuid,
before: Option<i64>, before: Option<i64>,
limit: i64, limit: i64,
) -> Result<(Vec<File>, Vec<i64>), DomainError> { ) -> Result<(Vec<File>, Vec<i64>, Vec<(Option<i32>, Option<i32>)>), DomainError> {
let rows: Vec<MediaFileRow> = sqlx::query_as( let rows: Vec<MediaFileRow> = sqlx::query_as(
r#" r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
@@ -379,9 +381,11 @@ impl FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.updated_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash, fi.blob_hash,
fi.user_id, fi.user_id,
EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date,
fm.width, fm.height
FROM storage.files fi FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id
WHERE fi.user_id = $1 WHERE fi.user_id = $1
AND NOT fi.is_trashed AND NOT fi.is_trashed
AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%') AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%')
@@ -400,15 +404,17 @@ impl FileBlobReadRepository {
let mut files = Vec::with_capacity(rows.len()); let mut files = Vec::with_capacity(rows.len());
let mut sort_dates = Vec::with_capacity(rows.len()); let mut sort_dates = Vec::with_capacity(rows.len());
let mut dims = Vec::with_capacity(rows.len());
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd) in rows { for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd, w, h) in rows {
files.push(Self::row_to_file( files.push(Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid,
)?); )?);
sort_dates.push(sd); sort_dates.push(sd);
dims.push((w, h));
} }
Ok((files, sort_dates)) Ok((files, sort_dates, dims))
} }
} }
+25 -6
View File
@@ -4,7 +4,7 @@ use axum::{
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
}; };
use serde::Deserialize; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use tracing::{error, info}; use tracing::{error, info};
@@ -21,6 +21,20 @@ pub struct PhotosQueryParams {
pub limit: Option<i64>, pub limit: Option<i64>,
} }
/// Photos-timeline item: a `FileDto` plus the image's original pixel
/// dimensions (from EXIF/metadata), flattened into the same JSON shape so
/// the gallery can lay tiles out at their true aspect ratio without a
/// second per-file metadata round-trip.
#[derive(Serialize)]
struct PhotoDto {
#[serde(flatten)]
file: FileDto,
#[serde(skip_serializing_if = "Option::is_none")]
width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
height: Option<u32>,
}
/// Lists all image/video files for the authenticated user, sorted by /// Lists all image/video files for the authenticated user, sorted by
/// capture date (EXIF DateTimeOriginal) falling back to upload date. /// capture date (EXIF DateTimeOriginal) falling back to upload date.
/// ///
@@ -55,17 +69,22 @@ pub async fn list_photos(
.list_media_files(user_id, params.before, limit) .list_media_files(user_id, params.before, limit)
.await .await
{ {
Ok((files, sort_dates)) => { Ok((files, sort_dates, dims)) => {
info!("Photos: returned {} media files for user", files.len()); info!("Photos: returned {} media files for user", files.len());
// Convert to DTOs with sort_date populated // Convert to DTOs with sort_date + pixel dimensions populated.
let dtos: Vec<FileDto> = files let dtos: Vec<PhotoDto> = files
.into_iter() .into_iter()
.zip(sort_dates.iter()) .zip(sort_dates.iter())
.map(|(file, &sd)| { .zip(dims.iter())
.map(|((file, &sd), &(w, h))| {
let mut dto = FileDto::from(file); let mut dto = FileDto::from(file);
dto.sort_date = Some(sd as u64); dto.sort_date = Some(sd as u64);
dto PhotoDto {
file: dto,
width: w.map(|v| v.max(0) as u32),
height: h.map(|v| v.max(0) as u32),
}
}) })
.collect(); .collect();
+2
View File
@@ -45,6 +45,8 @@
* @property {number} size * @property {number} size
* @property {string} size_formatted * @property {string} size_formatted
* @property {number} sort_date * @property {number} sort_date
* @property {number} [width] original pixel width (photos timeline only)
* @property {number} [height] original pixel height (photos timeline only)
* @property {string} etag opaque HTTP ETag, for If-Match / If-None-Match * @property {string} etag opaque HTTP ETag, for If-Match / If-None-Match
* @property {string} content_hash raw BLAKE3 content hash, for dedup checks * @property {string} content_hash raw BLAKE3 content hash, for dedup checks
* @property {string} [snippet] plain-text fragment around a content match (search results only) * @property {string} [snippet] plain-text fragment around a content match (search results only)