""" EXIF-Aufnahmedatum/-zeit aus Bildbytes (JPEG, PNG mit EXIF, …). EXIF enthält keine Zeitzone; wir interpretieren die Wandzeit in PHOTO_EXIF_TIMEZONE (Standard Europe/Berlin) und speichern als TIMESTAMPTZ (UTC in PostgreSQL). """ from __future__ import annotations import os from datetime import datetime, timezone from io import BytesIO from typing import Optional from zoneinfo import ZoneInfo from PIL import Image EXIF_DATETIME_FMT = "%Y:%m:%d %H:%M:%S" _EXIF_IFD = 0x8769 _EXIF_DATETIME_TAGS = (36867, 36868) # DateTimeOriginal, DateTimeDigitized _TAG_DATETIME_MAIN = 306 def extract_taken_at_from_image_bytes(raw: bytes) -> Optional[datetime]: """ Liest DateTimeOriginal (o. ä.) aus EXIF und gibt ein timezone-aware datetime zurück, oder None wenn nicht ermittelbar. """ try: img = Image.open(BytesIO(raw)) except Exception: return None try: naive = _extract_exif_naive_datetime(img) finally: try: img.close() except Exception: pass if naive is None: return None tz_name = os.getenv("PHOTO_EXIF_TIMEZONE", "Europe/Berlin") try: tz = ZoneInfo(tz_name) except Exception: tz = ZoneInfo("Europe/Berlin") return naive.replace(tzinfo=tz) def _extract_exif_naive_datetime(img: Image.Image) -> Optional[datetime]: exif = img.getexif() if not exif: return None strings: list[str] = [] try: exif_ifd = exif.get_ifd(_EXIF_IFD) except Exception: exif_ifd = None if exif_ifd: for tag in _EXIF_DATETIME_TAGS: v = exif_ifd.get(tag) if isinstance(v, str) and v.strip(): strings.append(v) v = exif.get(_TAG_DATETIME_MAIN) if isinstance(v, str) and v.strip(): strings.append(v) for s in strings: dt = _parse_exif_datetime_str(s) if dt: return dt return None def _parse_exif_datetime_str(s: str) -> Optional[datetime]: s = (s or "").strip() if not s: return None try: return datetime.strptime(s, EXIF_DATETIME_FMT) except ValueError: return None def taken_at_from_file_last_modified_ms(ms_raw: Optional[str]) -> Optional[datetime]: """ Browser sendet File.lastModified (ms seit UTC-Epoch), echte Dateirevision auf der Platte. Wird als echter Zeitpunkt interpretiert und nach PHOTO_EXIF_TIMEZONE für Anzeige gelegt (konsistent zu EXIF-Wandzeit). """ if not ms_raw or not str(ms_raw).strip(): return None try: ms = int(str(ms_raw).strip()) except ValueError: return None if ms <= 0: return None instant_utc = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc) tz_name = os.getenv("PHOTO_EXIF_TIMEZONE", "Europe/Berlin") try: tz = ZoneInfo(tz_name) except Exception: tz = ZoneInfo("Europe/Berlin") return instant_utc.astimezone(tz)