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"
|
name = "pdfs-fuse"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"fuser",
|
"fuser",
|
||||||
"futures",
|
"futures",
|
||||||
"image",
|
"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"] }
|
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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user