Files
eventlens/backend/app/providers/jambase.py
T
2026-09-20 17:21:38 +02:00

455 lines
17 KiB
Python

from datetime import datetime
import requests
from app.config import settings
from app.models import RegionScope, WatchItem, 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._reset_status()
if not self.is_configured():
self.last_status = "ok"
self.last_message = "JamBase ist nicht konfiguriert. Bitte JAMBASE_API_KEY setzen."
return []
items = self._fetch_items(term, watch_type, region_scope)
if items is None:
return []
results = self._dedupe_results(
self._event_data_for_term(item, term, watch_type, region_scope)
for item in items
)
self.last_match_count = len(results)
self.last_message = (
f"JamBase lieferte {self.last_raw_count} Roh-Events fuer '{term}', "
f"davon {len(results)} passend nach Eventlens-Filtern. "
f"{self.last_api_call_count} API-Call."
)
return results
def search_events_bulk(self, watch_items: list[WatchItem]) -> dict[int, list[dict]]:
self._reset_status()
results_by_watch_item_id: dict[int, list[dict]] = {
watch_item.id: [] for watch_item in watch_items
}
if not self.is_configured():
self.last_status = "ok"
self.last_message = "JamBase ist nicht konfiguriert. Bitte JAMBASE_API_KEY setzen."
return results_by_watch_item_id
grouped_items: dict[tuple[WatchType, RegionScope], list[WatchItem]] = {}
for watch_item in watch_items:
grouped_items.setdefault(
(watch_item.watch_type, watch_item.region_scope),
[],
).append(watch_item)
for (watch_type, region_scope), grouped_watch_items in grouped_items.items():
for batch in self._chunks(grouped_watch_items, settings.jambase_batch_size):
query_term = "|".join(watch_item.name for watch_item in batch)
items = self._fetch_items(query_term, watch_type, region_scope)
if items is None:
return results_by_watch_item_id
for item in items:
for watch_item in batch:
event_data = self._event_data_for_term(
item,
watch_item.name,
watch_item.watch_type,
watch_item.region_scope,
)
if event_data:
results_by_watch_item_id[watch_item.id].append(event_data)
for watch_item_id, results in results_by_watch_item_id.items():
results_by_watch_item_id[watch_item_id] = self._dedupe_results(results)
self.last_match_count = sum(len(results) for results in results_by_watch_item_id.values())
self.last_message = (
f"JamBase Bulk-Sync: {self.last_api_call_count} API-Calls, "
f"{self.last_raw_count} Roh-Events, {self.last_match_count} passende Events "
f"fuer {len(watch_items)} Watchlist-Eintraege."
)
return results_by_watch_item_id
def _reset_status(self):
self.last_status = "ok"
self.last_message = ""
self.last_raw_count = 0
self.last_match_count = 0
self.last_api_call_count = 0
def _headers(self) -> dict:
return {
"Authorization": f"Bearer {settings.jambase_api_key}",
"Accept": "application/json",
"User-Agent": settings.jambase_user_agent,
}
def _fetch_items(
self,
term: str,
watch_type: WatchType,
region_scope: RegionScope,
) -> list[dict] | None:
payload = None
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")
self.last_api_call_count += 1
response = requests.get(self.base_url, headers=self._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 None
if response.status_code == 403:
self.last_status = "blocked"
self.last_message = (
f"JamBase verweigert Zugriff fuer '{term}' (403). "
"Pruefe Plan oder Berechtigungen."
)
return None
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 None
raw_items = self._extract_items(payload)
self.last_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 None
self.last_status = "ok"
return self._extract_items(payload)
def _event_data_for_term(
self,
item: dict,
term: str,
watch_type: WatchType,
region_scope: RegionScope,
) -> dict | None:
normalized_term = normalize_search_text(term)
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:
return None
elif normalized_term not in normalize_search_text(title):
return None
city = self._extract_city(item)
country_code = self._extract_country_code(item)
if region_scope == RegionScope.hamburg and normalize_search_text(city) != "hamburg":
return None
if region_scope == RegionScope.germany and country_code not in {
"DE",
"DEU",
"GER",
"Germany",
"Deutschland",
}:
return None
event_date = self._extract_event_date(item)
if event_date and event_date.date() < datetime.utcnow().date():
return None
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'}"
)
return {
"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,
}
def _dedupe_results(self, results) -> list[dict]:
deduped: list[dict] = []
seen_ids: set[str] = set()
for result in results:
if not result:
continue
external_id = result["external_id"]
if external_id in seen_ids:
continue
seen_ids.add(external_id)
deduped.append(result)
return deduped
def _chunks(self, items: list[WatchItem], size: int):
chunk_size = max(1, size)
for index in range(0, len(items), chunk_size):
yield items[index : index + chunk_size]
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",
}
]
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:
for key in ("location", "venue"):
venue = item.get(key)
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
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")
country = container.get("country")
if isinstance(country, dict):
return self._get_first(country, "countryCode", "code", "identifier", "name")
if isinstance(country, str):
return 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 ("x-promoImage", "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