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:
38
backend/alembic/versions/0018_photos_nextcloud_fileid.py
Normal file
38
backend/alembic/versions/0018_photos_nextcloud_fileid.py
Normal 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")
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
0
backend/scripts/__init__.py
Normal file
0
backend/scripts/__init__.py
Normal file
121
backend/scripts/backfill_nextcloud_fileid.py
Normal file
121
backend/scripts/backfill_nextcloud_fileid.py
Normal 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())
|
||||
Reference in New Issue
Block a user