mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
`Core` held a concrete `ProtonDriveClient`, so no test could make Drive answer late, answer stale or not answer. This is the first step of phase 0 in docs/MILESTONE-3.0.0.md. `DriveApi` (`crates/pdfs-fuse/src/drive.rs`) covers the calls the sync core makes: listings, events, creates, uploads, downloads, renames, moves, trashes and restores. `ProtonDriveClient` implements it by forwarding. `Core::drive` holds it, and every call of those methods now goes through it. Sharing, Photos, devices, invitations and public links stay on `Core::client`. The trait takes readers and writers as trait objects, and `impl dyn DriveApi` puts the client's generic signatures back, so call sites read the same. Block reads go through `RevisionRead` instead of the SDK's `RevisionReader`, which only the SDK can construct. No behaviour change.
2503 lines
100 KiB
Rust
2503 lines
100 KiB
Rust
//! The photos timeline and its thumbnails.
|
||
//!
|
||
//! The timeline is a flat, newest-first projection of the photo share, persisted
|
||
//! by [`pdfs_core::db`] so the gallery opens instantly and refreshes behind the
|
||
//! user. Thumbnails the server has none for (anything a camera wrote rather than
|
||
//! a phone) are generated locally and stored as if the server had served them.
|
||
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::ffi::OsStr;
|
||
use std::fs::OpenOptions;
|
||
use std::io::{Read as _, Write as _};
|
||
use std::os::unix::fs::OpenOptionsExt as _;
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::{Command, Stdio};
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::sync::{Once, OnceLock};
|
||
use std::time::{Duration, Instant};
|
||
|
||
use base64::Engine as _;
|
||
use pdfs_core::control::{
|
||
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo,
|
||
ThumbnailBuildStatus, Topic, is_raw_image_name, is_thumbnail_image_name,
|
||
};
|
||
use pdfs_core::db::{self, StoredPhoto};
|
||
use pdfs_core::{CoreError, CoreResult};
|
||
|
||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, Thumbnail, ThumbnailType};
|
||
use tracing::{debug, info, warn};
|
||
|
||
use super::{
|
||
Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority,
|
||
node_size, now_ms, parse_uid,
|
||
};
|
||
|
||
/// Longest edge, in px, of a thumbnail generated locally for a photo the server
|
||
/// has none for. Matches the server's own thumbnail scale closely enough that a
|
||
/// tile can't tell them apart.
|
||
const THUMB_EDGE: u32 = 512;
|
||
/// JPEG quality of a locally generated thumbnail.
|
||
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.
|
||
pub(crate) const THUMB_GEN_CONCURRENCY: usize = 4;
|
||
/// Shots the similar-photo pass fetches thumbnails for at a time.
|
||
const SIMILAR_CHUNK: usize = 64;
|
||
/// Images processed between recursive-build progress updates.
|
||
const THUMB_BUILD_CHUNK: usize = 16;
|
||
/// A recursive build may wait briefly for an opportunistic tile job that
|
||
/// already owns a uid, but never forever.
|
||
const THUMB_BUILD_CLAIM_TIMEOUT: Duration = Duration::from_secs(30);
|
||
/// Backstop for cancellation if a notification races with waiter registration.
|
||
const THUMB_CANCEL_POLL: Duration = Duration::from_millis(100);
|
||
/// 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();
|
||
static FFMPEG_MISSING_WARNING: Once = Once::new();
|
||
/// Largest video downloaded whole to grab one frame for its thumbnail. The
|
||
/// download lands in memory, so a long 4K recording is left without one.
|
||
const VIDEO_THUMB_MAX_BYTES: u64 = 256 * 1024 * 1024;
|
||
/// A single frame is seconds of work even for 4K; this only stops a wedged
|
||
/// ffmpeg from holding a thumbnail permit.
|
||
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30);
|
||
/// Largest thumbnail attached to an upload. Proton's own clients hold theirs to
|
||
/// this, stepping the JPEG quality down until it fits.
|
||
const UPLOAD_THUMB_MAX_BYTES: usize = 60 * 1024;
|
||
/// JPEG qualities tried, in order, for an upload thumbnail over that size.
|
||
const UPLOAD_THUMB_QUALITIES: [u8; 4] = [65, 50, 35, 20];
|
||
/// Largest still image read whole to render the thumbnail its upload carries.
|
||
const UPLOAD_IMAGE_MAX_BYTES: u64 = 256 * 1024 * 1024;
|
||
|
||
/// 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.
|
||
fn ratio_of(bytes: &[u8]) -> Option<f64> {
|
||
let (width, height) = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||
.with_guessed_format()
|
||
.ok()?
|
||
.into_dimensions()
|
||
.ok()?;
|
||
(height > 0).then(|| f64::from(width) / f64::from(height))
|
||
}
|
||
|
||
/// The difference hash of each cached thumbnail in `paths`, keyed like the
|
||
/// input, or [`pdfs_core::similar::NO_HASH`] for one that can't be decoded or
|
||
/// shows no picture. Decoding is spread over the machine's cores.
|
||
fn similar_hashes(paths: Vec<(String, PathBuf)>) -> Vec<(String, u64)> {
|
||
fn hash(path: &Path) -> u64 {
|
||
image::ImageReader::open(path)
|
||
.ok()
|
||
.and_then(|reader| reader.with_guessed_format().ok())
|
||
.and_then(|reader| reader.decode().ok())
|
||
.and_then(|image| {
|
||
let luma = image.to_luma8();
|
||
pdfs_core::similar::difference_hash(
|
||
luma.width() as usize,
|
||
luma.height() as usize,
|
||
luma.as_raw(),
|
||
)
|
||
})
|
||
.unwrap_or(pdfs_core::similar::NO_HASH)
|
||
}
|
||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||
let per_thread = paths.len().div_ceil(threads).max(1);
|
||
std::thread::scope(|scope| {
|
||
let workers: Vec<_> = paths
|
||
.chunks(per_thread)
|
||
.map(|chunk| {
|
||
scope.spawn(move || {
|
||
chunk
|
||
.iter()
|
||
.map(|(uid, path)| (uid.clone(), hash(path)))
|
||
.collect::<Vec<_>>()
|
||
})
|
||
})
|
||
.collect();
|
||
workers
|
||
.into_iter()
|
||
.flat_map(|worker| worker.join().unwrap_or_default())
|
||
.collect()
|
||
})
|
||
}
|
||
|
||
/// A thumbnail the daemon made itself, and the aspect ratio of the photo it was
|
||
/// made from (free at that point — the full image had to be decoded anyway).
|
||
struct GeneratedThumb {
|
||
bytes: Vec<u8>,
|
||
ratio: f64,
|
||
}
|
||
|
||
/// Scaling distinguishes a permanent content verdict from an environmental
|
||
/// failure. Only `Undecodable` may enter the local negative cache.
|
||
enum ScaleAttempt {
|
||
Made(GeneratedThumb),
|
||
Undecodable,
|
||
Unavailable,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy)]
|
||
struct RawPreviewUnavailable;
|
||
|
||
/// How one attempt at generating a missing thumbnail ended. The distinction that
|
||
/// matters is *permanent* versus *transient*: only bytes we cannot decode prove
|
||
/// the photo will never have a thumbnail, and only that verdict is persisted.
|
||
enum ThumbAttempt {
|
||
Made(GeneratedThumb),
|
||
/// Decoded nothing — a format this build has no decoder for, or a video
|
||
/// too large to fetch for one frame. Permanent.
|
||
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
|
||
}
|
||
}
|
||
|
||
/// `true` means publish a new build, `false` means the caller may attach to the
|
||
/// already-running build for the same root. A different root is never silently
|
||
/// accepted as if it were the requested job.
|
||
fn thumbnail_build_may_start(status: &ThumbnailBuildStatus, path: &str) -> CoreResult<bool> {
|
||
if !status.running {
|
||
return Ok(true);
|
||
}
|
||
if status.path == path {
|
||
return Ok(false);
|
||
}
|
||
Err(CoreError::invalid(format!(
|
||
"a thumbnail build is already running for {}",
|
||
if status.path.is_empty() {
|
||
"Proton Drive"
|
||
} else {
|
||
&status.path
|
||
}
|
||
)))
|
||
}
|
||
|
||
/// Scale a full-size photo down to a thumbnail: at most [`THUMB_EDGE`] on its
|
||
/// longest side, JPEG, aspect ratio preserved. Environmental RAW-extraction
|
||
/// failures remain retryable; only bytes conclusively outside the supported
|
||
/// formats return an undecodable verdict.
|
||
///
|
||
/// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool.
|
||
fn scale_thumbnail(bytes: &[u8], name: &str, staging_dir: &Path) -> ScaleAttempt {
|
||
scale_thumbnail_with_exiftool(
|
||
bytes,
|
||
name,
|
||
staging_dir,
|
||
OsStr::new("exiftool"),
|
||
EXIFTOOL_TIMEOUT,
|
||
)
|
||
}
|
||
|
||
fn scale_thumbnail_with_exiftool(
|
||
bytes: &[u8],
|
||
name: &str,
|
||
staging_dir: &Path,
|
||
exiftool: &OsStr,
|
||
timeout: Duration,
|
||
) -> ScaleAttempt {
|
||
let direct = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||
.with_guessed_format()
|
||
.ok()
|
||
.and_then(|reader| reader.decode().ok());
|
||
let image = match direct {
|
||
Some(image) => image,
|
||
None if is_raw_image_name(name) => {
|
||
let (preview, orientation) =
|
||
match extract_raw_preview(bytes, name, staging_dir, exiftool, timeout) {
|
||
Ok(Some(preview)) => preview,
|
||
Ok(None) => return ScaleAttempt::Undecodable,
|
||
Err(_) => return ScaleAttempt::Unavailable,
|
||
};
|
||
let Some(mut image) = image::ImageReader::new(std::io::Cursor::new(preview))
|
||
.with_guessed_format()
|
||
.ok()
|
||
.and_then(|reader| reader.decode().ok())
|
||
else {
|
||
return ScaleAttempt::Undecodable;
|
||
};
|
||
if let Some(orientation) = image::metadata::Orientation::from_exif(orientation) {
|
||
image.apply_orientation(orientation);
|
||
}
|
||
image
|
||
}
|
||
None => return ScaleAttempt::Undecodable,
|
||
};
|
||
encode_thumb(&image)
|
||
}
|
||
|
||
/// Shrink a decoded image to a [`THUMB_EDGE`] JPEG, keeping its aspect ratio.
|
||
fn encode_thumb(image: &image::DynamicImage) -> ScaleAttempt {
|
||
let (width, height) = (image.width(), image.height());
|
||
if width == 0 || height == 0 {
|
||
return ScaleAttempt::Undecodable;
|
||
}
|
||
let ratio = f64::from(width) / f64::from(height);
|
||
|
||
// `thumbnail` fits the image *inside* the box, so the longest edge lands on
|
||
// THUMB_EDGE and the ratio is untouched.
|
||
let thumb = image.thumbnail(THUMB_EDGE, THUMB_EDGE).to_rgb8();
|
||
let mut bytes = Vec::new();
|
||
if image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, THUMB_QUALITY)
|
||
.encode_image(&thumb)
|
||
.is_err()
|
||
{
|
||
return ScaleAttempt::Unavailable;
|
||
}
|
||
ScaleAttempt::Made(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_in(staging_dir: &Path, 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 = staging_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);
|
||
}
|
||
}
|
||
|
||
/// How a bounded helper run failed.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum ToolError {
|
||
/// The helper isn't installed.
|
||
Missing,
|
||
/// It couldn't be started or read, or overran its budget and was killed.
|
||
Unavailable,
|
||
/// It ran and exited non-zero.
|
||
Failed,
|
||
}
|
||
|
||
/// Run a helper to completion within `timeout` and return its stdout, drained
|
||
/// concurrently. The latter matters for exiftool's full-size `JpgFromRaw`
|
||
/// fallbacks and for ffmpeg's frames, either of which can exceed a pipe buffer
|
||
/// long before the child exits.
|
||
fn run_bounded(mut command: Command, timeout: Duration) -> Result<Vec<u8>, ToolError> {
|
||
command
|
||
.stdin(Stdio::null())
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::null());
|
||
let mut child = command.spawn().map_err(|error| {
|
||
if error.kind() == std::io::ErrorKind::NotFound {
|
||
ToolError::Missing
|
||
} else {
|
||
ToolError::Unavailable
|
||
}
|
||
})?;
|
||
let Some(mut stdout) = child.stdout.take() else {
|
||
let _ = child.kill();
|
||
let _ = child.wait();
|
||
return Err(ToolError::Unavailable);
|
||
};
|
||
let reader = std::thread::spawn(move || {
|
||
let mut output = Vec::new();
|
||
stdout.read_to_end(&mut output).map(|_| output)
|
||
});
|
||
let started = Instant::now();
|
||
let status = loop {
|
||
match child.try_wait() {
|
||
Ok(Some(status)) => break Some(status.success()),
|
||
Ok(None) if started.elapsed() < timeout => {
|
||
std::thread::sleep(Duration::from_millis(10));
|
||
}
|
||
Ok(None) | Err(_) => {
|
||
let _ = child.kill();
|
||
let _ = child.wait();
|
||
break None;
|
||
}
|
||
}
|
||
};
|
||
let output = reader
|
||
.join()
|
||
.ok()
|
||
.and_then(Result::ok)
|
||
.ok_or(ToolError::Unavailable)?;
|
||
match status {
|
||
Some(true) => Ok(output),
|
||
Some(false) => Err(ToolError::Failed),
|
||
None => Err(ToolError::Unavailable),
|
||
}
|
||
}
|
||
|
||
/// Run one bounded exiftool JSON query.
|
||
fn exiftool_query(
|
||
exiftool: &OsStr,
|
||
path: &Path,
|
||
tags: &[&str],
|
||
timeout: Duration,
|
||
) -> Result<serde_json::Value, RawPreviewUnavailable> {
|
||
let mut command = Command::new(exiftool);
|
||
command.args(["-j", "-b", "-n"]);
|
||
command.args(tags);
|
||
command.arg(path);
|
||
let output = run_bounded(command, timeout).map_err(|error| {
|
||
if error == ToolError::Missing {
|
||
EXIFTOOL_MISSING_WARNING.call_once(|| {
|
||
warn!(
|
||
"exiftool is not installed; camera RAW thumbnails are unavailable \
|
||
(Debian/Ubuntu: libimage-exiftool-perl)"
|
||
);
|
||
});
|
||
}
|
||
RawPreviewUnavailable
|
||
})?;
|
||
serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable)
|
||
}
|
||
|
||
/// Whether `name` is a video, whose thumbnail is a frame ffmpeg grabs rather
|
||
/// than a picture the image decoder can scale.
|
||
fn is_video_name(name: &str) -> bool {
|
||
PhotoKind::classify(Some(name), None) == PhotoKind::Video
|
||
}
|
||
|
||
/// Whether ffmpeg can be run at all. Asked once, before a video is downloaded:
|
||
/// without ffmpeg every scroll past a video would fetch it whole for nothing.
|
||
fn ffmpeg_available() -> bool {
|
||
static AVAILABLE: OnceLock<bool> = OnceLock::new();
|
||
*AVAILABLE.get_or_init(|| {
|
||
let mut command = Command::new("ffmpeg");
|
||
command.arg("-version");
|
||
let available = run_bounded(command, Duration::from_secs(5)).is_ok();
|
||
if !available {
|
||
warn_ffmpeg_missing();
|
||
}
|
||
available
|
||
})
|
||
}
|
||
|
||
fn warn_ffmpeg_missing() {
|
||
FFMPEG_MISSING_WARNING.call_once(|| {
|
||
warn!("ffmpeg is not installed; video thumbnails are unavailable");
|
||
});
|
||
}
|
||
|
||
/// A thumbnail from one frame of a downloaded video, which ffmpeg reads from a
|
||
/// staging copy. See [`video_file_thumbnail`].
|
||
fn video_thumbnail(
|
||
bytes: &[u8],
|
||
name: &str,
|
||
staging_dir: &Path,
|
||
ffmpeg: &OsStr,
|
||
timeout: Duration,
|
||
) -> ScaleAttempt {
|
||
let Some(file) = RawTempFile::create_in(staging_dir, bytes, name) else {
|
||
return ScaleAttempt::Unavailable;
|
||
};
|
||
video_file_thumbnail(&file.0, ffmpeg, timeout)
|
||
}
|
||
|
||
/// A thumbnail from one frame of the video at `path`. A second in, past the
|
||
/// black lead-in most clips open on, and the first frame for a clip shorter than
|
||
/// that. ffmpeg applies the clip's rotation, so a portrait phone video comes out
|
||
/// upright.
|
||
///
|
||
/// Missing or wedged ffmpeg is retryable; a file ffmpeg can take no frame from
|
||
/// is the permanent verdict.
|
||
fn video_file_thumbnail(path: &Path, ffmpeg: &OsStr, timeout: Duration) -> ScaleAttempt {
|
||
for seek in ["1", "0"] {
|
||
let mut command = Command::new(ffmpeg);
|
||
command.args(["-nostdin", "-loglevel", "error", "-ss", seek, "-i"]);
|
||
command.arg(path);
|
||
command.args(["-frames:v", "1", "-f", "image2pipe", "-c:v", "mjpeg", "-"]);
|
||
match run_bounded(command, timeout) {
|
||
Ok(frame) if !frame.is_empty() => {
|
||
return match image::load_from_memory(&frame) {
|
||
Ok(image) => encode_thumb(&image),
|
||
Err(_) => ScaleAttempt::Undecodable,
|
||
};
|
||
}
|
||
// No frame that far in: try from the start.
|
||
Ok(_) | Err(ToolError::Failed) => continue,
|
||
Err(ToolError::Missing) => {
|
||
warn_ffmpeg_missing();
|
||
return ScaleAttempt::Unavailable;
|
||
}
|
||
Err(ToolError::Unavailable) => return ScaleAttempt::Unavailable,
|
||
}
|
||
}
|
||
ScaleAttempt::Undecodable
|
||
}
|
||
|
||
/// The thumbnail to attach to an upload of the local file at `path`, which is
|
||
/// named `name` in Drive. Without one, Drive's own apps show a generic icon and
|
||
/// the gallery has to download the whole file to make its own.
|
||
///
|
||
/// Best effort: a file that isn't an image or a video, is too large to read, or
|
||
/// can't be decoded uploads without a thumbnail, as it always did. Blocking and
|
||
/// CPU-bound, so async callers run it on the blocking pool.
|
||
pub(crate) fn upload_thumbnails(path: &Path, name: &str, staging_dir: &Path) -> Vec<Thumbnail> {
|
||
if is_video_name(name) && !ffmpeg_available() {
|
||
return Vec::new();
|
||
}
|
||
upload_thumbnails_with(path, name, staging_dir, OsStr::new("ffmpeg"))
|
||
}
|
||
|
||
fn upload_thumbnails_with(
|
||
path: &Path,
|
||
name: &str,
|
||
staging_dir: &Path,
|
||
ffmpeg: &OsStr,
|
||
) -> Vec<Thumbnail> {
|
||
let attempt = if is_video_name(name) {
|
||
if !is_video_file(path, name) {
|
||
return Vec::new();
|
||
}
|
||
video_file_thumbnail(path, ffmpeg, FFMPEG_TIMEOUT)
|
||
} else if is_thumbnail_image_name(name) {
|
||
let fits = std::fs::metadata(path).is_ok_and(|meta| meta.len() <= UPLOAD_IMAGE_MAX_BYTES);
|
||
let Some(bytes) = fits.then(|| std::fs::read(path).ok()).flatten() else {
|
||
return Vec::new();
|
||
};
|
||
scale_thumbnail(&bytes, name, staging_dir)
|
||
} else {
|
||
return Vec::new();
|
||
};
|
||
let ScaleAttempt::Made(thumb) = attempt else {
|
||
debug!(name, "uploading without a thumbnail; none could be made");
|
||
return Vec::new();
|
||
};
|
||
fit_upload_thumb(thumb.bytes)
|
||
.and_then(|bytes| Thumbnail::new(ThumbnailType::Thumbnail, bytes).ok())
|
||
.into_iter()
|
||
.collect()
|
||
}
|
||
|
||
/// `.ts` is TypeScript as often as it is an MPEG transport stream, so a source
|
||
/// tree must not cost an ffmpeg run per file. A transport stream opens on its
|
||
/// 0x47 sync byte; every other video extension is taken at its word.
|
||
fn is_video_file(path: &Path, name: &str) -> bool {
|
||
if !name.to_ascii_lowercase().ends_with(".ts") {
|
||
return true;
|
||
}
|
||
let mut first = [0u8; 1];
|
||
std::fs::File::open(path)
|
||
.and_then(|mut file| file.read_exact(&mut first))
|
||
.is_ok_and(|()| first[0] == 0x47)
|
||
}
|
||
|
||
/// Re-encode a thumbnail at falling quality until it fits
|
||
/// [`UPLOAD_THUMB_MAX_BYTES`], and at half the size if even the lowest quality
|
||
/// doesn't. `None` if nothing fits.
|
||
fn fit_upload_thumb(bytes: Vec<u8>) -> Option<Vec<u8>> {
|
||
if bytes.len() <= UPLOAD_THUMB_MAX_BYTES {
|
||
return Some(bytes);
|
||
}
|
||
let image = image::load_from_memory(&bytes).ok()?;
|
||
let half = image.thumbnail(image.width() / 2, image.height() / 2);
|
||
[image.to_rgb8(), half.to_rgb8()].iter().find_map(|image| {
|
||
UPLOAD_THUMB_QUALITIES.iter().find_map(|&quality| {
|
||
let mut smaller = Vec::new();
|
||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut smaller, quality)
|
||
.encode_image(image)
|
||
.ok()?;
|
||
(smaller.len() <= UPLOAD_THUMB_MAX_BYTES).then_some(smaller)
|
||
})
|
||
})
|
||
}
|
||
|
||
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,
|
||
staging_dir: &Path,
|
||
exiftool: &OsStr,
|
||
timeout: Duration,
|
||
) -> Result<Option<(Vec<u8>, u8)>, RawPreviewUnavailable> {
|
||
let file = RawTempFile::create_in(staging_dir, bytes, name).ok_or(RawPreviewUnavailable)?;
|
||
let primary = exiftool_query(
|
||
exiftool,
|
||
&file.0,
|
||
&["-Orientation", "-PreviewImage"],
|
||
timeout,
|
||
)?;
|
||
let primary = exiftool_object(&primary).ok_or(RawPreviewUnavailable)?;
|
||
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 Ok(Some((preview, orientation)));
|
||
}
|
||
|
||
const FALLBACKS: [&str; 5] = [
|
||
"OtherImage",
|
||
"JpgFromRaw",
|
||
"PreviewTIFF",
|
||
"ThumbnailImage",
|
||
"ThumbnailTIFF",
|
||
];
|
||
let fallback = exiftool_query(
|
||
exiftool,
|
||
&file.0,
|
||
&[
|
||
"-OtherImage",
|
||
"-JpgFromRaw",
|
||
"-PreviewTIFF",
|
||
"-ThumbnailImage",
|
||
"-ThumbnailTIFF",
|
||
],
|
||
timeout,
|
||
)?;
|
||
let fallback = exiftool_object(&fallback).ok_or(RawPreviewUnavailable)?;
|
||
Ok(FALLBACKS
|
||
.iter()
|
||
.find_map(|key| exiftool_binary(fallback, key))
|
||
.map(|preview| (preview, orientation)))
|
||
}
|
||
|
||
/// What a [`Core::trash_photos`] batch did: the uids that left the server, and
|
||
/// the ones that did not with the reason each was refused.
|
||
pub(crate) type TrashOutcome = (Vec<String>, Vec<(String, String)>);
|
||
|
||
/// Formats supported by the local thumbnail decoder and exposed by the GUI.
|
||
/// Clears the timeline refresh's progress when dropped, so an error or a
|
||
/// cancelled refresh doesn't leave a bar standing.
|
||
struct ProgressGuard<'a>(&'a parking_lot::Mutex<Option<(usize, usize)>>);
|
||
|
||
impl Drop for ProgressGuard<'_> {
|
||
fn drop(&mut self) {
|
||
*self.0.lock() = None;
|
||
}
|
||
}
|
||
|
||
/// What a timeline enrichment pass resolves for one photo, beyond what the
|
||
/// timeline listing already carries.
|
||
#[derive(Clone, Debug)]
|
||
struct PhotoMeta {
|
||
name: Option<String>,
|
||
media_type: Option<String>,
|
||
favorite: bool,
|
||
content_hash: Option<String>,
|
||
/// Where the photo was taken, as its uploader recorded it.
|
||
location: Option<(f64, f64)>,
|
||
}
|
||
|
||
impl Core {
|
||
/// [`upload_thumbnails`] for a file about to upload, staging any RAW copy
|
||
/// under this cache. Blocking.
|
||
pub(crate) fn upload_thumbnails(&self, path: &Path, name: &str) -> Vec<Thumbnail> {
|
||
upload_thumbnails(path, name, self.cache.raw_thumbnail_staging_dir())
|
||
}
|
||
|
||
/// [`Core::upload_thumbnails`] on the blocking pool, for async callers.
|
||
pub(crate) async fn upload_thumbnails_async(
|
||
&self,
|
||
path: PathBuf,
|
||
name: String,
|
||
) -> Vec<Thumbnail> {
|
||
let staging_dir = self.cache.raw_thumbnail_staging_dir().to_path_buf();
|
||
tokio::task::spawn_blocking(move || upload_thumbnails(&path, &name, &staging_dir))
|
||
.await
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
pub(crate) fn photos_timeline(
|
||
&self,
|
||
offset: usize,
|
||
limit: usize,
|
||
kind: Option<PhotoKind>,
|
||
range: Option<(i64, i64)>,
|
||
favorites: bool,
|
||
not_in_album: bool,
|
||
) -> CoreResult<Option<Vec<PhotoItem>>> {
|
||
let count = self.db.photos_count().map_err(CoreError::from)?;
|
||
if count == 0 {
|
||
// Nothing to serve, so this one request has to wait for the fetch —
|
||
// unless we already know the account has no photos volume and the
|
||
// answer is a fresh "no".
|
||
let known_empty = self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() == Some(0);
|
||
if known_empty && !self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
|
||
return Ok(None);
|
||
}
|
||
if !self.rt.block_on(self.refresh_timeline())? {
|
||
return Ok(None);
|
||
}
|
||
} else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
|
||
self.spawn_timeline_refresh();
|
||
}
|
||
// The filter is only as good as the album contents on disk, and those
|
||
// are fetched lazily, per album opened. Fill in the missing ones before
|
||
// the first page; later pages reuse what that fetch stored.
|
||
if not_in_album && offset == 0 {
|
||
self.ensure_album_contents()?;
|
||
}
|
||
|
||
let page = self
|
||
.db
|
||
.photos_page(offset, limit, kind, range, favorites, not_in_album)
|
||
.map_err(CoreError::from)?;
|
||
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
|
||
}
|
||
|
||
/// Project a persisted photo into the wire item the front-end paints: its
|
||
/// learned aspect ratio, its thumbnail verdict, and the on-disk path of its
|
||
/// thumbnail when one is cached (tagged with the capture time, which is the
|
||
/// only revision handle a photo carries).
|
||
pub(crate) fn photo_item(&self, photo: StoredPhoto) -> PhotoItem {
|
||
let thumb_path = parse_uid(&photo.uid).and_then(|uid| {
|
||
self.cache
|
||
.cached_thumbnail_path(&uid, ThumbnailType::Thumbnail.as_i32(), photo.capture_time)
|
||
.map(|p| p.display().to_string())
|
||
});
|
||
PhotoItem {
|
||
uid: photo.uid,
|
||
capture_time: photo.capture_time,
|
||
thumb_path,
|
||
name: photo.name,
|
||
ratio: photo.ratio,
|
||
no_thumb: photo.thumb_state == db::THUMB_NONE,
|
||
kind: photo.kind,
|
||
favorite: photo.favorite,
|
||
group_size: photo.group_size as u32,
|
||
has_raw: photo.has_raw,
|
||
}
|
||
}
|
||
|
||
/// Add or remove Proton's `Favorite` tag on a photo, then record the change
|
||
/// locally so the gallery reflects it without waiting for a timeline refresh.
|
||
///
|
||
/// The SDK reports one outcome per photo rather than failing the call, so the
|
||
/// single outcome is unwrapped back into this request's result.
|
||
pub(crate) fn set_photo_favorite(&self, uid: &NodeUid, favorite: bool) -> CoreResult<()> {
|
||
let photos = self.photos();
|
||
let update = PhotoTagsUpdate {
|
||
node_uid: uid.clone(),
|
||
tags_to_add: if favorite {
|
||
vec![PhotoTag::Favorite]
|
||
} else {
|
||
Vec::new()
|
||
},
|
||
tags_to_remove: if favorite {
|
||
Vec::new()
|
||
} else {
|
||
vec![PhotoTag::Favorite]
|
||
},
|
||
};
|
||
let outcomes = self
|
||
.rt
|
||
.block_on(photos.update_photos(std::slice::from_ref(&update)))
|
||
.map_err(|e| CoreError::from_api(&e, "update photo tags"))?;
|
||
pdfs_core::batch::into_unit(outcomes)
|
||
.map_err(|e| CoreError::from_api(&e, "update photo tags"))?;
|
||
// The photo event feed replays this write back at us as a bare
|
||
// `NodeUpdated`, which is indistinguishable from the same photo being
|
||
// favorited on a phone. Claim the echo, or the gallery pays for a
|
||
// resolve of a node whose one changed field is already stored below.
|
||
self.note_self_change(uid);
|
||
self.db
|
||
.photos_set_favorite(&uid.to_string(), favorite)
|
||
.map_err(CoreError::from)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// The timeline's duplicate sets, as wire items (see
|
||
/// [`db::Db::photo_duplicates`]).
|
||
pub(crate) fn photo_duplicates(&self) -> CoreResult<Vec<Vec<PhotoItem>>> {
|
||
let sets = self.db.photo_duplicates().map_err(CoreError::from)?;
|
||
Ok(sets
|
||
.into_iter()
|
||
.map(|set| set.into_iter().map(|p| self.photo_item(p)).collect())
|
||
.collect())
|
||
}
|
||
|
||
/// The sets of shots that look alike, as wire items (see
|
||
/// [`db::Db::photo_similar`]), how far hashing has got while it runs, and
|
||
/// how many shots couldn't be compared once it's done. Starts a hashing
|
||
/// pass for the shots not hashed yet unless one is running.
|
||
pub(crate) fn photo_similar(
|
||
&self,
|
||
) -> CoreResult<(Vec<Vec<PhotoItem>>, Option<MappingProgress>, usize)> {
|
||
self.spawn_similar_hashing();
|
||
let sets = self.db.photo_similar().map_err(CoreError::from)?;
|
||
let (done, total) = self.db.similar_progress().map_err(CoreError::from)?;
|
||
let hashing = self
|
||
.similar_hashing
|
||
.load(Ordering::SeqCst)
|
||
.then_some(MappingProgress { done, total });
|
||
let uncompared = if hashing.is_some() { 0 } else { total - done };
|
||
let sets = sets
|
||
.into_iter()
|
||
.map(|set| set.into_iter().map(|p| self.photo_item(p)).collect())
|
||
.collect();
|
||
Ok((sets, hashing, uncompared))
|
||
}
|
||
|
||
fn spawn_similar_hashing(&self) {
|
||
if self.similar_hashing.swap(true, Ordering::SeqCst) {
|
||
return;
|
||
}
|
||
let core = self.clone();
|
||
let spawned = std::thread::Builder::new()
|
||
.name("pdfs-similar".into())
|
||
.spawn(move || {
|
||
if let Err(e) = core.hash_similar() {
|
||
warn!(error = %e, "similar-photo hashing failed");
|
||
}
|
||
core.similar_hashing.store(false, Ordering::SeqCst);
|
||
});
|
||
if let Err(e) = spawned {
|
||
warn!(error = %e, "could not start similar-photo hashing");
|
||
self.similar_hashing.store(false, Ordering::SeqCst);
|
||
}
|
||
}
|
||
|
||
/// Hash the look of every shot not hashed yet, a chunk at a time. Only
|
||
/// thumbnails the server has are used: a photo without one is skipped
|
||
/// rather than downloaded whole, and tried again on the next pass.
|
||
fn hash_similar(&self) -> CoreResult<()> {
|
||
let mut tried: HashSet<String> = HashSet::new();
|
||
loop {
|
||
if self.shutdown.is_stopping() {
|
||
return Ok(());
|
||
}
|
||
// Shots tried this pass without a thumbnail are still unhashed and
|
||
// come back first, so ask past them.
|
||
let chunk: Vec<NodeUid> = self
|
||
.db
|
||
.photos_unhashed(tried.len() + SIMILAR_CHUNK)
|
||
.map_err(CoreError::from)?
|
||
.into_iter()
|
||
.filter(|photo| tried.insert(photo.uid.clone()))
|
||
.take(SIMILAR_CHUNK)
|
||
.filter_map(|photo| parse_uid(&photo.uid))
|
||
.collect();
|
||
if chunk.is_empty() {
|
||
return Ok(());
|
||
}
|
||
let paths: Vec<(String, PathBuf)> = self
|
||
.photo_thumbs_with(&chunk, false)
|
||
.into_iter()
|
||
.filter_map(|thumb| Some((thumb.uid, PathBuf::from(thumb.path?))))
|
||
.collect();
|
||
for (uid, hash) in similar_hashes(paths) {
|
||
self.db
|
||
.photo_set_similar_hash(&uid, hash)
|
||
.map_err(CoreError::from)?;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The towns the photos were taken in, as wire items. A town the bundled
|
||
/// table no longer knows (the stored id came from an older table) is left
|
||
/// out rather than shown without a name.
|
||
pub(crate) fn photo_places(&self) -> CoreResult<Vec<PlaceInfo>> {
|
||
let places = self.db.photo_places().map_err(CoreError::from)?;
|
||
Ok(places
|
||
.into_iter()
|
||
.filter_map(|place| {
|
||
let city = pdfs_core::places::city(place.id)?;
|
||
Some(PlaceInfo {
|
||
id: place.id,
|
||
name: city.name.to_string(),
|
||
country: city.country.to_string(),
|
||
latitude: city.latitude,
|
||
longitude: city.longitude,
|
||
photo_count: place.count,
|
||
cover: self.photo_item(place.cover),
|
||
})
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
/// A page of the photos taken in town `id`, as wire items.
|
||
pub(crate) fn place_photos(
|
||
&self,
|
||
id: u32,
|
||
offset: usize,
|
||
limit: usize,
|
||
) -> CoreResult<Vec<PhotoItem>> {
|
||
let photos = self
|
||
.db
|
||
.photos_at_place(id, offset, limit)
|
||
.map_err(CoreError::from)?;
|
||
Ok(photos.into_iter().map(|p| self.photo_item(p)).collect())
|
||
}
|
||
|
||
/// Every photo taken in town `id` with its own location, as wire items.
|
||
pub(crate) fn place_locations(&self, id: u32) -> CoreResult<Vec<PhotoLocation>> {
|
||
let photos = self
|
||
.db
|
||
.photo_locations_at_place(id)
|
||
.map_err(CoreError::from)?;
|
||
Ok(photos
|
||
.into_iter()
|
||
.map(|(photo, (latitude, longitude))| PhotoLocation {
|
||
photo: self.photo_item(photo),
|
||
latitude,
|
||
longitude,
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
/// Photos taken on today's date in earlier years, as wire items. Served
|
||
/// from the stored timeline only: the strip is a nicety, not worth a wait
|
||
/// on the network, and the timeline's own load keeps that store fresh.
|
||
pub(crate) fn photos_on_this_day(&self, limit: usize) -> CoreResult<Vec<PhotoItem>> {
|
||
let now = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map_or(0, |d| d.as_secs() as i64);
|
||
let photos = self
|
||
.db
|
||
.photos_on_this_day(now, limit)
|
||
.map_err(CoreError::from)?;
|
||
Ok(photos.into_iter().map(|p| self.photo_item(p)).collect())
|
||
}
|
||
|
||
/// Every file of one photo's group, in server order, as wire items. A photo
|
||
/// the timeline does not hold comes back empty, which the lightbox reads as
|
||
/// "nothing to switch between".
|
||
pub(crate) fn photo_group(&self, uid: &str) -> CoreResult<Vec<PhotoItem>> {
|
||
let members = self.db.photos_group(uid).map_err(CoreError::from)?;
|
||
Ok(members
|
||
.into_iter()
|
||
.map(|photo| self.photo_item(photo))
|
||
.collect())
|
||
}
|
||
|
||
/// Every uid asked for, plus the other files of any group they belong to,
|
||
/// each once. A uid the timeline does not hold (an album photo on someone
|
||
/// else's volume) is passed through unchanged.
|
||
fn expand_photo_groups(&self, uids: &[NodeUid]) -> Vec<NodeUid> {
|
||
let mut out: Vec<NodeUid> = Vec::with_capacity(uids.len());
|
||
let mut seen: HashSet<String> = HashSet::new();
|
||
for uid in uids {
|
||
let members = self.db.photos_group(&uid.to_string()).unwrap_or_default();
|
||
let mut group: Vec<NodeUid> = members
|
||
.iter()
|
||
.filter_map(|photo| parse_uid(&photo.uid))
|
||
.collect();
|
||
if group.is_empty() {
|
||
group.push(uid.clone());
|
||
}
|
||
for member in group {
|
||
if seen.insert(member.to_string()) {
|
||
out.push(member);
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Mutation admission for a photo, which [`Core::require_uid_writable`]
|
||
/// cannot answer on its own.
|
||
///
|
||
/// Photos live on the photo volume, and that volume is not part of the FUSE
|
||
/// tree: a photo has no `nodes` row, so every authority comes back
|
||
/// [`WriteAuthority::Unknown`] and the plain check refuses the whole
|
||
/// library. Membership of our own timeline is the authority that does exist
|
||
/// here — the timeline is enumerated from our photo share, so a uid in it is
|
||
/// ours to trash. Anything else (an album photo on someone else's volume)
|
||
/// still fails closed.
|
||
fn require_photo_writable(&self, uid: &NodeUid) -> Result<(), fuser::Errno> {
|
||
match self.uid_write_authority(uid) {
|
||
WriteAuthority::Writable => Ok(()),
|
||
WriteAuthority::Denied => Err(fuser::Errno::EACCES),
|
||
WriteAuthority::Unknown => {
|
||
let key = uid.to_string();
|
||
let known = self
|
||
.db
|
||
.photos_by_uid(std::slice::from_ref(&key))
|
||
.map(|rows| !rows.is_empty())
|
||
.unwrap_or(false);
|
||
if known {
|
||
Ok(())
|
||
} else {
|
||
Err(fuser::Errno::EACCES)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Trash photos by uid, and forget them locally.
|
||
///
|
||
/// The gallery is not part of the FUSE mount, so this is the photos-side
|
||
/// counterpart of [`Core::delete`]: same destination (Proton trash, so the
|
||
/// Trash page can restore them), addressed by uid because that is all a tile
|
||
/// has.
|
||
///
|
||
/// The server answers per node, and so does this: a batch where one photo
|
||
/// fails still removes the ones that went, and the caller is told which is
|
||
/// which. Only what actually left the server is forgotten locally.
|
||
///
|
||
/// A photo that is one file of a group takes its whole group with it. The
|
||
/// gallery shows a RAW+JPEG shot as one tile, so deleting that tile has to
|
||
/// delete the shot — leaving the RAW behind would put the photo back on the
|
||
/// Raw tab and nowhere else. `files_only` turns that off, for removing
|
||
/// one copy of a shot that is stored twice.
|
||
pub(crate) fn trash_photos(
|
||
&self,
|
||
uids: &[NodeUid],
|
||
files_only: bool,
|
||
) -> CoreResult<TrashOutcome> {
|
||
if uids.is_empty() {
|
||
return Ok((Vec::new(), Vec::new()));
|
||
}
|
||
let expanded;
|
||
let uids = if files_only {
|
||
uids
|
||
} else {
|
||
expanded = self.expand_photo_groups(uids);
|
||
&expanded
|
||
};
|
||
for uid in uids {
|
||
self.require_photo_writable(uid)
|
||
.map_err(|errno| self.errno_error(errno, "trash access"))?;
|
||
}
|
||
let outcomes = self
|
||
.rt
|
||
.block_on(self.drive.trash_nodes(uids))
|
||
.map_err(|e| CoreError::from_api(&e, "trash"))?;
|
||
let (trashed, failed) = pdfs_core::batch::split(outcomes);
|
||
|
||
let trashed: Vec<String> = trashed.iter().map(|uid| uid.to_string()).collect();
|
||
for uid in &trashed {
|
||
if let Some(parsed) = parse_uid(uid) {
|
||
self.cache.evict(&parsed);
|
||
self.evict_reader(&parsed);
|
||
}
|
||
}
|
||
// The timeline is served from the database, so removing the rows is what
|
||
// makes the photos disappear now rather than at the next refresh.
|
||
if let Err(error) = self.db.photos_delete(&trashed) {
|
||
warn!(%error, "trashed photos could not be removed from the timeline");
|
||
}
|
||
self.invalidate_photos();
|
||
self.invalidate_albums();
|
||
self.invalidate_trash();
|
||
|
||
let failed = failed
|
||
.into_iter()
|
||
.map(|(uid, error)| (uid.to_string(), error.to_string()))
|
||
.collect();
|
||
Ok((trashed, failed))
|
||
}
|
||
|
||
/// Thumbnails for `uids`, served from the cache, fetched from the server for
|
||
/// whatever is missing, and — for the photos the server has no thumbnail for
|
||
/// at all — *generated locally* from the full file (see
|
||
/// [`Core::generate_thumbs`]). Requested on demand as tiles scroll into view,
|
||
/// so a cold timeline paints immediately and only the photos actually on
|
||
/// screen cost a round-trip.
|
||
///
|
||
/// A photo absent from the persisted timeline is skipped: its capture time is
|
||
/// the cache's validity tag, and guessing that would poison the cache.
|
||
///
|
||
/// A camera raw is served by the JPEG of the same shot when there is one:
|
||
/// the two files show one picture, and the JPEG is already decodable in
|
||
/// milliseconds where the raw costs a full download and a raw decode. The
|
||
/// tile still answers under the uid that was asked for.
|
||
pub(crate) fn photo_thumbs(&self, requested: &[NodeUid]) -> Vec<PhotoThumb> {
|
||
self.photo_thumbs_with(requested, true)
|
||
}
|
||
|
||
/// [`Core::photo_thumbs`], with local generation only when `generate` is
|
||
/// set. Without it, a photo the server has no thumbnail for is left
|
||
/// without one rather than downloaded whole.
|
||
fn photo_thumbs_with(&self, requested: &[NodeUid], generate: bool) -> Vec<PhotoThumb> {
|
||
let ttype = ThumbnailType::Thumbnail.as_i32();
|
||
// Only a raw can be stood in for, and that is one batched read; the
|
||
// per-shot group lookup is then paid for raws alone, not for a page of
|
||
// ordinary photos.
|
||
let asked: Vec<String> = requested.iter().map(|u| u.to_string()).collect();
|
||
let raws: HashSet<String> = self
|
||
.db
|
||
.photos_by_uid(&asked)
|
||
.unwrap_or_default()
|
||
.into_iter()
|
||
.filter(|photo| photo.kind == PhotoKind::Raw)
|
||
.map(|photo| photo.uid)
|
||
.collect();
|
||
let sources: Vec<NodeUid> = requested
|
||
.iter()
|
||
.map(|uid| {
|
||
if raws.contains(&uid.to_string()) {
|
||
self.thumb_source(uid)
|
||
} else {
|
||
uid.clone()
|
||
}
|
||
})
|
||
.collect();
|
||
let uids = &sources;
|
||
let keys: Vec<String> = uids.iter().map(|u| u.to_string()).collect();
|
||
let mut stored = self.db.photos_by_uid(&keys).unwrap_or_default();
|
||
// Album covers and the contents of an album shared with us are not in our
|
||
// own timeline, so their capture time — the cache's validity tag — only
|
||
// exists on the album rows. Fill in whatever the timeline didn't cover.
|
||
{
|
||
let have: HashSet<String> = stored.iter().map(|p| p.uid.clone()).collect();
|
||
let missing: Vec<String> = keys
|
||
.iter()
|
||
.filter(|k| !have.contains(*k))
|
||
.cloned()
|
||
.collect();
|
||
stored.extend(self.db.album_photos_by_uid(&missing).unwrap_or_default());
|
||
}
|
||
let stored = stored;
|
||
let tags: HashMap<String, i64> = stored
|
||
.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.
|
||
let want: Vec<NodeUid> = uids
|
||
.iter()
|
||
.filter(|uid| {
|
||
let key = uid.to_string();
|
||
stored
|
||
.iter()
|
||
.find(|p| p.uid == key)
|
||
.is_some_and(|p| p.thumb_state != db::THUMB_NONE)
|
||
&& tags.get(&key).is_some_and(|&tag| {
|
||
self.cache.cached_thumbnail_path(uid, ttype, tag).is_none()
|
||
})
|
||
})
|
||
.cloned()
|
||
.collect();
|
||
|
||
if !want.is_empty() {
|
||
let mut missing = want.clone();
|
||
match self.rt.block_on(
|
||
self.photos()
|
||
.enumerate_thumbnails(&want, ThumbnailType::Thumbnail),
|
||
) {
|
||
Ok(thumbs) => {
|
||
for ft in thumbs {
|
||
let Some(&tag) = tags.get(&ft.file_uid.to_string()) else {
|
||
continue;
|
||
};
|
||
let Ok(bytes) = ft.result else { continue };
|
||
if bytes.is_empty() {
|
||
continue;
|
||
}
|
||
if self
|
||
.cache
|
||
.store_thumbnail(&ft.file_uid, ttype, tag, &bytes)
|
||
.is_ok()
|
||
{
|
||
missing.retain(|uid| uid != &ft.file_uid);
|
||
self.record_thumb(&ft.file_uid, db::THUMB_HAVE, ratio_of(&bytes));
|
||
}
|
||
}
|
||
}
|
||
// A failed batch is not a verdict: leave every uid in `missing` so
|
||
// the local fallback still gives those tiles an image.
|
||
Err(e) => warn!(error = %e, "batch photo thumbnails failed"),
|
||
}
|
||
|
||
// Whatever the server had nothing for gets a thumbnail made from its
|
||
// own bytes — this is what fills in camera photos uploaded by clients
|
||
// that never generated one. Off the request path: a full-size photo
|
||
// takes far longer to fetch than the whole rest of the batch, and the
|
||
// thumbnails that *are* ready must not wait behind it.
|
||
if generate && !missing.is_empty() {
|
||
self.spawn_generate_thumbs(missing, &tags, &names, ThumbJob::Photos);
|
||
}
|
||
}
|
||
|
||
let pending = self.thumb_gen.lock();
|
||
requested
|
||
.iter()
|
||
.zip(uids)
|
||
.map(|(asked, source)| PhotoThumb {
|
||
uid: asked.to_string(),
|
||
path: tags.get(&source.to_string()).and_then(|&tag| {
|
||
self.cache
|
||
.cached_thumbnail_path(source, ttype, tag)
|
||
.map(|p| p.display().to_string())
|
||
}),
|
||
pending: pending.contains(source),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Which file's thumbnail stands for `uid` on a tile.
|
||
///
|
||
/// Itself, for all but a camera raw that was written next to a JPEG of the
|
||
/// same shot. For that one, the JPEG: same picture, already compressed,
|
||
/// often carrying a server thumbnail the raw does not have — so the Raw tab
|
||
/// costs no raw download and no raw decode. A raw that stands alone keeps
|
||
/// its own local generation.
|
||
fn thumb_source(&self, uid: &NodeUid) -> NodeUid {
|
||
let members = match self.db.photos_group(&uid.to_string()) {
|
||
Ok(members) if members.len() > 1 => members,
|
||
_ => return uid.clone(),
|
||
};
|
||
let key = uid.to_string();
|
||
if !members
|
||
.iter()
|
||
.any(|photo| photo.uid == key && photo.kind == PhotoKind::Raw)
|
||
{
|
||
return uid.clone();
|
||
}
|
||
members
|
||
.iter()
|
||
.find(|photo| photo.kind != PhotoKind::Raw)
|
||
.and_then(|photo| parse_uid(&photo.uid))
|
||
.unwrap_or_else(|| uid.clone())
|
||
}
|
||
|
||
/// 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,
|
||
) -> Option<Vec<PhotoThumb>> {
|
||
let current =
|
||
generation != 0 && self.file_thumb_generation.load(Ordering::SeqCst) == generation;
|
||
if !current {
|
||
return None;
|
||
}
|
||
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
|
||
.thumbnail_misses
|
||
.lock()
|
||
.local_contains(&(uid.clone(), ttype), *modified)
|
||
|| !seen.insert(raw.clone())
|
||
{
|
||
continue;
|
||
}
|
||
wanted.push(uid.clone());
|
||
}
|
||
|
||
if !wanted.is_empty() {
|
||
self.spawn_generate_thumbs(wanted, &tags, &names, ThumbJob::Files(generation));
|
||
}
|
||
|
||
let pending = self.thumb_gen.lock();
|
||
Some(
|
||
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: uid.as_ref().is_some_and(|uid| pending.contains(uid)),
|
||
}
|
||
})
|
||
.collect(),
|
||
)
|
||
}
|
||
|
||
/// Reserve the next ordinary-file listing generation. This daemon-owned
|
||
/// monotonic value replaces GUI wall-clock seeds, which can move backwards
|
||
/// across NTP corrections, suspend, or dual boot.
|
||
pub(crate) fn reserve_file_thumb_generation(&self) -> u64 {
|
||
let next = |current| if current == u64::MAX { 1 } else { current + 1 };
|
||
let previous = self
|
||
.file_thumb_generation
|
||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
|
||
Some(next(current))
|
||
})
|
||
.expect("generation update always succeeds");
|
||
let generation = next(previous);
|
||
self.file_thumb_cancel.notify_waiters();
|
||
generation
|
||
}
|
||
|
||
/// 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.
|
||
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()
|
||
.filter(|uid| inflight.insert(uid.clone()))
|
||
.collect()
|
||
};
|
||
if fresh.is_empty() {
|
||
return;
|
||
}
|
||
|
||
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 || {
|
||
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");
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Make thumbnails for photos the server has none for: download each full
|
||
/// file once, scale it to [`THUMB_EDGE`], and store the result in the thumbnail
|
||
/// cache exactly as if the server had served it.
|
||
///
|
||
/// Bounded by [`THUMB_GEN_CONCURRENCY`] — these are full-size originals, and a
|
||
/// 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.
|
||
fn thumb_job_current(&self, job: ThumbJob) -> bool {
|
||
match job {
|
||
ThumbJob::Files(generation) => {
|
||
self.file_thumb_generation.load(Ordering::SeqCst) == generation
|
||
}
|
||
ThumbJob::Photos => true,
|
||
ThumbJob::Build => !self.thumbnail_build_cancelled.load(Ordering::SeqCst),
|
||
}
|
||
}
|
||
|
||
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 {
|
||
if !core.thumb_job_current(job) {
|
||
return (uid, ThumbAttempt::Cancelled);
|
||
}
|
||
let video = is_video_name(&name);
|
||
if video && !ffmpeg_available() {
|
||
return (uid, ThumbAttempt::Unavailable);
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
_ = tokio::time::sleep(THUMB_CANCEL_POLL) => {
|
||
if !core.thumb_job_current(job) {
|
||
return (uid, ThumbAttempt::Cancelled);
|
||
}
|
||
}
|
||
}
|
||
},
|
||
ThumbJob::Build => loop {
|
||
tokio::select! {
|
||
permit = &mut acquire => break permit.ok(),
|
||
_ = core.thumbnail_build_cancel.notified() => {
|
||
if !core.thumb_job_current(job) {
|
||
return (uid, ThumbAttempt::Cancelled);
|
||
}
|
||
}
|
||
_ = tokio::time::sleep(THUMB_CANCEL_POLL) => {
|
||
if !core.thumb_job_current(job) {
|
||
return (uid, ThumbAttempt::Cancelled);
|
||
}
|
||
}
|
||
}
|
||
},
|
||
ThumbJob::Photos => acquire.await.ok(),
|
||
};
|
||
let Some(_permit) = _permit else {
|
||
return (uid, ThumbAttempt::Unavailable);
|
||
};
|
||
if !core.thumb_job_current(job) {
|
||
return (uid, ThumbAttempt::Cancelled);
|
||
}
|
||
|
||
// A video is downloaded whole for one frame, so a long
|
||
// recording is not worth it — and would not fit in memory.
|
||
if video {
|
||
match core.photos().get_node(&uid).await {
|
||
Ok(Some(node)) if node_size(&node) > VIDEO_THUMB_MAX_BYTES => {
|
||
info!(%uid, size = node_size(&node), "video too large for a local thumbnail");
|
||
return (uid, ThumbAttempt::Undecodable);
|
||
}
|
||
Ok(Some(_)) => {}
|
||
Ok(None) | Err(_) => return (uid, ThumbAttempt::Unavailable),
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
_ = tokio::time::sleep(THUMB_CANCEL_POLL) => {
|
||
if !core.thumb_job_current(job) {
|
||
break None;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
ThumbJob::Build => loop {
|
||
tokio::select! {
|
||
result = &mut download => break Some(result),
|
||
_ = core.thumbnail_build_cancel.notified() => {
|
||
if !core.thumb_job_current(job) {
|
||
break None;
|
||
}
|
||
}
|
||
_ = tokio::time::sleep(THUMB_CANCEL_POLL) => {
|
||
if !core.thumb_job_current(job) {
|
||
break None;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
ThumbJob::Photos => 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, "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 staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
|
||
let made = tokio::task::spawn_blocking(move || {
|
||
if video {
|
||
video_thumbnail(
|
||
&bytes,
|
||
&name,
|
||
&staging_dir,
|
||
OsStr::new("ffmpeg"),
|
||
FFMPEG_TIMEOUT,
|
||
)
|
||
} else {
|
||
scale_thumbnail(&bytes, &name, &staging_dir)
|
||
}
|
||
})
|
||
.await
|
||
.unwrap_or(ScaleAttempt::Unavailable);
|
||
if !core.thumb_job_current(job) {
|
||
return (uid, ThumbAttempt::Cancelled);
|
||
}
|
||
match made {
|
||
ScaleAttempt::Made(thumb) => (uid, ThumbAttempt::Made(thumb)),
|
||
ScaleAttempt::Undecodable => (uid, ThumbAttempt::Undecodable),
|
||
ScaleAttempt::Unavailable => (uid, ThumbAttempt::Unavailable),
|
||
}
|
||
});
|
||
}
|
||
while let Some(joined) = set.join_next().await {
|
||
if let Ok(result) = joined {
|
||
out.push(result);
|
||
}
|
||
}
|
||
}
|
||
out
|
||
});
|
||
|
||
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(()) => {
|
||
summary.made += 1;
|
||
self.thumbnail_misses
|
||
.lock()
|
||
.forget_local(&(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 => {
|
||
summary.undecodable += 1;
|
||
if let Some(&tag) = tags.get(&uid.to_string()) {
|
||
self.thumbnail_misses
|
||
.lock()
|
||
.remember_local((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 => 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 request for the same root
|
||
/// may attach to it; a different root is rejected explicitly instead of
|
||
/// silently pretending the existing job belongs to the caller.
|
||
pub(crate) fn start_thumbnail_build(&self, root: PathBuf) -> CoreResult<ThumbnailBuildStatus> {
|
||
let path = root.to_string_lossy().into_owned();
|
||
let status = {
|
||
let mut status = self.thumbnail_build.lock();
|
||
if !thumbnail_build_may_start(&status, &path)? {
|
||
return Ok(status.clone());
|
||
}
|
||
self.thumbnail_build_cancelled
|
||
.store(false, Ordering::SeqCst);
|
||
*status = ThumbnailBuildStatus {
|
||
running: true,
|
||
scanning: true,
|
||
path: path.clone(),
|
||
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());
|
||
core.thumbnail_build_cancelled
|
||
.store(false, Ordering::SeqCst);
|
||
warn!(path = %status.path, "recursive thumbnail build panicked");
|
||
}
|
||
});
|
||
Ok(status)
|
||
}
|
||
|
||
pub(crate) fn cancel_thumbnail_build(&self) -> ThumbnailBuildStatus {
|
||
let (status, cancelled) = {
|
||
let status = self.thumbnail_build.lock();
|
||
let cancelled = status.running;
|
||
if cancelled {
|
||
self.thumbnail_build_cancelled.store(true, Ordering::SeqCst);
|
||
}
|
||
(status.clone(), cancelled)
|
||
};
|
||
if cancelled {
|
||
self.thumbnail_build_cancel.notify_waiters();
|
||
}
|
||
status
|
||
}
|
||
|
||
fn finish_thumbnail_build(&self) {
|
||
let mut status = self.thumbnail_build.lock();
|
||
let cancelled = self.thumbnail_build_cancelled.swap(false, Ordering::SeqCst);
|
||
status.running = false;
|
||
status.scanning = false;
|
||
if cancelled {
|
||
status.message = Some("Thumbnail build cancelled".to_string());
|
||
}
|
||
info!(
|
||
path = %status.path,
|
||
images = status.images_found,
|
||
failed = status.failed,
|
||
cancelled,
|
||
"recursive thumbnail build finished"
|
||
);
|
||
}
|
||
|
||
fn run_thumbnail_build(&self, root: PathBuf) {
|
||
let mut folders = vec![root];
|
||
let mut images: Vec<(NodeUid, i64, String)> = Vec::new();
|
||
let mut seen_folders = HashSet::new();
|
||
let mut seen_images = HashSet::new();
|
||
|
||
while let Some(folder) = folders.pop() {
|
||
if self.thumbnail_build_cancelled.load(Ordering::SeqCst) {
|
||
self.finish_thumbnail_build();
|
||
return;
|
||
}
|
||
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_folders.insert(entry.uid) {
|
||
folders.push(folder.join(entry.name));
|
||
}
|
||
} else if is_thumbnail_image_name(&entry.name) {
|
||
match parse_uid(&entry.uid) {
|
||
Some(uid) if seen_images.insert(uid.clone()) => {
|
||
found_here += 1;
|
||
images.push((uid, entry.modified, entry.name));
|
||
}
|
||
Some(_) => {}
|
||
None => {
|
||
found_here += 1;
|
||
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;
|
||
if self.thumbnail_build_cancelled.load(Ordering::SeqCst) {
|
||
self.finish_thumbnail_build();
|
||
return;
|
||
}
|
||
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
|
||
.thumbnail_misses
|
||
.lock()
|
||
.local_contains(&(uid.clone(), ttype), 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) {
|
||
if self.thumbnail_build_cancelled.load(Ordering::SeqCst) {
|
||
self.finish_thumbnail_build();
|
||
return;
|
||
}
|
||
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.
|
||
let waiting_since = Instant::now();
|
||
loop {
|
||
if self.thumbnail_build_cancelled.load(Ordering::SeqCst) {
|
||
self.finish_thumbnail_build();
|
||
return;
|
||
}
|
||
if self
|
||
.cache
|
||
.cached_thumbnail_path(uid, ttype, *modified)
|
||
.is_some()
|
||
{
|
||
self.thumbnail_build.lock().completed += 1;
|
||
break;
|
||
}
|
||
if self
|
||
.thumbnail_misses
|
||
.lock()
|
||
.local_contains(&(uid.clone(), ttype), *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;
|
||
}
|
||
if waiting_since.elapsed() >= THUMB_BUILD_CLAIM_TIMEOUT {
|
||
let mut status = self.thumbnail_build.lock();
|
||
status.completed += 1;
|
||
status.failed += 1;
|
||
if status.message.is_none() {
|
||
status.message = Some(
|
||
"Some images stayed busy and were skipped after a timeout".into(),
|
||
);
|
||
}
|
||
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());
|
||
}
|
||
}
|
||
}
|
||
self.finish_thumbnail_build();
|
||
}
|
||
|
||
/// Persist what a thumbnail attempt learned about a photo, against the
|
||
/// timeline and against every album the photo sits in — one attempt, one
|
||
/// verdict, wherever the photo is painted.
|
||
pub(crate) fn record_thumb(&self, uid: &NodeUid, state: i64, ratio: Option<f64>) {
|
||
let key = uid.to_string();
|
||
if let Err(e) = self.db.photo_set_thumb(&key, state, ratio) {
|
||
warn!(%uid, error = %e, "recording thumbnail state failed");
|
||
}
|
||
if let Err(e) = self.db.album_photo_set_thumb(&key, state, ratio) {
|
||
warn!(%uid, error = %e, "recording album thumbnail state failed");
|
||
}
|
||
}
|
||
|
||
/// Whether the listing stamped under `key` is older than `ttl` (or was never
|
||
/// fetched).
|
||
pub(crate) async fn refresh_timeline(&self) -> CoreResult<bool> {
|
||
let started = Instant::now();
|
||
let photos = self.photos();
|
||
if photos
|
||
.get_photos_root()
|
||
.await
|
||
.map_err(|e| CoreError::from_api(&e, "photos root"))?
|
||
.is_none()
|
||
{
|
||
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0);
|
||
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
|
||
return Ok(false);
|
||
}
|
||
let items = photos
|
||
.enumerate_timeline()
|
||
.await
|
||
.map_err(|e| CoreError::from_api(&e, "timeline"))?;
|
||
|
||
// The timeline DTO carries only a uid and capture time, but the gallery
|
||
// has to split into its tabs — which needs each photo's name and media
|
||
// type. Resolve those in batches off the request path. Best-effort: a
|
||
// photo whose node we fail to resolve keeps whatever was learned before
|
||
// (or classifies from nothing, i.e. a still photo), so a partial resolve
|
||
// never blanks the timeline.
|
||
//
|
||
// Only photos the daemon has never read, and ones a remote event marked
|
||
// stale, are read. Resolving all of them every time is what made a
|
||
// 12k-photo refresh take minutes (`docs/BUGS.md` B92): this metadata does
|
||
// not change on its own, and when it does change the event feed names the
|
||
// uid that changed (`Db::photos_mark_unresolved`).
|
||
let known: HashSet<String> = self
|
||
.db
|
||
.photos_resolved()
|
||
.map_err(CoreError::from)?
|
||
.into_iter()
|
||
.collect();
|
||
let uids: Vec<NodeUid> = items
|
||
.iter()
|
||
.filter(|it| !known.contains(&it.uid.to_string()))
|
||
.map(|it| it.uid.clone())
|
||
.collect();
|
||
let resolved_at = now_ms();
|
||
let mut meta: HashMap<String, PhotoMeta> = HashMap::new();
|
||
// A main photo names its related photos rather than the other way round,
|
||
// so the relation is recorded from whichever end resolves — the two ends
|
||
// can land in different chunks, and one of them may not resolve at all.
|
||
let mut main_of: HashMap<String, String> = HashMap::new();
|
||
let mut stopped = false;
|
||
// Photos read before count as done, so the bar picks up where an
|
||
// interrupted pass left off. Cleared however the pass ends.
|
||
let skipped = items.len() - uids.len();
|
||
let progress = ProgressGuard(&self.timeline_progress);
|
||
if !uids.is_empty() {
|
||
*progress.0.lock() = Some((skipped, items.len()));
|
||
}
|
||
for (index, chunk) in uids.chunks(TIMELINE_ENRICH_CHUNK).enumerate() {
|
||
// Teardown cancels the runtime under this loop, and every chunk
|
||
// still in flight then fails its decrypt task — a burst of
|
||
// "skipping undecryptable photo ... task was cancelled" warnings
|
||
// that read like corruption and are only a shutdown. Stop asking
|
||
// for chunks instead. What resolved so far is kept below; the rest
|
||
// stay unresolved and are read by the next refresh.
|
||
if self.shutdown.is_stopping() {
|
||
stopped = true;
|
||
break;
|
||
}
|
||
match photos.enumerate_nodes(chunk).await {
|
||
Ok(nodes) => {
|
||
for node in nodes {
|
||
let media_type = match &node.kind {
|
||
NodeKind::File { media_type, .. } => Some(media_type.clone()),
|
||
NodeKind::Folder => None,
|
||
};
|
||
// The favorite state is a tag on the photo node, so it
|
||
// rides along with the metadata resolve rather than
|
||
// costing a listing of its own.
|
||
let favorite = node
|
||
.photo
|
||
.as_ref()
|
||
.is_some_and(|p| p.tags.contains(&PhotoTag::Favorite));
|
||
let key = node.uid.to_string();
|
||
let content_hash = node.photo.as_ref().and_then(|p| p.content_hash.clone());
|
||
let location = node
|
||
.photo
|
||
.as_ref()
|
||
.and_then(|p| p.location)
|
||
.map(|l| (l.latitude, l.longitude));
|
||
if let Some(photo) = node.photo.as_ref() {
|
||
if let Some(main) = photo.main_photo_uid.as_ref() {
|
||
main_of.insert(key.clone(), main.to_string());
|
||
}
|
||
for related in &photo.related_photo_uids {
|
||
main_of.insert(related.to_string(), key.clone());
|
||
}
|
||
}
|
||
meta.insert(
|
||
key,
|
||
PhotoMeta {
|
||
name: Some(node.name),
|
||
media_type,
|
||
favorite,
|
||
content_hash,
|
||
location,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"),
|
||
}
|
||
let done = (skipped + (index + 1) * TIMELINE_ENRICH_CHUNK).min(items.len());
|
||
*progress.0.lock() = Some((done, items.len()));
|
||
}
|
||
drop(progress);
|
||
|
||
let rows: Vec<db::TimelineRow> = items
|
||
.iter()
|
||
.map(|it| {
|
||
let key = it.uid.to_string();
|
||
let main_uid = main_of.get(&key).cloned();
|
||
// An unresolved photo keeps whatever was learned before, for the
|
||
// favorite flag as much as for the name and media type.
|
||
match meta.get(&key).cloned() {
|
||
Some(m) => db::TimelineRow {
|
||
name: m.name,
|
||
media_type: m.media_type,
|
||
favorite: Some(m.favorite),
|
||
content_hash: m.content_hash,
|
||
location: m.location,
|
||
main_uid,
|
||
resolved_at: Some(resolved_at),
|
||
..db::TimelineRow::new(key, it.capture_time)
|
||
},
|
||
None => db::TimelineRow {
|
||
main_uid,
|
||
..db::TimelineRow::new(key, it.capture_time)
|
||
},
|
||
}
|
||
})
|
||
.collect();
|
||
self.db.photos_replace(&rows).map_err(CoreError::from)?;
|
||
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1);
|
||
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
|
||
// How long this took is the first thing to know when a refresh looks
|
||
// like it never happened, and the chunk count is what it scales with.
|
||
info!(
|
||
photos = rows.len(),
|
||
// `resolved` is what came back, which is short of `wanted` when
|
||
// shutdown cut the pass off or a chunk failed.
|
||
resolved = meta.len(),
|
||
wanted = uids.len(),
|
||
skipped = rows.len().saturating_sub(uids.len()),
|
||
chunks = uids.len().div_ceil(TIMELINE_ENRICH_CHUNK),
|
||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||
stopped,
|
||
"photos timeline refreshed"
|
||
);
|
||
Ok(true)
|
||
}
|
||
|
||
/// Refresh the timeline off the request path, so a stale page is still served
|
||
/// at DB speed. At most one refresh runs at a time.
|
||
pub(crate) fn spawn_timeline_refresh(&self) {
|
||
if self.timeline_refreshing.swap(true, Ordering::SeqCst) {
|
||
return;
|
||
}
|
||
let core = self.clone();
|
||
self.rt.spawn(async move {
|
||
let refreshed = core.refresh_timeline().await;
|
||
core.timeline_refreshing.store(false, Ordering::SeqCst);
|
||
// Only a refresh that landed: a front-end re-reading after a failed
|
||
// one would find the timeline still stale and start another.
|
||
match refreshed {
|
||
Ok(_) => core.events.publish(&[Topic::Photos]),
|
||
Err(e) => warn!(error = %e, "background timeline refresh failed"),
|
||
}
|
||
});
|
||
}
|
||
|
||
/// How far the running timeline refresh has got reading photo metadata, or
|
||
/// `None` when no refresh is reading any.
|
||
pub(crate) fn timeline_progress(&self) -> Option<MappingProgress> {
|
||
self.timeline_progress
|
||
.lock()
|
||
.map(|(done, total)| MappingProgress { done, total })
|
||
}
|
||
|
||
/// Whether a timeline refresh is running. A front-end that asked for one
|
||
/// watches this to know when the new timeline is worth re-reading.
|
||
pub(crate) fn timeline_refreshing(&self) -> bool {
|
||
self.timeline_refreshing.load(Ordering::SeqCst)
|
||
}
|
||
|
||
/// Download a photo's full content into the content cache, returning its
|
||
/// on-disk path (served from cache when a fresh blob already exists).
|
||
pub(crate) fn open_photo(&self, uid: &NodeUid) -> CoreResult<PathBuf> {
|
||
let photos = self.photos();
|
||
let node = self
|
||
.rt
|
||
.block_on(photos.get_node(uid))
|
||
.map_err(|e| CoreError::from_api(&e, "photo node"))?
|
||
.ok_or_else(|| CoreError::not_found("photo not found"))?;
|
||
let (mtime, size) = (node.modification_time, node_size(&node));
|
||
if let Some(p) = self.cache.cached_content_path(uid, mtime, size) {
|
||
return Ok(p);
|
||
}
|
||
let bytes = self
|
||
.download_photo_tracked(&photos, uid, &node.name, size)
|
||
.map_err(|e| CoreError::from_api(&e, "download photo"))?;
|
||
self.cache
|
||
.store(uid, mtime, size, &bytes)
|
||
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
|
||
Ok(self.cache.content_path(uid))
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod thumb_tests {
|
||
use super::{
|
||
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary,
|
||
fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool,
|
||
similar_hashes, thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
|
||
};
|
||
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
|
||
use proton_drive_rs::ThumbnailType;
|
||
use std::collections::HashMap;
|
||
use std::ffi::OsStr;
|
||
use std::os::unix::fs::PermissionsExt as _;
|
||
use std::time::Duration;
|
||
|
||
/// A `width`×`height` JPEG, standing in for a camera photo the server never
|
||
/// generated a thumbnail for.
|
||
fn jpeg(width: u32, height: u32) -> Vec<u8> {
|
||
let image = image::RgbImage::from_fn(width, height, |x, y| {
|
||
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
|
||
});
|
||
let mut bytes = Vec::new();
|
||
image::codecs::jpeg::JpegEncoder::new(&mut bytes)
|
||
.encode_image(&image)
|
||
.unwrap();
|
||
bytes
|
||
}
|
||
|
||
#[test]
|
||
fn scaling_fits_the_long_edge_and_keeps_the_aspect_ratio() {
|
||
let photo = jpeg(4000, 3000);
|
||
let ScaleAttempt::Made(thumb) = scale_thumbnail(&photo, "photo.jpg", &std::env::temp_dir())
|
||
else {
|
||
panic!("a JPEG scales");
|
||
};
|
||
|
||
let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes))
|
||
.with_guessed_format()
|
||
.unwrap()
|
||
.into_dimensions()
|
||
.unwrap();
|
||
assert_eq!(width, THUMB_EDGE, "the long edge lands on the target");
|
||
assert_eq!(height, THUMB_EDGE * 3 / 4, "and nothing is cropped");
|
||
assert!((thumb.ratio - 4.0 / 3.0).abs() < 1e-6);
|
||
assert!(
|
||
thumb.bytes.len() < photo.len(),
|
||
"a thumbnail that isn't smaller than its photo is no thumbnail"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_portrait_photo_fits_its_long_edge_too() {
|
||
let ScaleAttempt::Made(thumb) =
|
||
scale_thumbnail(&jpeg(1000, 2000), "portrait.jpeg", &std::env::temp_dir())
|
||
else {
|
||
panic!("a JPEG scales");
|
||
};
|
||
assert!(thumb.ratio < 1.0, "portrait stays portrait");
|
||
assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true));
|
||
}
|
||
|
||
#[test]
|
||
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!(matches!(
|
||
scale_thumbnail(b"not an image at all", "broken.jpg", &std::env::temp_dir()),
|
||
ScaleAttempt::Undecodable
|
||
));
|
||
assert!(ratio_of(b"not an image at all").is_none());
|
||
}
|
||
|
||
#[test]
|
||
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 staging_dir = std::env::temp_dir();
|
||
let file = RawTempFile::create_in(&staging_dir, b"private camera bytes", "shot.NEF")
|
||
.expect("temporary RAW file");
|
||
let path = file.0.clone();
|
||
assert_eq!(path.parent(), Some(staging_dir.as_path()));
|
||
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 ScaleAttempt::Made(thumb) = scale_thumbnail(&bytes, &name, &std::env::temp_dir())
|
||
else {
|
||
panic!("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",
|
||
"camera.raw",
|
||
] {
|
||
assert!(is_thumbnail_image_name(name), "{name}");
|
||
}
|
||
for name in [
|
||
"notes.txt",
|
||
"movie.mp4",
|
||
"image.jpg.zip",
|
||
"jpg",
|
||
"unsupported.heic",
|
||
"unsupported.avif",
|
||
"vector.svg",
|
||
"camera.rw",
|
||
] {
|
||
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);
|
||
}
|
||
|
||
#[test]
|
||
fn missing_exiftool_is_retryable_not_a_permanent_raw_miss() {
|
||
let attempt = scale_thumbnail_with_exiftool(
|
||
b"camera bytes that require an embedded preview",
|
||
"shot.raw",
|
||
&std::env::temp_dir(),
|
||
OsStr::new("pdfs-exiftool-deliberately-absent"),
|
||
Duration::from_millis(10),
|
||
);
|
||
assert!(matches!(attempt, ScaleAttempt::Unavailable));
|
||
}
|
||
|
||
/// An executable shell script standing in for ffmpeg, in its own directory
|
||
/// under `tmp` so parallel tests never share one.
|
||
fn fake_ffmpeg(tmp: &std::path::Path, body: &str) -> std::path::PathBuf {
|
||
std::fs::create_dir_all(tmp).unwrap();
|
||
let path = tmp.join("ffmpeg");
|
||
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
path
|
||
}
|
||
|
||
fn scratch(test: &str) -> std::path::PathBuf {
|
||
std::env::temp_dir().join(format!("pdfs-{test}-{}", std::process::id()))
|
||
}
|
||
|
||
#[test]
|
||
fn a_video_frame_becomes_a_thumbnail() {
|
||
let tmp = scratch("video-frame");
|
||
let frame = tmp.join("frame.jpg");
|
||
std::fs::create_dir_all(&tmp).unwrap();
|
||
std::fs::write(&frame, jpeg(1080, 1920)).unwrap();
|
||
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display()));
|
||
|
||
let ScaleAttempt::Made(thumb) = video_thumbnail(
|
||
b"video bytes",
|
||
"clip.mp4",
|
||
&tmp,
|
||
ffmpeg.as_os_str(),
|
||
Duration::from_secs(10),
|
||
) else {
|
||
panic!("a frame scales");
|
||
};
|
||
assert!((thumb.ratio - 1080.0 / 1920.0).abs() < 1e-6);
|
||
std::fs::remove_dir_all(&tmp).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn a_video_ffmpeg_cannot_read_is_a_permanent_miss() {
|
||
let tmp = scratch("video-broken");
|
||
let ffmpeg = fake_ffmpeg(&tmp, "exit 1");
|
||
let attempt = video_thumbnail(
|
||
b"not a video",
|
||
"clip.mp4",
|
||
&tmp,
|
||
ffmpeg.as_os_str(),
|
||
Duration::from_secs(10),
|
||
);
|
||
assert!(matches!(attempt, ScaleAttempt::Undecodable));
|
||
std::fs::remove_dir_all(&tmp).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn missing_ffmpeg_is_retryable() {
|
||
let attempt = video_thumbnail(
|
||
b"video bytes",
|
||
"clip.mp4",
|
||
&std::env::temp_dir(),
|
||
OsStr::new("pdfs-ffmpeg-deliberately-absent"),
|
||
Duration::from_millis(10),
|
||
);
|
||
assert!(matches!(attempt, ScaleAttempt::Unavailable));
|
||
}
|
||
|
||
/// Optional real-ffmpeg check, like the RAW fixture harness above.
|
||
#[test]
|
||
#[ignore = "set PDFS_VIDEO_FIXTURE to a video file; needs ffmpeg"]
|
||
fn real_video_fixture_yields_a_frame() {
|
||
let path = std::env::var_os("PDFS_VIDEO_FIXTURE").expect("PDFS_VIDEO_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 ScaleAttempt::Made(thumb) = video_thumbnail(
|
||
&bytes,
|
||
&name,
|
||
&std::env::temp_dir(),
|
||
OsStr::new("ffmpeg"),
|
||
Duration::from_secs(30),
|
||
) else {
|
||
panic!("a frame from the video");
|
||
};
|
||
assert!(thumb.ratio > 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn videos_are_told_apart_by_name() {
|
||
for name in [
|
||
"VID-20260804-WA0023.mp4",
|
||
"PXL_1.TS.mp4",
|
||
"clip.MOV",
|
||
"a.webm",
|
||
] {
|
||
assert!(is_video_name(name), "{name}");
|
||
}
|
||
for name in ["photo.jpg", "shot.NEF", "notes.txt", "mp4"] {
|
||
assert!(!is_video_name(name), "{name}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_running_build_only_accepts_the_same_root() {
|
||
let status = ThumbnailBuildStatus {
|
||
running: true,
|
||
path: "pictures/first".into(),
|
||
..Default::default()
|
||
};
|
||
assert!(!thumbnail_build_may_start(&status, "pictures/first").unwrap());
|
||
assert!(thumbnail_build_may_start(&status, "pictures/second").is_err());
|
||
|
||
let idle = ThumbnailBuildStatus::default();
|
||
assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn thumbnails_hash_alike_across_sizes_and_undecodable_ones_get_no_hash() {
|
||
let tmp = scratch("similar-hashes");
|
||
std::fs::create_dir_all(&tmp).unwrap();
|
||
// One picture at two sizes: a diagonal gradient with a bright corner.
|
||
let picture = |width: u32, height: u32| {
|
||
let image = image::GrayImage::from_fn(width, height, |x, y| {
|
||
let (fx, fy) = (x * 4 / width, y * 4 / height);
|
||
image::Luma([if fx + fy == 0 {
|
||
250
|
||
} else {
|
||
(fx * 40 + fy * 20) as u8
|
||
}])
|
||
});
|
||
let mut bytes = Vec::new();
|
||
image::codecs::jpeg::JpegEncoder::new(&mut bytes)
|
||
.encode_image(&image)
|
||
.unwrap();
|
||
bytes
|
||
};
|
||
std::fs::write(tmp.join("big"), picture(512, 384)).unwrap();
|
||
std::fs::write(tmp.join("small"), picture(256, 192)).unwrap();
|
||
std::fs::write(tmp.join("torn"), b"not a picture").unwrap();
|
||
|
||
let hashes: HashMap<String, u64> = similar_hashes(
|
||
["big", "small", "torn", "gone"]
|
||
.iter()
|
||
.map(|name| (name.to_string(), tmp.join(name)))
|
||
.collect(),
|
||
)
|
||
.into_iter()
|
||
.collect();
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
assert_eq!(hashes.len(), 4);
|
||
assert_ne!(hashes["big"], pdfs_core::similar::NO_HASH);
|
||
assert!(pdfs_core::similar::alike(hashes["big"], hashes["small"]));
|
||
assert_eq!(hashes["torn"], pdfs_core::similar::NO_HASH);
|
||
assert_eq!(hashes["gone"], pdfs_core::similar::NO_HASH);
|
||
}
|
||
|
||
#[test]
|
||
fn an_uploaded_photo_carries_its_thumbnail() {
|
||
let tmp = scratch("upload-photo");
|
||
std::fs::create_dir_all(&tmp).unwrap();
|
||
let photo = tmp.join("staged-blob");
|
||
std::fs::write(&photo, jpeg(4000, 3000)).unwrap();
|
||
|
||
let thumbnails = upload_thumbnails_with(&photo, "IMG_0001.jpg", &tmp, OsStr::new("ffmpeg"));
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
let [thumbnail] = thumbnails.as_slice() else {
|
||
panic!("one thumbnail, got {}", thumbnails.len());
|
||
};
|
||
assert!(matches!(thumbnail.thumbnail_type, ThumbnailType::Thumbnail));
|
||
assert!(thumbnail.content.len() <= UPLOAD_THUMB_MAX_BYTES);
|
||
assert_eq!(ratio_of(&thumbnail.content), Some(4.0 / 3.0));
|
||
}
|
||
|
||
#[test]
|
||
fn an_uploaded_video_carries_a_frame() {
|
||
let tmp = scratch("upload-video");
|
||
let frame = tmp.join("frame.jpg");
|
||
std::fs::create_dir_all(&tmp).unwrap();
|
||
std::fs::write(&frame, jpeg(1080, 1920)).unwrap();
|
||
std::fs::write(tmp.join("clip"), b"video bytes").unwrap();
|
||
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display()));
|
||
|
||
let thumbnails = upload_thumbnails_with(
|
||
&tmp.join("clip"),
|
||
"VID-WA0001.mp4",
|
||
&tmp,
|
||
ffmpeg.as_os_str(),
|
||
);
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
assert_eq!(thumbnails.len(), 1);
|
||
assert!(ratio_of(&thumbnails[0].content).is_some_and(|ratio| ratio < 1.0));
|
||
}
|
||
|
||
#[test]
|
||
fn other_files_upload_without_a_thumbnail() {
|
||
let tmp = scratch("upload-other");
|
||
std::fs::create_dir_all(&tmp).unwrap();
|
||
std::fs::write(tmp.join("file"), b"export const answer = 42;\n").unwrap();
|
||
// An ffmpeg that would hand back a frame, to prove it is never asked.
|
||
std::fs::write(tmp.join("frame.jpg"), jpeg(64, 64)).unwrap();
|
||
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", tmp.join("frame.jpg").display()));
|
||
|
||
for name in ["notes.txt", "answer.ts", "broken.jpg"] {
|
||
assert!(
|
||
upload_thumbnails_with(&tmp.join("file"), name, &tmp, ffmpeg.as_os_str())
|
||
.is_empty(),
|
||
"{name} uploads bare"
|
||
);
|
||
}
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
}
|
||
|
||
#[test]
|
||
fn an_oversized_thumbnail_is_squeezed_under_the_upload_limit() {
|
||
// Noise is the worst case for JPEG: at the gallery's quality a 512 px
|
||
// square of it lands far over the limit.
|
||
let mut seed = 0x2545_f491_u32;
|
||
let noise = image::RgbImage::from_fn(THUMB_EDGE, THUMB_EDGE, |_, _| {
|
||
seed ^= seed << 13;
|
||
seed ^= seed >> 17;
|
||
seed ^= seed << 5;
|
||
let [r, g, b, _] = seed.to_le_bytes();
|
||
image::Rgb([r, g, b])
|
||
});
|
||
let mut bytes = Vec::new();
|
||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, super::THUMB_QUALITY)
|
||
.encode_image(&noise)
|
||
.unwrap();
|
||
assert!(
|
||
bytes.len() > UPLOAD_THUMB_MAX_BYTES,
|
||
"the fixture is oversized"
|
||
);
|
||
|
||
let fitted = fit_upload_thumb(bytes).expect("noise fits at half size");
|
||
assert!(fitted.len() <= UPLOAD_THUMB_MAX_BYTES);
|
||
assert_eq!(ratio_of(&fitted), Some(1.0), "shrinking keeps the shape");
|
||
let small = jpeg(64, 64);
|
||
assert_eq!(
|
||
fit_upload_thumb(small.clone()),
|
||
Some(small),
|
||
"a small one is untouched"
|
||
);
|
||
}
|
||
}
|