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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user