Add multi-provider event discovery and improve sync UI
This commit is contained in:
parent
51aab152ff
commit
d77d9c0c0f
@ -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
|
||||
|
||||
|
||||
31
README.md
31
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.
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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]");
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
85
backend/app/providers/bandsintown.py
Normal file
85
backend/app/providers/bandsintown.py
Normal 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
|
||||
|
||||
115
backend/app/providers/eventim.py
Normal file
115
backend/app/providers/eventim.py
Normal 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
|
||||
12
backend/app/providers/registry.py
Normal file
12
backend/app/providers/registry.py
Normal 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(),
|
||||
]
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
40
scripts/fix-db-user.sh
Normal 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."
|
||||
Loading…
x
Reference in New Issue
Block a user