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::fmt;
use std::io::{Read, Seek, SeekFrom};
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -415,6 +416,10 @@ pub struct ContentCache {
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
/// of `content_dir` so the budget scan never sees thumbnail files.
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>` +
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
/// sees them; blocks carry their own LRU budget.
@@ -507,6 +512,19 @@ impl ContentCache {
std::fs::create_dir_all(&content_dir)?;
let thumb_dir = content_dir.join("thumbs");
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");
std::fs::create_dir_all(&block_dir)?;
// Scratch holds disk-backed write buffers. A previous run's leftovers are
@@ -530,6 +548,7 @@ impl ContentCache {
let cache = Self {
content_dir,
thumb_dir,
raw_thumb_dir,
block_dir,
scratch_dir,
staging_dir,
@@ -1666,6 +1685,12 @@ impl ContentCache {
.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.
/// Validated by node `mtime` alone: a new revision (which is what changes the
/// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails
+41 -50
View File
@@ -104,6 +104,10 @@ pub enum Request {
#[serde(default)]
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
/// timeline work and an explicit recursive build are deliberately separate.
CancelFileThumbs { generation: u64 },
@@ -111,6 +115,8 @@ pub enum Request {
/// The request returns immediately; progress is read with
/// [`Request::ThumbnailBuildStatus`].
StartThumbnailBuild { path: String },
/// Stop the current recursive ordinary-file thumbnail build, if any.
CancelThumbnailBuild,
/// Read progress for the recursive ordinary-file thumbnail build.
ThumbnailBuildStatus,
/// 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 {
return false;
};
matches!(
extension.to_ascii_lowercase().as_str(),
"bmp"
| "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"
)
let extension = extension.to_ascii_lowercase();
STANDARD_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
|| RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
}
/// 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 {
return false;
};
matches!(
extension.to_ascii_lowercase().as_str(),
"arw"
| "cr2"
| "cr3"
| "crw"
| "dng"
| "k25"
| "kdc"
| "mrw"
| "nef"
| "nrw"
| "orf"
| "pef"
| "raf"
| "rw"
| "rw2"
| "sr2"
| "srf"
| "x3f"
)
let extension = extension.to_ascii_lowercase();
RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
}
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
/// run at a time.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
@@ -1467,6 +1439,13 @@ pub enum Response {
PhotoMonths { months: Vec<PhotoMonth> },
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
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.
ThumbnailBuild { status: ThumbnailBuildStatus },
/// An on-disk path the front-end can open (e.g. a downloaded photo).
@@ -1801,10 +1780,12 @@ mod tests {
#[test]
fn thumbnail_build_requests_and_progress_roundtrip() {
let requests = [
Request::ReserveFileThumbGeneration,
Request::CancelFileThumbs { generation: 43 },
Request::StartThumbnailBuild {
path: "pictures/events".into(),
},
Request::CancelThumbnailBuild,
Request::ThumbnailBuildStatus,
];
for request in requests {
@@ -1828,6 +1809,16 @@ mod tests {
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::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]
+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),
}
}
Ok(CtlRequest::FileThumbs { items, generation }) => CtlResponse::Thumbs {
items: core.file_thumbs(&items, generation),
Ok(CtlRequest::FileThumbs { 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 }) => {
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(rel) => CtlResponse::ThumbnailBuild {
status: core.start_thumbnail_build(rel),
Ok(rel) => match core.start_thumbnail_build(rel) {
Ok(status) => CtlResponse::ThumbnailBuild { status },
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 {
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
/// root itself resolves to the empty path; an empty root is the mount itself
/// and covers everything.
@@ -521,8 +560,12 @@ struct Core {
file_thumb_cancel: Arc<tokio::sync::Notify>,
/// Progress of the explicit recursive build started from the Files toolbar.
thumbnail_build: Arc<Mutex<ThumbnailBuildStatus>>,
/// Nodes the remote has told us have *no* thumbnail of a given type, keyed by
/// `(uid, thumbnail type)` and holding the mtime the answer was learned at.
/// Cancellation for the explicit recursive build. Separate from ordinary
/// 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
/// 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
/// is the validity tag — a new revision may well have a thumbnail — matching
/// 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)`.
///
/// `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
// it every listing pays a round trip per file to be told nothing (B5).
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);
}
let bytes = self
@@ -3607,15 +3650,7 @@ impl Core {
.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes);
}
None => {
let mut misses = self.no_thumbnail.lock();
// 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);
self.thumbnail_misses.lock().remember_remote(key, mtime);
}
}
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)]
mod search_root_tests {
use super::*;
+3 -1
View File
@@ -395,7 +395,9 @@ pub fn mount(
file_thumb_generation: Arc::new(AtomicU64::new(0)),
file_thumb_cancel: Arc::new(tokio::sync::Notify::new()),
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)),
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
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.
use std::collections::{HashMap, HashSet};
use std::ffi::OsStr;
use std::fs::OpenOptions;
use std::io::{Read as _, Write as _};
use std::os::unix::fs::OpenOptionsExt as _;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Once;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -44,6 +45,11 @@ const THUMB_QUALITY: u8 = 82;
pub(crate) const THUMB_GEN_CONCURRENCY: usize = 4;
/// Images processed between recursive-build progress updates.
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
/// indefinitely. Matches the deliberately generous KDE RAW thumbnail budget.
const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20);
@@ -68,6 +74,17 @@ struct GeneratedThumb {
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
/// matters is *permanent* versus *transient*: only bytes we cannot decode prove
/// 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
/// longest side, JPEG, aspect ratio preserved. `None` when the bytes aren't an
/// image this build can decode — the caller then writes the photo off as
/// un-thumbnailable.
/// longest side, JPEG, aspect ratio preserved. Environmental RAW-extraction
/// failures remain retryable; only bytes conclusively outside the supported
/// formats return an undecodable verdict.
///
/// 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))
.with_guessed_format()
.ok()
@@ -123,22 +176,29 @@ fn scale_thumbnail(bytes: &[u8], name: &str) -> Option<GeneratedThumb> {
let image = match direct {
Some(image) => image,
None if is_raw_image_name(name) => {
let (preview, orientation) = extract_raw_preview(bytes, name)?;
let mut image = image::ImageReader::new(std::io::Cursor::new(preview))
let (preview, orientation) =
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()
.ok()?
.decode()
.ok()?;
.ok()
.and_then(|reader| reader.decode().ok())
else {
return ScaleAttempt::Undecodable;
};
if let Some(orientation) = image::metadata::Orientation::from_exif(orientation) {
image.apply_orientation(orientation);
}
image
}
None => return None,
None => return ScaleAttempt::Undecodable,
};
let (width, height) = (image.width(), image.height());
if width == 0 || height == 0 {
return None;
return ScaleAttempt::Undecodable;
}
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.
let thumb = image.thumbnail(THUMB_EDGE, THUMB_EDGE).to_rgb8();
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)
.ok()?;
Some(GeneratedThumb { bytes, ratio })
.is_err()
{
return ScaleAttempt::Unavailable;
}
ScaleAttempt::Made(GeneratedThumb { bytes, ratio })
}
/// 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);
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)
.extension()
.and_then(|value| value.to_str())
@@ -174,7 +237,7 @@ impl RawTempFile {
.as_nanos();
for _ in 0..16 {
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}",
std::process::id(),
));
@@ -208,8 +271,13 @@ impl Drop for RawTempFile {
/// Run one bounded exiftool JSON query while draining stdout concurrently. The
/// latter matters for full-size `JpgFromRaw` fallbacks, whose base64 can exceed a
/// pipe buffer long before the child exits.
fn exiftool_query(path: &std::path::Path, tags: &[&str]) -> Option<serde_json::Value> {
let mut command = Command::new("exiftool");
fn exiftool_query(
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(tags);
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 mut output = Vec::new();
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 {
match child.try_wait() {
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));
}
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 {
return None;
return Err(RawPreviewUnavailable);
}
serde_json::from_slice(&output).ok()
serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable)
}
fn exiftool_object(
@@ -276,17 +352,28 @@ fn exiftool_binary(
/// Extract the embedded display preview from one concrete camera RAW. PreviewImage
/// 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.
fn extract_raw_preview(bytes: &[u8], name: &str) -> Option<(Vec<u8>, u8)> {
let file = RawTempFile::create(bytes, name)?;
let primary = exiftool_query(&file.0, &["-Orientation", "-PreviewImage"])?;
let primary = exiftool_object(&primary)?;
fn extract_raw_preview(
bytes: &[u8],
name: &str,
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
.get("Orientation")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u8::try_from(value).ok())
.unwrap_or(1);
if let Some(preview) = exiftool_binary(primary, "PreviewImage") {
return Some((preview, orientation));
return Ok(Some((preview, orientation)));
}
const FALLBACKS: [&str; 5] = [
@@ -297,6 +384,7 @@ fn extract_raw_preview(bytes: &[u8], name: &str) -> Option<(Vec<u8>, u8)> {
"ThumbnailTIFF",
];
let fallback = exiftool_query(
exiftool,
&file.0,
&[
"-OtherImage",
@@ -305,12 +393,13 @@ fn extract_raw_preview(bytes: &[u8], name: &str) -> Option<(Vec<u8>, u8)> {
"-ThumbnailImage",
"-ThumbnailTIFF",
],
timeout,
)?;
let fallback = exiftool_object(&fallback)?;
FALLBACKS
let fallback = exiftool_object(&fallback).ok_or(RawPreviewUnavailable)?;
Ok(FALLBACKS
.iter()
.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.
@@ -514,14 +603,12 @@ impl Core {
&self,
items: &[FileThumbRequest],
generation: u64,
) -> Vec<PhotoThumb> {
let previous = self
.file_thumb_generation
.fetch_max(generation, Ordering::SeqCst);
if generation > previous {
self.file_thumb_cancel.notify_waiters();
) -> Option<Vec<PhotoThumb>> {
let current =
generation != 0 && self.file_thumb_generation.load(Ordering::SeqCst) == generation;
if !current {
return None;
}
let current = self.file_thumb_generation.load(Ordering::SeqCst) == generation;
let ttype = ThumbnailType::Thumbnail.as_i32();
let parsed: Vec<(String, NodeUid, i64)> = items
.iter()
@@ -549,7 +636,10 @@ impl Core {
.cache
.cached_thumbnail_path(uid, ttype, *modified)
.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())
{
continue;
@@ -557,11 +647,12 @@ impl Core {
wanted.push(uid.clone());
}
if current && !wanted.is_empty() {
if !wanted.is_empty() {
self.spawn_generate_thumbs(wanted, &tags, &names, ThumbJob::Files(generation));
}
let pending = self.thumb_gen.lock();
Some(
items
.iter()
.map(|item| {
@@ -573,10 +664,27 @@ impl Core {
.cached_thumbnail_path(uid, ttype, item.modified)
.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
@@ -644,7 +752,8 @@ impl Core {
ThumbJob::Files(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);
}
}
_ = 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 {
return (uid, ThumbAttempt::Unavailable);
@@ -707,9 +836,29 @@ impl Core {
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 {
return (uid, ThumbAttempt::Cancelled);
@@ -726,16 +875,19 @@ impl Core {
}
// Decoding + scaling a 20 MP JPEG is CPU-bound and would
// stall the runtime's worker; hand it to the blocking pool.
let made =
tokio::task::spawn_blocking(move || scale_thumbnail(&bytes, &name))
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
let made = tokio::task::spawn_blocking(move || {
scale_thumbnail(&bytes, &name, &staging_dir)
})
.await
.unwrap_or(None);
.unwrap_or(ScaleAttempt::Unavailable);
if !core.thumb_job_current(job) {
return (uid, ThumbAttempt::Cancelled);
}
match made {
Some(thumb) => (uid, ThumbAttempt::Made(thumb)),
None => (uid, ThumbAttempt::Undecodable),
ScaleAttempt::Made(thumb) => (uid, ThumbAttempt::Made(thumb)),
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) {
Ok(()) => {
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));
}
Err(e) => {
@@ -774,7 +928,9 @@ impl Core {
ThumbAttempt::Undecodable => {
summary.undecodable += 1;
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);
}
@@ -792,20 +948,22 @@ impl Core {
self.thumbnail_build.lock().clone()
}
/// Start one recursive local-thumbnail build. A second request while it is
/// running attaches to the existing job instead of duplicating every
/// download; the caller polls [`Core::thumbnail_build_status`].
pub(crate) fn start_thumbnail_build(&self, root: PathBuf) -> ThumbnailBuildStatus {
/// Start one recursive local-thumbnail build. A request for the same root
/// may attach to it; a different root is rejected explicitly instead of
/// silently pretending the existing job belongs to the caller.
pub(crate) fn start_thumbnail_build(&self, root: PathBuf) -> CoreResult<ThumbnailBuildStatus> {
let path = root.to_string_lossy().into_owned();
let status = {
let mut status = self.thumbnail_build.lock();
if status.running {
return status.clone();
if !thumbnail_build_may_start(&status, &path)? {
return Ok(status.clone());
}
self.thumbnail_build_cancelled
.store(false, Ordering::SeqCst);
*status = ThumbnailBuildStatus {
running: true,
scanning: true,
path,
path: path.clone(),
folders_scanned: 0,
images_found: 0,
completed: 0,
@@ -826,18 +984,57 @@ impl Core {
status.running = false;
status.scanning = false;
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");
}
});
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
}
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) {
let mut folders = vec![root];
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() {
if self.thumbnail_build_cancelled.load(Ordering::SeqCst) {
self.finish_thumbnail_build();
return;
}
let listing = self.list_dir(&folder);
{
let mut status = self.thumbnail_build.lock();
@@ -861,14 +1058,20 @@ impl Core {
let mut invalid_here = 0_u64;
for entry in entries {
if entry.is_dir {
if seen.insert(entry.uid) {
if seen_folders.insert(entry.uid) {
folders.push(folder.join(entry.name));
}
} else if is_thumbnail_image_name(&entry.name) {
found_here += 1;
match parse_uid(&entry.uid) {
Some(uid) => images.push((uid, entry.modified, entry.name)),
None => invalid_here += 1,
Some(uid) if seen_images.insert(uid.clone()) => {
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;
if self.thumbnail_build_cancelled.load(Ordering::SeqCst) {
self.finish_thumbnail_build();
return;
}
let ttype = ThumbnailType::Thumbnail.as_i32();
let mut todo = Vec::new();
for (uid, modified, name) in images {
@@ -888,7 +1095,11 @@ impl Core {
.is_some()
{
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();
status.completed += 1;
status.failed += 1;
@@ -898,6 +1109,10 @@ impl Core {
}
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 tags = 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
// toolbar action. Wait for that one bounded job to finish rather
// than downloading the full image twice.
let waiting_since = Instant::now();
loop {
if self.thumbnail_build_cancelled.load(Ordering::SeqCst) {
self.finish_thumbnail_build();
return;
}
if self
.cache
.cached_thumbnail_path(uid, ttype, *modified)
@@ -914,7 +1134,11 @@ impl Core {
self.thumbnail_build.lock().completed += 1;
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();
status.completed += 1;
status.failed += 1;
@@ -926,6 +1150,17 @@ impl Core {
claimed.push(uid.clone());
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));
}
}
@@ -955,16 +1190,7 @@ impl Core {
}
}
}
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"
);
self.finish_thumbnail_build();
}
/// Persist what a thumbnail attempt learned about a photo, against the
@@ -1092,9 +1318,14 @@ impl Core {
#[cfg(test)]
mod thumb_tests {
use super::{RawTempFile, THUMB_EDGE, exiftool_binary, ratio_of, scale_thumbnail};
use pdfs_core::control::is_thumbnail_image_name;
use super::{
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::time::Duration;
/// A `width`×`height` JPEG, standing in for a camera photo the server never
/// generated a thumbnail for.
@@ -1112,7 +1343,10 @@ mod thumb_tests {
#[test]
fn scaling_fits_the_long_edge_and_keeps_the_aspect_ratio() {
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))
.with_guessed_format()
@@ -1130,7 +1364,11 @@ mod thumb_tests {
#[test]
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_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() {
// 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.
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());
}
@@ -1150,9 +1391,11 @@ mod thumb_tests {
#[test]
fn raw_staging_file_is_private_and_removed_on_drop() {
let file =
RawTempFile::create(b"private camera bytes", "shot.NEF").expect("temporary RAW file");
let staging_dir = std::env::temp_dir();
let file = RawTempFile::create_in(&staging_dir, b"private camera bytes", "shot.NEF")
.expect("temporary RAW file");
let path = file.0.clone();
assert_eq!(path.parent(), Some(staging_dir.as_path()));
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
assert_eq!(
@@ -1172,7 +1415,10 @@ mod thumb_tests {
let path = std::path::PathBuf::from(path);
let name = path.file_name().unwrap().to_string_lossy();
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))
.with_guessed_format()
.unwrap()
@@ -1192,6 +1438,7 @@ mod thumb_tests {
"camera.NEF",
"negative.cr3",
"sensor.orf",
"camera.raw",
] {
assert!(is_thumbnail_image_name(name), "{name}");
}
@@ -1203,6 +1450,7 @@ mod thumb_tests {
"unsupported.heic",
"unsupported.avif",
"vector.svg",
"camera.rw",
] {
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, "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_poll: RefCell::new(None),
thumbnail_build_running: Cell::new(false),
thumbnail_cancel_pending: Cell::new(false),
search_source: RefCell::new(None),
load_generation: Cell::new(0),
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_poll: RefCell<Option<glib::SourceId>>,
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
/// only the last pause actually fires a [`Request::Search`].
pub(crate) search_source: RefCell<Option<glib::SourceId>>,
@@ -1469,7 +1470,13 @@ pub(crate) fn wire_browser_actions(
let ui_uf = ui.clone();
upload_folder.connect_clicked(move |_| prompt_upload_folder(&ui_uf));
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);
@@ -1488,7 +1495,10 @@ fn start_thumbnail_build(ui: &Rc<Ui>) {
}
cancel_file_thumbnails(ui);
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_progress.set_fraction(0.0);
ui.browser
@@ -1504,21 +1514,61 @@ fn start_thumbnail_build(ui: &Rc<Ui>) {
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::ThumbnailBuild { status })) => {
ui.browser.thumbnail_cancel_pending.set(false);
repaint_thumbnail_build(&ui, &status);
}
Ok(Ok(Response::Error { message, kind })) => {
thumbnail_build_failed(&ui, &message);
thumbnail_build_failed(&ui);
toast_failure(&ui, "Couldn't build thumbnails", &message, kind);
}
_ => {
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);
}
}
});
}
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>) {
if let Some(source) = ui.browser.thumbnail_poll.borrow_mut().take() {
source.remove();
@@ -1534,12 +1584,17 @@ fn schedule_thumbnail_build_poll(ui: &Rc<Ui>) {
repaint_thumbnail_build(&ui_result, &status)
}
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,
"Thumbnail build stopped",
"The mount service stopped reporting thumbnail progress.",
),
);
}
}
});
});
@@ -1593,13 +1648,12 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
if status.running {
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);
} else {
ui.browser.thumbnail_build_running.set(false);
ui.browser
.build_thumbnails
.set_sensitive(*ui.mounted.borrow());
ui.browser.thumbnail_cancel_pending.set(false);
repaint_thumbnail_build_action(ui, false);
if ui.stack.visible_child_name().as_deref() == Some("browser") {
reload_listing(ui);
}
@@ -1612,15 +1666,32 @@ fn show_thumbnail_build_progress(status: &ThumbnailBuildStatus) -> bool {
status.running
}
fn thumbnail_build_failed(ui: &Rc<Ui>, message: &str) {
ui.browser.thumbnail_build_running.set(false);
fn repaint_thumbnail_build_action(ui: &Rc<Ui>, running: bool) {
ui.browser.build_thumbnails.set_icon_name(if running {
"process-stop-symbolic"
} else {
"pdfs-build-thumbnails-symbolic"
});
ui.browser
.build_thumbnails
.set_sensitive(*ui.mounted.borrow());
ui.browser.thumbnail_build_row.set_visible(true);
.set_tooltip_text(Some(if running {
"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_status.set_label(message);
ui.browser.thumbnail_status.set_tooltip_text(Some(message));
ui.browser.thumbnail_status.set_label("");
ui.browser.thumbnail_status.set_tooltip_text(None);
}
/// 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.upload.set_sensitive(mounted);
ui.browser.upload_folder.set_sensitive(mounted);
ui.browser
.build_thumbnails
.set_sensitive(mounted && !ui.browser.thumbnail_build_running.get());
ui.browser.build_thumbnails.set_sensitive(
mounted
&& (!ui.browser.thumbnail_build_running.get()
|| !ui.browser.thumbnail_cancel_pending.get()),
);
ui.gallery.upload.set_sensitive(mounted);
ui.details.details.pin_row.set_sensitive(mounted);
ui.details.details.rename_button.set_sensitive(mounted);
+101 -19
View File
@@ -7,7 +7,6 @@
use crate::*;
use pdfs_core::control::{FileThumbRequest, is_thumbnail_image_name};
use std::time::{SystemTime, UNIX_EPOCH};
const THUMB_BATCH: usize = 32;
const THUMB_CACHE_MAX: usize = 256;
@@ -25,6 +24,27 @@ pub(crate) struct FileThumbnailState {
inflight: Cell<bool>,
source: RefCell<Option<glib::SourceId>>,
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 {
@@ -38,13 +58,8 @@ impl FileThumbnailState {
queue: RefCell::new(VecDeque::new()),
inflight: Cell::new(false),
source: RefCell::new(None),
generation: Cell::new(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.min(u128::from(u64::MAX)) as u64,
),
generation: Cell::new(0),
generation_loading: Cell::new(false),
}
}
@@ -84,15 +99,18 @@ pub(crate) fn cancel_file_thumbnails(ui: &Rc<Ui>) {
}
ui.file_thumbs.queue.borrow_mut().clear();
ui.file_thumbs.wanted.borrow_mut().clear();
let generation = ui.file_thumbs.generation.get().saturating_add(1);
ui.file_thumbs.generation.set(generation);
let generation = ui.file_thumbs.generation.replace(0);
// 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.
if generation != 0 {
drop(spawn_request(
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.
/// Call [`bind_file_thumbnail`] whenever a recycled row is bound to an entry.
@@ -244,6 +262,10 @@ fn schedule_file_thumbs(ui: &Rc<Ui>) {
if ui.file_thumbs.queue.borrow().is_empty() || ui.file_thumbs.inflight.get() {
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() {
source.remove();
}
@@ -255,6 +277,40 @@ fn schedule_file_thumbs(ui: &Rc<Ui>) {
*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>) {
if ui.file_thumbs.inflight.get() {
return;
@@ -291,19 +347,23 @@ fn flush_file_thumbs(ui: &Rc<Ui>) {
ui.file_thumbs.inflight.set(false);
match result {
Ok(Ok(Response::Thumbs { items: replies })) => {
let requested: HashMap<String, FileThumbRequest> = items
let mut requested: HashMap<String, FileThumbRequest> = items
.iter()
.cloned()
.map(|item| (item.uid.clone(), item))
.collect();
let mut pending = Vec::new();
for reply in replies {
let Some(request) = requested.get(&reply.uid) else {
let Some(request) = requested.remove(&reply.uid) else {
continue;
};
let key = thumbnail_key(&request.uid, request.modified);
match reply.path {
Some(path) => match gtk4::gdk::Texture::from_filename(&path) {
let wanted = ui.file_thumbs.wanted.borrow().contains_key(&key);
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) => {
ui.file_thumbs.store_texture(&key, texture.clone());
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.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.wanted.borrow_mut().remove(&key);
}
}
}
pending.extend(requested.into_values());
if !pending.is_empty() {
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, .. })) => {
tracing::debug!("file thumbnails failed: {message}");
retry_file_thumbs(&ui, items);
}
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => {
tracing::debug!("file thumbnails: no reply");
retry_file_thumbs(&ui, items);
}
}
schedule_file_thumbs(&ui);
@@ -358,7 +426,7 @@ fn retry_file_thumbs(ui: &Rc<Ui>, items: Vec<FileThumbRequest>) {
#[cfg(test)]
mod tests {
use super::is_image_name;
use super::{ReplyDisposition, is_image_name, reply_disposition};
#[test]
fn image_extensions_are_case_insensitive_and_specific() {
@@ -369,6 +437,7 @@ mod tests {
"archive.tiff",
"camera.nef",
"negative.CR3",
"camera.RAW",
] {
assert!(is_image_name(name), "{name}");
}
@@ -380,8 +449,21 @@ mod tests {
"camera.heic",
"frame.avif",
"graphic.svg",
"camera.rw",
] {
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
);
}
}