mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
feat(thumbnails): generate cached previews for Drive files
This commit is contained in:
1 parent
8bd9481902
commit
91b0864fce
9 files changed
+973
-33
No files matched your search
Generated
+1
@@ -3578,6 +3578,7 @@ dependencies = [
|
||||
name = "pdfs-fuse"
|
||||
version = "1.9.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"fuser",
|
||||
"futures",
|
||||
"image",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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.
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<PhotoMonth> },
|
||||
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
|
||||
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).
|
||||
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<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]
|
||||
fn unified_search_request_and_response_roundtrip() {
|
||||
let request = Request::SearchV2 {
|
||||
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user