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

@@ -204,11 +204,39 @@ async def _extract_metadata_async(photo_id: str):
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
logger.error(f"Photo not found: {photo_id}")
return {'status': 'error', 'message': 'Photo not found'}
# Cache Nextcloud's numeric fileid on the row so the thumbnail
# handler can proxy /index.php/core/preview without doing a
# PROPFIND per request. PROPFIND blocks for ~50ms; tolerable
# because extract_metadata already does seconds of ExifTool
# work. Failures are silent — the thumb handler falls back
# to its on-disk path when the column is NULL.
if photo.nextcloud_fileid is None and photo.user_id:
from app.models.user import User
from app.services.nextcloud_dav import (
fetch_fileid, is_nextcloud_path,
)
if photo.filepath and is_nextcloud_path(photo.filepath):
owner = (
await session.execute(
select(User).where(User.id == photo.user_id)
)
).scalar_one_or_none()
if owner is not None and owner.nextcloud_app_password_enc:
try:
fid = fetch_fileid(owner, photo.filepath)
except Exception as e:
logger.warning(
"fileid lookup failed for %s: %s", photo_id, e
)
fid = None
if fid is not None:
photo.nextcloud_fileid = fid
# Check if file exists
if not Path(photo.filepath).exists():
logger.error(f"File not found: {photo.filepath}")