Jambase zugefügt

This commit is contained in:
ecki
2026-04-26 16:20:56 +02:00
parent 1014f822a1
commit 793b334974
9 changed files with 402 additions and 2 deletions
+2
View File
@@ -9,6 +9,8 @@ DB_NAME=eventlens
MYSQL_ROOT_PASSWORD=rootsecret MYSQL_ROOT_PASSWORD=rootsecret
TICKETMASTER_API_KEY= TICKETMASTER_API_KEY=
JAMBASE_API_KEY=
JAMBASE_USER_AGENT=eventlens/0.1
BANDSINTOWN_APP_ID= BANDSINTOWN_APP_ID=
EVENTIM_ENABLED=true EVENTIM_ENABLED=true
POLL_INTERVAL_HOURS=6 POLL_INTERVAL_HOURS=6
+3
View File
@@ -33,6 +33,8 @@ API-Statusinfo findest du unter `http://127.0.0.1:8001/api`.
## Wichtige Umgebungsvariablen ## Wichtige Umgebungsvariablen
- `TICKETMASTER_API_KEY`: Ticketmaster Discovery API - `TICKETMASTER_API_KEY`: Ticketmaster Discovery API
- `JAMBASE_API_KEY`: JamBase Data API Bearer Token
- `JAMBASE_USER_AGENT`: eigene App-Kennung fuer JamBase-Requests
- `BANDSINTOWN_APP_ID`: echte Bandsintown App-ID fuer Artist-Events - `BANDSINTOWN_APP_ID`: echte Bandsintown App-ID fuer Artist-Events
- `EVENTIM_ENABLED`: aktiviert den Eventim-Website-Provider - `EVENTIM_ENABLED`: aktiviert den Eventim-Website-Provider
- `EVENTLENS_AUTH_USERNAME`, `EVENTLENS_AUTH_PASSWORD`: optionaler Passwortschutz fuer Webfrontend und API - `EVENTLENS_AUTH_USERNAME`, `EVENTLENS_AUTH_PASSWORD`: optionaler Passwortschutz fuer Webfrontend und API
@@ -110,6 +112,7 @@ sudo docker compose up -d --build
## Provider-Hinweise ## Provider-Hinweise
- Ticketmaster nutzt die offizielle Discovery API. - Ticketmaster nutzt die offizielle Discovery API.
- JamBase nutzt die JamBase Data API ueber `https://api.data.jambase.com/v3/events` mit Bearer-Token und `User-Agent`.
- Bandsintown nutzt die offizielle Artist-Events-API und arbeitet deshalb vor allem fuer Watchlist-Eintraege vom Typ `artist`. - 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. - 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.
- Eventim kann serverseitig durch Eventim/Akamai geblockt werden. In dem Fall liefert `eventlens` bewusst keine falschen Treffer, sondern ueberspringt den Provider und schreibt einen Hinweis ins Backend-Log. - Eventim kann serverseitig durch Eventim/Akamai geblockt werden. In dem Fall liefert `eventlens` bewusst keine falschen Treffer, sondern ueberspringt den Provider und schreibt einen Hinweis ins Backend-Log.
+2
View File
@@ -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", "")
jambase_api_key = os.getenv("JAMBASE_API_KEY", "")
jambase_user_agent = os.getenv("JAMBASE_USER_AGENT", "eventlens/0.1")
bandsintown_app_id = os.getenv("BANDSINTOWN_APP_ID", "") bandsintown_app_id = os.getenv("BANDSINTOWN_APP_ID", "")
eventim_enabled = os.getenv("EVENTIM_ENABLED", "true").lower() == "true" 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"))
+1
View File
@@ -113,6 +113,7 @@
<select id="provider-filter"> <select id="provider-filter">
<option value="all">Alle Quellen</option> <option value="all">Alle Quellen</option>
<option value="ticketmaster">Ticketmaster</option> <option value="ticketmaster">Ticketmaster</option>
<option value="jambase">JamBase</option>
<option value="bandsintown">Bandsintown</option> <option value="bandsintown">Bandsintown</option>
<option value="eventim">Eventim</option> <option value="eventim">Eventim</option>
<option value="barclays_arena">Barclays Arena</option> <option value="barclays_arena">Barclays Arena</option>
+2
View File
@@ -142,6 +142,7 @@ function prettifyProviderName(value) {
const names = { const names = {
ticketmaster: "Ticketmaster", ticketmaster: "Ticketmaster",
jambase: "JamBase",
bandsintown: "Bandsintown", bandsintown: "Bandsintown",
eventim: "Eventim", eventim: "Eventim",
barclays_arena: "Barclays Arena", barclays_arena: "Barclays Arena",
@@ -154,6 +155,7 @@ function prettifyProviderName(value) {
function getProviderClass(value) { function getProviderClass(value) {
const classes = { const classes = {
ticketmaster: "provider-ticketmaster", ticketmaster: "provider-ticketmaster",
jambase: "provider-jambase",
bandsintown: "provider-bandsintown", bandsintown: "provider-bandsintown",
eventim: "provider-eventim", eventim: "provider-eventim",
barclays_arena: "provider-barclays-arena", barclays_arena: "provider-barclays-arena",
+5
View File
@@ -352,6 +352,11 @@ button:hover,
color: var(--primary-dark); color: var(--primary-dark);
} }
.provider-jambase {
background: rgba(17, 108, 110, 0.13);
color: #0d5f61;
}
.provider-eventim { .provider-eventim {
background: rgba(86, 60, 153, 0.14); background: rgba(86, 60, 153, 0.14);
color: #4f3790; color: #4f3790;
+369
View File
@@ -0,0 +1,369 @@
from datetime import datetime
import requests
from app.config import settings
from app.models import RegionScope, WatchType
from app.providers.utils import normalize_search_text
class JamBaseProvider:
base_url = "https://api.data.jambase.com/v3/events"
source_name = "jambase"
def is_configured(self) -> bool:
return bool(settings.jambase_api_key and settings.jambase_user_agent)
def search_events(
self,
term: str,
watch_type: WatchType,
region_scope: RegionScope,
) -> list[dict]:
self.last_status = "ok"
self.last_message = ""
if not self.is_configured():
self.last_status = "ok"
self.last_message = "JamBase ist nicht konfiguriert. Bitte JAMBASE_API_KEY setzen."
return []
headers = {
"Authorization": f"Bearer {settings.jambase_api_key}",
"Accept": "application/json",
"User-Agent": settings.jambase_user_agent,
}
payload = None
raw_count = 0
tried_params: list[str] = []
bad_request_messages: list[str] = []
for params in self._build_param_candidates(term, watch_type, region_scope):
tried_params.append(", ".join(sorted(params.keys())) or "keine")
response = requests.get(self.base_url, headers=headers, params=params, timeout=30)
if response.status_code == 401:
self.last_status = "blocked"
self.last_message = (
f"JamBase hat den API-Key fuer '{term}' nicht akzeptiert (401)."
)
return []
if response.status_code == 403:
self.last_status = "blocked"
self.last_message = (
f"JamBase verweigert Zugriff fuer '{term}' (403). "
"Pruefe Plan oder Berechtigungen."
)
return []
if response.status_code == 400:
bad_request_messages.append(
self._format_error_response(response, params)
)
continue
response.raise_for_status()
try:
payload = response.json()
except ValueError:
self.last_status = "error"
self.last_message = (
f"JamBase lieferte keine gueltige JSON-Antwort fuer '{term}' "
f"(Status {response.status_code})."
)
return []
raw_items = self._extract_items(payload)
raw_count = len(raw_items)
if raw_items:
break
if payload is None:
self.last_status = "error"
detail = bad_request_messages[0] if bad_request_messages else "Keine Detailmeldung."
self.last_message = f"JamBase akzeptierte die Anfrage fuer '{term}' nicht: {detail}"
return []
items = self._extract_items(payload)
self.last_status = "ok"
normalized_term = normalize_search_text(term)
results: list[dict] = []
seen_ids: set[str] = set()
for item in items:
title = self._get_first(item, "name", "title", "eventName") or term
artists = self._extract_artist_names(item)
haystack = normalize_search_text(" ".join([title] + artists))
if watch_type == WatchType.artist:
if normalized_term not in haystack:
continue
elif normalized_term not in normalize_search_text(title):
continue
city = self._extract_city(item)
country_code = self._extract_country_code(item)
if region_scope == RegionScope.hamburg and normalize_search_text(city) != "hamburg":
continue
if region_scope == RegionScope.germany and country_code not in {"DE", "GER", "Germany"}:
continue
event_date = self._extract_event_date(item)
if event_date and event_date.date() < datetime.utcnow().date():
continue
external_id = str(
self._get_first(item, "id", "eventId", "identifier")
or item.get("slug")
or item.get("url")
or f"jambase:{normalize_search_text(title)}:{event_date.isoformat() if event_date else 'na'}"
)
if external_id in seen_ids:
continue
seen_ids.add(external_id)
results.append(
{
"external_id": external_id,
"title": title,
"matched_term": term,
"venue_name": self._extract_venue_name(item),
"city": city,
"country_code": country_code,
"event_date": event_date,
"ticket_url": self._extract_ticket_url(item),
"image_url": self._extract_image_url(item),
"raw_payload": item,
}
)
self.last_message = (
f"JamBase lieferte {raw_count} Roh-Events fuer '{term}', "
f"davon {len(results)} passend nach Eventlens-Filtern."
)
return results
def _build_param_candidates(
self,
term: str,
watch_type: WatchType,
region_scope: RegionScope,
) -> list[dict]:
base_params = {
"apikey": settings.jambase_api_key,
"perPage": 100,
"eventDateFrom": datetime.utcnow().date().isoformat(),
}
base_params["artistName"] = term
if region_scope == RegionScope.hamburg:
return [
base_params
| {
"geoLatitude": 53.5511,
"geoLongitude": 9.9937,
"geoRadiusAmount": 35,
"geoRadiusUnits": "km",
"geoCountryIso2": "DE",
},
base_params
| {
"geoLatitude": 53.5511,
"geoLongitude": 9.9937,
"geoRadiusAmount": 35,
"geoRadiusUnits": "km",
},
]
if region_scope == RegionScope.germany:
return [base_params | {"geoCountryIso2": "DE"}]
return [base_params]
def _extract_items(self, payload) -> list[dict]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if not isinstance(payload, dict):
return []
for key in ("data", "events", "results", "items"):
value = payload.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
embedded = payload.get("_embedded")
if isinstance(embedded, dict):
for key in ("events", "items"):
value = embedded.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
# Some APIs wrap collections inside nested objects like {"data": {"events": [...]}}.
for key in ("data", "results"):
value = payload.get(key)
if isinstance(value, dict):
nested_items = self._extract_items(value)
if nested_items:
return nested_items
return []
def _extract_artist_names(self, item: dict) -> list[str]:
names: list[str] = []
for key in ("artist", "artists", "performer", "performers", "lineup", "acts"):
value = item.get(key)
if isinstance(value, list):
for entry in value:
if isinstance(entry, dict):
name = self._get_first(entry, "name", "artistName", "title")
if name:
names.append(name)
elif isinstance(entry, str):
names.append(entry)
elif isinstance(value, dict):
name = self._get_first(value, "name", "artistName", "title")
if name:
names.append(name)
return names
def _extract_venue_name(self, item: dict) -> str | None:
venue = item.get("venue")
if isinstance(venue, dict):
return self._get_first(venue, "name", "venueName", "title")
return self._get_first(item, "venue_name", "venueName")
def _extract_city(self, item: dict) -> str | None:
venue = item.get("venue")
if isinstance(venue, dict):
city = venue.get("city")
if isinstance(city, dict):
return self._get_first(city, "name")
if isinstance(city, str):
return city
address = venue.get("address")
if isinstance(address, dict):
locality = address.get("addressLocality")
if isinstance(locality, dict):
return self._get_first(locality, "name")
if isinstance(locality, str):
return locality
return self._get_first(address, "city")
location = item.get("location")
if isinstance(location, dict):
city = location.get("city")
if isinstance(city, dict):
return self._get_first(city, "name")
if isinstance(city, str):
return city
address = location.get("address")
if isinstance(address, dict):
locality = address.get("addressLocality")
if isinstance(locality, dict):
return self._get_first(locality, "name")
if isinstance(locality, str):
return locality
return self._get_first(address, "city")
return self._get_first(item, "city")
def _extract_country_code(self, item: dict) -> str | None:
for container_name in ("venue", "location"):
container = item.get(container_name)
if not isinstance(container, dict):
continue
country = container.get("country")
if isinstance(country, dict):
return self._get_first(country, "countryCode", "code", "name")
if isinstance(country, str):
return country
address = container.get("address")
if isinstance(address, dict):
country = address.get("addressCountry")
if isinstance(country, dict):
return self._get_first(country, "identifier", "alternateName", "name")
if isinstance(country, str):
return country
return self._get_first(address, "countryCode", "country")
return self._get_first(item, "country_code", "countryCode", "country")
def _extract_event_date(self, item: dict) -> datetime | None:
for key in ("startDate", "date", "datetime", "eventDate", "startsAt"):
value = item.get(key)
parsed = self._parse_datetime(value)
if parsed:
return parsed
dates = item.get("dates")
if isinstance(dates, dict):
for key in ("start", "event"):
nested = dates.get(key)
if isinstance(nested, dict):
local_date = self._get_first(nested, "localDate", "date")
local_time = self._get_first(nested, "localTime", "time") or "20:00:00"
if local_date:
try:
return datetime.fromisoformat(f"{local_date}T{local_time}")
except ValueError:
continue
return None
def _extract_ticket_url(self, item: dict) -> str | None:
for key in ("url", "ticketUrl", "ticket_url"):
value = item.get(key)
if isinstance(value, str) and value:
return value
offers = item.get("offers")
if isinstance(offers, list):
for offer in offers:
if not isinstance(offer, dict):
continue
value = self._get_first(offer, "url", "ticketUrl")
if value:
return value
return None
def _extract_image_url(self, item: dict) -> str | None:
for key in ("image", "image_url", "imageUrl"):
value = item.get(key)
if isinstance(value, str) and value:
return value
images = item.get("images")
if isinstance(images, list):
for image in images:
if isinstance(image, dict):
value = self._get_first(image, "url", "src")
if value:
return value
return None
def _parse_datetime(self, value) -> datetime | None:
if not value or not isinstance(value, str):
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None)
except ValueError:
pass
for fmt in ("%Y-%m-%d", "%d.%m.%Y"):
try:
return datetime.strptime(value[:10], fmt)
except ValueError:
continue
return None
def _format_error_response(self, response: requests.Response, params: dict) -> str:
param_names = ", ".join(sorted(params.keys()))
body = response.text.strip().replace("\n", " ")
if len(body) > 420:
body = f"{body[:420]}..."
return f"HTTP {response.status_code} mit Parametern [{param_names}]: {body}"
def _get_first(self, payload: dict, *keys: str) -> str | None:
for key in keys:
value = payload.get(key)
if isinstance(value, str) and value:
return value
return None
+2
View File
@@ -2,12 +2,14 @@ from app.providers.bandsintown import BandsintownProvider
from app.providers.barclays_arena import BarclaysArenaProvider from app.providers.barclays_arena import BarclaysArenaProvider
from app.providers.eventim import EventimProvider from app.providers.eventim import EventimProvider
from app.providers.fabrik import FabrikProvider from app.providers.fabrik import FabrikProvider
from app.providers.jambase import JamBaseProvider
from app.providers.ticketmaster import TicketmasterProvider from app.providers.ticketmaster import TicketmasterProvider
def get_providers(): def get_providers():
return [ return [
TicketmasterProvider(), TicketmasterProvider(),
JamBaseProvider(),
BandsintownProvider(), BandsintownProvider(),
EventimProvider(), EventimProvider(),
BarclaysArenaProvider(), BarclaysArenaProvider(),
+16 -2
View File
@@ -153,10 +153,14 @@ def record_provider_sync_state(
def build_provider_status_message(state: dict) -> str: def build_provider_status_message(state: dict) -> str:
if state["status"] == ProviderStatusType.error: if state["status"] == ProviderStatusType.error:
if state["last_message"]:
return state["last_message"]
terms = ", ".join(state["error_terms"][:3]) terms = ", ".join(state["error_terms"][:3])
return f"Fehler bei {state['provider_name']} fuer: {terms}." return f"Fehler bei {state['provider_name']} fuer: {terms}."
if state["status"] == ProviderStatusType.blocked: if state["status"] == ProviderStatusType.blocked:
if state["last_message"]:
return state["last_message"]
terms = ", ".join(state["blocked_terms"][:3]) terms = ", ".join(state["blocked_terms"][:3])
return f"{state['provider_name']} wurde geblockt fuer: {terms}." return f"{state['provider_name']} wurde geblockt fuer: {terms}."
@@ -408,7 +412,17 @@ def run_sync(db: Session) -> SyncResult:
term=watch_item.name, term=watch_item.name,
message=provider_message, message=provider_message,
) )
except Exception: except Exception as exc:
provider_message = getattr(
provider,
"last_message",
f"Provider sync failed for '{watch_item.name}'.",
)
if not provider_message:
provider_message = (
f"{provider.source_name} failed for '{watch_item.name}': "
f"{type(exc).__name__}: {exc}"
)
logger.exception( logger.exception(
"Provider sync failed for provider=%s watch_item=%s", "Provider sync failed for provider=%s watch_item=%s",
provider.source_name, provider.source_name,
@@ -419,7 +433,7 @@ def run_sync(db: Session) -> SyncResult:
state=provider_states[provider.source_name], state=provider_states[provider.source_name],
status=ProviderStatusType.error, status=ProviderStatusType.error,
term=watch_item.name, term=watch_item.name,
message=f"Provider sync failed for '{watch_item.name}'.", message=provider_message,
) )
continue continue
for event_data in events: for event_data in events: