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:
12 files changed
+392
-8
No files matched your search
@@ -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 "{} изображений занимают {}"
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in new issue
Block a user