From 2a5759cc8d3fbde676a2c2cf411eed27ef6a50bd Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 11 May 2026 13:39:43 +0200 Subject: [PATCH] feat(metadata): read from NC Memories first, ExifTool subprocess as fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 (fat refactor). extract_metadata now tries Memories' HTTP API GET /index.php/apps/memories/api/image/info/{fileid} before spawning ExifTool. Replaces ~80–100 ms of subprocess work with a ~1–2 ms HTTP call for ongoing imports. What we kept from the ExifTool path: - Mule's date-fallback chain (SubSec → DateTimeOriginal → CreateDate → MediaCreateDate → TrackCreateDate → filename/folder guess → mtime). Memories' single `datetaken` field falls back to mtime, which would silently mis-date the 6k+ photos in our library that depend on filename-encoded dates. _apply_memories_metadata re-applies the same chain against Memories' `exif` dict. - taken_at_source='manual' is still sacred — never overwritten. - has_date_warning recomputed against the resolved taken_at. Format compat: Memories' `exif` dict uses plain key names (Make, Model, ISO, FNumber, DateTimeOriginal, GPSLatitude, ...) while the old ExifTool path stored `EXIF:Make` etc. PhotoInfoPanel only reads the four keys above and Memories has them in plain form, so the info panel keeps working without an adapter. Full-text search (ILIKE on exif_json) still hits camera names, lens names, dates etc. — value content is identical, only the keys differ. Fallback paths preserved: - 404 from Memories (file not yet indexed by NC's scan, brand-new upload): falls through to ExifTool. - non-NC photos (no nextcloud_fileid or no app password): ExifTool. - NC HTTP error or parse failure: ExifTool. CSRF: Memories' /api/image/info/{id} is CSRF-checked. We send `OCS-APIRequest: true` to bypass it, the same way the OCS clients do. Auth is the user's existing Fernet-encrypted app password. Verified end-to-end against: - IMG_4954.DNG (real DNG with GPS): width/height/lat/lon/taken_at match the previous ExifTool output exactly; exif_json switched to Memories format (Make/Model/ISO/FNumber preserved). - 20210817_000000_4A6737B6.jpg (path-dated archive photo): taken_at remained 2021-08-17 from the filename heuristic, source='path'. The `enabled` state of the Memories app is now required for new imports to skip ExifTool — left enabled in commit 0a4c8d... (NC admin action; not in this commit). --- backend/app/services/metadata.py | 174 ++++++++++++++++++++++---- backend/app/services/nextcloud_dav.py | 47 +++++++ 2 files changed, 199 insertions(+), 22 deletions(-) diff --git a/backend/app/services/metadata.py b/backend/app/services/metadata.py index 86de009..a9c2a0f 100644 --- a/backend/app/services/metadata.py +++ b/backend/app/services/metadata.py @@ -195,8 +195,87 @@ def extract_metadata(photo_id: str): """Extract metadata from a photo using ExifTool""" return asyncio.run(_extract_metadata_async(photo_id)) +def _apply_memories_metadata(photo: Photo, data: dict) -> None: + """Apply a Memories API `/image/info/{id}` response to a Photo row. + + Replicates the side-effects of the ExifTool path (width, height, + latitude, longitude, taken_at, taken_at_source, has_date_warning, + exif_json) without spawning a subprocess. Mule's date-fallback chain + (SubSec → DateTimeOriginal → CreateDate → MediaCreateDate → path) + is preserved — Memories itself only stores the resolved datetaken + and we still need to honour `taken_at_source='manual'` and recover + filename-encoded dates for archive photos that lack EXIF. + + The frontend PhotoInfoPanel reads `Make`/`Model`/`ISO`/`FNumber` + out of `exif_json`. Memories' `exif` dict uses those exact plain + key names (no `EXIF:` prefix), so storing it directly keeps the + info panel working without a format adapter. + """ + from app.services.date_guess import guess_date_from_path + + exif: Dict = data.get('exif') or {} + + # Dimensions + w = data.get('w') + h = data.get('h') + if w: + photo.width = int(w) + if h: + photo.height = int(h) + + # GPS — Memories stores plain decimal-degree values in the exif + # dict (no DMS/composite parsing needed). + gps_lat = exif.get('GPSLatitude') + gps_lon = exif.get('GPSLongitude') + if isinstance(gps_lat, (int, float)) and isinstance(gps_lon, (int, float)): + photo.latitude = float(gps_lat) + photo.longitude = float(gps_lon) + else: + # Memories omits GPS when not present; clear cleanly. + photo.latitude = None + photo.longitude = None + + # Store the EXIF dict for the info panel + full-text search. + photo.exif_json = json.dumps(exif) + + # Date extraction — only when the user hasn't pinned it manually. + if photo.taken_at_source != 'manual': + date_fields = [ + 'SubSecDateTimeOriginal', + 'DateTimeOriginal', + 'CreateDate', + 'MediaCreateDate', + 'TrackCreateDate', + ] + new_taken_at = None + for field in date_fields: + val = exif.get(field) + if not val: + continue + parsed = parse_exif_datetime(val) + if parsed: + new_taken_at = parsed + photo.taken_at = parsed + photo.taken_at_source = 'exif' + break + if new_taken_at is None: + # Filename / folder fallback — same heuristic as the + # ExifTool path uses for stripped JPEGs and archive scans. + guess = guess_date_from_path(photo.filepath) + if guess is not None: + photo.taken_at = guess.date + photo.taken_at_source = 'path' + + photo.has_date_warning = has_date_warning(photo.filepath, photo.taken_at) + + async def _extract_metadata_async(photo_id: str): - """Async implementation of metadata extraction""" + """Async implementation of metadata extraction. + + Primary path: Memories' HTTP API (~1-2 ms per photo, no + subprocess). Falls back to ExifTool when Memories returns 404 + (file not yet indexed by NC's scan) or any non-success response. + """ async with AsyncSessionLocal() as session: try: # Get photo from database @@ -209,33 +288,84 @@ async def _extract_metadata_async(photo_id: str): 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: + # Resolve the owner once — we need it for both the fileid + # lookup and the Memories API call. + owner = None + if photo.user_id: from app.models.user import User + owner = ( + await session.execute( + select(User).where(User.id == photo.user_id) + ) + ).scalar_one_or_none() + + # Backfill nextcloud_fileid if missing (same behaviour as + # before — the thumb handler depends on this column). + if ( + photo.nextcloud_fileid is None + and owner is not None + and owner.nextcloud_app_password_enc + ): 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) + try: + fid = fetch_fileid(owner, photo.filepath) + except Exception as e: + logger.warning( + "fileid lookup failed for %s: %s", photo_id, e ) - ).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 + fid = None + if fid is not None: + photo.nextcloud_fileid = fid + + # Primary path: ask Memories for the metadata it has + # already extracted. Replaces a ~80–100 ms ExifTool + # subprocess with a single ~1–2 ms HTTP call. + if ( + photo.nextcloud_fileid is not None + and owner is not None + and owner.nextcloud_app_password_enc + ): + from app.services.nextcloud_dav import ( + fetch_memories_info_async, + ) + memories_data = None + try: + memories_data = await fetch_memories_info_async( + owner, photo.nextcloud_fileid + ) + except Exception as e: + logger.warning( + "Memories info call failed for %s: %s", + photo_id, e, + ) + if memories_data: + _apply_memories_metadata(photo, memories_data) + photo.processing_status = 'completed' + photo.processing_error = None + await session.commit() + logger.info( + f"Metadata extracted via Memories for photo {photo_id}" + ) + return { + 'status': 'success', + 'source': 'memories', + 'photo_id': photo_id, + 'taken_at': ( + photo.taken_at.isoformat() if photo.taken_at else None + ), + } + logger.info( + "Memories had no info for fileid %s; falling back to ExifTool", + photo.nextcloud_fileid, + ) + + # Fallback path: ExifTool subprocess. Used when Memories + # hasn't indexed the file yet (brand-new uploads racing the + # NC scan), or for non-NC photos that bypass the Memories + # pipeline entirely. # Check if file exists if not Path(photo.filepath).exists(): diff --git a/backend/app/services/nextcloud_dav.py b/backend/app/services/nextcloud_dav.py index ce07ea6..e978f91 100644 --- a/backend/app/services/nextcloud_dav.py +++ b/backend/app/services/nextcloud_dav.py @@ -344,6 +344,53 @@ def fetch_fileid(user: User, abs_path: str) -> Optional[int]: return int(m.group(1)) if m else None +async def fetch_memories_info_async( + user: User, fileid: int +) -> Optional[dict]: + """Fetch the Memories app's per-file metadata blob. + + `GET /index.php/apps/memories/api/image/info/{fileid}` returns + Memories' pre-extracted view of the file: `w`, `h`, `datetaken` + (unix epoch), `mtime`, `mimetype`, `size`, plus an `exif` dict + of plain-named EXIF fields (Make, Model, ISO, FNumber, + DateTimeOriginal, GPSLatitude, GPSLongitude, etc.). The endpoint + is `#[NoAdminRequired] #[PublicPage]` but CSRF-checked, so we + send `OCS-APIRequest: true` to bypass the check the same way + OCS API clients do. + + Returns None on 404 (file not yet indexed by Memories, or + fileid stale) or any non-success response — callers should fall + back to ExifTool extraction in that case. + """ + nc_user, app_pw = _credentials_for(user) + url = ( + f"{_base_url()}/index.php/apps/memories/api/image/info/{int(fileid)}" + ) + client = _async_client((nc_user, app_pw)) + try: + resp = await client.get( + url, + headers={ + "OCS-APIRequest": "true", + "Accept": "application/json", + }, + ) + finally: + await client.aclose() + if resp.status_code == 404: + return None + if not resp.is_success: + logger.warning( + "Memories info for fileid %s returned %s", fileid, resp.status_code + ) + return None + try: + return resp.json() + except Exception as e: + logger.warning("Memories info parse failed for fileid %s: %s", fileid, e) + return None + + async def get_preview_async( user: User, fileid: int, x: int, y: int ) -> httpx.Response: