//! Thumbnail cache manager with LRU eviction use log::error; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService}; /// Configuration for the thumbnail cache #[derive(Debug, Clone)] pub struct CacheConfig { /// Maximum cache size in bytes (0 = unlimited) pub max_size_bytes: u64, /// Subdirectory name for cached thumbnails pub cache_subdir: String, /// Whether caching is enabled pub enabled: bool, } impl Default for CacheConfig { fn default() -> Self { Self { max_size_bytes: 1024 * 1024 * 1024, // 1GB cache_subdir: "thumbnails".to_string(), enabled: true, } } } /// Thumbnail cache with LRU eviction pub struct ThumbnailCache { config: Arc>, cache_dir: PathBuf, } impl ThumbnailCache { /// Create a new thumbnail cache pub fn new(app_data_dir: PathBuf, config: CacheConfig) -> Self { let cache_dir = app_data_dir.join(&config.cache_subdir); // Create cache directory if it doesn't exist if let Err(e) = std::fs::create_dir_all(&cache_dir) { error!("Failed to create thumbnail cache directory: {}", e); } Self { config: Arc::new(Mutex::new(config)), cache_dir, } } /// Check if caching is enabled pub fn is_enabled(&self) -> bool { self.config.lock().map(|c| c.enabled).unwrap_or(true) } /// Get cached thumbnail path, or None if not cached /// Updates last_accessed timestamp for LRU tracking pub async fn get_cached_path( &self, db: Arc, item_id: &str, image_type: &str, tag: &str, ) -> Option { let query = Query::with_params( "SELECT file_path FROM thumbnails WHERE item_id = ? AND image_type = ? AND image_tag = ?", vec![ QueryParam::String(item_id.to_string()), QueryParam::String(image_type.to_string()), QueryParam::String(tag.to_string()), ], ); let path_str: String = db.query_optional(query, |row| row.get(0)).await.ok()??; let path = PathBuf::from(&path_str); if path.exists() { // Update last_accessed for LRU tracking let update_query = Query::with_params( "UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP WHERE item_id = ? AND image_type = ? AND image_tag = ?", vec![ QueryParam::String(item_id.to_string()), QueryParam::String(image_type.to_string()), QueryParam::String(tag.to_string()), ], ); let _ = db.execute(update_query).await; Some(path) } else { // Clean up stale database entry let delete_query = Query::with_params( "DELETE FROM thumbnails WHERE item_id = ? AND image_type = ? AND image_tag = ?", vec![ QueryParam::String(item_id.to_string()), QueryParam::String(image_type.to_string()), QueryParam::String(tag.to_string()), ], ); let _ = db.execute(delete_query).await; None } } /// Save thumbnail to cache pub async fn save_thumbnail( &self, db: Arc, item_id: &str, image_type: &str, tag: &str, data: &[u8], width: Option, height: Option, ) -> Result { if !self.is_enabled() { return Err("Thumbnail caching is disabled".to_string()); } // Generate safe filename let safe_tag = tag.replace(|c: char| !c.is_alphanumeric(), "_"); let filename = format!("{}_{}_{}.jpg", item_id, image_type, safe_tag); let file_path = self.cache_dir.join(&filename); // Ensure we have space (evict LRU items if needed) self.ensure_space(db.clone(), data.len() as u64).await?; // Write file to disk std::fs::write(&file_path, data).map_err(|e| format!("Failed to write file: {}", e))?; // Insert/update database entry let query = Query::with_params( "INSERT INTO thumbnails (item_id, image_type, image_tag, file_path, width, height, file_size, last_accessed, cached_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT(item_id, image_type, image_tag) DO UPDATE SET file_path = excluded.file_path, width = excluded.width, height = excluded.height, file_size = excluded.file_size, last_accessed = CURRENT_TIMESTAMP", vec![ QueryParam::String(item_id.to_string()), QueryParam::String(image_type.to_string()), QueryParam::String(tag.to_string()), QueryParam::String(file_path.to_string_lossy().to_string()), width.map(QueryParam::Int).unwrap_or(QueryParam::Null), height.map(QueryParam::Int).unwrap_or(QueryParam::Null), QueryParam::Int64(data.len() as i64), ], ); db.execute(query) .await .map_err(|e| format!("Failed to save to database: {}", e))?; Ok(file_path) } /// Ensure there's enough space by evicting LRU items if needed async fn ensure_space(&self, db: Arc, needed_bytes: u64) -> Result<(), String> { let max_size = { let config = self.config.lock().map_err(|e| e.to_string())?; config.max_size_bytes }; if max_size == 0 { return Ok(()); // Unlimited } let current_size = self.get_cache_size(db.clone()).await; if current_size + needed_bytes <= max_size { return Ok(()); // Enough space } // Need to evict LRU items let to_free = (current_size + needed_bytes).saturating_sub(max_size); self.evict_lru(db, to_free).await } /// Evict least recently used items to free up space async fn evict_lru(&self, db: Arc, to_free: u64) -> Result<(), String> { let mut freed: u64 = 0; // Get items ordered by last_accessed (oldest first) let query = Query::new( "SELECT id, file_path, file_size FROM thumbnails ORDER BY last_accessed ASC", ); let items: Vec<(i64, String, i64)> = db .query_many(query, |row| { Ok((row.get(0)?, row.get(1)?, row.get(2)?)) }) .await .map_err(|e| e.to_string())?; for (id, path, size) in items { if freed >= to_free { break; } // Delete file from disk let _ = std::fs::remove_file(&path); // Delete from database let delete_query = Query::with_params( "DELETE FROM thumbnails WHERE id = ?", vec![QueryParam::Int64(id)], ); let _ = db.execute(delete_query).await; freed += size as u64; } Ok(()) } /// Get current total cache size in bytes pub async fn get_cache_size(&self, db: Arc) -> u64 { let query = Query::new("SELECT COALESCE(SUM(file_size), 0) FROM thumbnails"); db.query_one(query, |row| row.get::<_, i64>(0)) .await .unwrap_or(0) as u64 } /// Get count of cached items pub async fn get_item_count(&self, db: Arc) -> i64 { let query = Query::new("SELECT COUNT(*) FROM thumbnails"); db.query_one(query, |row| row.get(0)) .await .unwrap_or(0) } /// Get the current cache limit in bytes pub async fn get_limit(&self, db: Arc) -> u64 { let query = Query::with_params( "SELECT value FROM cache_settings WHERE key = ?", vec![QueryParam::String("image_cache_limit_bytes".to_string())], ); db.query_optional(query, |row| row.get::<_, String>(0)) .await .ok() .flatten() .and_then(|s| s.parse().ok()) .unwrap_or(1024 * 1024 * 1024) // 1GB default } /// Set the cache limit in bytes pub async fn set_limit(&self, db: Arc, limit_bytes: u64) -> Result<(), String> { // Update database setting let query = Query::with_params( "INSERT OR REPLACE INTO cache_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", vec![ QueryParam::String("image_cache_limit_bytes".to_string()), QueryParam::String(limit_bytes.to_string()), ], ); db.execute(query) .await .map_err(|e| format!("Failed to update setting: {}", e))?; // Update in-memory config if let Ok(mut config) = self.config.lock() { config.max_size_bytes = limit_bytes; } // If new limit is lower, evict to comply let current_size = self.get_cache_size(db.clone()).await; if limit_bytes > 0 && current_size > limit_bytes { let to_free = current_size - limit_bytes; self.evict_lru(db, to_free).await?; } Ok(()) } /// Clear all cached thumbnails pub async fn clear_cache(&self, db: Arc) -> Result<(), String> { // Get all file paths let query = Query::new("SELECT file_path FROM thumbnails"); let paths: Vec = db .query_many(query, |row| row.get(0)) .await .map_err(|e| e.to_string())?; // Delete files from disk for path in paths { let _ = std::fs::remove_file(&path); } // Clear database let delete_query = Query::new("DELETE FROM thumbnails"); db.execute(delete_query) .await .map_err(|e| format!("Failed to clear database: {}", e))?; Ok(()) } /// Delete cached thumbnail for a specific item pub async fn delete_item(&self, db: Arc, item_id: &str) -> Result<(), String> { // Get file paths for this item let query = Query::with_params( "SELECT file_path FROM thumbnails WHERE item_id = ?", vec![QueryParam::String(item_id.to_string())], ); let paths: Vec = db .query_many(query, |row| row.get(0)) .await .map_err(|e| e.to_string())?; // Delete files for path in paths { let _ = std::fs::remove_file(&path); } // Delete from database let delete_query = Query::with_params( "DELETE FROM thumbnails WHERE item_id = ?", vec![QueryParam::String(item_id.to_string())], ); db.execute(delete_query) .await .map_err(|e| format!("Failed to delete from database: {}", e))?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::storage::db_service::RusqliteService; use rusqlite::Connection; use std::sync::{Arc, Mutex}; use tempfile::TempDir; fn setup_test_db() -> (Arc, TempDir) { let temp_dir = TempDir::new().unwrap(); let conn = Connection::open_in_memory().unwrap(); // Create tables conn.execute( "CREATE TABLE thumbnails ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, image_type TEXT NOT NULL, image_tag TEXT NOT NULL, file_path TEXT NOT NULL, width INTEGER, height INTEGER, file_size INTEGER DEFAULT 0, cached_at TEXT DEFAULT CURRENT_TIMESTAMP, last_accessed TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(item_id, image_type, image_tag) )", [], ) .unwrap(); conn.execute( "CREATE TABLE cache_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT DEFAULT CURRENT_TIMESTAMP )", [], ) .unwrap(); let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn)))); (db_service, temp_dir) } #[test] fn test_cache_creation() { let temp_dir = TempDir::new().unwrap(); let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default()); assert!(cache.cache_dir.exists()); assert!(cache.is_enabled()); } #[tokio::test] async fn test_save_and_get_thumbnail() { let (conn, temp_dir) = setup_test_db(); let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default()); // Save a thumbnail let data = b"fake image data"; let path = cache .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", data, Some(100), Some(100)) .await .unwrap(); assert!(path.exists()); // Get cached path let cached = cache.get_cached_path(conn.clone(), "item1", "Primary", "tag1").await; assert!(cached.is_some()); assert_eq!(cached.unwrap(), path); } #[tokio::test] async fn test_cache_miss() { let (conn, temp_dir) = setup_test_db(); let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default()); let cached = cache.get_cached_path(conn.clone(), "nonexistent", "Primary", "tag1").await; assert!(cached.is_none()); } #[tokio::test] async fn test_lru_eviction() { let (conn, temp_dir) = setup_test_db(); let config = CacheConfig { max_size_bytes: 100, // Very small limit ..Default::default() }; let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), config); // Add items that exceed limit let data = vec![0u8; 60]; cache .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None) .await .unwrap(); // Second item should trigger eviction cache .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None) .await .unwrap(); // First item should be evicted let cached = cache.get_cached_path(conn.clone(), "item1", "Primary", "tag1").await; assert!(cached.is_none()); // Second item should exist let cached = cache.get_cached_path(conn.clone(), "item2", "Primary", "tag2").await; assert!(cached.is_some()); } #[tokio::test] async fn test_clear_cache() { let (conn, temp_dir) = setup_test_db(); let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default()); let data = b"fake image data"; cache .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", data, None, None) .await .unwrap(); cache .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", data, None, None) .await .unwrap(); assert_eq!(cache.get_item_count(conn.clone()).await, 2); cache.clear_cache(conn.clone()).await.unwrap(); assert_eq!(cache.get_item_count(conn.clone()).await, 0); assert_eq!(cache.get_cache_size(conn.clone()).await, 0); } #[tokio::test] async fn test_set_limit() { let (conn, temp_dir) = setup_test_db(); let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default()); // Save some thumbnails let data = vec![0u8; 50]; cache .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None) .await .unwrap(); cache .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None) .await .unwrap(); // Set a limit smaller than current size cache.set_limit(conn.clone(), 60).await.unwrap(); // Some items should be evicted let size = cache.get_cache_size(conn.clone()).await; assert!(size <= 60); } }