fix(artwork): bound the image cache during use, make the budget a setting, sweep orphans

The boot trim alone let a long session grow the folder past the budget
again (an early release reached 1 GB). A trim now also runs on the
blocking pool every 16 MB of new originals, the budget comes from
ui_prefs image_cache_max_mb (100/200/500/1000 MB, 200 default), .img files
with no row older than an hour are swept at boot, and the settings
document carries the stats, the size options and a clear action.

cc
This commit is contained in:
vicrodh committed 2026-09-13 09:09:29 -06:00
1 parent 903dfbefc6
commit 903952cacb
12 files changed
+392 -8

No files matched your search

+72
View File
@@ -197,6 +197,53 @@ impl ImageCacheService {
Ok(batch) Ok(batch)
} }
/// Delete `.img` files that have no row (a crash between `store`'s file
/// write and its insert, or a database that was lost), skipping anything
/// younger than `min_age` so a store in flight is never raced. Returns
/// (files removed, bytes freed).
pub fn sweep_orphans(&self, min_age: std::time::Duration) -> Result<(usize, u64), String> {
let mut stmt = self
.conn
.prepare("SELECT hash FROM cached_images")
.map_err(|e| format!("Failed to prepare orphan query: {}", e))?;
let tracked: std::collections::HashSet<String> = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| format!("Failed to query tracked images: {}", e))?
.filter_map(|r| r.ok())
.collect();
let entries = std::fs::read_dir(&self.cache_dir)
.map_err(|e| format!("Failed to read image cache dir: {}", e))?;
let now = std::time::SystemTime::now();
let (mut files, mut bytes) = (0usize, 0u64);
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("img") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if tracked.contains(stem) {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
let young = meta
.modified()
.ok()
.and_then(|m| now.duration_since(m).ok())
.map(|age| age < min_age)
.unwrap_or(true);
if young {
continue;
}
if std::fs::remove_file(&path).is_ok() {
files += 1;
bytes += meta.len();
}
}
Ok((files, bytes))
}
/// Get cache statistics. /// Get cache statistics.
pub fn stats(&self) -> Result<ImageCacheStats, String> { pub fn stats(&self) -> Result<ImageCacheStats, String> {
let (total_bytes, file_count): (i64, i64) = self let (total_bytes, file_count): (i64, i64) = self
@@ -297,6 +344,31 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
fn sweep_orphans_removes_only_old_files_without_a_row() {
let dir = fresh_dir("orphans");
let cache = ImageCacheService::open_at(dir.clone()).unwrap();
let tracked = cache.store("https://img/tracked", &[0u8; 100]).unwrap();
let old_orphan = dir.join("0000000000000000000000000000dead.img");
std::fs::write(&old_orphan, [0u8; 300]).unwrap();
let two_hours_ago = filetime::FileTime::from_unix_time(
filetime::FileTime::now().unix_seconds() - 7_200,
0,
);
filetime::set_file_mtime(&old_orphan, two_hours_ago).unwrap();
let fresh_orphan = dir.join("000000000000000000000000000beef1.img");
std::fs::write(&fresh_orphan, [0u8; 50]).unwrap();
assert_eq!(
cache.sweep_orphans(std::time::Duration::from_secs(3_600)).unwrap(),
(1, 300)
);
assert!(tracked.exists(), "a tracked file is never swept");
assert!(!old_orphan.exists());
assert!(fresh_orphan.exists(), "a store in flight is never raced");
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
fn get_refreshes_last_accessed_so_a_touched_entry_survives_eviction() { fn get_refreshes_last_accessed_so_a_touched_entry_survives_eviction() {
let dir = fresh_dir("touch"); let dir = fresh_dir("touch");
@@ -7523,3 +7523,23 @@ msgstr "Schleife aufheben"
#: qbz-qt/qml/settings/AppearanceSettings.qml #: qbz-qt/qml/settings/AppearanceSettings.qml
msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default." msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default."
msgstr "Führt die Gastinterpreten des Titels nach dem Hauptkünstler in den Qobuz-Titelzeilen auf, durch Kommas getrennt. Standardmäßig aus." msgstr "Führt die Gastinterpreten des Titels nach dem Hauptkünstler in den Qobuz-Titelzeilen auf, durch Kommas getrennt. Standardmäßig aus."
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "ARTWORK CACHE"
msgstr "COVER-CACHE"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Maximum size"
msgstr "Maximale Größe"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Album and artist images are kept on disk up to this budget; the least recently shown are dropped first, during use and at startup."
msgstr "Album- und Künstlerbilder werden bis zu diesem Budget auf der Festplatte behalten; die am längsten nicht gezeigten werden zuerst entfernt, während der Nutzung und beim Start."
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Clear artwork cache"
msgstr "Cover-Cache leeren"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "{} images using {}"
msgstr "{} Bilder belegen {}"
@@ -3149,3 +3149,23 @@ msgstr "Clear loop"
#: qbz-qt/qml/settings/AppearanceSettings.qml #: qbz-qt/qml/settings/AppearanceSettings.qml
msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default." msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default."
msgstr "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default." msgstr "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default."
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "ARTWORK CACHE"
msgstr "ARTWORK CACHE"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Maximum size"
msgstr "Maximum size"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Album and artist images are kept on disk up to this budget; the least recently shown are dropped first, during use and at startup."
msgstr "Album and artist images are kept on disk up to this budget; the least recently shown are dropped first, during use and at startup."
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Clear artwork cache"
msgstr "Clear artwork cache"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "{} images using {}"
msgstr "{} images using {}"
@@ -7529,3 +7529,23 @@ msgstr "Quitar bucle"
#: qbz-qt/qml/settings/AppearanceSettings.qml #: qbz-qt/qml/settings/AppearanceSettings.qml
msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default." msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default."
msgstr "Muestra los intérpretes invitados de la pista después del artista principal en las filas de pistas de Qobuz, separados por comas. Desactivado por defecto." msgstr "Muestra los intérpretes invitados de la pista después del artista principal en las filas de pistas de Qobuz, separados por comas. Desactivado por defecto."
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "ARTWORK CACHE"
msgstr "CACHÉ DE PORTADAS"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Maximum size"
msgstr "Tamaño máximo"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Album and artist images are kept on disk up to this budget; the least recently shown are dropped first, during use and at startup."
msgstr "Las imágenes de álbumes y artistas se guardan en disco hasta este límite; las que llevan más tiempo sin mostrarse se descartan primero, durante el uso y al arrancar."
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Clear artwork cache"
msgstr "Vaciar caché de portadas"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "{} images using {}"
msgstr "{} imágenes ocupando {}"
@@ -7521,3 +7521,23 @@ msgstr "Supprimer la boucle"
#: qbz-qt/qml/settings/AppearanceSettings.qml #: qbz-qt/qml/settings/AppearanceSettings.qml
msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default." msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default."
msgstr "Liste les interprètes invités du morceau après l'artiste principal sur les lignes de piste Qobuz, séparés par des virgules. Désactivé par défaut." msgstr "Liste les interprètes invités du morceau après l'artiste principal sur les lignes de piste Qobuz, séparés par des virgules. Désactivé par défaut."
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "ARTWORK CACHE"
msgstr "CACHE DES POCHETTES"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Maximum size"
msgstr "Taille maximale"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Album and artist images are kept on disk up to this budget; the least recently shown are dropped first, during use and at startup."
msgstr "Les images d'albums et d'artistes sont conservées sur le disque jusqu'à ce plafond ; les moins récemment affichées sont supprimées en premier, pendant l'utilisation et au démarrage."
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Clear artwork cache"
msgstr "Vider le cache des pochettes"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "{} images using {}"
msgstr "{} images occupant {}"
@@ -7504,3 +7504,23 @@ msgstr "ループを解除"
#: qbz-qt/qml/settings/AppearanceSettings.qml #: qbz-qt/qml/settings/AppearanceSettings.qml
msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default." msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default."
msgstr "Qobuz のトラック行で、メインアーティストの後にフィーチャリングされた演奏者をカンマ区切りで表示します。既定ではオフです。" msgstr "Qobuz のトラック行で、メインアーティストの後にフィーチャリングされた演奏者をカンマ区切りで表示します。既定ではオフです。"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "ARTWORK CACHE"
msgstr "アートワークキャッシュ"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Maximum size"
msgstr "最大サイズ"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Album and artist images are kept on disk up to this budget; the least recently shown are dropped first, during use and at startup."
msgstr "アルバムとアーティストの画像はこの上限までディスクに保持され、最も長く表示されていないものから削除されます(使用中および起動時)。"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Clear artwork cache"
msgstr "アートワークキャッシュを消去"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "{} images using {}"
msgstr "{} 件の画像、使用量 {}"
@@ -7511,3 +7511,23 @@ msgstr "Loop wissen"
#: qbz-qt/qml/settings/AppearanceSettings.qml #: qbz-qt/qml/settings/AppearanceSettings.qml
msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default." msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default."
msgstr "Toont de gastartiesten van de track na de hoofdartiest in Qobuz-trackregels, gescheiden door komma's. Standaard uit." msgstr "Toont de gastartiesten van de track na de hoofdartiest in Qobuz-trackregels, gescheiden door komma's. Standaard uit."
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "ARTWORK CACHE"
msgstr "ARTWORKCACHE"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Maximum size"
msgstr "Maximale grootte"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Album and artist images are kept on disk up to this budget; the least recently shown are dropped first, during use and at startup."
msgstr "Album- en artiestafbeeldingen blijven op schijf tot dit budget; de minst recent getoonde worden het eerst verwijderd, tijdens gebruik en bij het opstarten."
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Clear artwork cache"
msgstr "Artworkcache wissen"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "{} images using {}"
msgstr "{} afbeeldingen, {} in gebruik"
@@ -7521,3 +7521,23 @@ msgstr "Remover loop"
#: qbz-qt/qml/settings/AppearanceSettings.qml #: qbz-qt/qml/settings/AppearanceSettings.qml
msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default." msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default."
msgstr "Lista os intérpretes convidados da faixa após o artista principal nas linhas de faixa do Qobuz, separados por vírgulas. Desligado por padrão." msgstr "Lista os intérpretes convidados da faixa após o artista principal nas linhas de faixa do Qobuz, separados por vírgulas. Desligado por padrão."
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "ARTWORK CACHE"
msgstr "CACHE DE CAPAS"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Maximum size"
msgstr "Tamanho máximo"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Album and artist images are kept on disk up to this budget; the least recently shown are dropped first, during use and at startup."
msgstr "As imagens de álbuns e artistas ficam no disco até este limite; as menos recentemente mostradas são descartadas primeiro, durante o uso e no arranque."
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Clear artwork cache"
msgstr "Limpar cache de capas"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "{} images using {}"
msgstr "{} imagens ocupando {}"
@@ -7542,3 +7542,23 @@ msgstr "Убрать петлю"
#: qbz-qt/qml/settings/AppearanceSettings.qml #: qbz-qt/qml/settings/AppearanceSettings.qml
msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default." msgid "List the track's featured performers after the main artist on Qobuz track rows, comma separated. Off by default."
msgstr "Перечисляет приглашённых исполнителей трека после основного артиста в строках треков Qobuz, через запятую. По умолчанию выключено." msgstr "Перечисляет приглашённых исполнителей трека после основного артиста в строках треков Qobuz, через запятую. По умолчанию выключено."
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "ARTWORK CACHE"
msgstr "КЭШ ОБЛОЖЕК"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Maximum size"
msgstr "Максимальный размер"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Album and artist images are kept on disk up to this budget; the least recently shown are dropped first, during use and at startup."
msgstr "Изображения альбомов и артистов хранятся на диске в пределах этого лимита; дольше всего не показывавшиеся удаляются первыми — во время работы и при запуске."
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "Clear artwork cache"
msgstr "Очистить кэш обложек"
#: qbz-qt/qml/settings/OfflineSettings.qml
msgid "{} images using {}"
msgstr "{} изображений занимают {}"
+111 -8
View File
@@ -51,7 +51,9 @@
//! [`housekeeping`] runs on the blocking pool at boot and bounds BOTH caches: //! [`housekeeping`] runs on the blocking pool at boot and bounds BOTH caches:
//! the SHARED `~/.cache/qbz/images` (the full-size originals `store` writes; //! the SHARED `~/.cache/qbz/images` (the full-size originals `store` writes;
//! `qbz_cache::ImageCacheService`, SQLite `last_accessed` LRU, budget //! `qbz_cache::ImageCacheService`, SQLite `last_accessed` LRU, budget
//! [`MAX_SHARED_BYTES`], trimmed in [`EVICT_BATCH_ENTRIES`]-row batches so the //! [`shared_budget_bytes`] (a Settings preference, 200 MB by default), trimmed
//! at boot and again every [`TRIM_EVERY_BYTES`] of new originals, in
//! [`EVICT_BATCH_ENTRIES`]-row batches so the
//! GUI thread is never parked behind a long unlink loop) and this module's //! GUI thread is never parked behind a long unlink loop) and this module's
//! OWN derivative directory `images/scaled` ([`MAX_SCALED_BYTES`], LRU by //! OWN derivative directory `images/scaled` ([`MAX_SCALED_BYTES`], LRU by
//! mtime, plus the one-time `.jpg` orphan sweep). The shared trim was the //! mtime, plus the one-time `.jpg` orphan sweep). The shared trim was the
@@ -61,6 +63,7 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, LazyLock, Mutex, OnceLock, RwLock}; use std::sync::{Arc, LazyLock, Mutex, OnceLock, RwLock};
use qbz_cache::ImageCacheService; use qbz_cache::ImageCacheService;
@@ -583,6 +586,7 @@ async fn fetch_jobs_with_ready(jobs: Vec<(String, String)>, ready: Option<Artwor
} }
}); });
if let Some(path) = stored { if let Some(path) = stored {
note_stored(bytes.len());
// Seed the memo so the republish that follows resolves from // Seed the memo so the republish that follows resolves from
// RAM instead of re-querying SQLite for every card. // RAM instead of re-querying SQLite for every card.
memo_put(&key, path.clone()); memo_put(&key, path.clone());
@@ -922,6 +926,7 @@ async fn download_one(key: String, fetch: String) {
} }
}); });
if let Some(path) = stored { if let Some(path) = stored {
note_stored(bytes.len());
memo_put(&key, path); memo_put(&key, path);
} }
} }
@@ -994,6 +999,19 @@ mod tests {
assert!(rx.recv().await.is_none(), "duplicate URLs must emit once"); assert!(rx.recv().await.is_none(), "duplicate URLs must emit once");
} }
#[test]
fn a_trim_is_due_once_the_stored_bytes_cross_the_step() {
let counter = AtomicU64::new(0);
assert!(!trim_due_after(&counter, TRIM_EVERY_BYTES / 2));
assert!(!trim_due_after(&counter, TRIM_EVERY_BYTES / 2 - 1));
assert!(trim_due_after(&counter, 1));
}
#[test]
fn the_shared_budget_defaults_to_200_mb() {
assert_eq!(DEFAULT_SHARED_BUDGET_MB * 1024 * 1024, 200 * 1024 * 1024);
}
#[test] #[test]
fn http_urls_are_fetchable() { fn http_urls_are_fetchable() {
assert_eq!( assert_eq!(
@@ -1233,13 +1251,85 @@ pub fn scaled_path(path: &str, w: u32, h: u32) -> Option<std::path::PathBuf> {
/// order of magnitude under the 260 MB the parent image cache already holds. /// order of magnitude under the 260 MB the parent image cache already holds.
const MAX_SCALED_BYTES: u64 = 64 * 1024 * 1024; const MAX_SCALED_BYTES: u64 = 64 * 1024 * 1024;
/// Byte budget for the SHARED cache (`~/.cache/qbz/images`): the Slint app's /// Default byte budget for the SHARED cache (`~/.cache/qbz/images`): the
/// `MAX_CACHE_BYTES` ("matches the Tauri default"), restored unchanged. /// Tauri/Slint default, now a preference (`ui_prefs` `image_cache_max_mb`,
const MAX_SHARED_BYTES: u64 = 200 * 1024 * 1024; /// Settings > Offline > Artwork cache).
pub(crate) const DEFAULT_SHARED_BUDGET_MB: u64 = 200;
/// The budget in bytes, from the preference (0 / absent = the default).
pub(crate) fn shared_budget_bytes() -> u64 {
crate::settings_qt::pref_json("image_cache_max_mb")
.and_then(|v| v.as_u64())
.filter(|mb| *mb > 0)
.unwrap_or(DEFAULT_SHARED_BUDGET_MB)
* 1024
* 1024
}
/// Rows deleted per `evict` call; the cache mutex is released between calls. /// Rows deleted per `evict` call; the cache mutex is released between calls.
const EVICT_BATCH_ENTRIES: usize = 64; const EVICT_BATCH_ENTRIES: usize = 64;
/// Bytes stored since the last trim. The Tauri app evicted after EVERY
/// store; here a trim runs on the blocking pool once this many new bytes
/// have landed, so the directory can never sit more than this far above
/// the budget between the boot trim and the next one (an early release
/// grew the folder to 1 GB — never again).
const TRIM_EVERY_BYTES: u64 = 16 * 1024 * 1024;
static STORED_SINCE_TRIM: AtomicU64 = AtomicU64::new(0);
static TRIM_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
/// Account for a freshly stored original. Returns true when a trim is due
/// (the counter is reset by the caller that takes the trim).
fn trim_due_after(counter: &AtomicU64, stored: u64) -> bool {
counter.fetch_add(stored, Ordering::Relaxed) + stored >= TRIM_EVERY_BYTES
}
fn note_stored(stored: usize) {
if !trim_due_after(&STORED_SINCE_TRIM, stored as u64) {
return;
}
STORED_SINCE_TRIM.store(0, Ordering::Relaxed);
trim_shared_now();
}
/// Trim the shared cache to its budget on the blocking pool (a budget
/// change, or the running counter above); one trim in flight at a time.
pub(crate) fn trim_shared_now() {
if TRIM_IN_FLIGHT.swap(true, Ordering::SeqCst) {
return;
}
tokio::task::spawn_blocking(|| {
if let Some(freed) = evict_shared(shared_budget_bytes()) {
if freed > 0 {
log::info!("[qbz-qt][perf] image cache trimmed {freed} B (budget {} B)", shared_budget_bytes());
}
}
TRIM_IN_FLIGHT.store(false, Ordering::SeqCst);
});
}
/// (files, bytes) held by the shared cache, for the Settings stats line.
pub(crate) fn shared_stats() -> Option<(u64, u64)> {
let guard = cache()?.lock().ok()?;
let stats = guard.stats().ok()?;
Some((stats.file_count, stats.total_bytes))
}
/// Delete every original in the shared cache (Settings > Clear). Blocking —
/// call from `spawn_blocking`. The memo is dropped too so no row can keep
/// serving a path that is gone.
pub(crate) fn clear_shared() -> Result<u64, String> {
let cache = cache().ok_or_else(|| "image cache unavailable".to_string())?;
let freed = cache
.lock()
.map_err(|_| "image cache lock poisoned".to_string())?
.clear()?;
if let Ok(mut memo) = RESOLVED.write() {
memo.clear();
}
Ok(freed)
}
/// Trim the shared cache to `max_bytes`, LRU by `last_accessed`, in batches. /// Trim the shared cache to `max_bytes`, LRU by `last_accessed`, in batches.
/// Blocking (SQLite + unlinks) — call from `spawn_blocking`. Returns the /// Blocking (SQLite + unlinks) — call from `spawn_blocking`. Returns the
/// bytes freed, `None` when the cache could not be opened (already logged). /// bytes freed, `None` when the cache could not be opened (already logged).
@@ -1342,11 +1432,24 @@ fn sweep_orphan_scaled(dir: &Path) -> (usize, u64) {
/// "Cache eviction". Blocking; call from `spawn_blocking`. /// "Cache eviction". Blocking; call from `spawn_blocking`.
pub fn housekeeping() { pub fn housekeeping() {
// 1. The SHARED cache (full-size originals): LRU trim to the budget. // 1. The SHARED cache (full-size originals): LRU trim to the budget.
if let Some(freed) = evict_shared(MAX_SHARED_BYTES) { let budget = shared_budget_bytes();
if let Some(freed) = evict_shared(budget) {
if freed > 0 { if freed > 0 {
log::info!( log::info!("[qbz-qt][perf] image cache evicted {freed} B (budget {budget} B)");
"[qbz-qt][perf] image cache evicted {freed} B (budget {MAX_SHARED_BYTES} B)" }
); }
// 1b. Files the database does not know (a crash mid-store, a lost db):
// never evicted by the LRU, so they are swept here — anything under
// an hour old is left alone in case its store is still in flight.
if let Some(cache) = cache() {
if let Ok(guard) = cache.lock() {
match guard.sweep_orphans(std::time::Duration::from_secs(3_600)) {
Ok((files, bytes)) if files > 0 => {
log::info!("[qbz-qt][perf] image cache swept {files} orphan files ({bytes} B)");
}
Ok(_) => {}
Err(e) => log::warn!("[qbz-qt] image cache orphan sweep failed: {e}"),
}
} }
} }
// 2. This module's OWN derivative directory: orphan sweep, then byte cap. // 2. This module's OWN derivative directory: orphan sweep, then byte cap.
+9
View File
@@ -4247,6 +4247,14 @@ pub async fn settings_select(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &st
}; };
save_pref("startup_page", serde_json::json!(v)); save_pref("startup_page", serde_json::json!(v));
} }
"image-cache-max" => {
let Some(mb) = offline::IMAGE_CACHE_MB.get(index) else {
return;
};
save_pref("image_cache_max_mb", serde_json::json!(mb));
// A smaller budget applies right away, not at the next boot.
crate::artwork_qt::trim_shared_now();
}
"genre-filters-position" => { "genre-filters-position" => {
let Some(v) = LOCAL_GENRE_FILTER_POSITION_VALUES.get(index) else { let Some(v) = LOCAL_GENRE_FILTER_POSITION_VALUES.get(index) else {
return; return;
@@ -4533,6 +4541,7 @@ pub async fn settings_string(key: &str, value: String) {
"plex-clear-cache" => library::plex_clear_cache().await, "plex-clear-cache" => library::plex_clear_cache().await,
// --- Offline -------------------------------------------------------- // --- Offline --------------------------------------------------------
"lyrics-cache-clear" => offline::clear_lyrics_cache().await, "lyrics-cache-clear" => offline::clear_lyrics_cache().await,
"image-cache-clear" => offline::clear_image_cache().await,
// Offline > "Check now": nudge the connectivity actor. The status it // Offline > "Check now": nudge the connectivity actor. The status it
// publishes flows back through offline_fwd's forwarder, so there is // publishes flows back through offline_fwd's forwarder, so there is
// nothing to await and nothing to republish here. // nothing to await and nothing to republish here.
+40
View File
@@ -30,8 +30,23 @@ pub struct Snapshot {
pub lyrics_entries: i64, pub lyrics_entries: i64,
#[serde(rename = "lyricsSize")] #[serde(rename = "lyricsSize")]
pub lyrics_size: String, pub lyrics_size: String,
/// Shared artwork cache (`~/.cache/qbz/images`): stats line + budget.
#[serde(rename = "imageCacheLoaded")]
pub image_cache_loaded: bool,
#[serde(rename = "imageCacheFiles")]
pub image_cache_files: i64,
#[serde(rename = "imageCacheSize")]
pub image_cache_size: String,
#[serde(rename = "imageCacheSizes")]
pub image_cache_sizes: Vec<String>,
#[serde(rename = "imageCacheSizeIndex")]
pub image_cache_size_index: i32,
} }
/// Budget choices for the artwork cache, in MB (index 1 = the default).
pub(crate) const IMAGE_CACHE_MB: &[u64] = &[100, 200, 500, 1000];
const IMAGE_CACHE_LABELS: &[&str] = &["100 MB", "200 MB", "500 MB", "1 GB"];
fn lyrics_db_path() -> Option<std::path::PathBuf> { fn lyrics_db_path() -> Option<std::path::PathBuf> {
let uid = qbz_app::user_data::UserDataPaths::load_last_user_id()?; let uid = qbz_app::user_data::UserDataPaths::load_last_user_id()?;
Some( Some(
@@ -81,6 +96,17 @@ pub fn snapshot() -> Snapshot {
_ => (false, 0, String::new()), _ => (false, 0, String::new()),
}; };
let (image_cache_loaded, image_cache_files, image_cache_size) =
match crate::artwork_qt::shared_stats() {
Some((files, bytes)) => (true, files as i64, human_size(bytes)),
None => (false, 0, String::new()),
};
let budget_mb = crate::artwork_qt::shared_budget_bytes() / (1024 * 1024);
let image_cache_size_index = IMAGE_CACHE_MB
.iter()
.position(|mb| *mb == budget_mb)
.unwrap_or(1) as i32;
Snapshot { Snapshot {
mode_enabled: settings.manual_offline_mode, mode_enabled: settings.manual_offline_mode,
allow_immediate_scrobbling: settings.allow_immediate_scrobbling, allow_immediate_scrobbling: settings.allow_immediate_scrobbling,
@@ -88,9 +114,23 @@ pub fn snapshot() -> Snapshot {
lyrics_loaded, lyrics_loaded,
lyrics_entries, lyrics_entries,
lyrics_size, lyrics_size,
image_cache_loaded,
image_cache_files,
image_cache_size,
image_cache_sizes: IMAGE_CACHE_LABELS.iter().map(|l| l.to_string()).collect(),
image_cache_size_index,
} }
} }
/// Settings > Offline > "Clear" on the artwork cache.
pub async fn clear_image_cache() {
let _ = tokio::task::spawn_blocking(|| match crate::artwork_qt::clear_shared() {
Ok(freed) => log::info!("[qbz-qt] image cache cleared ({freed} B)"),
Err(e) => log::error!("[qbz-qt] image cache clear failed: {e}"),
})
.await;
}
pub fn set_allow_immediate_scrobbling(value: bool) -> Result<(), String> { pub fn set_allow_immediate_scrobbling(value: bool) -> Result<(), String> {
let result = crate::offline_fwd::engine().set_allow_immediate_scrobbling(value); let result = crate::offline_fwd::engine().set_allow_immediate_scrobbling(value);
if result.is_ok() { if result.is_ok() {