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:
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user