feat: add image cache backend with LRU eviction

- ImageCacheService: disk-based cache keyed by MD5 hash of URL
- LRU eviction by last-access time (not creation time)
- ImageCacheSettings: configurable max size (default 200MB), can disable
- v2 commands: get_cached_image, get/set settings, stats, clear cache
- Cache stored at ~/.cache/qbz/images/ with SQLite metadata
This commit is contained in:
vicrodh committed 2026-03-08 10:37:42 -06:00
1 parent 7cec1101d1
commit e8d6aa4f4f
5 files changed
+530

No files matched your search

+164
View File
@@ -11330,3 +11330,167 @@ pub async fn v2_fetch_url_bytes(url: String) -> Result<Vec<u8>, String> {
.map(|b| b.to_vec())
.map_err(|e| format!("Failed to read response: {}", e))
}
// ============ Image Cache Commands ============
#[tauri::command]
pub async fn v2_get_cached_image(
url: String,
cache_state: State<'_, crate::image_cache::ImageCacheState>,
settings_state: State<'_, crate::config::ImageCacheSettingsState>,
) -> Result<String, String> {
// Check if caching is enabled
let settings = {
let lock = settings_state
.store
.lock()
.map_err(|e| format!("Settings lock error: {}", e))?;
match lock.as_ref() {
Some(store) => store.get_settings()?,
None => crate::config::ImageCacheSettings::default(),
}
};
if !settings.enabled {
// Cache disabled — return original URL
return Ok(url);
}
// Check cache first
{
let lock = cache_state
.service
.lock()
.map_err(|e| format!("Cache lock error: {}", e))?;
if let Some(service) = lock.as_ref() {
if let Some(path) = service.get(&url) {
return Ok(format!("file://{}", path.display()));
}
}
}
// Download the image (in blocking context since reqwest::blocking)
let url_clone = url.clone();
let bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
let response = reqwest::blocking::Client::new()
.get(&url_clone)
.timeout(std::time::Duration::from_secs(10))
.send()
.map_err(|e| format!("Failed to download image: {}", e))?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status()));
}
response
.bytes()
.map(|b| b.to_vec())
.map_err(|e| format!("Failed to read image bytes: {}", e))
})
.await
.map_err(|e| format!("Task join error: {}", e))??;
// Store in cache and evict if needed
let max_bytes = (settings.max_size_mb as u64) * 1024 * 1024;
let lock = cache_state
.service
.lock()
.map_err(|e| format!("Cache lock error: {}", e))?;
if let Some(service) = lock.as_ref() {
let path = service.store(&url, &bytes)?;
// Evict LRU entries if over limit
let _ = service.evict(max_bytes);
Ok(format!("file://{}", path.display()))
} else {
// Service not initialized, return original URL
Ok(url)
}
}
#[tauri::command]
pub async fn v2_get_image_cache_settings(
state: State<'_, crate::config::ImageCacheSettingsState>,
) -> Result<crate::config::ImageCacheSettings, String> {
let lock = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
match lock.as_ref() {
Some(store) => store.get_settings(),
None => Ok(crate::config::ImageCacheSettings::default()),
}
}
#[tauri::command]
pub async fn v2_set_image_cache_enabled(
enabled: bool,
state: State<'_, crate::config::ImageCacheSettingsState>,
) -> Result<(), String> {
let lock = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
match lock.as_ref() {
Some(store) => store.set_enabled(enabled),
None => Err("Image cache settings not initialized".to_string()),
}
}
#[tauri::command]
pub async fn v2_set_image_cache_max_size(
max_size_mb: u32,
state: State<'_, crate::config::ImageCacheSettingsState>,
cache_state: State<'_, crate::image_cache::ImageCacheState>,
) -> Result<(), String> {
{
let lock = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
match lock.as_ref() {
Some(store) => store.set_max_size_mb(max_size_mb)?,
None => return Err("Image cache settings not initialized".to_string()),
}
}
// Trigger eviction with new limit
let max_bytes = (max_size_mb as u64) * 1024 * 1024;
let lock = cache_state
.service
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
if let Some(service) = lock.as_ref() {
let _ = service.evict(max_bytes);
}
Ok(())
}
#[tauri::command]
pub async fn v2_get_image_cache_stats(
state: State<'_, crate::image_cache::ImageCacheState>,
) -> Result<crate::image_cache::ImageCacheStats, String> {
let lock = state
.service
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
match lock.as_ref() {
Some(service) => service.stats(),
None => Ok(crate::image_cache::ImageCacheStats {
total_bytes: 0,
file_count: 0,
}),
}
}
#[tauri::command]
pub async fn v2_clear_image_cache(
state: State<'_, crate::image_cache::ImageCacheState>,
) -> Result<u64, String> {
let lock = state
.service
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
match lock.as_ref() {
Some(service) => service.clear(),
None => Ok(0),
}
}
@@ -0,0 +1,111 @@
//! Image cache settings persistence
//!
//! Stores user preferences for the Qobuz image cache:
//! - enabled: whether to cache images locally (default: true)
//! - max_size_mb: maximum cache size in megabytes (default: 200)
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageCacheSettings {
pub enabled: bool,
pub max_size_mb: u32,
}
impl Default for ImageCacheSettings {
fn default() -> Self {
Self {
enabled: true,
max_size_mb: 200,
}
}
}
pub struct ImageCacheSettingsStore {
conn: Connection,
}
impl ImageCacheSettingsStore {
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = data_dir.join("image_cache_settings.db");
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open image cache settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS image_cache_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 1,
max_size_mb INTEGER NOT NULL DEFAULT 200
);
INSERT OR IGNORE INTO image_cache_settings (id, enabled, max_size_mb)
VALUES (1, 1, 200);",
)
.map_err(|e| format!("Failed to create image cache settings table: {}", e))?;
Ok(Self { conn })
}
pub fn get_settings(&self) -> Result<ImageCacheSettings, String> {
self.conn
.query_row(
"SELECT enabled, max_size_mb FROM image_cache_settings WHERE id = 1",
[],
|row| {
Ok(ImageCacheSettings {
enabled: row.get::<_, i32>(0)? != 0,
max_size_mb: row.get::<_, u32>(1)?,
})
},
)
.map_err(|e| format!("Failed to read image cache settings: {}", e))
}
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE image_cache_settings SET enabled = ?1 WHERE id = 1",
params![enabled as i32],
)
.map_err(|e| format!("Failed to update image cache enabled: {}", e))?;
Ok(())
}
pub fn set_max_size_mb(&self, max_size_mb: u32) -> Result<(), String> {
self.conn
.execute(
"UPDATE image_cache_settings SET max_size_mb = ?1 WHERE id = 1",
params![max_size_mb],
)
.map_err(|e| format!("Failed to update image cache max size: {}", e))?;
Ok(())
}
}
pub struct ImageCacheSettingsState {
pub store: Arc<Mutex<Option<ImageCacheSettingsStore>>>,
}
impl ImageCacheSettingsState {
pub fn new() -> Result<Self, String> {
Ok(Self {
store: Arc::new(Mutex::new(Some(ImageCacheSettingsStore::new()?))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
}
+5
View File
@@ -15,6 +15,7 @@ pub mod download_settings;
pub mod favorites_cache;
pub mod favorites_preferences;
pub mod graphics_settings;
pub mod image_cache_settings;
pub mod legal_settings;
pub mod playback_preferences;
pub mod remote_control_settings;
@@ -80,3 +81,7 @@ pub use graphics_settings::{
pub use window_settings::{
get_window_settings, set_use_system_titlebar, WindowSettings, WindowSettingsState,
};
pub use image_cache_settings::{
ImageCacheSettings, ImageCacheSettingsState, ImageCacheSettingsStore,
};
+224
View File
@@ -0,0 +1,224 @@
//! Image cache service
//!
//! LRU disk cache for Qobuz album/artist images.
//! - Stores images keyed by MD5 hash of URL
//! - Tracks last-access time for LRU eviction
//! - Respects configurable max size (default 200MB)
//! - Can be disabled entirely via settings
use md5::{Digest, Md5};
use rusqlite::{params, Connection};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
/// Cache statistics returned to the frontend
#[derive(Debug, Clone, serde::Serialize)]
pub struct ImageCacheStats {
pub total_bytes: u64,
pub file_count: u64,
}
pub struct ImageCacheService {
cache_dir: PathBuf,
conn: Connection,
}
impl ImageCacheService {
pub fn new() -> Result<Self, String> {
let cache_dir = dirs::cache_dir()
.ok_or_else(|| "Could not find cache directory".to_string())?
.join("qbz")
.join("images");
std::fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create image cache dir: {}", e))?;
let db_path = cache_dir.join("image_cache.db");
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open image cache database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS cached_images (
hash TEXT PRIMARY KEY,
url TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
last_accessed INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_last_accessed ON cached_images (last_accessed);",
)
.map_err(|e| format!("Failed to create image cache table: {}", e))?;
Ok(Self { cache_dir, conn })
}
fn url_hash(url: &str) -> String {
let mut hasher = Md5::new();
hasher.update(url.as_bytes());
format!("{:x}", hasher.finalize())
}
fn cache_path(&self, hash: &str) -> PathBuf {
self.cache_dir.join(format!("{}.img", hash))
}
/// Get a cached image path, updating last-access time.
/// Returns None if not cached.
pub fn get(&self, url: &str) -> Option<PathBuf> {
let hash = Self::url_hash(url);
let path = self.cache_path(&hash);
if !path.exists() {
// File missing — clean up stale DB entry
let _ = self
.conn
.execute("DELETE FROM cached_images WHERE hash = ?1", params![hash]);
return None;
}
// Update last-accessed time
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let _ = self.conn.execute(
"UPDATE cached_images SET last_accessed = ?1 WHERE hash = ?2",
params![now, hash],
);
Some(path)
}
/// Store image bytes in the cache.
/// Returns the local file path on success.
pub fn store(&self, url: &str, bytes: &[u8]) -> Result<PathBuf, String> {
let hash = Self::url_hash(url);
let path = self.cache_path(&hash);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
std::fs::write(&path, bytes)
.map_err(|e| format!("Failed to write cached image: {}", e))?;
let file_size = bytes.len() as i64;
self.conn
.execute(
"INSERT OR REPLACE INTO cached_images (hash, url, file_size, last_accessed)
VALUES (?1, ?2, ?3, ?4)",
params![hash, url, file_size, now],
)
.map_err(|e| format!("Failed to insert image cache entry: {}", e))?;
Ok(path)
}
/// Evict least-recently-accessed entries until total size is under max_bytes.
pub fn evict(&self, max_bytes: u64) -> Result<u64, String> {
let total: i64 = self
.conn
.query_row(
"SELECT COALESCE(SUM(file_size), 0) FROM cached_images",
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to query cache size: {}", e))?;
if (total as u64) <= max_bytes {
return Ok(0);
}
let mut to_free = (total as u64) - max_bytes;
let mut freed: u64 = 0;
// Get LRU entries (oldest access first)
let mut stmt = self
.conn
.prepare("SELECT hash, file_size FROM cached_images ORDER BY last_accessed ASC")
.map_err(|e| format!("Failed to prepare eviction query: {}", e))?;
let entries: Vec<(String, i64)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.map_err(|e| format!("Failed to query LRU entries: {}", e))?
.filter_map(|r| r.ok())
.collect();
for (hash, file_size) in entries {
if to_free == 0 {
break;
}
let path = self.cache_path(&hash);
if path.exists() {
let _ = std::fs::remove_file(&path);
}
let _ = self
.conn
.execute("DELETE FROM cached_images WHERE hash = ?1", params![hash]);
let size = file_size as u64;
freed += size;
to_free = to_free.saturating_sub(size);
}
Ok(freed)
}
/// Get cache statistics.
pub fn stats(&self) -> Result<ImageCacheStats, String> {
let (total_bytes, file_count): (i64, i64) = self
.conn
.query_row(
"SELECT COALESCE(SUM(file_size), 0), COUNT(*) FROM cached_images",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(|e| format!("Failed to query image cache stats: {}", e))?;
Ok(ImageCacheStats {
total_bytes: total_bytes as u64,
file_count: file_count as u64,
})
}
/// Clear the entire cache.
pub fn clear(&self) -> Result<u64, String> {
let stats = self.stats()?;
// Delete all files
if let Ok(entries) = std::fs::read_dir(&self.cache_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "img").unwrap_or(false) {
let _ = std::fs::remove_file(path);
}
}
}
// Clear database
self.conn
.execute("DELETE FROM cached_images", [])
.map_err(|e| format!("Failed to clear image cache table: {}", e))?;
Ok(stats.total_bytes)
}
}
pub struct ImageCacheState {
pub service: Arc<Mutex<Option<ImageCacheService>>>,
}
impl ImageCacheState {
pub fn new() -> Result<Self, String> {
Ok(Self {
service: Arc::new(Mutex::new(Some(ImageCacheService::new()?))),
})
}
pub fn new_empty() -> Self {
Self {
service: Arc::new(Mutex::new(None)),
}
}
}
+26
View File
@@ -26,6 +26,7 @@ pub mod config;
pub mod credentials;
pub mod discogs;
pub mod flatpak;
pub mod image_cache;
pub mod snap;
pub mod lastfm;
pub mod library;
@@ -666,6 +667,22 @@ pub fn run() {
let listenbrainz_v2_state = integrations_v2::ListenBrainzV2State::new();
let musicbrainz_v2_state = integrations_v2::MusicBrainzV2State::new();
let lastfm_v2_state = integrations_v2::LastFmV2State::new();
let image_cache_settings_state = config::ImageCacheSettingsState::new()
.unwrap_or_else(|e| {
log::warn!(
"Failed to initialize image cache settings: {}. Using empty state.",
e
);
config::ImageCacheSettingsState::new_empty()
});
let image_cache_state = image_cache::ImageCacheState::new()
.unwrap_or_else(|e| {
log::warn!(
"Failed to initialize image cache: {}. Using empty state.",
e
);
image_cache::ImageCacheState::new_empty()
});
let developer_settings_state = config::developer_settings::DeveloperSettingsState::new()
.unwrap_or_else(|e| {
log::warn!(
@@ -1141,6 +1158,8 @@ pub fn run() {
.manage(developer_settings_state)
.manage(graphics_settings_state)
.manage(window_settings_state)
.manage(image_cache_settings_state)
.manage(image_cache_state)
.manage(pdf_viewer::BookletState::new())
// V2 integration states (qbz-integrations crate)
.manage(listenbrainz_v2_state)
@@ -1605,6 +1624,13 @@ pub fn run() {
pdf_viewer::v2_booklet_render_page,
pdf_viewer::v2_booklet_save,
pdf_viewer::v2_booklet_close,
// Image cache
commands_v2::v2_get_cached_image,
commands_v2::v2_get_image_cache_settings,
commands_v2::v2_set_image_cache_enabled,
commands_v2::v2_set_image_cache_max_size,
commands_v2::v2_get_image_cache_stats,
commands_v2::v2_clear_image_cache,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")