mitai-jinkendo/backend/photo_exif.py

104 lines
2.9 KiB
Python
Raw Normal View History

"""
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)