diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 4fc7ababa..e82c7d4bc 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -472,6 +472,117 @@ fn smart_location(s: &mut Session, p: &Value) -> Result { Ok(r) } +/// The thumbnail cache folder: where it is, the library's default, whether it is custom, what it +/// holds, whether it can be used now (a chosen folder on an unplugged drive is `available: false`). +#[cfg(not(target_arch = "wasm32"))] +fn thumbs_location_json(s: &Session) -> Value { + let dir = s.thumb_cache_dir(); + let default = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.thumbs_dir()); + let (count, bytes) = dir.as_deref().map_or((0, 0), lightcraft_preview::disk::cache_stats); + json!({ + "path": dir.as_ref().map(|d| d.to_string_lossy()), + "default": default.map(|d| d.to_string_lossy().to_string()), + "custom": s.thumbs_dir.is_some(), + "available": dir.as_ref().is_some_and(|d| d.is_dir()) || s.thumbs_dir.is_none(), + "count": count, + "bytes": bytes, + }) +} + +/// Apply `what` to the cached thumbnails in `from` for the new folder `to` → (moved or removed, +/// failed). Only the cache's own files are touched (`lightcraft_preview::disk::cache_files`). +#[cfg(not(target_arch = "wasm32"))] +fn thumbs_migrate(from: &std::path::Path, to: &std::path::Path, what: crate::smart::Existing) -> (usize, Vec) { + use crate::smart::Existing; + let (mut done, mut failed) = (0, Vec::new()); + if what == Existing::Leave { + return (done, failed); + } + for (src, _, _) in lightcraft_preview::disk::cache_files(from) { + let r = match what { + Existing::Discard => std::fs::remove_file(&src), + _ => { + let (Some(name), Some(shard)) = (src.file_name(), src.parent().and_then(|p| p.file_name())) else { continue }; + let dir = to.join(shard); + let dst = dir.join(name); + // create only the shard, never missing parents on an unplugged drive + let prepare = if dir.is_dir() { Ok(()) } else { std::fs::create_dir(&dir) }; + // keyed by content: one already there is the same thumbnail; across drives a copy + // goes through a temp file (the cache never sees a partial one) + prepare.and_then(|()| { + if dst.is_file() { + std::fs::remove_file(&src) + } else { + std::fs::rename(&src, &dst).or_else(|_| { + std::fs::read(&src) + .and_then(|b| lightcraft_catalog::safe_file::write_atomic(&dst, &b)) + .and_then(|()| std::fs::remove_file(&src)) + }) + } + }) + } + }; + match r { + Ok(()) => done += 1, + Err(err) => failed.push(format!("{}: {err}", src.display())), + } + } + (done, failed) +} + +/// Show or change where the thumbnail cache is kept (per library), like +/// [`smart_location`]: no fallback to another drive, and an explicit choice for what is cached. +#[cfg(not(target_arch = "wasm32"))] +fn thumbs_location(s: &mut Session, p: &Value) -> Result { + const C: &str = "library.thumbnailCacheLocation"; + let current = s.thumb_cache_dir().ok_or_else(|| bad(C, "the thumbnail cache folder needs a library on disk"))?; + let reset = p.get("reset").and_then(Value::as_bool).unwrap_or(false); + let chosen = str_param(p, "path").filter(|x| !x.trim().is_empty()); + if !reset && chosen.is_none() { + return Ok(thumbs_location_json(s)); + } + let default = s + .library + .as_ref() + .filter(|l| l.on_disk) + .map(|l| l.thumbs_dir()) + .ok_or_else(|| bad(C, "the thumbnail cache folder needs a library on disk"))?; + let (new_dir, custom) = match chosen { + Some(x) if !reset => { + let d = std::path::PathBuf::from(x.trim()); + if !d.is_absolute() { + return Err(bad(C, "path must be an absolute folder path")); + } + (d.clone(), (d != default).then_some(d)) + } + _ => (default, None), + }; + if new_dir == current { + return Ok(thumbs_location_json(s)); + } + // the new place must work before anything moves; there is no fallback to another drive + crate::smart::check_writable(&new_dir).map_err(|e| bad(C, e))?; + let (held, _) = lightcraft_preview::disk::cache_stats(¤t); + let existing = match str_param(p, "existing") { + Some(x) => Some(crate::smart::Existing::parse(x).ok_or_else(|| bad(C, "existing must be move, leave or discard"))?), + None if held == 0 => Some(crate::smart::Existing::Leave), + None => None, + }; + let Some(existing) = existing else { + return Err(bad(C, format!("{held} cached thumbnail(s) are in {}: pass existing = move, leave or discard", current.display()))); + }; + let (moved, failed) = thumbs_migrate(¤t, &new_dir, existing); + s.thumbs_dir = custom; + // the old cache is retired (render jobs holding it can't write there any more); the new + // folder fills as thumbnails are rendered + s.media.attach_disk_cache(&new_dir, s.cache_bytes(), s.thumbs_dir.is_some()); + s.save_prefs()?; + let mut r = thumbs_location_json(s); + r["handled"] = json!(moved); + r["failed"] = json!(failed); + Ok(r) +} + /// Whether photo `id` has a smart preview. pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool { match (&s.media.smart_dir, s.catalog.photo(id)) { @@ -502,6 +613,16 @@ pub fn specs() -> Vec { always, smart_location ), + #[cfg(not(target_arch = "wasm32"))] + cmd!( + "library.thumbnailCacheLocation", + "Thumbnail Cache Location", + [], + None, + "{path?: absolute folder, reset?: bool, existing?: move|leave|discard} — without path/reset: where this library keeps its thumbnail cache → {path, default, custom, available, count, bytes}. With path (or reset = back to `thumbs` in the library): use that folder instead (must exist or have an existing parent, and accept writes; never falls back to another drive; a chosen folder missing when the library opens is reported and thumbnails stay in memory). Thumbnails already in the old folder need `existing`: move them, leave them (the new folder fills as the grid renders), or discard them → also {handled, failed}", + always, + thumbs_location + ), cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| { let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?; let online = match s.catalog.photo(id).map(|p| p.source.clone()) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 494e11480..82e2bcd2b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -296,6 +296,9 @@ pub struct Session { pub cache_mb: u32, /// Where smart previews are kept when not in the library folder (persisted in prefs.json). pub smart_previews_dir: Option, + /// Where the thumbnail cache is kept when not in the library's `thumbs` folder (persisted in + /// prefs.json; see [`Session::thumb_cache_dir`]). + pub thumbs_dir: Option, /// Untouched Local records of folders not browsed for this many days are forgotten when the /// library opens (0 = never; persisted in prefs.json). See `cmd/browse.rs`. pub forget_local_days: u32, @@ -393,6 +396,7 @@ impl Session { import_defaults: import::ImportDefaults::default(), cache_mb: 0, smart_previews_dir: None, + thumbs_dir: None, forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS, #[cfg(not(target_arch = "wasm32"))] apple_photos: None, diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 393f4a96a..9f9ddc796 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -6,7 +6,7 @@ //! catalog.snap catalog.log (lightcraft-catalog journal) //! presets.json view.json (user presets + favourites; last source/sort/selection) //! prefs.json (library preferences: XMP sidecars, import defaults, cache size, last export) -//! thumbs/ (rendered thumbnail cache, safe to delete) +//! thumbs/ (rendered thumbnail cache, safe to delete; prefs.json may name another folder) //! Originals/ (photos imported with "copy into library") //! ``` //! @@ -192,6 +192,7 @@ impl Library { pub(crate) fn journal_mut(&mut self) -> &mut Journal { &mut self.journal } + /// The library's own thumbnail cache folder (the default; see [`Session::thumb_cache_dir`]). pub fn thumbs_dir(&self) -> PathBuf { self.dir.join("thumbs") } @@ -229,6 +230,8 @@ struct PrefsFile { cache_mb: u32, /// Folder for smart previews (default: `Smart Previews` in the library). smart_previews_dir: Option, + /// Folder for the thumbnail cache (default: `thumbs` in the library). + thumbs_dir: Option, /// Days after which untouched Local records of unbrowsed folders are forgotten (missing = /// the default, 0 = never). forget_local_days: Option, @@ -459,6 +462,15 @@ impl Session { if let Some(d) = &self.smart_previews_dir { self.media.smart_dir = Some(d.clone()); } + self.thumbs_dir = prefs.thumbs_dir.filter(|_| on_disk).map(PathBuf::from); + // a chosen folder that is gone is reported, and stays the cache folder: thumbnails are + // then kept in memory only (nothing is written to the library drive instead) + if let Some(d) = self.thumbs_dir.as_ref().filter(|d| !d.is_dir()) { + settings.warnings.push(format!( + "The thumbnail cache folder {} is not available (drive disconnected?): thumbnails are kept in memory only until it is back or you choose another folder in Settings", + d.display() + )); + } // presets if let Some(f) = settings.read::(files.as_mut(), "presets.json") { for p in &mut self.presets { @@ -503,7 +515,8 @@ impl Session { // photo ids are per library: drop decoded sources of the previous one self.media.clear_sources(); if on_disk { - self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes()); + let thumbs = self.thumbs_dir.clone().unwrap_or_else(|| dir.join("thumbs")); + self.media.attach_disk_cache(&thumbs, self.cache_bytes(), self.thumbs_dir.is_some()); } let view_written = self.view_json(); self.library = Some(Library { @@ -698,6 +711,7 @@ impl Session { import: self.import_defaults.clone(), cache_mb: self.cache_mb, smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()), + thumbs_dir: self.thumbs_dir.as_ref().map(|d| d.to_string_lossy().to_string()), forget_local_days: Some(self.forget_local_days), }) .unwrap_or_default(); @@ -719,6 +733,14 @@ impl Session { new } + /// Where the open library keeps its thumbnail cache: the chosen folder + /// ([`Session::thumbs_dir`], which may be unavailable), else `thumbs` in the library. `None` + /// without a library on disk. + pub fn thumb_cache_dir(&self) -> Option { + let lib = self.library.as_ref().filter(|l| l.on_disk)?; + Some(self.thumbs_dir.clone().unwrap_or_else(|| lib.thumbs_dir())) + } + /// The thumbnail disk cache budget in bytes ([`Session::cache_mb`], else the default). pub fn cache_bytes(&self) -> u64 { if self.cache_mb == 0 { crate::media::DISK_CACHE_BYTES } else { u64::from(self.cache_mb) << 20 } @@ -729,9 +751,8 @@ impl Session { /// Saved with the library preferences. pub fn set_cache_mb(&mut self, mb: u32) -> Result<()> { self.cache_mb = mb; - if let Some(lib) = self.library.as_ref().filter(|l| l.on_disk) { - let dir = lib.thumbs_dir(); - self.media.set_disk_cache_bytes(&dir, self.cache_bytes()); + if let Some(dir) = self.thumb_cache_dir() { + self.media.set_disk_cache_bytes(&dir, self.cache_bytes(), self.thumbs_dir.is_some()); } self.save_prefs() } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index cf17c3264..455c5fe5c 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -22,7 +22,7 @@ use std::sync::{Arc, Weak}; use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source}; use lightcraft_develop::DevelopSettings; use lightcraft_pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache}; -use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache}; +use lightcraft_preview::{DiskCache, Hash128, Hasher128, Lru, PreviewCache}; use lightcraft_raster::{Histogram, Rgb32f, Rgba8}; use serde::{Deserialize, Serialize}; @@ -431,19 +431,22 @@ fn rendered_budget(share: usize) -> usize { impl MediaCache { /// Keep rendered thumbnails on disk in `dir` as well. The cache this replaces is retired /// (its files stay): render jobs still holding it can't write through it any more, even - /// into a directory a later clear emptied (`dir` may be the same one). - pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) { + /// into a directory a later clear emptied (`dir` may be the same one). A `chosen` folder + /// (the user's, not the library's own) is never created: while it is missing, thumbnails + /// are kept in memory only. + pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64, chosen: bool) { self.rendered.retire(); - self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes)); + let disk = if chosen { DiskCache::chosen(dir, disk_bytes) } else { DiskCache::new(dir, disk_bytes) }; + self.rendered = Arc::new(PreviewCache::with_disk_cache(rendered_budget(self.budget), disk)); } /// Change the disk budget of the cache in `dir`: in place when that cache is attached (its /// thumbnails are keyed by content, so they and the textures shown from them stay valid, /// and the same cache object keeps its generation), else attach it. - pub fn set_disk_cache_bytes(&mut self, dir: &std::path::Path, disk_bytes: u64) { + pub fn set_disk_cache_bytes(&mut self, dir: &std::path::Path, disk_bytes: u64, chosen: bool) { match self.rendered.disk().filter(|d| d.dir() == dir) { Some(d) => d.set_budget(disk_bytes), - None => self.attach_disk_cache(dir, disk_bytes), + None => self.attach_disk_cache(dir, disk_bytes, chosen), } } diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index d387d2fe1..0e5cbc4a7 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -889,6 +889,82 @@ fn smart_previews_folder_is_chosen_per_library() { assert_eq!(r["path"], lib.join("Smart Previews").to_string_lossy().as_ref()); } +/// The thumbnail cache can live outside the library, like smart previews (issue #903): the +/// folder is saved per library, relative paths and missing drives are refused, the thumbnails +/// already cached need an explicit choice, and a chosen folder that is gone when the library +/// opens is reported (never a silent fallback to the library drive, nothing created there). +#[test] +fn thumbnail_cache_folder_is_chosen_per_library() { + let lib = temp_dir("thumbloc-lib"); + let away = temp_dir("thumbloc-away"); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + const C: &str = "library.thumbnailCacheLocation"; + let loc = s.execute(C, &json!({})).unwrap(); + assert_eq!((loc["custom"].clone(), loc["available"].clone()), (json!(false), json!(true)), "{loc}"); + assert_eq!(loc["path"], lib.join("thumbs").to_string_lossy().as_ref()); + assert_eq!(loc["default"], lib.join("thumbs").to_string_lossy().as_ref()); + // a cached thumbnail in the library's folder + let key = lightcraft_preview::hash_bytes(b"thumb"); + let img = lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[90, 120, 200, 255]; 16] }; + s.media.rendered.disk().unwrap().put(key, &img); + assert_eq!(s.execute(C, &json!({})).unwrap()["count"], 1); + + // relative paths and missing drives are refused, and nothing is created on this drive + let err = s.execute(C, &json!({"path": "rel/thumbs"})).unwrap_err().to_string(); + assert!(err.contains("absolute"), "{err}"); + let gone = away.join("unplugged/thumbs"); + let err = s.execute(C, &json!({"path": gone.to_string_lossy()})).unwrap_err().to_string(); + assert!(err.contains("not available"), "{err}"); + assert!(!away.join("unplugged").exists()); + // the thumbnails already cached need an explicit choice + let to = away.join("thumbs"); + let err = s.execute(C, &json!({"path": to.to_string_lossy()})).unwrap_err().to_string(); + assert!(err.contains("existing"), "{err}"); + assert_eq!(s.execute(C, &json!({})).unwrap()["custom"], false); + + let r = s.execute(C, &json!({"path": to.to_string_lossy(), "existing": "move"})).unwrap(); + assert_eq!((r["custom"].clone(), r["handled"].clone(), r["count"].clone()), (json!(true), json!(1), json!(1)), "{r}"); + assert_eq!(s.media.rendered.disk().unwrap().dir(), to.as_path(), "new thumbnails go to the chosen folder"); + assert!(s.media.rendered.disk().unwrap().get(key).is_some(), "the moved thumbnail is used"); + assert_eq!(lightcraft_preview::disk::cache_stats(&lib.join("thumbs")).0, 0); + + // the setting is saved with the library + drop(s); // one session per library (issue #99) + let mut again = Session::new().with_fs(); + again.open_library(&lib, false).unwrap(); + assert!(again.take_library_warnings().is_empty()); + assert_eq!(again.execute(C, &json!({})).unwrap()["path"], to.to_string_lossy().as_ref()); + assert_eq!(again.media.rendered.disk().unwrap().dir(), to.as_path()); + + // leaving the old thumbnails keeps them where they are; the new folder fills as the grid renders + let drive = away.join("drive"); + std::fs::create_dir_all(&drive).unwrap(); + let on_drive = drive.join("thumbs"); + let r = again.execute(C, &json!({"path": on_drive.to_string_lossy(), "existing": "leave"})).unwrap(); + assert_eq!((r["handled"].clone(), r["count"].clone()), (json!(0), json!(0)), "{r}"); + assert_eq!(lightcraft_preview::disk::cache_stats(&to).0, 1, "left in the old folder"); + + // the chosen folder is gone when the library opens (its drive's mount point is still + // there): reported, and the folder is not created again + drop(again); + std::fs::remove_dir_all(&on_drive).unwrap(); + let mut third = Session::new().with_fs(); + third.open_library(&lib, false).unwrap(); + let warnings = third.take_library_warnings(); + assert!(warnings.iter().any(|w| w.contains("not available") && w.contains("thumbs")), "{warnings:?}"); + let loc = third.execute(C, &json!({})).unwrap(); + assert_eq!((loc["available"].clone(), loc["path"].clone()), (json!(false), json!(on_drive.to_string_lossy())), "{loc}"); + third.media.rendered.disk().unwrap().put(key, &img); + assert!(!on_drive.exists(), "the missing chosen folder is not recreated"); + + // back to the library's own folder + let r = third.execute(C, &json!({"reset": true})).unwrap(); + assert_eq!((r["custom"].clone(), r["available"].clone()), (json!(false), json!(true)), "{r}"); + assert_eq!(r["path"], lib.join("thumbs").to_string_lossy().as_ref()); + assert_eq!(third.media.rendered.disk().unwrap().dir(), lib.join("thumbs").as_path()); +} + /// Missing Photos covers library photos only: a small library next to many Local browse /// records (and a deleted photo) checks — and lists, and counts — the library files alone. #[test] diff --git a/crates/preview/src/disk.rs b/crates/preview/src/disk.rs index 26d180ec9..9db28f0d4 100644 --- a/crates/preview/src/disk.rs +++ b/crates/preview/src/disk.rs @@ -22,6 +22,9 @@ const QUALITY: u8 = 90; pub struct DiskCache { dir: PathBuf, + /// Whether a missing `dir` may be created (when its parent exists). Not for a folder the user + /// chose: see [`DiskCache::chosen`]. + create_dir: bool, /// Bytes the cache may hold (changed in place by [`DiskCache::set_budget`]). budget: AtomicU64, /// Bytes on disk (`None` until first scanned). @@ -35,6 +38,7 @@ impl DiskCache { pub fn new(dir: &Path, budget: u64) -> DiskCache { DiskCache { dir: dir.to_path_buf(), + create_dir: true, budget: AtomicU64::new(budget), total: Mutex::new(None), hits: AtomicU64::new(0), @@ -43,6 +47,12 @@ impl DiskCache { } } + /// A cache in a folder the user chose (on another drive, perhaps): it is never created, so + /// while it is missing (drive unplugged) nothing is written and every read misses. + pub fn chosen(dir: &Path, budget: u64) -> DiskCache { + DiskCache { create_dir: false, ..DiskCache::new(dir, budget) } + } + pub fn dir(&self) -> &Path { &self.dir } @@ -91,7 +101,7 @@ impl DiskCache { let Some(bytes) = encode_jpeg(img) else { return }; let p = self.path(key); let Some(parent) = p.parent() else { return }; - if std::fs::create_dir_all(parent).is_err() { + if !parent.is_dir() && !self.make_shard(parent) { return; } // Serialize the file replacement and its size accounting. A repeated render of @@ -113,33 +123,26 @@ 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)> { - let mut out = Vec::new(); - let Ok(rd) = std::fs::read_dir(&self.dir) else { return out }; - for sub in rd.flatten() { - let sub_name = sub.file_name(); - let Some(sub_name) = sub_name.to_str() else { continue }; - if !is_shard_name(sub_name) { - continue; + /// Create the shard directory `shard` for a write, and the cache directory itself when its + /// parent exists and it wasn't [`DiskCache::chosen`]. Never creates missing parents: a cache + /// folder on an unplugged drive is not silently recreated on the system drive (its mount + /// point), the write is just skipped. + fn make_shard(&self, shard: &Path) -> bool { + if !self.dir.is_dir() { + if !self.create_dir || !self.dir.parent().is_some_and(Path::is_dir) { + return false; } - let Ok(files) = std::fs::read_dir(sub.path()) else { continue }; - for f in files.flatten() { - let name = f.file_name(); - let Some(name) = name.to_str() else { continue }; - if !is_cache_file(sub_name, name) { - continue; - } - // `DirEntry::metadata` does not follow symlinks: a link is never a cache file - if let Ok(m) = f.metadata() - && m.is_file() - { - out.push((f.path(), m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH))); - } + // another writer may have just made it + if std::fs::create_dir(&self.dir).is_err() && !self.dir.is_dir() { + return false; } } - out + std::fs::create_dir(shard).is_ok() || shard.is_dir() + } + + /// All cache files: see [`cache_files`]. + fn scan(&self) -> Vec<(PathBuf, u64, std::time::SystemTime)> { + cache_files(&self.dir) } /// Remove least recently used files until the cache is at most 80 % of its budget. @@ -173,6 +176,40 @@ impl DiskCache { } } +/// All files of a thumbnail cache in `dir` (only names of the cache's own shape, see +/// [`is_cache_file`]; symlinks are never followed): (path, size, modified). +pub fn cache_files(dir: &Path) -> Vec<(PathBuf, u64, std::time::SystemTime)> { + let mut out = Vec::new(); + let Ok(rd) = std::fs::read_dir(dir) else { return out }; + for sub in rd.flatten() { + let sub_name = sub.file_name(); + let Some(sub_name) = sub_name.to_str() else { continue }; + if !is_shard_name(sub_name) { + continue; + } + let Ok(files) = std::fs::read_dir(sub.path()) else { continue }; + for f in files.flatten() { + let name = f.file_name(); + let Some(name) = name.to_str() else { continue }; + if !is_cache_file(sub_name, name) { + continue; + } + // `DirEntry::metadata` does not follow symlinks: a link is never a cache file + if let Ok(m) = f.metadata() + && m.is_file() + { + out.push((f.path(), m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH))); + } + } + } + out +} + +/// The number of cached thumbnails in `dir` and their total size in bytes. +pub fn cache_stats(dir: &Path) -> (usize, u64) { + cache_files(dir).iter().fold((0usize, 0u64), |(n, bytes), f| (n.saturating_add(1), bytes.saturating_add(f.1))) +} + fn is_lower_hex(s: &str) -> bool { s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs index a571ecda0..d7115f18f 100644 --- a/crates/preview/src/lib.rs +++ b/crates/preview/src/lib.rs @@ -42,7 +42,12 @@ impl PreviewCache { /// Memory LRU + disk cache in `dir` (at most `disk_bytes`). pub fn with_disk(mem_bytes: usize, dir: &Path, disk_bytes: u64) -> PreviewCache { - PreviewCache { generation: RwLock::new(Some(0)), mem: Mutex::new(Lru::new(mem_bytes)), disk: Some(DiskCache::new(dir, disk_bytes)) } + PreviewCache::with_disk_cache(mem_bytes, DiskCache::new(dir, disk_bytes)) + } + + /// Memory LRU + the given disk cache (e.g. a [`DiskCache::chosen`] one). + pub fn with_disk_cache(mem_bytes: usize, disk: DiskCache) -> PreviewCache { + PreviewCache { generation: RwLock::new(Some(0)), mem: Mutex::new(Lru::new(mem_bytes)), disk: Some(disk) } } pub fn disk(&self) -> Option<&DiskCache> { diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs index 3179b91ec..ff4dcf735 100644 --- a/crates/preview/src/tests.rs +++ b/crates/preview/src/tests.rs @@ -123,6 +123,32 @@ fn disk_cache_roundtrip_and_prune() { let _ = std::fs::remove_dir_all(&dir); } +/// A missing cache folder is made when its parent exists, never with missing parents; a folder +/// the user chose is never made again (issue #903: an unplugged drive's folder stays missing). +#[test] +fn disk_cache_creates_only_its_own_missing_folder() { + let root = temp_dir("mkdir"); + std::fs::create_dir_all(&root).unwrap(); + let img = gradient(16, 16, 3); + let k = hash_bytes(b"mk"); + let own = root.join("thumbs"); + DiskCache::new(&own, 1 << 20).put(k, &img); + assert!(DiskCache::new(&own, 1 << 20).get(k).is_some(), "the library's own folder is made"); + let deep = root.join("unplugged/thumbs"); + DiskCache::new(&deep, 1 << 20).put(k, &img); + assert!(!root.join("unplugged").exists(), "no missing parents"); + let chosen = root.join("chosen"); + let d = DiskCache::chosen(&chosen, 1 << 20); + d.put(k, &img); + assert!(!chosen.exists(), "a chosen folder is not made"); + assert!(d.get(k).is_none()); + std::fs::create_dir(&chosen).unwrap(); + d.put(k, &img); + assert!(d.get(k).is_some(), "written once it is back"); + assert_eq!(super::disk::cache_stats(&chosen).0, 1); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn disk_cache_overwrite_accounts_for_replaced_jpeg_only_once() { let dir = temp_dir("overwrite"); diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 619ec7087..9e6c87ccd 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -733,7 +733,7 @@ "Join the ArtCraft Discord…": "ArtCraft auf Discord beitreten…", "Join the ArtCraft community on Discord": "Der ArtCraft-Community auf Discord beitreten", "Keep in Local": "In „Lokal“ behalten", - "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Wähle ein Laufwerk mit genügend Platz: Smart-Vorschauen benötigen etwa 1 MB pro Foto. Der Miniaturbild-Cache bleibt in der Bibliothek.", + "Keep it on a drive with room: smart previews are about 1 MB per photo.": "Wähle ein Laufwerk mit genügend Platz: Smart-Vorschauen benötigen etwa 1 MB pro Foto.", "Keyboard Shortcuts": "Tastenkürzel", "Keyboard shortcuts": "Tastenkürzel", "Keyword": "Stichwort", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 372478aa3..4efa55c85 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -711,7 +711,7 @@ "Join the ArtCraft Discord…": "Unirse al Discord de ArtCraft…", "Join the ArtCraft community on Discord": "Unirse a la comunidad de ArtCraft en Discord", "Keep in Local": "Conservar en Local", - "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Conviene un disco con espacio: las vistas previas inteligentes ocupan aproximadamente 1 MB por foto. La caché de miniaturas permanece en la biblioteca.", + "Keep it on a drive with room: smart previews are about 1 MB per photo.": "Conviene un disco con espacio: las vistas previas inteligentes ocupan aproximadamente 1 MB por foto.", "Keyboard Shortcuts": "Métodos abreviados de teclado", "Keyboard shortcuts": "Métodos abreviados de teclado", "Keyword": "Palabra clave", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 28618fe75..c0b05d2f0 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -692,7 +692,7 @@ "Join the ArtCraft Discord…": "Rejoindre le Discord d'ArtCraft…", "Join the ArtCraft community on Discord": "Rejoindre la communauté ArtCraft sur Discord", "Keep in Local": "Conserver dans Local", - "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Choisissez un disque avec de la place : les aperçus dynamiques font environ 1 Mo par photo. Le cache des miniatures reste dans la bibliothèque.", + "Keep it on a drive with room: smart previews are about 1 MB per photo.": "Choisissez un disque avec de la place : les aperçus dynamiques font environ 1 Mo par photo.", "Keyboard Shortcuts": "Raccourcis clavier", "Keyboard shortcuts": "Raccourcis clavier", "Keyword": "Mot-clé", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index b17d1d21a..bb9c67225 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -826,7 +826,7 @@ "Invert": "反転", "Invert Mask": "マスクを反転", "Keep in Local": "ローカルに残す", - "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "スマートプレビューは1枚あたり約1MBです。空き容量のあるドライブを指定してください。サムネイルキャッシュはライブラリに残ります", + "Keep it on a drive with room: smart previews are about 1 MB per photo.": "スマートプレビューは1枚あたり約1MBです。空き容量のあるドライブを指定してください。", "Keyboard Shortcuts": "キーボードショートカット", "Keyboard shortcuts": "キーボードショートカット", "Keyword": "キーワード", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index b1273b776..121fb996b 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -701,7 +701,7 @@ "Join the ArtCraft Discord…": "Entrar no Discord do ArtCraft…", "Join the ArtCraft community on Discord": "Participe da comunidade ArtCraft no Discord", "Keep in Local": "Manter em Local", - "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Guarde em um disco com espaço: as pré-visualizações inteligentes ocupam cerca de 1 MB por foto. O cache de miniaturas permanece na biblioteca.", + "Keep it on a drive with room: smart previews are about 1 MB per photo.": "Guarde em um disco com espaço: as pré-visualizações inteligentes ocupam cerca de 1 MB por foto.", "Keyboard Shortcuts": "Atalhos de teclado", "Keyboard shortcuts": "Atalhos de teclado", "Keyword": "Palavra-chave", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index f54f5d39e..cdc3916b0 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -579,7 +579,7 @@ "Join the ArtCraft Discord": "Присоединяйтесь к Discord ArtCraft", "Join the ArtCraft Discord…": "Присоединиться к Discord ArtCraft…", "Keep in Local": "Оставить в «Локальном»", - "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Храните их на диске со свободным местом: смарт-просмотр занимает около 1 МБ на фото. Кэш миниатюр остаётся в библиотеке.", + "Keep it on a drive with room: smart previews are about 1 MB per photo.": "Храните их на диске со свободным местом: смарт-просмотр занимает около 1 МБ на фото.", "Keyboard Shortcuts": "Сочетания клавиш", "Keyboard shortcuts": "Сочетания клавиш", "Keyword": "Ключевое слово", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 7428263d0..3af81795e 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -996,7 +996,7 @@ "Info overlay": "Накладання інформації", "Into one folder": "До однієї теки", "Keep in Local": "Залишити в локальних", - "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Зберігайте на диску з вільним місцем: смарт-перегляд займає приблизно 1 МБ на фото. Кеш мініатюр залишається в бібліотеці.", + "Keep it on a drive with room: smart previews are about 1 MB per photo.": "Зберігайте на диску з вільним місцем: смарт-перегляд займає приблизно 1 МБ на фото.", "Keyboard shortcuts": "Клавіатурні скорочення", "Keyword": "Ключове слово", "Keyword Painter": "Призначення ключових слів", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index b6c14c053..1ae7b8ae7 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -805,7 +805,7 @@ "Join the ArtCraft Discord": "加入 ArtCraft Discord", "Join the ArtCraft Discord…": "加入 ArtCraft Discord…", "Keep in Local": "保留在本地", - "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "请选择空间充足的驱动器:智能预览每张照片约 1 MB。缩略图缓存仍保留在图库中。", + "Keep it on a drive with room: smart previews are about 1 MB per photo.": "请选择空间充足的驱动器:智能预览每张照片约 1 MB。", "Keyboard Shortcuts": "键盘快捷键", "Keyboard shortcuts": "键盘快捷键", "Keyword": "关键字", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 13b876c83..c922b3437 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -805,7 +805,7 @@ "Join the ArtCraft Discord": "加入 ArtCraft Discord", "Join the ArtCraft Discord…": "加入 ArtCraft Discord…", "Keep in Local": "保留在本機", - "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "請放在空間充足的磁碟:每張智慧型預覽約 1 MB。縮圖快取仍保留在照片庫中。", + "Keep it on a drive with room: smart previews are about 1 MB per photo.": "請放在空間充足的磁碟:每張智慧型預覽約 1 MB。", "Keyboard Shortcuts": "鍵盤快速鍵", "Keyboard shortcuts": "鍵盤快速鍵", "Keyword": "關鍵字", diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index f559babd3..d16ff1172 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -562,6 +562,8 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let _ = app.run("library.clearPreviews", json!({})); } }); + #[cfg(not(target_arch = "wasm32"))] + thumb_cache_folder(app, ui, t); heading(ui, t, crate::i18n::tr("Local folders")); row(ui, t, crate::i18n::tr("Forget unchanged photos"), |ui| { let opts = [(0u32, "Never"), (7, "After 7 days"), (30, "After 30 days"), (90, "After 90 days"), (365, "After a year")]; @@ -581,6 +583,87 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { smart_previews(app, ui, t); } +/// Where this library keeps its thumbnail cache (issue #903): the effective folder, and choosing +/// another one, like the smart previews folder below. +#[cfg(not(target_arch = "wasm32"))] +fn thumb_cache_folder(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + // listing a big cache every frame would be slow: refresh every 2 s and after a change + let cache = egui::Id::new("thumbs-location"); + let now = ui.input(|i| i.time); + let loc: Value = match ui.data(|d| d.get_temp::<(f64, Value)>(cache)) { + Some((at, v)) if now - at < 2.0 => v, + _ => { + let v = app.run("library.thumbnailCacheLocation", json!({})).unwrap_or(Value::Null); + ui.data_mut(|d| d.insert_temp(cache, (now, v.clone()))); + v + } + }; + if loc.is_null() { + return; + } + let path = loc["path"].as_str().unwrap_or_default().to_string(); + let available = loc["available"].as_bool().unwrap_or(true); + let custom = loc["custom"].as_bool().unwrap_or(false); + row(ui, t, crate::i18n::tr("Folder"), |ui| { + let text = format!("{path}{}", if custom { "" } else { " (library default)" }); + ui.add(egui::Label::new(RichText::new(text.clone()).color(if available { t.text } else { t.reject })).truncate()).on_hover_text(text); + }); + if !available { + hint( + ui, + t, + crate::i18n::tr( + "This folder is not available (drive disconnected?). Thumbnails are kept in memory only until it is back or you choose another folder.", + ), + ); + } + // what happens to the thumbnails already cached when the folder changes + let mode_id = egui::Id::new("thumbs-existing"); + let mut mode: u8 = ui.data(|d| d.get_temp(mode_id)).unwrap_or(0); + if loc["count"].as_u64().unwrap_or(0) > 0 { + row(ui, t, crate::i18n::tr("Existing thumbnails"), |ui| { + if choices(ui, "settingsThumbsExisting", &[(0u8, "Move them"), (1, "Leave them"), (2, "Delete them")], &mut mode) { + ui.data_mut(|d| d.insert_temp(mode_id, mode)); + } + }); + } + let existing = ["move", "leave", "discard"][mode.min(2) as usize]; + let mut result = None; + ui.horizontal(|ui| { + ui.add_space(LABEL_W); + if app.services.pick_folder.is_some() { + let r = ui.button(crate::i18n::tr("Choose Folder…")); + register(ui.ctx(), "button:settingsThumbsChoose", r.rect); + if r.clicked() + && let Some(dir) = app.services.pick_folder.as_mut().and_then(|f| f()) + { + result = Some(app.run("library.thumbnailCacheLocation", json!({"path": dir, "existing": existing}))); + } + } + let r = ui.add_enabled(custom, egui::Button::new(crate::i18n::tr("Use Library Folder"))); + register(ui.ctx(), "button:settingsThumbsReset", r.rect); + if r.clicked() { + result = Some(app.run("library.thumbnailCacheLocation", json!({"reset": true, "existing": existing}))); + } + }); + if let Some(r) = result { + ui.data_mut(|d| d.remove::<(f64, Value)>(cache)); + match r { + Ok(v) => { + let failed = v["failed"].as_array().map_or(0, Vec::len); + let msg = match (existing, v["handled"].as_u64().unwrap_or(0)) { + ("move", n) if n > 0 => format!("Thumbnail cache folder changed; moved {n}"), + ("discard", n) if n > 0 => format!("Thumbnail cache folder changed; deleted {n}"), + _ => "Thumbnail cache folder changed".to_string(), + }; + app.toast(ui.ctx(), if failed > 0 { crate::i18n::tr_format!("{msg} ({failed} failed)", failed = failed, msg = msg) } else { msg }); + } + Err(e) => app.toast(ui.ctx(), e), + } + } + hint(ui, t, crate::i18n::tr("A fast local drive keeps the grid quick when the library itself is on a network or slow drive.")); +} + /// Where this library keeps its smart previews (the offline-editing proxies, which can be large): /// the effective folder, what is in it, and choosing another one. #[cfg(not(target_arch = "wasm32"))] @@ -675,7 +758,7 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { Err(e) => app.toast(ui.ctx(), e), } } - hint(ui, t, crate::i18n::tr("Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.")); + hint(ui, t, crate::i18n::tr("Keep it on a drive with room: smart previews are about 1 MB per photo.")); } // ---------------------------------------------------------------------------------- Interface diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 3c436b35d..4305fb640 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -424,7 +424,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Display / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; a damaged settings file is kept as `.corrupt-