Initial commit

This commit is contained in:
Percy 2026-01-13 22:26:48 +00:00
commit c803de020e
52 changed files with 20439 additions and 0 deletions

1
app/__init__.py Normal file
View file

@ -0,0 +1 @@
# App package

273
app/ai_radio_service.py Normal file
View 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

File diff suppressed because it is too large Load diff

133
app/cache.py Normal file
View 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
View 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
View 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
View 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
View 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 AB 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

194
app/musicbrainz_service.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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()