freedify/app/concert_service.py

325 lines
11 KiB
Python
Raw Normal View History

2026-01-13 22:26:48 +00:00
"""
Concert Service - Ticketmaster Discovery API + SeatGeek fallback
Provides upcoming concert search for artists
"""
import os
import httpx
import logging
from typing import List, Dict, Optional, Any
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
# API Configuration
TICKETMASTER_API_KEY = os.getenv("TICKETMASTER_API_KEY", "")
SEATGEEK_CLIENT_ID = os.getenv("SEATGEEK_CLIENT_ID", "")
TICKETMASTER_BASE = "https://app.ticketmaster.com/discovery/v2"
SEATGEEK_BASE = "https://api.seatgeek.com/2"
class ConcertService:
"""Service for fetching upcoming concerts from Ticketmaster and SeatGeek."""
def __init__(self):
self.client = httpx.AsyncClient(timeout=30.0, follow_redirects=True)
async def search_ticketmaster(
self,
artist: str,
city: Optional[str] = None,
limit: int = 10
) -> List[Dict[str, Any]]:
"""
Search Ticketmaster Discovery API for events.
Args:
artist: Artist name to search
city: Optional city to filter by
limit: Max results to return
Returns:
List of normalized event objects
"""
if not TICKETMASTER_API_KEY:
logger.warning("TICKETMASTER_API_KEY not set")
return []
try:
params = {
"apikey": TICKETMASTER_API_KEY,
"keyword": artist,
"classificationName": "music",
"size": limit,
"sort": "date,asc"
}
# Normalize city name (remove "City" suffix, common variants)
if city:
normalized_city = city.replace(" City", "").replace(" city", "").strip()
params["city"] = normalized_city
response = await self.client.get(
f"{TICKETMASTER_BASE}/events.json",
params=params
)
if response.status_code != 200:
logger.error(f"Ticketmaster API error: {response.status_code}")
return []
data = response.json()
events = data.get("_embedded", {}).get("events", [])
logger.info(f"Ticketmaster returned {len(events)} events for '{artist}'")
# If no events found with city filter, try without
if not events and city:
logger.info(f"No events with city filter, trying without...")
del params["city"]
response = await self.client.get(
f"{TICKETMASTER_BASE}/events.json",
params=params
)
if response.status_code == 200:
data = response.json()
events = data.get("_embedded", {}).get("events", [])
logger.info(f"Ticketmaster (no city) returned {len(events)} events")
return [self._normalize_ticketmaster_event(e) for e in events]
except Exception as e:
logger.error(f"Ticketmaster search error: {e}")
return []
def _normalize_ticketmaster_event(self, event: Dict) -> Dict[str, Any]:
"""Convert Ticketmaster event to normalized format."""
# Get venue info
venues = event.get("_embedded", {}).get("venues", [])
venue = venues[0] if venues else {}
# Get date/time
dates = event.get("dates", {})
start = dates.get("start", {})
# Get price range
price_ranges = event.get("priceRanges", [])
price = price_ranges[0] if price_ranges else {}
# Get image
images = event.get("images", [])
image = next((img["url"] for img in images if img.get("ratio") == "16_9"), None)
if not image and images:
image = images[0].get("url")
# Get artist name from attractions
attractions = event.get("_embedded", {}).get("attractions", [])
artist_name = attractions[0].get("name") if attractions else event.get("name", "")
return {
"id": event.get("id", ""),
"name": event.get("name", ""),
"artist": artist_name,
"venue": venue.get("name", "Unknown Venue"),
"city": venue.get("city", {}).get("name", ""),
"state": venue.get("state", {}).get("stateCode", ""),
"country": venue.get("country", {}).get("countryCode", ""),
"date": start.get("localDate", ""),
"time": start.get("localTime", ""),
"ticket_url": event.get("url", ""),
"price_min": price.get("min"),
"price_max": price.get("max"),
"currency": price.get("currency", "USD"),
"image": image,
"source": "ticketmaster"
}
async def search_seatgeek(
self,
artist: str,
city: Optional[str] = None,
limit: int = 10
) -> List[Dict[str, Any]]:
"""
Search SeatGeek API for events (fallback).
Args:
artist: Artist name to search
city: Optional city to filter by
limit: Max results to return
Returns:
List of normalized event objects
"""
if not SEATGEEK_CLIENT_ID:
logger.warning("SEATGEEK_CLIENT_ID not set")
return []
try:
# Use performers.slug for better matching (slugify artist name)
artist_slug = artist.lower().replace(" ", "-").replace("'", "")
params = {
"client_id": SEATGEEK_CLIENT_ID,
"performers.slug": artist_slug,
"per_page": limit,
"sort": "datetime_utc.asc"
}
response = await self.client.get(
f"{SEATGEEK_BASE}/events",
params=params
)
if response.status_code != 200:
logger.error(f"SeatGeek API error: {response.status_code}")
return []
data = response.json()
events = data.get("events", [])
logger.info(f"SeatGeek returned {len(events)} events for '{artist}'")
# If no results with slug, try keyword search
if not events:
logger.info(f"SeatGeek slug search failed, trying q=")
params = {
"client_id": SEATGEEK_CLIENT_ID,
"q": artist,
"type": "concert",
"per_page": limit,
"sort": "datetime_utc.asc"
}
response = await self.client.get(
f"{SEATGEEK_BASE}/events",
params=params
)
if response.status_code == 200:
data = response.json()
events = data.get("events", [])
logger.info(f"SeatGeek (q=) returned {len(events)} events")
return [self._normalize_seatgeek_event(e) for e in events]
except Exception as e:
logger.error(f"SeatGeek search error: {e}")
return []
def _normalize_seatgeek_event(self, event: Dict) -> Dict[str, Any]:
"""Convert SeatGeek event to normalized format."""
venue = event.get("venue", {})
performers = event.get("performers", [])
performer = performers[0] if performers else {}
# Parse datetime
datetime_utc = event.get("datetime_utc", "")
date_str = ""
time_str = ""
if datetime_utc:
try:
dt = datetime.fromisoformat(datetime_utc.replace("Z", "+00:00"))
date_str = dt.strftime("%Y-%m-%d")
time_str = dt.strftime("%H:%M:%S")
except:
pass
# Get stats for pricing
stats = event.get("stats", {})
return {
"id": str(event.get("id", "")),
"name": event.get("title", ""),
"artist": performer.get("name", event.get("title", "")),
"venue": venue.get("name", "Unknown Venue"),
"city": venue.get("city", ""),
"state": venue.get("state", ""),
"country": venue.get("country", ""),
"date": date_str,
"time": time_str,
"ticket_url": event.get("url", ""),
"price_min": stats.get("lowest_price"),
"price_max": stats.get("highest_price"),
"currency": "USD",
"image": performer.get("image"),
"source": "seatgeek"
}
async def search_events(
self,
artist: str,
city: Optional[str] = None,
limit: int = 10
) -> List[Dict[str, Any]]:
"""
Search for events with Ticketmaster primary, SeatGeek fallback.
Args:
artist: Artist name to search
city: Optional city to filter by
limit: Max results to return
Returns:
List of normalized event objects
"""
# Try Ticketmaster first
events = await self.search_ticketmaster(artist, city, limit)
# If no results or Ticketmaster unavailable, try SeatGeek
if not events:
logger.info(f"Falling back to SeatGeek for: {artist}")
events = await self.search_seatgeek(artist, city, limit)
return events
async def get_events_for_artists(
self,
artists: List[str],
cities: Optional[List[str]] = None,
limit_per_artist: int = 5
) -> List[Dict[str, Any]]:
"""
Get upcoming events for multiple artists.
Args:
artists: List of artist names
cities: Optional list of cities to filter by
limit_per_artist: Max events per artist
Returns:
List of all events, sorted by date
"""
all_events = []
for artist in artists[:10]: # Limit to 10 artists to avoid rate limits
if cities:
# Search each city
for city in cities[:3]: # Limit to 3 cities
events = await self.search_events(artist, city, limit_per_artist)
all_events.extend(events)
else:
events = await self.search_events(artist, None, limit_per_artist)
all_events.extend(events)
# Deduplicate by event ID
seen_ids = set()
unique_events = []
for event in all_events:
event_id = f"{event['source']}_{event['id']}"
if event_id not in seen_ids:
seen_ids.add(event_id)
unique_events.append(event)
# Sort by date
unique_events.sort(key=lambda e: e.get("date", "9999-99-99"))
return unique_events
async def close(self):
"""Close the HTTP client."""
await self.client.aclose()
# Singleton instance
concert_service = ConcertService()