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

+54 -2
View File
@@ -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<PathBuf> {
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());
}
}
+11 -1
View File
@@ -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<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
/// `(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())),
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())),
+699 -29
View File
@@ -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<GeneratedThumb> {
let image = image::ImageReader::new(std::io::Cursor::new(bytes))
fn scale_thumbnail(bytes: &[u8], name: &str) -> Option<GeneratedThumb> {
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<GeneratedThumb> {
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 {
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<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
// 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<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
/// 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<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 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<String, i64>) {
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<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 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);
}
}
+1
View File
@@ -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,