Files
calender_sync/sync.py
T

257 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""
Google Calendar (privater ICS-Feed) -> MariaDB Sync
Homelab: laeuft als Docker Container, pollt periodisch, expandiert RRULE/EXDATE/RECURRENCE-ID
und schreibt Einzel-Instanzen in eine eigene MariaDB-Datenbank (calendar_sync).
"""
import os
import sys
import time
import logging
import hashlib
from datetime import datetime, timedelta, timezone
import requests
import icalendar
import recurring_ical_events
import mysql.connector
from mysql.connector import Error as MySQLError
from api.database import get_connection
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO"),
format="%(asctime)s [%(levelname)s] %(message)s",
stream=sys.stdout,
)
log = logging.getLogger("calendar-sync")
ICS_URL = os.environ["ICS_URL"]
DB_HOST = os.environ.get("DB_HOST", "mariadb.fritz.box")
DB_PORT = int(os.environ.get("DB_PORT", "3306"))
DB_NAME = os.environ.get("DB_NAME", "calendar_sync")
DB_USER = os.environ["DB_USER"]
DB_PASSWORD = os.environ["DB_PASSWORD"]
SYNC_INTERVAL_MINUTES = int(os.environ.get("SYNC_INTERVAL_MINUTES", "15"))
WINDOW_PAST_DAYS = int(os.environ.get("WINDOW_PAST_DAYS", "90"))
WINDOW_FUTURE_DAYS = int(os.environ.get("WINDOW_FUTURE_DAYS", "365"))
CALENDAR_LABEL = os.environ.get("CALENDAR_LABEL", "default")
HEALTHCHECK_URL = os.environ.get("HEALTHCHECK_URL", "")
DB_BOOTSTRAP = os.environ.get("DB_BOOTSTRAP", "false").lower() == "true"
DB_ROOT_USER = os.environ.get("DB_ROOT_USER")
DB_ROOT_PASSWORD = os.environ.get("DB_ROOT_PASSWORD")
def bootstrap_database():
"""Legt DB_NAME und DB_USER an, falls sie noch nicht existieren.
Erfordert DB_ROOT_USER/DB_ROOT_PASSWORD mit ausreichenden Rechten
(z.B. root auf dem MariaDB LXC). Wird nur ausgefuehrt, wenn
DB_BOOTSTRAP=true gesetzt ist."""
if not DB_BOOTSTRAP:
return
if not DB_ROOT_USER or not DB_ROOT_PASSWORD:
log.warning("DB_BOOTSTRAP=true aber DB_ROOT_USER/DB_ROOT_PASSWORD fehlen, ueberspringe Bootstrap")
return
log.info("Bootstrap: pruefe/erstelle Datenbank '%s' und User '%s'", DB_NAME, DB_USER)
root_conn = mysql.connector.connect(
host=DB_HOST,
port=DB_PORT,
user=DB_ROOT_USER,
password=DB_ROOT_PASSWORD,
autocommit=True,
)
try:
cur = root_conn.cursor()
cur.execute(f"CREATE DATABASE IF NOT EXISTS `{DB_NAME}` CHARACTER SET utf8mb4")
cur.execute(
"CREATE USER IF NOT EXISTS %s@'%%' IDENTIFIED BY %s",
(DB_USER, DB_PASSWORD),
)
cur.execute(f"GRANT ALL PRIVILEGES ON `{DB_NAME}`.* TO %s@'%%'", (DB_USER,))
cur.execute("FLUSH PRIVILEGES")
cur.close()
log.info("Bootstrap abgeschlossen")
finally:
root_conn.close()
def ensure_schema(conn):
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS calendar_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
calendar_label VARCHAR(64) NOT NULL,
instance_key VARCHAR(255) NOT NULL,
uid VARCHAR(255) NOT NULL,
recurrence_id VARCHAR(64) NULL,
summary VARCHAR(512),
description TEXT,
location VARCHAR(512),
start_at DATETIME NOT NULL,
end_at DATETIME NULL,
all_day TINYINT(1) NOT NULL DEFAULT 0,
status VARCHAR(32) DEFAULT 'CONFIRMED',
deleted TINYINT(1) NOT NULL DEFAULT 0,
last_seen_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_instance (calendar_label, instance_key),
INDEX idx_start (start_at),
INDEX idx_deleted (deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""")
conn.commit()
cur.close()
def fetch_ics(url: str) -> bytes:
resp = requests.get(url, timeout=30)
resp.raise_for_status()
return resp.content
def to_naive_utc(dt) -> datetime:
"""Normalisiert date/datetime auf ein naive UTC datetime fuer MySQL DATETIME."""
if isinstance(dt, datetime):
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
return datetime(dt.year, dt.month, dt.day)
def instance_key_for(event) -> tuple[str, str, str | None]:
uid = str(event.get("UID", ""))
recurrence_id = event.get("RECURRENCE-ID")
rid_str = None
if recurrence_id is not None:
rid_dt = recurrence_id.dt
rid_str = to_naive_utc(rid_dt).isoformat()
key_source = uid + "|" + (rid_str or "")
key = hashlib.sha1(key_source.encode("utf-8")).hexdigest()
return key, uid, rid_str
def expand_events(ics_bytes: bytes, window_start: datetime, window_end: datetime):
calendar = icalendar.Calendar.from_ical(ics_bytes)
occurrences = recurring_ical_events.of(calendar).between(window_start, window_end)
return occurrences
def upsert_event(cur, calendar_label, run_ts, event):
key, uid, rid_str = instance_key_for(event)
summary = str(event.get("SUMMARY", "") or "")
description = str(event.get("DESCRIPTION", "") or "")
location = str(event.get("LOCATION", "") or "")
status = str(event.get("STATUS", "CONFIRMED") or "CONFIRMED")
dtstart = event["DTSTART"].dt
all_day = not isinstance(dtstart, datetime)
start_at = to_naive_utc(dtstart)
dtend_prop = event.get("DTEND")
end_at = to_naive_utc(dtend_prop.dt) if dtend_prop else None
cur.execute(
"""
INSERT INTO calendar_events
(calendar_label, instance_key, uid, recurrence_id, summary,
description, location, start_at, end_at, all_day, status,
deleted, last_seen_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 0, %s)
ON DUPLICATE KEY UPDATE
summary = VALUES(summary),
description = VALUES(description),
location = VALUES(location),
start_at = VALUES(start_at),
end_at = VALUES(end_at),
all_day = VALUES(all_day),
status = VALUES(status),
deleted = 0,
last_seen_at = VALUES(last_seen_at)
""",
(
calendar_label, key, uid, rid_str, summary, description,
location, start_at, end_at, all_day, status, run_ts,
),
)
def mark_missing_as_deleted(cur, calendar_label, run_ts, window_start, window_end):
cur.execute(
"""
UPDATE calendar_events
SET deleted = 1
WHERE calendar_label = %s
AND deleted = 0
AND last_seen_at < %s
AND start_at BETWEEN %s AND %s
""",
(calendar_label, run_ts, window_start, window_end),
)
return cur.rowcount
def ping_healthcheck():
if not HEALTHCHECK_URL:
return
try:
resp = requests.get(HEALTHCHECK_URL, timeout=10)
log.info("Healthcheck ping: %d", resp.status_code)
except Exception:
log.warning("Healthcheck ping fehlgeschlagen", exc_info=True)
def run_sync_once():
run_ts = datetime.now(timezone.utc).replace(tzinfo=None, microsecond=0)
window_start = run_ts - timedelta(days=WINDOW_PAST_DAYS)
window_end = run_ts + timedelta(days=WINDOW_FUTURE_DAYS)
log.info("Starte Sync fuer '%s' | Fenster %s bis %s", CALENDAR_LABEL, window_start, window_end)
ics_bytes = fetch_ics(ICS_URL)
occurrences = expand_events(ics_bytes, window_start, window_end)
log.info("ICS geladen, %d Instanzen im Fenster gefunden", len(occurrences))
conn = get_connection(autocommit=False)
try:
ensure_schema(conn)
cur = conn.cursor()
for occ in occurrences:
upsert_event(cur, CALENDAR_LABEL, run_ts, occ)
deleted_count = mark_missing_as_deleted(cur, CALENDAR_LABEL, run_ts, window_start, window_end)
conn.commit()
cur.close()
log.info("Sync fertig. %d Events als geloescht markiert.", deleted_count)
ping_healthcheck()
except MySQLError:
conn.rollback()
log.exception("DB-Fehler beim Sync, Rollback ausgefuehrt")
raise
finally:
conn.close()
def main():
bootstrap_database()
log.info(
"calendar-sync gestartet | Intervall=%smin | Fenster=-%dd/+%dd",
SYNC_INTERVAL_MINUTES, WINDOW_PAST_DAYS, WINDOW_FUTURE_DAYS,
)
while True:
try:
run_sync_once()
except Exception:
log.exception("Sync-Durchlauf fehlgeschlagen, versuche es beim naechsten Intervall erneut")
time.sleep(SYNC_INTERVAL_MINUTES * 60)
if __name__ == "__main__":
main()