feat(phase 4): vision fetches NC previews; stop writing /data/thumbs
Last consumer of the on-disk thumbnail pipeline was the vision
worker reading /data/thumbs/{id}/medium.webp. Now it asks Nextcloud
for a 640px preview (the same edge size the old thumb used) and
decodes the bytes in-memory — no disk dependency.
- nextcloud_dav.get_preview_bytes: sync sibling of get_preview_async,
for the celery vision worker (which is sync).
- vision._load_thumb: tries NC preview first; transitional disk
fallback stays for rows still indexed during the rollout.
- thumbs.WORKER_THUMB_SIZES = set() — generate_thumbnails still runs
the decode + pHash side-effect (perceptual dedup is mule-only and
needs original-resolution pixels) but no longer writes thumbnail
files.
The HTTP thumbnail endpoint's disk fallback path stays in place
unchanged: for NC-404 cases (e.g. iPhone JPEGs mis-extensioned as
.DNG), inline Pillow regeneration still writes a tiny per-photo
file so subsequent requests are fast. That path is rare and the
files are small.
Disk impact: /data/thumbs currently has ~22k medium.webp totaling
~1 GB. They'll stop being read after the worker-vision container
restarts, but no automatic delete — purge with the same find
pattern used for small/large reclaim when ready:
find /data/thumbs -name "medium.webp" -delete
This commit is contained in:
@@ -344,6 +344,35 @@ def fetch_fileid(user: User, abs_path: str) -> Optional[int]:
|
|||||||
return int(m.group(1)) if m else None
|
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(
|
async def fetch_memories_info_async(
|
||||||
user: User, fileid: int
|
user: User, fileid: int
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
|
|||||||
@@ -47,13 +47,13 @@ THUMB_SIZES = {
|
|||||||
'large': settings.thumbnails.large
|
'large': settings.thumbnails.large
|
||||||
}
|
}
|
||||||
|
|
||||||
# Sizes the worker actually writes to /data/thumbs. We used to write all
|
# Sizes the worker writes to /data/thumbs. Empty set since Phase 4 —
|
||||||
# three, but the API now proxies Nextcloud's /core/preview for `small`
|
# the API serves all sizes via Nextcloud's /core/preview proxy, and
|
||||||
# and `large` — only `medium` survives on disk because the vision
|
# the vision worker also fetches NC previews on demand instead of
|
||||||
# pipeline (app.tasks.vision) still loads it from there. When vision
|
# reading a local cache. generate_thumbnails still runs the decode-
|
||||||
# moves to NC previews too, this set drops to empty and the file
|
# and-pHash side-effect (perceptual dedup is mule-only and needs the
|
||||||
# pipeline can be deleted entirely.
|
# original-resolution pixels) but no longer touches the disk.
|
||||||
WORKER_THUMB_SIZES = {'medium'}
|
WORKER_THUMB_SIZES: set[str] = set()
|
||||||
|
|
||||||
def get_thumb_path(photo_id: str, size: str, user_id: str = None) -> str:
|
def get_thumb_path(photo_id: str, size: str, user_id: str = None) -> str:
|
||||||
"""Get the path for a thumbnail file.
|
"""Get the path for a thumbnail file.
|
||||||
|
|||||||
@@ -45,7 +45,76 @@ def _get_sync_session() -> Session:
|
|||||||
return sessionmaker(bind=_get_sync_engine())()
|
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:
|
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_base = Path("/data/thumbs")
|
||||||
thumb_path = thumb_base / photo_id / f"{size}.webp"
|
thumb_path = thumb_base / photo_id / f"{size}.webp"
|
||||||
if not thumb_path.exists():
|
if not thumb_path.exists():
|
||||||
@@ -53,7 +122,7 @@ def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None:
|
|||||||
if matches:
|
if matches:
|
||||||
thumb_path = matches[0]
|
thumb_path = matches[0]
|
||||||
else:
|
else:
|
||||||
logger.warning("Thumbnail not found: %s", thumb_path)
|
logger.warning("Thumbnail not found anywhere for %s", photo_id)
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
img = Image.open(thumb_path).convert("RGB")
|
img = Image.open(thumb_path).convert("RGB")
|
||||||
|
|||||||
Reference in New Issue
Block a user