feat(qbz-slint): route album artwork through the shared image cache
Cover art now goes through the QBZ image cache instead of being downloaded directly every time, matching the Tauri behavior. - Moves ImageCacheService / ImageCacheStats into the qbz-cache crate (LRU disk cache, MD5-keyed, SQLite index). src-tauri/image_cache.rs re-exports it and keeps the Tauri managed-state wrapper, so the Tauri app stays behavior-identical and both shells share one implementation and one on-disk cache (~/.cache/qbz/images). - qbz-slint artwork.rs checks the cache first, downloads and stores on a miss, and trims the cache to 200MB on startup. cc
This commit is contained in:
9 files changed
+301
-217
No files matched your search
Generated
+3
@@ -5215,6 +5215,8 @@ dependencies = [
|
||||
"dirs",
|
||||
"filetime",
|
||||
"log",
|
||||
"md-5",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -5377,6 +5379,7 @@ dependencies = [
|
||||
"log",
|
||||
"open",
|
||||
"qbz-app",
|
||||
"qbz-cache",
|
||||
"qbz-core",
|
||||
"qbz-models",
|
||||
"reqwest",
|
||||
|
||||
@@ -15,3 +15,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
# File system utilities
|
||||
dirs = "6"
|
||||
filetime = "0.2"
|
||||
|
||||
# Image cache index + URL hashing
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
md-5 = "0.10"
|
||||
@@ -0,0 +1,205 @@
|
||||
//! 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 a configurable max size
|
||||
//! - Framework-agnostic: shared by the Tauri app and the Slint shell so
|
||||
//! both read and write the same `~/.cache/qbz/images` cache.
|
||||
|
||||
use md5::{Digest, Md5};
|
||||
use rusqlite::{params, Connection};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Cache statistics.
|
||||
#[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)
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```no_run
|
||||
//! ```ignore
|
||||
//! use qbz_cache::{AudioCache, PlaybackCache};
|
||||
//! use std::sync::Arc;
|
||||
//!
|
||||
@@ -56,7 +56,9 @@
|
||||
//! ```
|
||||
|
||||
mod audio_cache;
|
||||
mod image_cache;
|
||||
mod playback_cache;
|
||||
|
||||
pub use audio_cache::{AudioCache, CacheStats, CachedTrack};
|
||||
pub use image_cache::{ImageCacheService, ImageCacheStats};
|
||||
pub use playback_cache::{PlaybackCache, PlaybackCacheStats};
|
||||
@@ -23,6 +23,7 @@ open = "5"
|
||||
qbz-app = { path = "../qbz-app" }
|
||||
qbz-core = { path = "../qbz-core" }
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
qbz-cache = { path = "../qbz-cache" }
|
||||
|
||||
[build-dependencies]
|
||||
slint-build = "1"
|
||||
@@ -1,12 +1,14 @@
|
||||
//! Album artwork pipeline.
|
||||
//!
|
||||
//! Cover images are fetched and decoded off the UI thread, then applied to
|
||||
//! individual `AlbumCardItem` rows on the Slint event loop. Only the
|
||||
//! affected row is updated, so artwork arriving never resets a list
|
||||
//! (POC ADR, sections 16 and 18).
|
||||
//! Cover images go through the shared QBZ image cache (`qbz_cache`), the
|
||||
//! same disk cache the Tauri app uses — covers are never re-downloaded
|
||||
//! once cached. Fetch and decode run off the UI thread; each decoded
|
||||
//! cover is applied to its own `AlbumCardItem` row on the Slint event
|
||||
//! loop, so artwork arriving never resets a list (POC ADR 16 and 18).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use qbz_cache::ImageCacheService;
|
||||
use slint::{ComponentHandle, Model};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
@@ -15,6 +17,17 @@ use crate::{AppWindow, HomeState};
|
||||
/// Cap on simultaneous artwork downloads.
|
||||
const MAX_CONCURRENT: usize = 16;
|
||||
|
||||
/// Target decode size. Cards display at 220px; 264px keeps them crisp at
|
||||
/// modest DPI without holding full ~600px source textures in memory.
|
||||
const DECODE_SIZE: u32 = 264;
|
||||
|
||||
/// Default image-cache size budget (matches the Tauri default).
|
||||
pub const MAX_CACHE_BYTES: u64 = 200 * 1024 * 1024;
|
||||
|
||||
/// Shared, optional image cache. `None` when the cache could not be opened
|
||||
/// — artwork then falls back to direct downloads.
|
||||
pub type ImageCache = Arc<Mutex<Option<ImageCacheService>>>;
|
||||
|
||||
/// An artwork download job: which card, and the image URL.
|
||||
pub struct ArtworkJob {
|
||||
pub section_idx: usize,
|
||||
@@ -22,16 +35,45 @@ pub struct ArtworkJob {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Open the shared QBZ image cache.
|
||||
pub fn open_cache() -> ImageCache {
|
||||
match ImageCacheService::new() {
|
||||
Ok(service) => Arc::new(Mutex::new(Some(service))),
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] image cache unavailable: {e}");
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim the image cache to the size budget. Runs once at startup.
|
||||
pub fn spawn_evict(cache: ImageCache) {
|
||||
tokio::spawn(async move {
|
||||
if let Ok(guard) = cache.lock() {
|
||||
if let Some(service) = guard.as_ref() {
|
||||
match service.evict(MAX_CACHE_BYTES) {
|
||||
Ok(freed) if freed > 0 => {
|
||||
log::info!("[qbz-slint] image cache evicted {freed} bytes")
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("[qbz-slint] image cache eviction failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawn artwork downloads for every job. Each completion updates only its
|
||||
/// own card row. Must be called from within the tokio runtime.
|
||||
pub fn spawn_loads(jobs: Vec<ArtworkJob>, window: slint::Weak<AppWindow>) {
|
||||
pub fn spawn_loads(jobs: Vec<ArtworkJob>, window: slint::Weak<AppWindow>, cache: ImageCache) {
|
||||
let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT));
|
||||
for job in jobs {
|
||||
let semaphore = semaphore.clone();
|
||||
let window = window.clone();
|
||||
let cache = cache.clone();
|
||||
tokio::spawn(async move {
|
||||
let _permit = semaphore.acquire().await.ok()?;
|
||||
let (pixels, width, height) = fetch_and_decode(&job.url).await?;
|
||||
let (pixels, width, height) = fetch_and_decode(&job.url, &cache).await?;
|
||||
let _ = window.upgrade_in_event_loop(move |w| {
|
||||
apply_artwork(&w, job.section_idx, job.album_idx, &pixels, width, height);
|
||||
});
|
||||
@@ -40,14 +82,28 @@ pub fn spawn_loads(jobs: Vec<ArtworkJob>, window: slint::Weak<AppWindow>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Target decode size. Cards display at 220px; 264px keeps them crisp at
|
||||
/// modest DPI without holding full ~600px source textures in memory.
|
||||
const DECODE_SIZE: u32 = 264;
|
||||
/// Resolve one cover image to raw RGBA8 pixels, downscaled to `DECODE_SIZE`.
|
||||
/// Reads from the shared cache on a hit; on a miss downloads, stores, and
|
||||
/// uses the bytes. Runs on a worker thread; the result tuple is `Send`.
|
||||
async fn fetch_and_decode(url: &str, cache: &ImageCache) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let cached_path = {
|
||||
let guard = cache.lock().ok()?;
|
||||
guard.as_ref().and_then(|service| service.get(url))
|
||||
};
|
||||
|
||||
let bytes: Vec<u8> = match cached_path {
|
||||
Some(path) => tokio::fs::read(&path).await.ok()?,
|
||||
None => {
|
||||
let downloaded = reqwest::get(url).await.ok()?.bytes().await.ok()?.to_vec();
|
||||
if let Ok(guard) = cache.lock() {
|
||||
if let Some(service) = guard.as_ref() {
|
||||
let _ = service.store(url, &downloaded);
|
||||
}
|
||||
}
|
||||
downloaded
|
||||
}
|
||||
};
|
||||
|
||||
/// Download and decode one cover image into raw RGBA8 pixels, downscaled to
|
||||
/// `DECODE_SIZE`. Runs on a worker thread; the result tuple is `Send`.
|
||||
async fn fetch_and_decode(url: &str) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let bytes = reqwest::get(url).await.ok()?.bytes().await.ok()?;
|
||||
let rgba = image::load_from_memory(&bytes)
|
||||
.ok()?
|
||||
.thumbnail(DECODE_SIZE, DECODE_SIZE)
|
||||
|
||||
@@ -37,10 +37,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
let tokio_rt = tokio::runtime::Runtime::new()?;
|
||||
let _enter = tokio_rt.enter();
|
||||
|
||||
let window = AppWindow::new()?;
|
||||
let app_runtime = Arc::new(AppRuntime::new(SlintAdapter::new(window.as_weak())));
|
||||
|
||||
// Shared QBZ image cache for album artwork; trim it on startup.
|
||||
let image_cache = artwork::open_cache();
|
||||
artwork::spawn_evict(image_cache.clone());
|
||||
|
||||
// Extract Qobuz bundle tokens in the background so OAuth is ready
|
||||
// by the time the user signs in.
|
||||
{
|
||||
@@ -59,9 +64,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
move || {
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
let image_cache = image_cache.clone();
|
||||
handle.spawn(async move {
|
||||
let user_id = match auth::login_via_system_browser(&runtime).await {
|
||||
Ok(user_id) => user_id,
|
||||
@@ -104,7 +111,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
home::apply_sections(&w, sections);
|
||||
w.global::<HomeState>().set_loading(false);
|
||||
});
|
||||
artwork::spawn_loads(jobs, weak.clone());
|
||||
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] discover load failed: {e}");
|
||||
|
||||
Generated
+2
@@ -5074,6 +5074,8 @@ dependencies = [
|
||||
"dirs",
|
||||
"filetime",
|
||||
"log",
|
||||
"md-5",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,208 +1,12 @@
|
||||
//! Image cache service
|
||||
//! Image cache — Tauri-side wiring.
|
||||
//!
|
||||
//! 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
|
||||
//! The cache implementation now lives in the `qbz-cache` crate so the
|
||||
//! Slint shell shares the exact same code and on-disk cache. This module
|
||||
//! re-exports it and keeps the Tauri managed-state wrapper.
|
||||
|
||||
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 use qbz_cache::{ImageCacheService, ImageCacheStats};
|
||||
|
||||
pub struct ImageCacheState {
|
||||
pub service: Arc<Mutex<Option<ImageCacheService>>>,
|
||||
|
||||
Reference in new issue
Block a user