diff --git a/backend/app/services/nextcloud_dav.py b/backend/app/services/nextcloud_dav.py index e978f91..143d64f 100644 --- a/backend/app/services/nextcloud_dav.py +++ b/backend/app/services/nextcloud_dav.py @@ -344,6 +344,35 @@ def fetch_fileid(user: User, abs_path: str) -> Optional[int]: return int(m.group(1)) if m else None +def get_preview_bytes( + user: User, fileid: int, x: int, y: int +) -> Optional[bytes]: + """Sync sibling of `get_preview_async` for callers in non-async + contexts (e.g. the vision celery worker, which is sync). + + Returns the preview body on success, None on 404 / non-success / + missing credentials. Caller is expected to feed the bytes into + PIL or similar. + """ + try: + nc_user, app_pw = _credentials_for(user) + except NextcloudCredentialsMissing: + return None + url = f"{_base_url()}/index.php/core/preview" + params = { + "fileId": str(fileid), + "x": str(x), + "y": str(y), + "a": "true", + "forceIcon": "false", + } + with _client((nc_user, app_pw)) as c: + resp = c.get(url, params=params) + if resp.status_code == 404 or not resp.is_success: + return None + return resp.content + + async def fetch_memories_info_async( user: User, fileid: int ) -> Optional[dict]: diff --git a/backend/app/tasks/thumbs.py b/backend/app/tasks/thumbs.py index 8111f37..83eb9ea 100644 --- a/backend/app/tasks/thumbs.py +++ b/backend/app/tasks/thumbs.py @@ -47,13 +47,13 @@ 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'} +# Sizes the worker writes to /data/thumbs. Empty set since Phase 4 — +# the API serves all sizes via Nextcloud's /core/preview proxy, and +# the vision worker also fetches NC previews on demand instead of +# reading a local cache. generate_thumbnails still runs the decode- +# and-pHash side-effect (perceptual dedup is mule-only and needs the +# original-resolution pixels) but no longer touches the disk. +WORKER_THUMB_SIZES: set[str] = set() def get_thumb_path(photo_id: str, size: str, user_id: str = None) -> str: """Get the path for a thumbnail file. diff --git a/backend/app/tasks/vision.py b/backend/app/tasks/vision.py index 7976842..9796930 100644 --- a/backend/app/tasks/vision.py +++ b/backend/app/tasks/vision.py @@ -45,7 +45,76 @@ def _get_sync_session() -> Session: return sessionmaker(bind=_get_sync_engine())() +# CLIP was trained against 640px medium thumbs that the on-disk +# pipeline used to produce. Now we ask Nextcloud's preview endpoint +# for the same edge size so the classifier sees the same input +# distribution. +_VISION_PREVIEW_PX = 640 + + def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None: + """Load the photo's RGB pixels into a numpy array for inference. + + Primary path: ask Nextcloud's `/index.php/core/preview` for a + 640px preview via the sync helper. Replaces the disk read of + `/data/thumbs/{photo_id}/medium.webp` so the on-disk pipeline + can retire entirely. + + Disk fallback (transitional): if NC has no preview or no + credentials, look for the medium.webp the old pipeline wrote. + Goes dead once `generate_thumbnails` stops writing files. + """ + from io import BytesIO + from app.models import Photo + from app.models.user import User + from app.services.nextcloud_dav import get_preview_bytes + + user_id: str | None = None + fileid: int | None = None + session = _get_sync_session() + try: + row = session.execute( + select(Photo.user_id, Photo.nextcloud_fileid).where( + Photo.id == photo_id + ) + ).one_or_none() + if row: + user_id, fileid = row[0], row[1] + finally: + session.close() + + if user_id and fileid: + session = _get_sync_session() + try: + owner = session.execute( + select(User).where(User.id == user_id) + ).scalar_one_or_none() + finally: + session.close() + if owner is not None and owner.nextcloud_app_password_enc: + try: + body = get_preview_bytes( + owner, fileid, _VISION_PREVIEW_PX, _VISION_PREVIEW_PX, + ) + except Exception as e: + logger.warning( + "NC preview fetch failed for %s: %s", photo_id, e + ) + body = None + if body: + try: + img = Image.open(BytesIO(body)).convert("RGB") + img.load() + arr = np.array(img) + img.close() + return arr + except Exception as e: + logger.warning( + "NC preview decode failed for %s: %s", photo_id, e + ) + + # Legacy disk fallback — transitional, dead once thumbs.py stops + # writing /data/thumbs. thumb_base = Path("/data/thumbs") thumb_path = thumb_base / photo_id / f"{size}.webp" if not thumb_path.exists(): @@ -53,7 +122,7 @@ def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None: if matches: thumb_path = matches[0] else: - logger.warning("Thumbnail not found: %s", thumb_path) + logger.warning("Thumbnail not found anywhere for %s", photo_id) return None try: img = Image.open(thumb_path).convert("RGB")