freedify/app/ai_radio_service.py

274 lines
9 KiB
Python
Raw Normal View History

2026-01-13 22:26:48 +00:00
"""
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()