freedify/app/live_show_service.py
2026-01-13 22:26:48 +00:00

206 lines
8 KiB
Python

"""
Live Show Search Service for Freedify.
Searches Phish.in for Phish shows and Archive.org for other jam bands.
"""
import httpx
import re
from typing import Optional, Dict, List, Any
import logging
logger = logging.getLogger(__name__)
# Bands that have shows on Archive.org Live Music Archive
ARCHIVE_BANDS = {
"grateful dead": "GratefulDead",
"dead": "GratefulDead",
"gd": "GratefulDead",
"billy strings": "BillyStrings",
"ween": "Ween",
"king gizzard": "KingGizzardAndTheLizardWizard",
"king gizzard & the lizard wizard": "KingGizzardAndTheLizardWizard",
"king gizzard and the lizard wizard": "KingGizzardAndTheLizardWizard",
"kglw": "KingGizzardAndTheLizardWizard",
}
class LiveShowService:
"""Service for searching live show archives."""
PHISH_API = "https://phish.in/api/v2"
ARCHIVE_API = "https://archive.org/advancedsearch.php"
def __init__(self):
self.client = httpx.AsyncClient(timeout=30.0)
def detect_live_search(self, query: str) -> Optional[Dict[str, Any]]:
"""
Detect if a search query is looking for live shows.
Returns dict with band, year, month if found, else None.
Examples:
- "Phish 2025" -> {"band": "phish", "year": "2025", "month": None}
- "Phish 2024/12" -> {"band": "phish", "year": "2024", "month": "12"}
- "Grateful Dead 1977" -> {"band": "grateful dead", "year": "1977", "month": None}
"""
query_lower = query.lower().strip()
# Pattern: band name + year or year/month
# e.g., "Phish 2025", "Grateful Dead 1977/05", "Billy Strings 2023-08"
pattern = r'^(phish|grateful dead|dead|gd|billy strings|ween|king gizzard.*?|kglw)\s+(\d{4})(?:[/-](\d{1,2}))?$'
match = re.match(pattern, query_lower)
if match:
band = match.group(1)
year = match.group(2)
month = match.group(3)
return {
"band": band,
"year": year,
"month": month.zfill(2) if month else None
}
return None
async def search_phish_shows(self, year: str, month: str = None) -> List[Dict[str, Any]]:
"""Search Phish.in for shows by year/month."""
try:
# Phish.in API endpoint for shows by year
url = f"{self.PHISH_API}/shows"
params = {"year": year}
response = await self.client.get(url, params=params)
if response.status_code != 200:
logger.warning(f"Phish.in API returned {response.status_code}")
return []
data = response.json()
# API v2 returns {'data': [...]} or {'shows': [...]} (observed 'shows' in testing)
shows = data.get('data', []) or data.get('shows', [])
# Filter by month if specified
if month:
shows = [s for s in shows if s.get("date", "").startswith(f"{year}-{month}")]
# Format as albums for the UI
results = []
for show in shows[:20]: # Limit to 20
date = show.get("date", "")
venue = show.get("venue", {})
venue_name = venue.get("name", "") if isinstance(venue, dict) else str(venue)
location = venue.get("location", "") if isinstance(venue, dict) else ""
results.append({
"id": f"phish_{date}",
"type": "album",
"name": f"Phish - {date}",
"artists": "Phish",
"album_art": "/static/icon.svg", # phish.in logo 404s, use local icon
"release_date": date,
"description": f"{venue_name}, {location}" if location else venue_name,
"total_tracks": show.get("tracks_count", 0),
"source": "phish.in",
"import_url": f"https://phish.in/{date}",
})
return results
except Exception as e:
logger.error(f"Phish.in search error: {e}")
return []
async def search_archive_shows(self, band: str, year: str, month: str = None) -> List[Dict[str, Any]]:
"""Search Archive.org Live Music Archive for shows."""
try:
# Get the Archive.org collection name
band_lower = band.lower()
collection = None
for key, val in ARCHIVE_BANDS.items():
if key in band_lower or band_lower in key:
collection = val
break
if not collection:
return []
# Build Archive.org search query
date_query = f"{year}-{month}" if month else year
query = f'collection:{collection} AND date:{date_query}* AND mediatype:etree'
params = {
"q": query,
"fl[]": ["identifier", "title", "date", "venue", "coverage", "description"],
"sort[]": "date asc",
"rows": 20,
"output": "json",
}
response = await self.client.get(self.ARCHIVE_API, params=params)
if response.status_code != 200:
logger.warning(f"Archive.org API returned {response.status_code}")
return []
data = response.json()
docs = data.get("response", {}).get("docs", [])
# Map band collection to display name
band_names = {
"GratefulDead": "Grateful Dead",
"BillyStrings": "Billy Strings",
"Ween": "Ween",
"KingGizzardAndTheLizardWizard": "King Gizzard & The Lizard Wizard",
}
display_name = band_names.get(collection, collection)
results = []
for doc in docs:
identifier = doc.get("identifier", "")
date = doc.get("date", "")[:10] if doc.get("date") else ""
title = doc.get("title", f"{display_name} - {date}")
venue = doc.get("venue", "")
location = doc.get("coverage", "")
results.append({
"id": f"archive_{identifier}",
"type": "album",
"name": title if title else f"{display_name} - {date}",
"artists": display_name,
"album_art": f"https://archive.org/services/img/{identifier}",
"release_date": date,
"description": f"{venue}, {location}" if venue and location else (venue or location or ""),
"source": "archive.org",
"import_url": f"https://archive.org/details/{identifier}",
})
return results
except Exception as e:
logger.error(f"Archive.org search error: {e}")
return []
async def search_live_shows(self, query: str) -> Optional[List[Dict[str, Any]]]:
"""
Main entry point - detect if query is for live shows and search appropriate source.
Returns None if not a live show query.
"""
detected = self.detect_live_search(query)
if not detected:
return None
band = detected["band"]
year = detected["year"]
month = detected["month"]
# Phish -> use phish.in
if band == "phish":
logger.info(f"Searching Phish.in for {year}" + (f"/{month}" if month else ""))
return await self.search_phish_shows(year, month)
# Other bands -> use Archive.org
logger.info(f"Searching Archive.org for {band} {year}" + (f"/{month}" if month else ""))
return await self.search_archive_shows(band, year, month)
async def close(self):
"""Close the HTTP client."""
await self.client.aclose()
# Singleton instance
live_show_service = LiveShowService()