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).
536 lines
21 KiB
Python
536 lines
21 KiB
Python
"""
|
||
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 ~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():
|
||
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)} |