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

@@ -47,6 +47,14 @@ THUMB_SIZES = {
'large': settings.thumbnails.large
}
# Sizes the worker actually writes to /data/thumbs. We used to write all
# three, but the API now proxies Nextcloud's /core/preview for `small`
# and `large` — only `medium` survives on disk because the vision
# pipeline (app.tasks.vision) still loads it from there. When vision
# moves to NC previews too, this set drops to empty and the file
# pipeline can be deleted entirely.
WORKER_THUMB_SIZES = {'medium'}
def get_thumb_path(photo_id: str, size: str, user_id: str = None) -> str:
"""Get the path for a thumbnail file.
@@ -363,14 +371,18 @@ async def _generate_thumbnails_async(photo_id: str, task):
logger.warning(f"phash failed for {photo_id}: {e}")
photo.phash = None
# Generate thumbnails for each size
# Generate only the sizes the worker still owns on disk
# (see WORKER_THUMB_SIZES above). The API serves the rest
# via Nextcloud's preview endpoint.
for size_name, size_value in THUMB_SIZES.items():
if size_name not in WORKER_THUMB_SIZES:
continue
thumb_path = get_thumb_path(photo_id, size_name, photo.user_id)
generate_thumbnail(image, size_value, thumb_path)
# Update database with thumbnail path
setattr(photo, f'thumb_{size_name}', thumb_path)
# Update progress
task.update_state(
state='PROGRESS',