Files
mule-image/backend/app/services/metadata.py
dtoro 30d03d8d4d feat: editable taken_at + folder-based date repair and filter
Lets operators fix corrupted capture dates at scale. Adds an editable
Date Taken field with a folder/filename-derived suggestion hint, a bulk
Date Taken section in the multi-select sidebar that either applies one
date to the whole selection or infers a per-photo date from each path,
a warning badge on thumbnails whose stored date disagrees with the
path, and a "Date issues" filter pill so suspicious photos can be
surfaced and fixed as a group. Edits are written back to EXIF on disk
so rescans don't clobber the fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:48:55 +02:00

287 lines
10 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"""
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
)
if result.returncode != 0:
logger.error(f"ExifTool error: {result.stderr}")
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)
# Update FTS table (would be done via trigger in production)
# For now, we'll store it in a comment
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}")
return {'status': 'error', 'message': 'ExifTool timeout'}
except json.JSONDecodeError as e:
logger.error(f"Failed to parse ExifTool output: {e}")
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)}