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

310 lines
12 KiB
Python

"""
Setlist.fm service for Freedify.
Searches for concert setlists and matches them to audio sources (Phish.in, Archive.org).
"""
import os
import httpx
from typing import Optional, Dict, List, Any
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
# API key from environment
SETLIST_FM_API_KEY = os.getenv("SETLIST_FM_API_KEY", "")
class SetlistService:
"""Service for searching and retrieving concert setlists from Setlist.fm."""
API_BASE = "https://api.setlist.fm/rest/1.0"
def __init__(self):
self.client = httpx.AsyncClient(
timeout=15.0,
headers={
"Accept": "application/json",
"x-api-key": SETLIST_FM_API_KEY
}
)
async def search_setlists(self, query: str, page: int = 1) -> List[Dict[str, Any]]:
"""Search for setlists by artist name or date.
Examples:
"Grateful Dead" - search by artist
"Phish 2023" - artist + year
"Pearl Jam 1991-09-20" - specific date
"""
if not SETLIST_FM_API_KEY:
logger.warning("Setlist.fm API key not configured")
return []
try:
# Parse query for artist and potential date
# Parse query for artist and potential date
params = {"p": page}
# Helper to strip date parts from query to get artist name
def clean_query(q, match_str):
return q.replace(match_str, "").strip()
import re
# Pattern 1: YYYY-MM-DD
date_match_iso = re.search(r'(\d{4})-(\d{2})-(\d{2})', query)
# Pattern 2: DD-MM-YYYY (what user tried)
date_match_eu = re.search(r'(\d{2})-(\d{2})-(\d{4})', query)
# Pattern 3: Month name and day
month_match = re.search(r'(?i)\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})(?:st|nd|rd|th)?(?:,)?\s*(\d{4})?', query)
# Pattern 4: Simple year
year_match = re.search(r'\b(19|20)\d{2}\b', query)
if date_match_iso:
# YYYY-MM-DD -> API needs dd-MM-yyyy
dt = datetime.strptime(date_match_iso.group(0), "%Y-%m-%d")
params["date"] = dt.strftime("%d-%m-%Y")
params["artistName"] = clean_query(query, date_match_iso.group(0))
elif date_match_eu:
# DD-MM-YYYY -> API needs dd-MM-yyyy (pass as is, or reformat to ensure validity)
# User typed: 31-12-2025
try:
dt = datetime.strptime(date_match_eu.group(0), "%d-%m-%Y")
params["date"] = dt.strftime("%d-%m-%Y")
params["artistName"] = clean_query(query, date_match_eu.group(0))
except ValueError:
# Invalid date (e.g. 99-99-2025), fallback to year or artist
logger.warning(f"Invalid date in query: {date_match_eu.group(0)}")
if year_match:
params["year"] = year_match.group(0)
params["artistName"] = clean_query(query, year_match.group(0))
else:
params["artistName"] = query
elif month_match:
# Month Day [Year]
try:
month_str = month_match.group(1)
day_str = month_match.group(2)
year_str = month_match.group(3)
# Convert month name to number
dt_str = f"{month_str} {day_str} {year_str if year_str else '2000'}" # Dummy year if missing
dt = datetime.strptime(dt_str, "%b %d %Y")
if year_str:
# Full date found
params["date"] = dt.strftime("%d-%m-%Y")
else:
# Recursive search or just month param? Setlist API only supports full date or year
# If year is missing in "Phish December 31", we might need to guess current year or search by year
# For now, let's assume current year if user says "Phish December 31"
current_year = datetime.now().year
params["date"] = dt.strftime(f"%d-%m-{current_year}")
params["artistName"] = clean_query(query, month_match.group(0))
except Exception as e:
logger.warning(f"Date parse error: {e}")
# Fallback to year only if possible
if year_match:
params["year"] = year_match.group(0)
params["artistName"] = clean_query(query, year_match.group(0))
else:
params["artistName"] = query
elif year_match:
# Year only
params["year"] = year_match.group(0)
params["artistName"] = clean_query(query, year_match.group(0))
else:
# Just artist name
params["artistName"] = query
logger.info(f"Searching Setlist.fm: {params}")
response = await self.client.get(f"{self.API_BASE}/search/setlists", params=params)
if response.status_code == 404:
return []
response.raise_for_status()
data = response.json()
setlists = data.get("setlist", [])
return [self._format_setlist(s) for s in setlists[:20]]
except Exception as e:
logger.error(f"Setlist.fm search error: {e}")
return []
async def get_setlist(self, setlist_id: str) -> Optional[Dict[str, Any]]:
"""Get full setlist details by ID."""
if not SETLIST_FM_API_KEY:
return None
try:
response = await self.client.get(f"{self.API_BASE}/setlist/{setlist_id}")
if response.status_code != 200:
return None
data = response.json()
return self._format_setlist_detail(data)
except Exception as e:
logger.error(f"Setlist.fm get_setlist error: {e}")
return None
def _format_setlist(self, item: dict) -> dict:
"""Format setlist data for search results."""
artist = item.get("artist", {})
venue = item.get("venue", {})
city = venue.get("city", {})
# Parse date (format: DD-MM-YYYY)
event_date = item.get("eventDate", "")
formatted_date = ""
iso_date = ""
if event_date:
try:
dt = datetime.strptime(event_date, "%d-%m-%Y")
formatted_date = dt.strftime("%B %d, %Y")
iso_date = dt.strftime("%Y-%m-%d")
except:
formatted_date = event_date
# Count songs
song_count = 0
for setlist_set in item.get("sets", {}).get("set", []):
song_count += len(setlist_set.get("song", []))
return {
"id": f"setlist_{item.get('id', '')}",
"type": "setlist",
"name": f"{artist.get('name', 'Unknown')} at {venue.get('name', 'Unknown Venue')}",
"artists": artist.get("name", ""),
"artist_mbid": artist.get("mbid", ""),
"venue": venue.get("name", ""),
"city": f"{city.get('name', '')}, {city.get('stateCode', '')} {city.get('country', {}).get('code', '')}".strip(", "),
"date": formatted_date,
"iso_date": iso_date,
"song_count": song_count,
"setlist_id": item.get("id", ""),
"url": item.get("url", ""),
"source": "setlist.fm",
# For display
"album_art": "/static/icon.svg", # Use default icon
"total_tracks": song_count,
"release_date": iso_date,
}
def _format_setlist_detail(self, item: dict) -> dict:
"""Format full setlist with all songs."""
base = self._format_setlist(item)
# Extract all songs from all sets
tracks = []
set_idx = 0
for setlist_set in item.get("sets", {}).get("set", []):
set_name = setlist_set.get("name") or f"Set {set_idx + 1}"
if setlist_set.get("encore"):
set_name = "Encore"
for song in setlist_set.get("song", []):
song_name = song.get("name", "Unknown")
# Build track info
track = {
"id": f"setlist_song_{base['setlist_id']}_{len(tracks)}",
"name": song_name,
"artists": base["artists"],
"set_name": set_name,
"with_info": song.get("with", {}).get("name"), # Guest artist
"cover_info": song.get("cover", {}).get("name"), # Original artist if cover
"info": song.get("info", ""), # Additional notes
"duration": "", # Setlist.fm doesn't have duration
"type": "track",
"source": "setlist.fm",
}
tracks.append(track)
set_idx += 1
base["tracks"] = tracks
base["type"] = "album" # Treat as album for detail view
# Determine audio source
artist_lower = base["artists"].lower()
if "phish" in artist_lower:
base["audio_source"] = "phish.in"
base["audio_url"] = f"https://phish.in/{base['iso_date']}"
else:
base["audio_source"] = "archive.org"
# We'll set audio_url after searching for the best version
base["audio_search"] = f"{base['artists']} {base['iso_date']}"
return base
async def find_best_archive_show(self, artist: str, iso_date: str) -> Optional[str]:
"""Search Archive.org for the best (most downloaded) version of a show."""
try:
# Map common artist names to Archive.org collections
artist_lower = artist.lower()
collection = None
collection_map = {
"grateful dead": "GratefulDead",
"dead": "GratefulDead",
"billy strings": "BillyStrings",
"ween": "Ween",
"king gizzard": "KingGizzardAndTheLizardWizard",
"kglw": "KingGizzardAndTheLizardWizard",
}
for key, val in collection_map.items():
if key in artist_lower:
collection = val
break
if not collection:
# Fallback: search by creator
query = f'creator:"{artist}" AND date:{iso_date}* AND mediatype:etree'
else:
query = f'collection:{collection} AND date:{iso_date}* AND mediatype:etree'
params = {
"q": query,
"fl[]": ["identifier", "downloads"],
"sort[]": "downloads desc", # Sort by most downloads
"rows": 1, # Just get the top one
"output": "json",
}
response = await self.client.get("https://archive.org/advancedsearch.php", params=params)
if response.status_code != 200:
return None
data = response.json()
docs = data.get("response", {}).get("docs", [])
if docs:
identifier = docs[0].get("identifier")
return f"https://archive.org/details/{identifier}"
return None
except Exception as e:
logger.error(f"Archive.org search error: {e}")
return None
async def close(self):
"""Close the HTTP client."""
await self.client.aclose()
# Singleton instance
setlist_service = SetlistService()