Fix thumbnail lifecycle failures

This commit is contained in:
emptyname committed 2026-08-21 12:32:31 +01:00
1 parent b3e6af1ae5
commit 53caf63ad4
10 files changed
+718 -193

No files matched your search

+25
View File
@@ -19,6 +19,7 @@
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt; use std::fmt;
use std::io::{Read, Seek, SeekFrom}; use std::io::{Read, Seek, SeekFrom};
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -415,6 +416,10 @@ pub struct ContentCache {
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out /// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
/// of `content_dir` so the budget scan never sees thumbnail files. /// of `content_dir` so the budget scan never sees thumbnail files.
thumb_dir: PathBuf, thumb_dir: PathBuf,
/// Owner-protected scratch space for decrypted camera RAWs passed to
/// exiftool. Files are removed on drop and swept on the next cache open after
/// an unclean shutdown.
raw_thumb_dir: PathBuf,
/// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` + /// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` +
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never /// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
/// sees them; blocks carry their own LRU budget. /// sees them; blocks carry their own LRU budget.
@@ -507,6 +512,19 @@ impl ContentCache {
std::fs::create_dir_all(&content_dir)?; std::fs::create_dir_all(&content_dir)?;
let thumb_dir = content_dir.join("thumbs"); let thumb_dir = content_dir.join("thumbs");
std::fs::create_dir_all(&thumb_dir)?; std::fs::create_dir_all(&thumb_dir)?;
let raw_thumb_dir = content_dir.join("raw-thumbnails");
std::fs::create_dir_all(&raw_thumb_dir)?;
std::fs::set_permissions(&raw_thumb_dir, std::fs::Permissions::from_mode(0o700))?;
if let Ok(entries) = std::fs::read_dir(&raw_thumb_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
if name.to_string_lossy().starts_with("pdfs-raw-")
&& entry.file_type().is_ok_and(|kind| kind.is_file())
{
let _ = std::fs::remove_file(entry.path());
}
}
}
let block_dir = content_dir.join("blocks"); let block_dir = content_dir.join("blocks");
std::fs::create_dir_all(&block_dir)?; std::fs::create_dir_all(&block_dir)?;
// Scratch holds disk-backed write buffers. A previous run's leftovers are // Scratch holds disk-backed write buffers. A previous run's leftovers are
@@ -530,6 +548,7 @@ impl ContentCache {
let cache = Self { let cache = Self {
content_dir, content_dir,
thumb_dir, thumb_dir,
raw_thumb_dir,
block_dir, block_dir,
scratch_dir, scratch_dir,
staging_dir, staging_dir,
@@ -1666,6 +1685,12 @@ impl ContentCache {
.join(format!("{}.t{ttype}.meta", Self::key(uid))) .join(format!("{}.t{ttype}.meta", Self::key(uid)))
} }
/// Owner-protected transient directory used to stage decrypted camera RAWs
/// for metadata helpers. Callers still remove individual files promptly.
pub fn raw_thumbnail_staging_dir(&self) -> &Path {
&self.raw_thumb_dir
}
/// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale. /// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale.
/// Validated by node `mtime` alone: a new revision (which is what changes the /// Validated by node `mtime` alone: a new revision (which is what changes the
/// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails /// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails
+41 -50
View File
@@ -104,6 +104,10 @@ pub enum Request {
#[serde(default)] #[serde(default)]
generation: u64, generation: u64,
}, },
/// Reserve a daemon-issued generation for ordinary-file thumbnail work.
/// The daemon is the ordering authority, so GUI restarts and wall-clock
/// corrections cannot make a new listing look older than an earlier one.
ReserveFileThumbGeneration,
/// Stop ordinary-file thumbnail work belonging to older listings. Photos /// Stop ordinary-file thumbnail work belonging to older listings. Photos
/// timeline work and an explicit recursive build are deliberately separate. /// timeline work and an explicit recursive build are deliberately separate.
CancelFileThumbs { generation: u64 }, CancelFileThumbs { generation: u64 },
@@ -111,6 +115,8 @@ pub enum Request {
/// The request returns immediately; progress is read with /// The request returns immediately; progress is read with
/// [`Request::ThumbnailBuildStatus`]. /// [`Request::ThumbnailBuildStatus`].
StartThumbnailBuild { path: String }, StartThumbnailBuild { path: String },
/// Stop the current recursive ordinary-file thumbnail build, if any.
CancelThumbnailBuild,
/// Read progress for the recursive ordinary-file thumbnail build. /// Read progress for the recursive ordinary-file thumbnail build.
ThumbnailBuildStatus, ThumbnailBuildStatus,
/// Download a photo's full content into the cache; replies with its path. /// Download a photo's full content into the cache; replies with its path.
@@ -1232,35 +1238,9 @@ pub fn is_thumbnail_image_name(name: &str) -> bool {
let Some((_, extension)) = name.rsplit_once('.') else { let Some((_, extension)) = name.rsplit_once('.') else {
return false; return false;
}; };
matches!( let extension = extension.to_ascii_lowercase();
extension.to_ascii_lowercase().as_str(), STANDARD_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
"bmp" || RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
| "gif"
| "jpeg"
| "jpg"
| "png"
| "tif"
| "tiff"
| "webp"
| "arw"
| "cr2"
| "cr3"
| "crw"
| "dng"
| "k25"
| "kdc"
| "mrw"
| "nef"
| "nrw"
| "orf"
| "pef"
| "raf"
| "rw"
| "rw2"
| "sr2"
| "srf"
| "x3f"
)
} }
/// Whether `name` is one of the concrete RAW formats handled by the exiftool /// Whether `name` is one of the concrete RAW formats handled by the exiftool
@@ -1270,29 +1250,21 @@ pub fn is_raw_image_name(name: &str) -> bool {
let Some((_, extension)) = name.rsplit_once('.') else { let Some((_, extension)) = name.rsplit_once('.') else {
return false; return false;
}; };
matches!( let extension = extension.to_ascii_lowercase();
extension.to_ascii_lowercase().as_str(), RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
"arw"
| "cr2"
| "cr3"
| "crw"
| "dng"
| "k25"
| "kdc"
| "mrw"
| "nef"
| "nrw"
| "orf"
| "pef"
| "raf"
| "rw"
| "rw2"
| "sr2"
| "srf"
| "x3f"
)
} }
const STANDARD_THUMBNAIL_EXTENSIONS: &[&str] =
&["bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "webp"];
/// Concrete camera RAW formats with embedded previews handled by exiftool.
/// This is the single source used by both RAW classification and the broader
/// thumbnail capability check.
const RAW_THUMBNAIL_EXTENSIONS: &[&str] = &[
"arw", "cr2", "cr3", "crw", "dng", "k25", "kdc", "mrw", "nef", "nrw", "orf", "pef", "raf",
"raw", "rw2", "sr2", "srf", "x3f",
];
/// Progress of the one recursive ordinary-file thumbnail build the daemon may /// Progress of the one recursive ordinary-file thumbnail build the daemon may
/// run at a time. /// run at a time.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
@@ -1467,6 +1439,13 @@ pub enum Response {
PhotoMonths { months: Vec<PhotoMonth> }, PhotoMonths { months: Vec<PhotoMonth> },
/// Thumbnails for a [`Request::PhotoThumbs`] batch. /// Thumbnails for a [`Request::PhotoThumbs`] batch.
Thumbs { items: Vec<PhotoThumb> }, Thumbs { items: Vec<PhotoThumb> },
/// A [`Request::FileThumbs`] generation is no longer current. This is
/// retryable after reserving a fresh generation and must never be interpreted
/// as a permanent "no thumbnail" verdict.
FileThumbsStale,
/// Daemon-issued generation reserved by
/// [`Request::ReserveFileThumbGeneration`].
FileThumbGeneration { generation: u64 },
/// Current recursive thumbnail-build progress. /// Current recursive thumbnail-build progress.
ThumbnailBuild { status: ThumbnailBuildStatus }, ThumbnailBuild { status: ThumbnailBuildStatus },
/// An on-disk path the front-end can open (e.g. a downloaded photo). /// An on-disk path the front-end can open (e.g. a downloaded photo).
@@ -1801,10 +1780,12 @@ mod tests {
#[test] #[test]
fn thumbnail_build_requests_and_progress_roundtrip() { fn thumbnail_build_requests_and_progress_roundtrip() {
let requests = [ let requests = [
Request::ReserveFileThumbGeneration,
Request::CancelFileThumbs { generation: 43 }, Request::CancelFileThumbs { generation: 43 },
Request::StartThumbnailBuild { Request::StartThumbnailBuild {
path: "pictures/events".into(), path: "pictures/events".into(),
}, },
Request::CancelThumbnailBuild,
Request::ThumbnailBuildStatus, Request::ThumbnailBuildStatus,
]; ];
for request in requests { for request in requests {
@@ -1828,6 +1809,16 @@ mod tests {
let line = serde_json::to_string(&response).unwrap(); let line = serde_json::to_string(&response).unwrap();
let decoded: Response = serde_json::from_str(&line).unwrap(); let decoded: Response = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&decoded).unwrap()); assert_eq!(line, serde_json::to_string(&decoded).unwrap());
let response = Response::FileThumbGeneration { generation: 44 };
let line = serde_json::to_string(&response).unwrap();
let decoded: Response = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
let response = Response::FileThumbsStale;
let line = serde_json::to_string(&response).unwrap();
let decoded: Response = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
} }
#[test] #[test]
+14 -4
View File
@@ -329,8 +329,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
items: core.photo_thumbs(&parsed), items: core.photo_thumbs(&parsed),
} }
} }
Ok(CtlRequest::FileThumbs { items, generation }) => CtlResponse::Thumbs { Ok(CtlRequest::FileThumbs { items, generation }) => {
items: core.file_thumbs(&items, generation), match core.file_thumbs(&items, generation) {
Some(items) => CtlResponse::Thumbs { items },
None => CtlResponse::FileThumbsStale,
}
}
Ok(CtlRequest::ReserveFileThumbGeneration) => CtlResponse::FileThumbGeneration {
generation: core.reserve_file_thumb_generation(),
}, },
Ok(CtlRequest::CancelFileThumbs { generation }) => { Ok(CtlRequest::CancelFileThumbs { generation }) => {
core.cancel_file_thumbs(generation); core.cancel_file_thumbs(generation);
@@ -339,11 +345,15 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
} }
} }
Ok(CtlRequest::StartThumbnailBuild { path }) => match rel_to_mount(mountpoint, &path) { Ok(CtlRequest::StartThumbnailBuild { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => CtlResponse::ThumbnailBuild { Ok(rel) => match core.start_thumbnail_build(rel) {
status: core.start_thumbnail_build(rel), Ok(status) => CtlResponse::ThumbnailBuild { status },
Err(error) => CtlResponse::error(error),
}, },
Err(error) => CtlResponse::error(error), Err(error) => CtlResponse::error(error),
}, },
Ok(CtlRequest::CancelThumbnailBuild) => CtlResponse::ThumbnailBuild {
status: core.cancel_thumbnail_build(),
},
Ok(CtlRequest::ThumbnailBuildStatus) => CtlResponse::ThumbnailBuild { Ok(CtlRequest::ThumbnailBuildStatus) => CtlResponse::ThumbnailBuild {
status: core.thumbnail_build_status(), status: core.thumbnail_build_status(),
}, },
+80 -13
View File
@@ -374,6 +374,45 @@ impl SearchRoots {
} }
} }
/// Negative thumbnail knowledge has two different authorities. A remote miss
/// only says Proton has no thumbnail to download; it must never suppress local
/// generation. A local miss says this exact file revision was successfully
/// inspected and could not be decoded. Both maps are bounded independently.
#[derive(Default)]
struct ThumbnailMissCaches {
remote: HashMap<(NodeUid, i32), i64>,
local: HashMap<(NodeUid, i32), i64>,
}
impl ThumbnailMissCaches {
fn remote_contains(&self, key: &(NodeUid, i32), tag: i64) -> bool {
self.remote.get(key) == Some(&tag)
}
fn local_contains(&self, key: &(NodeUid, i32), tag: i64) -> bool {
self.local.get(key) == Some(&tag)
}
fn remember_remote(&mut self, key: (NodeUid, i32), tag: i64) {
Self::remember(&mut self.remote, key, tag);
}
fn remember_local(&mut self, key: (NodeUid, i32), tag: i64) {
Self::remember(&mut self.local, key, tag);
}
fn forget_local(&mut self, key: &(NodeUid, i32)) {
self.local.remove(key);
}
fn remember(map: &mut HashMap<(NodeUid, i32), i64>, key: (NodeUid, i32), tag: i64) {
if map.len() >= MAX_THUMBNAIL_MISSES && !map.contains_key(&key) {
map.clear();
}
map.insert(key, tag);
}
}
/// `path` expressed relative to `root`, or `None` if it is not below it. The /// `path` expressed relative to `root`, or `None` if it is not below it. The
/// root itself resolves to the empty path; an empty root is the mount itself /// root itself resolves to the empty path; an empty root is the mount itself
/// and covers everything. /// and covers everything.
@@ -521,8 +560,12 @@ struct Core {
file_thumb_cancel: Arc<tokio::sync::Notify>, file_thumb_cancel: Arc<tokio::sync::Notify>,
/// Progress of the explicit recursive build started from the Files toolbar. /// Progress of the explicit recursive build started from the Files toolbar.
thumbnail_build: Arc<Mutex<ThumbnailBuildStatus>>, thumbnail_build: Arc<Mutex<ThumbnailBuildStatus>>,
/// Nodes the remote has told us have *no* thumbnail of a given type, keyed by /// Cancellation for the explicit recursive build. Separate from ordinary
/// `(uid, thumbnail type)` and holding the mtime the answer was learned at. /// listing cancellation because the two jobs have independent lifetimes.
thumbnail_build_cancelled: Arc<AtomicBool>,
thumbnail_build_cancel: Arc<tokio::sync::Notify>,
/// Negative thumbnail knowledge, separated by whether the remote lacked a
/// thumbnail or local decoding proved this revision unsupported.
/// ///
/// Absence has to be cached or it costs a round trip every time it is asked /// Absence has to be cached or it costs a round trip every time it is asked
/// for, and it is asked for constantly: an `ls -l` from an xattr-aware lister /// for, and it is asked for constantly: an `ls -l` from an xattr-aware lister
@@ -530,7 +573,7 @@ struct Core {
/// of videos re-probed 130 times per listing at ~186 ms each (B5). The mtime /// of videos re-probed 130 times per listing at ~186 ms each (B5). The mtime
/// is the validity tag — a new revision may well have a thumbnail — matching /// is the validity tag — a new revision may well have a thumbnail — matching
/// how [`ContentCache::read_thumbnail`] validates the positive side. /// how [`ContentCache::read_thumbnail`] validates the positive side.
no_thumbnail: Arc<Mutex<HashMap<(NodeUid, i32), i64>>>, thumbnail_misses: Arc<Mutex<ThumbnailMissCaches>>,
/// Last account quota answer and when it was learned, for `statfs(2)`. /// Last account quota answer and when it was learned, for `statfs(2)`.
/// ///
/// `df` and the free-space preflight of every file manager and installer ask /// `df` and the free-space preflight of every file manager and installer ask
@@ -3590,7 +3633,7 @@ impl Core {
// "This file has no thumbnail" is an answer worth remembering: without // "This file has no thumbnail" is an answer worth remembering: without
// it every listing pays a round trip per file to be told nothing (B5). // it every listing pays a round trip per file to be told nothing (B5).
let key = (uid.clone(), ttype.as_i32()); let key = (uid.clone(), ttype.as_i32());
if self.no_thumbnail.lock().get(&key) == Some(&mtime) { if self.thumbnail_misses.lock().remote_contains(&key, mtime) {
return Ok(None); return Ok(None);
} }
let bytes = self let bytes = self
@@ -3607,15 +3650,7 @@ impl Core {
.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes); .store_thumbnail(&uid, ttype.as_i32(), mtime, bytes);
} }
None => { None => {
let mut misses = self.no_thumbnail.lock(); self.thumbnail_misses.lock().remember_remote(key, mtime);
// Bounded by clearing rather than by LRU: the entries are two
// words each, the cap is far above any plausible working set,
// and re-learning a miss costs one round trip. Not worth a
// second data structure to order them.
if misses.len() >= MAX_THUMBNAIL_MISSES {
misses.clear();
}
misses.insert(key, mtime);
} }
} }
Ok(bytes) Ok(bytes)
@@ -7556,6 +7591,38 @@ mod tests {
} }
} }
#[cfg(test)]
mod thumbnail_miss_tests {
use super::*;
#[test]
fn remote_misses_never_suppress_local_generation() {
let uid = parse_uid("volume~node").expect("uid");
let key = (uid, ThumbnailType::Thumbnail.as_i32());
let mut misses = ThumbnailMissCaches::default();
misses.remember_remote(key.clone(), 42);
assert!(misses.remote_contains(&key, 42));
assert!(!misses.local_contains(&key, 42));
misses.remember_local(key.clone(), 42);
assert!(misses.local_contains(&key, 42));
misses.forget_local(&key);
assert!(!misses.local_contains(&key, 42));
assert!(misses.remote_contains(&key, 42));
}
#[test]
fn local_miss_cache_is_bounded_on_gui_only_paths() {
let mut misses = ThumbnailMissCaches::default();
for index in 0..=MAX_THUMBNAIL_MISSES {
let uid = parse_uid(&format!("volume~node-{index}")).expect("uid");
misses.remember_local((uid, ThumbnailType::Thumbnail.as_i32()), 42);
}
assert_eq!(misses.local.len(), 1);
}
}
#[cfg(test)] #[cfg(test)]
mod search_root_tests { mod search_root_tests {
use super::*; use super::*;
+3 -1
View File
@@ -395,7 +395,9 @@ pub fn mount(
file_thumb_generation: Arc::new(AtomicU64::new(0)), file_thumb_generation: Arc::new(AtomicU64::new(0)),
file_thumb_cancel: Arc::new(tokio::sync::Notify::new()), file_thumb_cancel: Arc::new(tokio::sync::Notify::new()),
thumbnail_build: Arc::new(Mutex::new(Default::default())), thumbnail_build: Arc::new(Mutex::new(Default::default())),
no_thumbnail: Arc::new(Mutex::new(HashMap::new())), thumbnail_build_cancelled: Arc::new(AtomicBool::new(false)),
thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()),
thumbnail_misses: Arc::new(Mutex::new(Default::default())),
quota: Arc::new(Mutex::new(None)), quota: Arc::new(Mutex::new(None)),
size_upgrades: Arc::new(Mutex::new(HashMap::new())), size_upgrades: Arc::new(Mutex::new(HashMap::new())),
size_waiters: Arc::new(Default::default()), size_waiters: Arc::new(Default::default()),
+360 -86
View File
@@ -6,10 +6,11 @@
//! a phone) are generated locally and stored as if the server had served them. //! a phone) are generated locally and stored as if the server had served them.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::ffi::OsStr;
use std::fs::OpenOptions; use std::fs::OpenOptions;
use std::io::{Read as _, Write as _}; use std::io::{Read as _, Write as _};
use std::os::unix::fs::OpenOptionsExt as _; use std::os::unix::fs::OpenOptionsExt as _;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::Once; use std::sync::Once;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@@ -44,6 +45,11 @@ const THUMB_QUALITY: u8 = 82;
pub(crate) const THUMB_GEN_CONCURRENCY: usize = 4; pub(crate) const THUMB_GEN_CONCURRENCY: usize = 4;
/// Images processed between recursive-build progress updates. /// Images processed between recursive-build progress updates.
const THUMB_BUILD_CHUNK: usize = 16; const THUMB_BUILD_CHUNK: usize = 16;
/// A recursive build may wait briefly for an opportunistic tile job that
/// already owns a uid, but never forever.
const THUMB_BUILD_CLAIM_TIMEOUT: Duration = Duration::from_secs(30);
/// Backstop for cancellation if a notification races with waiter registration.
const THUMB_CANCEL_POLL: Duration = Duration::from_millis(100);
/// A broken metadata helper must not hold one of the four thumbnail permits /// A broken metadata helper must not hold one of the four thumbnail permits
/// indefinitely. Matches the deliberately generous KDE RAW thumbnail budget. /// indefinitely. Matches the deliberately generous KDE RAW thumbnail budget.
const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20); const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20);
@@ -68,6 +74,17 @@ struct GeneratedThumb {
ratio: f64, ratio: f64,
} }
/// Scaling distinguishes a permanent content verdict from an environmental
/// failure. Only `Undecodable` may enter the local negative cache.
enum ScaleAttempt {
Made(GeneratedThumb),
Undecodable,
Unavailable,
}
#[derive(Debug, Clone, Copy)]
struct RawPreviewUnavailable;
/// How one attempt at generating a missing thumbnail ended. The distinction that /// How one attempt at generating a missing thumbnail ended. The distinction that
/// matters is *permanent* versus *transient*: only bytes we cannot decode prove /// matters is *permanent* versus *transient*: only bytes we cannot decode prove
/// the photo will never have a thumbnail, and only that verdict is persisted. /// the photo will never have a thumbnail, and only that verdict is persisted.
@@ -109,13 +126,49 @@ impl ThumbBatchSummary {
} }
} }
/// `true` means publish a new build, `false` means the caller may attach to the
/// already-running build for the same root. A different root is never silently
/// accepted as if it were the requested job.
fn thumbnail_build_may_start(status: &ThumbnailBuildStatus, path: &str) -> CoreResult<bool> {
if !status.running {
return Ok(true);
}
if status.path == path {
return Ok(false);
}
Err(CoreError::invalid(format!(
"a thumbnail build is already running for {}",
if status.path.is_empty() {
"Proton Drive"
} else {
&status.path
}
)))
}
/// Scale a full-size photo down to a thumbnail: at most [`THUMB_EDGE`] on its /// Scale a full-size photo down to a thumbnail: at most [`THUMB_EDGE`] on its
/// longest side, JPEG, aspect ratio preserved. `None` when the bytes aren't an /// longest side, JPEG, aspect ratio preserved. Environmental RAW-extraction
/// image this build can decode — the caller then writes the photo off as /// failures remain retryable; only bytes conclusively outside the supported
/// un-thumbnailable. /// formats return an undecodable verdict.
/// ///
/// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool. /// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool.
fn scale_thumbnail(bytes: &[u8], name: &str) -> Option<GeneratedThumb> { fn scale_thumbnail(bytes: &[u8], name: &str, staging_dir: &Path) -> ScaleAttempt {
scale_thumbnail_with_exiftool(
bytes,
name,
staging_dir,
OsStr::new("exiftool"),
EXIFTOOL_TIMEOUT,
)
}
fn scale_thumbnail_with_exiftool(
bytes: &[u8],
name: &str,
staging_dir: &Path,
exiftool: &OsStr,
timeout: Duration,
) -> ScaleAttempt {
let direct = image::ImageReader::new(std::io::Cursor::new(bytes)) let direct = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format() .with_guessed_format()
.ok() .ok()
@@ -123,22 +176,29 @@ fn scale_thumbnail(bytes: &[u8], name: &str) -> Option<GeneratedThumb> {
let image = match direct { let image = match direct {
Some(image) => image, Some(image) => image,
None if is_raw_image_name(name) => { None if is_raw_image_name(name) => {
let (preview, orientation) = extract_raw_preview(bytes, name)?; let (preview, orientation) =
let mut image = image::ImageReader::new(std::io::Cursor::new(preview)) match extract_raw_preview(bytes, name, staging_dir, exiftool, timeout) {
Ok(Some(preview)) => preview,
Ok(None) => return ScaleAttempt::Undecodable,
Err(_) => return ScaleAttempt::Unavailable,
};
let Some(mut image) = image::ImageReader::new(std::io::Cursor::new(preview))
.with_guessed_format() .with_guessed_format()
.ok()? .ok()
.decode() .and_then(|reader| reader.decode().ok())
.ok()?; else {
return ScaleAttempt::Undecodable;
};
if let Some(orientation) = image::metadata::Orientation::from_exif(orientation) { if let Some(orientation) = image::metadata::Orientation::from_exif(orientation) {
image.apply_orientation(orientation); image.apply_orientation(orientation);
} }
image image
} }
None => return None, None => return ScaleAttempt::Undecodable,
}; };
let (width, height) = (image.width(), image.height()); let (width, height) = (image.width(), image.height());
if width == 0 || height == 0 { if width == 0 || height == 0 {
return None; return ScaleAttempt::Undecodable;
} }
let ratio = f64::from(width) / f64::from(height); let ratio = f64::from(width) / f64::from(height);
@@ -146,10 +206,13 @@ fn scale_thumbnail(bytes: &[u8], name: &str) -> Option<GeneratedThumb> {
// THUMB_EDGE and the ratio is untouched. // THUMB_EDGE and the ratio is untouched.
let thumb = image.thumbnail(THUMB_EDGE, THUMB_EDGE).to_rgb8(); let thumb = image.thumbnail(THUMB_EDGE, THUMB_EDGE).to_rgb8();
let mut bytes = Vec::new(); let mut bytes = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, THUMB_QUALITY) if image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, THUMB_QUALITY)
.encode_image(&thumb) .encode_image(&thumb)
.ok()?; .is_err()
Some(GeneratedThumb { bytes, ratio }) {
return ScaleAttempt::Unavailable;
}
ScaleAttempt::Made(GeneratedThumb { bytes, ratio })
} }
/// Temporary file passed to exiftool. The helper identifies most RAWs from their /// Temporary file passed to exiftool. The helper identifies most RAWs from their
@@ -158,7 +221,7 @@ fn scale_thumbnail(bytes: &[u8], name: &str) -> Option<GeneratedThumb> {
struct RawTempFile(PathBuf); struct RawTempFile(PathBuf);
impl RawTempFile { impl RawTempFile {
fn create(bytes: &[u8], name: &str) -> Option<Self> { fn create_in(staging_dir: &Path, bytes: &[u8], name: &str) -> Option<Self> {
let extension = std::path::Path::new(name) let extension = std::path::Path::new(name)
.extension() .extension()
.and_then(|value| value.to_str()) .and_then(|value| value.to_str())
@@ -174,7 +237,7 @@ impl RawTempFile {
.as_nanos(); .as_nanos();
for _ in 0..16 { for _ in 0..16 {
let nonce = RAW_TEMP_NONCE.fetch_add(1, Ordering::Relaxed); let nonce = RAW_TEMP_NONCE.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!( let path = staging_dir.join(format!(
"pdfs-raw-{}-{started}-{nonce}.{extension}", "pdfs-raw-{}-{started}-{nonce}.{extension}",
std::process::id(), std::process::id(),
)); ));
@@ -208,8 +271,13 @@ impl Drop for RawTempFile {
/// Run one bounded exiftool JSON query while draining stdout concurrently. The /// Run one bounded exiftool JSON query while draining stdout concurrently. The
/// latter matters for full-size `JpgFromRaw` fallbacks, whose base64 can exceed a /// latter matters for full-size `JpgFromRaw` fallbacks, whose base64 can exceed a
/// pipe buffer long before the child exits. /// pipe buffer long before the child exits.
fn exiftool_query(path: &std::path::Path, tags: &[&str]) -> Option<serde_json::Value> { fn exiftool_query(
let mut command = Command::new("exiftool"); exiftool: &OsStr,
path: &Path,
tags: &[&str],
timeout: Duration,
) -> Result<serde_json::Value, RawPreviewUnavailable> {
let mut command = Command::new(exiftool);
command.args(["-j", "-b", "-n"]); command.args(["-j", "-b", "-n"]);
command.args(tags); command.args(tags);
command command
@@ -227,10 +295,14 @@ fn exiftool_query(path: &std::path::Path, tags: &[&str]) -> Option<serde_json::V
); );
}); });
} }
return None; return Err(RawPreviewUnavailable);
} }
}; };
let mut stdout = child.stdout.take()?; let Some(mut stdout) = child.stdout.take() else {
let _ = child.kill();
let _ = child.wait();
return Err(RawPreviewUnavailable);
};
let reader = std::thread::spawn(move || { let reader = std::thread::spawn(move || {
let mut output = Vec::new(); let mut output = Vec::new();
stdout.read_to_end(&mut output).map(|_| output) stdout.read_to_end(&mut output).map(|_| output)
@@ -239,7 +311,7 @@ fn exiftool_query(path: &std::path::Path, tags: &[&str]) -> Option<serde_json::V
let success = loop { let success = loop {
match child.try_wait() { match child.try_wait() {
Ok(Some(status)) => break status.success(), Ok(Some(status)) => break status.success(),
Ok(None) if started.elapsed() < EXIFTOOL_TIMEOUT => { Ok(None) if started.elapsed() < timeout => {
std::thread::sleep(Duration::from_millis(10)); std::thread::sleep(Duration::from_millis(10));
} }
Ok(None) | Err(_) => { Ok(None) | Err(_) => {
@@ -249,11 +321,15 @@ fn exiftool_query(path: &std::path::Path, tags: &[&str]) -> Option<serde_json::V
} }
} }
}; };
let output = reader.join().ok()?.ok()?; let output = reader
.join()
.ok()
.and_then(Result::ok)
.ok_or(RawPreviewUnavailable)?;
if !success { if !success {
return None; return Err(RawPreviewUnavailable);
} }
serde_json::from_slice(&output).ok() serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable)
} }
fn exiftool_object( fn exiftool_object(
@@ -276,17 +352,28 @@ fn exiftool_binary(
/// Extract the embedded display preview from one concrete camera RAW. PreviewImage /// Extract the embedded display preview from one concrete camera RAW. PreviewImage
/// is queried alone first so common files do not also serialize a discarded /// is queried alone first so common files do not also serialize a discarded
/// full-resolution JpgFromRaw; rarer preview tags are requested only on a miss. /// full-resolution JpgFromRaw; rarer preview tags are requested only on a miss.
fn extract_raw_preview(bytes: &[u8], name: &str) -> Option<(Vec<u8>, u8)> { fn extract_raw_preview(
let file = RawTempFile::create(bytes, name)?; bytes: &[u8],
let primary = exiftool_query(&file.0, &["-Orientation", "-PreviewImage"])?; name: &str,
let primary = exiftool_object(&primary)?; staging_dir: &Path,
exiftool: &OsStr,
timeout: Duration,
) -> Result<Option<(Vec<u8>, u8)>, RawPreviewUnavailable> {
let file = RawTempFile::create_in(staging_dir, bytes, name).ok_or(RawPreviewUnavailable)?;
let primary = exiftool_query(
exiftool,
&file.0,
&["-Orientation", "-PreviewImage"],
timeout,
)?;
let primary = exiftool_object(&primary).ok_or(RawPreviewUnavailable)?;
let orientation = primary let orientation = primary
.get("Orientation") .get("Orientation")
.and_then(serde_json::Value::as_u64) .and_then(serde_json::Value::as_u64)
.and_then(|value| u8::try_from(value).ok()) .and_then(|value| u8::try_from(value).ok())
.unwrap_or(1); .unwrap_or(1);
if let Some(preview) = exiftool_binary(primary, "PreviewImage") { if let Some(preview) = exiftool_binary(primary, "PreviewImage") {
return Some((preview, orientation)); return Ok(Some((preview, orientation)));
} }
const FALLBACKS: [&str; 5] = [ const FALLBACKS: [&str; 5] = [
@@ -297,6 +384,7 @@ fn extract_raw_preview(bytes: &[u8], name: &str) -> Option<(Vec<u8>, u8)> {
"ThumbnailTIFF", "ThumbnailTIFF",
]; ];
let fallback = exiftool_query( let fallback = exiftool_query(
exiftool,
&file.0, &file.0,
&[ &[
"-OtherImage", "-OtherImage",
@@ -305,12 +393,13 @@ fn extract_raw_preview(bytes: &[u8], name: &str) -> Option<(Vec<u8>, u8)> {
"-ThumbnailImage", "-ThumbnailImage",
"-ThumbnailTIFF", "-ThumbnailTIFF",
], ],
timeout,
)?; )?;
let fallback = exiftool_object(&fallback)?; let fallback = exiftool_object(&fallback).ok_or(RawPreviewUnavailable)?;
FALLBACKS Ok(FALLBACKS
.iter() .iter()
.find_map(|key| exiftool_binary(fallback, key)) .find_map(|key| exiftool_binary(fallback, key))
.map(|preview| (preview, orientation)) .map(|preview| (preview, orientation)))
} }
/// Formats supported by the local thumbnail decoder and exposed by the GUI. /// Formats supported by the local thumbnail decoder and exposed by the GUI.
@@ -514,14 +603,12 @@ impl Core {
&self, &self,
items: &[FileThumbRequest], items: &[FileThumbRequest],
generation: u64, generation: u64,
) -> Vec<PhotoThumb> { ) -> Option<Vec<PhotoThumb>> {
let previous = self let current =
.file_thumb_generation generation != 0 && self.file_thumb_generation.load(Ordering::SeqCst) == generation;
.fetch_max(generation, Ordering::SeqCst); if !current {
if generation > previous { return None;
self.file_thumb_cancel.notify_waiters();
} }
let current = self.file_thumb_generation.load(Ordering::SeqCst) == generation;
let ttype = ThumbnailType::Thumbnail.as_i32(); let ttype = ThumbnailType::Thumbnail.as_i32();
let parsed: Vec<(String, NodeUid, i64)> = items let parsed: Vec<(String, NodeUid, i64)> = items
.iter() .iter()
@@ -549,7 +636,10 @@ impl Core {
.cache .cache
.cached_thumbnail_path(uid, ttype, *modified) .cached_thumbnail_path(uid, ttype, *modified)
.is_some() .is_some()
|| self.no_thumbnail.lock().get(&(uid.clone(), ttype)) == Some(modified) || self
.thumbnail_misses
.lock()
.local_contains(&(uid.clone(), ttype), *modified)
|| !seen.insert(raw.clone()) || !seen.insert(raw.clone())
{ {
continue; continue;
@@ -557,11 +647,12 @@ impl Core {
wanted.push(uid.clone()); wanted.push(uid.clone());
} }
if current && !wanted.is_empty() { if !wanted.is_empty() {
self.spawn_generate_thumbs(wanted, &tags, &names, ThumbJob::Files(generation)); self.spawn_generate_thumbs(wanted, &tags, &names, ThumbJob::Files(generation));
} }
let pending = self.thumb_gen.lock(); let pending = self.thumb_gen.lock();
Some(
items items
.iter() .iter()
.map(|item| { .map(|item| {
@@ -573,10 +664,27 @@ impl Core {
.cached_thumbnail_path(uid, ttype, item.modified) .cached_thumbnail_path(uid, ttype, item.modified)
.map(|path| path.display().to_string()) .map(|path| path.display().to_string())
}), }),
pending: current && uid.as_ref().is_some_and(|uid| pending.contains(uid)), pending: uid.as_ref().is_some_and(|uid| pending.contains(uid)),
} }
}) })
.collect() .collect(),
)
}
/// Reserve the next ordinary-file listing generation. This daemon-owned
/// monotonic value replaces GUI wall-clock seeds, which can move backwards
/// across NTP corrections, suspend, or dual boot.
pub(crate) fn reserve_file_thumb_generation(&self) -> u64 {
let next = |current| if current == u64::MAX { 1 } else { current + 1 };
let previous = self
.file_thumb_generation
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
Some(next(current))
})
.expect("generation update always succeeds");
let generation = next(previous);
self.file_thumb_cancel.notify_waiters();
generation
} }
/// Advance the ordinary-file thumbnail generation. Every queued/download in /// Advance the ordinary-file thumbnail generation. Every queued/download in
@@ -644,7 +752,8 @@ impl Core {
ThumbJob::Files(generation) => { ThumbJob::Files(generation) => {
self.file_thumb_generation.load(Ordering::SeqCst) == generation self.file_thumb_generation.load(Ordering::SeqCst) == generation
} }
ThumbJob::Photos | ThumbJob::Build => true, ThumbJob::Photos => true,
ThumbJob::Build => !self.thumbnail_build_cancelled.load(Ordering::SeqCst),
} }
} }
@@ -684,9 +793,29 @@ impl Core {
return (uid, ThumbAttempt::Cancelled); return (uid, ThumbAttempt::Cancelled);
} }
} }
_ = tokio::time::sleep(THUMB_CANCEL_POLL) => {
if !core.thumb_job_current(job) {
return (uid, ThumbAttempt::Cancelled);
}
}
} }
}, },
ThumbJob::Photos | ThumbJob::Build => acquire.await.ok(), ThumbJob::Build => loop {
tokio::select! {
permit = &mut acquire => break permit.ok(),
_ = core.thumbnail_build_cancel.notified() => {
if !core.thumb_job_current(job) {
return (uid, ThumbAttempt::Cancelled);
}
}
_ = tokio::time::sleep(THUMB_CANCEL_POLL) => {
if !core.thumb_job_current(job) {
return (uid, ThumbAttempt::Cancelled);
}
}
}
},
ThumbJob::Photos => acquire.await.ok(),
}; };
let Some(_permit) = _permit else { let Some(_permit) = _permit else {
return (uid, ThumbAttempt::Unavailable); return (uid, ThumbAttempt::Unavailable);
@@ -707,9 +836,29 @@ impl Core {
break None; break None;
} }
} }
_ = tokio::time::sleep(THUMB_CANCEL_POLL) => {
if !core.thumb_job_current(job) {
break None;
}
}
} }
}, },
ThumbJob::Photos | ThumbJob::Build => Some(download.await), ThumbJob::Build => loop {
tokio::select! {
result = &mut download => break Some(result),
_ = core.thumbnail_build_cancel.notified() => {
if !core.thumb_job_current(job) {
break None;
}
}
_ = tokio::time::sleep(THUMB_CANCEL_POLL) => {
if !core.thumb_job_current(job) {
break None;
}
}
}
},
ThumbJob::Photos => Some(download.await),
}; };
let Some(downloaded) = downloaded else { let Some(downloaded) = downloaded else {
return (uid, ThumbAttempt::Cancelled); return (uid, ThumbAttempt::Cancelled);
@@ -726,16 +875,19 @@ impl Core {
} }
// Decoding + scaling a 20 MP JPEG is CPU-bound and would // Decoding + scaling a 20 MP JPEG is CPU-bound and would
// stall the runtime's worker; hand it to the blocking pool. // stall the runtime's worker; hand it to the blocking pool.
let made = let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
tokio::task::spawn_blocking(move || scale_thumbnail(&bytes, &name)) let made = tokio::task::spawn_blocking(move || {
scale_thumbnail(&bytes, &name, &staging_dir)
})
.await .await
.unwrap_or(None); .unwrap_or(ScaleAttempt::Unavailable);
if !core.thumb_job_current(job) { if !core.thumb_job_current(job) {
return (uid, ThumbAttempt::Cancelled); return (uid, ThumbAttempt::Cancelled);
} }
match made { match made {
Some(thumb) => (uid, ThumbAttempt::Made(thumb)), ScaleAttempt::Made(thumb) => (uid, ThumbAttempt::Made(thumb)),
None => (uid, ThumbAttempt::Undecodable), ScaleAttempt::Undecodable => (uid, ThumbAttempt::Undecodable),
ScaleAttempt::Unavailable => (uid, ThumbAttempt::Unavailable),
} }
}); });
} }
@@ -760,7 +912,9 @@ impl Core {
match self.cache.store_thumbnail(&uid, ttype, tag, &thumb.bytes) { match self.cache.store_thumbnail(&uid, ttype, tag, &thumb.bytes) {
Ok(()) => { Ok(()) => {
summary.made += 1; summary.made += 1;
self.no_thumbnail.lock().remove(&(uid.clone(), ttype)); self.thumbnail_misses
.lock()
.forget_local(&(uid.clone(), ttype));
self.record_thumb(&uid, db::THUMB_HAVE, Some(thumb.ratio)); self.record_thumb(&uid, db::THUMB_HAVE, Some(thumb.ratio));
} }
Err(e) => { Err(e) => {
@@ -774,7 +928,9 @@ impl Core {
ThumbAttempt::Undecodable => { ThumbAttempt::Undecodable => {
summary.undecodable += 1; summary.undecodable += 1;
if let Some(&tag) = tags.get(&uid.to_string()) { if let Some(&tag) = tags.get(&uid.to_string()) {
self.no_thumbnail.lock().insert((uid.clone(), ttype), tag); self.thumbnail_misses
.lock()
.remember_local((uid.clone(), ttype), tag);
} }
self.record_thumb(&uid, db::THUMB_NONE, None); self.record_thumb(&uid, db::THUMB_NONE, None);
} }
@@ -792,20 +948,22 @@ impl Core {
self.thumbnail_build.lock().clone() self.thumbnail_build.lock().clone()
} }
/// Start one recursive local-thumbnail build. A second request while it is /// Start one recursive local-thumbnail build. A request for the same root
/// running attaches to the existing job instead of duplicating every /// may attach to it; a different root is rejected explicitly instead of
/// download; the caller polls [`Core::thumbnail_build_status`]. /// silently pretending the existing job belongs to the caller.
pub(crate) fn start_thumbnail_build(&self, root: PathBuf) -> ThumbnailBuildStatus { pub(crate) fn start_thumbnail_build(&self, root: PathBuf) -> CoreResult<ThumbnailBuildStatus> {
let path = root.to_string_lossy().into_owned(); let path = root.to_string_lossy().into_owned();
let status = { let status = {
let mut status = self.thumbnail_build.lock(); let mut status = self.thumbnail_build.lock();
if status.running { if !thumbnail_build_may_start(&status, &path)? {
return status.clone(); return Ok(status.clone());
} }
self.thumbnail_build_cancelled
.store(false, Ordering::SeqCst);
*status = ThumbnailBuildStatus { *status = ThumbnailBuildStatus {
running: true, running: true,
scanning: true, scanning: true,
path, path: path.clone(),
folders_scanned: 0, folders_scanned: 0,
images_found: 0, images_found: 0,
completed: 0, completed: 0,
@@ -826,18 +984,57 @@ impl Core {
status.running = false; status.running = false;
status.scanning = false; status.scanning = false;
status.message = Some("Thumbnail generation stopped unexpectedly".to_string()); status.message = Some("Thumbnail generation stopped unexpectedly".to_string());
core.thumbnail_build_cancelled
.store(false, Ordering::SeqCst);
warn!(path = %status.path, "recursive thumbnail build panicked"); warn!(path = %status.path, "recursive thumbnail build panicked");
} }
}); });
Ok(status)
}
pub(crate) fn cancel_thumbnail_build(&self) -> ThumbnailBuildStatus {
let (status, cancelled) = {
let status = self.thumbnail_build.lock();
let cancelled = status.running;
if cancelled {
self.thumbnail_build_cancelled.store(true, Ordering::SeqCst);
}
(status.clone(), cancelled)
};
if cancelled {
self.thumbnail_build_cancel.notify_waiters();
}
status status
} }
fn finish_thumbnail_build(&self) {
let mut status = self.thumbnail_build.lock();
let cancelled = self.thumbnail_build_cancelled.swap(false, Ordering::SeqCst);
status.running = false;
status.scanning = false;
if cancelled {
status.message = Some("Thumbnail build cancelled".to_string());
}
info!(
path = %status.path,
images = status.images_found,
failed = status.failed,
cancelled,
"recursive thumbnail build finished"
);
}
fn run_thumbnail_build(&self, root: PathBuf) { fn run_thumbnail_build(&self, root: PathBuf) {
let mut folders = vec![root]; let mut folders = vec![root];
let mut images: Vec<(NodeUid, i64, String)> = Vec::new(); let mut images: Vec<(NodeUid, i64, String)> = Vec::new();
let mut seen = HashSet::new(); let mut seen_folders = HashSet::new();
let mut seen_images = HashSet::new();
while let Some(folder) = folders.pop() { while let Some(folder) = folders.pop() {
if self.thumbnail_build_cancelled.load(Ordering::SeqCst) {
self.finish_thumbnail_build();
return;
}
let listing = self.list_dir(&folder); let listing = self.list_dir(&folder);
{ {
let mut status = self.thumbnail_build.lock(); let mut status = self.thumbnail_build.lock();
@@ -861,14 +1058,20 @@ impl Core {
let mut invalid_here = 0_u64; let mut invalid_here = 0_u64;
for entry in entries { for entry in entries {
if entry.is_dir { if entry.is_dir {
if seen.insert(entry.uid) { if seen_folders.insert(entry.uid) {
folders.push(folder.join(entry.name)); folders.push(folder.join(entry.name));
} }
} else if is_thumbnail_image_name(&entry.name) { } else if is_thumbnail_image_name(&entry.name) {
found_here += 1;
match parse_uid(&entry.uid) { match parse_uid(&entry.uid) {
Some(uid) => images.push((uid, entry.modified, entry.name)), Some(uid) if seen_images.insert(uid.clone()) => {
None => invalid_here += 1, found_here += 1;
images.push((uid, entry.modified, entry.name));
}
Some(_) => {}
None => {
found_here += 1;
invalid_here += 1;
}
} }
} }
} }
@@ -879,6 +1082,10 @@ impl Core {
} }
self.thumbnail_build.lock().scanning = false; self.thumbnail_build.lock().scanning = false;
if self.thumbnail_build_cancelled.load(Ordering::SeqCst) {
self.finish_thumbnail_build();
return;
}
let ttype = ThumbnailType::Thumbnail.as_i32(); let ttype = ThumbnailType::Thumbnail.as_i32();
let mut todo = Vec::new(); let mut todo = Vec::new();
for (uid, modified, name) in images { for (uid, modified, name) in images {
@@ -888,7 +1095,11 @@ impl Core {
.is_some() .is_some()
{ {
self.thumbnail_build.lock().completed += 1; self.thumbnail_build.lock().completed += 1;
} else if self.no_thumbnail.lock().get(&(uid.clone(), ttype)) == Some(&modified) { } else if self
.thumbnail_misses
.lock()
.local_contains(&(uid.clone(), ttype), modified)
{
let mut status = self.thumbnail_build.lock(); let mut status = self.thumbnail_build.lock();
status.completed += 1; status.completed += 1;
status.failed += 1; status.failed += 1;
@@ -898,6 +1109,10 @@ impl Core {
} }
for chunk in todo.chunks(THUMB_BUILD_CHUNK) { for chunk in todo.chunks(THUMB_BUILD_CHUNK) {
if self.thumbnail_build_cancelled.load(Ordering::SeqCst) {
self.finish_thumbnail_build();
return;
}
let mut claimed = Vec::with_capacity(chunk.len()); let mut claimed = Vec::with_capacity(chunk.len());
let mut tags = HashMap::with_capacity(chunk.len()); let mut tags = HashMap::with_capacity(chunk.len());
let mut names = HashMap::with_capacity(chunk.len()); let mut names = HashMap::with_capacity(chunk.len());
@@ -905,7 +1120,12 @@ impl Core {
// A visible tile may have started this image just before the // A visible tile may have started this image just before the
// toolbar action. Wait for that one bounded job to finish rather // toolbar action. Wait for that one bounded job to finish rather
// than downloading the full image twice. // than downloading the full image twice.
let waiting_since = Instant::now();
loop { loop {
if self.thumbnail_build_cancelled.load(Ordering::SeqCst) {
self.finish_thumbnail_build();
return;
}
if self if self
.cache .cache
.cached_thumbnail_path(uid, ttype, *modified) .cached_thumbnail_path(uid, ttype, *modified)
@@ -914,7 +1134,11 @@ impl Core {
self.thumbnail_build.lock().completed += 1; self.thumbnail_build.lock().completed += 1;
break; break;
} }
if self.no_thumbnail.lock().get(&(uid.clone(), ttype)) == Some(modified) { if self
.thumbnail_misses
.lock()
.local_contains(&(uid.clone(), ttype), *modified)
{
let mut status = self.thumbnail_build.lock(); let mut status = self.thumbnail_build.lock();
status.completed += 1; status.completed += 1;
status.failed += 1; status.failed += 1;
@@ -926,6 +1150,17 @@ impl Core {
claimed.push(uid.clone()); claimed.push(uid.clone());
break; break;
} }
if waiting_since.elapsed() >= THUMB_BUILD_CLAIM_TIMEOUT {
let mut status = self.thumbnail_build.lock();
status.completed += 1;
status.failed += 1;
if status.message.is_none() {
status.message = Some(
"Some images stayed busy and were skipped after a timeout".into(),
);
}
break;
}
std::thread::sleep(Duration::from_millis(50)); std::thread::sleep(Duration::from_millis(50));
} }
} }
@@ -955,16 +1190,7 @@ impl Core {
} }
} }
} }
self.finish_thumbnail_build();
let mut status = self.thumbnail_build.lock();
status.running = false;
status.scanning = false;
info!(
path = %status.path,
images = status.images_found,
failed = status.failed,
"recursive thumbnail build finished"
);
} }
/// Persist what a thumbnail attempt learned about a photo, against the /// Persist what a thumbnail attempt learned about a photo, against the
@@ -1092,9 +1318,14 @@ impl Core {
#[cfg(test)] #[cfg(test)]
mod thumb_tests { mod thumb_tests {
use super::{RawTempFile, THUMB_EDGE, exiftool_binary, ratio_of, scale_thumbnail}; use super::{
use pdfs_core::control::is_thumbnail_image_name; RawTempFile, ScaleAttempt, THUMB_EDGE, exiftool_binary, ratio_of, scale_thumbnail,
scale_thumbnail_with_exiftool, thumbnail_build_may_start,
};
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
use std::ffi::OsStr;
use std::os::unix::fs::PermissionsExt as _; use std::os::unix::fs::PermissionsExt as _;
use std::time::Duration;
/// A `width`×`height` JPEG, standing in for a camera photo the server never /// A `width`×`height` JPEG, standing in for a camera photo the server never
/// generated a thumbnail for. /// generated a thumbnail for.
@@ -1112,7 +1343,10 @@ mod thumb_tests {
#[test] #[test]
fn scaling_fits_the_long_edge_and_keeps_the_aspect_ratio() { fn scaling_fits_the_long_edge_and_keeps_the_aspect_ratio() {
let photo = jpeg(4000, 3000); let photo = jpeg(4000, 3000);
let thumb = scale_thumbnail(&photo, "photo.jpg").expect("a JPEG scales"); let ScaleAttempt::Made(thumb) = scale_thumbnail(&photo, "photo.jpg", &std::env::temp_dir())
else {
panic!("a JPEG scales");
};
let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes)) let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes))
.with_guessed_format() .with_guessed_format()
@@ -1130,7 +1364,11 @@ mod thumb_tests {
#[test] #[test]
fn a_portrait_photo_fits_its_long_edge_too() { fn a_portrait_photo_fits_its_long_edge_too() {
let thumb = scale_thumbnail(&jpeg(1000, 2000), "portrait.jpeg").expect("a JPEG scales"); let ScaleAttempt::Made(thumb) =
scale_thumbnail(&jpeg(1000, 2000), "portrait.jpeg", &std::env::temp_dir())
else {
panic!("a JPEG scales");
};
assert!(thumb.ratio < 1.0, "portrait stays portrait"); assert!(thumb.ratio < 1.0, "portrait stays portrait");
assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true)); assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true));
} }
@@ -1139,7 +1377,10 @@ mod thumb_tests {
fn undecodable_bytes_are_not_a_thumbnail() { fn undecodable_bytes_are_not_a_thumbnail() {
// What a photo in a format this build has no decoder for looks like: the // What a photo in a format this build has no decoder for looks like: the
// caller writes it off as un-thumbnailable rather than retrying forever. // caller writes it off as un-thumbnailable rather than retrying forever.
assert!(scale_thumbnail(b"not an image at all", "broken.jpg").is_none()); assert!(matches!(
scale_thumbnail(b"not an image at all", "broken.jpg", &std::env::temp_dir()),
ScaleAttempt::Undecodable
));
assert!(ratio_of(b"not an image at all").is_none()); assert!(ratio_of(b"not an image at all").is_none());
} }
@@ -1150,9 +1391,11 @@ mod thumb_tests {
#[test] #[test]
fn raw_staging_file_is_private_and_removed_on_drop() { fn raw_staging_file_is_private_and_removed_on_drop() {
let file = let staging_dir = std::env::temp_dir();
RawTempFile::create(b"private camera bytes", "shot.NEF").expect("temporary RAW file"); let file = RawTempFile::create_in(&staging_dir, b"private camera bytes", "shot.NEF")
.expect("temporary RAW file");
let path = file.0.clone(); let path = file.0.clone();
assert_eq!(path.parent(), Some(staging_dir.as_path()));
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600); assert_eq!(mode, 0o600);
assert_eq!( assert_eq!(
@@ -1172,7 +1415,10 @@ mod thumb_tests {
let path = std::path::PathBuf::from(path); let path = std::path::PathBuf::from(path);
let name = path.file_name().unwrap().to_string_lossy(); let name = path.file_name().unwrap().to_string_lossy();
let bytes = std::fs::read(&path).unwrap(); let bytes = std::fs::read(&path).unwrap();
let thumb = scale_thumbnail(&bytes, &name).expect("embedded RAW preview"); let ScaleAttempt::Made(thumb) = scale_thumbnail(&bytes, &name, &std::env::temp_dir())
else {
panic!("embedded RAW preview");
};
let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes)) let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes))
.with_guessed_format() .with_guessed_format()
.unwrap() .unwrap()
@@ -1192,6 +1438,7 @@ mod thumb_tests {
"camera.NEF", "camera.NEF",
"negative.cr3", "negative.cr3",
"sensor.orf", "sensor.orf",
"camera.raw",
] { ] {
assert!(is_thumbnail_image_name(name), "{name}"); assert!(is_thumbnail_image_name(name), "{name}");
} }
@@ -1203,6 +1450,7 @@ mod thumb_tests {
"unsupported.heic", "unsupported.heic",
"unsupported.avif", "unsupported.avif",
"vector.svg", "vector.svg",
"camera.rw",
] { ] {
assert!(!is_thumbnail_image_name(name), "{name}"); assert!(!is_thumbnail_image_name(name), "{name}");
} }
@@ -1224,4 +1472,30 @@ mod thumb_tests {
assert_eq!(exiftool_binary(object, "ThumbnailImage"), None); assert_eq!(exiftool_binary(object, "ThumbnailImage"), None);
assert_eq!(exiftool_binary(object, "Missing"), None); assert_eq!(exiftool_binary(object, "Missing"), None);
} }
#[test]
fn missing_exiftool_is_retryable_not_a_permanent_raw_miss() {
let attempt = scale_thumbnail_with_exiftool(
b"camera bytes that require an embedded preview",
"shot.raw",
&std::env::temp_dir(),
OsStr::new("pdfs-exiftool-deliberately-absent"),
Duration::from_millis(10),
);
assert!(matches!(attempt, ScaleAttempt::Unavailable));
}
#[test]
fn a_running_build_only_accepts_the_same_root() {
let status = ThumbnailBuildStatus {
running: true,
path: "pictures/first".into(),
..Default::default()
};
assert!(!thumbnail_build_may_start(&status, "pictures/first").unwrap());
assert!(thumbnail_build_may_start(&status, "pictures/second").is_err());
let idle = ThumbnailBuildStatus::default();
assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap());
}
} }
+1
View File
@@ -407,6 +407,7 @@ fn build_window(app: &adw::Application) {
thumbnail_status: browser_widgets.thumbnail_status.clone(), thumbnail_status: browser_widgets.thumbnail_status.clone(),
thumbnail_poll: RefCell::new(None), thumbnail_poll: RefCell::new(None),
thumbnail_build_running: Cell::new(false), thumbnail_build_running: Cell::new(false),
thumbnail_cancel_pending: Cell::new(false),
search_source: RefCell::new(None), search_source: RefCell::new(None),
load_generation: Cell::new(0), load_generation: Cell::new(0),
views: browser_widgets.views.clone(), views: browser_widgets.views.clone(),
+88 -17
View File
@@ -35,6 +35,7 @@ pub(crate) struct BrowserState {
pub(crate) thumbnail_status: gtk4::Label, pub(crate) thumbnail_status: gtk4::Label,
pub(crate) thumbnail_poll: RefCell<Option<glib::SourceId>>, pub(crate) thumbnail_poll: RefCell<Option<glib::SourceId>>,
pub(crate) thumbnail_build_running: Cell<bool>, pub(crate) thumbnail_build_running: Cell<bool>,
pub(crate) thumbnail_cancel_pending: Cell<bool>,
/// Pending debounce timer for the search box; replaced on every keystroke so /// Pending debounce timer for the search box; replaced on every keystroke so
/// only the last pause actually fires a [`Request::Search`]. /// only the last pause actually fires a [`Request::Search`].
pub(crate) search_source: RefCell<Option<glib::SourceId>>, pub(crate) search_source: RefCell<Option<glib::SourceId>>,
@@ -1469,7 +1470,13 @@ pub(crate) fn wire_browser_actions(
let ui_uf = ui.clone(); let ui_uf = ui.clone();
upload_folder.connect_clicked(move |_| prompt_upload_folder(&ui_uf)); upload_folder.connect_clicked(move |_| prompt_upload_folder(&ui_uf));
let ui_thumbs = ui.clone(); let ui_thumbs = ui.clone();
build_thumbnails.connect_clicked(move |_| start_thumbnail_build(&ui_thumbs)); build_thumbnails.connect_clicked(move |_| {
if ui_thumbs.browser.thumbnail_build_running.get() {
cancel_thumbnail_build(&ui_thumbs);
} else {
start_thumbnail_build(&ui_thumbs);
}
});
} }
const THUMBNAIL_BUILD_POLL: Duration = Duration::from_millis(500); const THUMBNAIL_BUILD_POLL: Duration = Duration::from_millis(500);
@@ -1488,7 +1495,10 @@ fn start_thumbnail_build(ui: &Rc<Ui>) {
} }
cancel_file_thumbnails(ui); cancel_file_thumbnails(ui);
ui.browser.thumbnail_build_running.set(true); ui.browser.thumbnail_build_running.set(true);
ui.browser.build_thumbnails.set_sensitive(false); // Do not expose Cancel until the daemon has acknowledged Start; the two
// requests use separate control connections and could otherwise reorder.
ui.browser.thumbnail_cancel_pending.set(true);
repaint_thumbnail_build_action(ui, true);
ui.browser.thumbnail_build_row.set_visible(true); ui.browser.thumbnail_build_row.set_visible(true);
ui.browser.thumbnail_progress.set_fraction(0.0); ui.browser.thumbnail_progress.set_fraction(0.0);
ui.browser ui.browser
@@ -1504,21 +1514,61 @@ fn start_thumbnail_build(ui: &Rc<Ui>) {
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
match rx.recv().await { match rx.recv().await {
Ok(Ok(Response::ThumbnailBuild { status })) => { Ok(Ok(Response::ThumbnailBuild { status })) => {
ui.browser.thumbnail_cancel_pending.set(false);
repaint_thumbnail_build(&ui, &status); repaint_thumbnail_build(&ui, &status);
} }
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
thumbnail_build_failed(&ui, &message); thumbnail_build_failed(&ui);
toast_failure(&ui, "Couldn't build thumbnails", &message, kind); toast_failure(&ui, "Couldn't build thumbnails", &message, kind);
} }
_ => { _ => {
let message = "The mount service didn't respond."; let message = "The mount service didn't respond.";
thumbnail_build_failed(&ui, message); thumbnail_build_failed(&ui);
toast_error(&ui, "Couldn't build thumbnails", message); toast_error(&ui, "Couldn't build thumbnails", message);
} }
} }
}); });
} }
fn cancel_thumbnail_build(ui: &Rc<Ui>) {
if ui.browser.thumbnail_cancel_pending.replace(true) {
return;
}
ui.browser.build_thumbnails.set_sensitive(false);
ui.browser
.thumbnail_status
.set_label("Stopping thumbnail build…");
let rx = spawn_request(ui.dirs.control_socket(), Request::CancelThumbnailBuild);
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::ThumbnailBuild { status })) if !status.running => {
repaint_thumbnail_build(&ui, &status)
}
Ok(Ok(Response::ThumbnailBuild { .. })) => schedule_thumbnail_build_poll(&ui),
Ok(Ok(Response::Error { message, kind })) => {
thumbnail_cancel_failed(&ui);
toast_failure(&ui, "Couldn't cancel thumbnail build", &message, kind);
}
_ => {
thumbnail_cancel_failed(&ui);
toast_error(
&ui,
"Couldn't cancel thumbnail build",
"The mount service didn't respond.",
);
}
}
});
}
fn thumbnail_cancel_failed(ui: &Rc<Ui>) {
ui.browser.thumbnail_cancel_pending.set(false);
ui.browser.thumbnail_build_running.set(true);
repaint_thumbnail_build_action(ui, true);
schedule_thumbnail_build_poll(ui);
}
fn schedule_thumbnail_build_poll(ui: &Rc<Ui>) { fn schedule_thumbnail_build_poll(ui: &Rc<Ui>) {
if let Some(source) = ui.browser.thumbnail_poll.borrow_mut().take() { if let Some(source) = ui.browser.thumbnail_poll.borrow_mut().take() {
source.remove(); source.remove();
@@ -1534,12 +1584,17 @@ fn schedule_thumbnail_build_poll(ui: &Rc<Ui>) {
repaint_thumbnail_build(&ui_result, &status) repaint_thumbnail_build(&ui_result, &status)
} }
Ok(Ok(Response::Error { message, .. })) => { Ok(Ok(Response::Error { message, .. })) => {
thumbnail_build_failed(&ui_result, &message) thumbnail_build_failed(&ui_result);
toast_error(&ui_result, "Thumbnail build stopped", &message);
} }
_ => thumbnail_build_failed( _ => {
thumbnail_build_failed(&ui_result);
toast_error(
&ui_result, &ui_result,
"Thumbnail build stopped",
"The mount service stopped reporting thumbnail progress.", "The mount service stopped reporting thumbnail progress.",
), );
}
} }
}); });
}); });
@@ -1593,13 +1648,12 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
if status.running { if status.running {
ui.browser.thumbnail_build_running.set(true); ui.browser.thumbnail_build_running.set(true);
ui.browser.build_thumbnails.set_sensitive(false); repaint_thumbnail_build_action(ui, true);
schedule_thumbnail_build_poll(ui); schedule_thumbnail_build_poll(ui);
} else { } else {
ui.browser.thumbnail_build_running.set(false); ui.browser.thumbnail_build_running.set(false);
ui.browser ui.browser.thumbnail_cancel_pending.set(false);
.build_thumbnails repaint_thumbnail_build_action(ui, false);
.set_sensitive(*ui.mounted.borrow());
if ui.stack.visible_child_name().as_deref() == Some("browser") { if ui.stack.visible_child_name().as_deref() == Some("browser") {
reload_listing(ui); reload_listing(ui);
} }
@@ -1612,15 +1666,32 @@ fn show_thumbnail_build_progress(status: &ThumbnailBuildStatus) -> bool {
status.running status.running
} }
fn thumbnail_build_failed(ui: &Rc<Ui>, message: &str) { fn repaint_thumbnail_build_action(ui: &Rc<Ui>, running: bool) {
ui.browser.thumbnail_build_running.set(false); ui.browser.build_thumbnails.set_icon_name(if running {
"process-stop-symbolic"
} else {
"pdfs-build-thumbnails-symbolic"
});
ui.browser ui.browser
.build_thumbnails .build_thumbnails
.set_sensitive(*ui.mounted.borrow()); .set_tooltip_text(Some(if running {
ui.browser.thumbnail_build_row.set_visible(true); "Cancel thumbnail build"
} else {
"Build thumbnails in this folder and its subfolders"
}));
ui.browser.build_thumbnails.set_sensitive(
*ui.mounted.borrow() && (!running || !ui.browser.thumbnail_cancel_pending.get()),
);
}
fn thumbnail_build_failed(ui: &Rc<Ui>) {
ui.browser.thumbnail_build_running.set(false);
ui.browser.thumbnail_cancel_pending.set(false);
repaint_thumbnail_build_action(ui, false);
ui.browser.thumbnail_build_row.set_visible(false);
ui.browser.thumbnail_progress.set_fraction(0.0); ui.browser.thumbnail_progress.set_fraction(0.0);
ui.browser.thumbnail_status.set_label(message); ui.browser.thumbnail_status.set_label("");
ui.browser.thumbnail_status.set_tooltip_text(Some(message)); ui.browser.thumbnail_status.set_tooltip_text(None);
} }
/// Send a mutating request (rename / move / delete / mkdir / upload, or a trash /// Send a mutating request (rename / move / delete / mkdir / upload, or a trash
+5 -3
View File
@@ -683,9 +683,11 @@ pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
ui.browser.new_folder.set_sensitive(mounted); ui.browser.new_folder.set_sensitive(mounted);
ui.browser.upload.set_sensitive(mounted); ui.browser.upload.set_sensitive(mounted);
ui.browser.upload_folder.set_sensitive(mounted); ui.browser.upload_folder.set_sensitive(mounted);
ui.browser ui.browser.build_thumbnails.set_sensitive(
.build_thumbnails mounted
.set_sensitive(mounted && !ui.browser.thumbnail_build_running.get()); && (!ui.browser.thumbnail_build_running.get()
|| !ui.browser.thumbnail_cancel_pending.get()),
);
ui.gallery.upload.set_sensitive(mounted); ui.gallery.upload.set_sensitive(mounted);
ui.details.details.pin_row.set_sensitive(mounted); ui.details.details.pin_row.set_sensitive(mounted);
ui.details.details.rename_button.set_sensitive(mounted); ui.details.details.rename_button.set_sensitive(mounted);
+101 -19
View File
@@ -7,7 +7,6 @@
use crate::*; use crate::*;
use pdfs_core::control::{FileThumbRequest, is_thumbnail_image_name}; use pdfs_core::control::{FileThumbRequest, is_thumbnail_image_name};
use std::time::{SystemTime, UNIX_EPOCH};
const THUMB_BATCH: usize = 32; const THUMB_BATCH: usize = 32;
const THUMB_CACHE_MAX: usize = 256; const THUMB_CACHE_MAX: usize = 256;
@@ -25,6 +24,27 @@ pub(crate) struct FileThumbnailState {
inflight: Cell<bool>, inflight: Cell<bool>,
source: RefCell<Option<glib::SourceId>>, source: RefCell<Option<glib::SourceId>>,
generation: Cell<u64>, generation: Cell<u64>,
generation_loading: Cell<bool>,
}
#[derive(Debug, PartialEq, Eq)]
enum ReplyDisposition {
Ignore,
Load,
Retry,
Missing,
}
fn reply_disposition(wanted: bool, has_path: bool, pending: bool) -> ReplyDisposition {
if !wanted {
ReplyDisposition::Ignore
} else if has_path {
ReplyDisposition::Load
} else if pending {
ReplyDisposition::Retry
} else {
ReplyDisposition::Missing
}
} }
impl FileThumbnailState { impl FileThumbnailState {
@@ -38,13 +58,8 @@ impl FileThumbnailState {
queue: RefCell::new(VecDeque::new()), queue: RefCell::new(VecDeque::new()),
inflight: Cell::new(false), inflight: Cell::new(false),
source: RefCell::new(None), source: RefCell::new(None),
generation: Cell::new( generation: Cell::new(0),
SystemTime::now() generation_loading: Cell::new(false),
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.min(u128::from(u64::MAX)) as u64,
),
} }
} }
@@ -84,14 +99,17 @@ pub(crate) fn cancel_file_thumbnails(ui: &Rc<Ui>) {
} }
ui.file_thumbs.queue.borrow_mut().clear(); ui.file_thumbs.queue.borrow_mut().clear();
ui.file_thumbs.wanted.borrow_mut().clear(); ui.file_thumbs.wanted.borrow_mut().clear();
let generation = ui.file_thumbs.generation.get().saturating_add(1); let generation = ui.file_thumbs.generation.replace(0);
ui.file_thumbs.generation.set(generation);
// The request is useful even when its reply is not. `spawn_request` owns the // The request is useful even when its reply is not. `spawn_request` owns the
// socket round-trip on a worker thread, so dropping the receiver is safe. // socket round-trip on a worker thread, so dropping the receiver is safe.
if generation != 0 {
drop(spawn_request( drop(spawn_request(
ui.dirs.control_socket(), ui.dirs.control_socket(),
Request::CancelFileThumbs { generation }, Request::CancelFileThumbs {
generation: generation.saturating_add(1),
},
)); ));
}
} }
/// A square thumbnail surface with a generic file icon underneath the picture. /// A square thumbnail surface with a generic file icon underneath the picture.
@@ -244,6 +262,10 @@ fn schedule_file_thumbs(ui: &Rc<Ui>) {
if ui.file_thumbs.queue.borrow().is_empty() || ui.file_thumbs.inflight.get() { if ui.file_thumbs.queue.borrow().is_empty() || ui.file_thumbs.inflight.get() {
return; return;
} }
if ui.file_thumbs.generation.get() == 0 {
reserve_file_thumb_generation(ui);
return;
}
if let Some(source) = ui.file_thumbs.source.borrow_mut().take() { if let Some(source) = ui.file_thumbs.source.borrow_mut().take() {
source.remove(); source.remove();
} }
@@ -255,6 +277,40 @@ fn schedule_file_thumbs(ui: &Rc<Ui>) {
*ui.file_thumbs.source.borrow_mut() = Some(source); *ui.file_thumbs.source.borrow_mut() = Some(source);
} }
fn reserve_file_thumb_generation(ui: &Rc<Ui>) {
if ui.file_thumbs.generation_loading.replace(true) {
return;
}
let rx = spawn_request(
ui.dirs.control_socket(),
Request::ReserveFileThumbGeneration,
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.file_thumbs.generation_loading.set(false);
match result {
Ok(Ok(Response::FileThumbGeneration { generation })) if generation != 0 => {
ui.file_thumbs.generation.set(generation);
schedule_file_thumbs(&ui);
}
_ => retry_file_thumb_generation(&ui),
}
});
}
fn retry_file_thumb_generation(ui: &Rc<Ui>) {
if let Some(source) = ui.file_thumbs.source.borrow_mut().take() {
source.remove();
}
let ui_retry = ui.clone();
let source = glib::timeout_add_local_once(THUMB_RETRY, move || {
ui_retry.file_thumbs.source.borrow_mut().take();
schedule_file_thumbs(&ui_retry);
});
*ui.file_thumbs.source.borrow_mut() = Some(source);
}
fn flush_file_thumbs(ui: &Rc<Ui>) { fn flush_file_thumbs(ui: &Rc<Ui>) {
if ui.file_thumbs.inflight.get() { if ui.file_thumbs.inflight.get() {
return; return;
@@ -291,19 +347,23 @@ fn flush_file_thumbs(ui: &Rc<Ui>) {
ui.file_thumbs.inflight.set(false); ui.file_thumbs.inflight.set(false);
match result { match result {
Ok(Ok(Response::Thumbs { items: replies })) => { Ok(Ok(Response::Thumbs { items: replies })) => {
let requested: HashMap<String, FileThumbRequest> = items let mut requested: HashMap<String, FileThumbRequest> = items
.iter() .iter()
.cloned() .cloned()
.map(|item| (item.uid.clone(), item)) .map(|item| (item.uid.clone(), item))
.collect(); .collect();
let mut pending = Vec::new(); let mut pending = Vec::new();
for reply in replies { for reply in replies {
let Some(request) = requested.get(&reply.uid) else { let Some(request) = requested.remove(&reply.uid) else {
continue; continue;
}; };
let key = thumbnail_key(&request.uid, request.modified); let key = thumbnail_key(&request.uid, request.modified);
match reply.path { let wanted = ui.file_thumbs.wanted.borrow().contains_key(&key);
Some(path) => match gtk4::gdk::Texture::from_filename(&path) { match reply_disposition(wanted, reply.path.is_some(), reply.pending) {
ReplyDisposition::Ignore => continue,
ReplyDisposition::Load => {
let Some(path) = reply.path else { continue };
match gtk4::gdk::Texture::from_filename(&path) {
Ok(texture) => { Ok(texture) => {
ui.file_thumbs.store_texture(&key, texture.clone()); ui.file_thumbs.store_texture(&key, texture.clone());
if let Some(pictures) = if let Some(pictures) =
@@ -319,23 +379,31 @@ fn flush_file_thumbs(ui: &Rc<Ui>) {
ui.file_thumbs.store_missing(key.clone()); ui.file_thumbs.store_missing(key.clone());
ui.file_thumbs.wanted.borrow_mut().remove(&key); ui.file_thumbs.wanted.borrow_mut().remove(&key);
} }
}, }
None if reply.pending => pending.push(request.clone()), }
None => { ReplyDisposition::Retry => pending.push(request),
ReplyDisposition::Missing => {
ui.file_thumbs.store_missing(key.clone()); ui.file_thumbs.store_missing(key.clone());
ui.file_thumbs.wanted.borrow_mut().remove(&key); ui.file_thumbs.wanted.borrow_mut().remove(&key);
} }
} }
} }
pending.extend(requested.into_values());
if !pending.is_empty() { if !pending.is_empty() {
retry_file_thumbs(&ui, pending); retry_file_thumbs(&ui, pending);
} }
} }
Ok(Ok(Response::FileThumbsStale)) => {
ui.file_thumbs.generation.set(0);
retry_file_thumbs(&ui, items);
}
Ok(Ok(Response::Error { message, .. })) => { Ok(Ok(Response::Error { message, .. })) => {
tracing::debug!("file thumbnails failed: {message}"); tracing::debug!("file thumbnails failed: {message}");
retry_file_thumbs(&ui, items);
} }
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => { Ok(Ok(_)) | Ok(Err(_)) | Err(_) => {
tracing::debug!("file thumbnails: no reply"); tracing::debug!("file thumbnails: no reply");
retry_file_thumbs(&ui, items);
} }
} }
schedule_file_thumbs(&ui); schedule_file_thumbs(&ui);
@@ -358,7 +426,7 @@ fn retry_file_thumbs(ui: &Rc<Ui>, items: Vec<FileThumbRequest>) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::is_image_name; use super::{ReplyDisposition, is_image_name, reply_disposition};
#[test] #[test]
fn image_extensions_are_case_insensitive_and_specific() { fn image_extensions_are_case_insensitive_and_specific() {
@@ -369,6 +437,7 @@ mod tests {
"archive.tiff", "archive.tiff",
"camera.nef", "camera.nef",
"negative.CR3", "negative.CR3",
"camera.RAW",
] { ] {
assert!(is_image_name(name), "{name}"); assert!(is_image_name(name), "{name}");
} }
@@ -380,8 +449,21 @@ mod tests {
"camera.heic", "camera.heic",
"frame.avif", "frame.avif",
"graphic.svg", "graphic.svg",
"camera.rw",
] { ] {
assert!(!is_image_name(name), "{name}"); assert!(!is_image_name(name), "{name}");
} }
} }
#[test]
fn cancelled_listing_replies_cannot_become_permanent_misses() {
assert_eq!(
reply_disposition(false, false, false),
ReplyDisposition::Ignore
);
assert_eq!(
reply_disposition(true, false, false),
ReplyDisposition::Missing
);
}
} }