Legacy thumbnail and preview paths mirror the source's relative path, so a video's JPEG thumbnail lives at `…/clip.mp4` and a RAW's at `…/shot.arw`. NamedFile guessed the content type from that name and served JPEG bytes as `video/mp4`. Sniff JPEG/PNG magic bytes instead. The circular-thumbnail path had the same root cause: `image::open` picks the decoder from the extension, so it failed on those thumbs and quietly fell back to the square one. Guess the format from the bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1415 lines
54 KiB
Rust
1415 lines
54 KiB
Rust
//! `/image*` endpoints: image serving (with hash/library-scoped/bare
|
||
//! legacy thumbnail lookup), upload, EXIF metadata read + GPS / date
|
||
//! mutation, and the full exiftool dump used by Apollo's details modal.
|
||
|
||
use std::error::Error;
|
||
use std::fs::File;
|
||
use std::io::ErrorKind;
|
||
use std::io::prelude::*;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Mutex;
|
||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||
|
||
use actix_files::NamedFile;
|
||
use actix_multipart as mp;
|
||
use actix_web::{
|
||
HttpRequest, HttpResponse, Responder, get, post,
|
||
web::{self, BufMut, BytesMut, Data},
|
||
};
|
||
use chrono::Utc;
|
||
use futures::stream::StreamExt;
|
||
use log::{debug, error, info, trace, warn};
|
||
use opentelemetry::KeyValue;
|
||
use opentelemetry::trace::Status;
|
||
use urlencoding::decode;
|
||
|
||
use crate::content_hash;
|
||
use crate::data::{
|
||
Claims, MetadataResponse, PhotoSize, ThumbnailFormat, ThumbnailRequest, ThumbnailShape,
|
||
};
|
||
use crate::database::models::{ImageExif, InsertImageExif};
|
||
use crate::database::{DbErrorKind, ExifDao};
|
||
use crate::date_resolver;
|
||
use crate::exif;
|
||
use crate::file_types;
|
||
use crate::files::{RefreshThumbnailsMessage, is_image_or_video, is_valid_full_path};
|
||
use crate::libraries;
|
||
use crate::memories;
|
||
use crate::perceptual_hash;
|
||
use crate::state::AppState;
|
||
|
||
#[get("/image")]
|
||
#[tracing::instrument(name = "get_image", skip_all)]
|
||
pub async fn get_image(
|
||
_claims: Claims,
|
||
request: HttpRequest,
|
||
req: web::Query<ThumbnailRequest>,
|
||
app_state: Data<AppState>,
|
||
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||
) -> impl Responder {
|
||
let span = tracing::Span::current();
|
||
|
||
// Resolve library from query param; default to primary so clients that
|
||
// don't yet send `library=` continue to work.
|
||
let library = match libraries::resolve_library_param_state(&app_state, req.library.as_deref()) {
|
||
Ok(Some(lib)) => lib,
|
||
Ok(None) => app_state.primary_library(),
|
||
Err(msg) => {
|
||
span.set_status(Status::error(msg.clone()));
|
||
return HttpResponse::BadRequest().body(msg);
|
||
}
|
||
};
|
||
|
||
// Union-mode search returns flat rel_paths with no library attribution,
|
||
// so clients may request a file under the wrong library. Try the
|
||
// resolved library first; if the file isn't there, fall back to any
|
||
// other library holding that rel_path on disk.
|
||
let resolved = is_valid_full_path(&library.root_path, &req.path, false)
|
||
.filter(|p| p.exists())
|
||
.map(|p| (library, p))
|
||
.or_else(|| {
|
||
app_state.libraries.iter().find_map(|lib| {
|
||
if lib.id == library.id {
|
||
return None;
|
||
}
|
||
is_valid_full_path(&lib.root_path, &req.path, false)
|
||
.filter(|p| p.exists())
|
||
.map(|p| (lib, p))
|
||
})
|
||
});
|
||
|
||
if let Some((library, path)) = resolved {
|
||
let image_size = req.size.unwrap_or(PhotoSize::Full);
|
||
|
||
// `size=large|xlarge` is only meaningful for stills — there's no
|
||
// useful "resized video preview" tier. Videos fall back to the
|
||
// existing thumb pipeline (which already handles gif/static
|
||
// selection). `mut` so preview branches can downgrade to `Full`
|
||
// after a generation failure.
|
||
let mut image_size = if (image_size == PhotoSize::Large || image_size == PhotoSize::XLarge)
|
||
&& file_types::is_video_file(&path)
|
||
{
|
||
PhotoSize::Thumb
|
||
} else {
|
||
image_size
|
||
};
|
||
|
||
if image_size == PhotoSize::Large || image_size == PhotoSize::XLarge {
|
||
let tier = if image_size == PhotoSize::Large {
|
||
PreviewTier::Large
|
||
} else {
|
||
PreviewTier::XLarge
|
||
};
|
||
if let Some(resp) = serve_preview(
|
||
tier,
|
||
library,
|
||
&path,
|
||
Path::new(&app_state.thumbnail_path),
|
||
&exif_dao,
|
||
&request,
|
||
)
|
||
.await
|
||
{
|
||
span.set_status(Status::Ok);
|
||
return resp;
|
||
}
|
||
// Fall through to the Full branch below so the caller gets
|
||
// *something* useful (the original bytes — or the RAW
|
||
// embedded preview, which is what the Full branch returns for
|
||
// unrenderable RAW containers) instead of a 404.
|
||
image_size = PhotoSize::Full;
|
||
}
|
||
if image_size == PhotoSize::Thumb {
|
||
let relative_path = path
|
||
.strip_prefix(&library.root_path)
|
||
.expect("Error stripping library root prefix from thumbnail");
|
||
let relative_path_str = relative_path.to_string_lossy().replace('\\', "/");
|
||
|
||
let thumbs = &app_state.thumbnail_path;
|
||
let bare_legacy_thumb_path = Path::new(&thumbs).join(relative_path);
|
||
let scoped_legacy_thumb_path = content_hash::library_scoped_legacy_path(
|
||
Path::new(&thumbs),
|
||
library.id,
|
||
relative_path,
|
||
);
|
||
|
||
// Gif thumbnails are a separate lookup (video GIF previews).
|
||
// Dual-lookup for gif is out of scope; preserve existing flow.
|
||
if req.format == Some(ThumbnailFormat::Gif) && file_types::is_video_file(&path) {
|
||
let mut gif_path = Path::new(&app_state.gif_path).join(relative_path);
|
||
gif_path.set_extension("gif");
|
||
trace!("Gif thumbnail path: {:?}", gif_path);
|
||
if let Ok(file) = NamedFile::open(&gif_path) {
|
||
span.set_status(Status::Ok);
|
||
return file
|
||
.use_etag(true)
|
||
.use_last_modified(true)
|
||
.prefer_utf8(true)
|
||
.into_response(&request);
|
||
}
|
||
}
|
||
|
||
// Lookup chain (most-specific first, falling back as we miss):
|
||
// 1. hash-keyed (`<thumbs>/<hash[..2]>/<hash>.jpg`) — content
|
||
// identity, shared across libraries;
|
||
// 2. library-scoped legacy (`<thumbs>/<lib_id>/<rel_path>`) —
|
||
// written by current generation when hash isn't known;
|
||
// 3. bare legacy (`<thumbs>/<rel_path>`) — pre-multi-library
|
||
// thumbs from the days before library prefixing existed.
|
||
// Stage (3) goes away once a one-time migration lifts every
|
||
// bare-legacy file under a library prefix; until then it
|
||
// prevents needless 404s for already-warmed deployments.
|
||
let hash_thumb_path: Option<PathBuf> = {
|
||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||
match dao.get_exif(&relative_path_str) {
|
||
Ok(Some(row)) => row
|
||
.content_hash
|
||
.as_deref()
|
||
.map(|h| content_hash::thumbnail_path(Path::new(thumbs), h)),
|
||
_ => None,
|
||
}
|
||
};
|
||
let thumb_path = hash_thumb_path
|
||
.as_ref()
|
||
.filter(|p| p.exists())
|
||
.cloned()
|
||
.or_else(|| {
|
||
if scoped_legacy_thumb_path.exists() {
|
||
Some(scoped_legacy_thumb_path.clone())
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.unwrap_or_else(|| bare_legacy_thumb_path.clone());
|
||
|
||
// Handle circular thumbnail request
|
||
if req.shape == Some(ThumbnailShape::Circle) {
|
||
match create_circular_thumbnail(&thumb_path, thumbs).await {
|
||
Ok(circular_path) => {
|
||
if let Ok(file) = NamedFile::open(&circular_path) {
|
||
span.set_status(Status::Ok);
|
||
return file
|
||
.use_etag(true)
|
||
.use_last_modified(true)
|
||
.prefer_utf8(true)
|
||
.into_response(&request);
|
||
}
|
||
}
|
||
Err(e) => {
|
||
warn!("Failed to create circular thumbnail: {:?}", e);
|
||
// Fall through to serve square thumbnail
|
||
}
|
||
}
|
||
}
|
||
|
||
trace!("Thumbnail path: {:?}", thumb_path);
|
||
if let Some(file) = open_derivative(&thumb_path) {
|
||
span.set_status(Status::Ok);
|
||
return file
|
||
.use_etag(true)
|
||
.use_last_modified(true)
|
||
.prefer_utf8(true)
|
||
.into_response(&request);
|
||
}
|
||
|
||
// No thumb on disk (never generated, or an `.unsupported`
|
||
// sentinel from a failed scan). Never stream the original into
|
||
// a thumb slot: a 12432×3504 panorama decodes to 174MB, past
|
||
// Android's 100MB draw limit, and crashes the app natively.
|
||
// The large tier is capped at 2048px; if even that can't be
|
||
// made, the client gets a 404 and shows a blank tile.
|
||
if !file_types::is_video_file(&path) {
|
||
if let Some(resp) = serve_preview(
|
||
PreviewTier::Large,
|
||
library,
|
||
&path,
|
||
Path::new(&app_state.thumbnail_path),
|
||
&exif_dao,
|
||
&request,
|
||
)
|
||
.await
|
||
{
|
||
span.set_status(Status::Ok);
|
||
return resp;
|
||
}
|
||
span.set_status(Status::error("Thumbnail unavailable"));
|
||
return HttpResponse::NotFound().finish();
|
||
}
|
||
}
|
||
|
||
// Full-size requests for RAW formats (NEF/CR2/ARW/etc.) can't just
|
||
// NamedFile-stream the original bytes — browsers won't decode the
|
||
// RAW container, so a `<img src=...>` lands as a broken image. Serve
|
||
// the embedded JPEG preview instead (typically the camera's in-body
|
||
// review JPEG, ~1–2 MP). Falls through to NamedFile if no preview is
|
||
// available, which preserves the historical behavior for callers
|
||
// that genuinely want the original bytes.
|
||
if image_size == PhotoSize::Full
|
||
&& exif::is_tiff_raw(&path)
|
||
&& let Some(preview) = exif::extract_embedded_jpeg_preview(&path)
|
||
{
|
||
span.set_status(Status::Ok);
|
||
return HttpResponse::Ok()
|
||
.content_type("image/jpeg")
|
||
.insert_header(("Cache-Control", "public, max-age=3600"))
|
||
.body(preview);
|
||
}
|
||
|
||
if let Ok(file) = NamedFile::open(&path) {
|
||
span.set_status(Status::Ok);
|
||
// Enable ETag and set cache headers for full images (1 hour cache)
|
||
return file
|
||
.use_etag(true)
|
||
.use_last_modified(true)
|
||
.prefer_utf8(true)
|
||
.into_response(&request);
|
||
}
|
||
|
||
span.set_status(Status::error("Not found"));
|
||
HttpResponse::NotFound().finish()
|
||
} else {
|
||
span.set_status(Status::error("Not found"));
|
||
error!("Path does not exist in any library: {}", req.path);
|
||
HttpResponse::NotFound().finish()
|
||
}
|
||
}
|
||
|
||
/// On-demand resized tiers served by [`serve_preview`].
|
||
#[derive(Clone, Copy)]
|
||
enum PreviewTier {
|
||
Large,
|
||
XLarge,
|
||
}
|
||
|
||
impl PreviewTier {
|
||
fn name(self) -> &'static str {
|
||
match self {
|
||
PreviewTier::Large => "Large",
|
||
PreviewTier::XLarge => "XLarge",
|
||
}
|
||
}
|
||
|
||
fn legacy_dir(self) -> &'static str {
|
||
match self {
|
||
PreviewTier::Large => "_large",
|
||
PreviewTier::XLarge => "_xlarge",
|
||
}
|
||
}
|
||
|
||
fn hash_path(self, thumbs: &Path, hash: &str) -> PathBuf {
|
||
match self {
|
||
PreviewTier::Large => content_hash::large_preview_path(thumbs, hash),
|
||
PreviewTier::XLarge => content_hash::xlarge_preview_path(thumbs, hash),
|
||
}
|
||
}
|
||
|
||
fn generate(self, src: &Path, dest: &Path) -> std::io::Result<()> {
|
||
match self {
|
||
PreviewTier::Large => crate::thumbnails::generate_large_preview(src, dest),
|
||
PreviewTier::XLarge => crate::thumbnails::generate_xlarge_preview(src, dest),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Serve the cached `tier` preview of `path`, generating it on a miss.
|
||
/// `None` when it can't be produced; the caller picks the fallback.
|
||
async fn serve_preview(
|
||
tier: PreviewTier,
|
||
library: &libraries::Library,
|
||
path: &Path,
|
||
thumbs: &Path,
|
||
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
||
request: &HttpRequest,
|
||
) -> Option<HttpResponse> {
|
||
let relative_path = path
|
||
.strip_prefix(&library.root_path)
|
||
.expect("Error stripping library root prefix from preview");
|
||
let relative_path_str = relative_path.to_string_lossy().replace('\\', "/");
|
||
|
||
// Lookup chain mirrors the Thumb branch — hash-keyed first so
|
||
// multi-library deployments share derivative bytes across
|
||
// libraries, then library-scoped legacy as the fallback for
|
||
// rows that aren't hashed yet.
|
||
let hash_path: Option<PathBuf> = {
|
||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||
match dao.get_exif(&relative_path_str) {
|
||
Ok(Some(row)) => row
|
||
.content_hash
|
||
.as_deref()
|
||
.map(|h| tier.hash_path(thumbs, h)),
|
||
_ => None,
|
||
}
|
||
};
|
||
let scoped_legacy_path = content_hash::library_scoped_legacy_path(
|
||
&thumbs.join(tier.legacy_dir()),
|
||
library.id,
|
||
relative_path,
|
||
);
|
||
|
||
let serve = |p: &Path| {
|
||
open_derivative(p).map(|file| {
|
||
file.use_etag(true)
|
||
.use_last_modified(true)
|
||
.prefer_utf8(true)
|
||
.into_response(request)
|
||
})
|
||
};
|
||
|
||
let existing = hash_path
|
||
.as_ref()
|
||
.filter(|p| p.exists())
|
||
.or_else(|| Some(&scoped_legacy_path).filter(|p| p.exists()));
|
||
if let Some(resp) = existing.and_then(|p| serve(p)) {
|
||
return Some(resp);
|
||
}
|
||
|
||
// Cache miss — generate. Resize + JPEG-encode can take 100–500ms
|
||
// for a 24MP source (longer for RAW), so run on the blocking pool
|
||
// to keep the actix worker free. Prefer the hash-keyed
|
||
// destination when a hash is known so the result is reusable
|
||
// across libraries that hold the same bytes.
|
||
let dest = hash_path.unwrap_or(scoped_legacy_path);
|
||
let src = path.to_path_buf();
|
||
let dest_for_block = dest.clone();
|
||
let generated = web::block(move || generate_preview(tier, &src, &dest_for_block)).await;
|
||
|
||
match generated {
|
||
Ok(Ok(())) => {}
|
||
Ok(Err(e)) => warn!(
|
||
"{} preview generation failed for {:?}: {}",
|
||
tier.name(),
|
||
path,
|
||
e
|
||
),
|
||
Err(e) => warn!(
|
||
"{} preview blocking-pool error for {:?}: {}",
|
||
tier.name(),
|
||
path,
|
||
e
|
||
),
|
||
}
|
||
serve(&dest)
|
||
}
|
||
|
||
/// Generate `tier` for `src` into a per-call tempfile, then atomically
|
||
/// rename it to `dest`, so a concurrent reader never sees a half-written
|
||
/// JPEG. The tempfile name must be unique: clients fire the same request
|
||
/// twice at once (tile remounts), and a shared `<dest>.tmp` let the first
|
||
/// rename steal the second's file — "cannot find the file specified
|
||
/// (os error 2)".
|
||
fn generate_preview(tier: PreviewTier, src: &Path, dest: &Path) -> std::io::Result<()> {
|
||
let parent = dest
|
||
.parent()
|
||
.ok_or_else(|| std::io::Error::other("preview path has no parent"))?;
|
||
std::fs::create_dir_all(parent)?;
|
||
let tmp = tempfile::Builder::new()
|
||
.suffix(".jpg.tmp")
|
||
.tempfile_in(parent)?
|
||
.into_temp_path();
|
||
tier.generate(src, &tmp)?;
|
||
tmp.persist(dest)?;
|
||
Ok(())
|
||
}
|
||
|
||
async fn create_circular_thumbnail(
|
||
thumb_path: &Path,
|
||
thumbs_dir: &str,
|
||
) -> Result<PathBuf, Box<dyn Error>> {
|
||
use image::{GenericImageView, ImageBuffer, Rgba};
|
||
|
||
// Create circular thumbnails directory
|
||
let circular_dir = Path::new(thumbs_dir).join("_circular");
|
||
|
||
// Get relative path from thumbs_dir to create same structure
|
||
let relative_to_thumbs = thumb_path.strip_prefix(thumbs_dir)?;
|
||
let circular_path = circular_dir.join(relative_to_thumbs).with_extension("png");
|
||
|
||
// Check if circular thumbnail already exists
|
||
if circular_path.exists() {
|
||
return Ok(circular_path);
|
||
}
|
||
|
||
// Create parent directory if needed
|
||
if let Some(parent) = circular_path.parent() {
|
||
std::fs::create_dir_all(parent)?;
|
||
}
|
||
|
||
// Load the square thumbnail. Sniff the format: a legacy thumb keeps
|
||
// its source's name (`clip.mp4`, `shot.arw`) but holds JPEG bytes.
|
||
let img = image::ImageReader::open(thumb_path)?
|
||
.with_guessed_format()?
|
||
.decode()?;
|
||
let (width, height) = img.dimensions();
|
||
|
||
// Fixed output size for consistency
|
||
let output_size = 80u32;
|
||
let radius = output_size as f32 / 2.0;
|
||
|
||
// Calculate crop area to get square center of original image
|
||
let crop_size = width.min(height);
|
||
let crop_x = (width - crop_size) / 2;
|
||
let crop_y = (height - crop_size) / 2;
|
||
|
||
// Create a new RGBA image with transparency
|
||
let output = ImageBuffer::from_fn(output_size, output_size, |x, y| {
|
||
let dx = x as f32 - radius;
|
||
let dy = y as f32 - radius;
|
||
let distance = (dx * dx + dy * dy).sqrt();
|
||
|
||
if distance <= radius {
|
||
// Inside circle - map to cropped source area
|
||
// Scale from output coordinates to crop coordinates
|
||
let scale = crop_size as f32 / output_size as f32;
|
||
let src_x = crop_x + (x as f32 * scale) as u32;
|
||
let src_y = crop_y + (y as f32 * scale) as u32;
|
||
let pixel = img.get_pixel(src_x, src_y);
|
||
Rgba([pixel[0], pixel[1], pixel[2], 255])
|
||
} else {
|
||
// Outside circle - transparent
|
||
Rgba([0, 0, 0, 0])
|
||
}
|
||
});
|
||
|
||
// Save as PNG (supports transparency)
|
||
output.save(&circular_path)?;
|
||
|
||
Ok(circular_path)
|
||
}
|
||
|
||
#[get("/image/metadata")]
|
||
#[tracing::instrument(name = "get_file_metadata", skip_all)]
|
||
pub async fn get_file_metadata(
|
||
_: Claims,
|
||
path: web::Query<ThumbnailRequest>,
|
||
app_state: Data<AppState>,
|
||
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||
) -> impl Responder {
|
||
let span = tracing::Span::current();
|
||
|
||
let library = libraries::resolve_library_param_state(&app_state, path.library.as_deref())
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_else(|| app_state.primary_library());
|
||
|
||
// Fall back to other libraries if the file isn't under the resolved one,
|
||
// matching the `/image` handler so union-mode search results resolve.
|
||
let resolved = is_valid_full_path(&library.root_path, &path.path, false)
|
||
.filter(|p| p.exists())
|
||
.map(|p| (library, p))
|
||
.or_else(|| {
|
||
app_state.libraries.iter().find_map(|lib| {
|
||
if lib.id == library.id {
|
||
return None;
|
||
}
|
||
is_valid_full_path(&lib.root_path, &path.path, false)
|
||
.filter(|p| p.exists())
|
||
.map(|p| (lib, p))
|
||
})
|
||
});
|
||
|
||
match resolved
|
||
.ok_or_else(|| ErrorKind::InvalidData.into())
|
||
.and_then(|(lib, full_path)| {
|
||
File::open(&full_path)
|
||
.and_then(|file| file.metadata())
|
||
.map(|metadata| (lib, metadata))
|
||
}) {
|
||
Ok((resolved_library, metadata)) => {
|
||
let mut response: MetadataResponse = metadata.into();
|
||
response.library_id = Some(resolved_library.id);
|
||
response.library_name = Some(resolved_library.name.clone());
|
||
|
||
// Extract date from filename if possible
|
||
response.filename_date = memories::extract_date_from_filename(&path.path)
|
||
.map(memories::FilenameDate::legacy_timestamp);
|
||
|
||
// Query EXIF data if available
|
||
let row = exif_dao
|
||
.lock()
|
||
.ok()
|
||
.and_then(|mut dao| dao.get_exif(&path.path).ok().flatten());
|
||
set_capture_dates(
|
||
&mut response,
|
||
row.as_ref(),
|
||
&path.path,
|
||
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
|
||
);
|
||
response.exif = row.map(Into::into);
|
||
|
||
span.add_event(
|
||
"Metadata fetched",
|
||
vec![KeyValue::new("file", path.path.clone())],
|
||
);
|
||
span.set_status(Status::Ok);
|
||
|
||
HttpResponse::Ok().json(response)
|
||
}
|
||
Err(e) => {
|
||
let message = format!("Error getting metadata for file '{}': {:?}", path.path, e);
|
||
error!("{}", message);
|
||
span.set_status(Status::error(message));
|
||
|
||
HttpResponse::InternalServerError().finish()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Body for `POST /image/exif/gps` — write GPS coordinates into a file's
|
||
/// EXIF in place. Only `path` + `latitude` + `longitude` are required.
|
||
/// `library` is optional (falls back to the primary library) and matches
|
||
/// the convention of the other path-keyed routes.
|
||
#[derive(serde::Deserialize)]
|
||
struct SetGpsRequest {
|
||
path: String,
|
||
library: Option<String>,
|
||
latitude: f64,
|
||
longitude: f64,
|
||
}
|
||
|
||
#[post("/image/exif/gps")]
|
||
#[tracing::instrument(name = "set_image_gps", skip_all)]
|
||
pub async fn set_image_gps(
|
||
_: Claims,
|
||
body: web::Json<SetGpsRequest>,
|
||
app_state: Data<AppState>,
|
||
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||
) -> impl Responder {
|
||
let span = tracing::Span::current();
|
||
|
||
let library = libraries::resolve_library_param_state(&app_state, body.library.as_deref())
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_else(|| app_state.primary_library());
|
||
|
||
// Same fallback as get_file_metadata: union-mode means a file may
|
||
// resolve under a sibling library.
|
||
let resolved = is_valid_full_path(&library.root_path, &body.path, false)
|
||
.filter(|p| p.exists())
|
||
.map(|p| (library, p))
|
||
.or_else(|| {
|
||
app_state.libraries.iter().find_map(|lib| {
|
||
if lib.id == library.id {
|
||
return None;
|
||
}
|
||
is_valid_full_path(&lib.root_path, &body.path, false)
|
||
.filter(|p| p.exists())
|
||
.map(|p| (lib, p))
|
||
})
|
||
});
|
||
|
||
let (resolved_library, full_path) = match resolved {
|
||
Some(v) => v,
|
||
None => {
|
||
span.set_status(Status::error("file not found"));
|
||
return HttpResponse::NotFound().body("File not found");
|
||
}
|
||
};
|
||
|
||
if !exif::supports_exif(&full_path) {
|
||
return HttpResponse::BadRequest().body("File format does not support EXIF GPS write");
|
||
}
|
||
|
||
if let Err(e) = exif::write_gps(&full_path, body.latitude, body.longitude) {
|
||
let msg = format!("exiftool write failed: {}", e);
|
||
error!("{}", msg);
|
||
span.set_status(Status::error(msg.clone()));
|
||
return HttpResponse::InternalServerError().body(msg);
|
||
}
|
||
|
||
// Re-read EXIF from disk (the write path doesn't tell us the rest of
|
||
// the parsed fields back, and we want the DB row to match what
|
||
// extract_exif_from_path would now produce). Update the existing row
|
||
// rather than insert — this endpoint is invoked on already-indexed
|
||
// files only.
|
||
let extracted = match exif::extract_exif_from_path(&full_path) {
|
||
Ok(d) => d,
|
||
Err(e) => {
|
||
// GPS was written successfully but re-extraction failed; surface
|
||
// a 500 because the DB will now disagree with disk until the
|
||
// next file scan rewrites it.
|
||
let msg = format!("EXIF re-read failed after write: {}", e);
|
||
error!("{}", msg);
|
||
return HttpResponse::InternalServerError().body(msg);
|
||
}
|
||
};
|
||
let now = Utc::now().timestamp();
|
||
let normalized_path = body.path.replace('\\', "/");
|
||
// Re-run the canonical-date waterfall on every GPS write — exiftool
|
||
// writing GPS doesn't change the capture date, but if the row was
|
||
// previously sourced from `fs_time` the re-read may have given us a
|
||
// real EXIF date this time, and we want to upgrade the source.
|
||
let resolved_date = date_resolver::resolve_date_taken(
|
||
&full_path,
|
||
date_resolver::exif_prior_from(&extracted).as_ref(),
|
||
);
|
||
let insert_exif = InsertImageExif {
|
||
library_id: resolved_library.id,
|
||
file_path: normalized_path.clone(),
|
||
camera_make: extracted.camera_make,
|
||
camera_model: extracted.camera_model,
|
||
lens_model: extracted.lens_model,
|
||
width: extracted.width,
|
||
height: extracted.height,
|
||
orientation: extracted.orientation,
|
||
gps_latitude: extracted.gps_latitude.map(|v| v as f32),
|
||
gps_longitude: extracted.gps_longitude.map(|v| v as f32),
|
||
gps_altitude: extracted.gps_altitude.map(|v| v as f32),
|
||
focal_length: extracted.focal_length.map(|v| v as f32),
|
||
aperture: extracted.aperture.map(|v| v as f32),
|
||
shutter_speed: extracted.shutter_speed,
|
||
iso: extracted.iso,
|
||
date_taken: resolved_date.map(|r| r.timestamp),
|
||
// Created_time is preserved by update_exif (it doesn't touch the
|
||
// column); pass any int — it's ignored in the UPDATE statement.
|
||
created_time: now,
|
||
last_modified: now,
|
||
// Hash + size aren't touched in update_exif either, but the file
|
||
// bytes did change — best-effort recompute so the new hash lands
|
||
// on the next call to get_exif. Failure here just leaves the old
|
||
// values in place.
|
||
content_hash: content_hash::compute(&full_path)
|
||
.ok()
|
||
.map(|c| c.content_hash),
|
||
size_bytes: content_hash::compute(&full_path).ok().map(|c| c.size_bytes),
|
||
// GPS-update path doesn't touch perceptual hashes either; columns
|
||
// ignored by update_exif. Compute best-effort so a new file lands
|
||
// with a usable signal; failure just leaves prior values in place.
|
||
phash_64: perceptual_hash::compute(&full_path).map(|h| h.phash_64),
|
||
dhash_64: perceptual_hash::compute(&full_path).map(|h| h.dhash_64),
|
||
date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()),
|
||
date_utc: resolved_date.and_then(|r| r.date_utc),
|
||
camera_offset_minutes: resolved_date.and_then(|r| r.camera_offset_minutes),
|
||
date_taken_tz_source: resolved_date
|
||
.and_then(|r| r.tz_source)
|
||
.map(|t| t.as_str().to_string()),
|
||
};
|
||
|
||
let updated = {
|
||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||
// If the row doesn't exist yet (file isn't indexed for some reason),
|
||
// insert instead so the GPS write is at least visible the moment
|
||
// the watcher catches up.
|
||
match dao.get_exif(&normalized_path) {
|
||
Ok(Some(_)) => dao.update_exif(insert_exif),
|
||
Ok(None) => dao.store_exif(insert_exif),
|
||
Err(_) => dao.update_exif(insert_exif),
|
||
}
|
||
};
|
||
|
||
match updated {
|
||
Ok(row) => {
|
||
// Mirror the file metadata so the client gets the new size /
|
||
// mtime in the same response and can refresh its cached
|
||
// metadata block in one round-trip.
|
||
let fs_meta = std::fs::metadata(&full_path).ok();
|
||
let mut response: MetadataResponse = match fs_meta {
|
||
Some(m) => m.into(),
|
||
None => MetadataResponse {
|
||
created: None,
|
||
modified: None,
|
||
size: 0,
|
||
exif: None,
|
||
filename_date: None,
|
||
library_id: None,
|
||
library_name: None,
|
||
wall_clock: None,
|
||
true_instant: None,
|
||
},
|
||
};
|
||
set_capture_dates(
|
||
&mut response,
|
||
Some(&row),
|
||
&normalized_path,
|
||
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
|
||
);
|
||
response.exif = Some(row.into());
|
||
response.library_id = Some(resolved_library.id);
|
||
response.library_name = Some(resolved_library.name.clone());
|
||
response.filename_date = memories::extract_date_from_filename(&body.path)
|
||
.map(memories::FilenameDate::legacy_timestamp);
|
||
span.set_status(Status::Ok);
|
||
HttpResponse::Ok().json(response)
|
||
}
|
||
Err(e) => {
|
||
let msg = format!("EXIF DB update failed: {:?}", e);
|
||
error!("{}", msg);
|
||
span.set_status(Status::error(msg.clone()));
|
||
HttpResponse::InternalServerError().body(msg)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `GET /image/exif/full?path=&library=` — full per-file EXIF dump via
|
||
/// exiftool, for the DETAILS modal's "FULL EXIF" pane. Strictly richer
|
||
/// than `/image/metadata`'s curated subset (every group exiftool can
|
||
/// see: EXIF, File, MakerNotes, Composite, ICC_Profile, IPTC, …).
|
||
///
|
||
/// On-demand only — the watcher / indexer never calls this. Falls back
|
||
/// to 503 when exiftool isn't installed (deployer guidance is the same
|
||
/// as for the RAW preview pipeline: install exiftool for full coverage).
|
||
#[get("/image/exif/full")]
|
||
#[tracing::instrument(name = "get_full_exif", skip_all)]
|
||
pub async fn get_full_exif(
|
||
_: Claims,
|
||
path: web::Query<ThumbnailRequest>,
|
||
app_state: Data<AppState>,
|
||
) -> impl Responder {
|
||
let span = tracing::Span::current();
|
||
|
||
let library = libraries::resolve_library_param_state(&app_state, path.library.as_deref())
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_else(|| app_state.primary_library());
|
||
|
||
// Same union-mode fallback as get_file_metadata — the file may live
|
||
// under a sibling library when the requested one's path resolves but
|
||
// doesn't actually contain the bytes.
|
||
let resolved = is_valid_full_path(&library.root_path, &path.path, false)
|
||
.filter(|p| p.exists())
|
||
.map(|p| (library, p))
|
||
.or_else(|| {
|
||
app_state.libraries.iter().find_map(|lib| {
|
||
if lib.id == library.id {
|
||
return None;
|
||
}
|
||
is_valid_full_path(&lib.root_path, &path.path, false)
|
||
.filter(|p| p.exists())
|
||
.map(|p| (lib, p))
|
||
})
|
||
});
|
||
|
||
let (resolved_library, full_path) = match resolved {
|
||
Some(v) => v,
|
||
None => {
|
||
span.set_status(Status::error("file not found"));
|
||
return HttpResponse::NotFound().body("File not found");
|
||
}
|
||
};
|
||
|
||
// exiftool spawn is blocking — keep it off the actix worker by
|
||
// running on the blocking pool. ~50–200 ms typical for a JPEG;
|
||
// longer for RAW with rich MakerNotes.
|
||
let exif_result =
|
||
web::block(move || crate::exif::read_full_exif_via_exiftool(&full_path)).await;
|
||
|
||
match exif_result {
|
||
Ok(Ok(Some(tags))) => {
|
||
span.set_status(Status::Ok);
|
||
HttpResponse::Ok().json(serde_json::json!({
|
||
"library_id": resolved_library.id,
|
||
"library_name": resolved_library.name,
|
||
"tags": tags,
|
||
}))
|
||
}
|
||
Ok(Ok(None)) => {
|
||
// exiftool ran but produced no output for this file — treat as
|
||
// empty rather than an error so the modal renders "no tags"
|
||
// gracefully.
|
||
HttpResponse::Ok().json(serde_json::json!({
|
||
"library_id": resolved_library.id,
|
||
"library_name": resolved_library.name,
|
||
"tags": serde_json::Value::Object(Default::default()),
|
||
}))
|
||
}
|
||
Ok(Err(e)) => {
|
||
let msg = format!("exiftool failed: {}", e);
|
||
error!("{}", msg);
|
||
span.set_status(Status::error(msg.clone()));
|
||
// 503 — typically "exiftool isn't on PATH" or a transient spawn
|
||
// failure. Apollo surfaces a hint in the modal.
|
||
HttpResponse::ServiceUnavailable().body(msg)
|
||
}
|
||
Err(e) => {
|
||
let msg = format!("blocking-pool error: {}", e);
|
||
error!("{}", msg);
|
||
span.set_status(Status::error(msg.clone()));
|
||
HttpResponse::InternalServerError().body(msg)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Body for `POST /image/exif/date` — operator-driven date override.
|
||
/// `date_taken` is unix seconds: the wall the user picked rendered in their
|
||
/// display zone, encoded naive-as-UTC (the Apollo client passes through the
|
||
/// same value the photo carousel rendered before edit). The legacy
|
||
/// `date_taken` column stores it verbatim; the new-contract triple
|
||
/// (`date_utc` / `camera_offset_minutes` / `date_taken_tz_source =
|
||
/// 'manual'`, ADR-0001 ticket #115) is derived from it as a wall clock in
|
||
/// the user's display zone.
|
||
#[derive(serde::Deserialize)]
|
||
struct SetDateRequest {
|
||
path: String,
|
||
library: Option<String>,
|
||
date_taken: i64,
|
||
}
|
||
|
||
/// Body for `POST /image/exif/date/clear` — revert a manual override and
|
||
/// restore the resolver-derived `(date_taken, date_taken_source)` pair from
|
||
/// the snapshot.
|
||
#[derive(serde::Deserialize)]
|
||
struct ClearDateRequest {
|
||
path: String,
|
||
library: Option<String>,
|
||
}
|
||
|
||
/// Build a `MetadataResponse` for the date endpoints. Mirrors
|
||
/// `get_file_metadata`'s shape so the client gets a single source of truth
|
||
/// after every mutation. Filesystem metadata is best-effort: if the file is
|
||
/// on a stale mount or moved, the DB-side override still succeeds and the
|
||
/// response carries `created=None, modified=None, size=0`. The DB row's
|
||
/// updated EXIF is what matters here.
|
||
fn build_metadata_response_for_date_mutation(
|
||
library: &libraries::Library,
|
||
rel_path: &str,
|
||
exif: ImageExif,
|
||
display_zone: chrono_tz::Tz,
|
||
) -> MetadataResponse {
|
||
let full_path = is_valid_full_path(&library.root_path, &rel_path.to_string(), false);
|
||
let fs_meta = full_path
|
||
.as_ref()
|
||
.filter(|p| p.exists())
|
||
.and_then(|p| std::fs::metadata(p).ok());
|
||
let mut response: MetadataResponse = match fs_meta {
|
||
Some(m) => m.into(),
|
||
None => MetadataResponse {
|
||
created: None,
|
||
modified: None,
|
||
size: 0,
|
||
exif: None,
|
||
filename_date: None,
|
||
library_id: None,
|
||
library_name: None,
|
||
wall_clock: None,
|
||
true_instant: None,
|
||
},
|
||
};
|
||
set_capture_dates(&mut response, Some(&exif), rel_path, display_zone);
|
||
response.exif = Some(exif.into());
|
||
response.library_id = Some(library.id);
|
||
response.library_name = Some(library.name.clone());
|
||
response.filename_date = memories::extract_date_from_filename(rel_path)
|
||
.map(memories::FilenameDate::legacy_timestamp);
|
||
response
|
||
}
|
||
|
||
/// Fill the capture-date fields every metadata response carries (ADR-0001):
|
||
/// `wall_clock` to display and `true_instant` to match against. Needs the
|
||
/// fs times already on the response (the unindexed-file fallback).
|
||
fn set_capture_dates(
|
||
response: &mut MetadataResponse,
|
||
row: Option<&ImageExif>,
|
||
rel_path: &str,
|
||
display_zone: chrono_tz::Tz,
|
||
) {
|
||
let server_zone = crate::camera_tz::server_zone();
|
||
let fs_earliest = earliest_fs_time(response);
|
||
response.wall_clock =
|
||
crate::date_view::file_wall_clock(row, rel_path, fs_earliest, display_zone, server_zone);
|
||
response.true_instant =
|
||
crate::date_view::file_true_instant(row, rel_path, fs_earliest, display_zone, server_zone);
|
||
}
|
||
|
||
/// Earlier of the file's created / modified times — the ingest waterfall's
|
||
/// `fs_time` rule — for the wall-clock fallback of unindexed files.
|
||
fn earliest_fs_time(response: &MetadataResponse) -> Option<i64> {
|
||
match (response.created, response.modified) {
|
||
(Some(c), Some(m)) => Some(c.min(m)),
|
||
(c, m) => c.or(m),
|
||
}
|
||
}
|
||
|
||
#[post("/image/exif/date")]
|
||
#[tracing::instrument(name = "set_image_date", skip_all)]
|
||
pub async fn set_image_date(
|
||
_: Claims,
|
||
body: web::Json<SetDateRequest>,
|
||
app_state: Data<AppState>,
|
||
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||
) -> impl Responder {
|
||
let span = tracing::Span::current();
|
||
|
||
let library = match libraries::resolve_library_param_state(&app_state, body.library.as_deref())
|
||
{
|
||
Ok(Some(lib)) => lib,
|
||
Ok(None) => app_state.primary_library(),
|
||
Err(msg) => {
|
||
span.set_status(Status::error(msg.clone()));
|
||
return HttpResponse::BadRequest().body(msg);
|
||
}
|
||
};
|
||
|
||
// Path normalization matches set_image_gps so a Windows-import client
|
||
// doesn't end up with a backslash variant that misses the row.
|
||
let normalized_path = body.path.replace('\\', "/");
|
||
|
||
// The user's display zone — the wall the UI sent is rendered in this
|
||
// zone (ADR-0001, ticket #115). NULL / unparseable prefs fall back to
|
||
// the app default inside display_zone_for.
|
||
let display_tz = {
|
||
let mut prefs_dao = app_state
|
||
.user_ai_prefs_dao
|
||
.lock()
|
||
.expect("Unable to lock UserAiPrefsDao");
|
||
match prefs_dao.get_prefs() {
|
||
Ok(Some(prefs)) => crate::camera_tz::display_zone_for(prefs.display_tz.as_deref()),
|
||
_ => crate::camera_tz::display_zone_for(None),
|
||
}
|
||
};
|
||
|
||
let updated = {
|
||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||
dao.set_manual_date_taken(library.id, &normalized_path, body.date_taken, &display_tz)
|
||
};
|
||
|
||
match updated {
|
||
Ok(row) => {
|
||
span.set_status(Status::Ok);
|
||
HttpResponse::Ok().json(build_metadata_response_for_date_mutation(
|
||
library,
|
||
&normalized_path,
|
||
row,
|
||
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
|
||
))
|
||
}
|
||
Err(e) => {
|
||
let msg = format!("set_manual_date_taken failed: {:?}", e);
|
||
error!("{}", msg);
|
||
span.set_status(Status::error(msg.clone()));
|
||
match e.kind {
|
||
DbErrorKind::NotFound => HttpResponse::NotFound().body(msg),
|
||
_ => HttpResponse::InternalServerError().body(msg),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[post("/image/exif/date/clear")]
|
||
#[tracing::instrument(name = "clear_image_date", skip_all)]
|
||
pub async fn clear_image_date(
|
||
_: Claims,
|
||
body: web::Json<ClearDateRequest>,
|
||
app_state: Data<AppState>,
|
||
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||
) -> impl Responder {
|
||
let span = tracing::Span::current();
|
||
|
||
let library = match libraries::resolve_library_param_state(&app_state, body.library.as_deref())
|
||
{
|
||
Ok(Some(lib)) => lib,
|
||
Ok(None) => app_state.primary_library(),
|
||
Err(msg) => {
|
||
span.set_status(Status::error(msg.clone()));
|
||
return HttpResponse::BadRequest().body(msg);
|
||
}
|
||
};
|
||
|
||
let normalized_path = body.path.replace('\\', "/");
|
||
|
||
let updated = {
|
||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||
dao.clear_manual_date_taken(library.id, &normalized_path)
|
||
};
|
||
|
||
match updated {
|
||
Ok(row) => {
|
||
span.set_status(Status::Ok);
|
||
HttpResponse::Ok().json(build_metadata_response_for_date_mutation(
|
||
library,
|
||
&normalized_path,
|
||
row,
|
||
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
|
||
))
|
||
}
|
||
Err(e) => {
|
||
let msg = format!("clear_manual_date_taken failed: {:?}", e);
|
||
error!("{}", msg);
|
||
span.set_status(Status::error(msg.clone()));
|
||
match e.kind {
|
||
DbErrorKind::NotFound => HttpResponse::NotFound().body(msg),
|
||
_ => HttpResponse::InternalServerError().body(msg),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(serde::Deserialize)]
|
||
struct UploadQuery {
|
||
library: Option<String>,
|
||
}
|
||
|
||
#[post("/image")]
|
||
#[tracing::instrument(name = "upload_image", skip_all)]
|
||
pub async fn upload_image(
|
||
_: Claims,
|
||
query: web::Query<UploadQuery>,
|
||
mut payload: mp::Multipart,
|
||
app_state: Data<AppState>,
|
||
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||
) -> impl Responder {
|
||
let span = tracing::Span::current();
|
||
|
||
// Resolve the optional library selector. Absent → primary library
|
||
// (backwards-compatible with clients that don't yet send `library=`).
|
||
let target_library =
|
||
match libraries::resolve_library_param_state(&app_state, query.library.as_deref()) {
|
||
Ok(Some(lib)) => lib,
|
||
Ok(None) => app_state.primary_library(),
|
||
Err(msg) => {
|
||
span.set_status(Status::error(msg.clone()));
|
||
return HttpResponse::BadRequest().body(msg);
|
||
}
|
||
};
|
||
|
||
let mut file_content: BytesMut = BytesMut::new();
|
||
let mut file_name: Option<String> = None;
|
||
let mut file_path: Option<String> = None;
|
||
|
||
while let Some(Ok(mut part)) = payload.next().await {
|
||
if let Some(content_type) = part.content_disposition() {
|
||
debug!("{:?}", content_type);
|
||
if let Some(filename) = content_type.get_filename() {
|
||
debug!("Name (raw): {:?}", filename);
|
||
// Decode URL-encoded filename (e.g., "file%20name.jpg" -> "file name.jpg")
|
||
let decoded_filename = decode(filename)
|
||
.map(|s| s.to_string())
|
||
.unwrap_or_else(|_| filename.to_string());
|
||
debug!("Name (decoded): {:?}", decoded_filename);
|
||
file_name = Some(decoded_filename);
|
||
|
||
while let Some(Ok(data)) = part.next().await {
|
||
file_content.put(data);
|
||
}
|
||
} else if content_type.get_name() == Some("path") {
|
||
while let Some(Ok(data)) = part.next().await {
|
||
if let Ok(path) = std::str::from_utf8(&data) {
|
||
file_path = Some(path.to_string())
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let path = file_path.unwrap_or_else(|| target_library.root_path.clone());
|
||
if !file_content.is_empty() {
|
||
if file_name.is_none() {
|
||
span.set_status(Status::error("No filename provided"));
|
||
return HttpResponse::BadRequest().body("No filename provided");
|
||
}
|
||
let full_path = PathBuf::from(&path).join(file_name.unwrap());
|
||
if let Some(full_path) = is_valid_full_path(
|
||
&target_library.root_path,
|
||
&full_path.to_str().unwrap().to_string(),
|
||
true,
|
||
) {
|
||
// Pre-write content-hash check: if these exact bytes already
|
||
// exist anywhere in any library (and aren't themselves
|
||
// soft-marked as duplicates), don't write the file. Return
|
||
// 409 with the canonical sibling so the mobile app can show
|
||
// a friendly "already in your library" toast.
|
||
let upload_hash = blake3::Hasher::new()
|
||
.update(&file_content)
|
||
.finalize()
|
||
.to_hex()
|
||
.to_string();
|
||
{
|
||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||
if let Ok(Some(existing)) = dao.find_by_content_hash(&upload_hash)
|
||
&& existing.duplicate_of_hash.is_none()
|
||
{
|
||
let library_name = libraries::load_all(&mut crate::database::connect())
|
||
.into_iter()
|
||
.find(|l| l.id == existing.library_id)
|
||
.map(|l| l.name);
|
||
span.set_status(Status::Ok);
|
||
return HttpResponse::Conflict().json(serde_json::json!({
|
||
"duplicate_of": {
|
||
"library_id": existing.library_id,
|
||
"rel_path": existing.file_path,
|
||
},
|
||
"content_hash": upload_hash,
|
||
"library_name": library_name,
|
||
}));
|
||
}
|
||
}
|
||
|
||
let uploaded_path = if !full_path.is_file() && is_image_or_video(&full_path) {
|
||
let mut file = File::create(&full_path).unwrap();
|
||
file.write_all(&file_content).unwrap();
|
||
|
||
info!("Uploaded: {:?}", full_path);
|
||
full_path
|
||
} else {
|
||
warn!("File already exists: {:?}", full_path);
|
||
|
||
let new_path = format!(
|
||
"{}/{}_{}.{}",
|
||
full_path.parent().unwrap().to_str().unwrap(),
|
||
full_path.file_stem().unwrap().to_str().unwrap(),
|
||
Utc::now().timestamp(),
|
||
full_path
|
||
.extension()
|
||
.expect("Uploaded file should have an extension")
|
||
.to_str()
|
||
.unwrap()
|
||
);
|
||
info!("Uploaded: {}", new_path);
|
||
|
||
let new_path_buf = PathBuf::from(&new_path);
|
||
let mut file = File::create(&new_path_buf).unwrap();
|
||
file.write_all(&file_content).unwrap();
|
||
new_path_buf
|
||
};
|
||
|
||
// Extract and store EXIF data if file supports it
|
||
if exif::supports_exif(&uploaded_path) {
|
||
let relative_path = uploaded_path
|
||
.strip_prefix(&target_library.root_path)
|
||
.expect("Error stripping library root prefix")
|
||
.to_str()
|
||
.unwrap()
|
||
.replace('\\', "/");
|
||
|
||
match exif::extract_exif_from_path(&uploaded_path) {
|
||
Ok(exif_data) => {
|
||
let timestamp = Utc::now().timestamp();
|
||
let (content_hash, size_bytes) = match content_hash::compute(&uploaded_path)
|
||
{
|
||
Ok(id) => (Some(id.content_hash), Some(id.size_bytes)),
|
||
Err(e) => {
|
||
warn!(
|
||
"Failed to hash uploaded {}: {:?}",
|
||
uploaded_path.display(),
|
||
e
|
||
);
|
||
(None, None)
|
||
}
|
||
};
|
||
let perceptual = perceptual_hash::compute(&uploaded_path);
|
||
let resolved_date = date_resolver::resolve_date_taken(
|
||
&uploaded_path,
|
||
date_resolver::exif_prior_from(&exif_data).as_ref(),
|
||
);
|
||
let insert_exif = InsertImageExif {
|
||
library_id: target_library.id,
|
||
file_path: relative_path.clone(),
|
||
camera_make: exif_data.camera_make,
|
||
camera_model: exif_data.camera_model,
|
||
lens_model: exif_data.lens_model,
|
||
width: exif_data.width,
|
||
height: exif_data.height,
|
||
orientation: exif_data.orientation,
|
||
gps_latitude: exif_data.gps_latitude.map(|v| v as f32),
|
||
gps_longitude: exif_data.gps_longitude.map(|v| v as f32),
|
||
gps_altitude: exif_data.gps_altitude.map(|v| v as f32),
|
||
focal_length: exif_data.focal_length.map(|v| v as f32),
|
||
aperture: exif_data.aperture.map(|v| v as f32),
|
||
shutter_speed: exif_data.shutter_speed,
|
||
iso: exif_data.iso,
|
||
date_taken: resolved_date.map(|r| r.timestamp),
|
||
created_time: timestamp,
|
||
last_modified: timestamp,
|
||
content_hash,
|
||
size_bytes,
|
||
phash_64: perceptual.map(|h| h.phash_64),
|
||
dhash_64: perceptual.map(|h| h.dhash_64),
|
||
date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()),
|
||
date_utc: resolved_date.and_then(|r| r.date_utc),
|
||
camera_offset_minutes: resolved_date
|
||
.and_then(|r| r.camera_offset_minutes),
|
||
date_taken_tz_source: resolved_date
|
||
.and_then(|r| r.tz_source)
|
||
.map(|t| t.as_str().to_string()),
|
||
};
|
||
|
||
if let Ok(mut dao) = exif_dao.lock() {
|
||
if let Err(e) = dao.store_exif(insert_exif) {
|
||
error!("Failed to store EXIF data for {}: {:?}", relative_path, e);
|
||
} else {
|
||
debug!("EXIF data stored for {}", relative_path);
|
||
}
|
||
}
|
||
}
|
||
Err(e) => {
|
||
debug!(
|
||
"No EXIF data or error extracting from {}: {:?}",
|
||
uploaded_path.display(),
|
||
e
|
||
);
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
error!("Invalid path for upload: {:?}", full_path);
|
||
span.set_status(Status::error("Invalid path for upload"));
|
||
return HttpResponse::BadRequest().body("Path was not valid");
|
||
}
|
||
} else {
|
||
span.set_status(Status::error("No file body read"));
|
||
return HttpResponse::BadRequest().body("No file body read");
|
||
}
|
||
|
||
app_state.stream_manager.do_send(RefreshThumbnailsMessage);
|
||
span.set_status(Status::Ok);
|
||
|
||
HttpResponse::Ok().finish()
|
||
}
|
||
|
||
/// Open a generated derivative (thumbnail / preview) with a content type
|
||
/// taken from its bytes rather than its name. Legacy derivative paths
|
||
/// mirror the source's relative path, so a video's JPEG thumbnail lives
|
||
/// at `…/clip.mp4` and a RAW's at `…/shot.arw`; `NamedFile` would label
|
||
/// those `video/mp4` / `image/x-sony-arw`, and clients that trust the
|
||
/// header hand a JPEG to the wrong decoder.
|
||
fn open_derivative(path: &Path) -> Option<NamedFile> {
|
||
let file = NamedFile::open(path).ok()?;
|
||
let mut magic = [0u8; 8];
|
||
let sniffed = File::open(path)
|
||
.and_then(|mut f| f.read(&mut magic))
|
||
.ok()
|
||
.and_then(|n| sniff_image_mime(&magic[..n]));
|
||
Some(match sniffed {
|
||
Some(mime) => file.set_content_type(mime),
|
||
None => file,
|
||
})
|
||
}
|
||
|
||
/// The image formats derivatives are written in (JPEG, or PNG for a PNG
|
||
/// source thumbnailed by the `image` crate), identified by magic bytes.
|
||
fn sniff_image_mime(head: &[u8]) -> Option<actix_web::mime::Mime> {
|
||
if head.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||
Some(actix_web::mime::IMAGE_JPEG)
|
||
} else if head.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||
Some(actix_web::mime::IMAGE_PNG)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::database::SqliteExifDao;
|
||
use crate::database::test::in_memory_db_connection;
|
||
use actix_web::test::TestRequest;
|
||
use image::GenericImageView;
|
||
|
||
#[test]
|
||
fn derivative_content_type_comes_from_bytes_not_name() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let req = TestRequest::default().to_http_request();
|
||
let content_type = |name: &str, bytes: &[u8]| {
|
||
let p = dir.path().join(name);
|
||
std::fs::write(&p, bytes).unwrap();
|
||
let resp = open_derivative(&p).unwrap().into_response(&req);
|
||
resp.headers()
|
||
.get("content-type")
|
||
.unwrap()
|
||
.to_str()
|
||
.unwrap()
|
||
.to_string()
|
||
};
|
||
// A video's legacy thumb: JPEG bytes under the source's name.
|
||
assert_eq!(
|
||
content_type("clip.mp4", &[0xFF, 0xD8, 0xFF, 0xE0, 0]),
|
||
"image/jpeg"
|
||
);
|
||
assert_eq!(
|
||
content_type("shot.arw", &[0xFF, 0xD8, 0xFF, 0xDB]),
|
||
"image/jpeg"
|
||
);
|
||
assert_eq!(
|
||
content_type("shot.png", b"\x89PNG\r\n\x1a\n...."),
|
||
"image/png"
|
||
);
|
||
// Unrecognised bytes keep the extension-based guess.
|
||
assert_eq!(content_type("anim.gif", b"GIF89a"), "image/gif");
|
||
}
|
||
|
||
fn library(root: &Path) -> libraries::Library {
|
||
libraries::Library {
|
||
id: libraries::PRIMARY_LIBRARY_ID,
|
||
name: "main".to_string(),
|
||
root_path: root.to_string_lossy().to_string(),
|
||
enabled: true,
|
||
excluded_dirs: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn exif_dao() -> Mutex<Box<dyn ExifDao>> {
|
||
Mutex::new(Box::new(SqliteExifDao::from_connection(
|
||
in_memory_db_connection(),
|
||
)))
|
||
}
|
||
|
||
/// Write a solid-color JPEG of the given size under `root`.
|
||
fn write_jpeg(root: &Path, name: &str, w: u32, h: u32) -> PathBuf {
|
||
let path = root.join(name);
|
||
image::RgbImage::from_pixel(w, h, image::Rgb([90, 140, 200]))
|
||
.save(&path)
|
||
.unwrap();
|
||
path
|
||
}
|
||
|
||
#[test]
|
||
fn concurrent_generation_of_one_preview_all_succeed() {
|
||
let root = tempfile::tempdir().unwrap();
|
||
let thumbs = tempfile::tempdir().unwrap();
|
||
let src = write_jpeg(root.path(), "pano.jpg", 3000, 800);
|
||
let dest = thumbs.path().join("_large").join("ab").join("abcd.jpg");
|
||
|
||
// Clients fire the same preview twice at once; with a shared
|
||
// tempfile name the loser's rename failed with os error 2.
|
||
let barrier = std::sync::Barrier::new(8);
|
||
std::thread::scope(|s| {
|
||
let handles: Vec<_> = (0..8)
|
||
.map(|_| {
|
||
s.spawn(|| {
|
||
barrier.wait();
|
||
generate_preview(PreviewTier::Large, &src, &dest)
|
||
})
|
||
})
|
||
.collect();
|
||
for h in handles {
|
||
h.join().unwrap().expect("concurrent generation failed");
|
||
}
|
||
});
|
||
|
||
assert!(dest.exists());
|
||
let leftovers: Vec<_> = std::fs::read_dir(dest.parent().unwrap())
|
||
.unwrap()
|
||
.filter_map(Result::ok)
|
||
.filter(|e| e.file_name() != "abcd.jpg")
|
||
.collect();
|
||
assert!(leftovers.is_empty(), "stray tempfiles: {:?}", leftovers);
|
||
}
|
||
|
||
#[actix_rt::test]
|
||
async fn large_preview_caps_a_panorama_at_2048() {
|
||
let root = tempfile::tempdir().unwrap();
|
||
let thumbs = tempfile::tempdir().unwrap();
|
||
let lib = library(root.path());
|
||
let src = write_jpeg(root.path(), "pano.jpg", 6000, 1600);
|
||
let req = TestRequest::default().to_http_request();
|
||
|
||
serve_preview(
|
||
PreviewTier::Large,
|
||
&lib,
|
||
&src,
|
||
thumbs.path(),
|
||
&exif_dao(),
|
||
&req,
|
||
)
|
||
.await
|
||
.expect("preview served");
|
||
|
||
// Unhashed row → library-scoped legacy path under `_large/`.
|
||
let dest = content_hash::library_scoped_legacy_path(
|
||
&thumbs.path().join("_large"),
|
||
lib.id,
|
||
Path::new("pano.jpg"),
|
||
);
|
||
let (w, h) = image::open(dest).unwrap().dimensions();
|
||
assert_eq!((w, h), (2048, 546));
|
||
}
|
||
}
|