feat(metadata): read from NC Memories first, ExifTool subprocess as fallback

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).
This commit is contained in:
Claudio
2026-05-11 13:39:43 +02:00
parent f27f3cb820
commit 2a5759cc8d
2 changed files with 199 additions and 22 deletions

View File

@@ -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 ~80100 ms ExifTool
# subprocess with a single ~12 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():

View File

@@ -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: