Jambase Calls reduziert

This commit is contained in:
ecki
2026-09-20 17:21:38 +02:00
parent 3018573b22
commit bbfda72bc0
5 changed files with 267 additions and 72 deletions
+1
View File
@@ -12,6 +12,7 @@ TICKETMASTER_API_KEY=
JAMBASE_API_KEY= JAMBASE_API_KEY=
JAMBASE_USER_AGENT=eventlens/0.1 JAMBASE_USER_AGENT=eventlens/0.1
JAMBASE_SYNC_INTERVAL_HOURS=72 JAMBASE_SYNC_INTERVAL_HOURS=72
JAMBASE_BATCH_SIZE=10
BANDSINTOWN_APP_ID= BANDSINTOWN_APP_ID=
EVENTIM_ENABLED=true EVENTIM_ENABLED=true
POLL_INTERVAL_HOURS=6 POLL_INTERVAL_HOURS=6
+1
View File
@@ -36,6 +36,7 @@ API-Statusinfo findest du unter `http://127.0.0.1:8001/api`.
- `JAMBASE_API_KEY`: JamBase Data API Bearer Token - `JAMBASE_API_KEY`: JamBase Data API Bearer Token
- `JAMBASE_USER_AGENT`: eigene App-Kennung fuer JamBase-Requests - `JAMBASE_USER_AGENT`: eigene App-Kennung fuer JamBase-Requests
- `JAMBASE_SYNC_INTERVAL_HOURS`: Mindestabstand fuer JamBase-API-Laeufe, Standard `72`; `0` deaktiviert die Bremse - `JAMBASE_SYNC_INTERVAL_HOURS`: Mindestabstand fuer JamBase-API-Laeufe, Standard `72`; `0` deaktiviert die Bremse
- `JAMBASE_BATCH_SIZE`: Anzahl Watchlist-Namen pro JamBase-Bulk-Request, Standard `10`
- `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
+1
View File
@@ -15,6 +15,7 @@ class Settings:
jambase_api_key = os.getenv("JAMBASE_API_KEY", "") jambase_api_key = os.getenv("JAMBASE_API_KEY", "")
jambase_user_agent = os.getenv("JAMBASE_USER_AGENT", "eventlens/0.1") jambase_user_agent = os.getenv("JAMBASE_USER_AGENT", "eventlens/0.1")
jambase_sync_interval_hours = int(os.getenv("JAMBASE_SYNC_INTERVAL_HOURS", "72")) jambase_sync_interval_hours = int(os.getenv("JAMBASE_SYNC_INTERVAL_HOURS", "72"))
jambase_batch_size = int(os.getenv("JAMBASE_BATCH_SIZE", "10"))
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"))
+118 -37
View File
@@ -3,7 +3,7 @@ from datetime import datetime
import requests import requests
from app.config import settings from app.config import settings
from app.models import RegionScope, WatchType from app.models import RegionScope, WatchItem, WatchType
from app.providers.utils import normalize_search_text from app.providers.utils import normalize_search_text
@@ -20,35 +20,109 @@ class JamBaseProvider:
watch_type: WatchType, watch_type: WatchType,
region_scope: RegionScope, region_scope: RegionScope,
) -> list[dict]: ) -> list[dict]:
self.last_status = "ok" self._reset_status()
self.last_message = ""
if not self.is_configured(): if not self.is_configured():
self.last_status = "ok" self.last_status = "ok"
self.last_message = "JamBase ist nicht konfiguriert. Bitte JAMBASE_API_KEY setzen." self.last_message = "JamBase ist nicht konfiguriert. Bitte JAMBASE_API_KEY setzen."
return [] return []
headers = { 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}", "Authorization": f"Bearer {settings.jambase_api_key}",
"Accept": "application/json", "Accept": "application/json",
"User-Agent": settings.jambase_user_agent, "User-Agent": settings.jambase_user_agent,
} }
def _fetch_items(
self,
term: str,
watch_type: WatchType,
region_scope: RegionScope,
) -> list[dict] | None:
payload = None payload = None
raw_count = 0
self.last_raw_count = 0
self.last_match_count = 0
tried_params: list[str] = [] tried_params: list[str] = []
bad_request_messages: list[str] = [] bad_request_messages: list[str] = []
for params in self._build_param_candidates(term, watch_type, region_scope): for params in self._build_param_candidates(term, watch_type, region_scope):
tried_params.append(", ".join(sorted(params.keys())) or "keine") tried_params.append(", ".join(sorted(params.keys())) or "keine")
response = requests.get(self.base_url, headers=headers, params=params, timeout=30) self.last_api_call_count += 1
response = requests.get(self.base_url, headers=self._headers(), params=params, timeout=30)
if response.status_code == 401: if response.status_code == 401:
self.last_status = "blocked" self.last_status = "blocked"
self.last_message = ( self.last_message = (
f"JamBase hat den API-Key fuer '{term}' nicht akzeptiert (401)." f"JamBase hat den API-Key fuer '{term}' nicht akzeptiert (401)."
) )
return [] return None
if response.status_code == 403: if response.status_code == 403:
self.last_status = "blocked" self.last_status = "blocked"
@@ -56,7 +130,7 @@ class JamBaseProvider:
f"JamBase verweigert Zugriff fuer '{term}' (403). " f"JamBase verweigert Zugriff fuer '{term}' (403). "
"Pruefe Plan oder Berechtigungen." "Pruefe Plan oder Berechtigungen."
) )
return [] return None
if response.status_code == 400: if response.status_code == 400:
bad_request_messages.append( bad_request_messages.append(
@@ -73,10 +147,9 @@ class JamBaseProvider:
f"JamBase lieferte keine gueltige JSON-Antwort fuer '{term}' " f"JamBase lieferte keine gueltige JSON-Antwort fuer '{term}' "
f"(Status {response.status_code})." f"(Status {response.status_code})."
) )
return [] return None
raw_items = self._extract_items(payload) raw_items = self._extract_items(payload)
raw_count = len(raw_items) self.last_raw_count += len(raw_items)
self.last_raw_count = raw_count
if raw_items: if raw_items:
break break
@@ -84,30 +157,33 @@ class JamBaseProvider:
self.last_status = "error" self.last_status = "error"
detail = bad_request_messages[0] if bad_request_messages else "Keine Detailmeldung." detail = bad_request_messages[0] if bad_request_messages else "Keine Detailmeldung."
self.last_message = f"JamBase akzeptierte die Anfrage fuer '{term}' nicht: {detail}" self.last_message = f"JamBase akzeptierte die Anfrage fuer '{term}' nicht: {detail}"
return [] return None
items = self._extract_items(payload)
self.last_status = "ok" 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) 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 title = self._get_first(item, "name", "title", "eventName") or term
artists = self._extract_artist_names(item) artists = self._extract_artist_names(item)
haystack = normalize_search_text(" ".join([title] + artists)) haystack = normalize_search_text(" ".join([title] + artists))
if watch_type == WatchType.artist: if watch_type == WatchType.artist:
if normalized_term not in haystack: if normalized_term not in haystack:
continue return None
elif normalized_term not in normalize_search_text(title): elif normalized_term not in normalize_search_text(title):
continue return None
city = self._extract_city(item) city = self._extract_city(item)
country_code = self._extract_country_code(item) country_code = self._extract_country_code(item)
if region_scope == RegionScope.hamburg and normalize_search_text(city) != "hamburg": if region_scope == RegionScope.hamburg and normalize_search_text(city) != "hamburg":
continue return None
if region_scope == RegionScope.germany and country_code not in { if region_scope == RegionScope.germany and country_code not in {
"DE", "DE",
"DEU", "DEU",
@@ -115,11 +191,11 @@ class JamBaseProvider:
"Germany", "Germany",
"Deutschland", "Deutschland",
}: }:
continue return None
event_date = self._extract_event_date(item) event_date = self._extract_event_date(item)
if event_date and event_date.date() < datetime.utcnow().date(): if event_date and event_date.date() < datetime.utcnow().date():
continue return None
external_id = str( external_id = str(
self._get_first(item, "id", "eventId", "identifier") self._get_first(item, "id", "eventId", "identifier")
@@ -127,12 +203,7 @@ class JamBaseProvider:
or item.get("url") or item.get("url")
or f"jambase:{normalize_search_text(title)}:{event_date.isoformat() if event_date else 'na'}" or f"jambase:{normalize_search_text(title)}:{event_date.isoformat() if event_date else 'na'}"
) )
if external_id in seen_ids: return {
continue
seen_ids.add(external_id)
results.append(
{
"external_id": external_id, "external_id": external_id,
"title": title, "title": title,
"matched_term": term, "matched_term": term,
@@ -144,14 +215,24 @@ class JamBaseProvider:
"image_url": self._extract_image_url(item), "image_url": self._extract_image_url(item),
"raw_payload": item, "raw_payload": item,
} }
)
self.last_match_count = len(results) def _dedupe_results(self, results) -> list[dict]:
self.last_message = ( deduped: list[dict] = []
f"JamBase lieferte {raw_count} Roh-Events fuer '{term}', " seen_ids: set[str] = set()
f"davon {len(results)} passend nach Eventlens-Filtern." for result in results:
) if not result:
return results 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( def _build_param_candidates(
self, self,
+112 -1
View File
@@ -137,6 +137,7 @@ def init_provider_sync_state(provider_name: str) -> dict:
"error_terms": [], "error_terms": [],
"event_count": 0, "event_count": 0,
"raw_event_count": 0, "raw_event_count": 0,
"api_call_count": 0,
"last_message": "", "last_message": "",
} }
@@ -148,6 +149,7 @@ def record_provider_sync_state(
message: str, message: str,
event_count: int = 0, event_count: int = 0,
raw_event_count: int | None = None, raw_event_count: int | None = None,
api_call_count: int | None = None,
): ):
if PROVIDER_STATUS_PRIORITY[status] > PROVIDER_STATUS_PRIORITY[state["status"]]: if PROVIDER_STATUS_PRIORITY[status] > PROVIDER_STATUS_PRIORITY[state["status"]]:
state["status"] = status state["status"] = status
@@ -157,6 +159,8 @@ def record_provider_sync_state(
state["event_count"] += event_count state["event_count"] += event_count
if raw_event_count is not None: if raw_event_count is not None:
state["raw_event_count"] += raw_event_count state["raw_event_count"] += raw_event_count
if api_call_count is not None:
state["api_call_count"] += api_call_count
elif status == ProviderStatusType.blocked: elif status == ProviderStatusType.blocked:
if term not in state["blocked_terms"]: if term not in state["blocked_terms"]:
state["blocked_terms"].append(term) state["blocked_terms"].append(term)
@@ -188,6 +192,11 @@ def build_provider_status_message(state: dict) -> str:
if state["raw_event_count"] if state["raw_event_count"]
else "" else ""
) )
+ (
f" {state['api_call_count']} API-Calls."
if state["api_call_count"]
else ""
)
) )
@@ -394,6 +403,12 @@ def run_sync(db: Session, force_providers: bool = False) -> SyncResult:
for provider in providers for provider in providers
if provider.source_name not in skipped_provider_messages if provider.source_name not in skipped_provider_messages
] ]
bulk_providers = [
provider for provider in providers_to_sync if hasattr(provider, "search_events_bulk")
]
regular_providers = [
provider for provider in providers_to_sync if provider not in bulk_providers
]
provider_states = { provider_states = {
provider.source_name: init_provider_sync_state(provider.source_name) provider.source_name: init_provider_sync_state(provider.source_name)
for provider in providers_to_sync for provider in providers_to_sync
@@ -481,7 +496,7 @@ def run_sync(db: Session, force_providers: bool = False) -> SyncResult:
prune_stale_source_events(db, watch_item, source_name, seen_source_event_ids) prune_stale_source_events(db, watch_item, source_name, seen_source_event_ids)
db.commit() db.commit()
for provider in providers_to_sync: for provider in regular_providers:
try: try:
events = provider.search_events( events = provider.search_events(
term=watch_item.name, term=watch_item.name,
@@ -501,6 +516,7 @@ def run_sync(db: Session, force_providers: bool = False) -> SyncResult:
message=provider_message, message=provider_message,
event_count=len(events), event_count=len(events),
raw_event_count=getattr(provider, "last_raw_count", None), raw_event_count=getattr(provider, "last_raw_count", None),
api_call_count=getattr(provider, "last_api_call_count", None),
) )
except Exception as exc: except Exception as exc:
provider_message = getattr( provider_message = getattr(
@@ -567,6 +583,101 @@ def run_sync(db: Session, force_providers: bool = False) -> SyncResult:
notifications_skipped += 1 notifications_skipped += 1
db.commit() db.commit()
watch_items_by_id = {watch_item.id: watch_item for watch_item in active_items}
for provider in bulk_providers:
try:
events_by_watch_item_id = provider.search_events_bulk(active_items)
provider_status_name = getattr(provider, "last_status", "ok")
provider_message = getattr(
provider,
"last_message",
f"{provider.source_name} completed bulk sync.",
)
state = provider_states[provider.source_name]
provider_status = ProviderStatusType(provider_status_name)
if PROVIDER_STATUS_PRIORITY[provider_status] > PROVIDER_STATUS_PRIORITY[state["status"]]:
state["status"] = provider_status
if provider_status == ProviderStatusType.ok:
state["ok_count"] += len(active_items)
state["event_count"] += sum(
len(events) for events in events_by_watch_item_id.values()
)
state["raw_event_count"] += getattr(provider, "last_raw_count", 0) or 0
state["api_call_count"] += getattr(provider, "last_api_call_count", 0) or 0
elif provider_status == ProviderStatusType.blocked:
state["blocked_terms"].append("Bulk-Sync")
elif provider_status == ProviderStatusType.error:
state["error_terms"].append("Bulk-Sync")
state["last_message"] = provider_message
except Exception as exc:
provider_message = getattr(
provider,
"last_message",
f"Provider bulk sync failed for {provider.source_name}.",
)
if not provider_message:
provider_message = (
f"{provider.source_name} bulk sync failed: "
f"{type(exc).__name__}: {exc}"
)
logger.exception(
"Provider bulk sync failed for provider=%s",
provider.source_name,
)
db.rollback()
record_provider_sync_state(
state=provider_states[provider.source_name],
status=ProviderStatusType.error,
term="Bulk-Sync",
message=provider_message,
)
continue
for watch_item_id, events in events_by_watch_item_id.items():
watch_item = watch_items_by_id.get(watch_item_id)
if watch_item is None:
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
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()
for provider_name, state in provider_states.items(): for provider_name, state in provider_states.items():
update_provider_status( update_provider_status(
db=db, db=db,