fix(library): reduce artwork cache bloat with thumbnails

- metadata.rs now creates 500px thumbnails instead of copying full images
- Added cache management commands: library_get_cache_stats,
  library_clear_artwork_cache, library_clear_thumbnails_cache
- Thumbnails stored in ~/.local/share/qbz/thumbnails/ (was ~/.cache/qbz/artwork)
- Reduces cache size from GB to MB for large libraries
This commit is contained in:
vicrodh committed 2026-01-28 11:24:19 -06:00
1 parent a6fc264903
commit 7ed3dac7f5
4 files changed
+128 -53

No files matched your search

+3
View File
@@ -570,6 +570,9 @@ pub fn run() {
library::commands::library_add_folder,
library::commands::library_remove_folder,
library::commands::library_cleanup_missing_files,
library::commands::library_get_cache_stats,
library::commands::library_clear_artwork_cache,
library::commands::library_clear_thumbnails_cache,
library::commands::library_get_folders,
library::commands::library_get_folders_with_metadata,
library::commands::library_get_folder,
+98
View File
@@ -147,6 +147,104 @@ pub async fn library_cleanup_missing_files(
})
}
/// Result of cache stats query
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CacheStats {
/// Size of old artwork cache in bytes (to be cleaned)
pub artwork_cache_bytes: u64,
/// Size of thumbnails cache in bytes
pub thumbnails_cache_bytes: u64,
/// Number of files in artwork cache
pub artwork_file_count: usize,
/// Number of files in thumbnails cache
pub thumbnail_file_count: usize,
}
/// Get cache statistics
#[tauri::command]
pub async fn library_get_cache_stats() -> Result<CacheStats, String> {
log::info!("Command: library_get_cache_stats");
// Old artwork cache
let artwork_dir = get_artwork_cache_dir();
let (artwork_bytes, artwork_count) = if artwork_dir.exists() {
let mut size = 0u64;
let mut count = 0usize;
if let Ok(entries) = fs::read_dir(&artwork_dir) {
for entry in entries.flatten() {
if let Ok(meta) = entry.metadata() {
if meta.is_file() {
size += meta.len();
count += 1;
}
}
}
}
(size, count)
} else {
(0, 0)
};
// Thumbnails cache
let thumbnails_bytes = thumbnails::get_cache_size().unwrap_or(0);
let thumbnail_count = if let Ok(dir) = thumbnails::get_thumbnails_dir() {
fs::read_dir(&dir).map(|e| e.count()).unwrap_or(0)
} else {
0
};
Ok(CacheStats {
artwork_cache_bytes: artwork_bytes,
thumbnails_cache_bytes: thumbnails_bytes,
artwork_file_count: artwork_count,
thumbnail_file_count: thumbnail_count,
})
}
/// Clear the old artwork cache (full-size images that are no longer needed)
#[tauri::command]
pub async fn library_clear_artwork_cache() -> Result<u64, String> {
log::info!("Command: library_clear_artwork_cache");
let artwork_dir = get_artwork_cache_dir();
if !artwork_dir.exists() {
return Ok(0);
}
let mut cleared_bytes = 0u64;
if let Ok(entries) = fs::read_dir(&artwork_dir) {
for entry in entries.flatten() {
if let Ok(meta) = entry.metadata() {
if meta.is_file() {
cleared_bytes += meta.len();
if let Err(e) = fs::remove_file(entry.path()) {
log::warn!("Failed to remove cache file {:?}: {}", entry.path(), e);
}
}
}
}
}
log::info!("Cleared {} bytes from artwork cache", cleared_bytes);
Ok(cleared_bytes)
}
/// Clear the thumbnails cache
#[tauri::command]
pub async fn library_clear_thumbnails_cache() -> Result<u64, String> {
log::info!("Command: library_clear_thumbnails_cache");
let size_before = thumbnails::get_cache_size().unwrap_or(0);
thumbnails::clear_thumbnails().map_err(|e| e.to_string())?;
log::info!("Cleared {} bytes from thumbnails cache", size_before);
Ok(size_before)
}
#[tauri::command]
pub async fn library_get_folders(state: State<'_, LibraryState>) -> Result<Vec<String>, String> {
log::info!("Command: library_get_folders");
+25 -52
View File
@@ -6,6 +6,7 @@ use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::library::{AudioFormat, AudioProperties, LibraryError, LocalTrack};
use crate::library::thumbnails::{generate_thumbnail, generate_thumbnail_from_bytes};
/// Metadata extractor using lofty
pub struct MetadataExtractor;
@@ -425,72 +426,44 @@ impl MetadataExtractor {
}
}
/// Extract and save artwork to cache directory
/// Returns path to saved artwork or None
pub fn extract_artwork(file_path: &Path, cache_dir: &Path) -> Option<String> {
/// Extract and save artwork as thumbnail to cache directory
/// Returns path to saved thumbnail or None
/// Note: cache_dir parameter is kept for API compatibility but thumbnails go to thumbnails dir
pub fn extract_artwork(file_path: &Path, _cache_dir: &Path) -> Option<String> {
let tagged_file = Probe::open(file_path).ok()?.read().ok()?;
let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag())?;
let picture = tag.pictures().first()?;
// Generate filename from hash of file path
let hash = Self::simple_hash(&file_path.to_string_lossy());
let ext = match picture.mime_type() {
Some(MimeType::Png) => "png",
Some(MimeType::Jpeg) => "jpg",
Some(MimeType::Gif) => "gif",
Some(MimeType::Bmp) => "bmp",
_ => "jpg",
};
// Use file path as cache key for consistent thumbnail naming
let cache_key = file_path.to_string_lossy().to_string();
let artwork_filename = format!("{:x}.{}", hash, ext);
let artwork_path = cache_dir.join(&artwork_filename);
// Skip if already exists
if artwork_path.exists() {
return Some(artwork_path.to_string_lossy().to_string());
// Generate thumbnail from embedded artwork bytes (resized to 500px)
match generate_thumbnail_from_bytes(picture.data(), &cache_key) {
Ok(thumbnail_path) => Some(thumbnail_path.to_string_lossy().to_string()),
Err(e) => {
log::warn!("Failed to generate thumbnail for {:?}: {}", file_path, e);
None
}
}
// Ensure cache dir exists
fs::create_dir_all(cache_dir).ok()?;
// Write artwork
fs::write(&artwork_path, picture.data()).ok()?;
Some(artwork_path.to_string_lossy().to_string())
}
/// Copy an existing artwork file into the cache directory
/// Returns path to cached artwork or None
pub fn cache_artwork_file(artwork_path: &Path, cache_dir: &Path) -> Option<String> {
/// Generate thumbnail from an existing artwork file (e.g., cover.jpg, folder.jpg)
/// Returns path to thumbnail or None
/// Note: cache_dir parameter is kept for API compatibility but thumbnails go to thumbnails dir
pub fn cache_artwork_file(artwork_path: &Path, _cache_dir: &Path) -> Option<String> {
if !artwork_path.is_file() {
return None;
}
let ext = artwork_path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase())
.unwrap_or_else(|| "jpg".to_string());
let normalized_ext = match ext.as_str() {
"jpeg" => "jpg",
"png" | "jpg" | "gif" | "bmp" | "webp" => ext.as_str(),
_ => "jpg",
};
let hash = Self::simple_hash(&artwork_path.to_string_lossy());
let cached_name = format!("local_{:x}.{}", hash, normalized_ext);
let cached_path = cache_dir.join(cached_name);
if cached_path.exists() {
return Some(cached_path.to_string_lossy().to_string());
// Generate thumbnail (resized to 500px) instead of copying full-size file
match generate_thumbnail(artwork_path) {
Ok(thumbnail_path) => Some(thumbnail_path.to_string_lossy().to_string()),
Err(e) => {
log::warn!("Failed to generate thumbnail for {:?}: {}", artwork_path, e);
None
}
}
fs::create_dir_all(cache_dir).ok()?;
fs::copy(artwork_path, &cached_path).ok()?;
Some(cached_path.to_string_lossy().to_string())
}
/// Simple hash function for generating filenames
+2 -1
View File
@@ -7,7 +7,8 @@ use std::path::{Path, PathBuf};
use super::errors::LibraryError;
/// Default thumbnail size (width and height)
const THUMBNAIL_SIZE: u32 = 200;
/// 500px is a good balance for UI display while keeping file size reasonable
const THUMBNAIL_SIZE: u32 = 500;
/// Get the thumbnails directory path
pub fn get_thumbnails_dir() -> Result<PathBuf, LibraryError> {