From 576b0c236def71bdf601a2c60e8d25d85c391fe5 Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 11 May 2026 11:34:58 +0200 Subject: [PATCH] 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. --- .../versions/0018_photos_nextcloud_fileid.py | 38 ++++++ backend/app/models/photos.py | 9 ++ backend/app/routers/photos.py | 92 +++++++++++-- backend/app/services/metadata.py | 32 ++++- backend/app/services/nextcloud_dav.py | 84 ++++++++++++ backend/app/tasks/thumbs.py | 18 ++- backend/scripts/__init__.py | 0 backend/scripts/backfill_nextcloud_fileid.py | 121 ++++++++++++++++++ 8 files changed, 378 insertions(+), 16 deletions(-) create mode 100644 backend/alembic/versions/0018_photos_nextcloud_fileid.py create mode 100644 backend/scripts/__init__.py create mode 100644 backend/scripts/backfill_nextcloud_fileid.py diff --git a/backend/alembic/versions/0018_photos_nextcloud_fileid.py b/backend/alembic/versions/0018_photos_nextcloud_fileid.py new file mode 100644 index 0000000..69388a1 --- /dev/null +++ b/backend/alembic/versions/0018_photos_nextcloud_fileid.py @@ -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") diff --git a/backend/app/models/photos.py b/backend/app/models/photos.py index f0153f1..0f14cdb 100644 --- a/backend/app/models/photos.py +++ b/backend/app/models/photos.py @@ -22,6 +22,15 @@ class Photo(Base): filename = Column(String, nullable=False) 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' diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 0af894f..99aafe6 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -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 diff --git a/backend/app/services/metadata.py b/backend/app/services/metadata.py index 50130bb..86de009 100644 --- a/backend/app/services/metadata.py +++ b/backend/app/services/metadata.py @@ -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}") diff --git a/backend/app/services/nextcloud_dav.py b/backend/app/services/nextcloud_dav.py index 0705f03..ce07ea6 100644 --- a/backend/app/services/nextcloud_dav.py +++ b/backend/app/services/nextcloud_dav.py @@ -291,6 +291,90 @@ def ensure_parents_for_user(user: User, abs_path: str) -> None: mkcol_for_user(user, sub_abs) +_FILEID_PROPFIND = ( + b'' + b'' + b'' + b'' +) + + +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"(\d+)", 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 `//files` directory exists. Used by the UI to validate the override field diff --git a/backend/app/tasks/thumbs.py b/backend/app/tasks/thumbs.py index f466d6f..8111f37 100644 --- a/backend/app/tasks/thumbs.py +++ b/backend/app/tasks/thumbs.py @@ -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', diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/scripts/backfill_nextcloud_fileid.py b/backend/scripts/backfill_nextcloud_fileid.py new file mode 100644 index 0000000..24d5d0b --- /dev/null +++ b/backend/scripts/backfill_nextcloud_fileid.py @@ -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())