Remove unused imports and a dead test helper, and strengthen tests that held unused bindings/fields so the values are actually exercised: - drop unused imports (HttpConfig, MediaType, std::io::Write) - remove unused TestEventEmitter::clear helper - replace meaningless size_of asserts with real empty-manager checks - assert on MockTrack.name in sorting tests
503 lines
16 KiB
Rust
503 lines
16 KiB
Rust
//! 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<Mutex<CacheConfig>>,
|
|
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<RusqliteService>,
|
|
item_id: &str,
|
|
image_type: &str,
|
|
tag: &str,
|
|
) -> Option<PathBuf> {
|
|
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<RusqliteService>,
|
|
item_id: &str,
|
|
image_type: &str,
|
|
tag: &str,
|
|
data: &[u8],
|
|
width: Option<i32>,
|
|
height: Option<i32>,
|
|
) -> Result<PathBuf, String> {
|
|
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<RusqliteService>, 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<RusqliteService>, 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<RusqliteService>) -> 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<RusqliteService>) -> 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<RusqliteService>) -> 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<RusqliteService>, 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<RusqliteService>) -> Result<(), String> {
|
|
// Get all file paths
|
|
let query = Query::new("SELECT file_path FROM thumbnails");
|
|
|
|
let paths: Vec<String> = 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<RusqliteService>, 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<String> = 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<RusqliteService>, 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);
|
|
}
|
|
}
|