Add multi-provider event discovery and improve sync UI
This commit is contained in:
+2
-1
@@ -9,6 +9,8 @@ DB_NAME=eventlens
|
|||||||
MYSQL_ROOT_PASSWORD=rootsecret
|
MYSQL_ROOT_PASSWORD=rootsecret
|
||||||
|
|
||||||
TICKETMASTER_API_KEY=
|
TICKETMASTER_API_KEY=
|
||||||
|
BANDSINTOWN_APP_ID=eventlens
|
||||||
|
EVENTIM_ENABLED=true
|
||||||
POLL_INTERVAL_HOURS=6
|
POLL_INTERVAL_HOURS=6
|
||||||
REMINDER_INTERVAL_HOURS=12
|
REMINDER_INTERVAL_HOURS=12
|
||||||
|
|
||||||
@@ -19,4 +21,3 @@ SMTP_PASS=
|
|||||||
SMTP_SENDER=eventlens@example.local
|
SMTP_SENDER=eventlens@example.local
|
||||||
NOTIFICATION_EMAIL_TO=
|
NOTIFICATION_EMAIL_TO=
|
||||||
SMTP_STARTTLS=true
|
SMTP_STARTTLS=true
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- Watchlist fuer Kuenstler oder Events
|
- Watchlist fuer Kuenstler oder Events
|
||||||
- Regionen `hamburg` und `germany`
|
- 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
|
- E-Mail-Benachrichtigung bei neu gefundenen Terminen
|
||||||
- Markierung, ob Tickets gekauft wurden
|
- Markierung, ob Tickets gekauft wurden
|
||||||
- Erinnerung etwa eine Woche vor dem Termin
|
- 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
|
## 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
|
- `NOTIFICATION_EMAIL_TO`: Empfaenger fuer Benachrichtigungen
|
||||||
- `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`: SMTP-Zugang fuer E-Mails
|
- `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.
|
- 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.
|
- 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
|
## Naechste sinnvolle Ausbaustufen
|
||||||
|
|
||||||
- Web-Frontend fuer Watchlist und Events
|
- Web-Frontend fuer Watchlist und Events
|
||||||
- Weitere Datenquellen neben Ticketmaster
|
- Weitere Datenquellen neben Ticketmaster
|
||||||
- Telegram oder Push-Benachrichtigungen
|
- Telegram oder Push-Benachrichtigungen
|
||||||
- Nutzerverwaltung
|
- 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.
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class Settings:
|
|||||||
db_name = os.getenv("DB_NAME", "eventlens")
|
db_name = os.getenv("DB_NAME", "eventlens")
|
||||||
|
|
||||||
ticketmaster_api_key = os.getenv("TICKETMASTER_API_KEY", "")
|
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"))
|
poll_interval_hours = int(os.getenv("POLL_INTERVAL_HOURS", "6"))
|
||||||
reminder_interval_hours = int(os.getenv("REMINDER_INTERVAL_HOURS", "12"))
|
reminder_interval_hours = int(os.getenv("REMINDER_INTERVAL_HOURS", "12"))
|
||||||
|
|
||||||
@@ -25,4 +27,3 @@ class Settings:
|
|||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import URL, create_engine
|
||||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
DATABASE_URL = (
|
DATABASE_URL = URL.create(
|
||||||
f"mysql+pymysql://{settings.db_user}:{settings.db_password}"
|
"mysql+pymysql",
|
||||||
f"@{settings.db_host}:{settings.db_port}/{settings.db_name}"
|
username=settings.db_user,
|
||||||
|
password=settings.db_password,
|
||||||
|
host=settings.db_host,
|
||||||
|
port=int(settings.db_port),
|
||||||
|
database=settings.db_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
@@ -24,4 +28,3 @@ def get_db():
|
|||||||
yield db
|
yield db
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,12 @@
|
|||||||
<span class="stat-label">Letzte Aktion</span>
|
<span class="stat-label">Letzte Aktion</span>
|
||||||
<strong id="sync-status">Noch kein Sync ausgefuehrt</strong>
|
<strong id="sync-status">Noch kein Sync ausgefuehrt</strong>
|
||||||
</div>
|
</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>
|
</aside>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -104,6 +110,12 @@
|
|||||||
<option value="ticketed">Nur mit Ticket</option>
|
<option value="ticketed">Nur mit Ticket</option>
|
||||||
<option value="open">Nur offen</option>
|
<option value="open">Nur offen</option>
|
||||||
</select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ const eventsEl = document.querySelector("#events");
|
|||||||
const notificationsEl = document.querySelector("#notifications");
|
const notificationsEl = document.querySelector("#notifications");
|
||||||
const watchForm = document.querySelector("#watch-form");
|
const watchForm = document.querySelector("#watch-form");
|
||||||
const eventFilter = document.querySelector("#event-filter");
|
const eventFilter = document.querySelector("#event-filter");
|
||||||
|
const providerFilter = document.querySelector("#provider-filter");
|
||||||
const syncButton = document.querySelector("#sync-button");
|
const syncButton = document.querySelector("#sync-button");
|
||||||
const toastEl = document.querySelector("#toast");
|
const toastEl = document.querySelector("#toast");
|
||||||
|
const providerSummaryEl = document.querySelector("#provider-summary");
|
||||||
|
|
||||||
function showToast(message) {
|
function showToast(message) {
|
||||||
toastEl.textContent = message;
|
toastEl.textContent = message;
|
||||||
@@ -79,6 +81,51 @@ function renderStats() {
|
|||||||
document.querySelector("#ticket-count").textContent = state.events.filter(
|
document.querySelector("#ticket-count").textContent = state.events.filter(
|
||||||
(event) => event.is_ticket_purchased
|
(event) => event.is_ticket_purchased
|
||||||
).length;
|
).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() {
|
function renderWatchItems() {
|
||||||
@@ -126,13 +173,21 @@ function getWatchNameById(id) {
|
|||||||
|
|
||||||
function renderEvents() {
|
function renderEvents() {
|
||||||
const filterValue = eventFilter.value;
|
const filterValue = eventFilter.value;
|
||||||
|
const providerValue = providerFilter.value;
|
||||||
const filteredEvents = state.events.filter((event) => {
|
const filteredEvents = state.events.filter((event) => {
|
||||||
if (filterValue === "ticketed") {
|
if (filterValue === "ticketed") {
|
||||||
return event.is_ticket_purchased;
|
if (!event.is_ticket_purchased) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (filterValue === "open") {
|
if (filterValue === "open" && event.is_ticket_purchased) {
|
||||||
return !event.is_ticket_purchased;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (providerValue !== "all" && event.source !== providerValue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,6 +208,9 @@ function renderEvents() {
|
|||||||
<div class="pill-row">
|
<div class="pill-row">
|
||||||
<span class="pill">${escapeHtml(getWatchNameById(event.watch_item_id))}</span>
|
<span class="pill">${escapeHtml(getWatchNameById(event.watch_item_id))}</span>
|
||||||
<span class="pill">${escapeHtml(event.city || "ohne Stadt")}</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"}">
|
<span class="pill ${event.is_ticket_purchased ? "success" : "warning"}">
|
||||||
${event.is_ticket_purchased ? "Ticket markiert" : "ohne Ticket"}
|
${event.is_ticket_purchased ? "Ticket markiert" : "ohne Ticket"}
|
||||||
</span>
|
</span>
|
||||||
@@ -291,6 +349,7 @@ syncButton.addEventListener("click", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
eventFilter.addEventListener("change", renderEvents);
|
eventFilter.addEventListener("change", renderEvents);
|
||||||
|
providerFilter.addEventListener("change", renderEvents);
|
||||||
|
|
||||||
document.addEventListener("click", async (event) => {
|
document.addEventListener("click", async (event) => {
|
||||||
const button = event.target.closest("button[data-action]");
|
const button = event.target.closest("button[data-action]");
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ h1 {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.provider-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -324,6 +330,33 @@ button:hover,
|
|||||||
color: var(--danger);
|
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 {
|
.muted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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(),
|
||||||
|
]
|
||||||
|
|
||||||
+145
-45
@@ -1,20 +1,47 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
import re
|
||||||
|
|
||||||
from sqlalchemy import desc, select
|
from sqlalchemy import desc, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import NotificationStatus, NotificationType, TrackedEvent, WatchItem
|
from app.models import NotificationStatus, NotificationType, TrackedEvent, WatchItem
|
||||||
from app.notifications import send_email_notification
|
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
|
from app.schemas import SyncResult
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDER_PRIORITY = {
|
||||||
|
"ticketmaster": 3,
|
||||||
|
"eventim": 2,
|
||||||
|
"bandsintown": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_watch_items(db: Session) -> list[WatchItem]:
|
def list_watch_items(db: Session) -> list[WatchItem]:
|
||||||
return list(db.scalars(select(WatchItem).order_by(WatchItem.name)))
|
return list(db.scalars(select(WatchItem).order_by(WatchItem.name)))
|
||||||
|
|
||||||
|
|
||||||
def list_events(db: Session) -> list[TrackedEvent]:
|
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):
|
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))))
|
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(
|
def upsert_event(
|
||||||
db: Session,
|
db: Session,
|
||||||
watch_item: WatchItem,
|
watch_item: WatchItem,
|
||||||
@@ -53,6 +146,7 @@ def upsert_event(
|
|||||||
raw_payload=event_data.get("raw_payload"),
|
raw_payload=event_data.get("raw_payload"),
|
||||||
)
|
)
|
||||||
db.add(tracked_event)
|
db.add(tracked_event)
|
||||||
|
db.flush()
|
||||||
else:
|
else:
|
||||||
tracked_event.title = event_data["title"]
|
tracked_event.title = event_data["title"]
|
||||||
tracked_event.matched_term = event_data["matched_term"]
|
tracked_event.matched_term = event_data["matched_term"]
|
||||||
@@ -69,7 +163,7 @@ def upsert_event(
|
|||||||
|
|
||||||
|
|
||||||
def run_sync(db: Session) -> SyncResult:
|
def run_sync(db: Session) -> SyncResult:
|
||||||
provider = TicketmasterProvider()
|
providers = get_providers()
|
||||||
active_items = list(
|
active_items = list(
|
||||||
db.scalars(select(WatchItem).where(WatchItem.is_active.is_(True)).order_by(WatchItem.name))
|
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
|
notifications_skipped = 0
|
||||||
|
|
||||||
for watch_item in active_items:
|
for watch_item in active_items:
|
||||||
try:
|
for provider in providers:
|
||||||
events = provider.search_events(
|
try:
|
||||||
term=watch_item.name,
|
events = provider.search_events(
|
||||||
watch_type=watch_item.watch_type,
|
term=watch_item.name,
|
||||||
region_scope=watch_item.region_scope,
|
watch_type=watch_item.watch_type,
|
||||||
)
|
region_scope=watch_item.region_scope,
|
||||||
except Exception:
|
)
|
||||||
db.rollback()
|
except Exception:
|
||||||
continue
|
db.rollback()
|
||||||
for event_data in events:
|
continue
|
||||||
tracked_event, is_new = upsert_event(
|
for event_data in events:
|
||||||
db=db,
|
tracked_event, is_new = upsert_event(
|
||||||
watch_item=watch_item,
|
db=db,
|
||||||
provider_name=provider.source_name,
|
watch_item=watch_item,
|
||||||
event_data=event_data,
|
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"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
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(
|
return SyncResult(
|
||||||
scanned_watch_items=len(active_items),
|
scanned_watch_items=len(active_items),
|
||||||
|
|||||||
+3
-1
@@ -3,6 +3,8 @@ services:
|
|||||||
image: mariadb:11
|
image: mariadb:11
|
||||||
container_name: eventlens_db
|
container_name: eventlens_db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootsecret}
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootsecret}
|
||||||
MYSQL_DATABASE: ${DB_NAME:-eventlens}
|
MYSQL_DATABASE: ${DB_NAME:-eventlens}
|
||||||
@@ -26,7 +28,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8000:8000"
|
- "127.0.0.1:8001:8000"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
|
|||||||
@@ -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."
|
||||||
Reference in New Issue
Block a user