Initial commit
This commit is contained in:
commit
c803de020e
52 changed files with 20439 additions and 0 deletions
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# App package
|
||||
273
app/ai_radio_service.py
Normal file
273
app/ai_radio_service.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
"""
|
||||
AI Radio Service for Freedify.
|
||||
Generates continuous playlist recommendations based on a seed track or mood.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIRadioService:
|
||||
"""AI-powered radio that generates track recommendations."""
|
||||
|
||||
def __init__(self):
|
||||
# Helper for key fallback logic if needed, but primary is env var
|
||||
self.api_key = os.environ.get("GEMINI_API_KEY")
|
||||
self._genai = None
|
||||
self._model = None
|
||||
|
||||
def _init_genai(self):
|
||||
"""Lazy initialization of Gemini client."""
|
||||
if self._genai is None:
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
if not self.api_key:
|
||||
logger.warning("GEMINI_API_KEY not set - AI Radio will use basic mode")
|
||||
return False
|
||||
genai.configure(api_key=self.api_key)
|
||||
self._genai = genai
|
||||
self._model = genai.GenerativeModel('gemini-2.0-flash')
|
||||
logger.info("AI Radio: Gemini initialized")
|
||||
return True
|
||||
except ImportError:
|
||||
logger.warning("google-generativeai not installed")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Gemini for AI Radio: {e}")
|
||||
return False
|
||||
return True
|
||||
|
||||
async def generate_recommendations(
|
||||
self,
|
||||
seed_track: Optional[Dict[str, Any]] = None,
|
||||
mood: Optional[str] = None,
|
||||
current_queue: List[Dict[str, Any]] = None,
|
||||
count: int = 5
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate track recommendations for AI Radio.
|
||||
|
||||
Args:
|
||||
seed_track: A track to base recommendations on (name, artist, bpm, key)
|
||||
mood: A mood/vibe description if no seed track
|
||||
current_queue: Current queue to avoid duplicates
|
||||
count: Number of recommendations to generate
|
||||
|
||||
Returns:
|
||||
Dict with search_terms to find recommended tracks
|
||||
"""
|
||||
current_queue = current_queue or []
|
||||
|
||||
# Build context
|
||||
if seed_track:
|
||||
context = f"""Based on this seed track:
|
||||
Title: "{seed_track.get('name', 'Unknown')}"
|
||||
Artist: {seed_track.get('artists', 'Unknown')}
|
||||
BPM: {seed_track.get('bpm', 'Unknown')}
|
||||
Key: {seed_track.get('camelot', 'Unknown')}"""
|
||||
elif mood:
|
||||
context = f'Based on this mood/vibe: "{mood}"'
|
||||
else:
|
||||
context = "Generate a diverse mix of popular tracks"
|
||||
|
||||
# Exclude current queue tracks
|
||||
exclude_list = []
|
||||
for t in current_queue[:10]: # Limit to last 10
|
||||
exclude_list.append(f"- {t.get('name', '')} by {t.get('artists', '')}")
|
||||
|
||||
exclude_str = "\n".join(exclude_list) if exclude_list else "None"
|
||||
|
||||
# Try AI generation
|
||||
if self._init_genai() and self._model:
|
||||
try:
|
||||
return await self._ai_generate_recommendations(
|
||||
context, exclude_str, count, seed_track
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"AI recommendation failed: {e}")
|
||||
|
||||
# Fallback: return genre-based search terms
|
||||
return self._fallback_recommendations(seed_track, mood, count)
|
||||
|
||||
async def _ai_generate_recommendations(
|
||||
self,
|
||||
context: str,
|
||||
exclude_str: str,
|
||||
count: int,
|
||||
seed_track: Optional[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate recommendations using Gemini AI."""
|
||||
import json
|
||||
|
||||
prompt = f"""{context}
|
||||
|
||||
TASK: Recommend {count} songs that would flow well in a DJ mix or playlist.
|
||||
|
||||
RULES:
|
||||
1. Match the energy, tempo, and vibe of the seed track or mood
|
||||
2. Consider harmonic compatibility (Camelot wheel)
|
||||
3. Mix well-known tracks with hidden gems
|
||||
4. Vary artists but keep genre/style consistent
|
||||
|
||||
EXCLUDE these tracks already in queue:
|
||||
{exclude_str}
|
||||
|
||||
Respond ONLY with valid JSON:
|
||||
{{
|
||||
"recommendations": [
|
||||
{{"artist": "Artist Name", "title": "Song Title", "reason": "Why it fits"}},
|
||||
...
|
||||
],
|
||||
"suggested_searches": ["search term 1", "search term 2", ...],
|
||||
"vibe_description": "Brief description of the vibe"
|
||||
}}"""
|
||||
|
||||
response = await self._model.generate_content_async(prompt)
|
||||
text = response.text.strip()
|
||||
|
||||
# Extract JSON
|
||||
if "```json" in text:
|
||||
text = text.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in text:
|
||||
text = text.split("```")[1].split("```")[0].strip()
|
||||
|
||||
data = json.loads(text)
|
||||
|
||||
# Build search terms from recommendations
|
||||
search_terms = []
|
||||
for rec in data.get("recommendations", [])[:count]:
|
||||
artist = rec.get("artist", "")
|
||||
title = rec.get("title", "")
|
||||
if artist and title:
|
||||
search_terms.append(f"{artist} {title}")
|
||||
|
||||
# Add suggested searches as fallback
|
||||
search_terms.extend(data.get("suggested_searches", [])[:3])
|
||||
|
||||
logger.info(f"AI Radio generated {len(search_terms)} recommendations")
|
||||
|
||||
return {
|
||||
"search_terms": search_terms,
|
||||
"recommendations": data.get("recommendations", []),
|
||||
"vibe_description": data.get("vibe_description", ""),
|
||||
"method": "ai"
|
||||
}
|
||||
|
||||
def _fallback_recommendations(
|
||||
self,
|
||||
seed_track: Optional[Dict[str, Any]],
|
||||
mood: Optional[str],
|
||||
count: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Fallback when AI is unavailable."""
|
||||
search_terms = []
|
||||
|
||||
if seed_track:
|
||||
# Search for similar based on artist
|
||||
artist = seed_track.get("artists", "").split(",")[0].strip()
|
||||
if artist:
|
||||
search_terms.append(f"{artist}")
|
||||
search_terms.append(f"{artist} remix")
|
||||
|
||||
if mood:
|
||||
search_terms.append(mood)
|
||||
|
||||
# Generic fallback
|
||||
if not search_terms:
|
||||
search_terms = ["popular electronic", "chill beats", "dance hits"]
|
||||
|
||||
return {
|
||||
"search_terms": search_terms[:count],
|
||||
"recommendations": [],
|
||||
"vibe_description": "Based on your selection",
|
||||
"method": "fallback"
|
||||
}
|
||||
|
||||
|
||||
|
||||
async def generate_playlist(
|
||||
self,
|
||||
description: str,
|
||||
duration_mins: int = 60,
|
||||
track_count: int = 15
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a playlist from a natural language description.
|
||||
|
||||
Args:
|
||||
description: Playlist description like "morning coffee jazz" or "high energy workout"
|
||||
duration_mins: Target duration in minutes
|
||||
track_count: Number of tracks to generate
|
||||
|
||||
Returns:
|
||||
Dict with tracks (artist + title pairs), playlist name, description
|
||||
"""
|
||||
if not self._init_genai() or not self._model:
|
||||
return {
|
||||
"tracks": [],
|
||||
"playlist_name": "Generated Playlist",
|
||||
"description": description,
|
||||
"method": "fallback",
|
||||
"error": "AI not available"
|
||||
}
|
||||
|
||||
try:
|
||||
import json
|
||||
|
||||
# Estimate tracks based on duration (avg 3.5 min per track)
|
||||
estimated_tracks = min(max(duration_mins // 4, 5), track_count)
|
||||
|
||||
prompt = f"""You are a music curator. Create a playlist based on this description.
|
||||
|
||||
DESCRIPTION: "{description}"
|
||||
TARGET DURATION: ~{duration_mins} minutes ({estimated_tracks} tracks)
|
||||
|
||||
TASK: Generate a cohesive playlist that matches the vibe and purpose.
|
||||
|
||||
RULES:
|
||||
1. Mix popular tracks with quality deep cuts
|
||||
2. Consider flow and energy progression
|
||||
3. Vary artists while maintaining style consistency
|
||||
4. Include specific, real songs (not made-up titles)
|
||||
|
||||
Respond ONLY with valid JSON:
|
||||
{{
|
||||
"playlist_name": "Creative name for this playlist",
|
||||
"description": "Brief description of the vibe",
|
||||
"tracks": [
|
||||
{{"artist": "Artist Name", "title": "Song Title"}},
|
||||
...
|
||||
]
|
||||
}}"""
|
||||
|
||||
response = await self._model.generate_content_async(prompt)
|
||||
text = response.text.strip()
|
||||
|
||||
# Extract JSON
|
||||
if "```json" in text:
|
||||
text = text.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in text:
|
||||
text = text.split("```")[1].split("```")[0].strip()
|
||||
|
||||
data = json.loads(text)
|
||||
data["method"] = "ai"
|
||||
data["requested_duration"] = duration_mins
|
||||
|
||||
logger.info(f"Generated playlist '{data.get('playlist_name')}' with {len(data.get('tracks', []))} tracks")
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Playlist generation error: {e}")
|
||||
return {
|
||||
"tracks": [],
|
||||
"playlist_name": "Generated Playlist",
|
||||
"description": description,
|
||||
"method": "fallback",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
ai_radio_service = AIRadioService()
|
||||
1036
app/audio_service.py
Normal file
1036
app/audio_service.py
Normal file
File diff suppressed because it is too large
Load diff
133
app/cache.py
Normal file
133
app/cache.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""
|
||||
Cache service for storing transcoded audio files.
|
||||
Implements auto-cleanup to stay within storage limits.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
import aiofiles
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache configuration
|
||||
CACHE_DIR = Path(os.environ.get("CACHE_DIR", "/tmp/spotiflac_cache"))
|
||||
MAX_CACHE_SIZE_MB = int(os.environ.get("MAX_CACHE_SIZE_MB", "500"))
|
||||
CACHE_TTL_HOURS = int(os.environ.get("CACHE_TTL_HOURS", "24"))
|
||||
|
||||
|
||||
def ensure_cache_dir():
|
||||
"""Ensure cache directory exists."""
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return CACHE_DIR
|
||||
|
||||
|
||||
def get_cache_path(isrc: str, format: str = "mp3") -> Path:
|
||||
"""Get the cache file path for a given ISRC.
|
||||
|
||||
For LINK: prefixed IDs (which can be very long base64 strings),
|
||||
we hash the ID to create a shorter, valid filename.
|
||||
"""
|
||||
import hashlib
|
||||
ensure_cache_dir()
|
||||
|
||||
# Hash long IDs to prevent "filename too long" errors
|
||||
if len(isrc) > 100 or isrc.startswith("LINK:"):
|
||||
safe_name = hashlib.md5(isrc.encode()).hexdigest()
|
||||
else:
|
||||
# Sanitize the ISRC for use as filename
|
||||
safe_name = isrc.replace("/", "_").replace(":", "_")
|
||||
|
||||
return CACHE_DIR / f"{safe_name}.{format}"
|
||||
|
||||
|
||||
def is_cached(isrc: str, format: str = "mp3") -> bool:
|
||||
"""Check if a track is cached."""
|
||||
cache_path = get_cache_path(isrc, format)
|
||||
return cache_path.exists() and cache_path.stat().st_size > 0
|
||||
|
||||
|
||||
async def get_cached_file(isrc: str, format: str = "mp3") -> Optional[bytes]:
|
||||
"""Retrieve a cached file if it exists."""
|
||||
cache_path = get_cache_path(isrc, format)
|
||||
if cache_path.exists():
|
||||
try:
|
||||
# Update access time
|
||||
cache_path.touch()
|
||||
async with aiofiles.open(cache_path, 'rb') as f:
|
||||
return await f.read()
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading cache for {isrc}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def cache_file(isrc: str, data: bytes, format: str = "mp3") -> bool:
|
||||
"""Cache a transcoded file."""
|
||||
try:
|
||||
cache_path = get_cache_path(isrc, format)
|
||||
async with aiofiles.open(cache_path, 'wb') as f:
|
||||
await f.write(data)
|
||||
logger.info(f"Cached {isrc}.{format} ({len(data) / 1024 / 1024:.2f} MB)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error caching {isrc}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_cache_size_mb() -> float:
|
||||
"""Get total cache size in MB."""
|
||||
ensure_cache_dir()
|
||||
total = sum(f.stat().st_size for f in CACHE_DIR.iterdir() if f.is_file())
|
||||
return total / 1024 / 1024
|
||||
|
||||
|
||||
async def cleanup_cache():
|
||||
"""Remove old files to stay within cache limits."""
|
||||
ensure_cache_dir()
|
||||
now = time.time()
|
||||
ttl_seconds = CACHE_TTL_HOURS * 3600
|
||||
max_bytes = MAX_CACHE_SIZE_MB * 1024 * 1024
|
||||
|
||||
files = []
|
||||
for f in CACHE_DIR.iterdir():
|
||||
if f.is_file():
|
||||
stat = f.stat()
|
||||
files.append({
|
||||
'path': f,
|
||||
'size': stat.st_size,
|
||||
'atime': stat.st_atime
|
||||
})
|
||||
|
||||
# Remove files older than TTL
|
||||
for file_info in files[:]:
|
||||
if now - file_info['atime'] > ttl_seconds:
|
||||
try:
|
||||
file_info['path'].unlink()
|
||||
files.remove(file_info)
|
||||
logger.info(f"Removed expired cache file: {file_info['path'].name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing {file_info['path']}: {e}")
|
||||
|
||||
# If still over limit, remove oldest files
|
||||
files.sort(key=lambda x: x['atime'])
|
||||
total_size = sum(f['size'] for f in files)
|
||||
|
||||
while total_size > max_bytes and files:
|
||||
oldest = files.pop(0)
|
||||
try:
|
||||
oldest['path'].unlink()
|
||||
total_size -= oldest['size']
|
||||
logger.info(f"Removed cache file to free space: {oldest['path'].name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing {oldest['path']}: {e}")
|
||||
|
||||
logger.info(f"Cache size after cleanup: {total_size / 1024 / 1024:.2f} MB")
|
||||
|
||||
|
||||
async def periodic_cleanup(interval_minutes: int = 30):
|
||||
"""Run cache cleanup periodically."""
|
||||
while True:
|
||||
await asyncio.sleep(interval_minutes * 60)
|
||||
await cleanup_cache()
|
||||
324
app/concert_service.py
Normal file
324
app/concert_service.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
"""
|
||||
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()
|
||||
258
app/dab_service.py
Normal file
258
app/dab_service.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
"""
|
||||
Dab Music Service
|
||||
Retrieves Hi-Res audio from Dab Music API.
|
||||
"""
|
||||
import httpx
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DabService:
|
||||
BASE_URL = "https://dabmusic.xyz/api"
|
||||
|
||||
def __init__(self):
|
||||
self._initialized = False
|
||||
self.client = None
|
||||
self.session_token = ""
|
||||
self.visitor_id = ""
|
||||
|
||||
def _ensure_initialized(self):
|
||||
"""Lazy initialization - loads credentials on first use, not import time."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
# Load credentials at runtime (not import time) for cloud deployment compatibility
|
||||
self.session_token = os.getenv("DAB_SESSION", "")
|
||||
self.visitor_id = os.getenv("DAB_VISITOR_ID", "")
|
||||
|
||||
# Debug: Log if credentials are present (not the actual values)
|
||||
if self.session_token:
|
||||
logger.info(f"Dab credentials loaded: session={len(self.session_token)} chars, visitor={len(self.visitor_id)} chars")
|
||||
else:
|
||||
logger.warning("Dab credentials not found - Hi-Res streaming will be unavailable")
|
||||
|
||||
self.headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Referer": "https://dabmusic.xyz/",
|
||||
"Origin": "https://dabmusic.xyz"
|
||||
}
|
||||
self.cookies = {
|
||||
"session": self.session_token
|
||||
}
|
||||
|
||||
self.client = httpx.AsyncClient(
|
||||
headers=self.headers,
|
||||
cookies=self.cookies,
|
||||
timeout=15.0,
|
||||
follow_redirects=True
|
||||
)
|
||||
self._initialized = True
|
||||
|
||||
async def search_tracks(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Search for tracks on Dab Music."""
|
||||
self._ensure_initialized()
|
||||
try:
|
||||
resp = await self.client.get(
|
||||
f"{self.BASE_URL}/search",
|
||||
params={"q": query, "type": "track", "limit": limit}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
tracks = data.get("tracks", [])
|
||||
|
||||
# Debug logging
|
||||
if tracks and len(tracks) > 0:
|
||||
logger.info(f"Dab Search Track 0: {type(tracks[0])} - {str(tracks[0])[:50]}...")
|
||||
|
||||
return [self._format_track(t) for t in tracks if isinstance(t, dict)]
|
||||
elif resp.status_code == 401:
|
||||
logger.error("Dab API Unauthorized - Token may have expired")
|
||||
else:
|
||||
logger.warning(f"Dab search failed: {resp.status_code}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Dab search error: {e}")
|
||||
return []
|
||||
|
||||
async def search_albums(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Search for albums on Dab Music."""
|
||||
self._ensure_initialized()
|
||||
try:
|
||||
resp = await self.client.get(
|
||||
f"{self.BASE_URL}/search",
|
||||
params={"q": query, "type": "album", "limit": limit}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
albums = data.get("albums", [])
|
||||
|
||||
# Debug logging
|
||||
if albums and len(albums) > 0:
|
||||
logger.info(f"Dab Search Album 0: {type(albums[0])} - {albums[0]}")
|
||||
|
||||
return [self._format_album(a) for a in albums if isinstance(a, dict)]
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Dab album search error: {e}")
|
||||
return []
|
||||
|
||||
async def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get album details with tracks."""
|
||||
self._ensure_initialized()
|
||||
try:
|
||||
clean_id = album_id.replace("dab_", "")
|
||||
# Try getAlbum endpoint first (based on test results, /getAlbum works usually if /album fails)
|
||||
# Actually test showed 404 for all... Wait, I need to be careful.
|
||||
# But earlier test showed /album and /getAlbum failing with 400/404.
|
||||
# BUT web interface uses: /api/get?id=... (from observation usually)
|
||||
# Or /api/album?id=...
|
||||
# I will try /getAlbum based on typical Qobuz proxies, or fallback to search if needed?
|
||||
# actually if the test failed, I might need to rely on what I saw in other code or assume /getAlbum or /album.
|
||||
# Let's try /getAlbum with 'albumId' param as that is specific to Dab often.
|
||||
|
||||
resp = await self.client.get(f"{self.BASE_URL}/getAlbum", params={"albumId": clean_id})
|
||||
if resp.status_code != 200:
|
||||
resp = await self.client.get(f"{self.BASE_URL}/album", params={"albumId": clean_id})
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
|
||||
# Check for nested 'album' key which is common in getAlbum/album endpoints
|
||||
album_data = data.get("album", data)
|
||||
|
||||
album = self._format_album(album_data)
|
||||
|
||||
tracks = []
|
||||
# Tracks are usually inside the album object or 'tracks' key
|
||||
raw_tracks = album_data.get("tracks", [])
|
||||
# Sometimes tracks are wrapped in 'items'
|
||||
if isinstance(raw_tracks, dict) and "items" in raw_tracks:
|
||||
raw_tracks = raw_tracks["items"]
|
||||
elif not isinstance(raw_tracks, list):
|
||||
raw_tracks = []
|
||||
|
||||
tracks = [self._format_track(t, album_info=album_data) for t in raw_tracks]
|
||||
album["tracks"] = tracks
|
||||
return album
|
||||
|
||||
logger.warning(f"Dab get_album failed: {resp.status_code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Dab get_album error: {e}")
|
||||
return None
|
||||
|
||||
def _format_track(self, item: dict, album_info: dict = None) -> dict:
|
||||
"""Format Dab track to frontend schema."""
|
||||
# Clean ID
|
||||
track_id = str(item.get("id"))
|
||||
|
||||
# Album info might come from item or parent
|
||||
alb_title = item.get("albumTitle") or item.get("album", {}).get("title")
|
||||
if album_info: alb_title = alb_title or album_info.get("title")
|
||||
|
||||
alb_cover = item.get("albumCover") or item.get("album", {}).get("cover")
|
||||
if not alb_cover and album_info:
|
||||
alb_cover = album_info.get("image", {}).get("large") or album_info.get("cover")
|
||||
|
||||
# Artist
|
||||
artist_obj = item.get("artist")
|
||||
if isinstance(artist_obj, dict):
|
||||
artist_name = artist_obj.get("name")
|
||||
else:
|
||||
artist_name = artist_obj
|
||||
|
||||
if not artist_name and album_info:
|
||||
artist_val = album_info.get("artist")
|
||||
if isinstance(artist_val, dict):
|
||||
artist_name = artist_val.get("name")
|
||||
else:
|
||||
artist_name = artist_val
|
||||
|
||||
return {
|
||||
"id": f"dab_{track_id}",
|
||||
"type": "track",
|
||||
"name": item.get("title", "Unknown"),
|
||||
"artists": artist_name,
|
||||
"artist_names": [artist_name],
|
||||
"album": alb_title,
|
||||
"album_id": f"dab_{item.get('albumId') or (album_info['id'] if album_info else '')}",
|
||||
"album_art": alb_cover,
|
||||
"duration_ms": item.get("duration", 0) * 1000,
|
||||
"duration": self._format_duration(item.get("duration", 0) * 1000),
|
||||
"isrc": item.get("isrc"), # Dab often provides isrc
|
||||
"release_date": item.get("releaseDate", ""),
|
||||
"source": "dab",
|
||||
"is_hi_res": item.get("audioQuality", {}).get("isHiRes", False)
|
||||
}
|
||||
|
||||
def _format_album(self, item: dict) -> dict:
|
||||
"""Format Dab album to frontend schema."""
|
||||
# Extract images
|
||||
images = item.get("images", {})
|
||||
cover = None
|
||||
if isinstance(images, dict):
|
||||
cover = images.get("large") or images.get("medium")
|
||||
|
||||
if not cover: cover = item.get("cover") # Fallback to top level
|
||||
|
||||
if isinstance(cover, dict): cover = cover.get("large") # Handle nested cases
|
||||
|
||||
# Handle Artist
|
||||
artist_obj = item.get("artist")
|
||||
if isinstance(artist_obj, dict):
|
||||
artist_name = artist_obj.get("name")
|
||||
else:
|
||||
artist_name = artist_obj
|
||||
|
||||
# Extract audio quality info
|
||||
audio_quality = item.get("audioQuality", {})
|
||||
|
||||
return {
|
||||
"id": f"dab_{item.get('id')}",
|
||||
"type": "album",
|
||||
"name": item.get("title", ""),
|
||||
"artists": artist_name,
|
||||
"album_art": cover,
|
||||
"release_date": item.get("releaseDate", ""),
|
||||
"total_tracks": item.get("trackCount", 0),
|
||||
"source": "dab",
|
||||
"is_hi_res": audio_quality.get("isHiRes", False),
|
||||
"audio_quality": {
|
||||
"maximumBitDepth": audio_quality.get("maximumBitDepth", 16),
|
||||
"maximumSamplingRate": audio_quality.get("maximumSamplingRate", 44.1),
|
||||
"isHiRes": audio_quality.get("isHiRes", False)
|
||||
},
|
||||
"format": "FLAC" if audio_quality.get("isHiRes", False) else "FLAC"
|
||||
}
|
||||
|
||||
def _format_duration(self, ms: int) -> str:
|
||||
seconds = int(ms // 1000)
|
||||
minutes = seconds // 60
|
||||
secs = seconds % 60
|
||||
return f"{minutes}:{secs:02d}"
|
||||
|
||||
async def get_stream_url(self, track_id: str, quality: str = "27") -> Optional[str]:
|
||||
"""Get stream URL for a track. Quality 27=Hi-Res, 7=Lossless."""
|
||||
self._ensure_initialized()
|
||||
try:
|
||||
clean_id = str(track_id).replace("dab_", "")
|
||||
resp = await self.client.get(
|
||||
f"{self.BASE_URL}/stream",
|
||||
params={"trackId": clean_id, "quality": quality}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return data.get("url")
|
||||
logger.warning(f"Dab stream fetch failed: {resp.status_code} - {resp.text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Dab stream error: {e}")
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
await self.client.aclose()
|
||||
|
||||
# Singleton
|
||||
dab_service = DabService()
|
||||
160
app/deezer_service.py
Normal file
160
app/deezer_service.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""
|
||||
Deezer service for Freedify.
|
||||
Provides search (tracks, albums, artists) as fallback when Spotify is rate limited.
|
||||
Deezer API is free and doesn't require authentication for basic searches.
|
||||
"""
|
||||
import httpx
|
||||
from typing import Optional, Dict, List, Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeezerService:
|
||||
"""Service for searching and fetching metadata from Deezer."""
|
||||
|
||||
API_BASE = "https://api.deezer.com"
|
||||
|
||||
def __init__(self):
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
async def _api_request(self, endpoint: str, params: dict = None) -> dict:
|
||||
"""Make API request to Deezer."""
|
||||
response = await self.client.get(f"{self.API_BASE}{endpoint}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# ========== TRACK METHODS ==========
|
||||
|
||||
async def search_tracks(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""Search for tracks."""
|
||||
data = await self._api_request("/search/track", {"q": query, "limit": limit, "index": offset})
|
||||
return [self._format_track(item) for item in data.get("data", [])]
|
||||
|
||||
def _format_track(self, item: dict) -> dict:
|
||||
"""Format track data for frontend (matching Spotify format)."""
|
||||
album = item.get("album", {})
|
||||
artist = item.get("artist", {})
|
||||
return {
|
||||
"id": f"dz_{item['id']}",
|
||||
"type": "track",
|
||||
"name": item.get("title", ""),
|
||||
"artists": artist.get("name", ""),
|
||||
"artist_names": [artist.get("name", "")],
|
||||
"album": album.get("title", ""),
|
||||
"album_id": f"dz_{album.get('id', '')}",
|
||||
"album_art": album.get("cover_xl") or album.get("cover_big") or album.get("cover_medium"),
|
||||
"duration_ms": item.get("duration", 0) * 1000,
|
||||
"duration": self._format_duration(item.get("duration", 0) * 1000),
|
||||
"isrc": item.get("isrc"),
|
||||
"preview_url": item.get("preview"),
|
||||
"release_date": album.get("release_date", ""),
|
||||
"source": "deezer",
|
||||
}
|
||||
|
||||
# ========== ALBUM METHODS ==========
|
||||
|
||||
async def search_albums(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""Search for albums."""
|
||||
data = await self._api_request("/search/album", {"q": query, "limit": limit, "index": offset})
|
||||
return [self._format_album(item) for item in data.get("data", [])]
|
||||
|
||||
async def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get album with all tracks."""
|
||||
try:
|
||||
# Remove dz_ prefix if present
|
||||
clean_id = album_id.replace("dz_", "")
|
||||
data = await self._api_request(f"/album/{clean_id}")
|
||||
album = self._format_album(data)
|
||||
|
||||
# Format tracks
|
||||
tracks = []
|
||||
for item in data.get("tracks", {}).get("data", []):
|
||||
track = {
|
||||
"id": f"dz_{item['id']}",
|
||||
"type": "track",
|
||||
"name": item.get("title", ""),
|
||||
"artists": data.get("artist", {}).get("name", ""),
|
||||
"artist_names": [data.get("artist", {}).get("name", "")],
|
||||
"album": data.get("title", ""),
|
||||
"album_id": f"dz_{clean_id}",
|
||||
"album_art": album["album_art"],
|
||||
"duration_ms": item.get("duration", 0) * 1000,
|
||||
"duration": self._format_duration(item.get("duration", 0) * 1000),
|
||||
"isrc": item.get("isrc"),
|
||||
"preview_url": item.get("preview"),
|
||||
"release_date": data.get("release_date", ""),
|
||||
"source": "deezer",
|
||||
}
|
||||
tracks.append(track)
|
||||
|
||||
album["tracks"] = tracks
|
||||
return album
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Deezer album {album_id}: {e}")
|
||||
return None
|
||||
|
||||
def _format_album(self, item: dict) -> dict:
|
||||
"""Format album data for frontend."""
|
||||
artist = item.get("artist", {})
|
||||
return {
|
||||
"id": f"dz_{item['id']}",
|
||||
"type": "album",
|
||||
"name": item.get("title", ""),
|
||||
"artists": artist.get("name", ""),
|
||||
"album_art": item.get("cover_xl") or item.get("cover_big") or item.get("cover_medium"),
|
||||
"release_date": item.get("release_date", ""),
|
||||
"total_tracks": item.get("nb_tracks", 0),
|
||||
"source": "deezer",
|
||||
}
|
||||
|
||||
# ========== ARTIST METHODS ==========
|
||||
|
||||
async def search_artists(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""Search for artists."""
|
||||
data = await self._api_request("/search/artist", {"q": query, "limit": limit, "index": offset})
|
||||
return [self._format_artist(item) for item in data.get("data", [])]
|
||||
|
||||
async def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get artist info with top tracks."""
|
||||
try:
|
||||
clean_id = artist_id.replace("dz_", "")
|
||||
data = await self._api_request(f"/artist/{clean_id}")
|
||||
artist = self._format_artist(data)
|
||||
|
||||
# Get top tracks
|
||||
top_tracks = await self._api_request(f"/artist/{clean_id}/top", {"limit": 10})
|
||||
artist["tracks"] = [self._format_track(t) for t in top_tracks.get("data", [])]
|
||||
|
||||
return artist
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Deezer artist {artist_id}: {e}")
|
||||
return None
|
||||
|
||||
def _format_artist(self, item: dict) -> dict:
|
||||
"""Format artist data for frontend."""
|
||||
return {
|
||||
"id": f"dz_{item['id']}",
|
||||
"type": "artist",
|
||||
"name": item.get("name", ""),
|
||||
"image": item.get("picture_xl") or item.get("picture_big") or item.get("picture_medium"),
|
||||
"fans": item.get("nb_fan", 0),
|
||||
"source": "deezer",
|
||||
}
|
||||
|
||||
# ========== UTILITIES ==========
|
||||
|
||||
def _format_duration(self, ms: int) -> str:
|
||||
"""Format duration from ms to MM:SS."""
|
||||
seconds = ms // 1000
|
||||
minutes = seconds // 60
|
||||
secs = seconds % 60
|
||||
return f"{minutes}:{secs:02d}"
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client."""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
deezer_service = DeezerService()
|
||||
406
app/dj_service.py
Normal file
406
app/dj_service.py
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
"""
|
||||
DJ Service for Freedify.
|
||||
AI-powered setlist generation using Gemini 2.0 Flash.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Camelot wheel compatibility chart
|
||||
# Compatible keys: same key, +1/-1 on wheel, switch A/B (relative major/minor)
|
||||
CAMELOT_COMPAT = {
|
||||
"1A": ["1A", "1B", "12A", "2A"],
|
||||
"1B": ["1B", "1A", "12B", "2B"],
|
||||
"2A": ["2A", "2B", "1A", "3A"],
|
||||
"2B": ["2B", "2A", "1B", "3B"],
|
||||
"3A": ["3A", "3B", "2A", "4A"],
|
||||
"3B": ["3B", "3A", "2B", "4B"],
|
||||
"4A": ["4A", "4B", "3A", "5A"],
|
||||
"4B": ["4B", "4A", "3B", "5B"],
|
||||
"5A": ["5A", "5B", "4A", "6A"],
|
||||
"5B": ["5B", "5A", "4B", "6B"],
|
||||
"6A": ["6A", "6B", "5A", "7A"],
|
||||
"6B": ["6B", "6A", "5B", "7B"],
|
||||
"7A": ["7A", "7B", "6A", "8A"],
|
||||
"7B": ["7B", "7A", "6B", "8B"],
|
||||
"8A": ["8A", "8B", "7A", "9A"],
|
||||
"8B": ["8B", "8A", "7B", "9B"],
|
||||
"9A": ["9A", "9B", "8A", "10A"],
|
||||
"9B": ["9B", "9A", "8B", "10B"],
|
||||
"10A": ["10A", "10B", "9A", "11A"],
|
||||
"10B": ["10B", "10A", "9B", "11B"],
|
||||
"11A": ["11A", "11B", "10A", "12A"],
|
||||
"11B": ["11B", "11A", "10B", "12B"],
|
||||
"12A": ["12A", "12B", "11A", "1A"],
|
||||
"12B": ["12B", "12A", "11B", "1B"],
|
||||
}
|
||||
|
||||
|
||||
class DJService:
|
||||
"""AI-powered DJ setlist generator using Gemini 2.0 Flash."""
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = os.environ.get("GEMINI_API_KEY")
|
||||
self._genai = None
|
||||
self._model = None
|
||||
|
||||
def _init_genai(self):
|
||||
"""Lazy initialization of Gemini client."""
|
||||
if self._genai is None:
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
if not self.api_key:
|
||||
logger.warning("GEMINI_API_KEY not set - AI features will use rule-based fallback")
|
||||
return False
|
||||
genai.configure(api_key=self.api_key)
|
||||
self._genai = genai
|
||||
self._model = genai.GenerativeModel('gemini-2.0-flash')
|
||||
logger.info("Gemini 2.0 Flash initialized successfully")
|
||||
return True
|
||||
except ImportError:
|
||||
logger.warning("google-generativeai not installed - using rule-based fallback")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Gemini: {e}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_harmonically_compatible(self, camelot1: str, camelot2: str) -> bool:
|
||||
"""Check if two Camelot keys are harmonically compatible."""
|
||||
if camelot1 == "?" or camelot2 == "?":
|
||||
return False
|
||||
return camelot2 in CAMELOT_COMPAT.get(camelot1, [])
|
||||
|
||||
def _rule_based_setlist(self, tracks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generate setlist using rule-based algorithm when AI is unavailable.
|
||||
Strategy: Sort by energy, then optimize for harmonic compatibility.
|
||||
"""
|
||||
if len(tracks) <= 2:
|
||||
return sorted(tracks, key=lambda t: t.get("energy", 0.5))
|
||||
|
||||
# Start with lowest energy track
|
||||
sorted_tracks = sorted(tracks, key=lambda t: t.get("energy", 0.5))
|
||||
setlist = [sorted_tracks.pop(0)]
|
||||
|
||||
while sorted_tracks:
|
||||
last = setlist[-1]
|
||||
last_camelot = last.get("camelot", "?")
|
||||
last_bpm = last.get("bpm", 120)
|
||||
|
||||
# Score remaining tracks by compatibility
|
||||
def score_track(t):
|
||||
score = 0
|
||||
# Harmonic compatibility (+10 points)
|
||||
if self.is_harmonically_compatible(last_camelot, t.get("camelot", "?")):
|
||||
score += 10
|
||||
# BPM proximity (+5 for within 5 BPM, +3 for within 10)
|
||||
bpm_diff = abs(t.get("bpm", 120) - last_bpm)
|
||||
if bpm_diff <= 5:
|
||||
score += 5
|
||||
elif bpm_diff <= 10:
|
||||
score += 3
|
||||
# Slight energy increase preferred (+2)
|
||||
energy_diff = t.get("energy", 0.5) - last.get("energy", 0.5)
|
||||
if 0 < energy_diff < 0.15:
|
||||
score += 2
|
||||
return score
|
||||
|
||||
# Pick best scoring track
|
||||
sorted_tracks.sort(key=score_track, reverse=True)
|
||||
setlist.append(sorted_tracks.pop(0))
|
||||
|
||||
return setlist
|
||||
|
||||
async def generate_setlist(
|
||||
self,
|
||||
tracks: List[Dict[str, Any]],
|
||||
style: str = "progressive" # or "peak-time", "chill", "journey"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate an AI-optimized DJ setlist.
|
||||
|
||||
Args:
|
||||
tracks: List of tracks with bpm, camelot, energy, name, artists
|
||||
style: Setlist style preference
|
||||
|
||||
Returns:
|
||||
Dict with ordered track IDs and mixing suggestions
|
||||
"""
|
||||
if len(tracks) < 2:
|
||||
return {
|
||||
"ordered_ids": [t.get("id") for t in tracks],
|
||||
"suggestions": [],
|
||||
"method": "passthrough"
|
||||
}
|
||||
|
||||
# Try AI generation first
|
||||
if self._init_genai() and self._model:
|
||||
try:
|
||||
result = await self._ai_generate_setlist(tracks, style)
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"AI setlist generation failed: {e}")
|
||||
|
||||
# Fallback to rule-based
|
||||
logger.info("Using rule-based setlist generation")
|
||||
ordered = self._rule_based_setlist(tracks.copy())
|
||||
|
||||
# Generate basic suggestions
|
||||
suggestions = []
|
||||
for i in range(len(ordered) - 1):
|
||||
t1, t2 = ordered[i], ordered[i+1]
|
||||
bpm_diff = abs(t2.get("bpm", 0) - t1.get("bpm", 0))
|
||||
compatible = self.is_harmonically_compatible(
|
||||
t1.get("camelot", "?"), t2.get("camelot", "?")
|
||||
)
|
||||
|
||||
suggestion = {
|
||||
"from_id": t1.get("id"),
|
||||
"to_id": t2.get("id"),
|
||||
"harmonic_match": compatible,
|
||||
"bpm_diff": bpm_diff,
|
||||
}
|
||||
|
||||
if compatible and bpm_diff <= 5:
|
||||
suggestion["tip"] = "Perfect mix - smooth harmonic transition"
|
||||
elif compatible:
|
||||
suggestion["tip"] = f"Harmonically compatible, adjust BPM by {bpm_diff}"
|
||||
elif bpm_diff <= 3:
|
||||
suggestion["tip"] = "BPM locked, consider EQ mixing"
|
||||
else:
|
||||
suggestion["tip"] = "Energy transition - use effects or beat drop"
|
||||
|
||||
suggestions.append(suggestion)
|
||||
|
||||
return {
|
||||
"ordered_ids": [t.get("id") for t in ordered],
|
||||
"suggestions": suggestions,
|
||||
"method": "rule-based"
|
||||
}
|
||||
|
||||
async def _ai_generate_setlist(
|
||||
self,
|
||||
tracks: List[Dict[str, Any]],
|
||||
style: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Generate setlist using Gemini AI."""
|
||||
import json
|
||||
|
||||
# Build track summary for the prompt
|
||||
track_summary = []
|
||||
for i, t in enumerate(tracks):
|
||||
track_summary.append(
|
||||
f"{i+1}. \"{t.get('name', 'Unknown')}\" by {t.get('artists', 'Unknown')} | "
|
||||
f"BPM: {t.get('bpm', '?')} | Key: {t.get('camelot', '?')} | Energy: {t.get('energy', '?')}"
|
||||
)
|
||||
|
||||
style_desc = {
|
||||
"progressive": "gradually build energy from low to high, creating a journey",
|
||||
"peak-time": "maintain high energy throughout with dramatic moments",
|
||||
"chill": "keep energy low to medium, prioritizing smooth vibes",
|
||||
"journey": "create a wave pattern - build up, peak, come down, build again"
|
||||
}.get(style, "gradually build energy")
|
||||
|
||||
prompt = f"""You are an expert DJ creating an optimal setlist. Analyze these tracks and order them for the best flow.
|
||||
|
||||
TRACKS:
|
||||
{chr(10).join(track_summary)}
|
||||
|
||||
GOAL: {style_desc}
|
||||
|
||||
MIXING RULES:
|
||||
1. Harmonically compatible keys mix best (same Camelot number, or ±1, or A↔B switch)
|
||||
2. Keep BPM changes within ±8 BPM between tracks for smooth mixing
|
||||
3. Energy should follow the style pattern
|
||||
4. Consider musical "story" - intro, build, peak, outro
|
||||
|
||||
DJ TECHNIQUES TO SUGGEST:
|
||||
- "Long Blend" - 16-32 bar crossfade with EQ swapping
|
||||
- "Filter Sweep" - Use low-pass or high-pass filter on outgoing track
|
||||
- "Echo Out" - Apply echo/delay while fading out
|
||||
- "Hard Cut" - Quick switch on phrase start (for genre changes or impact)
|
||||
- "Beat Drop" - Drop incoming track on a breakdown/drop
|
||||
- "EQ Swap" - Gradually swap bass/mids/highs between tracks
|
||||
- "Loop & Build" - Loop outgoing track while bringing in new one
|
||||
- "Acapella Blend" - If vocal track, layer over instrumental
|
||||
|
||||
Respond ONLY with valid JSON in this exact format:
|
||||
{{
|
||||
"order": [1, 3, 2, ...],
|
||||
"tips": [
|
||||
{{
|
||||
"from": 1,
|
||||
"to": 3,
|
||||
"technique": "Filter Sweep",
|
||||
"timing": "16 bars",
|
||||
"tip": "Filter out the bass of track 1, bring in track 3 on the drop"
|
||||
}},
|
||||
...
|
||||
]
|
||||
}}"""
|
||||
|
||||
try:
|
||||
response = await self._model.generate_content_async(prompt)
|
||||
text = response.text.strip()
|
||||
|
||||
# Extract JSON from response
|
||||
if "```json" in text:
|
||||
text = text.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in text:
|
||||
text = text.split("```")[1].split("```")[0].strip()
|
||||
|
||||
data = json.loads(text)
|
||||
order = data.get("order", [])
|
||||
tips = data.get("tips", [])
|
||||
|
||||
# Map back to track IDs
|
||||
ordered_ids = []
|
||||
for idx in order:
|
||||
if 1 <= idx <= len(tracks):
|
||||
ordered_ids.append(tracks[idx - 1].get("id"))
|
||||
|
||||
# Map tips to track IDs
|
||||
suggestions = []
|
||||
for tip in tips:
|
||||
from_idx = tip.get("from", 0)
|
||||
to_idx = tip.get("to", 0)
|
||||
if 1 <= from_idx <= len(tracks) and 1 <= to_idx <= len(tracks):
|
||||
t1 = tracks[from_idx - 1]
|
||||
t2 = tracks[to_idx - 1]
|
||||
suggestions.append({
|
||||
"from_id": t1.get("id"),
|
||||
"to_id": t2.get("id"),
|
||||
"harmonic_match": self.is_harmonically_compatible(
|
||||
t1.get("camelot", "?"), t2.get("camelot", "?")
|
||||
),
|
||||
"bpm_diff": abs(t2.get("bpm", 0) - t1.get("bpm", 0)),
|
||||
"technique": tip.get("technique", ""),
|
||||
"timing": tip.get("timing", ""),
|
||||
"tip": tip.get("tip", "")
|
||||
})
|
||||
|
||||
logger.info(f"AI generated setlist with {len(ordered_ids)} tracks")
|
||||
return {
|
||||
"ordered_ids": ordered_ids,
|
||||
"suggestions": suggestions,
|
||||
"method": "ai-gemini-2.0-flash"
|
||||
}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Failed to parse AI response as JSON: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"AI generation error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
return None
|
||||
|
||||
async def get_audio_features_ai(self, name: str, artist: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Estimate audio features using AI when Spotify data is unavailable.
|
||||
"""
|
||||
if not self._init_genai() or not self._model:
|
||||
return None
|
||||
|
||||
prompt = f"""Act as an expert musicologist and DJ.
|
||||
Provide the OFFICIAL studio audio analysis for the track:
|
||||
Title: "{name}"
|
||||
Artist: "{artist}"
|
||||
|
||||
Analyze the genre and style. (e.g. Dubstep/Mid-tempo is usually 90-110 BPM, House is 120-130).
|
||||
Provide the most accurate:
|
||||
1. BPM (Integer) - Check for half-time/double-time ambiguities.
|
||||
2. Key (Camelot Notation, e.g. 5A, 11B)
|
||||
3. Energy (0.0 to 1.0)
|
||||
|
||||
Respond ONLY with valid JSON:
|
||||
{{
|
||||
"bpm": 100,
|
||||
"camelot": "5A",
|
||||
"energy": 0.8
|
||||
}}"""
|
||||
|
||||
try:
|
||||
response = await self._model.generate_content_async(prompt)
|
||||
text = response.text.strip()
|
||||
|
||||
# Extract JSON
|
||||
import json
|
||||
if "```json" in text:
|
||||
text = text.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in text:
|
||||
text = text.split("```")[1].split("```")[0].strip()
|
||||
|
||||
data = json.loads(text)
|
||||
|
||||
# Validate
|
||||
return {
|
||||
"track_id": f"ai_{abs(hash(name + artist))}", # Dummy ID
|
||||
"bpm": int(data.get("bpm", 120)),
|
||||
"camelot": data.get("camelot", "?"),
|
||||
"energy": float(data.get("energy", 0.5)),
|
||||
"key": -1, # Unknown
|
||||
"mode": 0,
|
||||
"danceability": 0.5,
|
||||
"valence": 0.5,
|
||||
"source": "ai_estimate"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"AI audio features estimation failed (using fallback): {e}")
|
||||
return None
|
||||
|
||||
async def interpret_mood_query(self, query: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Interpret a natural language mood query using AI.
|
||||
Returns structured search terms and mood metadata.
|
||||
"""
|
||||
if not self._init_genai() or not self._model:
|
||||
return None
|
||||
|
||||
prompt = f"""You are a music discovery AI. The user wants to find music based on a mood or vibe description.
|
||||
|
||||
USER QUERY: "{query}"
|
||||
|
||||
Interpret this mood/vibe and provide:
|
||||
1. 3-5 search terms that would find matching songs (artist names, genres, song characteristics)
|
||||
2. Mood keywords that describe this vibe
|
||||
3. Suggested BPM range
|
||||
4. Energy level (low, medium, high)
|
||||
|
||||
Respond ONLY with valid JSON:
|
||||
{{
|
||||
"search_terms": ["term1", "term2", "term3"],
|
||||
"moods": ["chill", "relaxed"],
|
||||
"bpm_range": {{"min": 70, "max": 100}},
|
||||
"energy": "low",
|
||||
"description": "Brief 1-sentence description of the vibe"
|
||||
}}"""
|
||||
|
||||
try:
|
||||
import json
|
||||
response = await self._model.generate_content_async(prompt)
|
||||
text = response.text.strip()
|
||||
|
||||
# Extract JSON
|
||||
if "```json" in text:
|
||||
text = text.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in text:
|
||||
text = text.split("```")[1].split("```")[0].strip()
|
||||
|
||||
data = json.loads(text)
|
||||
logger.info(f"AI interpreted mood query: {query} -> {data.get('search_terms', [])}")
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"AI mood interpretation failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Singleton instance
|
||||
dj_service = DJService()
|
||||
241
app/genius_service.py
Normal file
241
app/genius_service.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"""
|
||||
Genius service for Freedify.
|
||||
Provides lyrics, annotations, and song information from Genius.
|
||||
API docs: https://docs.genius.com/
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GeniusService:
|
||||
"""Service for fetching lyrics and annotations from Genius."""
|
||||
|
||||
API_BASE = "https://api.genius.com"
|
||||
|
||||
def __init__(self):
|
||||
# Access token: use env var (required for production)
|
||||
self.access_token = os.environ.get("GENIUS_ACCESS_TOKEN", "")
|
||||
if not self.access_token:
|
||||
logger.warning("GENIUS_ACCESS_TOKEN not set - lyrics will not work")
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
async def _api_request(self, endpoint: str, params: dict = None) -> dict:
|
||||
"""Make authenticated API request to Genius."""
|
||||
headers = {"Authorization": f"Bearer {self.access_token}"}
|
||||
if params is None:
|
||||
params = {}
|
||||
|
||||
response = await self.client.get(
|
||||
f"{self.API_BASE}{endpoint}",
|
||||
headers=headers,
|
||||
params=params
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def search_song(self, query: str) -> Optional[Dict[str, Any]]:
|
||||
"""Search for a song on Genius. Returns the best match."""
|
||||
try:
|
||||
data = await self._api_request("/search", {"q": query})
|
||||
hits = data.get("response", {}).get("hits", [])
|
||||
|
||||
# Find first song result
|
||||
for hit in hits:
|
||||
if hit.get("type") == "song":
|
||||
song = hit.get("result", {})
|
||||
return {
|
||||
"id": song.get("id"),
|
||||
"title": song.get("title"),
|
||||
"artist": song.get("primary_artist", {}).get("name"),
|
||||
"url": song.get("url"),
|
||||
"thumbnail": song.get("song_art_image_thumbnail_url"),
|
||||
"full_title": song.get("full_title"),
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Genius search error: {e}")
|
||||
return None
|
||||
|
||||
async def get_song_details(self, song_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get detailed song information including annotations."""
|
||||
try:
|
||||
data = await self._api_request(f"/songs/{song_id}")
|
||||
song = data.get("response", {}).get("song", {})
|
||||
|
||||
# Extract useful info
|
||||
description = song.get("description", {})
|
||||
if isinstance(description, dict):
|
||||
description_text = description.get("plain", "")
|
||||
else:
|
||||
description_text = str(description) if description else ""
|
||||
|
||||
return {
|
||||
"id": song.get("id"),
|
||||
"title": song.get("title"),
|
||||
"artist": song.get("primary_artist", {}).get("name"),
|
||||
"album": song.get("album", {}).get("name") if song.get("album") else None,
|
||||
"release_date": song.get("release_date_for_display"),
|
||||
"url": song.get("url"),
|
||||
"thumbnail": song.get("song_art_image_url"),
|
||||
"description": description_text,
|
||||
"apple_music_id": song.get("apple_music_id"),
|
||||
"recording_location": song.get("recording_location"),
|
||||
"producer_artists": [p.get("name") for p in song.get("producer_artists", [])],
|
||||
"writer_artists": [w.get("name") for w in song.get("writer_artists", [])],
|
||||
"featured_artists": [f.get("name") for f in song.get("featured_artists", [])],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Genius song details error: {e}")
|
||||
return None
|
||||
|
||||
async def scrape_lyrics(self, genius_url: str) -> Optional[str]:
|
||||
"""Scrape lyrics from a Genius song page."""
|
||||
try:
|
||||
# Fetch the page
|
||||
response = await self.client.get(genius_url, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
# Genius uses data-lyrics-container for lyrics sections
|
||||
lyrics_containers = soup.find_all("div", {"data-lyrics-container": "true"})
|
||||
|
||||
if lyrics_containers:
|
||||
lyrics_parts = []
|
||||
for container in lyrics_containers:
|
||||
# Get text, preserving line breaks
|
||||
for br in container.find_all("br"):
|
||||
br.replace_with("\n")
|
||||
lyrics_parts.append(container.get_text())
|
||||
|
||||
lyrics = "\n".join(lyrics_parts)
|
||||
# Clean up extra whitespace
|
||||
lyrics = re.sub(r'\n{3,}', '\n\n', lyrics)
|
||||
return lyrics.strip()
|
||||
|
||||
# Fallback: try older format
|
||||
lyrics_div = soup.find("div", class_="lyrics")
|
||||
if lyrics_div:
|
||||
return lyrics_div.get_text().strip()
|
||||
|
||||
logger.warning(f"Could not find lyrics on page: {genius_url}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Genius lyrics scrape error: {e}")
|
||||
return None
|
||||
|
||||
async def get_song_referents(self, song_id: int) -> list:
|
||||
"""Get annotations for a song using the Genius API referents endpoint."""
|
||||
annotations = []
|
||||
try:
|
||||
# Use API to get referents (annotated sections)
|
||||
data = await self._api_request(f"/referents", {
|
||||
"song_id": song_id,
|
||||
"text_format": "plain",
|
||||
"per_page": 20
|
||||
})
|
||||
|
||||
referents = data.get("response", {}).get("referents", [])
|
||||
|
||||
for ref in referents[:15]: # Limit to 15 annotations
|
||||
fragment = ref.get("fragment", "")
|
||||
annotation_list = ref.get("annotations", [])
|
||||
|
||||
for ann in annotation_list:
|
||||
# Get the annotation body
|
||||
body = ann.get("body", {})
|
||||
if isinstance(body, dict):
|
||||
plain_text = body.get("plain", "")
|
||||
else:
|
||||
plain_text = str(body) if body else ""
|
||||
|
||||
# Also get the annotation state/votes for quality filtering
|
||||
votes_total = ann.get("votes_total", 0)
|
||||
|
||||
if plain_text and len(plain_text) > 10:
|
||||
annotations.append({
|
||||
"fragment": fragment[:150] + "..." if len(fragment) > 150 else fragment,
|
||||
"text": plain_text,
|
||||
"votes": votes_total
|
||||
})
|
||||
|
||||
# Sort by votes (most upvoted first)
|
||||
annotations.sort(key=lambda x: x.get("votes", 0), reverse=True)
|
||||
|
||||
return annotations
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Genius referents API error: {e}")
|
||||
return []
|
||||
|
||||
async def get_lyrics_and_info(self, artist: str, title: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Main method: Search for a song, get lyrics and details.
|
||||
Returns a dict with lyrics, about info, annotations, and metadata.
|
||||
"""
|
||||
result = {
|
||||
"found": False,
|
||||
"lyrics": None,
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"about": None,
|
||||
"album": None,
|
||||
"release_date": None,
|
||||
"producers": [],
|
||||
"writers": [],
|
||||
"annotations": [],
|
||||
"genius_url": None,
|
||||
"thumbnail": None,
|
||||
}
|
||||
|
||||
# Search for the song
|
||||
query = f"{artist} {title}"
|
||||
song = await self.search_song(query)
|
||||
|
||||
if not song:
|
||||
logger.info(f"No Genius match for: {query}")
|
||||
return result
|
||||
|
||||
result["found"] = True
|
||||
result["genius_url"] = song.get("url")
|
||||
result["thumbnail"] = song.get("thumbnail")
|
||||
result["title"] = song.get("title", title)
|
||||
result["artist"] = song.get("artist", artist)
|
||||
|
||||
# Get detailed info
|
||||
song_id = song.get("id")
|
||||
if song_id:
|
||||
details = await self.get_song_details(song_id)
|
||||
if details:
|
||||
result["about"] = details.get("description")
|
||||
result["album"] = details.get("album")
|
||||
result["release_date"] = details.get("release_date")
|
||||
result["producers"] = details.get("producer_artists", [])
|
||||
result["writers"] = details.get("writer_artists", [])
|
||||
|
||||
# Scrape lyrics
|
||||
if song.get("url"):
|
||||
lyrics = await self.scrape_lyrics(song["url"])
|
||||
result["lyrics"] = lyrics
|
||||
|
||||
# Get annotations via API (requires song_id)
|
||||
if song_id:
|
||||
annotations = await self.get_song_referents(song_id)
|
||||
result["annotations"] = annotations
|
||||
|
||||
return result
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client."""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
genius_service = GeniusService()
|
||||
254
app/jamendo_service.py
Normal file
254
app/jamendo_service.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
"""
|
||||
Jamendo service for Freedify.
|
||||
Provides access to 600,000+ independent and Creative Commons licensed tracks.
|
||||
Jamendo API docs: https://developer.jamendo.com/v3.0
|
||||
"""
|
||||
import os
|
||||
import httpx
|
||||
from typing import Optional, Dict, List, Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JamendoService:
|
||||
"""Service for searching and streaming from Jamendo."""
|
||||
|
||||
API_BASE = "https://api.jamendo.com/v3.0"
|
||||
|
||||
def __init__(self):
|
||||
# Client ID: use env var or fallback for local testing
|
||||
self.client_id = os.environ.get("JAMENDO_CLIENT_ID", "90aefcef")
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
async def _api_request(self, endpoint: str, params: dict = None) -> dict:
|
||||
"""Make API request to Jamendo."""
|
||||
if params is None:
|
||||
params = {}
|
||||
params["client_id"] = self.client_id
|
||||
params["format"] = "json"
|
||||
|
||||
response = await self.client.get(f"{self.API_BASE}{endpoint}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# ========== TRACK METHODS ==========
|
||||
|
||||
async def search_tracks(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""Search for tracks."""
|
||||
data = await self._api_request("/tracks/", {
|
||||
"search": query,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"include": "musicinfo licenses",
|
||||
"audioformat": "flac", # Request FLAC URLs
|
||||
})
|
||||
return [self._format_track(item) for item in data.get("results", [])]
|
||||
|
||||
async def get_track(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get single track details."""
|
||||
try:
|
||||
clean_id = track_id.replace("jm_", "")
|
||||
data = await self._api_request("/tracks/", {
|
||||
"id": clean_id,
|
||||
"include": "musicinfo licenses",
|
||||
"audioformat": "flac",
|
||||
})
|
||||
results = data.get("results", [])
|
||||
if results:
|
||||
return self._format_track(results[0])
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Jamendo track {track_id}: {e}")
|
||||
return None
|
||||
|
||||
def _format_track(self, item: dict) -> dict:
|
||||
"""Format track data for frontend (matching Spotify/Deezer format)."""
|
||||
# Get best quality audio URL (prefer FLAC, fallback to MP3)
|
||||
audio_url = item.get("audiodownload") or item.get("audio") or ""
|
||||
|
||||
# Jamendo returns audio URL with format parameter
|
||||
# We'll use the direct audio field which respects audioformat param
|
||||
return {
|
||||
"id": f"jm_{item['id']}",
|
||||
"type": "track",
|
||||
"name": item.get("name", ""),
|
||||
"artists": item.get("artist_name", ""),
|
||||
"artist_names": [item.get("artist_name", "")],
|
||||
"artist_id": f"jm_artist_{item.get('artist_id', '')}",
|
||||
"album": item.get("album_name", ""),
|
||||
"album_id": f"jm_{item.get('album_id', '')}",
|
||||
"album_art": item.get("album_image") or item.get("image") or "",
|
||||
"duration_ms": item.get("duration", 0) * 1000,
|
||||
"duration": self._format_duration(item.get("duration", 0) * 1000),
|
||||
"audio_url": audio_url, # Direct stream URL
|
||||
"license": item.get("license_ccurl", ""),
|
||||
"release_date": item.get("releasedate", ""),
|
||||
"source": "jamendo",
|
||||
"format": "flac" if "flac" in audio_url.lower() else "mp3",
|
||||
}
|
||||
|
||||
# ========== ALBUM METHODS ==========
|
||||
|
||||
async def search_albums(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""Search for albums."""
|
||||
data = await self._api_request("/albums/", {
|
||||
"search": query,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
return [self._format_album(item) for item in data.get("results", [])]
|
||||
|
||||
async def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get album with all tracks."""
|
||||
try:
|
||||
clean_id = album_id.replace("jm_", "")
|
||||
|
||||
# Get album info
|
||||
album_data = await self._api_request("/albums/", {"id": clean_id})
|
||||
albums = album_data.get("results", [])
|
||||
if not albums:
|
||||
return None
|
||||
|
||||
album = self._format_album(albums[0])
|
||||
|
||||
# Get album tracks
|
||||
tracks_data = await self._api_request("/albums/tracks/", {
|
||||
"id": clean_id,
|
||||
"audioformat": "flac",
|
||||
})
|
||||
|
||||
tracks = []
|
||||
for item in tracks_data.get("results", []):
|
||||
for track in item.get("tracks", []):
|
||||
track["album_name"] = album["name"]
|
||||
track["album_image"] = album["album_art"]
|
||||
track["album_id"] = clean_id
|
||||
track["artist_name"] = album["artists"]
|
||||
track["artist_id"] = albums[0].get("artist_id", "")
|
||||
tracks.append(self._format_track(track))
|
||||
|
||||
album["tracks"] = tracks
|
||||
return album
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Jamendo album {album_id}: {e}")
|
||||
return None
|
||||
|
||||
def _format_album(self, item: dict) -> dict:
|
||||
"""Format album data for frontend."""
|
||||
return {
|
||||
"id": f"jm_{item['id']}",
|
||||
"type": "album",
|
||||
"name": item.get("name", ""),
|
||||
"artists": item.get("artist_name", ""),
|
||||
"artist_id": f"jm_artist_{item.get('artist_id', '')}",
|
||||
"album_art": item.get("image") or "",
|
||||
"release_date": item.get("releasedate", ""),
|
||||
"total_tracks": 0, # Not always provided
|
||||
"source": "jamendo",
|
||||
}
|
||||
|
||||
# ========== ARTIST METHODS ==========
|
||||
|
||||
async def search_artists(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""Search for artists."""
|
||||
data = await self._api_request("/artists/", {
|
||||
"search": query,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
return [self._format_artist(item) for item in data.get("results", [])]
|
||||
|
||||
async def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get artist info with top tracks."""
|
||||
try:
|
||||
clean_id = artist_id.replace("jm_artist_", "").replace("jm_", "")
|
||||
|
||||
# Get artist info
|
||||
artist_data = await self._api_request("/artists/", {"id": clean_id})
|
||||
artists = artist_data.get("results", [])
|
||||
if not artists:
|
||||
return None
|
||||
|
||||
artist = self._format_artist(artists[0])
|
||||
|
||||
# Get artist's tracks
|
||||
tracks_data = await self._api_request("/artists/tracks/", {
|
||||
"id": clean_id,
|
||||
"limit": 20,
|
||||
"audioformat": "flac",
|
||||
})
|
||||
|
||||
tracks = []
|
||||
for item in tracks_data.get("results", []):
|
||||
for track in item.get("tracks", []):
|
||||
track["artist_name"] = artist["name"]
|
||||
track["artist_id"] = clean_id
|
||||
tracks.append(self._format_track(track))
|
||||
|
||||
artist["tracks"] = tracks
|
||||
return artist
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Jamendo artist {artist_id}: {e}")
|
||||
return None
|
||||
|
||||
def _format_artist(self, item: dict) -> dict:
|
||||
"""Format artist data for frontend."""
|
||||
return {
|
||||
"id": f"jm_artist_{item['id']}",
|
||||
"type": "artist",
|
||||
"name": item.get("name", ""),
|
||||
"image": item.get("image") or "",
|
||||
"website": item.get("website", ""),
|
||||
"source": "jamendo",
|
||||
}
|
||||
|
||||
# ========== STREAM URL ==========
|
||||
|
||||
async def get_stream_url(self, track_id: str, prefer_flac: bool = True) -> Optional[str]:
|
||||
"""Get direct stream URL for a track. Tries FLAC first, falls back to MP3."""
|
||||
try:
|
||||
clean_id = track_id.replace("jm_", "")
|
||||
|
||||
# Try FLAC first
|
||||
if prefer_flac:
|
||||
data = await self._api_request("/tracks/", {
|
||||
"id": clean_id,
|
||||
"audioformat": "flac",
|
||||
})
|
||||
results = data.get("results", [])
|
||||
if results:
|
||||
url = results[0].get("audiodownload") or results[0].get("audio")
|
||||
if url:
|
||||
return url
|
||||
|
||||
# Fallback to MP3 (mp32 = VBR good quality)
|
||||
data = await self._api_request("/tracks/", {
|
||||
"id": clean_id,
|
||||
"audioformat": "mp32",
|
||||
})
|
||||
results = data.get("results", [])
|
||||
if results:
|
||||
return results[0].get("audiodownload") or results[0].get("audio")
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Jamendo stream URL for {track_id}: {e}")
|
||||
return None
|
||||
|
||||
# ========== UTILITIES ==========
|
||||
|
||||
def _format_duration(self, ms: int) -> str:
|
||||
"""Format duration from ms to MM:SS."""
|
||||
seconds = ms // 1000
|
||||
minutes = seconds // 60
|
||||
secs = seconds % 60
|
||||
return f"{minutes}:{secs:02d}"
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client."""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
jamendo_service = JamendoService()
|
||||
409
app/listenbrainz_service.py
Normal file
409
app/listenbrainz_service.py
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
"""
|
||||
ListenBrainz service for Freedify.
|
||||
Handles scrobbling (listening history) and personalized recommendations.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import httpx
|
||||
from typing import Optional, Dict, List, Any
|
||||
import logging
|
||||
from app.musicbrainz_service import musicbrainz_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# User token from environment (can also be set via frontend settings)
|
||||
LISTENBRAINZ_TOKEN = os.getenv("LISTENBRAINZ_TOKEN")
|
||||
|
||||
|
||||
class ListenBrainzService:
|
||||
"""Service for ListenBrainz scrobbling and recommendations."""
|
||||
|
||||
API_BASE = "https://api.listenbrainz.org"
|
||||
|
||||
def __init__(self):
|
||||
self.token = LISTENBRAINZ_TOKEN
|
||||
self.client = httpx.AsyncClient(timeout=15.0)
|
||||
|
||||
def set_token(self, token: str):
|
||||
"""Set user token (from settings UI)."""
|
||||
self.token = token
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if ListenBrainz token is configured."""
|
||||
return bool(self.token)
|
||||
|
||||
def _get_headers(self) -> dict:
|
||||
"""Get headers with authorization."""
|
||||
return {
|
||||
"Authorization": f"Token {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
async def submit_now_playing(self, track: Dict[str, Any]) -> bool:
|
||||
"""Submit 'now playing' status when a track starts.
|
||||
|
||||
Args:
|
||||
track: Track info with name, artists, album, duration_ms
|
||||
"""
|
||||
if not self.is_configured():
|
||||
logger.debug("ListenBrainz not configured, skipping now playing")
|
||||
return False
|
||||
|
||||
try:
|
||||
payload = {
|
||||
"listen_type": "playing_now",
|
||||
"payload": [self._format_track_payload(track)]
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
f"{self.API_BASE}/1/submit-listens",
|
||||
headers=self._get_headers(),
|
||||
json=payload
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"ListenBrainz now playing: {track.get('name')}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"ListenBrainz now playing failed: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ListenBrainz now playing error: {e}")
|
||||
return False
|
||||
|
||||
async def submit_listen(self, track: Dict[str, Any], listened_at: Optional[int] = None) -> bool:
|
||||
"""Submit a completed listen (scrobble).
|
||||
|
||||
Should be called after user listens to 50% of track or 4 minutes, whichever is shorter.
|
||||
|
||||
Args:
|
||||
track: Track info with name, artists, album, duration_ms
|
||||
listened_at: Unix timestamp when listening started (defaults to now)
|
||||
"""
|
||||
if not self.is_configured():
|
||||
logger.debug("ListenBrainz not configured, skipping scrobble")
|
||||
return False
|
||||
|
||||
try:
|
||||
track_payload = self._format_track_payload(track)
|
||||
track_payload["listened_at"] = listened_at or int(time.time())
|
||||
|
||||
payload = {
|
||||
"listen_type": "single",
|
||||
"payload": [track_payload]
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
f"{self.API_BASE}/1/submit-listens",
|
||||
headers=self._get_headers(),
|
||||
json=payload
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"ListenBrainz scrobbled: {track.get('name')}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"ListenBrainz scrobble failed: {response.status_code} - {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ListenBrainz scrobble error: {e}")
|
||||
return False
|
||||
|
||||
def _format_track_payload(self, track: Dict[str, Any]) -> dict:
|
||||
"""Format track data for ListenBrainz API."""
|
||||
# Get artist name (handle both string and list formats)
|
||||
artist = track.get("artists", "")
|
||||
if isinstance(artist, list):
|
||||
artist = ", ".join(artist)
|
||||
|
||||
additional_info = {}
|
||||
|
||||
# Add duration if available
|
||||
duration_ms = track.get("duration_ms")
|
||||
if duration_ms:
|
||||
additional_info["duration_ms"] = duration_ms
|
||||
|
||||
# Add release name (album)
|
||||
if track.get("album"):
|
||||
additional_info["release_name"] = track["album"]
|
||||
|
||||
# Add ISRC if available (helps with MusicBrainz matching)
|
||||
if track.get("isrc") and not track["isrc"].startswith(("dz_", "ytm_", "LINK:", "pod_")):
|
||||
additional_info["isrc"] = track["isrc"]
|
||||
|
||||
# Add track number if available
|
||||
if track.get("track_number"):
|
||||
additional_info["tracknumber"] = track["track_number"]
|
||||
|
||||
return {
|
||||
"track_metadata": {
|
||||
"artist_name": artist,
|
||||
"track_name": track.get("name", "Unknown"),
|
||||
"additional_info": additional_info if additional_info else None
|
||||
}
|
||||
}
|
||||
|
||||
async def get_recommendations(self, username: str, count: int = 25) -> List[Dict[str, Any]]:
|
||||
"""Get personalized recommendations for a user.
|
||||
|
||||
Note: Recommendations are generated weekly by ListenBrainz based on listening history.
|
||||
|
||||
Args:
|
||||
username: ListenBrainz username
|
||||
count: Number of recommendations to fetch
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.API_BASE}/1/cf/recommendation/recording/{username}",
|
||||
params={"count": count}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"ListenBrainz recommendations failed: {response.status_code}")
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
payload = data.get("payload", {})
|
||||
|
||||
recommendations = []
|
||||
mbids = [rec.get("recording_mbid") for rec in payload.get("mbids", [])[:15]] # Limit to 15 for performance
|
||||
|
||||
for mbid in mbids:
|
||||
if not mbid: continue
|
||||
# Lookup metadata from MusicBrainz
|
||||
track_data = await musicbrainz_service.lookup_recording(mbid)
|
||||
if track_data:
|
||||
track_data["type"] = "recommendation"
|
||||
track_data["source"] = "listenbrainz"
|
||||
recommendations.append(track_data)
|
||||
|
||||
return recommendations
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ListenBrainz recommendations error: {e}")
|
||||
return []
|
||||
|
||||
async def get_user_listens(self, username: str, count: int = 25) -> List[Dict[str, Any]]:
|
||||
"""Get recent listens for a user."""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.API_BASE}/1/user/{username}/listens",
|
||||
params={"count": count}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
listens = data.get("payload", {}).get("listens", [])
|
||||
|
||||
return [{
|
||||
"track_name": l.get("track_metadata", {}).get("track_name"),
|
||||
"artist_name": l.get("track_metadata", {}).get("artist_name"),
|
||||
"listened_at": l.get("listened_at"),
|
||||
"source": "listenbrainz"
|
||||
} for l in listens]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ListenBrainz get listens error: {e}")
|
||||
return []
|
||||
|
||||
async def validate_token(self) -> Optional[str]:
|
||||
"""Validate token and return username if valid."""
|
||||
if not self.is_configured():
|
||||
return None
|
||||
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.API_BASE}/1/validate-token",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("valid"):
|
||||
return data.get("user_name")
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ListenBrainz token validation error: {e}")
|
||||
return None
|
||||
|
||||
async def get_user_playlists(self, username: str, count: int = 25) -> List[Dict[str, Any]]:
|
||||
"""Get user's playlists from ListenBrainz (includes Weekly Exploration)."""
|
||||
formatted = []
|
||||
|
||||
# Fetch user-created playlists
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.API_BASE}/1/user/{username}/playlists",
|
||||
params={"count": count}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
playlists = data.get("playlists", [])
|
||||
formatted.extend(self._format_playlists(playlists, username))
|
||||
except Exception as e:
|
||||
logger.error(f"ListenBrainz user playlists error: {e}")
|
||||
|
||||
# Fetch "created-for" playlists (Weekly Exploration, Daily Jam, etc.)
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.API_BASE}/1/user/{username}/playlists/createdfor",
|
||||
params={"count": count}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
playlists = data.get("playlists", [])
|
||||
# Add these at the beginning since they're the most interesting
|
||||
formatted = self._format_playlists(playlists, username, is_generated=True) + formatted
|
||||
except Exception as e:
|
||||
logger.error(f"ListenBrainz created-for playlists error: {e}")
|
||||
|
||||
return formatted
|
||||
|
||||
async def get_user_stats(self, username: str) -> Dict[str, Any]:
|
||||
"""Get user's listening statistics from ListenBrainz."""
|
||||
stats = {
|
||||
"listen_count": 0,
|
||||
"top_artists": [],
|
||||
"top_releases": [],
|
||||
"username": username
|
||||
}
|
||||
|
||||
try:
|
||||
# Get total listen count
|
||||
response = await self.client.get(
|
||||
f"{self.API_BASE}/1/user/{username}/listen-count"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
stats["listen_count"] = data.get("payload", {}).get("count", 0)
|
||||
except Exception as e:
|
||||
logger.warning(f"ListenBrainz listen count error: {e}")
|
||||
|
||||
try:
|
||||
# Get top artists - try this_week first, fall back to all_time
|
||||
for time_range in ["this_week", "all_time"]:
|
||||
response = await self.client.get(
|
||||
f"{self.API_BASE}/1/stats/user/{username}/artists",
|
||||
params={"count": 5, "range": time_range}
|
||||
)
|
||||
logger.info(f"LB stats for {username} ({time_range}): {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
artists = data.get("payload", {}).get("artists", [])
|
||||
if artists:
|
||||
stats["top_artists"] = [
|
||||
{
|
||||
"name": a.get("artist_name", "Unknown"),
|
||||
"count": a.get("listen_count", 0)
|
||||
}
|
||||
for a in artists[:5]
|
||||
]
|
||||
break # Found artists, stop trying
|
||||
elif response.status_code == 204:
|
||||
# No content = no stats available yet
|
||||
logger.info(f"No {time_range} stats available for {username}")
|
||||
except Exception as e:
|
||||
logger.warning(f"ListenBrainz top artists error: {e}")
|
||||
|
||||
return stats
|
||||
|
||||
def _format_playlists(self, playlists: list, username: str, is_generated: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Format playlist data from ListenBrainz API response."""
|
||||
formatted = []
|
||||
for p in playlists:
|
||||
playlist = p.get("playlist", {})
|
||||
# Extract playlist MBID from identifier URL
|
||||
identifier = playlist.get("identifier", "")
|
||||
playlist_id = identifier.split("/")[-1] if identifier else ""
|
||||
|
||||
name = playlist.get("title", "Untitled Playlist")
|
||||
|
||||
formatted.append({
|
||||
"id": f"lb_{playlist_id}",
|
||||
"type": "album", # Treat as album for UI compatibility
|
||||
"name": name,
|
||||
"artists": playlist.get("creator", username),
|
||||
"description": playlist.get("annotation", "")[:150] if playlist.get("annotation") else "",
|
||||
"album_art": "/static/icon.svg", # LB playlists don't have artwork
|
||||
"total_tracks": len(playlist.get("track", [])),
|
||||
"source": "listenbrainz",
|
||||
"is_playlist": True,
|
||||
"is_generated": is_generated # True for Weekly Exploration, Daily Jam, etc.
|
||||
})
|
||||
|
||||
return formatted
|
||||
|
||||
async def get_playlist_tracks(self, playlist_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get tracks from a ListenBrainz playlist."""
|
||||
try:
|
||||
# Remove lb_ prefix if present
|
||||
clean_id = playlist_id.replace("lb_", "")
|
||||
|
||||
response = await self.client.get(
|
||||
f"{self.API_BASE}/1/playlist/{clean_id}"
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"ListenBrainz playlist fetch failed: {response.status_code}")
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
playlist = data.get("playlist", {})
|
||||
|
||||
# Parse JSPF tracks
|
||||
jspf_tracks = playlist.get("track", [])
|
||||
tracks = []
|
||||
|
||||
for i, t in enumerate(jspf_tracks):
|
||||
# Extract artist and title from JSPF
|
||||
artist = t.get("creator", "Unknown Artist")
|
||||
title = t.get("title", "Unknown Track")
|
||||
|
||||
# Build search query for audio lookup
|
||||
search_query = f"{artist} - {title}"
|
||||
|
||||
# Create a searchable track object
|
||||
# Use the search query as the track "isrc" so the audio service can find it
|
||||
tracks.append({
|
||||
"id": f"query:{search_query}", # This will trigger a search
|
||||
"type": "track",
|
||||
"name": title,
|
||||
"artists": artist,
|
||||
"album": playlist.get("title", "ListenBrainz Playlist"),
|
||||
"album_art": "/static/icon.svg",
|
||||
"duration": "0:00", # Duration not in JSPF
|
||||
"isrc": f"query:{search_query}", # Audio service will search by name
|
||||
"source": "listenbrainz"
|
||||
})
|
||||
|
||||
return {
|
||||
"id": f"lb_{clean_id}",
|
||||
"type": "album",
|
||||
"name": playlist.get("title", "ListenBrainz Playlist"),
|
||||
"artists": playlist.get("creator", "ListenBrainz"),
|
||||
"album_art": "/static/icon.svg",
|
||||
"tracks": tracks,
|
||||
"total_tracks": len(tracks),
|
||||
"source": "listenbrainz"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ListenBrainz playlist tracks error: {e}")
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client."""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
listenbrainz_service = ListenBrainzService()
|
||||
206
app/live_show_service.py
Normal file
206
app/live_show_service.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""
|
||||
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()
|
||||
1220
app/main.py
Normal file
1220
app/main.py
Normal file
File diff suppressed because it is too large
Load diff
194
app/musicbrainz_service.py
Normal file
194
app/musicbrainz_service.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"""
|
||||
MusicBrainz service for Freedify.
|
||||
Provides metadata enrichment: release year, label, and cover art from Cover Art Archive.
|
||||
"""
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MusicBrainzService:
|
||||
"""Service for enriching track metadata from MusicBrainz."""
|
||||
|
||||
MB_API = "https://musicbrainz.org/ws/2"
|
||||
CAA_API = "https://coverartarchive.org"
|
||||
USER_AGENT = "Freedify/1.0 (https://github.com/freedify)"
|
||||
|
||||
def __init__(self):
|
||||
self.client = httpx.AsyncClient(
|
||||
timeout=15.0,
|
||||
headers={"User-Agent": self.USER_AGENT}
|
||||
)
|
||||
|
||||
async def lookup_recording(self, mbid: str) -> Optional[Dict[str, Any]]:
|
||||
"""Look up a recording by MBID.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'id': '...',
|
||||
'name': 'Track Name',
|
||||
'artists': 'Artist Name',
|
||||
'album': 'Album Name',
|
||||
'album_art': '...',
|
||||
'release_date': '...',
|
||||
'duration': '3:45'
|
||||
}
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.MB_API}/recording/{mbid}",
|
||||
params={"fmt": "json", "inc": "releases+artist-credits+release-groups+genres"}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.debug(f"MBID lookup failed: {mbid}")
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Helper to get artist name
|
||||
artist_credit = data.get("artist-credit", [])
|
||||
artist_name = ", ".join([ac.get("name", "") for ac in artist_credit]) if artist_credit else "Unknown Artist"
|
||||
|
||||
result = {
|
||||
"id": mbid,
|
||||
"name": data.get("title", "Unknown Track"),
|
||||
"artists": artist_name,
|
||||
"duration": data.get("length", 0) // 1000 if data.get("length") else 0
|
||||
}
|
||||
|
||||
# Get release info
|
||||
releases = data.get("releases", [])
|
||||
if releases:
|
||||
release = releases[0]
|
||||
result["album"] = release.get("title", "")
|
||||
result["release_date"] = release.get("date", "")
|
||||
|
||||
# Cover Art
|
||||
release_id = release.get("id")
|
||||
if release_id:
|
||||
cover_url = await self._get_cover_art(release_id)
|
||||
if cover_url:
|
||||
result["album_art"] = cover_url
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MusicBrainz recording lookup error: {e}")
|
||||
return None
|
||||
|
||||
async def lookup_by_isrc(self, isrc: str) -> Optional[Dict[str, Any]]:
|
||||
"""Look up a recording by ISRC and return enriched metadata.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'release_date': '2020-01-15',
|
||||
'label': 'Atlantic Records',
|
||||
'cover_art_url': 'https://...',
|
||||
'genres': ['pop', 'electronic'],
|
||||
'release_id': '...' # for further lookups
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Skip non-standard ISRCs (like dz_ or ytm_ prefixed IDs)
|
||||
if not isrc or isrc.startswith(('dz_', 'ytm_', 'LINK:')):
|
||||
return None
|
||||
|
||||
logger.info(f"Looking up ISRC on MusicBrainz: {isrc}")
|
||||
|
||||
# Search for recording by ISRC
|
||||
response = await self.client.get(
|
||||
f"{self.MB_API}/isrc/{isrc}",
|
||||
params={"fmt": "json", "inc": "releases+release-groups+labels+genres"}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.debug(f"No MusicBrainz result for ISRC: {isrc}")
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
recordings = data.get("recordings", [])
|
||||
|
||||
if not recordings:
|
||||
return None
|
||||
|
||||
# Get the first recording's release info
|
||||
recording = recordings[0]
|
||||
releases = recording.get("releases", [])
|
||||
|
||||
if not releases:
|
||||
return None
|
||||
|
||||
# Use the first release (typically the original)
|
||||
release = releases[0]
|
||||
release_id = release.get("id", "")
|
||||
|
||||
result = {
|
||||
"release_date": release.get("date", ""),
|
||||
"release_id": release_id,
|
||||
"label": "",
|
||||
"cover_art_url": "",
|
||||
"genres": []
|
||||
}
|
||||
|
||||
# Get label from label-info
|
||||
label_info = release.get("label-info", [])
|
||||
if label_info and label_info[0].get("label"):
|
||||
result["label"] = label_info[0]["label"].get("name", "")
|
||||
|
||||
# Get genres from recording
|
||||
genres = recording.get("genres", [])
|
||||
result["genres"] = [g.get("name", "") for g in genres[:5]]
|
||||
|
||||
# Try to get cover art from Cover Art Archive
|
||||
if release_id:
|
||||
cover_url = await self._get_cover_art(release_id)
|
||||
if cover_url:
|
||||
result["cover_art_url"] = cover_url
|
||||
|
||||
logger.info(f"MusicBrainz enrichment found: year={result['release_date']}, label={result['label']}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"MusicBrainz lookup error for {isrc}: {e}")
|
||||
return None
|
||||
|
||||
async def _get_cover_art(self, release_id: str) -> Optional[str]:
|
||||
"""Get cover art URL from Cover Art Archive."""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.CAA_API}/release/{release_id}",
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
images = data.get("images", [])
|
||||
|
||||
# Get front cover, prefer large size
|
||||
for img in images:
|
||||
if img.get("front"):
|
||||
# Prefer 500px version for quality/speed balance
|
||||
thumbnails = img.get("thumbnails", {})
|
||||
return thumbnails.get("500") or thumbnails.get("large") or img.get("image")
|
||||
|
||||
# Fallback to first image
|
||||
if images:
|
||||
return images[0].get("image")
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Cover Art Archive error: {e}")
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client."""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
musicbrainz_service = MusicBrainzService()
|
||||
165
app/podcast_service.py
Normal file
165
app/podcast_service.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"""
|
||||
Podcast service using PodcastIndex API.
|
||||
https://podcastindex-org.github.io/docs-api/
|
||||
"""
|
||||
import httpx
|
||||
import logging
|
||||
import hashlib
|
||||
import time
|
||||
import base64
|
||||
import os
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# API Keys - MUST be set via environment variables
|
||||
PODCASTINDEX_KEY = os.getenv("PODCASTINDEX_KEY", "")
|
||||
PODCASTINDEX_SECRET = os.getenv("PODCASTINDEX_SECRET", "")
|
||||
|
||||
class PodcastService:
|
||||
"""Service for searching podcasts via PodcastIndex API."""
|
||||
|
||||
BASE_URL = "https://api.podcastindex.org/api/1.0"
|
||||
|
||||
def __init__(self):
|
||||
self.client = httpx.AsyncClient(timeout=15.0)
|
||||
self.api_key = PODCASTINDEX_KEY
|
||||
self.api_secret = PODCASTINDEX_SECRET
|
||||
|
||||
def _get_auth_headers(self) -> Dict[str, str]:
|
||||
"""Generate authentication headers for PodcastIndex API."""
|
||||
if not self.api_key or not self.api_secret:
|
||||
logger.warning("PodcastIndex API keys are missing!")
|
||||
return {}
|
||||
|
||||
epoch_time = int(time.time())
|
||||
data_to_hash = self.api_key + self.api_secret + str(epoch_time)
|
||||
sha1_hash = hashlib.sha1(data_to_hash.encode('utf-8')).hexdigest()
|
||||
|
||||
return {
|
||||
"X-Auth-Key": self.api_key,
|
||||
"X-Auth-Date": str(epoch_time),
|
||||
"Authorization": sha1_hash,
|
||||
"User-Agent": "Freedify/1.0"
|
||||
}
|
||||
|
||||
async def search_podcasts(self, query: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""Search for podcasts by term."""
|
||||
try:
|
||||
if not self.api_key:
|
||||
logger.error("Cannot search podcasts: Missing API Key")
|
||||
return []
|
||||
|
||||
params = {"q": query, "max": limit}
|
||||
response = await self.client.get(
|
||||
f"{self.BASE_URL}/search/byterm",
|
||||
params=params,
|
||||
headers=self._get_auth_headers()
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"PodcastIndex search failed: {response.status_code}")
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
feeds = data.get("feeds", [])
|
||||
|
||||
return [self._format_podcast(feed) for feed in feeds[:limit]]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Podcast search error: {e}")
|
||||
return []
|
||||
|
||||
async def get_podcast_episodes(self, feed_id: str, limit: int = 50) -> Optional[Dict[str, Any]]:
|
||||
"""Get episodes for a podcast by feed ID."""
|
||||
try:
|
||||
if not self.api_key:
|
||||
return None
|
||||
|
||||
# First get feed info
|
||||
feed_response = await self.client.get(
|
||||
f"{self.BASE_URL}/podcasts/byfeedid",
|
||||
params={"id": feed_id},
|
||||
headers=self._get_auth_headers()
|
||||
)
|
||||
|
||||
if feed_response.status_code != 200:
|
||||
logger.error(f"Failed to get feed info: {feed_response.status_code}")
|
||||
return None
|
||||
|
||||
feed_data = feed_response.json().get("feed", {})
|
||||
|
||||
# Get episodes
|
||||
episodes_response = await self.client.get(
|
||||
f"{self.BASE_URL}/episodes/byfeedid",
|
||||
params={"id": feed_id, "max": limit},
|
||||
headers=self._get_auth_headers()
|
||||
)
|
||||
|
||||
if episodes_response.status_code != 200:
|
||||
logger.error(f"Failed to get episodes: {episodes_response.status_code}")
|
||||
return None
|
||||
|
||||
episodes_data = episodes_response.json().get("items", [])
|
||||
|
||||
# Format episodes as tracks
|
||||
tracks = []
|
||||
for ep in episodes_data:
|
||||
audio_url = ep.get("enclosureUrl")
|
||||
if not audio_url:
|
||||
continue
|
||||
|
||||
# Create ID that audio_service can decode (LINK:base64)
|
||||
safe_id = f"LINK:{base64.urlsafe_b64encode(audio_url.encode()).decode()}"
|
||||
|
||||
duration_s = ep.get("duration", 0)
|
||||
duration_str = f"{int(duration_s // 60)}:{int(duration_s % 60):02d}" if duration_s else "0:00"
|
||||
|
||||
tracks.append({
|
||||
"id": safe_id,
|
||||
"type": "track",
|
||||
"name": ep.get("title", "Unknown Episode"),
|
||||
"artists": feed_data.get("author") or feed_data.get("title", "Unknown"),
|
||||
"album": feed_data.get("title", "Podcast"),
|
||||
"album_art": ep.get("image") or feed_data.get("image") or "/static/icon.svg",
|
||||
"duration": duration_str,
|
||||
"isrc": safe_id,
|
||||
"source": "podcast",
|
||||
# Metadata for Info Modal
|
||||
"description": ep.get("description", ""),
|
||||
"datePublished": ep.get("datePublishedPretty", "")
|
||||
})
|
||||
|
||||
return {
|
||||
"id": f"pod_{feed_id}",
|
||||
"type": "album",
|
||||
"name": feed_data.get("title", "Unknown Podcast"),
|
||||
"artists": feed_data.get("author") or "Podcast",
|
||||
"image": feed_data.get("image") or "/static/icon.svg",
|
||||
"album_art": feed_data.get("image") or "/static/icon.svg",
|
||||
"tracks": tracks,
|
||||
"total_tracks": len(tracks),
|
||||
"source": "podcast"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching episodes for feed {feed_id}: {e}")
|
||||
return None
|
||||
|
||||
def _format_podcast(self, feed: dict) -> dict:
|
||||
"""Format PodcastIndex feed to app format."""
|
||||
return {
|
||||
"id": f"pod_{feed.get('id')}",
|
||||
"type": "album",
|
||||
"is_podcast": True,
|
||||
"name": feed.get("title", "Unknown Podcast"),
|
||||
"artists": feed.get("author") or feed.get("ownerName", "Unknown"),
|
||||
"album_art": feed.get("image") or feed.get("artwork") or "/static/icon.svg",
|
||||
"description": feed.get("description", "")[:150],
|
||||
"source": "podcast"
|
||||
}
|
||||
|
||||
async def close(self):
|
||||
await self.client.aclose()
|
||||
|
||||
podcast_service = PodcastService()
|
||||
14
app/requirements.txt
Normal file
14
app/requirements.txt
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
httpx[socks]>=0.25.0
|
||||
aiofiles>=23.2.0
|
||||
ffmpeg-python>=0.2.0
|
||||
mutagen>=1.47.0
|
||||
pyotp>=2.9.0
|
||||
requests>=2.31.0
|
||||
google-generativeai>=0.8.0
|
||||
python-dotenv>=1.0.0
|
||||
yt-dlp>=2024.1.0
|
||||
ytmusicapi>=1.8.0
|
||||
packaging>=23.0
|
||||
beautifulsoup4>=4.12.0
|
||||
310
app/setlist_service.py
Normal file
310
app/setlist_service.py
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
"""
|
||||
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()
|
||||
496
app/spotify_service.py
Normal file
496
app/spotify_service.py
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
"""
|
||||
Spotify service for Freedify.
|
||||
Provides playlist/album fetching and URL parsing.
|
||||
ONLY used when a Spotify URL is pasted - not for search (to avoid rate limits).
|
||||
"""
|
||||
import httpx
|
||||
import re
|
||||
from typing import Optional, Dict, List, Any, Tuple
|
||||
import logging
|
||||
from random import randrange
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_random_user_agent():
|
||||
return f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_{randrange(11, 15)}_{randrange(4, 9)}) AppleWebKit/{randrange(530, 537)}.{randrange(30, 37)} (KHTML, like Gecko) Chrome/{randrange(80, 105)}.0.{randrange(3000, 4500)}.{randrange(60, 125)} Safari/{randrange(530, 537)}.{randrange(30, 36)}"
|
||||
|
||||
|
||||
class SpotifyService:
|
||||
"""Service for fetching metadata from Spotify URLs (not for search)."""
|
||||
|
||||
TOKEN_URL = "https://open.spotify.com/get_access_token?reason=transport&productType=web_player"
|
||||
AUTH_URL = "https://accounts.spotify.com/api/token"
|
||||
API_BASE = "https://api.spotify.com/v1"
|
||||
|
||||
# Regex patterns for Spotify URLs
|
||||
URL_PATTERNS = {
|
||||
'track': re.compile(r'(?:spotify\.com/track/|spotify:track:)([a-zA-Z0-9]+)'),
|
||||
'album': re.compile(r'(?:spotify\.com/album/|spotify:album:)([a-zA-Z0-9]+)'),
|
||||
'playlist': re.compile(r'(?:spotify\.com/playlist/|spotify:playlist:)([a-zA-Z0-9]+)'),
|
||||
'artist': re.compile(r'(?:spotify\.com/artist/|spotify:artist:)([a-zA-Z0-9]+)'),
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
import os
|
||||
self.access_token: Optional[str] = None
|
||||
self.client_id = os.environ.get("SPOTIFY_CLIENT_ID")
|
||||
self.client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET")
|
||||
self.sp_dc = os.environ.get("SPOTIFY_SP_DC")
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""Get access token (Client Creds > Cookie > Web Player > Embed)."""
|
||||
if self.access_token:
|
||||
return self.access_token
|
||||
|
||||
# 1. Try Client Credentials Flow
|
||||
if self.client_id and self.client_secret:
|
||||
try:
|
||||
import base64
|
||||
auth_str = f"{self.client_id}:{self.client_secret}"
|
||||
b64_auth = base64.b64encode(auth_str.encode()).decode()
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Basic {b64_auth}",
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
data = {"grant_type": "client_credentials"}
|
||||
|
||||
response = await self.client.post(self.AUTH_URL, headers=headers, data=data)
|
||||
if response.status_code == 200:
|
||||
token_data = response.json()
|
||||
self.access_token = token_data.get("access_token")
|
||||
logger.info("Got Spotify token via Client Credentials")
|
||||
return self.access_token
|
||||
except Exception as e:
|
||||
logger.error(f"Client Credentials auth failed: {e}")
|
||||
|
||||
# 2. Try Cookie Auth (sp_dc) - Mimics logged-in Web Player
|
||||
# This is the best fallback if Developer App creation is blocked
|
||||
cookies = None
|
||||
if self.sp_dc:
|
||||
cookies = {"sp_dc": self.sp_dc}
|
||||
logger.info("Using provided sp_dc cookie for authentication")
|
||||
|
||||
# 3. Web Player Token (Anonymous or Authenticated via Cookie)
|
||||
headers = {
|
||||
"User-Agent": get_random_user_agent(),
|
||||
"Accept": "application/json",
|
||||
"Referer": "https://open.spotify.com/",
|
||||
}
|
||||
|
||||
try:
|
||||
# If cookies are passed, this request becomes authenticated!
|
||||
response = await self.client.get(self.TOKEN_URL, headers=headers, cookies=cookies)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
self.access_token = data.get("accessToken")
|
||||
if self.access_token:
|
||||
logger.info(f"Got Spotify token via Web Player ({'Authenticated' if cookies else 'Anonymous'})")
|
||||
return self.access_token
|
||||
except Exception as e:
|
||||
logger.warning(f"Web Player token fetch failed: {e}")
|
||||
|
||||
# 4. Fallback: Embed Page
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
self.access_token = data.get("accessToken")
|
||||
if self.access_token:
|
||||
logger.info("Got Spotify token via direct method")
|
||||
return self.access_token
|
||||
except Exception as e:
|
||||
logger.warning(f"Direct token fetch failed: {e}")
|
||||
|
||||
# 3. Fallback: Embed Page
|
||||
try:
|
||||
embed_url = "https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT"
|
||||
response = await self.client.get(embed_url, headers={"User-Agent": get_random_user_agent()})
|
||||
if response.status_code == 200:
|
||||
token_match = re.search(r'"accessToken":"([^"]+)"', response.text)
|
||||
if token_match:
|
||||
self.access_token = token_match.group(1)
|
||||
logger.info("Got Spotify token via embed page")
|
||||
return self.access_token
|
||||
except Exception as e:
|
||||
logger.warning(f"Embed token fetch failed: {e}")
|
||||
|
||||
raise Exception("Failed to get Spotify access token")
|
||||
|
||||
async def _api_request(self, endpoint: str, params: dict = None) -> dict:
|
||||
"""Make authenticated API request with rate limit handling."""
|
||||
import asyncio
|
||||
|
||||
max_retries = 3
|
||||
retry_delay = 2
|
||||
|
||||
for attempt in range(max_retries):
|
||||
token = await self._get_access_token()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": get_random_user_agent(),
|
||||
"Accept": "application/json",
|
||||
}
|
||||
response = await self.client.get(f"{self.API_BASE}{endpoint}", headers=headers, params=params)
|
||||
|
||||
if response.status_code == 401:
|
||||
logger.warning("Got 401, refreshing Spotify token...")
|
||||
self.access_token = None
|
||||
continue
|
||||
|
||||
if response.status_code == 429:
|
||||
retry_after = min(int(response.headers.get("Retry-After", retry_delay)), 10)
|
||||
logger.warning(f"Rate limited (429). Waiting {retry_after}s before retry {attempt + 1}/{max_retries}")
|
||||
await asyncio.sleep(retry_after)
|
||||
retry_delay *= 2
|
||||
continue
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def parse_spotify_url(self, url: str) -> Optional[Tuple[str, str]]:
|
||||
"""Parse Spotify URL and return (type, id) or None."""
|
||||
for url_type, pattern in self.URL_PATTERNS.items():
|
||||
match = pattern.search(url)
|
||||
if match:
|
||||
return (url_type, match.group(1))
|
||||
return None
|
||||
|
||||
def is_spotify_url(self, url: str) -> bool:
|
||||
"""Check if a URL is a Spotify URL."""
|
||||
return 'spotify.com/' in url or 'spotify:' in url
|
||||
|
||||
# ========== TRACK METHODS ==========
|
||||
|
||||
async def get_track_by_id(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a single track by ID."""
|
||||
try:
|
||||
data = await self._api_request(f"/tracks/{track_id}", {"market": "US"})
|
||||
return self._format_track(data)
|
||||
except:
|
||||
return None
|
||||
|
||||
def _format_track(self, item: dict) -> dict:
|
||||
"""Format track data for frontend."""
|
||||
return {
|
||||
"id": item["id"],
|
||||
"type": "track",
|
||||
"name": item["name"],
|
||||
"artists": ", ".join(a["name"] for a in item["artists"]),
|
||||
"artist_names": [a["name"] for a in item["artists"]],
|
||||
"album": item["album"]["name"],
|
||||
"album_id": item["album"]["id"],
|
||||
"album_art": self._get_best_image(item["album"]["images"]),
|
||||
"duration_ms": item["duration_ms"],
|
||||
"duration": self._format_duration(item["duration_ms"]),
|
||||
"isrc": item.get("external_ids", {}).get("isrc"),
|
||||
"source": "spotify",
|
||||
}
|
||||
|
||||
# ========== ALBUM METHODS ==========
|
||||
|
||||
async def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get album with all tracks."""
|
||||
try:
|
||||
data = await self._api_request(f"/albums/{album_id}", {"market": "US"})
|
||||
album = self._format_album(data)
|
||||
|
||||
tracks = []
|
||||
for item in data.get("tracks", {}).get("items", []):
|
||||
track = {
|
||||
"id": item["id"],
|
||||
"type": "track",
|
||||
"name": item["name"],
|
||||
"artists": ", ".join(a["name"] for a in item["artists"]),
|
||||
"artist_names": [a["name"] for a in item["artists"]],
|
||||
"album": data["name"],
|
||||
"album_id": album_id,
|
||||
"album_art": album["album_art"],
|
||||
"duration_ms": item["duration_ms"],
|
||||
"duration": self._format_duration(item["duration_ms"]),
|
||||
"isrc": None,
|
||||
"source": "spotify",
|
||||
}
|
||||
tracks.append(track)
|
||||
|
||||
album["tracks"] = tracks
|
||||
return album
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Spotify album {album_id}: {e}")
|
||||
return None
|
||||
|
||||
def _format_album(self, item: dict) -> dict:
|
||||
return {
|
||||
"id": item["id"],
|
||||
"type": "album",
|
||||
"name": item["name"],
|
||||
"artists": ", ".join(a["name"] for a in item.get("artists", [])),
|
||||
"album_art": self._get_best_image(item.get("images", [])),
|
||||
"release_date": item.get("release_date", ""),
|
||||
"total_tracks": item.get("total_tracks", 0),
|
||||
"source": "spotify",
|
||||
}
|
||||
|
||||
# ========== PLAYLIST METHODS ==========
|
||||
|
||||
async def get_playlist(self, playlist_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get playlist with all tracks."""
|
||||
try:
|
||||
data = await self._api_request(f"/playlists/{playlist_id}", {"market": "US"})
|
||||
|
||||
playlist = {
|
||||
"id": data["id"],
|
||||
"type": "playlist",
|
||||
"name": data["name"],
|
||||
"description": data.get("description", ""),
|
||||
"album_art": self._get_best_image(data.get("images", [])),
|
||||
"owner": data.get("owner", {}).get("display_name", ""),
|
||||
"total_tracks": data.get("tracks", {}).get("total", 0),
|
||||
"source": "spotify",
|
||||
}
|
||||
|
||||
tracks = []
|
||||
for item in data.get("tracks", {}).get("items", []):
|
||||
track_data = item.get("track")
|
||||
if track_data and track_data.get("id"):
|
||||
tracks.append(self._format_track(track_data))
|
||||
|
||||
playlist["tracks"] = tracks
|
||||
return playlist
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Spotify playlist {playlist_id}: {e}")
|
||||
return None
|
||||
|
||||
# ========== ARTIST METHODS ==========
|
||||
|
||||
async def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get artist info with top tracks."""
|
||||
try:
|
||||
artist_data = await self._api_request(f"/artists/{artist_id}")
|
||||
artist = {
|
||||
"id": artist_data["id"],
|
||||
"type": "artist",
|
||||
"name": artist_data["name"],
|
||||
"image": self._get_best_image(artist_data.get("images", [])),
|
||||
"genres": artist_data.get("genres", []),
|
||||
"followers": artist_data.get("followers", {}).get("total", 0),
|
||||
"source": "spotify",
|
||||
}
|
||||
|
||||
top_tracks = await self._api_request(f"/artists/{artist_id}/top-tracks", {"market": "US"})
|
||||
artist["tracks"] = [self._format_track(t) for t in top_tracks.get("tracks", [])]
|
||||
|
||||
return artist
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Spotify artist {artist_id}: {e}")
|
||||
return None
|
||||
|
||||
# ========== AUDIO FEATURES & CAMELOT ==========
|
||||
|
||||
# Camelot Wheel: Maps (pitch_class, mode) to Camelot notation
|
||||
# pitch_class: 0=C, 1=C#, 2=D, ..., 11=B
|
||||
# mode: 1=Major (B), 0=Minor (A)
|
||||
CAMELOT_MAP = {
|
||||
(0, 1): "8B", (0, 0): "5A", # C Major / C Minor
|
||||
(1, 1): "3B", (1, 0): "12A", # C# Major / C# Minor
|
||||
(2, 1): "10B", (2, 0): "7A", # D Major / D Minor
|
||||
(3, 1): "5B", (3, 0): "2A", # D# Major / D# Minor
|
||||
(4, 1): "12B", (4, 0): "9A", # E Major / E Minor
|
||||
(5, 1): "7B", (5, 0): "4A", # F Major / F Minor
|
||||
(6, 1): "2B", (6, 0): "11A", # F# Major / F# Minor
|
||||
(7, 1): "9B", (7, 0): "6A", # G Major / G Minor
|
||||
(8, 1): "4B", (8, 0): "1A", # G# Major / G# Minor
|
||||
(9, 1): "11B", (9, 0): "8A", # A Major / A Minor
|
||||
(10, 1): "6B", (10, 0): "3A", # A# Major / A# Minor
|
||||
(11, 1): "1B", (11, 0): "10A", # B Major / B Minor
|
||||
}
|
||||
|
||||
def _to_camelot(self, key: int, mode: int) -> str:
|
||||
"""Convert Spotify key/mode to Camelot notation."""
|
||||
return self.CAMELOT_MAP.get((key, mode), "?")
|
||||
|
||||
async def search_track_by_isrc(self, isrc: str) -> Optional[str]:
|
||||
"""Search for a track by ISRC and return Spotify track ID."""
|
||||
try:
|
||||
data = await self._api_request("/search", {"q": f"isrc:{isrc}", "type": "track", "limit": 1})
|
||||
tracks = data.get("tracks", {}).get("items", [])
|
||||
if tracks:
|
||||
return tracks[0].get("id")
|
||||
except Exception as e:
|
||||
logger.warning(f"ISRC search failed for {isrc}: {e}")
|
||||
return None
|
||||
|
||||
async def search_track_by_name(self, name: str, artist: str) -> Optional[str]:
|
||||
"""Search for a track by name and artist, return Spotify track ID."""
|
||||
try:
|
||||
# 1. Try strict search first
|
||||
query = f"track:{name} artist:{artist}"
|
||||
data = await self._api_request("/search", {"q": query, "type": "track", "limit": 1, "market": "US"})
|
||||
tracks = data.get("tracks", {}).get("items", [])
|
||||
if tracks:
|
||||
return tracks[0].get("id")
|
||||
|
||||
# 2. Fallback to loose search (just string matching)
|
||||
# Remove special chars and extra artists for better matching
|
||||
clean_name = name.split('(')[0].split('-')[0].strip()
|
||||
clean_artist = artist.split(',')[0].strip()
|
||||
query = f"{clean_name} {clean_artist}"
|
||||
data = await self._api_request("/search", {"q": query, "type": "track", "limit": 1, "market": "US"})
|
||||
tracks = data.get("tracks", {}).get("items", [])
|
||||
if tracks:
|
||||
return tracks[0].get("id")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Name search failed for {name} by {artist}: {e}")
|
||||
return None
|
||||
|
||||
async def get_audio_features(self, track_id: str, isrc: str = None, name: str = None, artist: str = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get audio features (BPM, key, energy) for a single track.
|
||||
|
||||
If track_id starts with 'dz_' (Deezer), will try ISRC or name/artist lookup first.
|
||||
"""
|
||||
spotify_id = track_id
|
||||
|
||||
# Handle Deezer tracks - need to find Spotify equivalent
|
||||
if track_id.startswith("dz_"):
|
||||
spotify_id = None
|
||||
# Try ISRC first
|
||||
if isrc:
|
||||
spotify_id = await self.search_track_by_isrc(isrc)
|
||||
# Fallback to name/artist search
|
||||
if not spotify_id and name and artist:
|
||||
spotify_id = await self.search_track_by_name(name, artist)
|
||||
|
||||
if not spotify_id:
|
||||
logger.warning(f"Could not find Spotify ID for Deezer track {track_id}")
|
||||
return None
|
||||
|
||||
try:
|
||||
data = await self._api_request(f"/audio-features/{spotify_id}")
|
||||
return self._format_audio_features(data)
|
||||
except Exception as e:
|
||||
# 403 Forbidden likely means token lacks permission (scraper token) or ID is invalid
|
||||
if "403" in str(e):
|
||||
logger.warning(f"Spotify 403 Forbidden for {spotify_id} (token permissions?)")
|
||||
else:
|
||||
logger.error(f"Error fetching audio features for {spotify_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_audio_features_batch(self, track_ids: List[str]) -> List[Optional[Dict[str, Any]]]:
|
||||
"""Get audio features for multiple tracks (max 100 per request)."""
|
||||
if not track_ids:
|
||||
return []
|
||||
|
||||
# Spotify API limit is 100 tracks per request
|
||||
results = []
|
||||
for i in range(0, len(track_ids), 100):
|
||||
batch = track_ids[i:i+100]
|
||||
try:
|
||||
data = await self._api_request("/audio-features", {"ids": ",".join(batch)})
|
||||
for features in data.get("audio_features", []):
|
||||
if features:
|
||||
results.append(self._format_audio_features(features))
|
||||
else:
|
||||
results.append(None)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching batch audio features: {e}")
|
||||
results.extend([None] * len(batch))
|
||||
|
||||
return results
|
||||
|
||||
def _format_audio_features(self, data: dict) -> dict:
|
||||
"""Format audio features for frontend."""
|
||||
key = data.get("key", -1)
|
||||
mode = data.get("mode", 0)
|
||||
return {
|
||||
"track_id": data.get("id"),
|
||||
"bpm": round(data.get("tempo", 0)),
|
||||
"key": key,
|
||||
"mode": mode,
|
||||
"camelot": self._to_camelot(key, mode) if key >= 0 else "?",
|
||||
"energy": round(data.get("energy", 0), 2),
|
||||
"danceability": round(data.get("danceability", 0), 2),
|
||||
"valence": round(data.get("valence", 0), 2), # "happiness"
|
||||
}
|
||||
|
||||
# ========== UTILITIES ==========
|
||||
|
||||
def _get_best_image(self, images: List[Dict]) -> Optional[str]:
|
||||
if not images:
|
||||
return None
|
||||
sorted_images = sorted(images, key=lambda x: x.get("width", 0), reverse=True)
|
||||
return sorted_images[0]["url"] if sorted_images else None
|
||||
|
||||
def _format_duration(self, ms: int) -> str:
|
||||
seconds = ms // 1000
|
||||
minutes = seconds // 60
|
||||
secs = seconds % 60
|
||||
return f"{minutes}:{secs:02d}"
|
||||
|
||||
async def get_made_for_you_playlists(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get 'Made For You' playlists (Daily Mix, Discover Weekly, etc.).
|
||||
Uses search API with strict filtering for Spotify-owned playlists.
|
||||
Requires authenticated token (sp_dc cookie).
|
||||
"""
|
||||
try:
|
||||
# Check if we have a valid token (will try cookie auth)
|
||||
token = await self._get_access_token()
|
||||
if not token:
|
||||
logger.warning("No Spotify token available for Made For You")
|
||||
return []
|
||||
|
||||
mixes = []
|
||||
queries = ["Daily Mix", "Discover Weekly", "Release Radar", "On Repeat", "Repeat Rewind"]
|
||||
|
||||
for q in queries:
|
||||
try:
|
||||
data = await self._api_request("/search", {"q": q, "type": "playlist", "limit": 10})
|
||||
if not data:
|
||||
continue
|
||||
|
||||
items = data.get("playlists", {}).get("items", [])
|
||||
for item in items:
|
||||
if not item:
|
||||
continue
|
||||
|
||||
owner_id = item.get("owner", {}).get("id", "")
|
||||
name = item.get("name", "")
|
||||
|
||||
# Filter: owned by "spotify" OR name starts with one of our keywords
|
||||
# (Daily Mix 1, Daily Mix 2, etc. are personalized)
|
||||
is_spotify_owned = owner_id == "spotify"
|
||||
name_matches = any(name.startswith(kw) or name == kw for kw in queries)
|
||||
|
||||
if is_spotify_owned or name_matches:
|
||||
mixes.append({
|
||||
"id": item["id"],
|
||||
"name": name,
|
||||
"description": item.get("description", ""),
|
||||
"image": self._get_best_image(item.get("images", [])),
|
||||
"owner": "Spotify",
|
||||
"type": "playlist",
|
||||
"source": "spotify"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch mix '{q}': {e}")
|
||||
|
||||
# Deduplicate by ID
|
||||
unique_mixes = {m['id']: m for m in mixes}.values()
|
||||
logger.info(f"Found {len(list(unique_mixes))} Made For You playlists")
|
||||
return list(unique_mixes)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Made For You playlists: {e}")
|
||||
return []
|
||||
|
||||
async def close(self):
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
spotify_service = SpotifyService()
|
||||
158
app/ytmusic_service.py
Normal file
158
app/ytmusic_service.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"""
|
||||
YouTube Music service for Freedify.
|
||||
Uses ytmusicapi for searching YouTube Music catalog.
|
||||
Streaming is handled by existing audio_service (yt-dlp).
|
||||
"""
|
||||
from ytmusicapi import YTMusic
|
||||
from typing import Optional, Dict, List, Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YTMusicService:
|
||||
"""Service for searching YouTube Music."""
|
||||
|
||||
def __init__(self):
|
||||
# Initialize without auth (works for search)
|
||||
self.ytm = YTMusic()
|
||||
|
||||
async def search_tracks(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""Search for songs on YouTube Music."""
|
||||
try:
|
||||
# YTMusic doesn't have native offset, so we fetch more and slice
|
||||
total_needed = offset + limit
|
||||
results = self.ytm.search(query, filter="songs", limit=total_needed)
|
||||
# Slice to get the offset range
|
||||
sliced = results[offset:offset + limit] if offset > 0 else results[:limit]
|
||||
return [self._format_track(item) for item in sliced if item.get("videoId")]
|
||||
except Exception as e:
|
||||
logger.error(f"YTMusic search error: {e}")
|
||||
return []
|
||||
|
||||
async def search_albums(self, query: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""Search for albums on YouTube Music."""
|
||||
try:
|
||||
results = self.ytm.search(query, filter="albums", limit=limit)
|
||||
return [self._format_album(item) for item in results if item.get("browseId")]
|
||||
except Exception as e:
|
||||
logger.error(f"YTMusic album search error: {e}")
|
||||
return []
|
||||
|
||||
async def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get album details with tracks."""
|
||||
try:
|
||||
# Remove ytm_ prefix if present
|
||||
clean_id = album_id.replace("ytm_", "")
|
||||
data = self.ytm.get_album(clean_id)
|
||||
|
||||
album = {
|
||||
"id": f"ytm_{clean_id}",
|
||||
"type": "album",
|
||||
"name": data.get("title", ""),
|
||||
"artists": ", ".join([a.get("name", "") for a in data.get("artists", [])]),
|
||||
"album_art": self._get_thumbnail(data.get("thumbnails")),
|
||||
"total_tracks": data.get("trackCount", 0),
|
||||
"release_date": data.get("year", ""),
|
||||
"source": "ytmusic",
|
||||
}
|
||||
|
||||
tracks = []
|
||||
for item in data.get("tracks", []):
|
||||
if not item.get("videoId"):
|
||||
continue
|
||||
track = self._format_track(item)
|
||||
track["album"] = album["name"]
|
||||
track["album_art"] = album["album_art"]
|
||||
tracks.append(track)
|
||||
|
||||
album["tracks"] = tracks
|
||||
return album
|
||||
except Exception as e:
|
||||
logger.error(f"YTMusic get_album error: {e}")
|
||||
return None
|
||||
|
||||
def _format_track(self, item: dict) -> dict:
|
||||
"""Format track data for frontend."""
|
||||
artists = item.get("artists", [])
|
||||
artist_str = ", ".join([a.get("name", "") for a in artists]) if artists else ""
|
||||
|
||||
# Get album info if available
|
||||
album = item.get("album", {}) or {}
|
||||
|
||||
# Duration can be in seconds or as string "3:45"
|
||||
duration_str = item.get("duration", "0:00")
|
||||
duration_ms = self._parse_duration(duration_str)
|
||||
|
||||
return {
|
||||
"id": f"ytm_{item.get('videoId', '')}",
|
||||
"type": "track",
|
||||
"name": item.get("title", ""),
|
||||
"artists": artist_str,
|
||||
"artist_names": [a.get("name", "") for a in artists],
|
||||
"album": album.get("name", "") if isinstance(album, dict) else str(album),
|
||||
"album_id": f"ytm_{album.get('id', '')}" if isinstance(album, dict) else "",
|
||||
"album_art": self._get_thumbnail(item.get("thumbnails")),
|
||||
"duration_ms": duration_ms,
|
||||
"duration": duration_str if isinstance(duration_str, str) else self._format_duration(duration_ms),
|
||||
"isrc": f"ytm_{item.get('videoId', '')}", # Use prefixed videoId for streaming
|
||||
"source": "ytmusic",
|
||||
"video_id": item.get("videoId", ""), # Keep for reference
|
||||
}
|
||||
|
||||
def _format_album(self, item: dict) -> dict:
|
||||
"""Format album data for frontend."""
|
||||
artists = item.get("artists", [])
|
||||
artist_str = ", ".join([a.get("name", "") for a in artists]) if artists else ""
|
||||
|
||||
return {
|
||||
"id": f"ytm_{item.get('browseId', '')}",
|
||||
"type": "album",
|
||||
"name": item.get("title", ""),
|
||||
"artists": artist_str,
|
||||
"album_art": self._get_thumbnail(item.get("thumbnails")),
|
||||
"release_date": item.get("year", ""),
|
||||
"source": "ytmusic",
|
||||
}
|
||||
|
||||
def _get_thumbnail(self, thumbnails: list) -> str:
|
||||
"""Get highest quality thumbnail."""
|
||||
if not thumbnails:
|
||||
return "/static/icon.svg"
|
||||
# Sort by width descending and get the largest
|
||||
sorted_thumbs = sorted(thumbnails, key=lambda x: x.get("width", 0), reverse=True)
|
||||
url = sorted_thumbs[0].get("url", "/static/icon.svg")
|
||||
|
||||
# Proxy googleusercontent images to avoid 429
|
||||
if "googleusercontent.com" in url or "ggpht.com" in url:
|
||||
import urllib.parse
|
||||
return f"/api/proxy_image?url={urllib.parse.quote(url)}"
|
||||
|
||||
return url
|
||||
|
||||
def _parse_duration(self, duration) -> int:
|
||||
"""Parse duration string to milliseconds."""
|
||||
if isinstance(duration, int):
|
||||
return duration * 1000
|
||||
if not duration or not isinstance(duration, str):
|
||||
return 0
|
||||
try:
|
||||
parts = duration.split(":")
|
||||
if len(parts) == 2:
|
||||
return (int(parts[0]) * 60 + int(parts[1])) * 1000
|
||||
elif len(parts) == 3:
|
||||
return (int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])) * 1000
|
||||
return 0
|
||||
except:
|
||||
return 0
|
||||
|
||||
def _format_duration(self, ms: int) -> str:
|
||||
"""Format milliseconds to mm:ss."""
|
||||
seconds = ms // 1000
|
||||
mins = seconds // 60
|
||||
secs = seconds % 60
|
||||
return f"{mins}:{secs:02d}"
|
||||
|
||||
|
||||
# Singleton instance
|
||||
ytmusic_service = YTMusicService()
|
||||
Loading…
Add table
Add a link
Reference in a new issue