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

@@ -0,0 +1,38 @@
"""Add photos.nextcloud_fileid for NC preview proxying
Revision ID: 0018_photos_nextcloud_fileid
Revises: 0017_photos_list_index
Create Date: 2026-05-11
The thumbnail endpoint will proxy Nextcloud's /index.php/core/preview
instead of generating and serving its own WebP cache under /data/thumbs.
That requires storing each photo's Nextcloud numeric fileid alongside
the row. NULL is allowed because legacy / non-NC photos still exist
and the handler keeps the on-disk fallback for them.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0018_photos_nextcloud_fileid"
down_revision: Union[str, None] = "0017_photos_list_index"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"photos",
sa.Column("nextcloud_fileid", sa.Integer(), nullable=True),
)
op.create_index(
"ix_photos_nextcloud_fileid",
"photos",
["nextcloud_fileid"],
)
def downgrade() -> None:
op.drop_index("ix_photos_nextcloud_fileid", table_name="photos")
op.drop_column("photos", "nextcloud_fileid")

View File

@@ -23,6 +23,15 @@ class Photo(Base):
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'
original_format = Column(String) # 'CR3', 'NEF', 'HEIC', 'MP4', 'JPEG', etc.

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,14 +635,71 @@ 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:
@@ -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

View File

@@ -209,6 +209,34 @@ async def _extract_metadata_async(photo_id: str):
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}")

View File

@@ -291,6 +291,90 @@ def ensure_parents_for_user(user: User, abs_path: str) -> None:
mkcol_for_user(user, sub_abs)
_FILEID_PROPFIND = (
b'<?xml version="1.0"?>'
b'<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">'
b'<d:prop><oc:fileid/></d:prop>'
b'</d:propfind>'
)
def fetch_fileid(user: User, abs_path: str) -> Optional[int]:
"""Look up Nextcloud's numeric fileid for the file at `abs_path`.
`abs_path` is the absolute filesystem path under the bind mount,
e.g. `/nextcloud-users/admin/files/Photos/2024/01/foo.jpg`. Returns
None when the file isn't under a Nextcloud-rooted tree, the user
has no app password set, or Nextcloud returns 404 — callers should
treat None as "skip this row" rather than an error.
Used by `scripts/backfill_nextcloud_fileid.py`. The hot path (the
thumbnail handler) reads `Photo.nextcloud_fileid` directly so it
doesn't round-trip to Nextcloud per request.
"""
if not is_nextcloud_path(abs_path):
return None
try:
nc_user, app_pw = _credentials_for(user)
except NextcloudCredentialsMissing:
return None
try:
expected_user, rel = split_nextcloud_path(abs_path)
except ValueError:
return None
if expected_user != nc_user:
return None
url = _dav_url(nc_user, rel)
with _client((nc_user, app_pw)) as c:
resp = c.request(
"PROPFIND",
url,
headers={"Depth": "0", "Content-Type": "application/xml"},
content=_FILEID_PROPFIND,
)
if resp.status_code == 404:
return None
if not resp.is_success:
logger.warning(
"Nextcloud PROPFIND %s returned %s", rel, resp.status_code
)
return None
import re as _re
m = _re.search(rb"<oc:fileid>(\d+)</oc:fileid>", resp.content)
return int(m.group(1)) if m else None
async def get_preview_async(
user: User, fileid: int, x: int, y: int
) -> httpx.Response:
"""Fetch a Nextcloud preview for `fileid` sized up to (x, y).
Nextcloud's `/index.php/core/preview` endpoint returns a JPEG (or
icon fallback) sized so the longest edge fits within the requested
box. `a=true` preserves the source aspect ratio; `forceIcon=false`
makes it 404 rather than returning a placeholder if no real preview
can be produced.
Auth uses the user's encrypted app password — same path as every
other mutation in this module. The caller streams the body back
to the frontend; we don't buffer the bytes here.
"""
nc_user, app_pw = _credentials_for(user)
url = f"{_base_url()}/index.php/core/preview"
params = {
"fileId": str(fileid),
"x": str(x),
"y": str(y),
"a": "true",
"forceIcon": "false",
}
client = _async_client((nc_user, app_pw))
try:
return await client.get(url, params=params)
finally:
await client.aclose()
def whoami_dir_exists(nc_username: str) -> bool:
"""True iff the bind-mounted `<NEXTCLOUD_USERS_ROOT>/<user>/files`
directory exists. Used by the UI to validate the override field

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,8 +371,12 @@ 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)

View File

View File

@@ -0,0 +1,121 @@
"""Backfill Photo.nextcloud_fileid for photos under Nextcloud-rooted paths.
The Phase-1 thumbnail proxy reads `Photo.nextcloud_fileid` to know which
file to ask Nextcloud's /core/preview endpoint about. New photos pick it
up at scan time; this script catches up the existing library.
Run inside the backend container, e.g.:
pct exec 120 -- docker exec mulita-backend python -m scripts.backfill_nextcloud_fileid
Idempotent: skips rows that already have nextcloud_fileid set, and any
row whose path isn't under the Nextcloud bind mount. One PROPFIND per
photo. At ~50ms each that's ~18 minutes for a 22k-row library — run
during off-hours.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models import Photo
from app.models.user import User
from app.services.nextcloud_dav import fetch_fileid, is_nextcloud_path
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("backfill_nextcloud_fileid")
BATCH = 500
async def _user_cache(session: AsyncSession) -> dict[str, User]:
"""One SELECT per script run instead of per photo."""
result = await session.execute(select(User))
return {u.id: u for u in result.scalars().all()}
async def run() -> None:
async with AsyncSessionLocal() as session:
users = await _user_cache(session)
total = await session.scalar(
select(func.count(Photo.id)).where(Photo.nextcloud_fileid.is_(None))
)
logger.info("photos with NULL nextcloud_fileid: %s", total)
done = 0
skipped_no_user = 0
skipped_not_nc = 0
skipped_no_creds = 0
filled = 0
not_found = 0
offset = 0
while True:
result = await session.execute(
select(Photo)
.where(Photo.nextcloud_fileid.is_(None))
.order_by(Photo.id)
.offset(offset)
.limit(BATCH)
)
rows = list(result.scalars().all())
if not rows:
break
for photo in rows:
done += 1
if not photo.user_id:
skipped_no_user += 1
continue
owner = users.get(photo.user_id)
if owner is None:
skipped_no_user += 1
continue
if not photo.filepath or not is_nextcloud_path(photo.filepath):
skipped_not_nc += 1
continue
if not owner.nextcloud_app_password_enc:
skipped_no_creds += 1
continue
fid: Optional[int] = None
try:
fid = fetch_fileid(owner, photo.filepath)
except Exception as e:
logger.warning(
"PROPFIND failed for photo %s (%s): %s",
photo.id, photo.filepath, e,
)
if fid is None:
not_found += 1
continue
photo.nextcloud_fileid = fid
filled += 1
await session.commit()
offset += BATCH
logger.info(
"progress: scanned=%s filled=%s not_found=%s "
"skipped(no_user=%s not_nc=%s no_creds=%s) of total=%s",
done, filled, not_found,
skipped_no_user, skipped_not_nc, skipped_no_creds,
total,
)
logger.info(
"done: scanned=%s filled=%s not_found=%s "
"skipped(no_user=%s not_nc=%s no_creds=%s)",
done, filled, not_found,
skipped_no_user, skipped_not_nc, skipped_no_creds,
)
if __name__ == "__main__":
asyncio.run(run())