mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 05:28:25 -06:00
Preview cache: cached thumbnails are no longer taken for the person's photos
The thumbnail cache wrote .jpg files into the library's thumbs folder, which is usually inside Pictures: photo galleries listed every thumbnail as a photo (issues #610, #1066), and every read brought the file's date forward, so each launch made them all look new. - Cache files are named <32 hex>.lcthumb (JPEG data as before). A .jpg of before is taken over under the new name when it is asked for; the rest when the cache is tidied, which the engine does once per cache folder, on a thread of its own with a row in the activity stack that shows its progress and can stop it. Nothing is rendered again. - Reading writes nothing: what was read is remembered for the session (a full cache lets it go last), and only a file that went unused for a week has its date brought forward. - A folder that holds nothing but the cache gets a CACHEDIR.TAG, when the cache makes it and when it is tidied; one that also holds somebody's files is not tagged (issue #98). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a4b53b28cc
commit
d2523e8544
@@ -73,7 +73,7 @@ impl Unit {
|
||||
pub struct TaskInfo {
|
||||
pub id: u64,
|
||||
/// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`,
|
||||
/// `faces`, `denoise`, `findMissing`, `applePhotos`.
|
||||
/// `faces`, `denoise`, `findMissing`, `applePhotos`, `cache`.
|
||||
pub kind: &'static str,
|
||||
/// English; the frontend translates it.
|
||||
pub label: String,
|
||||
|
||||
@@ -322,6 +322,38 @@ impl SettingsLoad {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Take over what the thumbnail cache wrote under its name of before (`.jpg` files, which
|
||||
/// photo galleries took for the person's photos: issues #610, #1066), once for a cache
|
||||
/// folder that was not made or tidied by this build yet. On a thread of its own, with a row
|
||||
/// in the activity stack that can stop it: a large cache is some 100 000 files to rename.
|
||||
fn tidy_thumbnail_cache(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
let cache = self.media.rendered.clone();
|
||||
if !cache.disk().is_some_and(|d| d.needs_tidying()) {
|
||||
return;
|
||||
}
|
||||
let stop = std::sync::Arc::new(AtomicBool::new(false));
|
||||
let task = self.activity.start("cache", "Tidying the thumbnail cache", crate::activity::Cancel::Flag(stop.clone()));
|
||||
let spawned = std::thread::Builder::new().name("lc-thumbs-tidy".into()).spawn(move || {
|
||||
// the row stays while this runs (it is dropped with the closure, however that ends)
|
||||
let _row = task;
|
||||
if let Some(disk) = cache.disk() {
|
||||
// (also given up when the library is closed or another is opened: the cache
|
||||
// is retired then, and it is gone on with when this library opens again)
|
||||
let stopped = || stop.load(Ordering::Relaxed) || cache.generation() == u64::MAX;
|
||||
let taken = disk.take_over_old(&stopped, &|done, of| _row.progress(done as u64, of as u64));
|
||||
log::info!("thumbnail cache: {taken} thumbnails taken over under their new name in {}", disk.dir().display());
|
||||
}
|
||||
});
|
||||
// (not started: the thumbnails are still found one by one, and it is tried at the next open)
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("thumbnail cache: could not be tidied: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check, without closing the library open now, that [`Session::open_library`] can be expected
|
||||
/// to open `dir`: not a Lightroom catalog, not some other file, a readable folder (or one not
|
||||
/// made yet), not locked by another program. Returns the library folder ([`resolve_dir`]).
|
||||
@@ -504,6 +536,7 @@ impl Session {
|
||||
self.media.clear_sources();
|
||||
if on_disk {
|
||||
self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes());
|
||||
self.tidy_thumbnail_cache();
|
||||
}
|
||||
let view_written = self.view_json();
|
||||
self.library = Some(Library {
|
||||
|
||||
@@ -222,7 +222,7 @@ fn face_tiles_never_crash() {
|
||||
assert!(job.run().rendered.is_ok());
|
||||
let thumbs = s.library.as_ref().unwrap().thumbs_dir();
|
||||
drop(s);
|
||||
let entry = thumbs.join(&key[..2]).join(format!("{key}.jpg"));
|
||||
let entry = thumbs.join(&key[..2]).join(format!("{key}.lcthumb"));
|
||||
assert!(entry.exists(), "the tile was written to the thumbnail cache");
|
||||
std::fs::write(&entry, b"not a jpeg").unwrap();
|
||||
let mut s = open(&dir);
|
||||
|
||||
@@ -675,3 +675,50 @@ fn an_album_filter_loop_is_refused() {
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("include itself")), "refused");
|
||||
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(a)).unwrap().smart.as_ref().and_then(|f| f.album), None);
|
||||
}
|
||||
|
||||
/// Given a library whose thumbnail cache is from before (`.jpg` files in `thumbs`), when it is
|
||||
/// opened, then the cache is tidied in the background with a row in the activity stack: the
|
||||
/// thumbnails are taken over under a name that is no picture's, the folder is tagged as a
|
||||
/// cache, and the next time the library opens there is nothing to tidy and no row.
|
||||
#[test]
|
||||
fn a_thumbnail_cache_from_before_is_tidied_in_the_background_when_the_library_opens() {
|
||||
let dir = temp_dir("tidy-thumbs");
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&dir, true).unwrap();
|
||||
assert!(s.activity.list().is_empty(), "a new library has nothing to tidy");
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
// the cache as an older build left it
|
||||
let thumbs = dir.join("thumbs");
|
||||
let _ = std::fs::remove_dir_all(&thumbs);
|
||||
let img = lightcraft_raster::Rgba8 { width: 32, height: 20, data: vec![[90, 120, 150, 255]; 640] };
|
||||
let jpeg = lightcraft_preview::encode_jpeg(&img).unwrap();
|
||||
let names: Vec<String> = (0..2000u16).map(|i| lightcraft_preview::hash_bytes(&i.to_le_bytes()).to_string()).collect();
|
||||
for hex in &names {
|
||||
std::fs::create_dir_all(thumbs.join(&hex[..2])).unwrap();
|
||||
std::fs::write(thumbs.join(&hex[..2]).join(format!("{hex}.jpg")), &jpeg).unwrap();
|
||||
}
|
||||
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&dir, true).unwrap();
|
||||
// (two thousand files to rename: the row is there when the library has just opened)
|
||||
let rows = s.activity.list();
|
||||
assert!(rows.iter().any(|r| r.kind == "cache" && r.label == "Tidying the thumbnail cache" && r.cancellable), "{rows:?}");
|
||||
let t0 = std::time::Instant::now();
|
||||
while !s.activity.list().is_empty() && t0.elapsed() < std::time::Duration::from_secs(20) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(s.activity.list().is_empty());
|
||||
for hex in &names {
|
||||
let shard = thumbs.join(&hex[..2]);
|
||||
assert!(shard.join(format!("{hex}.lcthumb")).is_file() && !shard.join(format!("{hex}.jpg")).exists(), "{hex}");
|
||||
}
|
||||
assert!(thumbs.join("CACHEDIR.TAG").is_file());
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&dir, true).unwrap();
|
||||
assert!(s.activity.list().is_empty(), "tidied once");
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
+229
-23
@@ -1,15 +1,35 @@
|
||||
//! Rendered thumbnails on disk: `<dir>/<2 hex>/<32 hex>.jpg`, bounded in bytes.
|
||||
//! Rendered thumbnails on disk: `<dir>/<2 hex>/<32 hex>.lcthumb`, bounded in bytes.
|
||||
//!
|
||||
//! The cache is disposable: files are written via temp + rename (readers never see partial
|
||||
//! files) but not fsynced; unreadable files are deleted and re-rendered. When the total size
|
||||
//! exceeds the budget, the least recently used files (by modification time, refreshed on read)
|
||||
//! are removed down to 80 % of the budget.
|
||||
//! exceeds the budget, the least recently used files (by modification time) are removed down to
|
||||
//! 80 % of the budget.
|
||||
//!
|
||||
//! The cache only ever lists, counts or deletes files of its own name shape
|
||||
//! (`<2 hex>/<32 hex>.jpg` and the `<32 hex>.tmp…` temp files of [`DiskCache::put`], see
|
||||
//! [`is_cache_file`]): the directory may be shared with other files (e.g. a library opened on a
|
||||
//! photo folder that already has a `thumbs/` folder), and those are never touched (issue #98).
|
||||
//! A shard directory that is a symlink or junction is not followed (issue #1504).
|
||||
//! **The cache is nobody's pictures** (issues #610, #1066). A library is often in the person's
|
||||
//! Pictures folder, which photo galleries and backups look through, so:
|
||||
//! - its files are JPEG data under a name that is no picture's ([`EXT`]). They used to be
|
||||
//! `.jpg`, and every thumbnail (one per photo, size and state of its edits) showed up in the
|
||||
//! system's gallery as a photo of the person's. Files from then are taken over under the new
|
||||
//! name: one that is asked for at once, the rest when the cache is tidied
|
||||
//! ([`DiskCache::take_over_old`], which the engine does once per folder in the background).
|
||||
//! Nothing is rendered again for it.
|
||||
//! - reading writes nothing. A file's date was brought forward on every read, so every launch
|
||||
//! made every thumbnail in view look new; now only one that was not used for [`STALE`] is
|
||||
//! touched. What was read in this session is remembered in memory, and a full cache lets it
|
||||
//! go last.
|
||||
//! - a folder that holds nothing but the cache is tagged as one ([`TAG`]): when the cache
|
||||
//! makes it, and when it is tidied.
|
||||
//!
|
||||
//! An older build that is run again writes `.jpg` files once more; in a folder that was tidied
|
||||
//! already those are taken over one by one as they are asked for, and the rest go as the cache
|
||||
//! fills.
|
||||
//!
|
||||
//! The cache only ever lists, counts, renames or deletes files of its own name shape
|
||||
//! (`<2 hex>/<32 hex>.lcthumb`, the `.jpg` of before, and the `<32 hex>.tmp…` temp files of
|
||||
//! [`DiskCache::put`], see [`is_cache_file`]): the directory may be shared with other files
|
||||
//! (e.g. a library opened on a photo folder that already has a `thumbs/` folder), and those are
|
||||
//! never touched (issue #98). A shard directory that is a symlink or junction is not followed
|
||||
//! (issue #1504).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
@@ -21,6 +41,32 @@ use crate::Hash128;
|
||||
|
||||
const QUALITY: u8 = 90;
|
||||
|
||||
/// What a cached thumbnail's file is named after its key: not a picture's ending, so that
|
||||
/// nothing that looks for the person's photos takes the cache for them.
|
||||
pub const EXT: &str = "lcthumb";
|
||||
/// What it was named before (JPEG data then as now).
|
||||
const OLD_EXT: &str = "jpg";
|
||||
|
||||
/// How long a thumbnail goes unused before a read brings its date forward (the date is what a
|
||||
/// full cache lets go by, oldest first). Reads within it write nothing.
|
||||
pub const STALE: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 3600);
|
||||
|
||||
/// The file that says the cache has looked through its folder for thumbnails under the name
|
||||
/// of before and taken them over ([`DiskCache::take_over_old`]): written after a whole pass,
|
||||
/// whatever else the folder holds, so that it is done once.
|
||||
const TIDIED: &str = ".lightcraft-thumbs";
|
||||
|
||||
/// Files an operating system leaves in a folder that was looked at: nobody's, and no reason
|
||||
/// to take a folder for more than a cache.
|
||||
const DROPPINGS: [&str; 3] = [".DS_Store", "desktop.ini", "Thumbs.db"];
|
||||
|
||||
/// The file that says a folder is a cache, for backup programs and the like (the Cache
|
||||
/// Directory Tagging Specification: the first line is the signature).
|
||||
pub const TAG: &str = "CACHEDIR.TAG";
|
||||
const TAG_TEXT: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
|
||||
# This folder holds LightCraft's thumbnail cache. It is safe to delete: LightCraft renders what it needs again.\n\
|
||||
# See https://bford.info/cachedir/\n";
|
||||
|
||||
pub struct DiskCache {
|
||||
dir: PathBuf,
|
||||
/// Bytes the cache may hold (changed in place by [`DiskCache::set_budget`]).
|
||||
@@ -30,6 +76,8 @@ pub struct DiskCache {
|
||||
pub hits: AtomicU64,
|
||||
pub misses: AtomicU64,
|
||||
pub writes: AtomicU64,
|
||||
/// The thumbnails read in this session: a full cache lets them go last ([`Self::prune`]).
|
||||
read: Mutex<std::collections::HashSet<u128>>,
|
||||
}
|
||||
|
||||
impl DiskCache {
|
||||
@@ -41,6 +89,7 @@ impl DiskCache {
|
||||
hits: AtomicU64::new(0),
|
||||
misses: AtomicU64::new(0),
|
||||
writes: AtomicU64::new(0),
|
||||
read: Mutex::new(Default::default()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,22 +109,57 @@ impl DiskCache {
|
||||
}
|
||||
|
||||
fn path(&self, key: Hash128) -> PathBuf {
|
||||
self.named(key, EXT)
|
||||
}
|
||||
|
||||
fn named(&self, key: Hash128, ext: &str) -> PathBuf {
|
||||
let hex = key.to_string();
|
||||
self.dir.join(&hex[..2]).join(format!("{hex}.jpg"))
|
||||
// (32 hex digits: the first two are there)
|
||||
self.dir.join(hex.get(..2).unwrap_or("00")).join(format!("{hex}.{ext}"))
|
||||
}
|
||||
|
||||
/// The file's bytes: under its name, or under the name it had before ([`OLD_EXT`]), which
|
||||
/// it then no longer has.
|
||||
fn read(&self, key: Hash128) -> Option<(PathBuf, Vec<u8>)> {
|
||||
let p = self.path(key);
|
||||
// a shard that is a symlink (or junction) leads outside the cache: nothing is read
|
||||
// through it, under either name
|
||||
if !p.parent().is_some_and(is_real_dir) {
|
||||
return None;
|
||||
}
|
||||
if let Ok(bytes) = std::fs::read(&p) {
|
||||
return Some((p, bytes));
|
||||
}
|
||||
let old = self.named(key, OLD_EXT);
|
||||
let Ok(bytes) = std::fs::read(&old) else {
|
||||
// (taken over by the tidying between the two looks: it is under the new name now)
|
||||
return std::fs::read(&p).ok().map(|bytes| (p, bytes));
|
||||
};
|
||||
// (taken over; if that can't be done it is read where it is, and tried again next time)
|
||||
match std::fs::rename(&old, &p) {
|
||||
Ok(()) => Some((p, bytes)),
|
||||
Err(_) => Some((old, bytes)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: Hash128) -> Option<Rgba8> {
|
||||
let p = self.path(key);
|
||||
let Some(bytes) = p.parent().filter(|d| is_real_dir(d)).and_then(|_| std::fs::read(&p).ok()) else {
|
||||
let Some((p, bytes)) = self.read(key) else {
|
||||
self.misses.fetch_add(1, Ordering::Relaxed);
|
||||
return None;
|
||||
};
|
||||
match decode_jpeg(&bytes) {
|
||||
Some(img) => {
|
||||
self.hits.fetch_add(1, Ordering::Relaxed);
|
||||
// refresh recency for pruning
|
||||
if let Ok(f) = std::fs::File::options().write(true).open(&p) {
|
||||
let _ = f.set_modified(std::time::SystemTime::now());
|
||||
// Recency for pruning, without a write for every read: it is remembered here for
|
||||
// this session, and only a file that was not used for a long while has its date
|
||||
// brought forward (for the sessions after).
|
||||
self.read.lock().unwrap_or_else(|e| e.into_inner()).insert(key.0);
|
||||
let now = std::time::SystemTime::now();
|
||||
let unused = std::fs::metadata(&p).and_then(|m| m.modified()).ok().and_then(|at| now.duration_since(at).ok());
|
||||
if unused.is_some_and(|d| d > STALE)
|
||||
&& let Ok(f) = std::fs::File::options().write(true).open(&p)
|
||||
{
|
||||
let _ = f.set_modified(now);
|
||||
}
|
||||
Some(img)
|
||||
}
|
||||
@@ -92,9 +176,15 @@ impl DiskCache {
|
||||
let Some(bytes) = encode_jpeg(img) else { return };
|
||||
let p = self.path(key);
|
||||
let Some(parent) = p.parent() else { return };
|
||||
// a folder the cache makes for itself says that it is a cache; one that was there
|
||||
// already may hold somebody's files, and is not declared one
|
||||
let made = !self.dir.exists();
|
||||
if std::fs::create_dir_all(parent).is_err() || !is_real_dir(parent) {
|
||||
return;
|
||||
}
|
||||
if made {
|
||||
let _ = std::fs::write(self.dir.join(TAG), TAG_TEXT);
|
||||
}
|
||||
// Serialize the file replacement and its size accounting. A repeated render of
|
||||
// the same key replaces its JPEG; it must not count as another cache entry.
|
||||
let mut total = self.total.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -117,9 +207,17 @@ impl DiskCache {
|
||||
/// All cache files (only names of the cache's own shape, see [`is_cache_file`]):
|
||||
/// (path, size, modified).
|
||||
fn scan(&self) -> Vec<(PathBuf, u64, std::time::SystemTime)> {
|
||||
self.scan_until(&|| false).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`Self::scan`], given up (`None`) when `stop` says so, which is asked for each shard.
|
||||
fn scan_until(&self, stop: &dyn Fn() -> bool) -> Option<Vec<(PathBuf, u64, std::time::SystemTime)>> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(rd) = std::fs::read_dir(&self.dir) else { return out };
|
||||
let Ok(rd) = std::fs::read_dir(&self.dir) else { return Some(out) };
|
||||
for sub in rd.flatten() {
|
||||
if stop() {
|
||||
return None;
|
||||
}
|
||||
let sub_name = sub.file_name();
|
||||
let Some(sub_name) = sub_name.to_str() else { continue };
|
||||
if !is_shard_name(sub_name) {
|
||||
@@ -144,21 +242,123 @@ impl DiskCache {
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Remove least recently used files until the cache is at most 80 % of its budget.
|
||||
/// Whether the folder may hold thumbnails under the name they had before ([`OLD_EXT`]): it
|
||||
/// is there, and neither made by a cache that names them as now (which tags it, [`TAG`])
|
||||
/// nor looked through by one ([`TIDIED`]). Cheap: asked when a library opens.
|
||||
pub fn needs_tidying(&self) -> bool {
|
||||
self.dir.is_dir() && !self.dir.join(TAG).exists() && !self.dir.join(TIDIED).exists()
|
||||
}
|
||||
|
||||
/// Take over the thumbnails cached under the name of before: each `<32 hex>.jpg` of the
|
||||
/// cache's own shape is renamed (or removed, when the thumbnail is there under the new name
|
||||
/// too). After a whole pass the folder is marked as looked through, so that this is done
|
||||
/// once, and tagged as a cache if it holds nothing but the cache. Returns how many were
|
||||
/// renamed. `stop` is asked for each shard listed and each file renamed; `progress` is told
|
||||
/// (done, of) as the old files are gone through.
|
||||
///
|
||||
/// This walks the whole folder and renames file by file (a cache of 2 GB is some 100 000
|
||||
/// files): it is for a background thread, never for the interface.
|
||||
pub fn take_over_old(&self, stop: &dyn Fn() -> bool, progress: &dyn Fn(usize, usize)) -> usize {
|
||||
// (listed in full before anything is renamed: a folder that changes while it is listed
|
||||
// lists some of its files twice)
|
||||
let Some(listed) = self.scan_until(stop) else { return 0 };
|
||||
let old: Vec<(PathBuf, u64)> = listed.into_iter().filter(|f| f.0.extension().is_some_and(|ext| ext == OLD_EXT)).map(|f| (f.0, f.1)).collect();
|
||||
let (mut taken, mut removed, mut blocked) = (0usize, 0u64, false);
|
||||
for (done, (path, len)) in old.iter().enumerate() {
|
||||
if stop() {
|
||||
self.forget(removed);
|
||||
return taken;
|
||||
}
|
||||
progress(done, old.len());
|
||||
let new = path.with_extension(EXT);
|
||||
match std::fs::symlink_metadata(&new) {
|
||||
// there under both names: one and the same thumbnail
|
||||
Ok(m) if m.is_file() => {
|
||||
if std::fs::remove_file(path).is_ok() {
|
||||
removed = removed.saturating_add(*len);
|
||||
}
|
||||
}
|
||||
// something that is no file has the name: not the cache's to move aside
|
||||
Ok(_) => blocked = true,
|
||||
Err(_) => match std::fs::rename(path, &new) {
|
||||
Ok(()) => taken += 1,
|
||||
Err(_) => blocked = true,
|
||||
},
|
||||
}
|
||||
}
|
||||
progress(old.len(), old.len());
|
||||
self.forget(removed);
|
||||
// done, also when the folder holds other things; not when something could not be taken
|
||||
// over (a folder that can't be written: it is tried again at the next open)
|
||||
if !blocked {
|
||||
let _ = std::fs::write(self.dir.join(TIDIED), b"");
|
||||
if self.holds_only_the_cache() {
|
||||
let _ = std::fs::write(self.dir.join(TAG), TAG_TEXT);
|
||||
}
|
||||
}
|
||||
taken
|
||||
}
|
||||
|
||||
/// Take `bytes` that are no longer on disk off the total, when it is known.
|
||||
fn forget(&self, bytes: u64) {
|
||||
if let Some(total) = self.total.lock().unwrap_or_else(|e| e.into_inner()).as_mut() {
|
||||
*total = total.saturating_sub(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether everything in the folder is the cache's: its own marks, and shard folders that
|
||||
/// hold only its own files under the name they have now. What an operating system leaves in
|
||||
/// a folder that was looked at ([`DROPPINGS`]) does not count against it. (A folder with
|
||||
/// anything else in it may be somebody's, issue #98.)
|
||||
fn holds_only_the_cache(&self) -> bool {
|
||||
let dropping = |name: &str| DROPPINGS.contains(&name) || name.starts_with("._");
|
||||
let Ok(entries) = std::fs::read_dir(&self.dir) else { return false };
|
||||
entries.flatten().all(|e| {
|
||||
let name = e.file_name();
|
||||
let Some(name) = name.to_str() else { return false };
|
||||
if name == TAG || name == TIDIED || dropping(name) {
|
||||
return true;
|
||||
}
|
||||
// (a shard that is a symlink or junction leads somewhere else: not only the cache's)
|
||||
if !is_shard_name(name) || !is_real_dir(&e.path()) {
|
||||
return false;
|
||||
}
|
||||
let Ok(inside) = std::fs::read_dir(e.path()) else { return false };
|
||||
inside.flatten().all(|f| {
|
||||
// a link among the files is not a file the cache wrote
|
||||
let link = f.file_type().is_ok_and(|t| t.is_symlink());
|
||||
!link && f.file_name().to_str().is_some_and(|f| dropping(f) || (is_cache_file(name, f) && !f.ends_with(&format!(".{OLD_EXT}"))))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove least recently used files until the cache is at most 80 % of its budget: by the
|
||||
/// date of each file, the ones read in this session last.
|
||||
pub fn prune(&self) {
|
||||
let mut files = self.scan();
|
||||
let mut total: u64 = files.iter().map(|f| f.1).sum();
|
||||
let target = self.budget() / 5 * 4;
|
||||
files.sort_by_key(|f| f.2);
|
||||
let read = self.read.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let in_use = |p: &Path| key_of(p).is_some_and(|k| read.contains(&k));
|
||||
files.sort_by_cached_key(|f| (in_use(&f.0), f.2));
|
||||
let mut gone = Vec::new();
|
||||
for (p, len, _) in files {
|
||||
if total <= target {
|
||||
break;
|
||||
}
|
||||
if std::fs::remove_file(&p).is_ok() {
|
||||
total -= len;
|
||||
total = total.saturating_sub(len);
|
||||
gone.extend(key_of(&p));
|
||||
}
|
||||
}
|
||||
// (what is gone is no longer "read this session": the set holds only what is there)
|
||||
if !gone.is_empty() {
|
||||
let mut read = self.read.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for key in gone {
|
||||
read.remove(&key);
|
||||
}
|
||||
}
|
||||
*self.total.lock().unwrap_or_else(|e| e.into_inner()) = Some(total);
|
||||
@@ -183,6 +383,11 @@ fn is_real_dir(p: &Path) -> bool {
|
||||
std::fs::symlink_metadata(p).is_ok_and(|m| m.is_dir())
|
||||
}
|
||||
|
||||
/// The key a cache file is of, by its name.
|
||||
fn key_of(path: &Path) -> Option<u128> {
|
||||
path.file_stem().and_then(|s| s.to_str()).and_then(|hex| u128::from_str_radix(hex, 16).ok())
|
||||
}
|
||||
|
||||
fn is_lower_hex(s: &str) -> bool {
|
||||
s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
@@ -192,9 +397,10 @@ fn is_shard_name(s: &str) -> bool {
|
||||
s.len() == 2 && is_lower_hex(s)
|
||||
}
|
||||
|
||||
/// Whether `name` inside shard directory `shard` is a file this cache wrote: `<32 hex>.jpg`
|
||||
/// (whose first two digits are the shard) or a `<32 hex>.tmp<alphanumeric>` temp file of
|
||||
/// [`DiskCache::put`]. Anything else in the cache directory belongs to someone else.
|
||||
/// Whether `name` inside shard directory `shard` is a file this cache wrote: `<32 hex>.lcthumb`
|
||||
/// (whose first two digits are the shard), the `<32 hex>.jpg` it wrote before, or a
|
||||
/// `<32 hex>.tmp<alphanumeric>` temp file of [`DiskCache::put`]. Anything else in the cache
|
||||
/// directory belongs to someone else.
|
||||
pub fn is_cache_file(shard: &str, name: &str) -> bool {
|
||||
if !is_shard_name(shard) {
|
||||
return false;
|
||||
@@ -203,7 +409,7 @@ pub fn is_cache_file(shard: &str, name: &str) -> bool {
|
||||
if stem.len() != 32 || !is_lower_hex(stem) || !stem.starts_with(shard) {
|
||||
return false;
|
||||
}
|
||||
ext == "jpg" || ext.strip_prefix("tmp").is_some_and(|t| !t.is_empty() && t.bytes().all(|b| b.is_ascii_alphanumeric()))
|
||||
ext == EXT || ext == OLD_EXT || ext.strip_prefix("tmp").is_some_and(|t| !t.is_empty() && t.bytes().all(|b| b.is_ascii_alphanumeric()))
|
||||
}
|
||||
|
||||
/// Encode a thumbnail as the cache stores it (JPEG, RGB, 4:2:0).
|
||||
|
||||
+276
-2
@@ -114,7 +114,7 @@ fn disk_cache_roundtrip_and_prune() {
|
||||
let k2 = hash_bytes(b"corrupt");
|
||||
d.put(k2, &img);
|
||||
let hex = k2.to_string();
|
||||
let p = dir.join(&hex[..2]).join(format!("{hex}.jpg"));
|
||||
let p = dir.join(&hex[..2]).join(format!("{hex}.lcthumb"));
|
||||
std::fs::write(&p, b"not a jpeg").unwrap();
|
||||
assert!(d.get(k2).is_none());
|
||||
assert!(!p.exists());
|
||||
@@ -143,7 +143,7 @@ fn disk_cache_overwrite_accounts_for_replaced_jpeg_only_once() {
|
||||
cache.put(a, &gradient(120, 90, 9));
|
||||
let path = |key: Hash128| {
|
||||
let hex = key.to_string();
|
||||
dir.join(&hex[..2]).join(format!("{hex}.jpg"))
|
||||
dir.join(&hex[..2]).join(format!("{hex}.lcthumb"))
|
||||
};
|
||||
let actual = std::fs::metadata(path(a)).unwrap().len() + std::fs::metadata(path(b)).unwrap().len();
|
||||
assert_eq!(cache.size(), actual);
|
||||
@@ -375,8 +375,57 @@ fn disk_cache_never_touches_foreign_files() {
|
||||
for p in &foreign {
|
||||
assert!(p.exists(), "foreign file deleted: {}", p.display());
|
||||
}
|
||||
assert!(disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.lcthumb"));
|
||||
// (what the cache wrote before it stopped naming its files as pictures is still its own)
|
||||
assert!(disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.jpg"));
|
||||
assert!(!disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.jpg.bak"));
|
||||
assert!(!disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.lcthumb.bak"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---- the cache is nobody's pictures (issues #610, #1066)
|
||||
|
||||
/// Every file under `dir`, by its path from there.
|
||||
fn files_under(dir: &std::path::Path) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut todo = vec![dir.to_path_buf()];
|
||||
while let Some(d) = todo.pop() {
|
||||
for e in std::fs::read_dir(&d).into_iter().flatten().flatten() {
|
||||
if e.path().is_dir() {
|
||||
todo.push(e.path());
|
||||
} else {
|
||||
out.push(e.path().strip_prefix(dir).unwrap().to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// The endings by which a photo gallery, a backup of "my pictures" or a media scanner takes a
|
||||
/// file for a picture.
|
||||
const PICTURE_ENDINGS: [&str; 12] = ["jpg", "jpeg", "jpe", "jfif", "png", "gif", "bmp", "tif", "tiff", "webp", "heic", "avif"];
|
||||
|
||||
fn is_named_as_a_picture(name: &str) -> bool {
|
||||
name.rsplit_once('.').is_some_and(|(_, ending)| PICTURE_ENDINGS.contains(&ending.to_ascii_lowercase().as_str()))
|
||||
}
|
||||
|
||||
/// Given a library in the Pictures folder, when thumbnails are cached, then no file the cache
|
||||
/// writes is named as a picture: a photo gallery that looks through Pictures does not list the
|
||||
/// cache as the person's photos, however many thumbnails there are.
|
||||
#[test]
|
||||
fn nothing_the_cache_writes_is_named_as_a_picture() {
|
||||
let dir = temp_dir("not-pictures");
|
||||
let d = DiskCache::new(&dir, 10 << 20);
|
||||
for i in 0..12u8 {
|
||||
d.put(hash_bytes(&[i, 9]), &gradient(160, 100, i));
|
||||
}
|
||||
let written = files_under(&dir);
|
||||
assert!(written.len() >= 12, "{written:?}");
|
||||
let pictures: Vec<&String> = written.iter().filter(|f| is_named_as_a_picture(f)).collect();
|
||||
assert!(pictures.is_empty(), "named as pictures: {pictures:?}");
|
||||
// and they are found again under those names
|
||||
assert!((0..12u8).all(|i| d.get(hash_bytes(&[i, 9])).is_some()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -407,6 +456,231 @@ fn disk_cache_ignores_symlinked_shard_directories() {
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
/// A folder whose shard is a symlink to somewhere else is not only the cache's: tidying it
|
||||
/// neither follows the link nor tags the folder as a cache (issue #1504).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn tidying_does_not_declare_a_folder_with_a_symlinked_shard_a_cache() {
|
||||
let dir = temp_dir("symlink-tidy");
|
||||
let outside = temp_dir("symlink-tidy-outside");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let hex = hash_bytes(&[9, 8]).to_string();
|
||||
// (named as a thumbnail is now: only the link says it is not the cache's)
|
||||
let sentinel = outside.join(format!("{hex}.lcthumb"));
|
||||
std::fs::write(&sentinel, b"somebody's file").unwrap();
|
||||
std::os::unix::fs::symlink(&outside, dir.join(&hex[..2])).unwrap();
|
||||
let d = DiskCache::new(&dir, 100 << 20);
|
||||
assert!(d.needs_tidying());
|
||||
assert_eq!(d.take_over_old(&|| false, &|_, _| {}), 0, "nothing behind the link is taken over");
|
||||
assert!(sentinel.is_file(), "the file behind the link is where it was");
|
||||
assert!(!dir.join("CACHEDIR.TAG").exists(), "a folder with a link in it is not declared a cache");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
/// The bytes of every file under `dir` that is the cache's own.
|
||||
fn cache_bytes_on_disk(dir: &std::path::Path) -> u64 {
|
||||
files_under(dir)
|
||||
.iter()
|
||||
.filter(|f| f.split_once('/').is_some_and(|(shard, name)| disk::is_cache_file(shard, name)))
|
||||
.map(|f| std::fs::metadata(dir.join(f)).unwrap().len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Given a cache from before, whose thumbnails are `.jpg` files, when one is asked for, then it
|
||||
/// is found (nothing is rendered again for the change of name) and is no longer a `.jpg`. When
|
||||
/// the cache is tidied, none of its old files is a `.jpg` any more, each thumbnail is there
|
||||
/// once and counted once, and the folder (which holds nothing but the cache) is tagged. Asking
|
||||
/// how big the cache is renames nothing: that is asked from the interface.
|
||||
#[test]
|
||||
fn thumbnails_cached_as_jpg_files_are_taken_over_under_the_new_name() {
|
||||
let dir = temp_dir("taken-over");
|
||||
let old_path = |k: Hash128| {
|
||||
let hex = k.to_string();
|
||||
dir.join(&hex[..2]).join(format!("{hex}.jpg"))
|
||||
};
|
||||
// (thousands in one folder: renaming while a folder is listed lists some of them twice)
|
||||
let keys: Vec<Hash128> = (0..3000u128).map(|i| Hash128((0xab << 120) | i)).collect();
|
||||
let jpeg = disk::encode_jpeg(&gradient(32, 20, 3)).unwrap();
|
||||
for k in &keys {
|
||||
std::fs::create_dir_all(old_path(*k).parent().unwrap()).unwrap();
|
||||
std::fs::write(old_path(*k), &jpeg).unwrap();
|
||||
}
|
||||
// one that is there under both names (an older build ran after a newer one)
|
||||
std::fs::write(old_path(keys[1]).with_extension("lcthumb"), &jpeg).unwrap();
|
||||
|
||||
let d = DiskCache::new(&dir, 100 << 20);
|
||||
let back = d.get(keys[0]).expect("found under its old name");
|
||||
assert_eq!((back.width, back.height), (32, 20));
|
||||
assert!(!old_path(keys[0]).exists(), "and no longer a .jpg");
|
||||
assert_eq!(d.writes.load(std::sync::atomic::Ordering::Relaxed), 0, "nothing was rendered or written anew");
|
||||
let before = d.size();
|
||||
assert_eq!(before, cache_bytes_on_disk(&dir));
|
||||
assert!(old_path(keys[2]).exists(), "asking for the size renames nothing");
|
||||
|
||||
let seen = std::sync::atomic::AtomicUsize::new(0);
|
||||
let taken = d.take_over_old(&|| false, &|done, of| {
|
||||
assert_eq!(of, 2999, "the old files, less the one already asked for");
|
||||
seen.store(done, std::sync::atomic::Ordering::Relaxed);
|
||||
});
|
||||
assert_eq!(taken, 2998, "all but the one already asked for and the one that was there twice");
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::Relaxed), 2999, "and its progress came to the end");
|
||||
let left: Vec<String> = files_under(&dir).into_iter().filter(|f| is_named_as_a_picture(f)).collect();
|
||||
assert_eq!(left, Vec::<String>::new(), "nothing of the cache's is named as a picture");
|
||||
assert_eq!(d.size(), cache_bytes_on_disk(&dir), "each thumbnail counted once");
|
||||
assert_eq!(d.size(), jpeg.len() as u64 * 3000);
|
||||
assert!(keys.iter().all(|k| d.get(*k).is_some()));
|
||||
assert!(dir.join("CACHEDIR.TAG").is_file(), "a folder that holds nothing but the cache is tagged");
|
||||
// tidied once: there is nothing more to do, and it says so
|
||||
assert!(!d.needs_tidying());
|
||||
assert_eq!(d.take_over_old(&|| false, &|_, _| {}), 0);
|
||||
// a thumbnail written while the size was not known is counted with the rest, not alone
|
||||
let fresh = DiskCache::new(&dir, 100 << 20);
|
||||
fresh.put(hash_bytes(b"one more"), &gradient(32, 20, 9));
|
||||
assert_eq!(fresh.size(), cache_bytes_on_disk(&dir));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Given a `thumbs` folder that also holds somebody else's files (a library opened on a photo
|
||||
/// folder, issue #98), when the cache is tidied, then their files are as they were, whatever
|
||||
/// they are named, and the folder is not declared a cache. A thing that is no file under a
|
||||
/// thumbnail's new name keeps the old one from being touched. And tidying can be stopped.
|
||||
#[test]
|
||||
fn tidying_leaves_what_is_somebodys_and_can_be_stopped() {
|
||||
let dir = temp_dir("tidy-shared");
|
||||
let jpeg = disk::encode_jpeg(&gradient(32, 20, 3)).unwrap();
|
||||
let key = hash_bytes(b"ours");
|
||||
let hex = key.to_string();
|
||||
let ours = dir.join(&hex[..2]).join(format!("{hex}.jpg"));
|
||||
std::fs::create_dir_all(ours.parent().unwrap()).unwrap();
|
||||
std::fs::write(&ours, &jpeg).unwrap();
|
||||
let theirs = [dir.join("holiday.jpg"), dir.join(&hex[..2]).join("holiday.jpg"), dir.join("album").join("one.jpg")];
|
||||
for p in &theirs {
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, b"somebody's picture").unwrap();
|
||||
}
|
||||
// something that is no file has another thumbnail's new name
|
||||
let blocked = hash_bytes(b"blocked").to_string();
|
||||
let blocked_old = dir.join(&blocked[..2]).join(format!("{blocked}.jpg"));
|
||||
std::fs::create_dir_all(blocked_old.with_extension("lcthumb")).unwrap();
|
||||
std::fs::write(&blocked_old, &jpeg).unwrap();
|
||||
|
||||
let d = DiskCache::new(&dir, 100 << 20);
|
||||
assert!(d.needs_tidying());
|
||||
assert_eq!(d.take_over_old(&|| true, &|_, _| {}), 0, "stopped before anything");
|
||||
assert!(ours.exists() && d.needs_tidying(), "and to be gone on with");
|
||||
assert_eq!(d.take_over_old(&|| false, &|_, _| {}), 1);
|
||||
assert!(!ours.exists() && ours.with_extension("lcthumb").is_file());
|
||||
assert!(blocked_old.is_file(), "left where it is");
|
||||
for p in &theirs {
|
||||
assert_eq!(std::fs::read(p).unwrap(), b"somebody's picture", "{}", p.display());
|
||||
}
|
||||
assert!(!dir.join("CACHEDIR.TAG").exists(), "a folder with somebody's files in it is not declared a cache");
|
||||
assert!(d.needs_tidying(), "one could not be taken over: it is tried again");
|
||||
// with that out of the way it is done, once, though the folder is still shared
|
||||
std::fs::remove_dir(blocked_old.with_extension("lcthumb")).unwrap();
|
||||
assert_eq!(d.take_over_old(&|| false, &|_, _| {}), 1);
|
||||
assert!(!d.needs_tidying() && !dir.join("CACHEDIR.TAG").exists());
|
||||
|
||||
// what a file manager leaves behind in a folder it showed does not make it somebody's
|
||||
let own = temp_dir("tidy-droppings");
|
||||
let k = hash_bytes(b"k").to_string();
|
||||
std::fs::create_dir_all(own.join(&k[..2])).unwrap();
|
||||
std::fs::write(own.join(&k[..2]).join(format!("{k}.jpg")), &jpeg).unwrap();
|
||||
for left in [own.join(".DS_Store"), own.join("desktop.ini"), own.join(&k[..2]).join("Thumbs.db"), own.join("._thumbs")] {
|
||||
std::fs::write(left, b"x").unwrap();
|
||||
}
|
||||
let d = DiskCache::new(&own, 100 << 20);
|
||||
assert_eq!(d.take_over_old(&|| false, &|_, _| {}), 1);
|
||||
assert!(own.join("CACHEDIR.TAG").is_file() && !d.needs_tidying());
|
||||
let _ = std::fs::remove_dir_all(&own);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Given a full cache, when it has to let thumbnails go, then the ones read in this session
|
||||
/// stay over ones that were written later and never looked at: what is in use is not the first
|
||||
/// to go, though reading it wrote nothing.
|
||||
#[test]
|
||||
fn a_full_cache_keeps_what_was_read_this_session() {
|
||||
let dir = temp_dir("read-kept");
|
||||
let d = DiskCache::new(&dir, 60_000);
|
||||
let keys: Vec<Hash128> = (0..8u8).map(|i| hash_bytes(&[i, 7])).collect();
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
d.put(*k, &gradient(160, 100, i as u8));
|
||||
}
|
||||
// the first two written are the ones in use
|
||||
assert!(d.get(keys[0]).is_some() && d.get(keys[1]).is_some());
|
||||
for i in 0..40u8 {
|
||||
d.put(hash_bytes(&[i, 8]), &gradient(160, 100, i));
|
||||
}
|
||||
assert!(d.size() <= 60_000, "{}", d.size());
|
||||
assert!(d.get(keys[0]).is_some() && d.get(keys[1]).is_some(), "what was read stayed");
|
||||
assert!(d.get(keys[5]).is_none(), "what was written after it and never read went");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn modified(p: &std::path::Path) -> std::time::SystemTime {
|
||||
std::fs::metadata(p).unwrap().modified().unwrap()
|
||||
}
|
||||
|
||||
/// Given cached thumbnails, when they are read (as at every launch, for every photo in view),
|
||||
/// then nothing is written: a file read today is as it was, date and all. Only one that was not
|
||||
/// used for a long while has its date brought forward, so that what is still in use is not the
|
||||
/// first to go when the cache is full.
|
||||
#[test]
|
||||
fn reading_a_thumbnail_writes_nothing() {
|
||||
let dir = temp_dir("read-only");
|
||||
let d = DiskCache::new(&dir, 10 << 20);
|
||||
let (fresh, stale) = (hash_bytes(b"fresh"), hash_bytes(b"stale"));
|
||||
let path = |k: Hash128| {
|
||||
let hex = k.to_string();
|
||||
dir.join(&hex[..2]).join(format!("{hex}.lcthumb"))
|
||||
};
|
||||
d.put(fresh, &gradient(160, 100, 1));
|
||||
d.put(stale, &gradient(160, 100, 2));
|
||||
let day = std::time::Duration::from_secs(24 * 3600);
|
||||
let now = std::time::SystemTime::now();
|
||||
let set = |k: Hash128, at: std::time::SystemTime| std::fs::File::options().write(true).open(path(k)).unwrap().set_modified(at).unwrap();
|
||||
set(fresh, now - day);
|
||||
set(stale, now - day * 60);
|
||||
let before = (modified(&path(fresh)), std::fs::read(path(fresh)).unwrap());
|
||||
for _ in 0..3 {
|
||||
assert!(d.get(fresh).is_some());
|
||||
}
|
||||
assert_eq!((modified(&path(fresh)), std::fs::read(path(fresh)).unwrap()), before, "read, and untouched");
|
||||
assert!(d.get(stale).is_some());
|
||||
assert!(modified(&path(stale)) > now - day, "one not used for two months is in use again");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A folder the cache makes for itself says that it is a cache (`CACHEDIR.TAG`, which backup
|
||||
/// programs go by), and the tag is not one of its thumbnails: clearing the cache leaves it. A
|
||||
/// folder that was there before (it may hold somebody's files, issue #98) is not tagged.
|
||||
#[test]
|
||||
fn a_folder_the_cache_makes_is_tagged_as_a_cache_and_one_that_was_there_is_not() {
|
||||
let (made, theirs) = (temp_dir("tagged"), temp_dir("untagged"));
|
||||
let own = made.join("thumbs");
|
||||
let d = DiskCache::new(&own, 10 << 20);
|
||||
assert!(!own.exists(), "nothing is made before there is something to keep");
|
||||
d.put(hash_bytes(b"a"), &gradient(160, 100, 1));
|
||||
let tag = std::fs::read_to_string(own.join("CACHEDIR.TAG")).expect("tagged");
|
||||
assert!(tag.starts_with("Signature: 8a477f597d28d172789f06886806bc55"), "{tag}");
|
||||
d.clear();
|
||||
assert_eq!(d.size(), 0);
|
||||
assert!(own.join("CACHEDIR.TAG").is_file());
|
||||
|
||||
std::fs::create_dir_all(&theirs).unwrap();
|
||||
std::fs::write(theirs.join("holiday.jpg"), b"somebody's picture").unwrap();
|
||||
let d = DiskCache::new(&theirs, 10 << 20);
|
||||
d.put(hash_bytes(b"a"), &gradient(160, 100, 1));
|
||||
assert!(d.get(hash_bytes(b"a")).is_some());
|
||||
assert!(!theirs.join("CACHEDIR.TAG").exists(), "a folder with somebody's files in it is not declared a cache");
|
||||
for dir in [made, theirs] {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`JobPool::wait`] returns once a job finishes, which [`JobPool::try_recv`] then hands out, or
|
||||
/// after the timeout.
|
||||
#[test]
|
||||
|
||||
@@ -2118,5 +2118,6 @@
|
||||
"Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt",
|
||||
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.",
|
||||
"On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.",
|
||||
"Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert."
|
||||
"Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert.",
|
||||
"Tidying the thumbnail cache": "Miniaturcache wird aufgeräumt"
|
||||
}
|
||||
|
||||
@@ -1995,5 +1995,6 @@
|
||||
"Adding to Apple Photos": "Añadiendo a Fotos de Apple",
|
||||
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.",
|
||||
"On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.",
|
||||
"Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero."
|
||||
"Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero.",
|
||||
"Tidying the thumbnail cache": "Ordenando la caché de miniaturas"
|
||||
}
|
||||
|
||||
@@ -1957,5 +1957,6 @@
|
||||
"Adding to Apple Photos": "Ajout à Apple Photos",
|
||||
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.",
|
||||
"On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.",
|
||||
"Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé."
|
||||
"Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé.",
|
||||
"Tidying the thumbnail cache": "Rangement du cache des miniatures"
|
||||
}
|
||||
|
||||
@@ -1961,5 +1961,6 @@
|
||||
"Adding to Apple Photos": "Appleの写真に追加中",
|
||||
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。",
|
||||
"On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。",
|
||||
"Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。"
|
||||
"Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。",
|
||||
"Tidying the thumbnail cache": "サムネールキャッシュを整理中"
|
||||
}
|
||||
|
||||
@@ -1998,5 +1998,6 @@
|
||||
"Recognition models": "Modelos de reconhecimento",
|
||||
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.",
|
||||
"On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.",
|
||||
"Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado."
|
||||
"Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado.",
|
||||
"Tidying the thumbnail cache": "Organizando o cache de miniaturas"
|
||||
}
|
||||
|
||||
@@ -2151,5 +2151,6 @@
|
||||
"Adding to Apple Photos": "Добавление в Apple Фото",
|
||||
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.",
|
||||
"On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.",
|
||||
"Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый."
|
||||
"Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый.",
|
||||
"Tidying the thumbnail cache": "Упорядочивание кэша миниатюр"
|
||||
}
|
||||
|
||||
@@ -2151,5 +2151,6 @@
|
||||
"Adding to Apple Photos": "Додавання до Apple Фото",
|
||||
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.",
|
||||
"On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.",
|
||||
"Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший."
|
||||
"Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший.",
|
||||
"Tidying the thumbnail cache": "Упорядкування кешу мініатюр"
|
||||
}
|
||||
|
||||
@@ -1997,5 +1997,6 @@
|
||||
"Adding to Apple Photos": "正在添加到 Apple 照片",
|
||||
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。",
|
||||
"On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。",
|
||||
"Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。"
|
||||
"Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。",
|
||||
"Tidying the thumbnail cache": "正在整理缩略图缓存"
|
||||
}
|
||||
|
||||
@@ -1959,5 +1959,6 @@
|
||||
"Adding to Apple Photos": "正在加入 Apple 照片",
|
||||
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。",
|
||||
"On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。",
|
||||
"Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。"
|
||||
"Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。",
|
||||
"Tidying the thumbnail cache": "正在整理縮圖快取"
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ guard.detail(&file_name);
|
||||
dead job never leaves a stuck bar. A worker that only reports progress takes a `TaskHandle` (`guard.handle()`,
|
||||
`Clone + Send`).
|
||||
- **`kind`** is a stable camelCase id that agents filter by (`export`, `import`, `scan`, `sync`, `previews`,
|
||||
`download`, `faces`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it
|
||||
`download`, `faces`, `cache`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it
|
||||
translated, so add it to every catalog in `crates/ui-egui/locales/`.
|
||||
- **Progress:** `progress(done, total)`. A `total` of 0 means the amount of work isn't known yet, and the bar sweeps.
|
||||
`set_unit(Unit::Bytes)` makes the count read "12 of 340 MB"; `Unit::Percent` reads "40 %". The default `Unit::Count`
|
||||
|
||||
@@ -218,6 +218,24 @@
|
||||
- Compare and Reference View panes say "Rendering…" (or that the photo can't be opened) the same way, so a
|
||||
magnified preview isn't taken for the photo when checking focus at 1:1.
|
||||
|
||||
### The thumbnail cache is no longer taken for your photos
|
||||
- LightCraft's cached thumbnails no longer show up in Windows Photos / Gallery or other photo apps as pictures of
|
||||
yours (issues #610, #1066). They were ordinary `.jpg` files in the library's `thumbs` folder, which is usually
|
||||
inside Pictures, so every thumbnail (one for each photo, size and state of its edits) was listed as a photo.
|
||||
They are now kept under a name that is no picture's. The ones already there are taken over, not rendered again:
|
||||
the first time a library opens after this update, "Tidying the thumbnail cache" shows in the activity stack
|
||||
while they are renamed (it can be stopped, and goes on at the next open).
|
||||
- Opening a library no longer rewrites the date of every thumbnail it shows, which made them all look new at each
|
||||
launch. Reading a thumbnail writes nothing (but for one that went unused for a week, whose date is brought
|
||||
forward); what was read is remembered for the session, so a full cache still lets go of what is not in use first.
|
||||
- A `thumbs` folder that holds nothing but the cache says that it is one (`CACHEDIR.TAG`), so backup programs that
|
||||
go by that can leave it out. It is safe to delete while LightCraft is closed, as before. A `thumbs` folder that
|
||||
also holds files of yours (a library opened on a photo folder that had one) is not tagged, and your files in it
|
||||
are left as they are. Note that files there named exactly like the cache's own (`ab/ab…` with 32 hex digits and
|
||||
`.jpg`) have always been taken for the cache's, and are renamed with it.
|
||||
- If you go back to an older version, it renders its thumbnails again as `.jpg`. This version then takes those
|
||||
over one by one as they are used; the rest stay until the cache makes room.
|
||||
|
||||
### JPEG XL export
|
||||
- Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI
|
||||
(`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a
|
||||
|
||||
Reference in New Issue
Block a user