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.
This commit is contained in:
Claudio
2026-05-11 11:34:58 +02:00
parent 9e9b1ba224
commit 576b0c236d
8 changed files with 378 additions and 16 deletions

View File

@@ -22,6 +22,15 @@ class Photo(Base):
filename = Column(String, nullable=False)
folder_id = Column(String, ForeignKey('folders.id'))
file_hash = Column(String, index=True) # SHA-256 hash for duplicate detection
# Nextcloud fileid for the same file. Set by the scanner when the file
# lives under a Nextcloud-rooted SourceRoot. Used by the thumbnail
# endpoint to proxy /index.php/core/preview instead of generating
# and serving thumbs locally — Nextcloud already maintains previews
# for the same source file, and duplicating that work was the bulk
# of `/data/thumbs/*`. NULL on legacy / non-NC paths; the handler
# falls back to on-disk thumbs when this is unset.
nextcloud_fileid = Column(Integer, nullable=True, index=True)
# Media information
media_type = Column(String, nullable=False) # 'photo' | 'video' | 'raw' | 'heic'