Grid and memories tiles requested `size=large` (up to 2048px) for tiles that display at ~590px (two columns) to ~1180px (one full-width column) on a 3× phone, downloading 10–15× the pixels they show. `medium` caps the long edge at 1024: crisp at both, a quarter of `large`'s pixels. Cached under `_medium/` like the other tiers. The large and xlarge generators were copies differing only in their cap; fold them into one `generate_preview(src, dest, max_dim)` so the third tier doesn't make a third copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
766 lines
25 KiB
Rust
766 lines
25 KiB
Rust
use std::{fs, str::FromStr};
|
||
|
||
use crate::database::models::ImageExif;
|
||
use anyhow::{Context, anyhow};
|
||
|
||
use chrono::{DateTime, Utc};
|
||
use log::error;
|
||
|
||
use actix_web::error::ErrorUnauthorized;
|
||
use actix_web::{Error, FromRequest, HttpRequest, dev, http::header};
|
||
use futures::future::{Ready, err, ok};
|
||
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
#[derive(Serialize)]
|
||
pub struct Token<'a> {
|
||
pub token: &'a str,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||
pub struct Claims {
|
||
pub sub: String,
|
||
pub exp: i64,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub mod helper {
|
||
use super::Claims;
|
||
use chrono::{Duration, Utc};
|
||
|
||
impl Claims {
|
||
pub fn valid_user(user_id: String) -> Self {
|
||
Claims {
|
||
sub: user_id,
|
||
exp: (Utc::now() + Duration::minutes(1)).timestamp(),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn secret_key() -> String {
|
||
if cfg!(test) {
|
||
String::from("test_key")
|
||
} else {
|
||
dotenv::var("SECRET_KEY").expect("SECRET_KEY env not set!")
|
||
}
|
||
}
|
||
|
||
impl FromStr for Claims {
|
||
type Err = jsonwebtoken::errors::Error;
|
||
|
||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||
let token = s.strip_prefix("Bearer ").ok_or_else(|| {
|
||
jsonwebtoken::errors::Error::from(jsonwebtoken::errors::ErrorKind::InvalidToken)
|
||
})?;
|
||
|
||
match decode::<Claims>(
|
||
token,
|
||
&DecodingKey::from_secret(secret_key().as_bytes()),
|
||
&Validation::new(Algorithm::HS256),
|
||
) {
|
||
Ok(data) => Ok(data.claims),
|
||
Err(other) => {
|
||
error!("DecodeError: {}", other);
|
||
Err(other)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl FromRequest for Claims {
|
||
type Error = Error;
|
||
type Future = Ready<Result<Self, Self::Error>>;
|
||
|
||
fn from_request(req: &HttpRequest, _payload: &mut dev::Payload) -> Self::Future {
|
||
req.headers()
|
||
.get(header::AUTHORIZATION)
|
||
.map_or_else(
|
||
|| Err(anyhow!("No authorization header")),
|
||
|header| {
|
||
header
|
||
.to_str()
|
||
.context("Unable to read Authorization header to string")
|
||
},
|
||
)
|
||
.and_then(|header| {
|
||
Claims::from_str(header)
|
||
.with_context(|| "Unable to decode token from Authorization header")
|
||
})
|
||
.map_or_else(
|
||
|e| {
|
||
error!("{}", e);
|
||
err(ErrorUnauthorized("Bad token"))
|
||
},
|
||
ok,
|
||
)
|
||
}
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Debug)]
|
||
pub struct PhotosResponse {
|
||
pub photos: Vec<String>,
|
||
pub dirs: Vec<String>,
|
||
|
||
/// Library id for each entry in `photos`, same length and ordering.
|
||
/// Parallel array rather than an object per row to keep the payload
|
||
/// small and backwards-compatible with older clients.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub photo_libraries: Vec<i32>,
|
||
|
||
// Pagination metadata (only present when limit is set)
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub total_count: Option<i64>,
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub has_more: Option<bool>,
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub next_offset: Option<i64>,
|
||
}
|
||
|
||
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum SortType {
|
||
Shuffle,
|
||
NameAsc,
|
||
NameDesc,
|
||
TagCountAsc,
|
||
TagCountDesc,
|
||
DateTakenAsc,
|
||
DateTakenDesc,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct FilesRequest {
|
||
pub path: String,
|
||
// comma separated numbers
|
||
pub tag_ids: Option<String>,
|
||
pub exclude_tag_ids: Option<String>,
|
||
pub tag_filter_mode: Option<FilterMode>,
|
||
pub recursive: Option<bool>,
|
||
pub sort: Option<SortType>,
|
||
|
||
// EXIF-based search parameters
|
||
pub camera_make: Option<String>,
|
||
pub camera_model: Option<String>,
|
||
pub lens_model: Option<String>,
|
||
|
||
// GPS location search
|
||
pub gps_lat: Option<f64>,
|
||
pub gps_lon: Option<f64>,
|
||
pub gps_radius_km: Option<f64>,
|
||
|
||
// Date range filtering: inclusive true instants (unix seconds), compared
|
||
// against each row's true instant (ADR-0001, #152). For calendar days,
|
||
// send the day's bounds in the owner's display zone.
|
||
pub date_from: Option<i64>,
|
||
pub date_to: Option<i64>,
|
||
|
||
// Media type filtering
|
||
pub media_type: Option<MediaType>,
|
||
|
||
// Pagination parameters (optional - backward compatible)
|
||
pub limit: Option<i64>,
|
||
pub offset: Option<i64>,
|
||
|
||
/// Optional library filter. Accepts a library id (e.g. "1") or name
|
||
/// (e.g. "main"). When omitted, results span all libraries.
|
||
pub library: Option<String>,
|
||
|
||
/// When true, include rows soft-marked as duplicates of another file
|
||
/// (i.e. `image_exif.duplicate_of_hash IS NOT NULL`). Default false —
|
||
/// the standard /photos listing hides demoted siblings so the grid
|
||
/// silently shrinks after a resolve. The Apollo duplicates modal
|
||
/// passes `true` so it can show both survivors and demoted members
|
||
/// inside a group.
|
||
#[serde(default)]
|
||
pub include_duplicates: Option<bool>,
|
||
}
|
||
|
||
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
|
||
pub enum FilterMode {
|
||
Any,
|
||
All,
|
||
}
|
||
|
||
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum MediaType {
|
||
Photo,
|
||
Video,
|
||
All,
|
||
}
|
||
|
||
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum PhotoSize {
|
||
Full,
|
||
XLarge,
|
||
Large,
|
||
Medium,
|
||
Thumb,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct ThumbnailRequest {
|
||
pub(crate) path: String,
|
||
#[allow(dead_code)] // Part of API contract, may be used in future
|
||
pub(crate) size: Option<PhotoSize>,
|
||
#[serde(default)]
|
||
#[allow(dead_code)] // Part of API contract, may be used in future
|
||
pub(crate) format: Option<ThumbnailFormat>,
|
||
#[serde(default)]
|
||
#[allow(dead_code)] // Part of API contract, may be used in future
|
||
pub(crate) shape: Option<ThumbnailShape>,
|
||
/// Optional library filter. Accepts a library id (e.g. "1") or name
|
||
/// (e.g. "main"). When omitted, defaults to the primary library.
|
||
#[serde(default)]
|
||
pub(crate) library: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize, PartialEq)]
|
||
pub enum ThumbnailFormat {
|
||
#[serde(rename = "gif")]
|
||
Gif,
|
||
#[serde(rename = "image")]
|
||
Image,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize, PartialEq)]
|
||
pub enum ThumbnailShape {
|
||
#[serde(rename = "circle")]
|
||
Circle,
|
||
#[serde(rename = "square")]
|
||
Square,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct LoginRequest {
|
||
pub username: String,
|
||
pub password: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct CreateAccountRequest {
|
||
pub username: String,
|
||
pub password: String,
|
||
pub confirmation: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct AddFavoriteRequest {
|
||
pub path: String,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct MetadataResponse {
|
||
pub created: Option<i64>,
|
||
pub modified: Option<i64>,
|
||
pub size: u64,
|
||
pub exif: Option<ExifMetadata>,
|
||
pub filename_date: Option<i64>, // Date extracted from filename
|
||
pub library_id: Option<i32>,
|
||
pub library_name: Option<String>,
|
||
/// The capture wall clock to display, naive ISO-8601
|
||
/// (`2018-09-29T14:00:00`) — `date_view::file_wall_clock`. Render it
|
||
/// verbatim; never zone-convert it (ADR-0001).
|
||
pub wall_clock: Option<String>,
|
||
/// True instant of capture (unix seconds) — `date_view::file_true_instant`.
|
||
/// For instant-to-instant matching (SMS / location windows); never
|
||
/// render it, render `wall_clock`.
|
||
pub true_instant: Option<i64>,
|
||
}
|
||
|
||
impl From<fs::Metadata> for MetadataResponse {
|
||
fn from(metadata: fs::Metadata) -> Self {
|
||
MetadataResponse {
|
||
created: metadata.created().ok().map(|created| {
|
||
let utc: DateTime<Utc> = created.into();
|
||
utc.timestamp()
|
||
}),
|
||
modified: metadata.modified().ok().map(|modified| {
|
||
let utc: DateTime<Utc> = modified.into();
|
||
utc.timestamp()
|
||
}),
|
||
size: metadata.len(),
|
||
exif: None,
|
||
filename_date: None, // Will be set in endpoint handler
|
||
library_id: None,
|
||
library_name: None,
|
||
wall_clock: None, // Set in the endpoint handler
|
||
true_instant: None, // Set in the endpoint handler
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct ExifMetadata {
|
||
pub camera: Option<CameraInfo>,
|
||
pub image_properties: Option<ImageProperties>,
|
||
pub gps: Option<GpsCoordinates>,
|
||
pub capture_settings: Option<CaptureSettings>,
|
||
pub date_taken: Option<i64>,
|
||
/// Which step of the canonical-date waterfall populated `date_taken`:
|
||
/// `"exif" | "exiftool" | "filename" | "fs_time" | "manual"`. NULL when
|
||
/// `date_taken` itself is NULL.
|
||
pub date_taken_source: Option<String>,
|
||
/// True UTC instant under the new date contract (ADR-0001). NULL while
|
||
/// the row is still on the legacy convention (drain pending, or the
|
||
/// unclassified filename branch).
|
||
pub date_utc: Option<i64>,
|
||
/// Camera offset (local − UTC, minutes) that produced `date_utc`.
|
||
pub camera_offset_minutes: Option<i32>,
|
||
/// Why the offset is what it is: `offset_tag` | `gps` |
|
||
/// `assumed_server` | `utc_direct` | `manual`.
|
||
pub date_taken_tz_source: Option<String>,
|
||
/// When `date_taken_source = "manual"`, the prior `date_taken` snapshot.
|
||
/// Used by the UI's revert affordance and to label "manually overridden;
|
||
/// originally X" in the details modal.
|
||
pub original_date_taken: Option<i64>,
|
||
/// When `date_taken_source = "manual"`, the prior source.
|
||
pub original_date_taken_source: Option<String>,
|
||
/// When `date_taken_source = "manual"`, the prior `date_utc` snapshot
|
||
/// (the new-contract triple the revert restores alongside the legacy
|
||
/// pair above).
|
||
pub original_date_utc: Option<i64>,
|
||
/// When `date_taken_source = "manual"`, the prior camera offset.
|
||
pub original_camera_offset_minutes: Option<i32>,
|
||
/// When `date_taken_source = "manual"`, the prior tz source.
|
||
pub original_date_taken_tz_source: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct CameraInfo {
|
||
pub make: Option<String>,
|
||
pub model: Option<String>,
|
||
pub lens: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct ImageProperties {
|
||
pub width: Option<i32>,
|
||
pub height: Option<i32>,
|
||
pub orientation: Option<i32>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct GpsCoordinates {
|
||
pub latitude: Option<f64>,
|
||
pub longitude: Option<f64>,
|
||
pub altitude: Option<f64>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct CaptureSettings {
|
||
pub focal_length: Option<f64>,
|
||
pub aperture: Option<f64>,
|
||
pub shutter_speed: Option<String>,
|
||
pub iso: Option<i32>,
|
||
}
|
||
|
||
impl From<ImageExif> for ExifMetadata {
|
||
fn from(exif: ImageExif) -> Self {
|
||
let has_camera_info =
|
||
exif.camera_make.is_some() || exif.camera_model.is_some() || exif.lens_model.is_some();
|
||
let has_image_properties =
|
||
exif.width.is_some() || exif.height.is_some() || exif.orientation.is_some();
|
||
let has_gps = exif.gps_latitude.is_some()
|
||
|| exif.gps_longitude.is_some()
|
||
|| exif.gps_altitude.is_some();
|
||
let has_capture_settings = exif.focal_length.is_some()
|
||
|| exif.aperture.is_some()
|
||
|| exif.shutter_speed.is_some()
|
||
|| exif.iso.is_some();
|
||
|
||
ExifMetadata {
|
||
camera: if has_camera_info {
|
||
Some(CameraInfo {
|
||
make: exif.camera_make,
|
||
model: exif.camera_model,
|
||
lens: exif.lens_model,
|
||
})
|
||
} else {
|
||
None
|
||
},
|
||
image_properties: if has_image_properties {
|
||
Some(ImageProperties {
|
||
width: exif.width,
|
||
height: exif.height,
|
||
orientation: exif.orientation,
|
||
})
|
||
} else {
|
||
None
|
||
},
|
||
gps: if has_gps {
|
||
Some(GpsCoordinates {
|
||
latitude: exif.gps_latitude.map(|v| v as f64),
|
||
longitude: exif.gps_longitude.map(|v| v as f64),
|
||
altitude: exif.gps_altitude.map(|v| v as f64),
|
||
})
|
||
} else {
|
||
None
|
||
},
|
||
capture_settings: if has_capture_settings {
|
||
Some(CaptureSettings {
|
||
focal_length: exif.focal_length.map(|v| v as f64),
|
||
aperture: exif.aperture.map(|v| v as f64),
|
||
shutter_speed: exif.shutter_speed,
|
||
iso: exif.iso,
|
||
})
|
||
} else {
|
||
None
|
||
},
|
||
date_taken: exif.date_taken,
|
||
date_taken_source: exif.date_taken_source,
|
||
date_utc: exif.date_utc,
|
||
camera_offset_minutes: exif.camera_offset_minutes,
|
||
date_taken_tz_source: exif.date_taken_tz_source,
|
||
original_date_taken: exif.original_date_taken,
|
||
original_date_taken_source: exif.original_date_taken_source,
|
||
original_date_utc: exif.original_date_utc,
|
||
original_camera_offset_minutes: exif.original_camera_offset_minutes,
|
||
original_date_taken_tz_source: exif.original_date_taken_tz_source,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct AddTagRequest {
|
||
pub file_name: String,
|
||
pub tag_name: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct GetTagsRequest {
|
||
pub path: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct GpsPhotoSummary {
|
||
pub path: String,
|
||
pub lat: f64,
|
||
pub lon: f64,
|
||
/// Legacy mixed-convention date (frozen for existing rows; ADR-0001).
|
||
pub date_taken: Option<i64>,
|
||
/// True instant (epoch seconds), when the row has been converted.
|
||
pub date_utc: Option<i64>,
|
||
/// Camera offset in minutes from UTC at capture, when known.
|
||
pub camera_offset_minutes: Option<i32>,
|
||
/// Which evidence pinned the instant (`offset_tag`, `gps`,
|
||
/// `assumed_server`, `utc_direct`, `manual`).
|
||
pub date_taken_tz_source: Option<String>,
|
||
/// Capture wall clock to display (naive ISO-8601; see
|
||
/// `MetadataResponse::wall_clock`).
|
||
pub wall_clock: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct GpsPhotosResponse {
|
||
pub photos: Vec<GpsPhotoSummary>,
|
||
pub total: usize,
|
||
}
|
||
|
||
/// Single-row projection of `image_exif` rich enough to drive Apollo's
|
||
/// photo-to-track matcher (and any similar window-scoped consumer) without
|
||
/// a per-file `/image/metadata` round-trip. Returned by `/photos/exif`.
|
||
#[derive(Debug, Serialize)]
|
||
pub struct ExifSummary {
|
||
pub file_path: String,
|
||
pub library_id: i32,
|
||
pub library_name: Option<String>,
|
||
pub camera_model: Option<String>,
|
||
pub width: Option<i32>,
|
||
pub height: Option<i32>,
|
||
pub gps_latitude: Option<f64>,
|
||
pub gps_longitude: Option<f64>,
|
||
pub date_taken: Option<i64>,
|
||
/// True UTC instant under the new date contract (ADR-0001). NULL while
|
||
/// the row is still on the legacy convention.
|
||
pub date_utc: Option<i64>,
|
||
/// Camera offset (local − UTC, minutes) that produced `date_utc`.
|
||
pub camera_offset_minutes: Option<i32>,
|
||
/// How the offset was determined. See `camera_tz::TzSource`.
|
||
pub date_taken_tz_source: Option<String>,
|
||
/// True instant of capture (unix seconds) — `date_view::row_true_instant`:
|
||
/// `date_utc` when converted, else the legacy value under its per-source
|
||
/// convention, so unconverted and permanent-floor rows still match
|
||
/// instant-to-instant. For matching (location track, SMS windows); never
|
||
/// render it, render `wall_clock`.
|
||
pub true_instant: Option<i64>,
|
||
/// Capture wall clock to display (naive ISO-8601; see
|
||
/// `MetadataResponse::wall_clock`).
|
||
pub wall_clock: Option<String>,
|
||
}
|
||
|
||
impl ExifSummary {
|
||
/// Project an `image_exif` row onto the `/photos/exif` wire shape.
|
||
/// `true_instant` / `wall_clock` go through the same `date_view` reads
|
||
/// as `/image/metadata` and `/photos/gps-summary`, so all three agree.
|
||
pub fn from_row(
|
||
r: ImageExif,
|
||
library_name: Option<String>,
|
||
display_zone: chrono_tz::Tz,
|
||
server_zone: Option<chrono_tz::Tz>,
|
||
) -> Self {
|
||
ExifSummary {
|
||
true_instant: crate::date_view::row_true_instant(&r, display_zone, server_zone),
|
||
wall_clock: crate::date_view::row_wall_clock(&r, display_zone, server_zone),
|
||
library_name,
|
||
file_path: r.file_path,
|
||
library_id: r.library_id,
|
||
camera_model: r.camera_model,
|
||
width: r.width,
|
||
height: r.height,
|
||
// image_exif stores GPS as f32 to keep row size small; widen
|
||
// for the JSON shape so clients don't need to know about the
|
||
// on-disk precision.
|
||
gps_latitude: r.gps_latitude.map(f64::from),
|
||
gps_longitude: r.gps_longitude.map(f64::from),
|
||
date_taken: r.date_taken,
|
||
date_utc: r.date_utc,
|
||
camera_offset_minutes: r.camera_offset_minutes,
|
||
date_taken_tz_source: r.date_taken_tz_source,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct ExifBatchResponse {
|
||
pub photos: Vec<ExifSummary>,
|
||
pub total: usize,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct ExifBatchRequest {
|
||
/// Lower bound (inclusive) on the row's true instant (`true_instant`),
|
||
/// unix seconds. Same semantics as `date_from` on `/photos`.
|
||
pub date_from: Option<i64>,
|
||
/// Upper bound (inclusive). Same semantics as `date_to` on `/photos`.
|
||
pub date_to: Option<i64>,
|
||
/// Restrict results to a single library by id. Omit (or "" / "all") for
|
||
/// union mode — the default. Filtered post-query in the handler so the
|
||
/// existing `query_by_exif` DAO trait stays untouched.
|
||
pub library: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct PreviewClipRequest {
|
||
pub path: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct PreviewStatusRequest {
|
||
pub paths: Vec<String>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct PreviewStatusResponse {
|
||
pub previews: Vec<PreviewStatusItem>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct PreviewStatusItem {
|
||
pub path: String,
|
||
pub status: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub preview_url: Option<String>,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::Claims;
|
||
use jsonwebtoken::errors::ErrorKind;
|
||
use std::str::FromStr;
|
||
|
||
#[test]
|
||
fn test_token_from_claims() {
|
||
let claims = Claims {
|
||
exp: 16136164790, // 2481-ish
|
||
sub: String::from("9"),
|
||
};
|
||
|
||
let c = Claims::from_str(
|
||
"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5IiwiZXhwIjoxNjEzNjE2NDc5MH0.9wwK4l8vhvq55YoueEljMbN_5uVTaAsGLLRPr0AuymE")
|
||
.unwrap();
|
||
|
||
assert_eq!(claims.sub, c.sub);
|
||
assert_eq!(claims.exp, c.exp);
|
||
}
|
||
|
||
#[test]
|
||
fn test_expired_token() {
|
||
let err = Claims::from_str(
|
||
"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5IiwiZXhwIjoxNn0.eZnfaNfiD54VMbphIqeBICeG9SzAtwNXntLwtTBihjY",
|
||
);
|
||
|
||
match err.unwrap_err().into_kind() {
|
||
ErrorKind::ExpiredSignature => {}
|
||
kind => panic!("Unexpected error: {:?}", kind),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_junk_token_is_invalid() {
|
||
let err = Claims::from_str("uni-֍ՓՓՓՓՓՓՓՓՓՓՓՓՓՓՓ");
|
||
|
||
match err.unwrap_err().into_kind() {
|
||
ErrorKind::InvalidToken => {}
|
||
kind => panic!("Unexpected error: {:?}", kind),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_preview_clip_request_deserialize() {
|
||
use super::PreviewClipRequest;
|
||
let json = r#"{"path":"photos/2024/video.mp4"}"#;
|
||
let req: PreviewClipRequest = serde_json::from_str(json).unwrap();
|
||
assert_eq!(req.path, "photos/2024/video.mp4");
|
||
}
|
||
|
||
#[test]
|
||
fn test_preview_status_request_deserialize() {
|
||
use super::PreviewStatusRequest;
|
||
let json = r#"{"paths":["a/one.mp4","b/two.mp4","c/three.mp4"]}"#;
|
||
let req: PreviewStatusRequest = serde_json::from_str(json).unwrap();
|
||
assert_eq!(req.paths.len(), 3);
|
||
assert_eq!(req.paths[0], "a/one.mp4");
|
||
assert_eq!(req.paths[2], "c/three.mp4");
|
||
}
|
||
|
||
#[test]
|
||
fn test_preview_status_response_serialize() {
|
||
use super::{PreviewStatusItem, PreviewStatusResponse};
|
||
|
||
let response = PreviewStatusResponse {
|
||
previews: vec![
|
||
PreviewStatusItem {
|
||
path: "a/one.mp4".to_string(),
|
||
status: "complete".to_string(),
|
||
preview_url: Some("/video/preview?path=a%2Fone.mp4".to_string()),
|
||
},
|
||
PreviewStatusItem {
|
||
path: "b/two.mp4".to_string(),
|
||
status: "pending".to_string(),
|
||
preview_url: None,
|
||
},
|
||
],
|
||
};
|
||
|
||
let json = serde_json::to_value(&response).unwrap();
|
||
let previews = json["previews"].as_array().unwrap();
|
||
assert_eq!(previews.len(), 2);
|
||
|
||
// Complete item should have preview_url
|
||
assert_eq!(previews[0]["status"], "complete");
|
||
assert!(previews[0]["preview_url"].is_string());
|
||
|
||
// Pending item should not have preview_url (skip_serializing_if)
|
||
assert_eq!(previews[1]["status"], "pending");
|
||
assert!(previews[1].get("preview_url").is_none());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod date_contract_tests {
|
||
use super::*;
|
||
|
||
fn sample_row() -> crate::database::models::ImageExif {
|
||
crate::database::models::ImageExif {
|
||
id: 1,
|
||
library_id: 1,
|
||
file_path: "2024/vacation/IMG_0001.jpg".to_string(),
|
||
camera_make: None,
|
||
camera_model: Some("NIKON Z 8".to_string()),
|
||
lens_model: None,
|
||
width: None,
|
||
height: None,
|
||
orientation: None,
|
||
gps_latitude: None,
|
||
gps_longitude: None,
|
||
gps_altitude: None,
|
||
focal_length: None,
|
||
aperture: None,
|
||
shutter_speed: None,
|
||
iso: None,
|
||
// Wall 2024-07-04 12:00:00 stored legacy-style (as-UTC).
|
||
date_taken: Some(1_720_094_400),
|
||
created_time: 0,
|
||
last_modified: 0,
|
||
content_hash: None,
|
||
size_bytes: None,
|
||
phash_64: None,
|
||
dhash_64: None,
|
||
duplicate_of_hash: None,
|
||
duplicate_decided_at: None,
|
||
date_taken_source: Some("exif".to_string()),
|
||
original_date_taken: None,
|
||
original_date_taken_source: None,
|
||
clip_embedding: None,
|
||
clip_model_version: None,
|
||
date_utc: Some(1_720_108_800),
|
||
camera_offset_minutes: Some(-240),
|
||
date_taken_tz_source: Some("offset_tag".to_string()),
|
||
original_date_utc: None,
|
||
original_camera_offset_minutes: None,
|
||
original_date_taken_tz_source: None,
|
||
}
|
||
}
|
||
|
||
/// /image/metadata contract: the new-contract date fields ride along on
|
||
/// the EXIF metadata payload alongside the legacy `date_taken`.
|
||
#[test]
|
||
fn exif_metadata_response_includes_date_contract_fields() {
|
||
let meta = ExifMetadata::from(sample_row());
|
||
assert_eq!(meta.date_taken, Some(1_720_094_400));
|
||
assert_eq!(meta.date_utc, Some(1_720_108_800));
|
||
assert_eq!(meta.camera_offset_minutes, Some(-240));
|
||
assert_eq!(meta.date_taken_tz_source.as_deref(), Some("offset_tag"));
|
||
}
|
||
|
||
/// /photos/exif contract: Apollo's per-photo summary carries the same
|
||
/// fields so the viewer flip (spec #113) can read one shape, plus the
|
||
/// server-computed `true_instant` / `wall_clock` it matches and renders.
|
||
#[test]
|
||
fn exif_summary_shape_includes_date_contract_fields() {
|
||
let summary = ExifSummary::from_row(
|
||
sample_row(),
|
||
Some("main".to_string()),
|
||
chrono_tz::America::New_York,
|
||
Some(chrono_tz::America::New_York),
|
||
);
|
||
// Verify the JSON wire names clients (Apollo, FVR) will consume.
|
||
let json = serde_json::to_value(&summary).unwrap();
|
||
assert_eq!(json["date_taken"], 1_720_094_400);
|
||
assert_eq!(json["date_utc"], 1_720_108_800);
|
||
assert_eq!(json["camera_offset_minutes"], -240);
|
||
assert_eq!(json["date_taken_tz_source"], "offset_tag");
|
||
assert_eq!(json["true_instant"], 1_720_108_800);
|
||
assert_eq!(json["wall_clock"], "2024-07-04T12:00:00");
|
||
assert_eq!(json["library_name"], "main");
|
||
}
|
||
|
||
/// A row the drain hasn't converted (or never will — the edited/exported
|
||
/// floor) has no `date_utc`, but `/photos/exif` must still hand clients
|
||
/// a true instant and wall clock via the per-source fallback, so they
|
||
/// never re-derive the legacy convention themselves (ADR-0001).
|
||
#[test]
|
||
fn exif_summary_unconverted_row_falls_back_to_legacy_convention() {
|
||
let mut row = sample_row();
|
||
row.date_utc = None;
|
||
row.camera_offset_minutes = None;
|
||
row.date_taken_tz_source = None;
|
||
let summary = ExifSummary::from_row(
|
||
row,
|
||
None,
|
||
chrono_tz::America::New_York,
|
||
Some(chrono_tz::America::New_York),
|
||
);
|
||
assert_eq!(summary.date_utc, None);
|
||
// exif legacy = 12:00 wall as-UTC; read in the server zone (EDT)
|
||
// that's 16:00 UTC — the same instant the converted row names.
|
||
assert_eq!(summary.true_instant, Some(1_720_108_800));
|
||
assert_eq!(summary.wall_clock.as_deref(), Some("2024-07-04T12:00:00"));
|
||
}
|
||
}
|