The taken_at extractor walked four EXIF fields in order: DateTimeOriginal,
CreateDate, MediaCreateDate, ModifyDate. The last one is set every time
a file is re-saved (Lightroom export, EXIF strip, batch resize), so any
photo whose original capture metadata was lost during editing ended up
labeled 'exif' with the *edit* date instead of the shoot date.
Changes:
- SubSecDateTimeOriginal at the top of the list (sub-second precision,
often carries OffsetTime).
- QuickTime:CreateDate added next to MediaCreateDate.
- ModifyDate dropped from the trusted list entirely.
- When no trusted EXIF date is present, fall back to guess_date_from_path
(already used for has_date_warning) and tag taken_at_source='path'.
Better than filesystem mtime, which on Nextcloud-mounted libraries
just reflects the upload time.
- Skip the date-write block entirely if photo.taken_at_source == 'manual'
so a rescan can't clobber a user correction.
- parse_exif_datetime: handle the all-zero placeholder some cameras
emit, accept tz-aware variants (%z), normalize to naive UTC.
Frontend: new 'PATH' badge in TakenAtEditor with a tooltip explaining
the date came from filename / folder rather than real EXIF.
Backfill: new backfill_taken_at celery task and
POST /api/v1/library/maintenance/backfill-taken-at endpoint that
re-enqueues extract_metadata for every non-manual photo. ~21k tasks
finish in ~15 min on the existing worker-light concurrency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
378 lines
15 KiB
Python
378 lines
15 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))
|
||
|
||
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 — 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)} |