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

@@ -5,7 +5,7 @@ from typing import List, Optional, Dict, Any
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel
from sqlalchemy import select, and_, or_, func, tuple_
from sqlalchemy.ext.asyncio import AsyncSession
@@ -31,7 +31,12 @@ from app.dependencies import (
get_user_or_shared_heap, get_user_or_shared_folder,
can_access_photo_via_share,
)
from app.services.nextcloud_dav import is_nextcloud_path, move_for_user
from app.services.nextcloud_dav import (
NextcloudCredentialsMissing,
get_preview_async,
is_nextcloud_path,
move_for_user,
)
from app.config import settings
@@ -615,6 +620,13 @@ async def _get_photo_with_share_fallback(
raise HTTPException(status_code=404, detail="Photo not found")
# Pixel box mule's three logical sizes map to. Nextcloud's preview
# endpoint takes (x, y) as a bounding box and `a=true` preserves the
# source aspect ratio, so passing a square box is fine. Keep these in
# sync with the worker's THUMB_SIZES if you ever change them.
_NC_PREVIEW_PX = {"small": 240, "medium": 640, "large": 1280}
@router.get("/{photo_id}/thumb/{size}")
async def get_thumbnail(
photo_id: str,
@@ -623,20 +635,77 @@ async def get_thumbnail(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user_media),
):
"""Serve thumbnail (with Nginx X-Accel-Redirect support)"""
if size not in ['small', 'medium', 'large']:
"""Serve a thumbnail.
Primary path: proxy Nextcloud's `/index.php/core/preview` for the
photo's `nextcloud_fileid`, authenticated with the owner's NC app
password. Nextcloud already maintains previews for the source file;
duplicating that work in `/data/thumbs/*` was burning disk and CPU.
Fallback path: legacy / non-NC photos (where `nextcloud_fileid` is
NULL) and any NC error keep working through the original on-disk
thumbnail cache + inline-generate fallback. The fallback is
intentionally identical to the old handler so a revert is one
file diff.
"""
if size not in _NC_PREVIEW_PX:
raise HTTPException(status_code=400, detail="Invalid thumbnail size")
photo = await _get_photo_with_share_fallback(photo_id, current_user, db)
# Check if thumbnail exists, generate if not.
# User-prefixed path for isolation.
# ── Primary: proxy Nextcloud's preview endpoint ────────────────────
if photo.nextcloud_fileid and photo.user_id:
owner = (
await db.execute(select(User).where(User.id == photo.user_id))
).scalar_one_or_none()
if owner is not None:
try:
upstream = await get_preview_async(
owner,
photo.nextcloud_fileid,
_NC_PREVIEW_PX[size],
_NC_PREVIEW_PX[size],
)
except NextcloudCredentialsMissing:
# Owner hasn't set their NC app password — fall through
# to disk; that path still works for them.
upstream = None
except Exception as e:
logger.warning(
"NC preview proxy failed for photo %s size=%s: %s",
photo_id, size, e,
)
upstream = None
if upstream is not None and upstream.is_success:
headers = {
"Cache-Control": "private, max-age=86400",
"X-Mule-Thumb-Source": "nextcloud",
}
etag = upstream.headers.get("etag")
if etag:
headers["ETag"] = etag
media_type = upstream.headers.get(
"content-type", "image/jpeg"
)
return Response(
content=upstream.content,
media_type=media_type,
headers=headers,
)
# Non-success or exception: log + fall through.
if upstream is not None:
logger.info(
"NC preview returned %s for photo=%s fileid=%s — falling back to disk",
upstream.status_code, photo_id, photo.nextcloud_fileid,
)
# ── Fallback: on-disk thumbnail (unchanged from pre-NC-proxy) ──────
if photo.user_id:
thumb_dir = f"/data/thumbs/{photo.user_id}/{photo_id}"
else:
thumb_dir = f"/data/thumbs/{photo_id}"
thumb_path = f"{thumb_dir}/{size}.webp"
if not os.path.exists(thumb_path):
# Queue background generation (handles RAW/HEIC/video properly)
from app.tasks.thumbs import generate_thumbnails
@@ -650,7 +719,7 @@ async def get_thumbnail(
try:
os.makedirs(thumb_dir, exist_ok=True)
img = Image.open(photo.filepath)
# Auto-rotate based on EXIF
from PIL import ExifTags
try:
@@ -668,15 +737,15 @@ async def get_thumbnail(
img = img.rotate(90, expand=True)
except:
pass
# Generate thumbnail size
sizes = {'small': 150, 'medium': 400, 'large': 800}
target_size = sizes.get(size, 400)
img.thumbnail((target_size, target_size), Image.Resampling.LANCZOS)
# Save as WebP
img.save(thumb_path, 'WEBP', quality=85, optimize=True)
except HTTPException:
raise
except Exception as e:
@@ -694,6 +763,7 @@ async def get_thumbnail(
headers={"Retry-After": "2"},
)
response.headers["X-Mule-Thumb-Source"] = "disk"
# Check if we're behind Nginx
if os.environ.get('USE_X_ACCEL_REDIRECT'):
# Use Nginx X-Accel-Redirect for better performance