//! Thumbnail cache and image-URL commands. //! //! TRACES: UR-007 | JA-028 | DR-016 use serde::Deserialize; use std::sync::{Arc, OnceLock}; use tauri::State; use tokio::sync::Semaphore; use super::{DatabaseWrapper, ThumbnailCacheWrapper}; use crate::commands::repository::RepositoryManagerWrapper; use crate::repository::types::{ImageOptions, ImageType}; use crate::repository::MediaRepository; use crate::thumbnail::{ThumbnailCacheStats, ThumbnailWorker}; /// Get cached thumbnail path, returns None if not cached /// Also updates last_accessed timestamp for LRU tracking #[tauri::command] #[specta::specta] pub async fn thumbnail_get_cached( db: State<'_, DatabaseWrapper>, thumbnail_cache: State<'_, ThumbnailCacheWrapper>, item_id: String, image_type: String, tag: String, ) -> Result, String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; let result = thumbnail_cache .0 .get_cached_path(db_service, &item_id, &image_type, &tag) .await .map(|p| p.to_string_lossy().to_string()); Ok(result) } /// Download and save a thumbnail to cache /// Returns the local file path on success #[tauri::command] #[specta::specta] pub async fn thumbnail_save( db: State<'_, DatabaseWrapper>, thumbnail_cache: State<'_, ThumbnailCacheWrapper>, item_id: String, image_type: String, tag: String, url: String, ) -> Result { // Download the image let worker = ThumbnailWorker::new(); let data = worker .download_with_retry(&url, 2) .await .map_err(|e| e.to_string())?; // Save to cache let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; let path = thumbnail_cache .0 .save_thumbnail(db_service, &item_id, &image_type, &tag, &data, None, None) .await?; Ok(path.to_string_lossy().to_string()) } /// Get thumbnail cache statistics #[tauri::command] #[specta::specta] pub async fn thumbnail_get_stats( db: State<'_, DatabaseWrapper>, thumbnail_cache: State<'_, ThumbnailCacheWrapper>, ) -> Result { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; let total_size_bytes = thumbnail_cache.0.get_cache_size(db_service.clone()).await; let item_count = thumbnail_cache.0.get_item_count(db_service.clone()).await; let limit_bytes = thumbnail_cache.0.get_limit(db_service).await; Ok(ThumbnailCacheStats { total_size_bytes, item_count, limit_bytes, }) } /// Set thumbnail cache storage limit in bytes #[tauri::command] #[specta::specta] pub async fn thumbnail_set_limit( db: State<'_, DatabaseWrapper>, thumbnail_cache: State<'_, ThumbnailCacheWrapper>, limit_bytes: u64, ) -> Result<(), String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; thumbnail_cache.0.set_limit(db_service, limit_bytes).await } /// Clear all cached thumbnails #[tauri::command] #[specta::specta] pub async fn thumbnail_clear_cache( db: State<'_, DatabaseWrapper>, thumbnail_cache: State<'_, ThumbnailCacheWrapper>, ) -> Result<(), String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; thumbnail_cache.0.clear_cache(db_service).await } /// Delete cached thumbnails for a specific item #[tauri::command] #[specta::specta] pub async fn thumbnail_delete_item( db: State<'_, DatabaseWrapper>, thumbnail_cache: State<'_, ThumbnailCacheWrapper>, item_id: String, ) -> Result<(), String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; thumbnail_cache.0.delete_item(db_service, &item_id).await } fn mime_from_ext(ext: Option<&str>) -> &'static str { match ext { Some("jpg") | Some("jpeg") => "image/jpeg", Some("png") => "image/png", Some("gif") => "image/gif", Some("webp") => "image/webp", _ => "image/jpeg", } } /// Limit concurrent image downloads to avoid saturating the connection pool. /// Without this, rendering a page with hundreds of album cards fires hundreds of /// concurrent HTTP requests, starving API calls and causing timeouts. static IMAGE_DOWNLOAD_SEMAPHORE: OnceLock = OnceLock::new(); fn image_semaphore() -> &'static Semaphore { IMAGE_DOWNLOAD_SEMAPHORE.get_or_init(|| Semaphore::new(6)) } /// Request to get an image URL (with caching) #[derive(specta::Type, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetImageRequest { pub item_id: String, pub image_type: String, #[serde(skip_serializing_if = "Option::is_none")] pub max_width: Option, #[serde(skip_serializing_if = "Option::is_none")] pub max_height: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tag: Option, } /// Get image as base64 data URL, caching if not already cached /// This extends the thumbnail system to serve all images through Rust with automatic caching #[tauri::command] #[specta::specta] pub async fn image_get_url( repository_manager: State<'_, RepositoryManagerWrapper>, thumbnail_cache: State<'_, ThumbnailCacheWrapper>, db: State<'_, DatabaseWrapper>, repository_handle: String, request: GetImageRequest, ) -> Result { use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use std::fs; let tag = request.tag.as_deref().unwrap_or("default"); // Get database service let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; // Check cache first if let Some(cached_path) = thumbnail_cache .0 .get_cached_path( db_service.clone(), &request.item_id, &request.image_type, tag, ) .await { let image_data = fs::read(&cached_path).map_err(|e| format!("Failed to read cached image: {}", e))?; let base64_data = BASE64.encode(&image_data); let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str())); return Ok(format!("data:{};base64,{}", mime_type, base64_data)); } // Not cached — fetch from server and cache. // Acquire semaphore to limit concurrent downloads (prevents connection pool starvation). let _permit = image_semaphore() .acquire() .await .map_err(|_| "Image download semaphore closed".to_string())?; let repository = repository_manager .0 .get(&repository_handle) .ok_or_else(|| "Repository not found - user may need to log in".to_string())?; let image_type_enum = match request.image_type.as_str() { "Primary" => ImageType::Primary, "Backdrop" => ImageType::Backdrop, "Banner" => ImageType::Banner, "Thumb" => ImageType::Thumb, "Logo" => ImageType::Logo, _ => ImageType::Primary, }; let options = ImageOptions { max_width: request.max_width, max_height: request.max_height, quality: Some(90), tag: request.tag.clone(), }; let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options)); let image_data = repository .download_bytes(&server_url) .await .map_err(|e| format!("Failed to download image: {}", e))?; let cached_path = thumbnail_cache .0 .save_thumbnail( db_service, &request.item_id, &request.image_type, tag, &image_data, request.max_width.map(|w| w as i32), request.max_height.map(|h| h as i32), ) .await?; let base64_data = BASE64.encode(&image_data); let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str())); Ok(format!("data:{};base64,{}", mime_type, base64_data)) }