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())