diff --git a/Cargo.lock b/Cargo.lock index 4584f0e3..c2f03b67 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -1477,6 +1477,15 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "kamadak-exif" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077" +dependencies = [ + "mutate_once", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1718,6 +1727,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1820,6 +1835,7 @@ dependencies = [ "image", "infer", "jsonwebtoken", + "kamadak-exif", "md-5", "mimalloc", "mime_guess", diff --git a/Cargo.toml b/Cargo.toml index bd58bced..80c6cc5a 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ dotenvy = "0.15.7" moka = { version = "0.12", features = ["future", "sync"] } http-range-header = "0.4" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +kamadak-exif = "0.5" md-5 = "0.10" sha2 = "0.10.9" blake3 = { version = "1.8.3", features = ["rayon"] } diff --git a/db/schema.sql b/db/schema.sql index 85a1ecc7..9aaa5c4e 100755 --- a/db/schema.sql +++ b/db/schema.sql @@ -646,6 +646,28 @@ CREATE INDEX IF NOT EXISTS idx_shares_created_by ON storage.shares(created_by); COMMENT ON TABLE storage.shares IS 'Shared links for files and folders with token-based access'; +-- ── EXIF / media metadata for image and video files ───────────────────── +-- Separate table keeps storage.files lean (most files aren't images). +-- Populated at upload time by the ExifService. +CREATE TABLE IF NOT EXISTS storage.file_metadata ( + file_id UUID PRIMARY KEY REFERENCES storage.files(id) ON DELETE CASCADE, + captured_at TIMESTAMP WITH TIME ZONE, -- EXIF DateTimeOriginal + latitude DOUBLE PRECISION, -- GPS latitude (decimal degrees) + longitude DOUBLE PRECISION, -- GPS longitude (decimal degrees) + camera_make TEXT, -- EXIF Make + camera_model TEXT, -- EXIF Model + orientation SMALLINT, -- EXIF Orientation (1-8) + width INTEGER, -- Original pixel width + height INTEGER, -- Original pixel height + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- For the Photos timeline: ORDER BY captured_at DESC with cursor pagination +CREATE INDEX IF NOT EXISTS idx_file_metadata_captured + ON storage.file_metadata(captured_at DESC) WHERE captured_at IS NOT NULL; + +COMMENT ON TABLE storage.file_metadata IS 'EXIF and media metadata extracted at upload time'; + -- ── Atomic recursive folder copy (WebDAV COPY Depth: infinity) ────────── -- -- Copies the entire subtree rooted at `p_source_id` under `p_target_parent_id`. diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index 18cc815a..1fba6812 100755 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -51,6 +51,11 @@ pub struct FileDto { /// Owner user ID (omitted from JSON when None) #[serde(skip_serializing_if = "Option::is_none")] pub owner_id: Option, + + /// Sort date for Photos timeline — COALESCE(EXIF captured_at, created_at). + /// Only populated by the /api/photos endpoint. + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_date: Option, } impl From for FileDto { @@ -73,6 +78,7 @@ impl From for FileDto { category: Arc::from(category_for(name, mime)), size_formatted: format_file_size(size), owner_id: file.owner_id().map(String::from), + sort_date: None, } } } @@ -112,6 +118,7 @@ impl FileDto { category: Arc::from("Document"), size_formatted: "0 Bytes".to_string(), owner_id: None, + sort_date: None, } } } diff --git a/src/common/di.rs b/src/common/di.rs index 8e4f434b..c89374b0 100755 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -24,7 +24,8 @@ use crate::common::config::AppConfig; use crate::common::errors::DomainError; use crate::infrastructure::repositories::pg::SharePgRepository; use crate::infrastructure::repositories::pg::{ - FileBlobReadRepository, FileBlobWriteRepository, FolderDbRepository, TrashDbRepository, + FileBlobReadRepository, FileBlobWriteRepository, FileMetadataRepository, FolderDbRepository, + TrashDbRepository, }; use crate::infrastructure::services::file_content_cache::{ FileContentCache, FileContentCacheConfig, @@ -214,6 +215,9 @@ impl AppServiceFactory { None }; + // File metadata repository — EXIF/media metadata for images + let file_metadata_repository = Arc::new(FileMetadataRepository::new(db_pool.clone())); + tracing::info!( "Repository services initialized with 100% blob storage model (PG metadata + DedupService blobs)" ); @@ -223,6 +227,7 @@ impl AppServiceFactory { folder_repo_concrete, file_read_repository, file_write_repository, + file_metadata_repository, i18n_repository, trash_repository, } @@ -815,6 +820,7 @@ pub struct RepositoryServices { pub folder_repo_concrete: Arc, pub file_read_repository: Arc, pub file_write_repository: Arc, + pub file_metadata_repository: Arc, pub i18n_repository: Arc, pub trash_repository: Option>, } diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 6207d6fb..ce8525a2 100755 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -144,6 +144,68 @@ impl FileBlobReadRepository { self.hash_cache.insert(file_id.to_owned(), hash.clone()); Ok(hash) } + + /// Lists all image/video files for a user, sorted by capture date (EXIF) or + /// creation date, with cursor-based pagination for the Photos timeline. + /// + /// Returns `(Vec, Vec)` where the second vec contains the + /// `sort_date` epoch for each file (used as pagination cursor). + pub async fn list_media_files( + &self, + owner_id: &str, + before: Option, + limit: i64, + ) -> Result<(Vec, Vec), DomainError> { + let rows: Vec<( + String, // id + String, // name + Option, // folder_id + Option, // folder path + i64, // size + String, // mime_type + i64, // created_at + i64, // updated_at + Option, // user_id + i64, // sort_date + )> = sqlx::query_as( + r#" + SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, + fi.size, fi.mime_type, + EXTRACT(EPOCH FROM fi.created_at)::bigint, + EXTRACT(EPOCH FROM fi.updated_at)::bigint, + fi.user_id::text, + EXTRACT(EPOCH FROM COALESCE(fm.captured_at, fi.created_at))::bigint AS sort_date + FROM storage.files fi + 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 + AND NOT fi.is_trashed + AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%') + AND ($2::bigint IS NULL + OR EXTRACT(EPOCH FROM COALESCE(fm.captured_at, fi.created_at))::bigint < $2::bigint) + ORDER BY COALESCE(fm.captured_at, fi.created_at) DESC + LIMIT $3 + "#, + ) + .bind(owner_id) + .bind(before) + .bind(limit) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_media: {e}")))?; + + let mut files = Vec::with_capacity(rows.len()); + let mut sort_dates = Vec::with_capacity(rows.len()); + + for (id, name, fid, fpath, size, mime, ca, ma, uid, sd) in rows { + files.push(Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, uid, + )?); + sort_dates.push(sd); + } + + Ok((files, sort_dates)) + } } impl FileReadPort for FileBlobReadRepository { diff --git a/src/infrastructure/repositories/pg/file_metadata_repository.rs b/src/infrastructure/repositories/pg/file_metadata_repository.rs new file mode 100644 index 00000000..e7c6d305 --- /dev/null +++ b/src/infrastructure/repositories/pg/file_metadata_repository.rs @@ -0,0 +1,195 @@ +//! PostgreSQL repository for image/video EXIF metadata. + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; +use std::collections::HashMap; +use std::sync::Arc; +use tracing::error; + +use crate::common::errors::DomainError; +use crate::infrastructure::services::exif_service::ExifMetadata; + +/// Metadata as stored/retrieved from the database. +#[derive(Debug, Clone, Serialize)] +pub struct StoredMetadata { + pub file_id: String, + pub captured_at: Option>, + pub latitude: Option, + pub longitude: Option, + pub camera_make: Option, + pub camera_model: Option, + pub orientation: Option, + pub width: Option, + pub height: Option, +} + +/// Repository for `storage.file_metadata` table operations. +pub struct FileMetadataRepository { + pool: Arc, +} + +impl FileMetadataRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + /// Insert or update EXIF metadata for a file. + pub async fn upsert(&self, file_id: &str, meta: &ExifMetadata) -> Result<(), DomainError> { + sqlx::query( + r#" + INSERT INTO storage.file_metadata + (file_id, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height) + VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (file_id) DO UPDATE SET + captured_at = EXCLUDED.captured_at, + latitude = EXCLUDED.latitude, + longitude = EXCLUDED.longitude, + camera_make = EXCLUDED.camera_make, + camera_model = EXCLUDED.camera_model, + orientation = EXCLUDED.orientation, + width = EXCLUDED.width, + height = EXCLUDED.height + "#, + ) + .bind(file_id) + .bind(meta.captured_at) + .bind(meta.latitude) + .bind(meta.longitude) + .bind(&meta.camera_make) + .bind(&meta.camera_model) + .bind(meta.orientation.map(|o| o as i16)) + .bind(meta.width.map(|w| w as i32)) + .bind(meta.height.map(|h| h as i32)) + .execute(self.pool.as_ref()) + .await + .map_err(|e| { + error!("Failed to upsert file metadata: {}", e); + DomainError::internal_error("FileMetadata", format!("upsert: {e}")) + })?; + + Ok(()) + } + + /// Get metadata for a single file. + pub async fn get(&self, file_id: &str) -> Result, DomainError> { + let row: Option<( + String, + Option>, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + )> = sqlx::query_as( + r#" + SELECT file_id::text, captured_at, latitude, longitude, + camera_make, camera_model, orientation, width, height + FROM storage.file_metadata + WHERE file_id = $1::uuid + "#, + ) + .bind(file_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| { + error!("Failed to get file metadata: {}", e); + DomainError::internal_error("FileMetadata", format!("get: {e}")) + })?; + + Ok(row.map( + |( + file_id, + captured_at, + latitude, + longitude, + camera_make, + camera_model, + orientation, + width, + height, + )| { + StoredMetadata { + file_id, + captured_at, + latitude, + longitude, + camera_make, + camera_model, + orientation, + width, + height, + } + }, + )) + } + + /// Get metadata for multiple files in a single query. + pub async fn get_batch( + &self, + file_ids: &[String], + ) -> Result, DomainError> { + if file_ids.is_empty() { + return Ok(HashMap::new()); + } + + let rows: Vec<( + String, + Option>, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + )> = sqlx::query_as( + r#" + SELECT file_id::text, captured_at, latitude, longitude, + camera_make, camera_model, orientation, width, height + FROM storage.file_metadata + WHERE file_id = ANY($1::uuid[]) + "#, + ) + .bind(file_ids) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| { + error!("Failed to batch get file metadata: {}", e); + DomainError::internal_error("FileMetadata", format!("get_batch: {e}")) + })?; + + let mut map = HashMap::with_capacity(rows.len()); + for ( + file_id, + captured_at, + latitude, + longitude, + camera_make, + camera_model, + orientation, + width, + height, + ) in rows + { + map.insert( + file_id.clone(), + StoredMetadata { + file_id, + captured_at, + latitude, + longitude, + camera_make, + camera_model, + orientation, + width, + height, + }, + ); + } + + Ok(map) + } +} diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index 8a245867..baf9b598 100755 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -7,6 +7,7 @@ mod contact_persistence_dto; mod contact_pg_repository; mod device_code_pg_repository; mod favorites_pg_repository; +pub mod file_metadata_repository; mod nextcloud_object_id_repository; mod recent_items_pg_repository; mod session_pg_repository; @@ -32,6 +33,7 @@ pub use device_code_pg_repository::DeviceCodePgRepository; pub use favorites_pg_repository::FavoritesPgRepository; pub use file_blob_read_repository::FileBlobReadRepository; pub use file_blob_write_repository::FileBlobWriteRepository; +pub use file_metadata_repository::FileMetadataRepository; pub use folder_db_repository::FolderDbRepository; pub use nextcloud_object_id_repository::NextcloudObjectIdRepository; pub use recent_items_pg_repository::RecentItemsPgRepository; diff --git a/src/infrastructure/services/exif_service.rs b/src/infrastructure/services/exif_service.rs new file mode 100644 index 00000000..a309e926 --- /dev/null +++ b/src/infrastructure/services/exif_service.rs @@ -0,0 +1,188 @@ +//! EXIF metadata extraction from image files. +//! +//! Uses `kamadak-exif` to parse EXIF headers from JPEG/TIFF/HEIF images. +//! Extraction is cheap — only the header bytes are read, not the full image. + +use chrono::{DateTime, NaiveDateTime, Utc}; +use exif::{In, Reader, Tag}; +use std::io::Cursor; + +/// Extracted EXIF metadata fields. +#[derive(Debug, Clone, Default)] +pub struct ExifMetadata { + /// Photo capture time (EXIF DateTimeOriginal) + pub captured_at: Option>, + /// GPS latitude in decimal degrees (positive = North) + pub latitude: Option, + /// GPS longitude in decimal degrees (positive = East) + pub longitude: Option, + /// Camera manufacturer (EXIF Make) + pub camera_make: Option, + /// Camera model (EXIF Model) + pub camera_model: Option, + /// EXIF Orientation tag (1-8) + pub orientation: Option, + /// Original image width in pixels + pub width: Option, + /// Original image height in pixels + pub height: Option, +} + +/// Stateless service for extracting EXIF metadata from image bytes. +pub struct ExifService; + +impl ExifService { + /// Extract EXIF metadata from raw image bytes. + /// + /// Returns `None` if the file has no EXIF data (e.g. PNG, GIF, WebP) + /// or if parsing fails entirely. Individual fields may be `None` even + /// when the EXIF block exists (not all cameras populate every tag). + pub fn extract(data: &[u8]) -> Option { + let exif = Reader::new() + .read_from_container(&mut Cursor::new(data)) + .ok()?; + + let mut meta = ExifMetadata::default(); + + // ── Capture date ── + if let Some(field) = exif.get_field(Tag::DateTimeOriginal, In::PRIMARY) { + meta.captured_at = parse_exif_datetime(&field.display_value().to_string()); + } + // Fallback to DateTimeDigitized if DateTimeOriginal is missing + if meta.captured_at.is_none() + && let Some(field) = exif.get_field(Tag::DateTimeDigitized, In::PRIMARY) + { + meta.captured_at = parse_exif_datetime(&field.display_value().to_string()); + } + + // ── GPS coordinates ── + meta.latitude = parse_gps_coord(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef); + meta.longitude = parse_gps_coord(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef); + + // ── Camera info ── + if let Some(field) = exif.get_field(Tag::Make, In::PRIMARY) { + let val = field + .display_value() + .to_string() + .trim_matches('"') + .trim() + .to_string(); + if !val.is_empty() { + meta.camera_make = Some(val); + } + } + if let Some(field) = exif.get_field(Tag::Model, In::PRIMARY) { + let val = field + .display_value() + .to_string() + .trim_matches('"') + .trim() + .to_string(); + if !val.is_empty() { + meta.camera_model = Some(val); + } + } + + // ── Orientation ── + if let Some(field) = exif.get_field(Tag::Orientation, In::PRIMARY) + && let exif::Value::Short(ref v) = field.value + && let Some(&o) = v.first() + && (1..=8).contains(&o) + { + meta.orientation = Some(o); + } + + // ── Dimensions ── + if let Some(field) = exif.get_field(Tag::PixelXDimension, In::PRIMARY) { + meta.width = parse_u32_value(&field.value); + } + if let Some(field) = exif.get_field(Tag::PixelYDimension, In::PRIMARY) { + meta.height = parse_u32_value(&field.value); + } + // Fallback to ImageWidth/ImageLength if PixelXDimension is missing + if meta.width.is_none() + && let Some(field) = exif.get_field(Tag::ImageWidth, In::PRIMARY) + { + meta.width = parse_u32_value(&field.value); + } + if meta.height.is_none() + && let Some(field) = exif.get_field(Tag::ImageLength, In::PRIMARY) + { + meta.height = parse_u32_value(&field.value); + } + + Some(meta) + } +} + +/// Parse EXIF datetime string "YYYY:MM:DD HH:MM:SS" into DateTime. +fn parse_exif_datetime(s: &str) -> Option> { + // EXIF dates use ":" as separator for date parts + let s = s.trim().trim_matches('"'); + NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") + .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y:%m:%d %H:%M:%S")) + .ok() + .map(|ndt| ndt.and_utc()) +} + +/// Parse GPS coordinate from EXIF rational values + reference (N/S or E/W). +fn parse_gps_coord(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option { + let field = exif.get_field(coord_tag, In::PRIMARY)?; + let ref_field = exif.get_field(ref_tag, In::PRIMARY)?; + + let rationals = match &field.value { + exif::Value::Rational(v) if v.len() >= 3 => v, + _ => return None, + }; + + let degrees = rationals[0].to_f64(); + let minutes = rationals[1].to_f64(); + let seconds = rationals[2].to_f64(); + + let mut decimal = degrees + minutes / 60.0 + seconds / 3600.0; + + // Apply hemisphere sign + let reference = ref_field.display_value().to_string(); + let reference = reference.trim().trim_matches('"'); + if reference == "S" || reference == "W" { + decimal = -decimal; + } + + Some(decimal) +} + +/// Extract a u32 from various EXIF value types (Short, Long). +fn parse_u32_value(value: &exif::Value) -> Option { + match value { + exif::Value::Short(v) => v.first().map(|&x| x as u32), + exif::Value::Long(v) => v.first().copied(), + _ => None, + } +} + +/// Apply EXIF orientation to a `DynamicImage`. +/// +/// EXIF orientation values 1-8 describe how the stored pixels relate to +/// the intended display orientation. This function transforms the image +/// to match the intended orientation. +pub fn apply_orientation(img: image::DynamicImage, orientation: u16) -> image::DynamicImage { + match orientation { + 1 => img, // Normal + 2 => image::DynamicImage::from(image::imageops::flip_horizontal(&img)), // Mirror horizontal + 3 => image::DynamicImage::from(image::imageops::rotate180(&img)), // Rotate 180° + 4 => image::DynamicImage::from(image::imageops::flip_vertical(&img)), // Mirror vertical + 5 => { + // Transpose: flip horizontal then rotate 270° (= rotate 90° CW then flip horizontal) + let flipped = image::imageops::flip_horizontal(&img); + image::DynamicImage::from(image::imageops::rotate270(&flipped)) + } + 6 => image::DynamicImage::from(image::imageops::rotate90(&img)), // Rotate 90° CW + 7 => { + // Transverse: flip horizontal then rotate 90° + let flipped = image::imageops::flip_horizontal(&img); + image::DynamicImage::from(image::imageops::rotate90(&flipped)) + } + 8 => image::DynamicImage::from(image::imageops::rotate270(&img)), // Rotate 270° CW + _ => img, + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 0fe2886d..da2f6983 100755 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -1,6 +1,7 @@ pub mod chunked_upload_service; pub mod compression_service; pub mod dedup_service; +pub mod exif_service; pub mod file_content_cache; pub mod file_system_i18n_service; pub mod image_transcode_service; diff --git a/src/infrastructure/services/path_resolver_service.rs b/src/infrastructure/services/path_resolver_service.rs index f237afe6..60a0b391 100755 --- a/src/infrastructure/services/path_resolver_service.rs +++ b/src/infrastructure/services/path_resolver_service.rs @@ -173,6 +173,7 @@ impl PathResolverService { category: Arc::from(category_for(&name, &mime)), size_formatted: format_file_size(sz), owner_id: uid, + sort_date: None, })) } } diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index 1ca81b25..2ae18fd4 100755 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -269,6 +269,17 @@ impl ThumbnailService { let img = image::load_from_memory(&data) .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + // Apply EXIF orientation so thumbnails display correctly + let img = { + use crate::infrastructure::services::exif_service::{ + ExifService, apply_orientation, + }; + let orientation = ExifService::extract(&data) + .and_then(|m| m.orientation) + .unwrap_or(1); + apply_orientation(img, orientation) + }; + // Calculate new dimensions preserving aspect ratio let (orig_width, orig_height) = (img.width(), img.height()); let (new_width, new_height) = if orig_width > orig_height { @@ -349,6 +360,17 @@ impl ThumbnailService { let img = image::load_from_memory(&data) .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + // Apply EXIF orientation so thumbnails display correctly + let img = { + use crate::infrastructure::services::exif_service::{ + ExifService, apply_orientation, + }; + let orientation = ExifService::extract(&data) + .and_then(|m| m.orientation) + .unwrap_or(1); + apply_orientation(img, orientation) + }; + let (orig_w, orig_h) = (img.width(), img.height()); ThumbnailSize::all() diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index de5f0859..a1dd2d3f 100755 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -605,7 +605,7 @@ impl FileHandler { Err(response) => return response.into_response(), }; - // Generate thumbnails for supported images in background + // Generate thumbnails and extract EXIF metadata for supported images in background if state .core .thumbnail_service @@ -615,6 +615,7 @@ impl FileHandler { let thumbnail_service = state.core.thumbnail_service.clone(); let dedup_service = state.core.dedup_service.clone(); let file_read = state.repositories.file_read_repository.clone(); + let metadata_repo = state.repositories.file_metadata_repository.clone(); tokio::spawn(async move { // Resolve the actual blob path on disk (not the logical file path, @@ -627,6 +628,29 @@ impl FileHandler { } }; let file_path = dedup_service.blob_path(&blob_hash); + + // Extract EXIF metadata (reads only header bytes, very fast). + // Runs before thumbnail generation so the OS page cache is primed. + { + use crate::infrastructure::services::exif_service::ExifService; + match tokio::fs::read(&file_path).await { + Ok(data) => { + if let Some(meta) = ExifService::extract(&data) + && let Err(e) = metadata_repo.upsert(&file_id, &meta).await + { + tracing::warn!("Failed to store EXIF for {}: {}", file_id, e); + } + } + Err(e) => { + tracing::warn!( + "Failed to read file for EXIF extraction {}: {}", + file_id, + e + ); + } + } + } + tracing::info!("🖼️ Generating thumbnails for: {}", file_id); thumbnail_service.generate_all_sizes_background(file_id, file_path); }); @@ -635,6 +659,47 @@ impl FileHandler { Self::created_json_response(&file).into_response() } + // ═══════════════════════════════════════════════════════════════════════ + // METADATA + // ═══════════════════════════════════════════════════════════════════════ + + /// Returns EXIF/media metadata for a file. + /// + /// Used by the Photos lightbox and for testing EXIF extraction. + pub async fn get_file_metadata( + State(state): State, + auth_user: AuthUser, + Path(file_id): Path, + ) -> impl IntoResponse { + // Verify ownership + let file_read = &state.repositories.file_read_repository; + if let Err(e) = file_read.verify_file_owner(&file_id, &auth_user.id).await { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e.to_string() })), + ) + .into_response(); + } + + let metadata_repo = &state.repositories.file_metadata_repository; + match metadata_repo.get(&file_id).await { + Ok(Some(meta)) => (StatusCode::OK, Json(meta)).into_response(), + Ok(None) => ( + StatusCode::OK, + Json(serde_json::json!({ + "file_id": file_id, + "message": "No EXIF metadata available" + })), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + .into_response(), + } + } + // ═══════════════════════════════════════════════════════════════════════ // DELETE // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 5dc06917..0e7048e2 100755 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -11,6 +11,7 @@ pub mod favorites_handler; pub mod file_handler; pub mod folder_handler; pub mod i18n_handler; +pub mod photos_handler; pub mod recent_handler; pub mod search_handler; pub mod share_handler; diff --git a/src/interfaces/api/handlers/photos_handler.rs b/src/interfaces/api/handlers/photos_handler.rs new file mode 100644 index 00000000..3cdaa32c --- /dev/null +++ b/src/interfaces/api/handlers/photos_handler.rs @@ -0,0 +1,78 @@ +use axum::{ + Json, + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde::Deserialize; +use std::sync::Arc; +use tracing::{error, info}; + +use crate::application::dtos::file_dto::FileDto; +use crate::common::di::AppState; +use crate::interfaces::middleware::auth::AuthUser; + +/// Query parameters for the photos timeline endpoint. +#[derive(Deserialize)] +pub struct PhotosQueryParams { + /// Cursor: only return items with sort_date < this value (epoch seconds). + pub before: Option, + /// Max items to return (default 200, max 500). + pub limit: Option, +} + +/// Lists all image/video files for the authenticated user, sorted by +/// capture date (EXIF DateTimeOriginal) falling back to upload date. +/// +/// Supports cursor-based pagination via the `before` parameter. +/// The `X-Next-Cursor` response header contains the cursor for the next page. +pub async fn list_photos( + State(state): State>, + auth_user: AuthUser, + Query(params): Query, +) -> impl IntoResponse { + let user_id = &auth_user.id; + let limit = params.limit.unwrap_or(200).clamp(1, 500); + + let file_read = &state.repositories.file_read_repository; + + match file_read + .list_media_files(user_id, params.before, limit) + .await + { + Ok((files, sort_dates)) => { + info!("Photos: returned {} media files for user", files.len()); + + // Convert to DTOs with sort_date populated + let dtos: Vec = files + .into_iter() + .zip(sort_dates.iter()) + .map(|(file, &sd)| { + let mut dto = FileDto::from(file); + dto.sort_date = Some(sd as u64); + dto + }) + .collect(); + + // Set cursor header for next page + let mut response = Json(&dtos).into_response(); + if let Some(&last_sd) = sort_dates.last() { + response + .headers_mut() + .insert("X-Next-Cursor", last_sd.to_string().parse().unwrap()); + } + + response + } + Err(err) => { + error!("Error listing photos: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Failed to list photos: {}", err) + })), + ) + .into_response() + } + } +} diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index d59d756d..15d1efe1 100755 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -183,11 +183,7 @@ async fn handle_webdav_methods( /// If `path` doesn't already start with the user's home folder name, prepend /// the home folder path so downstream services can find the resource in the DB. /// Returns `None` when the path already includes the prefix or resolution fails. -async fn resolve_webdav_path( - state: &Arc, - user_id: &str, - path: &str, -) -> Option { +async fn resolve_webdav_path(state: &Arc, user_id: &str, path: &str) -> Option { let folder_service = &state.applications.folder_service; let home_folders = folder_service .list_folders_for_owner(None, user_id) @@ -213,10 +209,7 @@ async fn handle_webdav_dispatch( // prefix when the path doesn't already include it. // Extract user_id before any async call to keep the future Send. let path = if !path.is_empty() && method.as_str() != "OPTIONS" { - let user_id = req - .extensions() - .get::() - .map(|u| u.id.clone()); + let user_id = req.extensions().get::().map(|u| u.id.clone()); if let Some(uid) = user_id { resolve_webdav_path(&state, &uid, &path) .await diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 2f9f4a32..1062df7b 100755 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -146,6 +146,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .route("/upload", post(FileHandler::upload_file_with_thumbnails)) .route("/{id}", get(FileHandler::download_file)) .route("/{id}/thumbnail/{size}", get(FileHandler::get_thumbnail)) + .route("/{id}/metadata", get(FileHandler::get_file_metadata)) .layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)) // 10 GB for file uploads .with_state(app_state.clone()); @@ -308,6 +309,17 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .nest("/favorites", favorites_router) .nest("/recent", recent_router); + // Photos timeline endpoint — lists all image/video files sorted by capture date + { + use crate::interfaces::api::handlers::photos_handler; + + let photos_router = Router::new() + .route("/", get(photos_handler::list_photos)) + .with_state(app_state.clone()); + + router = router.nest("/photos", photos_router); + } + // Re-enable trash routes to make the trash view work if let Some(_trash_service_ref) = trash_service.clone() { tracing::info!("Setting up trash routes for trash view"); diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index 0fb23bdf..106dbc75 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -265,6 +265,7 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes category: category_for(&fr.name, &fr.mime_type).to_string().into(), size_formatted: format_file_size(fr.size), owner_id: None, + sort_date: None, } } diff --git a/static/css/views/photos.css b/static/css/views/photos.css new file mode 100644 index 00000000..77cf26ad --- /dev/null +++ b/static/css/views/photos.css @@ -0,0 +1,303 @@ +/* Photos timeline view */ +.photos-container { + padding: 0; + display: none; +} + +.photos-container.active { + display: block; +} + +/* Toolbar with group mode toggle */ +.photos-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 8px 8px 4px; +} + +/* Toggle buttons — wider for text labels */ +.photos-toolbar .toggle-btn { + width: auto; + padding: 0 14px; + font-size: 13px; + font-weight: 500; +} + +/* Group header */ +.photos-day-header { + padding: 16px 8px 10px; + font-size: 15px; + font-weight: 600; + color: #2d3748; +} + +.photos-day-header .photos-day-count { + font-weight: 400; + color: #94a3b8; + font-size: 13px; + margin-left: 8px; +} + +/* Photo grid — base (daily mode) */ +.photos-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 4px; + padding: 0 8px; + margin-bottom: 16px; +} + +/* Monthly mode — larger tiles, more breathing room */ +.photos-group-monthly .photos-grid { + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 6px; +} + +.photos-group-monthly .photos-day-header { + font-size: 17px; + padding: 20px 8px 12px; +} + +/* Yearly mode — smaller tiles, more items visible */ +.photos-group-yearly .photos-grid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 4px; +} + +.photos-group-yearly .photos-day-header { + font-size: 20px; + padding: 24px 8px 14px; +} + +/* Individual photo tile */ +.photo-tile { + position: relative; + aspect-ratio: 1; + overflow: hidden; + border-radius: 4px; + cursor: pointer; + background: #e2e8f0; +} + +.photo-tile img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.2s ease; +} + +.photo-tile:hover img { + transform: scale(1.05); +} + +/* Selection checkbox */ +.photo-tile .photo-check { + position: absolute; + top: 6px; + left: 6px; + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.8); + background: rgba(0, 0, 0, 0.25); + opacity: 0; + transition: opacity 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 11px; + z-index: 2; +} + +.photo-tile:hover .photo-check, +.photo-tile.selected .photo-check { + opacity: 1; +} + +.photo-tile.selected .photo-check { + background: #ff5e3a; + border-color: #ff5e3a; +} + +.photo-tile.selected { + outline: 3px solid #ff5e3a; + outline-offset: -3px; +} + +.photo-tile.selected img { + transform: scale(0.92); +} + +/* Video badge */ +.photo-tile .video-badge { + position: absolute; + bottom: 6px; + right: 6px; + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 12px; + z-index: 2; +} + +/* Duration badge for videos */ +.photo-tile .video-duration { + position: absolute; + bottom: 6px; + left: 6px; + font-size: 11px; + color: #fff; + background: rgba(0, 0, 0, 0.55); + padding: 2px 6px; + border-radius: 4px; + z-index: 2; +} + +/* Empty state */ +.photos-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 20px; + text-align: center; + color: #94a3b8; +} + +.photos-empty i { + font-size: 56px; + margin-bottom: 16px; + color: #cbd5e1; +} + +.photos-empty p { + margin: 4px 0; + font-size: 15px; +} + +.photos-empty .photos-empty-title { + font-size: 18px; + font-weight: 600; + color: #64748b; +} + +/* Infinite scroll sentinel */ +.photos-sentinel { + height: 1px; + width: 100%; +} + +/* Loading spinner */ +.photos-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + color: #94a3b8; + font-size: 14px; + gap: 8px; +} + +.photos-loading i { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Selection bar */ +.photos-selection-bar { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: #1e293b; + color: #fff; + padding: 10px 20px; + border-radius: 12px; + display: flex; + align-items: center; + gap: 16px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3); + z-index: 1000; + font-size: 14px; +} + +.photos-selection-bar button { + background: none; + border: none; + color: #fff; + cursor: pointer; + padding: 6px 10px; + border-radius: 6px; + font-size: 14px; + transition: background 0.15s; +} + +.photos-selection-bar button:hover { + background: rgba(255, 255, 255, 0.15); +} + +.photos-selection-bar .selection-count { + font-weight: 600; +} + +/* Responsive */ +@media (max-width: 768px) { + .photos-grid { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 2px; + padding: 0 2px; + margin-bottom: 8px; + } + + .photos-group-monthly .photos-grid { + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + } + + .photos-group-yearly .photos-grid { + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + } + + .photo-tile .photo-check { + opacity: 1; + } + + .photos-day-header { + font-size: 14px; + padding: 10px 4px 6px; + } + + .photos-toolbar { + padding: 6px 4px 2px; + } +} + +/* Dark theme */ +[data-theme="dark"] .photos-day-header { + color: #e2e8f0; +} + +[data-theme="dark"] .photo-tile { + background: #334155; +} + +[data-theme="dark"] .photos-empty i { + color: #475569; +} + +[data-theme="dark"] .photos-empty .photos-empty-title { + color: #94a3b8; +} + +[data-theme="dark"] .photos-empty p { + color: #64748b; +} diff --git a/static/css/views/photosLightbox.css b/static/css/views/photosLightbox.css new file mode 100644 index 00000000..47eaee58 --- /dev/null +++ b/static/css/views/photosLightbox.css @@ -0,0 +1,194 @@ +/* Photos lightbox overlay */ +.photos-lightbox { + position: fixed; + inset: 0; + z-index: 10000; + background: rgba(0, 0, 0, 0.92); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.photos-lightbox.active { + opacity: 1; + pointer-events: auto; +} + +/* Main content area */ +.lightbox-content { + position: relative; + max-width: 90vw; + max-height: 85vh; + display: flex; + align-items: center; + justify-content: center; +} + +.lightbox-content img, +.lightbox-content video { + max-width: 90vw; + max-height: 85vh; + object-fit: contain; + border-radius: 4px; + user-select: none; +} + +/* Navigation arrows */ +.lightbox-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 48px; + height: 48px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.12); + color: #fff; + font-size: 20px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; + z-index: 10001; +} + +.lightbox-nav:hover { + background: rgba(255, 255, 255, 0.25); +} + +.lightbox-prev { + left: 20px; +} + +.lightbox-next { + right: 20px; +} + +/* Close button */ +.lightbox-close { + position: absolute; + top: 16px; + right: 16px; + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.12); + color: #fff; + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; + z-index: 10001; +} + +.lightbox-close:hover { + background: rgba(255, 255, 255, 0.25); +} + +/* Top info bar */ +.lightbox-info { + position: absolute; + top: 0; + left: 0; + right: 0; + padding: 16px 70px 16px 20px; + background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent); + color: #fff; + z-index: 10001; +} + +.lightbox-filename { + font-size: 15px; + font-weight: 600; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.lightbox-meta { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +/* Bottom toolbar */ +.lightbox-toolbar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 16px 20px; + background: linear-gradient(to top, rgba(0,0,0,0.6), transparent); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + z-index: 10001; +} + +.lightbox-toolbar button { + background: rgba(255, 255, 255, 0.12); + border: none; + color: #fff; + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: background 0.15s; +} + +.lightbox-toolbar button:hover { + background: rgba(255, 255, 255, 0.25); +} + +.lightbox-toolbar button.active { + color: #ff5e3a; +} + +/* Counter */ +.lightbox-counter { + position: absolute; + bottom: 16px; + left: 20px; + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + z-index: 10001; +} + +/* Responsive */ +@media (max-width: 768px) { + .lightbox-nav { + width: 36px; + height: 36px; + font-size: 16px; + } + + .lightbox-prev { + left: 8px; + } + + .lightbox-next { + right: 8px; + } + + .lightbox-content img, + .lightbox-content video { + max-width: 100vw; + max-height: 80vh; + } +} diff --git a/static/index.html b/static/index.html index cf098ace..b9734f55 100755 --- a/static/index.html +++ b/static/index.html @@ -14,6 +14,8 @@ + + @@ -32,6 +34,8 @@ + + @@ -81,6 +85,10 @@ Favorites +