mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
The first timeline refresh after the Places update reads the metadata of the whole library, and until it is done the Places view has nothing to show and no sign that anything is happening. The refresh now records how many photos it has resolved out of how many, counting photos read by an earlier pass as done. Response::Places carries it as mapping (absent from older daemons, which parse as None). The Places view shows "Mapping photos: 120 of 480" with a bar while it is set, asks again every two seconds, and loads the new places once the pass ends. An empty listing during the pass says the photos are being mapped rather than that there are no places.
2314 lines
92 KiB
Rust
2314 lines
92 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, PhotoThumb, PlaceInfo,
|
||
ThumbnailBuildStatus, 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;
|
||
/// 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))
|
||
}
|
||
|
||
/// 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 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(),
|
||
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())
|
||
}
|
||
|
||
/// 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.client.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> {
|
||
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 !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 {
|
||
if let Err(e) = core.refresh_timeline().await {
|
||
warn!(error = %e, "background timeline refresh failed");
|
||
}
|
||
core.timeline_refreshing.store(false, Ordering::SeqCst);
|
||
});
|
||
}
|
||
|
||
/// 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,
|
||
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::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 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"
|
||
);
|
||
}
|
||
}
|