Addresses 16 robustness, transparency, and performance issues across the Celery media processing pipeline: Critical: - Singleton DB engine in vision tasks (was leaking one per task call) - acks_late + task_reject_on_worker_lost so crashed workers don't lose tasks - Global soft/hard time limits (5/10 min) to prevent hung worker slots - Thumbnail copy-before-resize (in-place mutation degraded larger sizes) - backfill_vision now checks each task type independently (OCR, faces, etc.) - Parameterized LIMIT in backfill_vision (was f-string SQL injection) High: - try/except + retry(max=3) on all vision inference tasks - extract_metadata writes processing_error on exiftool failure - PIL Image handles closed in _load_thumb/_load_original - Scan progress Redis keys auto-expire after 1 hour - Watcher lock renewal is wall-clock based (30s) not event-count based - worker_process_init signal warms up vision models on startup Medium: - Explicit task_routes for every task name (wildcards never matched) - app.services.metadata added to Celery include list - POST /maintenance/recover-stuck endpoint for photos stuck in processing - Docker healthchecks for worker-light, worker-vision, and Redis - Task ID in vision log lines for distributed tracing - Bare except:pass narrowed to specific exceptions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
291 lines
10 KiB
Python
291 lines
10 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"""
|
||
if not date_str:
|
||
return None
|
||
|
||
# Common EXIF datetime formats
|
||
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",
|
||
"%Y-%m-%dT%H:%M:%S%z"
|
||
]
|
||
|
||
for fmt in formats:
|
||
try:
|
||
return datetime.strptime(date_str, fmt)
|
||
except ValueError:
|
||
continue
|
||
|
||
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))
|
||
|
||
async def _extract_metadata_async(photo_id: str):
|
||
"""Async implementation of metadata extraction"""
|
||
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'}
|
||
|
||
# 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
|
||
date_fields = [
|
||
'EXIF:DateTimeOriginal',
|
||
'EXIF:CreateDate',
|
||
'QuickTime:MediaCreateDate',
|
||
'EXIF:ModifyDate'
|
||
]
|
||
|
||
for field in date_fields:
|
||
if field in exif_data:
|
||
taken_at = parse_exif_datetime(exif_data[field])
|
||
if taken_at:
|
||
photo.taken_at = taken_at
|
||
photo.taken_at_source = 'exif'
|
||
break
|
||
|
||
# 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)} |