feat: photo/video capture-date pipeline + premium UI/UX overhaul
Backend — Photos timeline now groups by real capture date instead of upload time. New MediaMetadataService (FileLifecycleHook) extracts EXIF DateTimeOriginal from images and container creation_time from videos (mov/mp4/mkv) via nom-exif, timezone-correct (OffsetTimeOriginal), persisting captured_at so the existing media_sort_date trigger takes over. Adds POST /admin/photos/metadata/reextract to backfill existing media. Falls back to upload date when no embedded date exists. Frontend — premium grid cards: combined metadata line (relative date · size, owner avatar when shared), custom selection checkbox with a clear checked state, uniform full-width 4:3 thumbnail tiles independent of filename length, centered file-type icons, and a hit-test fix so checkbox/star/kebab clicks reach the controls (the decorative thumbnail no longer captures pointer events). Notification messages internationalised across all 16 locales. Broader polish: design tokens, a11y/focus-visible states, brand + PWA assets. Chore — bump semver-compatible dependencies (cargo upgrade); add nom-exif 3.6.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,7 @@ use crate::infrastructure::services::chunked_upload_service::ChunkedUploadServic
|
||||
use crate::infrastructure::services::dedup_service::DedupService;
|
||||
use crate::infrastructure::services::image_transcode_service::ImageTranscodeService;
|
||||
use crate::infrastructure::services::jwt_service::JwtTokenService;
|
||||
use crate::infrastructure::services::media_metadata_service::MediaMetadataService;
|
||||
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
|
||||
use crate::infrastructure::services::path_resolver_service::PathResolverService;
|
||||
use crate::infrastructure::services::thumbnail_service::{ThumbnailRefreshHook, ThumbnailService};
|
||||
@@ -340,6 +341,10 @@ impl AppServiceFactory {
|
||||
// Audio metadata service — created here so it can be wired into file_lifecycle.
|
||||
let audio_metadata_service = self.create_audio_metadata_service(db_pool);
|
||||
|
||||
// Image/video capture-metadata service — extracts EXIF/container capture
|
||||
// dates so the Photos timeline groups by real capture time, not upload time.
|
||||
let media_metadata_service = self.create_media_metadata_service(db_pool);
|
||||
|
||||
// ThumbnailRefreshHook: handles FileLifecycleHook events (create/update/delete).
|
||||
// Implemented on ThumbnailRefreshHook (not ThumbnailService) to avoid circular Arc:
|
||||
// DedupService → BlobLifecycleService → ThumbnailRefreshHook → DedupService.
|
||||
@@ -353,6 +358,7 @@ impl AppServiceFactory {
|
||||
if let Some(audio) = &audio_metadata_service {
|
||||
fls = fls.with_hook(audio.clone());
|
||||
}
|
||||
fls = fls.with_hook(media_metadata_service.clone());
|
||||
let file_lifecycle = Arc::new(fls);
|
||||
|
||||
Ok(CoreServices {
|
||||
@@ -361,6 +367,7 @@ impl AppServiceFactory {
|
||||
thumbnail_service,
|
||||
file_lifecycle,
|
||||
audio_metadata_service,
|
||||
media_metadata_service,
|
||||
chunked_upload_service,
|
||||
image_transcode_service,
|
||||
dedup_service,
|
||||
@@ -538,6 +545,7 @@ impl AppServiceFactory {
|
||||
favorites_service: None, // Configured later with create_favorites_service
|
||||
recent_service: None, // Configured later with create_recent_service
|
||||
audio_metadata_service: core.audio_metadata_service.clone(),
|
||||
media_metadata_service: core.media_metadata_service.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,6 +565,16 @@ impl AppServiceFactory {
|
||||
)))
|
||||
}
|
||||
|
||||
/// Creates the image/video capture-metadata service (EXIF + container
|
||||
/// creation dates). Always enabled — the Photos timeline relies on it.
|
||||
pub fn create_media_metadata_service(
|
||||
&self,
|
||||
db_pool: &Arc<PgPool>,
|
||||
) -> Arc<MediaMetadataService> {
|
||||
let blob_root = self.storage_path.join(".blobs");
|
||||
Arc::new(MediaMetadataService::new(db_pool.clone(), blob_root))
|
||||
}
|
||||
|
||||
/// Creates the trash service
|
||||
pub async fn create_trash_service(
|
||||
&self,
|
||||
@@ -1448,6 +1466,8 @@ pub struct CoreServices {
|
||||
/// Composite lifecycle dispatcher — wires thumbnails + audio metadata for all file events.
|
||||
pub file_lifecycle: Arc<FileLifecycleService>,
|
||||
pub audio_metadata_service: Option<Arc<AudioMetadataService>>,
|
||||
/// Image/video capture-metadata extractor (EXIF + container dates).
|
||||
pub media_metadata_service: Arc<MediaMetadataService>,
|
||||
pub chunked_upload_service: Arc<ChunkedUploadService>,
|
||||
pub image_transcode_service: Arc<ImageTranscodeService>,
|
||||
pub dedup_service: Arc<DedupService>,
|
||||
@@ -1487,6 +1507,7 @@ pub struct ApplicationServices {
|
||||
pub favorites_service: Option<Arc<FavoritesService>>,
|
||||
pub recent_service: Option<Arc<RecentService>>,
|
||||
pub audio_metadata_service: Option<Arc<AudioMetadataService>>,
|
||||
pub media_metadata_service: Arc<MediaMetadataService>,
|
||||
}
|
||||
|
||||
/// Container for authentication services
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
//!
|
||||
//! Performance: < 1µs for the `infer` check (reads only header bytes, no allocation).
|
||||
|
||||
use std::path::Path;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
/// Maximum bytes needed for magic-byte detection. Upload ingestion peeks
|
||||
/// this many bytes off the stream before forwarding them unchanged.
|
||||
pub const MAGIC_BYTES_LEN: usize = 8192;
|
||||
@@ -53,6 +56,58 @@ pub fn refine_content_type(buf: &[u8], filename: &str, claimed: &str) -> String
|
||||
claimed.to_string()
|
||||
}
|
||||
|
||||
/// Detect the `Content-Type` to serve for an already-encoded thumbnail.
|
||||
///
|
||||
/// The slow encode path re-encodes to JPEG, but the fast path stores the
|
||||
/// source image as-is (PNG / GIF / WebP), so the handler must not blindly
|
||||
/// claim `image/jpeg`. Detects the real format from magic bytes, defaulting
|
||||
/// to `image/jpeg` (the slow-path output) when detection is inconclusive.
|
||||
pub fn thumbnail_content_type(data: &[u8]) -> &'static str {
|
||||
infer::get(data)
|
||||
.map(|kind| kind.mime_type())
|
||||
.filter(|mime| mime.starts_with("image/"))
|
||||
.unwrap_or("image/jpeg")
|
||||
}
|
||||
|
||||
/// Async helper: reads the first bytes of a file on disk and refines the MIME type.
|
||||
///
|
||||
/// Designed for the upload path where the file has been spooled to a temp path.
|
||||
pub async fn refine_content_type_from_file(
|
||||
temp_path: &Path,
|
||||
filename: &str,
|
||||
claimed: &str,
|
||||
) -> String {
|
||||
// Fast path: if the client gave us a specific type, trust it
|
||||
if !claimed.is_empty()
|
||||
&& claimed != "application/octet-stream"
|
||||
&& claimed != "binary/octet-stream"
|
||||
{
|
||||
return claimed.to_string();
|
||||
}
|
||||
|
||||
// Read only the first bytes needed for magic detection (not the whole file).
|
||||
match tokio::fs::File::open(temp_path).await {
|
||||
Ok(mut file) => {
|
||||
let mut buf = vec![0u8; MAGIC_BYTES_LEN];
|
||||
let n = file.read(&mut buf).await.unwrap_or(0);
|
||||
refine_content_type(&buf[..n], filename, claimed)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"MIME detection: failed to read {} for magic bytes: {}",
|
||||
temp_path.display(),
|
||||
e
|
||||
);
|
||||
// Fall back to extension
|
||||
let guess = mime_guess::from_path(filename);
|
||||
if let Some(mime) = guess.first() {
|
||||
return mime.to_string();
|
||||
}
|
||||
claimed.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -80,6 +135,20 @@ mod tests {
|
||||
assert_eq!(result, "image/jpeg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_content_type_detects_real_format() {
|
||||
// Fast-path thumbnails keep the source format — serve it accurately.
|
||||
let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
|
||||
assert_eq!(thumbnail_content_type(png), "image/png");
|
||||
|
||||
let jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF";
|
||||
assert_eq!(thumbnail_content_type(jpeg), "image/jpeg");
|
||||
|
||||
// Inconclusive bytes default to JPEG (the slow-path encoder output).
|
||||
assert_eq!(thumbnail_content_type(b"garbage"), "image/jpeg");
|
||||
assert_eq!(thumbnail_content_type(b""), "image/jpeg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_octet_stream_also_triggers_detection() {
|
||||
let jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF";
|
||||
|
||||
Reference in New Issue
Block a user