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:
vicrodh committed 2026-05-18 13:32:10 -06:00
1 parent 2e47fbb544
commit 3e61ec3b0f
9 files changed
+301 -217

No files matched your search

+3
View File
@@ -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",
+4
View File
@@ -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"
+205
View File
@@ -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)
}
}
+3 -1
View File
@@ -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};
+1
View File
@@ -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"
+70 -14
View File
@@ -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)
+8 -1
View File
@@ -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}");
+2
View File
@@ -5074,6 +5074,8 @@ dependencies = [
"dirs",
"filetime",
"log",
"md-5",
"rusqlite",
"serde",
]
+5 -201
View File
@@ -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>>>,