mitai-jinkendo/backend/photo_exif.py
Lars 0035d08149
All checks were successful
Deploy Development / deploy (push) Successful in 1m4s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
feat: enhance photo upload and management features
- Added `taken_at` timestamp to the photos table for improved photo metadata.
- Updated the photo upload API to support optional EXIF data extraction and file last modified timestamp.
- Enhanced the photo upload process to allow skipping EXIF data, defaulting to today's date if no other date is provided.
- Improved the photo display in various components to utilize a unified caption format.
- Refactored photo sorting and grouping logic for better organization in the UI.
2026-04-19 10:13:22 +02:00

104 lines
2.9 KiB
Python

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