- 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.
104 lines
2.9 KiB
Python
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)
|