diff --git a/.env.example b/.env.example index 0b82824..03c29ec 100644 --- a/.env.example +++ b/.env.example @@ -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 - diff --git a/README.md b/README.md index 8cec6d4..8b58fa6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/app/config.py b/backend/app/config.py index 2c05f1f..018590d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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() - diff --git a/backend/app/database.py b/backend/app/database.py index 87dc811..8cbb1b4 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -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() - diff --git a/backend/app/frontend/index.html b/backend/app/frontend/index.html index b1dbeb3..63a60ef 100644 --- a/backend/app/frontend/index.html +++ b/backend/app/frontend/index.html @@ -45,6 +45,12 @@ Letzte Aktion Noch kein Sync ausgefuehrt +
+ Quellen +
+ Noch keine Daten +
+
@@ -104,6 +110,12 @@ + diff --git a/backend/app/frontend/static/app.js b/backend/app/frontend/static/app.js index 439723f..b065306 100644 --- a/backend/app/frontend/static/app.js +++ b/backend/app/frontend/static/app.js @@ -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 = 'Noch keine Daten'; + 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]) => ` + + ${escapeHtml(prettifyProviderName(provider))}: ${count} + + ` + ) + .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() {
${escapeHtml(getWatchNameById(event.watch_item_id))} ${escapeHtml(event.city || "ohne Stadt")} + + ${escapeHtml(prettifyProviderName(event.source))} + ${event.is_ticket_purchased ? "Ticket markiert" : "ohne Ticket"} @@ -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]"); diff --git a/backend/app/frontend/static/styles.css b/backend/app/frontend/static/styles.css index b194144..9fcccfb 100644 --- a/backend/app/frontend/static/styles.css +++ b/backend/app/frontend/static/styles.css @@ -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); } diff --git a/backend/app/providers/bandsintown.py b/backend/app/providers/bandsintown.py new file mode 100644 index 0000000..7bd3ebc --- /dev/null +++ b/backend/app/providers/bandsintown.py @@ -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 + diff --git a/backend/app/providers/eventim.py b/backend/app/providers/eventim.py new file mode 100644 index 0000000..6715368 --- /dev/null +++ b/backend/app/providers/eventim.py @@ -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 diff --git a/backend/app/providers/registry.py b/backend/app/providers/registry.py new file mode 100644 index 0000000..685bf9a --- /dev/null +++ b/backend/app/providers/registry.py @@ -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(), + ] + diff --git a/backend/app/services.py b/backend/app/services.py index 6adccaf..fd09663 100644 --- a/backend/app/services.py +++ b/backend/app/services.py @@ -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), diff --git a/docker-compose.yml b/docker-compose.yml index 1871469..03aa307 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/scripts/fix-db-user.sh b/scripts/fix-db-user.sh new file mode 100644 index 0000000..602e2b3 --- /dev/null +++ b/scripts/fix-db-user.sh @@ -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}" <