diff --git a/src/application/ports/thumbnail_ports.rs b/src/application/ports/thumbnail_ports.rs index 6074f625..8dcc04b7 100755 --- a/src/application/ports/thumbnail_ports.rs +++ b/src/application/ports/thumbnail_ports.rs @@ -84,6 +84,27 @@ pub trait ThumbnailPort: Send + Sync + 'static { /// Delete all thumbnails for a file. async fn delete_thumbnails(&self, file_id: &str) -> Result<(), DomainError>; + /// Try to get a cached thumbnail without generating one. + /// + /// Returns `None` if no cached thumbnail exists on disk or in memory. + /// Used for non-image file types (videos) where thumbnails are + /// generated client-side and uploaded. + async fn get_cached_thumbnail( + &self, + file_id: &str, + size: ThumbnailSize, + ) -> Option; + + /// Store an externally-generated thumbnail (e.g. client-side video frame). + /// + /// Validates the image, re-encodes to WebP, and persists to cache. + async fn store_external_thumbnail( + &self, + file_id: &str, + size: ThumbnailSize, + data: Bytes, + ) -> Result; + /// Get cache statistics. async fn get_stats(&self) -> ThumbnailStatsDto; } diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index 2ae18fd4..604deb15 100755 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -226,6 +226,101 @@ impl ThumbnailService { Ok(bytes) } + /// Try to serve a thumbnail from cache only (memory → disk). + /// + /// Unlike `get_thumbnail`, this does **not** generate a new thumbnail. + /// Useful for non-image file types (videos) where a client-generated + /// thumbnail may have been uploaded previously. + pub async fn get_cached_thumbnail( + &self, + file_id: &str, + size: ThumbnailSize, + ) -> Option { + // 1. Check in-memory cache + let cache_key = ThumbnailCacheKey { + file_id: file_id.to_string(), + size, + }; + if let Some(bytes) = self.cache.get(&cache_key).await { + if !bytes.is_empty() { + return Some(bytes); + } + } + + // 2. Check disk + let thumb_path = self.get_thumbnail_path(file_id, size); + if let Ok(data) = fs::read(&thumb_path).await { + let bytes = Bytes::from(data); + // Populate in-memory cache for next hit + self.cache.insert(cache_key, bytes.clone()).await; + Some(bytes) + } else { + None + } + } + + /// Store an externally-generated thumbnail (e.g. client-side video frame). + /// + /// Validates the image data, re-encodes to WebP for cache consistency, + /// and persists to both disk and in-memory cache. + pub async fn store_external_thumbnail( + &self, + file_id: &str, + size: ThumbnailSize, + data: Bytes, + ) -> Result { + let max_dim = size.max_dimension(); + + // Validate + re-encode in blocking thread (tiny image, ~1 ms) + let webp_bytes = tokio::task::spawn_blocking(move || -> Result, ThumbnailError> { + let img = image::load_from_memory(&data) + .map_err(|e| ThumbnailError::ImageError(format!("Invalid image data: {e}")))?; + + // Resize if larger than target size + let (w, h) = (img.width(), img.height()); + let img = if w > max_dim || h > max_dim { + let filter = FilterType::CatmullRom; + if w > h { + let ratio = max_dim as f32 / w as f32; + img.resize(max_dim, (h as f32 * ratio) as u32, filter) + } else { + let ratio = max_dim as f32 / h as f32; + img.resize((w as f32 * ratio) as u32, max_dim, filter) + } + } else { + img + }; + + let mut buffer = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut buffer), ImageFormat::WebP) + .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + Ok(buffer) + }) + .await + .map_err(|e| ThumbnailError::TaskError(e.to_string()))??; + + let bytes = Bytes::from(webp_bytes); + + // Save to disk + let thumb_path = self.get_thumbnail_path(file_id, size); + if let Some(parent) = thumb_path.parent() { + let _ = fs::create_dir_all(parent).await; + } + fs::write(&thumb_path, &bytes) + .await + .map_err(|e| ThumbnailError::IoError(e.to_string()))?; + + // Populate in-memory cache + let cache_key = ThumbnailCacheKey { + file_id: file_id.to_string(), + size, + }; + self.cache.insert(cache_key, bytes.clone()).await; + + tracing::info!("✅ Stored external thumbnail: {} {:?}", file_id, size); + Ok(bytes) + } + /// Generate a thumbnail from an image file. /// /// Concurrency is bounded by `decode_semaphore` to prevent OOM when @@ -505,6 +600,25 @@ impl ThumbnailPort for ThumbnailService { .map_err(|e| DomainError::new(ErrorKind::InternalError, "Thumbnail", e.to_string())) } + async fn get_cached_thumbnail( + &self, + file_id: &str, + size: PortThumbnailSize, + ) -> Option { + self.get_cached_thumbnail(file_id, size.into()).await + } + + async fn store_external_thumbnail( + &self, + file_id: &str, + size: PortThumbnailSize, + data: Bytes, + ) -> Result { + self.store_external_thumbnail(file_id, size.into(), data) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Thumbnail", e.to_string())) + } + async fn get_stats(&self) -> ThumbnailStatsDto { let stats = self.get_stats().await; ThumbnailStatsDto { diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 68fa15d0..7ddcb488 100755 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -331,13 +331,25 @@ impl FileHandler { }; if !thumbnail_service.is_supported_image(&file.mime_type) { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "File is not a supported image type" - })), - ) - .into_response(); + // For non-images (videos, etc.), serve from cache if available; + // otherwise return 204 to signal "not yet generated — please + // generate client-side and upload via PUT". + if let Some(data) = thumbnail_service + .get_cached_thumbnail(&id, thumb_size.into()) + .await + { + let etag = format!("\"thumb-{}-{:?}\"", id, thumb_size); + return Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "image/webp") + .header(header::CONTENT_LENGTH, data.len()) + .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") + .header(header::ETAG, etag) + .body(Body::from(data)) + .unwrap() + .into_response(); + } + return StatusCode::NO_CONTENT.into_response(); } // Resolve the physical blob path (content-addressable storage) @@ -377,6 +389,73 @@ impl FileHandler { } } + // ═══════════════════════════════════════════════════════════════════════ + // UPLOAD THUMBNAIL (client-generated, e.g. video frames) + // ═══════════════════════════════════════════════════════════════════════ + + /// Accept a client-generated thumbnail (e.g. video frame extracted via + /// `