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:
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user