feat(thumbnails): generate cached previews for Drive files

This commit is contained in:
emptyname committed 2026-08-18 13:06:27 +01:00
1 parent 8bd9481902
commit 91b0864fce
9 files changed
+973 -33

No files matched your search

Generated
+1
View File
@@ -3578,6 +3578,7 @@ dependencies = [
name = "pdfs-fuse" name = "pdfs-fuse"
version = "1.9.0" version = "1.9.0"
dependencies = [ dependencies = [
"base64",
"fuser", "fuser",
"futures", "futures",
"image", "image",
+1
View File
@@ -20,6 +20,7 @@ pdfs-fuse = { path = "crates/pdfs-fuse" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
base64 = "0.22"
thiserror = "2" thiserror = "2"
anyhow = "1" anyhow = "1"
# Non-poisoning mutexes. A handler that panics while holding a lock must not # Non-poisoning mutexes. A handler that panics while holding a lock must not
+199 -1
View File
@@ -93,6 +93,26 @@ pub enum Request {
/// already cached (one batched round-trip) and replying with their on-disk /// 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. /// paths. Keep the batch small — it is served on demand, as tiles scroll in.
PhotoThumbs { uids: Vec<String> }, PhotoThumbs { uids: Vec<String> },
/// 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<FileThumbRequest>,
/// 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. /// Download a photo's full content into the cache; replies with its path.
OpenPhoto { uid: String }, OpenPhoto { uid: String },
/// Add or remove Proton's `Favorite` tag on a photo. Replies with /// 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. /// The shared node's decrypted name.
pub name: String, pub name: String,
pub is_dir: bool, 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 /// Mountpoint-relative path, when the daemon can resolve it (the node lives in
/// my own tree). Empty when the path is unknown. /// my own tree). Empty when the path is unknown.
#[serde(default)] #[serde(default)]
@@ -1187,6 +1211,110 @@ pub struct PhotoThumb {
pub pending: bool, 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<String>,
}
/// A daemon too old to report connectivity is assumed online — it could not /// A daemon too old to report connectivity is assumed online — it could not
/// have mounted at all otherwise. /// have mounted at all otherwise.
fn default_online() -> bool { fn default_online() -> bool {
@@ -1339,6 +1467,8 @@ pub enum Response {
PhotoMonths { months: Vec<PhotoMonth> }, PhotoMonths { months: Vec<PhotoMonth> },
/// Thumbnails for a [`Request::PhotoThumbs`] batch. /// Thumbnails for a [`Request::PhotoThumbs`] batch.
Thumbs { items: Vec<PhotoThumb> }, Thumbs { items: Vec<PhotoThumb> },
/// Current recursive thumbnail-build progress.
ThumbnailBuild { status: ThumbnailBuildStatus },
/// An on-disk path the front-end can open (e.g. a downloaded photo). /// An on-disk path the front-end can open (e.g. a downloaded photo).
FilePath { path: String }, FilePath { path: String },
/// Full-text search results (reply to [`Request::Search`]). /// 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 /// 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. /// Shared by the CLI and GUI so both speak the wire format identically.
pub fn send(socket: &Path, req: &Request) -> Result<Response> { pub fn send(socket: &Path, req: &Request) -> Result<Response> {
@@ -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] #[test]
fn unified_search_request_and_response_roundtrip() { fn unified_search_request_and_response_roundtrip() {
let request = Request::SearchV2 { let request = Request::SearchV2 {
+1
View File
@@ -18,6 +18,7 @@ tracing.workspace = true
fuser.workspace = true fuser.workspace = true
libc.workspace = true libc.workspace = true
serde_json.workspace = true serde_json.workspace = true
base64.workspace = true
image.workspace = true image.workspace = true
notify = "8" notify = "8"
parking_lot.workspace = true parking_lot.workspace = true
+54 -2
View File
@@ -11,7 +11,7 @@
use std::io::{BufRead, BufReader, Write as _}; use std::io::{BufRead, BufReader, Write as _};
use std::os::unix::net::{UnixListener, UnixStream}; 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::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -134,12 +134,21 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
/// the mount root. /// the mount root.
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> { fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
let p = Path::new(path); let p = Path::new(path);
if p.is_absolute() { let relative = if p.is_absolute() {
p.strip_prefix(mountpoint) p.strip_prefix(mountpoint)
.map(Path::to_path_buf) .map(Path::to_path_buf)
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint"))) .map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
} else { } else {
Ok(p.to_path_buf()) 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), 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) { Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
Some(u) => match core.open_photo(&u) { Some(u) => match core.open_photo(&u) {
Ok(p) => CtlResponse::FilePath { Ok(p) => CtlResponse::FilePath {
@@ -1221,4 +1248,29 @@ mod request_limit_tests {
std::io::ErrorKind::InvalidData 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());
}
} }
+11 -1
View File
@@ -60,7 +60,7 @@ use pdfs_core::config::{AppDirs, SweepMode};
use pdfs_core::control::{ use pdfs_core::control::{
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo, ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress, SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
TransferDirection, ThumbnailBuildStatus, TransferDirection,
}; };
use pdfs_core::db::{ use pdfs_core::db::{
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp, 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 /// those downloads is a full-size photo — so an in-flight uid is never started
/// twice. /// twice.
thumb_gen: Arc<Mutex<HashSet<NodeUid>>>, thumb_gen: Arc<Mutex<HashSet<NodeUid>>>,
/// 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<tokio::sync::Semaphore>,
/// 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<AtomicU64>,
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 /// 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. /// `(uid, thumbnail type)` and holding the mtime the answer was learned at.
/// ///
+6
View File
@@ -389,6 +389,12 @@ pub fn mount(
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())), own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
self_changes: Arc::new(Mutex::new(HashMap::new())), self_changes: Arc::new(Mutex::new(HashMap::new())),
thumb_gen: Arc::new(Mutex::new(HashSet::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())), no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
quota: Arc::new(Mutex::new(None)), quota: Arc::new(Mutex::new(None)),
size_upgrades: Arc::new(Mutex::new(HashMap::new())), size_upgrades: Arc::new(Mutex::new(HashMap::new())),
+699 -29
View File
@@ -6,12 +6,22 @@
//! a phone) are generated locally and stored as if the server had served them. //! a phone) are generated locally and stored as if the server had served them.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs::OpenOptions;
use std::io::{Read as _, Write as _};
use std::os::unix::fs::OpenOptionsExt as _;
use std::path::PathBuf; 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::db::{self, StoredPhoto};
use pdfs_core::{CoreError, CoreResult}; use pdfs_core::{CoreError, CoreResult};
use std::sync::atomic::Ordering;
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType}; 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 /// How many photos may be downloaded at once to generate their missing
/// thumbnails. Bounded: a screenful of 20 MB digicam JPEGs would otherwise /// thumbnails. Bounded: a screenful of 20 MB digicam JPEGs would otherwise
/// saturate the link and starve the rest of the daemon. /// 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 /// 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. /// pixels are decoded. `None` when the format is unknown or the header is torn.
@@ -60,6 +77,36 @@ enum ThumbAttempt {
Undecodable, Undecodable,
/// The photo couldn't be downloaded. Transient: try again next time. /// The photo couldn't be downloaded. Transient: try again next time.
Unavailable, 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 /// Scale a full-size photo down to a thumbnail: at most [`THUMB_EDGE`] on its
@@ -68,12 +115,27 @@ enum ThumbAttempt {
/// un-thumbnailable. /// un-thumbnailable.
/// ///
/// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool. /// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool.
fn scale_thumbnail(bytes: &[u8]) -> Option<GeneratedThumb> { fn scale_thumbnail(bytes: &[u8], name: &str) -> Option<GeneratedThumb> {
let image = image::ImageReader::new(std::io::Cursor::new(bytes)) let direct = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format() .with_guessed_format()
.ok()? .ok()
.decode() .and_then(|reader| reader.decode().ok());
.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()); let (width, height) = (image.width(), image.height());
if width == 0 || height == 0 { if width == 0 || height == 0 {
return None; return None;
@@ -90,6 +152,168 @@ fn scale_thumbnail(bytes: &[u8]) -> Option<GeneratedThumb> {
Some(GeneratedThumb { bytes, ratio }) 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<Self> {
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<serde_json::Value> {
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<String, serde_json::Value>> {
value.as_array()?.first()?.as_object()
}
fn exiftool_binary(
object: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Option<Vec<u8>> {
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>, 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 { impl Core {
pub(crate) fn photos_timeline( pub(crate) fn photos_timeline(
&self, &self,
@@ -206,6 +430,10 @@ impl Core {
.iter() .iter()
.map(|p| (p.uid.clone(), p.capture_time)) .map(|p| (p.uid.clone(), p.capture_time))
.collect(); .collect();
let names: HashMap<String, String> = 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 // Ask the server only for photos that are missing a cached thumbnail and
// haven't already been written off as un-thumbnailable. // 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 // takes far longer to fetch than the whole rest of the batch, and the
// thumbnails that *are* ready must not wait behind it. // thumbnails that *are* ready must not wait behind it.
if !missing.is_empty() { 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() .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<PhotoThumb> {
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<String, i64> = parsed
.iter()
.map(|(raw, _, modified)| (raw.clone(), *modified))
.collect();
let names: HashMap<String, String> = 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 /// Generate the missing thumbnails on the runtime, skipping any photo already
/// being generated. The uids are marked in-flight before the task starts, so /// 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. /// the reply this call is about to send already reports them as pending.
pub(crate) fn spawn_generate_thumbs(&self, uids: Vec<NodeUid>, tags: &HashMap<String, i64>) { fn spawn_generate_thumbs(
&self,
uids: Vec<NodeUid>,
tags: &HashMap<String, i64>,
names: &HashMap<String, String>,
job: ThumbJob,
) {
let fresh: Vec<NodeUid> = { let fresh: Vec<NodeUid> = {
let mut inflight = self.thumb_gen.lock(); let mut inflight = self.thumb_gen.lock();
uids.into_iter() uids.into_iter()
@@ -294,14 +613,21 @@ impl Core {
let core = self.clone(); let core = self.clone();
let tags = tags.clone(); let tags = tags.clone();
let names = names.clone();
// `generate_thumbs` blocks on the runtime itself, so it belongs on the // `generate_thumbs` blocks on the runtime itself, so it belongs on the
// blocking pool rather than on an async worker. // blocking pool rather than on an async worker.
self.rt.spawn_blocking(move || { 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(); let mut inflight = core.thumb_gen.lock();
for uid in &fresh { for uid in &fresh {
inflight.remove(uid); 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 /// 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 /// can't be decoded (a codec `image` doesn't speak) is marked
/// [`db::THUMB_NONE`] and never attempted again. /// [`db::THUMB_NONE`] and never attempted again.
pub(crate) fn generate_thumbs(&self, uids: &[NodeUid], tags: &HashMap<String, i64>) { fn thumb_job_current(&self, job: ThumbJob) -> bool {
info!( match job {
count = uids.len(), ThumbJob::Files(generation) => {
"generating thumbnails the server has none for" self.file_thumb_generation.load(Ordering::SeqCst) == generation
); }
ThumbJob::Photos | ThumbJob::Build => true,
}
}
fn generate_thumbs(
&self,
uids: &[NodeUid],
tags: &HashMap<String, i64>,
names: &HashMap<String, String>,
job: ThumbJob,
) -> ThumbBatchSummary {
info!(count = uids.len(), "generating local thumbnails");
let results: Vec<(NodeUid, ThumbAttempt)> = self.rt.block_on(async { let results: Vec<(NodeUid, ThumbAttempt)> = self.rt.block_on(async {
let mut out = Vec::with_capacity(uids.len()); let mut out = Vec::with_capacity(uids.len());
for chunk in uids.chunks(THUMB_GEN_CONCURRENCY) { for chunk in uids.chunks(THUMB_GEN_CONCURRENCY) {
let mut set = tokio::task::JoinSet::new(); let mut set = tokio::task::JoinSet::new();
for uid in chunk { for uid in chunk {
let core = self.clone();
let client = self.client.clone(); let client = self.client.clone();
let uid = uid.clone(); let uid = uid.clone();
let name = names.get(&uid.to_string()).cloned().unwrap_or_default();
set.spawn(async move { 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, Ok(bytes) => bytes,
Err(e) => { Err(e) => {
warn!(%uid, error = %e, "photo download for thumbnail failed"); warn!(%uid, error = %e, "image download for thumbnail failed");
return (uid, ThumbAttempt::Unavailable); 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 // Decoding + scaling a 20 MP JPEG is CPU-bound and would
// stall the runtime's worker; hand it to the blocking pool. // stall the runtime's worker; hand it to the blocking pool.
let made = tokio::task::spawn_blocking(move || scale_thumbnail(&bytes)) let made =
.await tokio::task::spawn_blocking(move || scale_thumbnail(&bytes, &name))
.unwrap_or(None); .await
.unwrap_or(None);
if !core.thumb_job_current(job) {
return (uid, ThumbAttempt::Cancelled);
}
match made { match made {
Some(thumb) => (uid, ThumbAttempt::Made(thumb)), Some(thumb) => (uid, ThumbAttempt::Made(thumb)),
None => (uid, ThumbAttempt::Undecodable), None => (uid, ThumbAttempt::Undecodable),
@@ -354,26 +749,222 @@ impl Core {
}); });
let ttype = ThumbnailType::Thumbnail.as_i32(); let ttype = ThumbnailType::Thumbnail.as_i32();
let mut summary = ThumbBatchSummary::default();
for (uid, attempt) in results { for (uid, attempt) in results {
match attempt { match attempt {
ThumbAttempt::Made(thumb) => { ThumbAttempt::Made(thumb) => {
let Some(&tag) = tags.get(&uid.to_string()) else { let Some(&tag) = tags.get(&uid.to_string()) else {
summary.unavailable += 1;
continue; continue;
}; };
match self.cache.store_thumbnail(&uid, ttype, tag, &thumb.bytes) { match self.cache.store_thumbnail(&uid, ttype, tag, &thumb.bytes) {
Ok(()) => self.record_thumb(&uid, db::THUMB_HAVE, Some(thumb.ratio)), Ok(()) => {
Err(e) => warn!(%uid, error = %e, "storing generated thumbnail failed"), 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 // The photo's own bytes aren't an image we can read: no thumbnail
// will ever exist for it, so stop trying. // 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 // The download failed — a dropped connection, an expired link. That
// is not a verdict on the photo: leave its state alone so the next // is not a verdict on the photo: leave its state alone so the next
// scroll past it tries again. // 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 /// Persist what a thumbnail attempt learned about a photo, against the
@@ -501,7 +1092,9 @@ impl Core {
#[cfg(test)] #[cfg(test)]
mod thumb_tests { 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 /// A `width`×`height` JPEG, standing in for a camera photo the server never
/// generated a thumbnail for. /// generated a thumbnail for.
@@ -519,7 +1112,7 @@ mod thumb_tests {
#[test] #[test]
fn scaling_fits_the_long_edge_and_keeps_the_aspect_ratio() { fn scaling_fits_the_long_edge_and_keeps_the_aspect_ratio() {
let photo = jpeg(4000, 3000); let photo = jpeg(4000, 3000);
let thumb = scale_thumbnail(&photo).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)) let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes))
.with_guessed_format() .with_guessed_format()
@@ -537,7 +1130,7 @@ mod thumb_tests {
#[test] #[test]
fn a_portrait_photo_fits_its_long_edge_too() { fn a_portrait_photo_fits_its_long_edge_too() {
let thumb = scale_thumbnail(&jpeg(1000, 2000)).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!(thumb.ratio < 1.0, "portrait stays portrait");
assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true)); assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true));
} }
@@ -546,7 +1139,7 @@ mod thumb_tests {
fn undecodable_bytes_are_not_a_thumbnail() { fn undecodable_bytes_are_not_a_thumbnail() {
// What a photo in a format this build has no decoder for looks like: the // What a photo in a format this build has no decoder for looks like: the
// caller writes it off as un-thumbnailable rather than retrying forever. // caller writes it off as un-thumbnailable rather than retrying forever.
assert!(scale_thumbnail(b"not an image at all").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()); 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() { fn ratio_is_read_from_the_header_alone() {
assert_eq!(ratio_of(&jpeg(300, 200)), Some(1.5)); 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);
}
} }
+1
View File
@@ -609,6 +609,7 @@ impl Core {
items.push(SharedItem { items.push(SharedItem {
uid: uid.to_string(), uid: uid.to_string(),
is_dir: n.is_folder(), is_dir: n.is_folder(),
modified: n.modification_time,
name: n.name, name: n.name,
path: self.rel_path_for_uid(&uid).unwrap_or_default(), path: self.rel_path_for_uid(&uid).unwrap_or_default(),
member_count: members, member_count: members,