Commit Graph

5 Commits

Author SHA1 Message Date
Claudio
347f58b4f3 perf(thumbs): pool NC client, smaller grid thumbs, eager owner load
Five stacked optimisations for the thumbnail hot path so the timeline
grid lands in fewer round trips and fewer bytes.

1. PhotoThumbnail: switch from 'medium' (640px) to 'small' (240px) for
   grid cells. 240px oversamples 150-200px logical cells on 2x retina
   and drops payload 5-8x. Lightbox and preview filmstrip keep 'large'
   and 'medium' respectively.

2. nextcloud_dav: pool the httpx client. A module-level AsyncClient
   with HTTP/2 + keepalive (max_connections=64, keepalive_expiry=120s)
   replaces the per-request constructor that paid a fresh TCP+TLS
   handshake on every preview fetch. Auth is per-user so it stays at
   the call site via auth=BasicAuth(...). Lifespan-managed: init in
   main.py's lifespan startup, aclose on shutdown. requirements.txt
   gains the http2 extra to pull in h2 (not currently installed).
   Same change applies to fetch_memories_info_async since it hits the
   same host.

3. PhotoThumbnail img: add decoding="async" so JPEG/WebP decode moves
   off the main thread, plus fetchPriority="low" so grid backfill
   doesn't fight UI fetches.

4. Eager-load Photo.user via joinedload from the thumb handler.
   _get_photo_with_share_fallback gains an options parameter so other
   callers stay zero-overhead; only the thumb handler asks for the
   owner join. Eliminates the second SELECT users per request.

5. Disk-fallback path picks up Cache-Control: private, max-age=86400
   in both the FileResponse and X-Accel branches so re-renders match
   the NC primary path's caching behaviour.

Net: a warm grid page should drop from ~200-400 ms median per thumb to
well under 100 ms; payload drops ~5-8x; backend sustains higher
concurrency with fewer sockets to Nextcloud and one fewer Postgres
round-trip per request.
2026-05-12 00:30:43 +02:00
Claudio
5a67ed7e7b feat(phase 4): vision fetches NC previews; stop writing /data/thumbs
Last consumer of the on-disk thumbnail pipeline was the vision
worker reading /data/thumbs/{id}/medium.webp. Now it asks Nextcloud
for a 640px preview (the same edge size the old thumb used) and
decodes the bytes in-memory — no disk dependency.

- nextcloud_dav.get_preview_bytes: sync sibling of get_preview_async,
  for the celery vision worker (which is sync).
- vision._load_thumb: tries NC preview first; transitional disk
  fallback stays for rows still indexed during the rollout.
- thumbs.WORKER_THUMB_SIZES = set() — generate_thumbnails still runs
  the decode + pHash side-effect (perceptual dedup is mule-only and
  needs original-resolution pixels) but no longer writes thumbnail
  files.

The HTTP thumbnail endpoint's disk fallback path stays in place
unchanged: for NC-404 cases (e.g. iPhone JPEGs mis-extensioned as
.DNG), inline Pillow regeneration still writes a tiny per-photo
file so subsequent requests are fast. That path is rare and the
files are small.

Disk impact: /data/thumbs currently has ~22k medium.webp totaling
~1 GB. They'll stop being read after the worker-vision container
restarts, but no automatic delete — purge with the same find
pattern used for small/large reclaim when ready:

    find /data/thumbs -name "medium.webp" -delete
2026-05-11 13:52:52 +02:00
Claudio
2a5759cc8d feat(metadata): read from NC Memories first, ExifTool subprocess as fallback
Phase 3 (fat refactor). extract_metadata now tries Memories'
HTTP API GET /index.php/apps/memories/api/image/info/{fileid}
before spawning ExifTool. Replaces ~80–100 ms of subprocess work
with a ~1–2 ms HTTP call for ongoing imports.

What we kept from the ExifTool path:
- Mule's date-fallback chain (SubSec → DateTimeOriginal → CreateDate
  → MediaCreateDate → TrackCreateDate → filename/folder guess → mtime).
  Memories' single `datetaken` field falls back to mtime, which would
  silently mis-date the 6k+ photos in our library that depend on
  filename-encoded dates. _apply_memories_metadata re-applies the
  same chain against Memories' `exif` dict.
- taken_at_source='manual' is still sacred — never overwritten.
- has_date_warning recomputed against the resolved taken_at.

Format compat: Memories' `exif` dict uses plain key names (Make,
Model, ISO, FNumber, DateTimeOriginal, GPSLatitude, ...) while the
old ExifTool path stored `EXIF:Make` etc. PhotoInfoPanel only reads
the four keys above and Memories has them in plain form, so the info
panel keeps working without an adapter. Full-text search (ILIKE on
exif_json) still hits camera names, lens names, dates etc. — value
content is identical, only the keys differ.

Fallback paths preserved:
- 404 from Memories (file not yet indexed by NC's scan, brand-new
  upload): falls through to ExifTool.
- non-NC photos (no nextcloud_fileid or no app password): ExifTool.
- NC HTTP error or parse failure: ExifTool.

CSRF: Memories' /api/image/info/{id} is CSRF-checked. We send
`OCS-APIRequest: true` to bypass it, the same way the OCS clients
do. Auth is the user's existing Fernet-encrypted app password.

Verified end-to-end against:
- IMG_4954.DNG (real DNG with GPS): width/height/lat/lon/taken_at
  match the previous ExifTool output exactly; exif_json switched
  to Memories format (Make/Model/ISO/FNumber preserved).
- 20210817_000000_4A6737B6.jpg (path-dated archive photo): taken_at
  remained 2021-08-17 from the filename heuristic, source='path'.

The `enabled` state of the Memories app is now required for new
imports to skip ExifTool — left enabled in commit 0a4c8d... (NC
admin action; not in this commit).
2026-05-11 13:39:43 +02:00
Claudio
576b0c236d feat(thumbs): proxy Nextcloud previews instead of duplicating the cache
mule-image was generating and storing three WebP sizes per photo in
/data/thumbs while Nextcloud already keeps its own previews for the
same source files. Frontend thumbnail requests now proxy NC's
/index.php/core/preview keyed by the photo's Nextcloud fileid,
authenticated with the owner's encrypted app password.

- new column photos.nextcloud_fileid (alembic 0018) plus an index
- get_preview_async + fetch_fileid helpers in nextcloud_dav.py
- thumb route proxies NC primary, falls back to /data/thumbs (legacy
  rows / NC unreachable) so a single-file revert restores the old path
- extract_metadata caches the fileid on first run for new photos
- generate_thumbnails now writes only medium since the vision worker
  still loads it from disk; small + large drop out of the worker path
- backend/scripts/backfill_nextcloud_fileid.py for one-shot population
  of existing rows: docker exec mulita-backend python -m scripts.backfill_nextcloud_fileid

X-Mule-Thumb-Source response header marks each request 'nextcloud' or
'disk' for observability while the rollout settles.
2026-05-11 11:34:58 +02:00
Claudio
bc0bb44c05 feat(nextcloud): per-user Nextcloud library integration
Lets each mule-image user (matched via OIDC preferred_username,
overridable in Settings) browse their Nextcloud files/ tree from the
mule-image UI and register subfolders as per-user SourceRoots. Reads
stay direct on the bind-mounted /nextcloud-users path; mutations
(upload, delete, rename, move within NC) dispatch through Nextcloud
WebDAV so oc_filecache, trashbin, comments, and desktop-sync clients
stay coherent.

Backend:
- users.nextcloud_username + nextcloud_app_password_enc (Fernet at rest,
  key derived from SECRET_KEY) — alembic 0016
- services/nextcloud_dav.py: minimal WebDAV client (PUT, MKCOL, DELETE,
  MOVE) with HTTP Basic auth via the per-user app password
- routers/nextcloud.py: GET /browse, /whoami, GET/POST/DELETE
  /source-roots (path-scoped to current_user.nextcloud_username with
  realpath traversal guard)
- PATCH /api/v1/auth/me to update nextcloud_username and app password
- OIDC callback defaults nextcloud_username from preferred_username on
  first login; backfill on existing users; never overwrites a manual
  override
- routers/upload.py: stream upload to NamedTemporaryFile, then PUT to
  WebDAV (with MKCOL chain) when destination is NC-rooted; existing
  Photo row creation runs unchanged
- routers/discard.py empty-trash: WebDAV DELETE for NC files
- routers/photos.py rename + move: WebDAV MOVE for NC paths;
  cross-system move/copy returns a clean error
- routers/folders.py rename + create + permanent-delete: dispatch via
  WebDAV when targeting NC-rooted paths

Frontend:
- AuthUser carries nextcloud_username + has_nextcloud_app_password
- services/api.ts: nextcloud + account namespaces
- components/dialogs/NextcloudFolderPicker.tsx: lazy tree browser, name
  + submit -> POST /source-roots
- SettingsDialog: new "Nextcloud library" card with username override +
  validate, app-password input, list/remove of NC libraries, and the
  picker entry point

docker-compose.yml: NEXTCLOUD_USERS_HOST_PATH bind to /nextcloud-users
on backend + 3 workers; NEXTCLOUD_USERS_ROOT + NEXTCLOUD_BASE_URL env.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:06:37 +02:00