feat(photos): add EXIF metadata extraction and storage

Extract EXIF orientation, GPS coordinates, camera info, and timestamps
from uploaded images using kamadak-exif. Store metadata in a new
file_metadata PG table. Apply EXIF orientation to thumbnail generation
so images display correctly. Add /api/files/{id}/metadata endpoint.
This commit is contained in:
Jared Wolff
2026-03-05 12:48:47 -05:00
parent cea7665a43
commit 69fe3a8b07
11 changed files with 483 additions and 2 deletions
+62 -1
View File
@@ -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,25 @@ 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) {
if 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 +655,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<GlobalState>,
auth_user: AuthUser,
Path(file_id): Path<String>,
) -> 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
// ═══════════════════════════════════════════════════════════════════════
+1
View File
@@ -146,6 +146,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
.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());