Add multi-provider event discovery and improve sync UI

This commit is contained in:
ecki 2026-04-13 18:50:09 +02:00
parent 51aab152ff
commit d77d9c0c0f
13 changed files with 548 additions and 58 deletions

View File

@ -9,6 +9,8 @@ DB_NAME=eventlens
MYSQL_ROOT_PASSWORD=rootsecret
TICKETMASTER_API_KEY=
BANDSINTOWN_APP_ID=eventlens
EVENTIM_ENABLED=true
POLL_INTERVAL_HOURS=6
REMINDER_INTERVAL_HOURS=12
@ -19,4 +21,3 @@ SMTP_PASS=
SMTP_SENDER=eventlens@example.local
NOTIFICATION_EMAIL_TO=
SMTP_STARTTLS=true

View File

@ -6,7 +6,7 @@
- Watchlist fuer Kuenstler oder Events
- Regionen `hamburg` und `germany`
- Geplanter Event-Abgleich ueber die Ticketmaster Discovery API
- Geplanter Event-Abgleich ueber mehrere Quellen
- E-Mail-Benachrichtigung bei neu gefundenen Terminen
- Markierung, ob Tickets gekauft wurden
- Erinnerung etwa eine Woche vor dem Termin
@ -32,7 +32,9 @@ API-Statusinfo findest du unter `http://127.0.0.1:8000/api`.
## Wichtige Umgebungsvariablen
- `TICKETMASTER_API_KEY`: noetig fuer die externe Eventsuche
- `TICKETMASTER_API_KEY`: Ticketmaster Discovery API
- `BANDSINTOWN_APP_ID`: Bandsintown App-ID fuer Artist-Events
- `EVENTIM_ENABLED`: aktiviert den Eventim-Website-Provider
- `NOTIFICATION_EMAIL_TO`: Empfaenger fuer Benachrichtigungen
- `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`: SMTP-Zugang fuer E-Mails
@ -77,9 +79,34 @@ curl -X PATCH http://127.0.0.1:8000/events/1/purchase \
- Fuer produktiven Betrieb solltest du TLS im NGINX-Terminator aktivieren.
- Das Frontend wird direkt vom FastAPI-Container ausgeliefert, es ist kein Node- oder Build-Container noetig.
## Bekannte Betriebsfalle
Wenn MariaDB bereits mit aelteren Zugangsdaten initialisiert wurde, reicht eine Aenderung in `.env` allein nicht aus. In dem Fall bleibt das Docker-Volume bestehen und der App-User in MariaDB hat noch das alte Passwort.
Zum Angleichen auf die aktuellen Werte aus `.env`:
```bash
cd /opt/eventlens
sudo bash scripts/fix-db-user.sh
```
Wenn dir die Datenbank egal ist und du komplett frisch starten willst:
```bash
cd /opt/eventlens
sudo docker compose down -v
sudo docker compose up -d --build
```
## Naechste sinnvolle Ausbaustufen
- Web-Frontend fuer Watchlist und Events
- Weitere Datenquellen neben Ticketmaster
- Telegram oder Push-Benachrichtigungen
- Nutzerverwaltung
## Provider-Hinweise
- Ticketmaster nutzt die offizielle Discovery API.
- Bandsintown nutzt die offizielle Artist-Events-API und arbeitet deshalb vor allem fuer Watchlist-Eintraege vom Typ `artist`.
- Eventim nutzt eine beobachtete oeffentliche JSON-Web-API von eventim.de. Das ist keine offiziell dokumentierte Developer-API, aber stabiler als HTML-Scraping. Aenderungen an diesem Web-Endpoint koennen trotzdem Anpassungen noetig machen.

View File

@ -12,6 +12,8 @@ class Settings:
db_name = os.getenv("DB_NAME", "eventlens")
ticketmaster_api_key = os.getenv("TICKETMASTER_API_KEY", "")
bandsintown_app_id = os.getenv("BANDSINTOWN_APP_ID", "eventlens")
eventim_enabled = os.getenv("EVENTIM_ENABLED", "true").lower() == "true"
poll_interval_hours = int(os.getenv("POLL_INTERVAL_HOURS", "6"))
reminder_interval_hours = int(os.getenv("REMINDER_INTERVAL_HOURS", "12"))
@ -25,4 +27,3 @@ class Settings:
settings = Settings()

View File

@ -1,12 +1,16 @@
from sqlalchemy import create_engine
from sqlalchemy import URL, create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from app.config import settings
DATABASE_URL = (
f"mysql+pymysql://{settings.db_user}:{settings.db_password}"
f"@{settings.db_host}:{settings.db_port}/{settings.db_name}"
DATABASE_URL = URL.create(
"mysql+pymysql",
username=settings.db_user,
password=settings.db_password,
host=settings.db_host,
port=int(settings.db_port),
database=settings.db_name,
)
engine = create_engine(
@ -24,4 +28,3 @@ def get_db():
yield db
finally:
db.close()

View File

@ -45,6 +45,12 @@
<span class="stat-label">Letzte Aktion</span>
<strong id="sync-status">Noch kein Sync ausgefuehrt</strong>
</div>
<div class="stat-card wide">
<span class="stat-label">Quellen</span>
<div id="provider-summary" class="provider-summary">
<span class="provider-chip">Noch keine Daten</span>
</div>
</div>
</aside>
</header>
@ -104,6 +110,12 @@
<option value="ticketed">Nur mit Ticket</option>
<option value="open">Nur offen</option>
</select>
<select id="provider-filter">
<option value="all">Alle Quellen</option>
<option value="ticketmaster">Ticketmaster</option>
<option value="bandsintown">Bandsintown</option>
<option value="eventim">Eventim</option>
</select>
</div>
</div>

View File

@ -9,8 +9,10 @@ const eventsEl = document.querySelector("#events");
const notificationsEl = document.querySelector("#notifications");
const watchForm = document.querySelector("#watch-form");
const eventFilter = document.querySelector("#event-filter");
const providerFilter = document.querySelector("#provider-filter");
const syncButton = document.querySelector("#sync-button");
const toastEl = document.querySelector("#toast");
const providerSummaryEl = document.querySelector("#provider-summary");
function showToast(message) {
toastEl.textContent = message;
@ -79,6 +81,51 @@ function renderStats() {
document.querySelector("#ticket-count").textContent = state.events.filter(
(event) => event.is_ticket_purchased
).length;
renderProviderSummary();
}
function prettifyProviderName(value) {
const names = {
ticketmaster: "Ticketmaster",
bandsintown: "Bandsintown",
eventim: "Eventim",
};
return names[value] || value;
}
function getProviderClass(value) {
const classes = {
ticketmaster: "provider-ticketmaster",
bandsintown: "provider-bandsintown",
eventim: "provider-eventim",
};
return classes[value] || "";
}
function renderProviderSummary() {
if (!state.events.length) {
providerSummaryEl.innerHTML = '<span class="provider-chip">Noch keine Daten</span>';
return;
}
const counts = state.events.reduce((accumulator, event) => {
const key = event.source || "unknown";
accumulator[key] = (accumulator[key] || 0) + 1;
return accumulator;
}, {});
providerSummaryEl.innerHTML = Object.entries(counts)
.sort((left, right) => right[1] - left[1])
.map(
([provider, count]) => `
<span class="provider-chip ${getProviderClass(provider)}">
${escapeHtml(prettifyProviderName(provider))}: ${count}
</span>
`
)
.join("");
}
function renderWatchItems() {
@ -126,13 +173,21 @@ function getWatchNameById(id) {
function renderEvents() {
const filterValue = eventFilter.value;
const providerValue = providerFilter.value;
const filteredEvents = state.events.filter((event) => {
if (filterValue === "ticketed") {
return event.is_ticket_purchased;
if (!event.is_ticket_purchased) {
return false;
}
}
if (filterValue === "open") {
return !event.is_ticket_purchased;
if (filterValue === "open" && event.is_ticket_purchased) {
return false;
}
if (providerValue !== "all" && event.source !== providerValue) {
return false;
}
return true;
});
@ -153,6 +208,9 @@ function renderEvents() {
<div class="pill-row">
<span class="pill">${escapeHtml(getWatchNameById(event.watch_item_id))}</span>
<span class="pill">${escapeHtml(event.city || "ohne Stadt")}</span>
<span class="pill ${getProviderClass(event.source)}">
${escapeHtml(prettifyProviderName(event.source))}
</span>
<span class="pill ${event.is_ticket_purchased ? "success" : "warning"}">
${event.is_ticket_purchased ? "Ticket markiert" : "ohne Ticket"}
</span>
@ -291,6 +349,7 @@ syncButton.addEventListener("click", async () => {
});
eventFilter.addEventListener("change", renderEvents);
providerFilter.addEventListener("change", renderEvents);
document.addEventListener("click", async (event) => {
const button = event.target.closest("button[data-action]");

View File

@ -143,6 +143,12 @@ h1 {
line-height: 1.5;
}
.provider-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@ -324,6 +330,33 @@ button:hover,
color: var(--danger);
}
.provider-chip {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: rgba(46, 39, 30, 0.08);
color: var(--text);
font-family: var(--mono);
font-size: 0.78rem;
}
.provider-ticketmaster {
background: rgba(36, 94, 63, 0.12);
color: var(--success);
}
.provider-bandsintown {
background: rgba(194, 77, 44, 0.12);
color: var(--primary-dark);
}
.provider-eventim {
background: rgba(86, 60, 153, 0.14);
color: #4f3790;
}
.muted {
color: var(--muted);
}

View File

@ -0,0 +1,85 @@
from datetime import datetime
from urllib.parse import quote
import requests
from app.config import settings
from app.models import RegionScope, WatchType
class BandsintownProvider:
base_url = "https://rest.bandsintown.com"
source_name = "bandsintown"
def is_configured(self) -> bool:
return bool(settings.bandsintown_app_id)
def search_events(
self,
term: str,
watch_type: WatchType,
region_scope: RegionScope,
) -> list[dict]:
if not self.is_configured():
return []
if watch_type != WatchType.artist:
return []
artist_name = quote(term, safe="")
url = f"{self.base_url}/artists/{artist_name}/events"
response = requests.get(
url,
params={"app_id": settings.bandsintown_app_id, "date": "upcoming"},
timeout=30,
)
if response.status_code == 404:
return []
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
return []
results: list[dict] = []
for item in payload:
venue = item.get("venue") or {}
city = venue.get("city")
country = venue.get("country")
if region_scope == RegionScope.hamburg and (city or "").casefold() != "hamburg":
continue
if region_scope == RegionScope.germany and (country or "").casefold() != "germany":
continue
datetime_value = item.get("datetime")
event_date = None
if datetime_value:
try:
event_date = datetime.fromisoformat(
datetime_value.replace("Z", "+00:00")
).replace(tzinfo=None)
except ValueError:
event_date = None
offers = item.get("offers") or []
ticket_url = next((offer.get("url") for offer in offers if offer.get("url")), None)
results.append(
{
"external_id": str(item.get("id")),
"title": item.get("title") or f"{term} live",
"matched_term": term,
"venue_name": venue.get("name"),
"city": city,
"country_code": "DE" if country == "Germany" else country,
"event_date": event_date,
"ticket_url": ticket_url or item.get("url"),
"image_url": item.get("artist", {}).get("thumb_url"),
"raw_payload": item,
}
)
return results

View File

@ -0,0 +1,115 @@
from datetime import datetime
import requests
from app.config import settings
from app.models import RegionScope, WatchType
class EventimProvider:
base_url = "https://public-api.eventim.com/websearch/search/api/exploration/v1/products"
source_name = "eventim"
def is_configured(self) -> bool:
return settings.eventim_enabled
def search_events(
self,
term: str,
watch_type: WatchType,
region_scope: RegionScope,
) -> list[dict]:
if not self.is_configured():
return []
params = {
"webId": "web__eventim-de",
"language": "de",
"page": 1,
"sort": "DateAsc",
"top": 50,
"search_term": term,
}
if region_scope == RegionScope.hamburg:
params["city_names"] = "Hamburg"
response = requests.get(
self.base_url,
params=params,
headers={
"Accept": "application/json",
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
),
},
timeout=30,
)
response.raise_for_status()
payload = response.json()
products = payload.get("products") or []
results: list[dict] = []
normalized_term = term.casefold()
for product in products:
title = product.get("name") or ""
attractions = product.get("attractions") or []
attraction_names = [entry.get("name", "") for entry in attractions]
if watch_type == WatchType.artist:
haystack = " ".join(attraction_names + [title]).casefold()
if normalized_term not in haystack:
continue
elif normalized_term not in title.casefold():
continue
live_data = product.get("typeAttributes", {}).get("liveEntertainment", {})
location = live_data.get("location") or {}
city = location.get("city")
if region_scope == RegionScope.hamburg and (city or "").casefold() != "hamburg":
continue
event_date = None
start_date = live_data.get("startDate")
if start_date:
try:
event_date = datetime.fromisoformat(
start_date.replace("Z", "+00:00")
).replace(tzinfo=None)
except ValueError:
event_date = None
url = self._build_url(product)
country_code = "DE" if region_scope == RegionScope.germany or city else None
results.append(
{
"external_id": str(product.get("productId") or url or title),
"title": title,
"matched_term": term,
"venue_name": location.get("name"),
"city": city,
"country_code": country_code,
"event_date": event_date,
"ticket_url": url,
"image_url": None,
"raw_payload": product,
}
)
return results
def _build_url(self, product: dict) -> str | None:
direct_link = product.get("link")
if direct_link:
return direct_link
url = product.get("url") or {}
domain = url.get("domain")
path = url.get("path")
if domain and path:
return f"{domain}{path}"
return None

View File

@ -0,0 +1,12 @@
from app.providers.bandsintown import BandsintownProvider
from app.providers.eventim import EventimProvider
from app.providers.ticketmaster import TicketmasterProvider
def get_providers():
return [
TicketmasterProvider(),
BandsintownProvider(),
EventimProvider(),
]

View File

@ -1,20 +1,47 @@
from datetime import datetime, timedelta
import re
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.models import NotificationStatus, NotificationType, TrackedEvent, WatchItem
from app.notifications import send_email_notification
from app.providers.ticketmaster import TicketmasterProvider
from app.providers.registry import get_providers
from app.schemas import SyncResult
PROVIDER_PRIORITY = {
"ticketmaster": 3,
"eventim": 2,
"bandsintown": 1,
}
def list_watch_items(db: Session) -> list[WatchItem]:
return list(db.scalars(select(WatchItem).order_by(WatchItem.name)))
def list_events(db: Session) -> list[TrackedEvent]:
return list(db.scalars(select(TrackedEvent).order_by(desc(TrackedEvent.event_date))))
events = list(db.scalars(select(TrackedEvent).order_by(desc(TrackedEvent.event_date))))
deduped: list[TrackedEvent] = []
for event in events:
duplicate_index = next(
(
index
for index, existing in enumerate(deduped)
if events_are_equivalent(existing, event)
),
None,
)
if duplicate_index is None:
deduped.append(event)
continue
if is_preferred_event(event, deduped[duplicate_index]):
deduped[duplicate_index] = event
return deduped
def list_notifications(db: Session):
@ -23,6 +50,72 @@ def list_notifications(db: Session):
return list(db.scalars(select(NotificationLog).order_by(desc(NotificationLog.created_at))))
def normalize_event_text(value: str | None) -> str:
if not value:
return ""
cleaned = re.sub(r"[^a-z0-9]+", " ", value.casefold())
return " ".join(cleaned.split())
def titles_match(left: str | None, right: str | None) -> bool:
normalized_left = normalize_event_text(left)
normalized_right = normalize_event_text(right)
if not normalized_left or not normalized_right:
return False
return (
normalized_left == normalized_right
or normalized_left in normalized_right
or normalized_right in normalized_left
)
def get_event_date_key(value: datetime | None):
return value.date() if value else None
def events_are_equivalent(left: TrackedEvent, right: TrackedEvent) -> bool:
if left.watch_item_id != right.watch_item_id:
return False
if get_event_date_key(left.event_date) != get_event_date_key(right.event_date):
return False
left_city = normalize_event_text(left.city)
right_city = normalize_event_text(right.city)
if left_city and right_city and left_city != right_city:
return False
title_matches = titles_match(left.title, right.title)
venue_matches = titles_match(left.venue_name, right.venue_name)
return title_matches or venue_matches
def is_preferred_event(candidate: TrackedEvent, current: TrackedEvent) -> bool:
candidate_score = (
1 if candidate.is_ticket_purchased else 0,
PROVIDER_PRIORITY.get(candidate.source, 0),
1 if candidate.ticket_url else 0,
candidate.last_seen_at or datetime.min,
)
current_score = (
1 if current.is_ticket_purchased else 0,
PROVIDER_PRIORITY.get(current.source, 0),
1 if current.ticket_url else 0,
current.last_seen_at or datetime.min,
)
return candidate_score > current_score
def has_equivalent_existing_event(db: Session, tracked_event: TrackedEvent) -> bool:
stmt = select(TrackedEvent).where(
TrackedEvent.watch_item_id == tracked_event.watch_item_id,
TrackedEvent.id != tracked_event.id,
)
existing_events = list(db.scalars(stmt))
return any(events_are_equivalent(tracked_event, existing) for existing in existing_events)
def upsert_event(
db: Session,
watch_item: WatchItem,
@ -53,6 +146,7 @@ def upsert_event(
raw_payload=event_data.get("raw_payload"),
)
db.add(tracked_event)
db.flush()
else:
tracked_event.title = event_data["title"]
tracked_event.matched_term = event_data["matched_term"]
@ -69,7 +163,7 @@ def upsert_event(
def run_sync(db: Session) -> SyncResult:
provider = TicketmasterProvider()
providers = get_providers()
active_items = list(
db.scalars(select(WatchItem).where(WatchItem.is_active.is_(True)).order_by(WatchItem.name))
)
@ -80,50 +174,56 @@ def run_sync(db: Session) -> SyncResult:
notifications_skipped = 0
for watch_item in active_items:
try:
events = provider.search_events(
term=watch_item.name,
watch_type=watch_item.watch_type,
region_scope=watch_item.region_scope,
)
except Exception:
db.rollback()
continue
for event_data in events:
tracked_event, is_new = upsert_event(
db=db,
watch_item=watch_item,
provider_name=provider.source_name,
event_data=event_data,
)
if is_new:
new_events += 1
else:
updated_events += 1
if is_new and tracked_event.discovery_notified_at is None:
status = send_email_notification(
db=db,
tracked_event=tracked_event,
notification_type=NotificationType.discovery,
subject=f"Neuer Termin fuer {watch_item.name}",
body=(
f"Es wurde ein neuer Termin fuer '{watch_item.name}' gefunden.\n\n"
f"Titel: {tracked_event.title}\n"
f"Ort: {tracked_event.venue_name or 'unbekannt'}\n"
f"Stadt: {tracked_event.city or 'unbekannt'}\n"
f"Datum: {tracked_event.event_date or 'unbekannt'}\n"
f"Tickets: {tracked_event.ticket_url or 'keine URL'}\n"
),
for provider in providers:
try:
events = provider.search_events(
term=watch_item.name,
watch_type=watch_item.watch_type,
region_scope=watch_item.region_scope,
)
except Exception:
db.rollback()
continue
for event_data in events:
tracked_event, is_new = upsert_event(
db=db,
watch_item=watch_item,
provider_name=provider.source_name,
event_data=event_data,
)
if status == NotificationStatus.sent:
tracked_event.discovery_notified_at = datetime.utcnow()
notifications_sent += 1
else:
notifications_skipped += 1
db.commit()
if is_new:
new_events += 1
else:
updated_events += 1
should_notify = (
is_new
and tracked_event.discovery_notified_at is None
and not has_equivalent_existing_event(db, tracked_event)
)
if should_notify:
status = send_email_notification(
db=db,
tracked_event=tracked_event,
notification_type=NotificationType.discovery,
subject=f"Neuer Termin fuer {watch_item.name}",
body=(
f"Es wurde ein neuer Termin fuer '{watch_item.name}' gefunden.\n\n"
f"Quelle: {tracked_event.source}\n"
f"Titel: {tracked_event.title}\n"
f"Ort: {tracked_event.venue_name or 'unbekannt'}\n"
f"Stadt: {tracked_event.city or 'unbekannt'}\n"
f"Datum: {tracked_event.event_date or 'unbekannt'}\n"
f"Tickets: {tracked_event.ticket_url or 'keine URL'}\n"
),
)
if status == NotificationStatus.sent:
tracked_event.discovery_notified_at = datetime.utcnow()
notifications_sent += 1
else:
notifications_skipped += 1
db.commit()
return SyncResult(
scanned_watch_items=len(active_items),

View File

@ -3,6 +3,8 @@ services:
image: mariadb:11
container_name: eventlens_db
restart: unless-stopped
env_file:
- .env
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootsecret}
MYSQL_DATABASE: ${DB_NAME:-eventlens}
@ -26,7 +28,7 @@ services:
env_file:
- .env
ports:
- "127.0.0.1:8000:8000"
- "127.0.0.1:8001:8000"
volumes:
db_data:

40
scripts/fix-db-user.sh Normal file
View File

@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
if [[ ! -f "${PROJECT_DIR}/.env" ]]; then
echo ".env wurde in ${PROJECT_DIR} nicht gefunden." >&2
exit 1
fi
read_env_value() {
local key="$1"
local line
line="$(grep -E "^${key}=" "${PROJECT_DIR}/.env" | tail -n 1 || true)"
if [[ -z "${line}" ]]; then
echo "Wert ${key} wurde in ${PROJECT_DIR}/.env nicht gefunden." >&2
exit 1
fi
printf '%s' "${line#*=}"
}
DB_NAME="$(read_env_value DB_NAME)"
DB_USER="$(read_env_value DB_USER)"
DB_PASSWORD="$(read_env_value DB_PASSWORD)"
MYSQL_ROOT_PASSWORD="$(read_env_value MYSQL_ROOT_PASSWORD)"
docker compose -f "${PROJECT_DIR}/docker-compose.yml" exec -T db \
mariadb -uroot "-p${MYSQL_ROOT_PASSWORD}" <<SQL
CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\`;
CREATE USER IF NOT EXISTS '${DB_USER}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
ALTER USER '${DB_USER}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
GRANT ALL PRIVILEGES ON \`${DB_NAME}\`.* TO '${DB_USER}'@'%';
FLUSH PRIVILEGES;
SQL
echo "MariaDB-App-User '${DB_USER}' wurde fuer '${DB_NAME}' aktualisiert."