diff --git a/src-tauri/src/commands_v2.rs b/src-tauri/src/commands_v2.rs index 95880a106..61ede3c34 100644 --- a/src-tauri/src/commands_v2.rs +++ b/src-tauri/src/commands_v2.rs @@ -11330,3 +11330,167 @@ pub async fn v2_fetch_url_bytes(url: String) -> Result, 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 { + // 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, 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 { + 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 { + 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 { + let lock = state + .service + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + match lock.as_ref() { + Some(service) => service.clear(), + None => Ok(0), + } +} diff --git a/src-tauri/src/config/image_cache_settings.rs b/src-tauri/src/config/image_cache_settings.rs new file mode 100644 index 000000000..fcb9a97c3 --- /dev/null +++ b/src-tauri/src/config/image_cache_settings.rs @@ -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 { + 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 { + 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>>, +} + +impl ImageCacheSettingsState { + pub fn new() -> Result { + Ok(Self { + store: Arc::new(Mutex::new(Some(ImageCacheSettingsStore::new()?))), + }) + } + + pub fn new_empty() -> Self { + Self { + store: Arc::new(Mutex::new(None)), + } + } +} diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 2800548db..dd5e83ccc 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -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, +}; diff --git a/src-tauri/src/image_cache.rs b/src-tauri/src/image_cache.rs new file mode 100644 index 000000000..ea37144e7 --- /dev/null +++ b/src-tauri/src/image_cache.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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>>, +} + +impl ImageCacheState { + pub fn new() -> Result { + Ok(Self { + service: Arc::new(Mutex::new(Some(ImageCacheService::new()?))), + }) + } + + pub fn new_empty() -> Self { + Self { + service: Arc::new(Mutex::new(None)), + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dcdfda124..98526e645 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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")