First working POC
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
//! 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::io::Write;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Thumbnail caching with LRU eviction
|
||||
//!
|
||||
//! This module handles caching image thumbnails from Jellyfin servers with:
|
||||
//! - Lazy caching (cache as viewed)
|
||||
//! - LRU (Least Recently Used) eviction when storage limit reached
|
||||
//! - Configurable storage limits
|
||||
//! - SQLite-backed cache metadata
|
||||
|
||||
pub mod cache;
|
||||
pub mod worker;
|
||||
|
||||
pub use cache::{CacheConfig, ThumbnailCache};
|
||||
pub use worker::ThumbnailWorker;
|
||||
|
||||
/// Statistics about the thumbnail cache
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThumbnailCacheStats {
|
||||
pub total_size_bytes: u64,
|
||||
pub item_count: i64,
|
||||
pub limit_bytes: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_stats_serialization() {
|
||||
let stats = ThumbnailCacheStats {
|
||||
total_size_bytes: 1024,
|
||||
item_count: 10,
|
||||
limit_bytes: 1073741824,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&stats).unwrap();
|
||||
assert!(json.contains("\"totalSizeBytes\":1024"));
|
||||
assert!(json.contains("\"itemCount\":10"));
|
||||
assert!(json.contains("\"limitBytes\":1073741824"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Thumbnail download worker
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// Worker for downloading thumbnails from Jellyfin server
|
||||
pub struct ThumbnailWorker {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl ThumbnailWorker {
|
||||
/// Create a new thumbnail worker
|
||||
pub fn new() -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Download a thumbnail from URL
|
||||
pub async fn download(&self, url: &str) -> Result<Vec<u8>, ThumbnailDownloadError> {
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ThumbnailDownloadError::Network(e.to_string()))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(ThumbnailDownloadError::Http(status.as_u16()));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| ThumbnailDownloadError::Network(e.to_string()))?;
|
||||
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
/// Download with retry logic
|
||||
pub async fn download_with_retry(
|
||||
&self,
|
||||
url: &str,
|
||||
max_retries: u32,
|
||||
) -> Result<Vec<u8>, ThumbnailDownloadError> {
|
||||
let mut last_error = ThumbnailDownloadError::Network("No attempts made".to_string());
|
||||
|
||||
for attempt in 0..=max_retries {
|
||||
match self.download(url).await {
|
||||
Ok(data) => return Ok(data),
|
||||
Err(e) if e.is_retryable() && attempt < max_retries => {
|
||||
last_error = e;
|
||||
// Simple exponential backoff: 100ms, 200ms, 400ms
|
||||
let delay = Duration::from_millis(100 * (1 << attempt));
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ThumbnailWorker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur during thumbnail download
|
||||
#[derive(Debug)]
|
||||
pub enum ThumbnailDownloadError {
|
||||
Network(String),
|
||||
Http(u16),
|
||||
}
|
||||
|
||||
impl ThumbnailDownloadError {
|
||||
/// Check if this error is retryable
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
match self {
|
||||
ThumbnailDownloadError::Network(_) => true,
|
||||
ThumbnailDownloadError::Http(status) => *status >= 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ThumbnailDownloadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ThumbnailDownloadError::Network(msg) => write!(f, "Network error: {}", msg),
|
||||
ThumbnailDownloadError::Http(status) => write!(f, "HTTP error: {}", status),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ThumbnailDownloadError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_error_retryable() {
|
||||
assert!(ThumbnailDownloadError::Network("timeout".to_string()).is_retryable());
|
||||
assert!(ThumbnailDownloadError::Http(500).is_retryable());
|
||||
assert!(ThumbnailDownloadError::Http(503).is_retryable());
|
||||
assert!(!ThumbnailDownloadError::Http(404).is_retryable());
|
||||
assert!(!ThumbnailDownloadError::Http(400).is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_worker_creation() {
|
||||
let _worker = ThumbnailWorker::new();
|
||||
// Just verify it doesn't panic
|
||||
let _default = ThumbnailWorker::default();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user