From 91b0864fce476ef1d9abeb342b48b7e5c0c21d46 Mon Sep 17 00:00:00 2001 From: emptyname Date: Tue, 18 Aug 2026 13:06:27 +0100 Subject: [PATCH] feat(thumbnails): generate cached previews for Drive files --- Cargo.lock | 1 + Cargo.toml | 1 + crates/pdfs-core/src/control.rs | 200 ++++++++- crates/pdfs-fuse/Cargo.toml | 1 + crates/pdfs-fuse/src/control.rs | 56 ++- crates/pdfs-fuse/src/lib.rs | 12 +- crates/pdfs-fuse/src/mount.rs | 6 + crates/pdfs-fuse/src/photos.rs | 728 ++++++++++++++++++++++++++++++-- crates/pdfs-fuse/src/sharing.rs | 1 + 9 files changed, 973 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ef147e..d399051 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3578,6 +3578,7 @@ dependencies = [ name = "pdfs-fuse" version = "1.9.0" dependencies = [ + "base64", "fuser", "futures", "image", diff --git a/Cargo.toml b/Cargo.toml index 6da9058..61e5f22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ pdfs-fuse = { path = "crates/pdfs-fuse" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +base64 = "0.22" thiserror = "2" anyhow = "1" # Non-poisoning mutexes. A handler that panics while holding a lock must not diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 5f1dd3a..f81601a 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -93,6 +93,26 @@ pub enum Request { /// already cached (one batched round-trip) and replying with their on-disk /// paths. Keep the batch small — it is served on demand, as tiles scroll in. PhotoThumbs { uids: Vec }, + /// Fetch thumbnails for ordinary Drive image files shown outside the Photos + /// timeline. The modification time is the cache validity tag, so replacing + /// an image can never reuse its previous revision's thumbnail. + FileThumbs { + items: Vec, + /// Identifies the front-end's current listing. Moving to another page or + /// folder advances it, allowing the daemon to stop work for rows that + /// are no longer visible. + #[serde(default)] + generation: u64, + }, + /// Stop ordinary-file thumbnail work belonging to older listings. Photos + /// timeline work and an explicit recursive build are deliberately separate. + CancelFileThumbs { generation: u64 }, + /// Start building local thumbnails for every supported image below `path`. + /// The request returns immediately; progress is read with + /// [`Request::ThumbnailBuildStatus`]. + StartThumbnailBuild { path: String }, + /// Read progress for the recursive ordinary-file thumbnail build. + ThumbnailBuildStatus, /// Download a photo's full content into the cache; replies with its path. OpenPhoto { uid: String }, /// Add or remove Proton's `Favorite` tag on a photo. Replies with @@ -679,6 +699,10 @@ pub struct SharedItem { /// The shared node's decrypted name. pub name: String, pub is_dir: bool, + /// Modification time used to validate an image thumbnail. Older daemons + /// omit it, in which case clients use `0` as a conservative fallback tag. + #[serde(default)] + pub modified: i64, /// Mountpoint-relative path, when the daemon can resolve it (the node lives in /// my own tree). Empty when the path is unknown. #[serde(default)] @@ -1187,6 +1211,110 @@ pub struct PhotoThumb { pub pending: bool, } +/// One ordinary Drive file whose thumbnail a front-end wants to paint. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct FileThumbRequest { + /// Node uid in `volume~link` form. + pub uid: String, + /// File modification time, used as the thumbnail cache validity tag. + pub modified: i64, + /// Original Drive name. Local RAW-preview extraction uses the extension as a + /// format hint; defaulted for wire compatibility with older front-ends. + #[serde(default)] + pub name: String, +} + +/// Whether a Drive name is a raster image that the built-in decoder can read, +/// or a concrete camera RAW format whose embedded preview exiftool can extract. +/// Keep this list shared by the daemon and every GUI surface so a visible tile +/// is never advertised without a matching generation path. +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" + ) +} + +/// Whether `name` is one of the concrete RAW formats handled by the exiftool +/// embedded-preview path. Deliberately excludes generic/legacy grab-bags whose +/// preview availability is not dependable. +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" + ) +} + +/// Progress of the one recursive ordinary-file thumbnail build the daemon may +/// run at a time. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct ThumbnailBuildStatus { + pub running: bool, + /// True while folders are still being discovered, before the final image + /// count is known and the progress bar can become determinate. + pub scanning: bool, + /// Mountpoint-relative root selected when the build started. + pub path: String, + pub folders_scanned: u64, + pub images_found: u64, + /// Images already cached or processed during this build. Includes failures, + /// so `completed == images_found` still means the job has finished. + pub completed: u64, + /// Images that could not be downloaded or decoded. + pub failed: u64, + /// A traversal-level problem, such as a folder that could not be listed. + #[serde(default)] + pub message: Option, +} + /// A daemon too old to report connectivity is assumed online — it could not /// have mounted at all otherwise. fn default_online() -> bool { @@ -1339,6 +1467,8 @@ pub enum Response { PhotoMonths { months: Vec }, /// Thumbnails for a [`Request::PhotoThumbs`] batch. Thumbs { items: Vec }, + /// Current recursive thumbnail-build progress. + ThumbnailBuild { status: ThumbnailBuildStatus }, /// An on-disk path the front-end can open (e.g. a downloaded photo). FilePath { path: String }, /// Full-text search results (reply to [`Request::Search`]). @@ -1473,7 +1603,7 @@ impl ErrorKind { } /// Send one [`Request`] to the daemon listening on `socket` and read its -/// [`Response`]. Errors (e.g. [`Error::Io`]) if no daemon is listening. +/// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening. /// /// Shared by the CLI and GUI so both speak the wire format identically. pub fn send(socket: &Path, req: &Request) -> Result { @@ -1632,6 +1762,74 @@ mod tests { } } + #[test] + fn file_thumbnail_requests_keep_their_revision_tags() { + let request = Request::FileThumbs { + items: vec![ + FileThumbRequest { + uid: "volume~first".into(), + modified: 1_700_000_001, + name: "first.jpg".into(), + }, + FileThumbRequest { + uid: "volume~second".into(), + modified: 1_700_000_002, + name: "second.nef".into(), + }, + ], + generation: 42, + }; + let line = serde_json::to_string(&request).unwrap(); + let decoded: Request = serde_json::from_str(&line).unwrap(); + assert_eq!(line, serde_json::to_string(&decoded).unwrap()); + } + + #[test] + fn legacy_file_thumbnail_requests_default_the_name() { + let decoded: Request = serde_json::from_str( + r#"{"FileThumbs":{"items":[{"uid":"volume~first","modified":1700000001}],"generation":42}}"#, + ) + .unwrap(); + let Request::FileThumbs { items, generation } = decoded else { + panic!("decoded the wrong request variant"); + }; + assert_eq!(generation, 42); + assert_eq!(items.len(), 1); + assert!(items[0].name.is_empty()); + } + + #[test] + fn thumbnail_build_requests_and_progress_roundtrip() { + let requests = [ + Request::CancelFileThumbs { generation: 43 }, + Request::StartThumbnailBuild { + path: "pictures/events".into(), + }, + Request::ThumbnailBuildStatus, + ]; + for request in requests { + let line = serde_json::to_string(&request).unwrap(); + let decoded: Request = serde_json::from_str(&line).unwrap(); + assert_eq!(line, serde_json::to_string(&decoded).unwrap()); + } + + let response = Response::ThumbnailBuild { + status: ThumbnailBuildStatus { + running: true, + scanning: false, + path: "pictures/events".into(), + folders_scanned: 12, + images_found: 80, + completed: 25, + failed: 1, + message: None, + }, + }; + 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] fn unified_search_request_and_response_roundtrip() { let request = Request::SearchV2 { diff --git a/crates/pdfs-fuse/Cargo.toml b/crates/pdfs-fuse/Cargo.toml index f4564d7..21a0e27 100644 --- a/crates/pdfs-fuse/Cargo.toml +++ b/crates/pdfs-fuse/Cargo.toml @@ -18,6 +18,7 @@ tracing.workspace = true fuser.workspace = true libc.workspace = true serde_json.workspace = true +base64.workspace = true image.workspace = true notify = "8" parking_lot.workspace = true diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 3425a69..445384e 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -11,7 +11,7 @@ use std::io::{BufRead, BufReader, Write as _}; use std::os::unix::net::{UnixListener, UnixStream}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; @@ -134,12 +134,21 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor /// the mount root. fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult { let p = Path::new(path); - if p.is_absolute() { + let relative = if p.is_absolute() { p.strip_prefix(mountpoint) .map(Path::to_path_buf) .map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint"))) } else { Ok(p.to_path_buf()) + }?; + + if relative + .components() + .all(|component| matches!(component, Component::Normal(_) | Component::CurDir)) + { + Ok(relative) + } else { + Err(CoreError::invalid(format!("{path} escapes the mountpoint"))) } } @@ -320,6 +329,24 @@ 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::CancelFileThumbs { generation }) => { + core.cancel_file_thumbs(generation); + CtlResponse::Ok { + message: "thumbnail requests cancelled".to_string(), + } + } + Ok(CtlRequest::StartThumbnailBuild { path }) => match rel_to_mount(mountpoint, &path) { + Ok(rel) => CtlResponse::ThumbnailBuild { + status: core.start_thumbnail_build(rel), + }, + Err(error) => CtlResponse::error(error), + }, + Ok(CtlRequest::ThumbnailBuildStatus) => CtlResponse::ThumbnailBuild { + status: core.thumbnail_build_status(), + }, Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) { Some(u) => match core.open_photo(&u) { Ok(p) => CtlResponse::FilePath { @@ -1221,4 +1248,29 @@ mod request_limit_tests { std::io::ErrorKind::InvalidData ); } + + #[test] + fn accepts_paths_inside_the_mountpoint() { + let mountpoint = Path::new("/mnt/proton"); + assert_eq!( + rel_to_mount(mountpoint, "Photos/2026").unwrap(), + Path::new("Photos/2026") + ); + assert_eq!( + rel_to_mount(mountpoint, "/mnt/proton/Photos/2026").unwrap(), + Path::new("Photos/2026") + ); + assert_eq!( + rel_to_mount(mountpoint, "/mnt/proton").unwrap(), + Path::new("") + ); + } + + #[test] + fn rejects_paths_that_escape_the_mountpoint() { + let mountpoint = Path::new("/mnt/proton"); + assert!(rel_to_mount(mountpoint, "../escape").is_err()); + assert!(rel_to_mount(mountpoint, "Photos/../../escape").is_err()); + assert!(rel_to_mount(mountpoint, "/mnt/elsewhere").is_err()); + } } diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 8d6c580..f22b32e 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -60,7 +60,7 @@ use pdfs_core::config::{AppDirs, SweepMode}; use pdfs_core::control::{ ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo, SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress, - TransferDirection, + ThumbnailBuildStatus, TransferDirection, }; use pdfs_core::db::{ Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp, @@ -511,6 +511,16 @@ struct Core { /// those downloads is a full-size photo — so an in-flight uid is never started /// twice. thumb_gen: Arc>>, + /// Global permit pool for locally generated thumbnails. The old per-request + /// limit multiplied when the GUI sent several batches, allowing dozens of + /// full-size images to download at once. + thumb_gen_budget: Arc, + /// Current ordinary-file listing generation and a wake-up for tasks waiting + /// on a permit or a download when that listing is abandoned. + file_thumb_generation: Arc, + file_thumb_cancel: Arc, + /// Progress of the explicit recursive build started from the Files toolbar. + thumbnail_build: Arc>, /// 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. /// diff --git a/crates/pdfs-fuse/src/mount.rs b/crates/pdfs-fuse/src/mount.rs index 0063216..c9f392e 100644 --- a/crates/pdfs-fuse/src/mount.rs +++ b/crates/pdfs-fuse/src/mount.rs @@ -389,6 +389,12 @@ pub fn mount( own_sealed_revs: Arc::new(Mutex::new(HashMap::new())), self_changes: Arc::new(Mutex::new(HashMap::new())), thumb_gen: Arc::new(Mutex::new(HashSet::new())), + thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new( + super::photos::THUMB_GEN_CONCURRENCY, + )), + 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())), quota: Arc::new(Mutex::new(None)), size_upgrades: Arc::new(Mutex::new(HashMap::new())), diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index 2c320d2..7185be5 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -6,12 +6,22 @@ //! a phone) are generated locally and stored as if the server had served them. use std::collections::{HashMap, HashSet}; +use std::fs::OpenOptions; +use std::io::{Read as _, Write as _}; +use std::os::unix::fs::OpenOptionsExt as _; use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Once; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; -use pdfs_core::control::{PhotoItem, PhotoKind, PhotoThumb}; +use base64::Engine as _; +use pdfs_core::control::{ + FileThumbRequest, PhotoItem, PhotoKind, PhotoThumb, ThumbnailBuildStatus, is_raw_image_name, + is_thumbnail_image_name, +}; use pdfs_core::db::{self, StoredPhoto}; use pdfs_core::{CoreError, CoreResult}; -use std::sync::atomic::Ordering; use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType}; @@ -31,7 +41,14 @@ const THUMB_QUALITY: u8 = 82; /// How many photos may be downloaded at once to generate their missing /// thumbnails. Bounded: a screenful of 20 MB digicam JPEGs would otherwise /// saturate the link and starve the rest of the daemon. -const THUMB_GEN_CONCURRENCY: usize = 4; +pub(crate) const THUMB_GEN_CONCURRENCY: usize = 4; +/// Images processed between recursive-build progress updates. +const THUMB_BUILD_CHUNK: usize = 16; +/// 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); +static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0); +static EXIFTOOL_MISSING_WARNING: Once = Once::new(); /// The aspect ratio (w/h) of an encoded image, read from its header alone — no /// pixels are decoded. `None` when the format is unknown or the header is torn. @@ -60,6 +77,36 @@ enum ThumbAttempt { Undecodable, /// The photo couldn't be downloaded. Transient: try again next time. Unavailable, + /// Its ordinary-file listing was left while this work was queued or running. + Cancelled, +} + +/// Why a local thumbnail is being generated. Only opportunistic thumbnails for +/// ordinary file listings are cancellable on navigation; Photos and an explicit +/// recursive build are intentional work with their own lifecycle. +#[derive(Clone, Copy)] +enum ThumbJob { + Photos, + Files(u64), + Build, +} + +#[derive(Default)] +struct ThumbBatchSummary { + made: u64, + undecodable: u64, + unavailable: u64, + cancelled: u64, +} + +impl ThumbBatchSummary { + fn processed(&self) -> u64 { + self.made + self.undecodable + self.unavailable + self.cancelled + } + + fn failed(&self) -> u64 { + self.undecodable + self.unavailable + } } /// Scale a full-size photo down to a thumbnail: at most [`THUMB_EDGE`] on its @@ -68,12 +115,27 @@ enum ThumbAttempt { /// un-thumbnailable. /// /// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool. -fn scale_thumbnail(bytes: &[u8]) -> Option { - let image = image::ImageReader::new(std::io::Cursor::new(bytes)) +fn scale_thumbnail(bytes: &[u8], name: &str) -> Option { + let direct = image::ImageReader::new(std::io::Cursor::new(bytes)) .with_guessed_format() - .ok()? - .decode() - .ok()?; + .ok() + .and_then(|reader| reader.decode().ok()); + 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)) + .with_guessed_format() + .ok()? + .decode() + .ok()?; + if let Some(orientation) = image::metadata::Orientation::from_exif(orientation) { + image.apply_orientation(orientation); + } + image + } + None => return None, + }; let (width, height) = (image.width(), image.height()); if width == 0 || height == 0 { return None; @@ -90,6 +152,168 @@ fn scale_thumbnail(bytes: &[u8]) -> Option { Some(GeneratedThumb { bytes, ratio }) } +/// Temporary file passed to exiftool. The helper identifies most RAWs from their +/// magic bytes, while preserving a safe extension also covers formats whose +/// detector uses the original name as a hint. +struct RawTempFile(PathBuf); + +impl RawTempFile { + fn create(bytes: &[u8], name: &str) -> Option { + let extension = std::path::Path::new(name) + .extension() + .and_then(|value| value.to_str()) + .filter(|value| { + !value.is_empty() + && value.len() <= 10 + && value.bytes().all(|byte| byte.is_ascii_alphanumeric()) + }) + .unwrap_or("raw"); + let started = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + for _ in 0..16 { + let nonce = RAW_TEMP_NONCE.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "pdfs-raw-{}-{started}-{nonce}.{extension}", + std::process::id(), + )); + match OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + { + Ok(mut file) => { + if file.write_all(bytes).is_ok() { + return Some(Self(path)); + } + let _ = std::fs::remove_file(path); + return None; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(_) => return None, + } + } + None + } +} + +impl Drop for RawTempFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +/// 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 { + let mut command = Command::new("exiftool"); + command.args(["-j", "-b", "-n"]); + command.args(tags); + command + .arg(path) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + if error.kind() == std::io::ErrorKind::NotFound { + EXIFTOOL_MISSING_WARNING.call_once(|| { + warn!( + "exiftool is not installed; camera RAW thumbnails are unavailable \ + (Debian/Ubuntu: libimage-exiftool-perl)" + ); + }); + } + return None; + } + }; + let mut stdout = child.stdout.take()?; + let reader = std::thread::spawn(move || { + let mut output = Vec::new(); + stdout.read_to_end(&mut output).map(|_| output) + }); + let started = Instant::now(); + let success = loop { + match child.try_wait() { + Ok(Some(status)) => break status.success(), + Ok(None) if started.elapsed() < EXIFTOOL_TIMEOUT => { + std::thread::sleep(Duration::from_millis(10)); + } + Ok(None) | Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + break false; + } + } + }; + let output = reader.join().ok()?.ok()?; + if !success { + return None; + } + serde_json::from_slice(&output).ok() +} + +fn exiftool_object( + value: &serde_json::Value, +) -> Option<&serde_json::Map> { + value.as_array()?.first()?.as_object() +} + +fn exiftool_binary( + object: &serde_json::Map, + key: &str, +) -> Option> { + let encoded = object.get(key)?.as_str()?.strip_prefix("base64:")?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + (!decoded.is_empty()).then_some(decoded) +} + +/// 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)> { + let file = RawTempFile::create(bytes, name)?; + let primary = exiftool_query(&file.0, &["-Orientation", "-PreviewImage"])?; + let primary = exiftool_object(&primary)?; + 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)); + } + + const FALLBACKS: [&str; 5] = [ + "OtherImage", + "JpgFromRaw", + "PreviewTIFF", + "ThumbnailImage", + "ThumbnailTIFF", + ]; + let fallback = exiftool_query( + &file.0, + &[ + "-OtherImage", + "-JpgFromRaw", + "-PreviewTIFF", + "-ThumbnailImage", + "-ThumbnailTIFF", + ], + )?; + let fallback = exiftool_object(&fallback)?; + FALLBACKS + .iter() + .find_map(|key| exiftool_binary(fallback, key)) + .map(|preview| (preview, orientation)) +} + +/// Formats supported by the local thumbnail decoder and exposed by the GUI. impl Core { pub(crate) fn photos_timeline( &self, @@ -206,6 +430,10 @@ impl Core { .iter() .map(|p| (p.uid.clone(), p.capture_time)) .collect(); + let names: HashMap = stored + .iter() + .filter_map(|photo| photo.name.clone().map(|name| (photo.uid.clone(), name))) + .collect(); // Ask the server only for photos that are missing a cached thumbnail and // haven't already been written off as un-thumbnailable. @@ -260,7 +488,7 @@ impl Core { // takes far longer to fetch than the whole rest of the batch, and the // thumbnails that *are* ready must not wait behind it. if !missing.is_empty() { - self.spawn_generate_thumbs(missing, &tags); + self.spawn_generate_thumbs(missing, &tags, &names, ThumbJob::Photos); } } @@ -278,10 +506,101 @@ impl Core { .collect() } + /// Thumbnails for image files shown by the regular file browser, Shared and + /// Trash pages. Unlike [`Core::photo_thumbs`], these nodes need not be in the + /// Photos timeline: the request supplies each file's modification time as + /// the cache validity tag. + pub(crate) fn file_thumbs( + &self, + items: &[FileThumbRequest], + generation: u64, + ) -> Vec { + let previous = self + .file_thumb_generation + .fetch_max(generation, Ordering::SeqCst); + if generation > previous { + self.file_thumb_cancel.notify_waiters(); + } + let current = self.file_thumb_generation.load(Ordering::SeqCst) == generation; + let ttype = ThumbnailType::Thumbnail.as_i32(); + let parsed: Vec<(String, NodeUid, i64)> = items + .iter() + .filter_map(|item| { + parse_uid(&item.uid).map(|uid| (item.uid.clone(), uid, item.modified)) + }) + .collect(); + let tags: HashMap = parsed + .iter() + .map(|(raw, _, modified)| (raw.clone(), *modified)) + .collect(); + let names: HashMap = items + .iter() + .map(|item| (item.uid.clone(), item.name.clone())) + .collect(); + + // Proton only stores server-generated thumbnails for nodes in Photos. + // These requests come exclusively from ordinary Drive, Shared and Trash + // views, so downloading a remote thumbnail here would be a guaranteed + // miss. Generate directly from the full image and cache the result. + let mut wanted = Vec::new(); + let mut seen = HashSet::new(); + for (raw, uid, modified) in &parsed { + if self + .cache + .cached_thumbnail_path(uid, ttype, *modified) + .is_some() + || self.no_thumbnail.lock().get(&(uid.clone(), ttype)) == Some(modified) + || !seen.insert(raw.clone()) + { + continue; + } + wanted.push(uid.clone()); + } + + if current && !wanted.is_empty() { + self.spawn_generate_thumbs(wanted, &tags, &names, ThumbJob::Files(generation)); + } + + let pending = self.thumb_gen.lock(); + items + .iter() + .map(|item| { + let uid = parse_uid(&item.uid); + PhotoThumb { + uid: item.uid.clone(), + path: uid.as_ref().and_then(|uid| { + self.cache + .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)), + } + }) + .collect() + } + + /// Advance the ordinary-file thumbnail generation. Every queued/download in + /// an older listing observes the new value and exits; Photos and explicit + /// recursive builds do not use this generation and continue normally. + pub(crate) fn cancel_file_thumbs(&self, generation: u64) { + let previous = self + .file_thumb_generation + .fetch_max(generation, Ordering::SeqCst); + if generation > previous { + self.file_thumb_cancel.notify_waiters(); + } + } + /// Generate the missing thumbnails on the runtime, skipping any photo already /// being generated. The uids are marked in-flight before the task starts, so /// the reply this call is about to send already reports them as pending. - pub(crate) fn spawn_generate_thumbs(&self, uids: Vec, tags: &HashMap) { + fn spawn_generate_thumbs( + &self, + uids: Vec, + tags: &HashMap, + names: &HashMap, + job: ThumbJob, + ) { let fresh: Vec = { let mut inflight = self.thumb_gen.lock(); uids.into_iter() @@ -294,14 +613,21 @@ impl Core { let core = self.clone(); let tags = tags.clone(); + let names = names.clone(); // `generate_thumbs` blocks on the runtime itself, so it belongs on the // blocking pool rather than on an async worker. self.rt.spawn_blocking(move || { - core.generate_thumbs(&fresh, &tags); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + core.generate_thumbs(&fresh, &tags, &names, job) + })); let mut inflight = core.thumb_gen.lock(); for uid in &fresh { inflight.remove(uid); } + drop(inflight); + if result.is_err() { + warn!("thumbnail generation worker panicked"); + } }); } @@ -313,31 +639,100 @@ impl Core { /// screenful of them at once would saturate the link. A photo whose bytes /// can't be decoded (a codec `image` doesn't speak) is marked /// [`db::THUMB_NONE`] and never attempted again. - pub(crate) fn generate_thumbs(&self, uids: &[NodeUid], tags: &HashMap) { - info!( - count = uids.len(), - "generating thumbnails the server has none for" - ); + fn thumb_job_current(&self, job: ThumbJob) -> bool { + match job { + ThumbJob::Files(generation) => { + self.file_thumb_generation.load(Ordering::SeqCst) == generation + } + ThumbJob::Photos | ThumbJob::Build => true, + } + } + + fn generate_thumbs( + &self, + uids: &[NodeUid], + tags: &HashMap, + names: &HashMap, + job: ThumbJob, + ) -> ThumbBatchSummary { + info!(count = uids.len(), "generating local thumbnails"); let results: Vec<(NodeUid, ThumbAttempt)> = self.rt.block_on(async { let mut out = Vec::with_capacity(uids.len()); for chunk in uids.chunks(THUMB_GEN_CONCURRENCY) { let mut set = tokio::task::JoinSet::new(); for uid in chunk { + let core = self.clone(); let client = self.client.clone(); let uid = uid.clone(); + let name = names.get(&uid.to_string()).cloned().unwrap_or_default(); set.spawn(async move { - let bytes = match client.download_file(&uid).await { + if !core.thumb_job_current(job) { + return (uid, ThumbAttempt::Cancelled); + } + + // One shared permit pool bounds all thumbnail batches, + // rather than multiplying the limit by every control + // request the GUI happens to have in flight. + let acquire = core.thumb_gen_budget.clone().acquire_owned(); + tokio::pin!(acquire); + let _permit = match job { + ThumbJob::Files(_) => loop { + tokio::select! { + permit = &mut acquire => break permit.ok(), + _ = core.file_thumb_cancel.notified() => { + if !core.thumb_job_current(job) { + return (uid, ThumbAttempt::Cancelled); + } + } + } + }, + ThumbJob::Photos | ThumbJob::Build => acquire.await.ok(), + }; + let Some(_permit) = _permit else { + return (uid, ThumbAttempt::Unavailable); + }; + if !core.thumb_job_current(job) { + return (uid, ThumbAttempt::Cancelled); + } + + let download_uid = uid.clone(); + let download = async move { client.download_file(&download_uid).await }; + tokio::pin!(download); + let downloaded = match job { + ThumbJob::Files(_) => loop { + tokio::select! { + result = &mut download => break Some(result), + _ = core.file_thumb_cancel.notified() => { + if !core.thumb_job_current(job) { + break None; + } + } + } + }, + ThumbJob::Photos | ThumbJob::Build => Some(download.await), + }; + let Some(downloaded) = downloaded else { + return (uid, ThumbAttempt::Cancelled); + }; + let bytes = match downloaded { Ok(bytes) => bytes, Err(e) => { - warn!(%uid, error = %e, "photo download for thumbnail failed"); + warn!(%uid, error = %e, "image download for thumbnail failed"); return (uid, ThumbAttempt::Unavailable); } }; + if !core.thumb_job_current(job) { + return (uid, ThumbAttempt::Cancelled); + } // 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)) - .await - .unwrap_or(None); + let made = + tokio::task::spawn_blocking(move || scale_thumbnail(&bytes, &name)) + .await + .unwrap_or(None); + if !core.thumb_job_current(job) { + return (uid, ThumbAttempt::Cancelled); + } match made { Some(thumb) => (uid, ThumbAttempt::Made(thumb)), None => (uid, ThumbAttempt::Undecodable), @@ -354,26 +749,222 @@ impl Core { }); let ttype = ThumbnailType::Thumbnail.as_i32(); + let mut summary = ThumbBatchSummary::default(); for (uid, attempt) in results { match attempt { ThumbAttempt::Made(thumb) => { let Some(&tag) = tags.get(&uid.to_string()) else { + summary.unavailable += 1; continue; }; match self.cache.store_thumbnail(&uid, ttype, tag, &thumb.bytes) { - Ok(()) => self.record_thumb(&uid, db::THUMB_HAVE, Some(thumb.ratio)), - Err(e) => warn!(%uid, error = %e, "storing generated thumbnail failed"), + Ok(()) => { + summary.made += 1; + self.no_thumbnail.lock().remove(&(uid.clone(), ttype)); + self.record_thumb(&uid, db::THUMB_HAVE, Some(thumb.ratio)); + } + Err(e) => { + summary.unavailable += 1; + warn!(%uid, error = %e, "storing generated thumbnail failed"); + } } } // The photo's own bytes aren't an image we can read: no thumbnail // will ever exist for it, so stop trying. - ThumbAttempt::Undecodable => self.record_thumb(&uid, db::THUMB_NONE, None), + ThumbAttempt::Undecodable => { + summary.undecodable += 1; + if let Some(&tag) = tags.get(&uid.to_string()) { + self.no_thumbnail.lock().insert((uid.clone(), ttype), tag); + } + self.record_thumb(&uid, db::THUMB_NONE, None); + } // The download failed — a dropped connection, an expired link. That // is not a verdict on the photo: leave its state alone so the next // scroll past it tries again. - ThumbAttempt::Unavailable => {} + ThumbAttempt::Unavailable => summary.unavailable += 1, + ThumbAttempt::Cancelled => summary.cancelled += 1, } } + summary + } + + pub(crate) fn thumbnail_build_status(&self) -> ThumbnailBuildStatus { + 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 { + let path = root.to_string_lossy().into_owned(); + let status = { + let mut status = self.thumbnail_build.lock(); + if status.running { + return status.clone(); + } + *status = ThumbnailBuildStatus { + running: true, + scanning: true, + path, + folders_scanned: 0, + images_found: 0, + completed: 0, + failed: 0, + message: None, + }; + status.clone() + }; + + let core = self.clone(); + self.rt.spawn_blocking(move || { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + core.run_thumbnail_build(root) + })) + .is_err() + { + let mut status = core.thumbnail_build.lock(); + status.running = false; + status.scanning = false; + status.message = Some("Thumbnail generation stopped unexpectedly".to_string()); + warn!(path = %status.path, "recursive thumbnail build panicked"); + } + }); + status + } + + 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(); + + while let Some(folder) = folders.pop() { + let listing = self.list_dir(&folder); + { + let mut status = self.thumbnail_build.lock(); + status.folders_scanned += 1; + } + let entries = match listing { + Ok(entries) => entries, + Err(error) => { + let mut status = self.thumbnail_build.lock(); + if status.message.is_none() { + status.message = Some(format!( + "Some folders could not be read ({}: {error})", + folder.display() + )); + } + continue; + } + }; + + let mut found_here = 0_u64; + let mut invalid_here = 0_u64; + for entry in entries { + if entry.is_dir { + if seen.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, + } + } + } + let mut status = self.thumbnail_build.lock(); + status.images_found += found_here; + status.completed += invalid_here; + status.failed += invalid_here; + } + + self.thumbnail_build.lock().scanning = false; + let ttype = ThumbnailType::Thumbnail.as_i32(); + let mut todo = Vec::new(); + for (uid, modified, name) in images { + if self + .cache + .cached_thumbnail_path(&uid, ttype, modified) + .is_some() + { + self.thumbnail_build.lock().completed += 1; + } else if self.no_thumbnail.lock().get(&(uid.clone(), ttype)) == Some(&modified) { + let mut status = self.thumbnail_build.lock(); + status.completed += 1; + status.failed += 1; + } else { + todo.push((uid, modified, name)); + } + } + + for chunk in todo.chunks(THUMB_BUILD_CHUNK) { + let mut claimed = Vec::with_capacity(chunk.len()); + let mut tags = HashMap::with_capacity(chunk.len()); + let mut names = HashMap::with_capacity(chunk.len()); + for (uid, modified, name) in chunk { + // 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. + loop { + if self + .cache + .cached_thumbnail_path(uid, ttype, *modified) + .is_some() + { + self.thumbnail_build.lock().completed += 1; + break; + } + if self.no_thumbnail.lock().get(&(uid.clone(), ttype)) == Some(modified) { + let mut status = self.thumbnail_build.lock(); + status.completed += 1; + status.failed += 1; + break; + } + if self.thumb_gen.lock().insert(uid.clone()) { + tags.insert(uid.to_string(), *modified); + names.insert(uid.to_string(), name.clone()); + claimed.push(uid.clone()); + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + } + + if claimed.is_empty() { + continue; + } + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.generate_thumbs(&claimed, &tags, &names, ThumbJob::Build) + })); + { + let mut inflight = self.thumb_gen.lock(); + for uid in &claimed { + inflight.remove(uid); + } + } + let mut status = self.thumbnail_build.lock(); + match result { + Ok(summary) => { + status.completed += summary.processed(); + status.failed += summary.failed(); + } + Err(_) => { + status.completed += claimed.len() as u64; + status.failed += claimed.len() as u64; + status.message = Some("Some thumbnail workers stopped unexpectedly".into()); + } + } + } + + 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 @@ -501,7 +1092,9 @@ impl Core { #[cfg(test)] mod thumb_tests { - use super::{THUMB_EDGE, ratio_of, scale_thumbnail}; + use super::{RawTempFile, THUMB_EDGE, exiftool_binary, ratio_of, scale_thumbnail}; + use pdfs_core::control::is_thumbnail_image_name; + use std::os::unix::fs::PermissionsExt as _; /// A `width`×`height` JPEG, standing in for a camera photo the server never /// generated a thumbnail for. @@ -519,7 +1112,7 @@ 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).expect("a JPEG scales"); + let thumb = scale_thumbnail(&photo, "photo.jpg").expect("a JPEG scales"); let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes)) .with_guessed_format() @@ -537,7 +1130,7 @@ mod thumb_tests { #[test] fn a_portrait_photo_fits_its_long_edge_too() { - let thumb = scale_thumbnail(&jpeg(1000, 2000)).expect("a JPEG scales"); + let thumb = scale_thumbnail(&jpeg(1000, 2000), "portrait.jpeg").expect("a JPEG scales"); assert!(thumb.ratio < 1.0, "portrait stays portrait"); assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true)); } @@ -546,7 +1139,7 @@ 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").is_none()); + assert!(scale_thumbnail(b"not an image at all", "broken.jpg").is_none()); assert!(ratio_of(b"not an image at all").is_none()); } @@ -554,4 +1147,81 @@ mod thumb_tests { fn ratio_is_read_from_the_header_alone() { assert_eq!(ratio_of(&jpeg(300, 200)), Some(1.5)); } + + #[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 path = file.0.clone(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + assert_eq!( + path.extension().and_then(|value| value.to_str()), + Some("NEF") + ); + drop(file); + assert!(!path.exists()); + } + + /// Optional real-file regression harness. RAW fixtures are too large to keep + /// in the repository; developers can point this at any supported camera file. + #[test] + #[ignore = "set PDFS_RAW_FIXTURE to a supported camera RAW file"] + fn real_raw_fixture_extracts_and_scales() { + let path = std::env::var_os("PDFS_RAW_FIXTURE").expect("PDFS_RAW_FIXTURE"); + 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 (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes)) + .with_guessed_format() + .unwrap() + .into_dimensions() + .unwrap(); + assert!(width <= THUMB_EDGE && height <= THUMB_EDGE); + assert!(width > 0 && height > 0); + } + + #[test] + fn recursive_build_recognises_the_same_image_names_as_the_gui() { + for name in [ + "photo.jpg", + "SCAN.PNG", + "animation.Gif", + "archive.tiff", + "camera.NEF", + "negative.cr3", + "sensor.orf", + ] { + assert!(is_thumbnail_image_name(name), "{name}"); + } + for name in [ + "notes.txt", + "movie.mp4", + "image.jpg.zip", + "jpg", + "unsupported.heic", + "unsupported.avif", + "vector.svg", + ] { + assert!(!is_thumbnail_image_name(name), "{name}"); + } + } + + #[test] + fn exiftool_binary_accepts_only_prefixed_base64() { + let value = serde_json::json!({ + "PreviewImage": "base64:aGVsbG8=", + "OtherImage": "not-binary", + "ThumbnailImage": "base64:" + }); + let object = value.as_object().unwrap(); + assert_eq!( + exiftool_binary(object, "PreviewImage"), + Some(b"hello".to_vec()) + ); + assert_eq!(exiftool_binary(object, "OtherImage"), None); + assert_eq!(exiftool_binary(object, "ThumbnailImage"), None); + assert_eq!(exiftool_binary(object, "Missing"), None); + } } diff --git a/crates/pdfs-fuse/src/sharing.rs b/crates/pdfs-fuse/src/sharing.rs index 76304b6..37b9307 100644 --- a/crates/pdfs-fuse/src/sharing.rs +++ b/crates/pdfs-fuse/src/sharing.rs @@ -609,6 +609,7 @@ impl Core { items.push(SharedItem { uid: uid.to_string(), is_dir: n.is_folder(), + modified: n.modification_time, name: n.name, path: self.rel_path_for_uid(&uid).unwrap_or_default(), member_count: members,