274 lines
9 KiB
Python
274 lines
9 KiB
Python
|
|
"""
|
||
|
|
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()
|