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:
5 files changed
+530
No files matched your search
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,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")
|
||||
|
||||
Reference in new issue
Block a user