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:
1 parent
a6fc264903
commit
7ed3dac7f5
4 files changed
+128
-53
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user