Files
mule-image/backend/app/services/metadata.py
Claudio 2a5759cc8d 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).
2026-05-11 13:39:43 +02:00

536 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Metadata extraction service using ExifTool
"""
import json
import logging
import re
import asyncio
from datetime import datetime
from typing import Dict, Optional
import subprocess
from pathlib import Path
from celery import shared_task
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import Photo
from app.services.date_guess import has_date_warning
logger = logging.getLogger(__name__)
def parse_exif_datetime(date_str: str) -> Optional[datetime]:
"""Parse EXIF datetime string to Python datetime.
Returns a tz-naive datetime — the photos.taken_at column is
`timestamp without time zone`. Tz-aware inputs (e.g. SubSec
fields with `+02:00` or QuickTime UTC `Z`) are converted to UTC
and stripped. Cameras that wrote the all-zero placeholder
return None.
"""
if not date_str:
return None
s = str(date_str).strip()
# All-zero placeholder some cameras emit when the clock isn't set.
if s.startswith("0000:00:00") or s.startswith("0000-00-00"):
return None
formats = [
"%Y:%m:%d %H:%M:%S",
"%Y-%m-%d %H:%M:%S",
"%Y:%m:%d %H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M:%S.%f",
# Tz-aware variants: SubSecDateTimeOriginal often looks like
# "2023:11:30 14:30:45.123+02:00", QuickTime CreateDate as
# "2023:11:30 14:30:45Z" or with offsets.
"%Y:%m:%d %H:%M:%S%z",
"%Y:%m:%d %H:%M:%S.%f%z",
"%Y-%m-%dT%H:%M:%S%z",
"%Y-%m-%dT%H:%M:%S.%f%z",
]
for fmt in formats:
try:
dt = datetime.strptime(s, fmt)
except ValueError:
continue
if dt.tzinfo is not None:
from datetime import timezone
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
return None
_DMS_RE = re.compile(
r"""\s*
(?P<deg>-?\d+(?:\.\d+)?)\s*(?:deg|°|d)?\s*
(?:(?P<min>\d+(?:\.\d+)?)\s*[\'m]?\s*)?
(?:(?P<sec>\d+(?:\.\d+)?)\s*[\"”s]?\s*)?
(?P<ref>[NSEW])?\s*$""",
re.IGNORECASE | re.VERBOSE,
)
def _parse_coord(value, ref: str | None) -> float | None:
"""Coerce a single GPS coordinate from any form ExifTool may emit.
ExifTool's ``-j`` JSON output applies print conversion by default, so
coordinates can come back as:
* a number (``48.1278``) — happens for some sources / when ``-n`` is set
* a plain DMS string (``"48 deg 7' 39.96\\""``) — bare ``EXIF:GPSLatitude``
* a DMS-with-ref string (``"48 deg 7' 39.96\\" N"``) — ``Composite:GPSLatitude``
The optional ``ref`` argument lets the caller pass an explicit
``GPSLatitudeRef`` / ``GPSLongitudeRef`` ('N'/'S'/'E'/'W') when the
string itself doesn't carry one. Returns signed decimal degrees, or
``None`` if the value is unparseable.
"""
if value is None:
return None
# Numeric path — already decimal degrees, possibly already signed.
if isinstance(value, (int, float)):
out = float(value)
else:
m = _DMS_RE.match(str(value))
if not m:
return None
deg = float(m.group('deg'))
minutes = float(m.group('min') or 0)
seconds = float(m.group('sec') or 0)
out = abs(deg) + minutes / 60.0 + seconds / 3600.0
if deg < 0:
out = -out
embedded_ref = m.group('ref')
if embedded_ref:
ref = embedded_ref
if ref:
r = ref[0].upper()
if r in ('S', 'W'):
out = -abs(out)
elif r in ('N', 'E'):
out = abs(out)
return out
def extract_gps(exif_data: Dict) -> tuple:
"""Return (lat, lon) in signed decimal degrees, or (None, None).
With ``exiftool -G -j`` GPS values are keyed under their group.
``Composite:GPSLatitude`` / ``Composite:GPSLongitude`` carry the
hemisphere reference inline (``"48 deg 7' 39.96\\" N"``) while the bare
``EXIF:GPSLatitude`` / ``EXIF:GPSLongitude`` need the separate
``EXIF:GPSLatitudeRef`` / ``EXIF:GPSLongitudeRef`` to know the sign.
Pre-fix this function read the *unprefixed* keys ``GPSLatitude`` /
``GPSLongitude`` (which never exist in ``-G`` output) AND assumed
they were already floats — so it silently dropped every photo's GPS.
"""
lat = _parse_coord(exif_data.get('Composite:GPSLatitude'), None)
lon = _parse_coord(exif_data.get('Composite:GPSLongitude'), None)
if lat is None or lon is None:
lat = _parse_coord(
exif_data.get('EXIF:GPSLatitude'),
exif_data.get('EXIF:GPSLatitudeRef'),
)
lon = _parse_coord(
exif_data.get('EXIF:GPSLongitude'),
exif_data.get('EXIF:GPSLongitudeRef'),
)
if lat is None or lon is None:
return None, None
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
return None, None
# Some cameras emit (0, 0) when they have no GPS lock — treat as missing
if lat == 0 and lon == 0:
return None, None
return lat, lon
def extract_key_metadata(exif_data: Dict) -> Dict:
"""Extract key metadata fields for FTS indexing"""
key_fields = []
# Camera information
if 'EXIF:Make' in exif_data:
key_fields.append(exif_data['EXIF:Make'])
if 'EXIF:Model' in exif_data:
key_fields.append(exif_data['EXIF:Model'])
if 'EXIF:LensModel' in exif_data:
key_fields.append(exif_data['EXIF:LensModel'])
# Location information
lat, lon = extract_gps(exif_data)
if lat is not None and lon is not None:
key_fields.append(f"GPS: {lat}, {lon}")
# IPTC/XMP keywords
keywords = exif_data.get('IPTC:Keywords') or exif_data.get('XMP:Subject')
if keywords:
if isinstance(keywords, list):
key_fields.extend(keywords)
else:
key_fields.append(keywords)
# Copyright and creator
if 'EXIF:Copyright' in exif_data:
key_fields.append(exif_data['EXIF:Copyright'])
if 'XMP:Creator' in exif_data:
key_fields.append(exif_data['XMP:Creator'])
if 'EXIF:Artist' in exif_data:
key_fields.append(exif_data['EXIF:Artist'])
return {
'exif_text': ' '.join(str(f) for f in key_fields),
'camera_make': exif_data.get('EXIF:Make'),
'camera_model': exif_data.get('EXIF:Model'),
'lens_model': exif_data.get('EXIF:LensModel'),
'gps_latitude': lat,
'gps_longitude': lon,
}
@shared_task(name='extract_metadata')
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.
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
result = await session.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
logger.error(f"Photo not found: {photo_id}")
return {'status': 'error', 'message': 'Photo not found'}
# 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):
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
# 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():
logger.error(f"File not found: {photo.filepath}")
return {'status': 'error', 'message': 'File not found'}
# Run ExifTool to extract metadata
cmd = [
'exiftool',
'-j', # JSON output
'-G', # Group names
'-s', # Short output format
'-All', # All metadata
photo.filepath
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
)
if result.returncode != 0:
logger.error(f"ExifTool error: {result.stderr}")
photo.processing_error = f"ExifTool: {result.stderr[:500]}"
await session.commit()
return {'status': 'error', 'message': result.stderr}
# Parse JSON output
metadata = json.loads(result.stdout)
if metadata and len(metadata) > 0:
exif_data = metadata[0]
# Store full metadata as JSON
photo.exif_json = json.dumps(exif_data)
# Extract taken_at date — but only if the user hasn't
# explicitly set it via the UI. Manual edits are the
# source of truth and must survive any rescan.
if photo.taken_at_source != 'manual':
# Trusted EXIF fields, in order of preference.
# SubSecDateTimeOriginal includes sub-second
# precision and often a tz offset, so it's the
# most accurate when present. ModifyDate is NOT
# in this list — it's set every time the file
# is re-saved (Lightroom export, EXIF strip,
# batch resize) and routinely overwrote correct
# capture dates with edit-time dates.
date_fields = [
'EXIF:SubSecDateTimeOriginal',
'EXIF:DateTimeOriginal',
'EXIF:CreateDate',
'QuickTime:MediaCreateDate',
'QuickTime:CreateDate',
]
new_taken_at = None
for field in date_fields:
if field in exif_data:
parsed = parse_exif_datetime(exif_data[field])
if parsed:
new_taken_at = parsed
photo.taken_at = parsed
photo.taken_at_source = 'exif'
break
# Fallback: if the file has no trusted EXIF date,
# try to extract one from the filename / folder
# path. The same date_guess module powers the
# has_date_warning flag — reusing it here means
# photos without EXIF (scanned prints, stripped
# JPEGs, re-saved exports) get a sensible date
# instead of falling back to filesystem mtime
# (which on Nextcloud-mounted files is just the
# upload time).
if new_taken_at is None:
from app.services.date_guess import guess_date_from_path
guess = guess_date_from_path(photo.filepath)
if guess is not None:
photo.taken_at = guess.date
photo.taken_at_source = 'path'
# Re-run the path-vs-date heuristic now that we know
# whether EXIF provided a real capture date. A true EXIF
# date that matches the folder clears the warning the
# scanner set during the filesystem-mtime pass.
photo.has_date_warning = has_date_warning(
photo.filepath, photo.taken_at
)
# Extract dimensions if not already set
if not photo.width:
photo.width = exif_data.get('EXIF:ImageWidth') or exif_data.get('File:ImageWidth')
if not photo.height:
photo.height = exif_data.get('EXIF:ImageHeight') or exif_data.get('File:ImageHeight')
# Extract GPS coordinates into first-class columns so the
# Map view can query them without parsing exif_json.
lat, lon = extract_gps(exif_data)
photo.latitude = lat
photo.longitude = lon
# Extract and store key metadata for search
key_metadata = extract_key_metadata(exif_data)
await session.commit()
logger.info(f"Metadata extracted for photo {photo_id}")
return {
'status': 'success',
'photo_id': photo_id,
'taken_at': photo.taken_at.isoformat() if photo.taken_at else None
}
except subprocess.TimeoutExpired:
logger.error(f"ExifTool timeout for {photo.filepath}")
photo.processing_error = 'ExifTool timeout'
await session.commit()
return {'status': 'error', 'message': 'ExifTool timeout'}
except json.JSONDecodeError as e:
logger.error(f"Failed to parse ExifTool output: {e}")
photo.processing_error = f"Invalid ExifTool output: {e}"
await session.commit()
return {'status': 'error', 'message': 'Invalid ExifTool output'}
except Exception as e:
logger.error(f"Error extracting metadata for {photo_id}: {e}")
return {'status': 'error', 'message': str(e)}
@shared_task(name='backfill_taken_at')
def backfill_taken_at():
"""Re-enqueue extract_metadata for every non-manual photo.
Used after fixing the date-extraction logic (removing ModifyDate
fallback, adding path-based fallback) to re-derive taken_at across
the whole library without touching photos the user has manually
corrected. Each enqueued task is fast (~90ms) and runs on the
default queue; ~21k photos finish in ~15 min on the existing
worker-light concurrency.
"""
return asyncio.run(_backfill_taken_at_async())
async def _backfill_taken_at_async():
from sqlalchemy import or_
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Photo.id).where(
# NULL taken_at_source predates the column default and
# should still be re-extracted; only 'manual' is sacred.
or_(
Photo.taken_at_source != 'manual',
Photo.taken_at_source.is_(None),
),
Photo.is_discarded.is_(False),
)
)
photo_ids = [row[0] for row in result.all()]
for pid in photo_ids:
extract_metadata.delay(pid)
logger.info(f"backfill_taken_at: queued extract_metadata for {len(photo_ids)} photos")
return {'queued': len(photo_ids)}