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

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