/photos, /photos/exif and unified search take date_from_day /
date_to_day (YYYY-MM-DD): the server turns them into the first day's
start and the last day's end in the owner's display zone, the same days
/memories groups by. Each replaces its instant counterpart. Clients stop
computing midnights in the device's zone, which drifted by the travel
offset whenever the phone's zone differed from display_tz.
e2e check: every fixture is included by its display-zone day and
excluded by the days either side.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
date_from / date_to on /photos, /photos/exif and unified search ran on
the legacy mixed-convention date_taken, so a photo could display inside
a range yet be filtered out of it by up to its UTC offset (or by years,
for rows the filename class re-dated).
The filter lives in the shared ExifDao::query_by_exif, so it's fixed
once there:
- query_by_exif takes Option<date_view::InstantRange> (bounds + display
and server zones) instead of raw date_from / date_to.
- SQL pre-filter: converted rows on date_utc exactly; unconverted rows
(drain pending, permanent floor) on date_taken widened by
LEGACY_INSTANT_BAND_SECS (±15 h, now shared with /memories). Converted
rows are deliberately not bounded by date_taken: a re-dated row's
legacy value can sit years from its instant.
- Exact pass in Rust via InstantRange::contains → row_true_instant, so
every returned row's true instant is inside the range.
Calendar days now mean the owner's days: nl_query::iso_to_unix builds
day bounds in the display zone (it used UTC midnights, which only lined
up with the legacy wall-as-UTC column), and unified search's "today" is
the owner's today rather than UTC's. The midnight-with-DST-gap fallback
moves from memories_anchor into date_view::day_start_instant.
Compatibility: clients already sending real instants (Apollo's
/photos/exif windows) get correct edges with no change. Clients sending
UTC-midnight day bounds see ranges shift by up to the UTC offset until
they send display-zone bounds.
No index on date_utc; the OR clause scans, which is milliseconds at
personal-library scale (same as /memories today).
dev/e2e check gains /photos/exif wall_clock + true_instant assertions and
range-edge checks ([t, t] in, [.., t-1] and [t+1, ..] out). Not run here:
the harness needs the WSL + Windows toolchain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/photos/exif is Apollo's photo-to-track matching feed, but unlike
/image/metadata and /photos/gps-summary it only carried the raw
new-contract triple. date_utc is NULL for unconverted rows and, by
design, forever for the drain's edited/exported floor (#120), so a
client reading date_utc alone would have to re-implement the
per-source legacy conventions to cover those rows: the re-anchoring
ADR-0001 says consumers delete, not migrate.
ExifSummary now carries:
- true_instant: date_view::row_true_instant (date_utc when converted,
else the per-source fallback), for instant-to-instant matching.
- wall_clock: date_view::row_wall_clock, rendered verbatim.
The row → wire mapping moves to ExifSummary::from_row so the tests
cover the real projection, including an unconverted row falling back
to the same instant and wall as its converted twin. Additive: legacy
fields are unchanged, so current clients see the same values.
The date_from/date_to filter still runs on the legacy column; that's
#152, which now explicitly covers /photos/exif too.
Unblocks Apollo #37.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mobile viewer anchored its "messages around this photo" window on
the legacy date_taken (a wall clock stored as UTC for EXIF rows), while
SMS-API dates are true instants: the window was off by the photo's UTC
offset (4-5 h for photos taken at home; ImageApi #113 user story 3).
Verified SMS-API side: importers store backup epochs as UTC, and 40/40
sampled rows equal their source epochs exactly.
`/image/metadata` (+ GPS / date mutation responses) now carries
`true_instant` from `date_view::file_true_instant`: `row_true_instant`,
else the filename-class instant, else the earliest fs time. Server-side
for the same reason as `wall_clock`: the drain's permanent floor rows
have no `date_utc`. `set_capture_dates` fills both fields at all four
response sites.
dev/e2e: the manifest records each fixture's expected instant (the wall
read in its GPS zone, else America/New_York) and `e2e.sh check` asserts
it (97/97).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every payload a client renders a capture time from now carries
`wall_clock`, a naive ISO-8601 string computed by the existing
`date_view::row_display_wall`: the camera's wall clock (true instant +
camera offset; the owner's display zone without an offset), with the
per-source fallback for unconverted rows. Clients render it verbatim and
do no zone math, which is the FileViewer-React #33 / Apollo #37 flip with
the rule kept server-side: the drain's permanent `date_utc IS NULL` floor
(#120: edited/exported files) would otherwise force every client to
carry the per-source legacy conventions forever, and insight prompts
already use the same function, so prompt and UI agree by construction.
- `/image/metadata` (+ the GPS / date / date-clear mutation responses):
`file_wall_clock` — the row's wall, else the filename-class date, else
the earliest fs time in the display zone (unindexed files).
- `/memories` items and `/photos/gps-summary` photos: `row_wall_clock`.
- `get_all_with_gps` returns full rows instead of a 7-tuple that dropped
`date_taken_source`, which unconverted rows need.
- `date_view::owner_display_zone` resolves prefs -> app default per request.
Legacy fields are untouched, so current clients see byte-identical
responses (additive; safe to deploy ahead of the client flip, per #120).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- New src/date_view.rs: pure read-side functions (row_true_instant,
row_display_wall, row_display_day, filename_true_instant) applying the
per-source convention table for unconverted rows and the new-contract
triple for converted rows (ADR-0001).
- /memories: grouping on the row's true instant rendered in the owner's
display zone (prefs -> app default), not the client device offset.
SQL is a coarse interval pre-filter over the full years-back window;
exact month-day/week/month matching across past years happens in Rust
(memories::day_in_span, sqlite_w_week).
- Reels: ReelSelector::Memories carries an IANA display_tz instead of a
fixed minute offset; pre-gen params read REEL_PREGEN_DISPLAY_TZ; the
tz_offset_minutes ledger column stores the zone offset at render time.
- Insights: prompt-facing dates render as the display wall; the
proximity timestamp and chat context block use the true instant;
InsightGenerator reads the owner's display zone from prefs per call.
- GpsPhotoSummary carries the new-contract triple alongside the frozen
legacy date_taken.
Closes#119
The exiftool branch of the date waterfall now pulls raw strings with
group prefixes (one `exiftool -j -q -G1` subprocess per batch, no
`-d %s`) and classifies each candidate:
- zoned strings (own offset / Z) are true instants;
- naive strings from container groups (QuickTime/Track*/Apple) are
UTC-rendered creation_time fields -> true instants (utc_direct).
Running them through the camera wall-clock cascade was the
"videos off by 4-5 hours" bug: it reinterpreted a UTC instant in
the server zone;
- naive strings from photo-EXIF groups are camera wall clocks and run
the offset cascade (OffsetTime* pairing + GPS evidence pulled in
the same exiftool pass).
The legacy date_taken column stays byte-identical to the old
server-local interpretation (reproduced in Rust, DST-aware); the
new-contract date_utc / camera_offset_minutes / date_taken_tz_source
columns carry the true instant.
Manual date edit (POST /image/exif/date) reinterprets the
naive-as-UTC epoch the client sent as a wall clock in the user's
display zone (camera_tz::manual_date_resolution, tz_source =
'manual'), snapshots the pre-override new-contract triple next to the
existing legacy snapshot pair (3 new columns, migration
2026-09-25-000000), and revert restores both pairs.
ADR-0001 records the container-date finding; CLAUDE.md date-pipeline
section updated.
Populate the new-contract date columns at ingest while keeping the
legacy date_taken column byte-identical (ADR-0001):
- camera_tz module: offset cascade (offset tag -> GPS zone -> server
zone) as a pure, testable function; bundled offline TZF geometry for
GPS -> IANA resolution; DST-correct offsets for the capture date;
display-zone helpers (DEFAULT_DISPLAY_TZ, display_zone_for,
display_zone_offset_minutes) for the read-side cutover.
- EXIF branch: kamadak-exif now collects OffsetTime* tags paired with
the winning date tag; resolve_date_taken takes an ExifPrior and
emits (date_utc, camera_offset_minutes, date_taken_tz_source) per
branch: exif -> cascade, exiftool/fs_time -> utc_direct, filename
-> unclassified (pending #116).
- Schema: migration adds image_exif.date_utc /
camera_offset_minutes / date_taken_tz_source and
user_ai_prefs.display_tz (all nullable; down.sql drops them).
- DAO + ingest: backfill_date_taken persists the triple alongside the
legacy pair; watcher, upload, and set_image_gps call sites feed the
cascade.
- Response surface: ExifMetadata and ExifSummary carry the triple so
/image/metadata and /photos/exif expose it (null for unconverted
rows); reel requests mirror display_tz into prefs.
cargo build + clippy clean; 625 lib + 702 bin tests pass (the one
failing doctest in utils.rs is pre-existing at the base commit).
New `PhotoSize::XLarge` variant sits between `Large` (2048px) and
`Full` (original). On-demand generated and disk-cached at
`_xlarge/<hash>.jpg`, same waterfall as `Large` (embedded RAW preview
→ ffmpeg → image crate). Sources below 4096px serve at native size.
Reduces decoded bitmap memory from ~192MB (48MP full) to ~64MB for
the mobile viewer's zoom tier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a third PhotoSize between Thumb (200px) and Full (original). The
viewer placeholder and map callout previously upscaled a 200px thumb
into a full-screen / full-width view, which looked visibly blocky on
3× devices. The new tier is generated on-demand, disk-cached, and
served via the existing /image endpoint.
Storage layout mirrors the Thumb branch's lookup chain:
1. hash-keyed: <thumbs>/_large/<hash[..2]>/<hash>.jpg (shared across
libraries when content_hash is known)
2. library-scoped legacy: <thumbs>/_large/<lib_id>/<rel_path>
Generation pipeline mirrors generate_image_thumbnail:
- RAW: decode the embedded JPEG preview, apply EXIF orientation,
resize to 2048-long-edge, encode JPEG q85
- HEIC/HEIF: ffmpeg with scale + q:v 5 (≈ q85)
- everything else: image crate decode + thumbnail() + JpegEncoder
Never upscales — sources below the 2048 cap re-encode at native size.
Handler offloads decode/resize to web::block to keep the actix worker
free (a 24MP source takes 100–500ms). Writes via tempfile+rename so
concurrent readers can't observe a half-written JPEG. On any
generation failure, falls through to the Full branch (which itself
serves the RAW embedded preview for unrenderable RAW containers).
Video requests for size=large fall back to the existing thumb pipeline
since there's no useful 2048px video tier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `POST /image/exif/date` and `POST /image/exif/date/clear` so an
operator can correct a row whose canonical-date waterfall landed on the
wrong value (camera clock reset, fs_time fallback for a copied-from-
backup file, etc). New `original_date_taken` / `original_date_taken_source`
columns snapshot the prior value on first override so revert is lossless.
The waterfall source set is now `'exif' | 'exiftool' | 'filename' | 'fs_time' | 'manual'`.
The existing `idx_image_exif_date_backfill` partial index already filters
to `date_taken IS NULL OR date_taken_source = 'fs_time'`, so manual rows
are naturally excluded from the per-tick drain — no index change needed.
`ExifMetadata` now exposes `date_taken_source` + originals so a UI can
render "manually set; was X via filename".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds pHash + dHash columns alongside the existing blake3 content_hash so
near-duplicates (re-encoded, resized, format-converted copies) become
queryable. /duplicates/{exact,perceptual} return groups; /duplicates/
{resolve,unresolve} flip a duplicate_of_hash soft-mark on losing rows
and union perceptual-only tag sets onto the survivor. The default
/photos listing filters duplicate_of_hash IS NULL so demoted siblings
stop cluttering the grid; include_duplicates=true opts back in for
Apollo's review modal. Upload now hashes bytes pre-write and returns
409 with the canonical sibling when a file's bytes already exist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups on the same feature branch:
1. Bake EXIF orientation into generated thumbnails. The `image` crate
doesn't apply Orientation on load, and `save_with_format(..Jpeg)`
drops EXIF — so portrait phone shots ended up sideways in any client
that displays the cached thumb directly (no EXIF tag for the browser
to compensate from). New `exif::read_orientation` reads the tag
cheaply (no full EXIF parse) and `exif::apply_orientation` does the
rotate/flip via image's existing `rotate90/180/270` + `fliph/flipv`.
Applied in both branches of `generate_image_thumbnail` (RAW embedded-
JPEG path and the regular `image::open` path). Existing thumbnails
in the cache are still wrong-orientation; wipe the thumb dir or run
a one-off backfill once this lands.
2. Optional `library` query param on `/photos/exif`. Accepts numeric id
or name (same shape as `/image?library=...`), resolved via the
existing `resolve_library_param` helper so a bad value 400s before
we touch the DAO. Filter is applied post-query in the handler
rather than pushed into `query_by_exif` to keep the DAO trait
(and its test mocks) unchanged. Cheap enough at typical library
counts; can be moved into SQL later if it ever isn't.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a single round-trip projection of `image_exif` for every photo whose
`date_taken` falls in `[date_from, date_to]`. Wraps the existing
`ExifDao::query_by_exif` DAO method which already handles the SQL filter
in one query against the covering index — the only missing piece was
HTTP plumbing.
Designed for window-scoped consumers like Apollo's photo-to-track
matcher, which currently does N+1 (one `/photos` listing + one
`/image/metadata` per photo). Because `/image/metadata` serializes on
`Data<Mutex<dyn ExifDao>>`, that pattern can take 10s+ for windows with
hundreds of photos. The new endpoint takes one mutex acquisition for
the whole batch.
Response shape:
{ photos: [
{ file_path, library_id, library_name,
camera_model, width, height,
gps_latitude, gps_longitude, date_taken } ],
total: N }
Two notes on scope:
- Photos with NULL `date_taken` are excluded by `query_by_exif`'s
semantics. Filename-extracted dates are not synthesized here; rare
callers that need that fallback can still hit `/image/metadata`.
- GPS columns are stored as f32 in image_exif to keep row size small;
the JSON shape widens to f64 so clients don't have to know about the
on-disk precision.
Library names are pre-mapped from `app_state.libraries` once and
stamped on each row, avoiding an O(rows × libraries) linear scan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a parallel `photo_libraries: Vec<i32>` array alongside `photos`
in `PhotosResponse` so clients can render per-thumbnail badges.
Populated with the scoped library id at the two main return sites;
left empty for `/favorites` since favorites are library-agnostic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Silence forward-looking dead_code on unused DAO modules, annotate
individual placeholder items, rewrite tautological assert!(true/false)
in token tests as panic! arms, and pick up fmt drift.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /video/generate and /image/metadata handlers assumed files live under
the resolved library only, which broke when a mobile client passed no
library (union mode) but the file lived in a non-primary library. Both
now fall back to scanning every configured library for an existing file.
InsightGenerator held a single base_path, so vision-model loads and
filename-date fallbacks failed for non-primary libraries. It now takes
Vec<Library> and probes each root in resolve_full_path.
/image/metadata responses now carry library_id/library_name so the
mobile viewer can surface which library a file belongs to.
Thumbnail generation at startup is now spawned on a background thread
so the HTTP server can accept traffic while large libraries backfill.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 3 plumbing accepted `library=` but didn't actually route
requests through the scoped library once it was resolved. Three
concrete bugs surfaced when testing against a second mounted library:
- `/image` always resolved paths against AppState.base_path (primary),
so thumbnails for non-primary libraries 400'd when their rel_paths
didn't exist under primary. Now resolves against the scoped library
and defaults to primary when the param is omitted.
- `/memories` walked the scoped library correctly but its helper
functions hardcoded `library_id: PRIMARY_LIBRARY_ID` on every
MemoryItem, causing clients to route thumbnails back to primary
regardless of which library the memory actually came from.
- `/photos` non-recursive listing delegated to a `RealFileSystem`
constructed from AppState.base_path at startup, so walks always
hit primary even when `library=2` was passed. The non-primary
path now uses list_files against the scoped library's root;
primary still goes through FileSystemAccess to preserve the
existing test mock plumbing.
Also adds `library` to ThumbnailRequest so the /image query param
is actually parsed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New `/libraries` endpoint returns configured libraries so clients can
discover them. `FilesRequest` and `MemoriesRequest` gain an optional
`library` param (accepts name or numeric id). Unknown values are
rejected with 400; absent values span all libraries. `/memories`
now scopes its filesystem walk + EXIF query to the resolved library.
`MemoryItem` carries `library_id` so union-mode clients can render a
per-item source badge.
Behavior is unchanged in single-library mode: omitting `library` still
returns results from the primary library, which is the only one
configured until a second row is added to the libraries table.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Implement unit tests for PreviewClipRequest/PreviewStatusRequest serialization and deserialization.
- Add tests for PreviewDao (insert, update, batch retrieval, and status-based queries).
- Extend Actix-web integration tests for `/video/preview/status` endpoint scenarios.
- Introduce in-memory TestPreviewDao for mock database interactions.
- Update README with new config parameters for preview clips.
Backend (Rust/Actix-web):
- Add video_preview_clips table and PreviewDao for tracking preview generation
- Add ffmpeg preview clip generator: 10 equally-spaced 1s segments at 480p with CUDA NVENC auto-detection
- Add PreviewClipGenerator actor with semaphore-limited concurrent processing
- Add GET /video/preview and POST /video/preview/status endpoints
- Extend file watcher to detect and queue previews for new videos
- Use relative paths consistently for DB storage (matching EXIF convention)
Frontend (React Native/Expo):
- Add VideoWall grid view with 2-3 column layout of looping preview clips
- Add VideoWallItem component with ActiveVideoPlayer sub-component for lifecycle management
- Add useVideoWall hook for batch status polling with 5s refresh
- Add navigation button in grid header (visible when videos exist)
- Use TextureView surface type to fix Android z-ordering issues
- Optimize memory: players only mount while visible via FlatList windowSize
- Configure ExoPlayer buffer options and caching for short clips
- Tap to toggle audio focus, long press to open in full viewer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement database-level sorting with composite indexes for efficient date and tag queries. Add pagination metadata support and optimize tag count queries using batch processing.
Implements Phase 1 & 2 of Google Takeout RAG integration:
- Database migrations for calendar_events, location_history, search_history
- DAO implementations with hybrid time + semantic search
- Parsers for .ics, JSON, and HTML Google Takeout formats
- Import utilities with batch insert optimization
Features:
- CalendarEventDao: Hybrid time-range + semantic search for events
- LocationHistoryDao: GPS proximity with Haversine distance calculation
- SearchHistoryDao: Semantic-first search (queries are embedding-rich)
- Batch inserts for performance (1M+ records in minutes vs hours)
- OpenTelemetry tracing for all database operations
Import utilities:
- import_calendar: Parse .ics with optional embedding generation
- import_location_history: High-volume GPS data with batch inserts
- import_search_history: Always generates embeddings for semantic search
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit addresses several security vulnerabilities in the authentication
and authorization system:
1. JWT Encoding Panic Fix (Critical)
- Replace .unwrap() with proper error handling in JWT token generation
- Prevents server crashes from encoding failures
- Returns HTTP 500 with error logging instead of panicking
2. Rate Limiting for Login Endpoint (Critical)
- Add actix-governor dependency (v0.5)
- Configure rate limiter: 2 requests/sec with burst of 5
- Protects against brute-force authentication attacks
3. Strengthen Password Requirements
- Minimum length increased from 6 to 12 characters
- Require uppercase, lowercase, numeric, and special characters
- Add comprehensive validation with clear error messages
4. Fix Token Parsing Vulnerability
- Replace unsafe split().last().unwrap_or() pattern
- Use strip_prefix() for proper Bearer token validation
- Return InvalidToken error for malformed Authorization headers
5. Improve Authentication Logging
- Sanitize error messages to avoid leaking user existence
- Change from "User not found or incorrect password" to "Failed login attempt"
All changes tested and verified with existing test suite (65/65 tests passing).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Implements foundation for EXIF-based photo search capabilities:
- Add geo.rs module with GPS distance calculations (Haversine + bounding box)
- Extend FilesRequest with EXIF search parameters (camera, GPS, date, media type)
- Add MediaType enum and DateTakenAsc/DateTakenDesc sort options
- Create date_taken index migration for efficient date queries
- Implement ExifDao methods: get_exif_batch, query_by_exif, get_camera_makes
- Add FileWithMetadata struct for date-aware sorting
- Implement date sorting with filename extraction fallback
- Make extract_date_from_filename public for reuse
Next: Integrate EXIF filtering into list_photos() and enhance get_all_tags()
Manually parsing the tag_ids for the file filtering isn't amazing, but
this works in a more friendly format.
Also the All filter mode was set up in the wrong direction instead of
checking that the file had ALL the tag ids provided, it checked that all
the tag-ids were on a file, which is too restrictive and wouldn't show
many files. Perhaps an ONLY option could exist for being even more
specific.