Duncan Tourolle ccc9dca924
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m16s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
fix: resolve all Rust test compile warnings without suppression
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
2026-06-21 09:33:50 +02:00

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);
}
}