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 self.last_raw_count = 0 self.last_match_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) self.last_raw_count = raw_count 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", "DEU", "GER", "Germany", "Deutschland", }: 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_match_count = len(results) 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", } ] 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