""" 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-?\d+(?:\.\d+)?)\s*(?:deg|°|d)?\s* (?:(?P\d+(?:\.\d+)?)\s*[\'’m]?\s*)? (?:(?P\d+(?:\.\d+)?)\s*[\"”s]?\s*)? (?P[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'} # Cache Nextcloud's numeric fileid on the row so the thumbnail # handler can proxy /index.php/core/preview without doing a # PROPFIND per request. PROPFIND blocks for ~50ms; tolerable # because extract_metadata already does seconds of ExifTool # work. Failures are silent — the thumb handler falls back # to its on-disk path when the column is NULL. if photo.nextcloud_fileid is None and photo.user_id: from app.models.user import User from app.services.nextcloud_dav import ( fetch_fileid, is_nextcloud_path, ) if photo.filepath and is_nextcloud_path(photo.filepath): owner = ( await session.execute( select(User).where(User.id == photo.user_id) ) ).scalar_one_or_none() if owner is not None and owner.nextcloud_app_password_enc: 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 # 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)}