"""Spotify integration -- emotion-to-feature mapping and track recommendations.""" import os import json import math import random import urllib.request import urllib.parse from typing import Optional # Multiple mood-keyword sets per emotion — one is picked randomly each call EMOTION_SEARCH_TERMS: dict = { "joy": ["happy upbeat feel-good", "cheerful bright danceable", "fun positive energetic", "joyful lively vibrant"], "love": ["romantic love sweet", "tender intimate soulful", "passionate warm affectionate", "dreamy soft romantic"], "sadness": ["sad melancholic emotional", "heartbreak lonely blue", "grief somber tearful", "wistful slow mournful"], "anger": ["angry intense aggressive", "furious hard-hitting raw", "rage rebellious fierce", "heavy loud confrontational"], "fear": ["dark atmospheric tense", "eerie haunting unsettling", "sinister cold suspenseful", "gothic mysterious brooding"], "surprise": ["exciting energetic dynamic", "unexpected bold eclectic", "euphoric rush powerful", "electrifying dramatic vivid"], "neutral": ["chill relaxed smooth", "ambient laid-back mellow", "focus calm easy-listening", "soft understated steady"], "sarcasm": ["quirky witty alternative", "sardonic indie offbeat", "ironic playful subversive", "dry wry unconventional"], } EMOTION_TARGETS: dict = { "joy": {"valence": 0.85, "energy": 0.82, "danceability": 0.80, "tempo": 128, "mode": 1, "genres": ["pop", "dance", "happy"]}, "love": {"valence": 0.75, "energy": 0.52, "danceability": 0.62, "tempo": 96, "mode": 1, "genres": ["romance", "r-n-b", "soul"]}, "sadness": {"valence": 0.18, "energy": 0.28, "danceability": 0.32, "tempo": 72, "mode": 0, "genres": ["sad", "indie", "acoustic"]}, "anger": {"valence": 0.18, "energy": 0.88, "danceability": 0.58, "tempo": 148, "mode": 0, "genres": ["metal", "hard-rock", "punk"]}, "fear": {"valence": 0.22, "energy": 0.52, "danceability": 0.38, "tempo": 88, "mode": 0, "genres": ["ambient", "goth", "trip-hop"]}, "surprise": {"valence": 0.62, "energy": 0.80, "danceability": 0.70, "tempo": 138, "mode": 1, "genres": ["pop", "electronic", "edm"]}, "neutral": {"valence": 0.50, "energy": 0.38, "danceability": 0.50, "tempo": 100, "mode": 1, "genres": ["chill", "study", "indie-pop"]}, "sarcasm": {"valence": 0.45, "energy": 0.60, "danceability": 0.55, "tempo": 110, "mode": 0, "genres": ["alternative", "indie", "emo"]}, } class EmotionFeatureMapper: """Converts emotion classifier output to Spotify audio feature targets. Blends the top-2 emotions weighted by their probabilities. Uses confidence to set tight/loose constraint windows around targets. """ def map(self, probabilities: dict, confidence: float) -> dict: """Map emotion probabilities to Spotify target parameters. Args: probabilities: {emotion: probability} from EmotionPredictor confidence: Top emotion confidence (0-1) Returns: Dict with target_* params, min/max_valence window, seed_genres, and _target_vector (internal, stripped before Spotify API call). """ sorted_emotions = sorted(probabilities.items(), key=lambda x: x[1], reverse=True) top1_name, top1_prob = sorted_emotions[0] top2_name, top2_prob = sorted_emotions[1] if len(sorted_emotions) > 1 else (top1_name, 0.0) t1 = EMOTION_TARGETS.get(top1_name, EMOTION_TARGETS["neutral"]) t2 = EMOTION_TARGETS.get(top2_name, EMOTION_TARGETS["neutral"]) total = top1_prob + top2_prob w1 = top1_prob / total if total > 0 else 1.0 w2 = top2_prob / total if total > 0 else 0.0 bv = round(w1 * t1["valence"] + w2 * t2["valence"], 3) be = round(w1 * t1["energy"] + w2 * t2["energy"], 3) bd = round(w1 * t1["danceability"] + w2 * t2["danceability"], 3) bt = round(w1 * t1["tempo"] + w2 * t2["tempo"]) bm = round(w1 * t1["mode"] + w2 * t2["mode"]) window = 0.08 if confidence > 0.75 else (0.15 if confidence > 0.45 else 0.25) return { "target_valence": bv, "target_energy": be, "target_danceability": bd, "target_tempo": bt, "target_mode": bm, "min_valence": round(max(0.0, bv - window), 3), "max_valence": round(min(1.0, bv + window), 3), "seed_genres": t1["genres"][:2], "_target_vector": [bv, be, bd], } class SpotifyRecommender: """Fetches song recommendations from Spotify based on emotion analysis. Credentials are read from env vars: SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET Anti-popularity bias: max_popularity (default 65) avoids chart-toppers. """ def __init__( self, client_id: Optional[str] = None, client_secret: Optional[str] = None, max_popularity: int = 65, ): try: import spotipy from spotipy.oauth2 import SpotifyClientCredentials except ImportError: raise ImportError( "spotipy is required.\n" "Install: pip install spotipy>=2.23.0" ) _id = client_id or os.environ.get("SPOTIFY_CLIENT_ID") _secret = client_secret or os.environ.get("SPOTIFY_CLIENT_SECRET") if not _id or not _secret: raise ValueError( "Spotify credentials not found.\n" "Copy .env.example -> .env and fill in SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET." ) self.sp = spotipy.Spotify( auth_manager=SpotifyClientCredentials(client_id=_id, client_secret=_secret) ) self.mapper = EmotionFeatureMapper() self.max_popularity = max_popularity def recommend( self, predict_result: dict, limit: int = 10, genre_override: Optional[str] = None, blend: bool = True, raw_text: Optional[str] = None, ) -> dict: """Recommend songs based on an EmotionPredictor result. Args: predict_result: dict from EmotionPredictor.predict() limit: Number of tracks to return (default 10) genre_override: Override seed genres with a single genre string blend: If False, use only top-1 emotion (no blending) Returns: dict with emotion summary, targets used, and ranked track list """ emotion = predict_result["emotion"] confidence = predict_result["confidence"] probabilities = predict_result["probabilities"] if not blend: top = max(probabilities, key=probabilities.get) probabilities = {top: 1.0} targets = self.mapper.map(probabilities, confidence) target_vector = targets.pop("_target_vector") seed_genres = [genre_override] if genre_override else targets.pop("seed_genres") api_params = {k: v for k, v in targets.items() if k.startswith(("target_", "min_", "max_"))} # Build search query from emotion mood terms and genre seeds # Randomly pick a mood-term variant and a search offset for result variety term_options = EMOTION_SEARCH_TERMS.get(emotion, [""]) mood_terms = random.choice(term_options) genre_terms = " ".join(seed_genres[:2]) query = f"{genre_terms} {mood_terms}".strip() offset = random.randint(0, 3) * 10 # 0, 10, 20, or 30 # NOTE: Spotify limits non-extended-access apps to limit=10 on /search. try: results = self.sp.search(q=query, type="track", limit=10, offset=offset) except Exception as exc: raise RuntimeError(f"Spotify API error: {exc}") from exc raw_tracks = results.get("tracks", {}).get("items", []) # Prefer tracks that have album art — niche searches often return library # tracks with empty image arrays; filter them out unless it empties the pool with_art = [t for t in raw_tracks if (t.get("album") or {}).get("images")] if with_art: raw_tracks = with_art if not raw_tracks: return { "emotion": emotion, "confidence": confidence, "probabilities": predict_result.get("probabilities", {}), "explanation": predict_result.get("explanation", ""), "targets_used": {**api_params, "seed_genres": seed_genres}, "tracks": [], } # Popularity filter — prefer hidden gems filtered = [t for t in raw_tracks if t.get("popularity", 101) <= self.max_popularity] if not filtered: filtered = sorted(raw_tracks, key=lambda t: t.get("popularity", 100))[: limit * 2] track_ids = [t["id"] for t in filtered if t.get("id")] features_map = self._fetch_audio_features(track_ids) scored = self._score_tracks(filtered, features_map, target_vector) diverse = self._diversity_filter(scored, limit=limit, max_per_artist=2) # If input looks like lyrics, pin the source song at position 0 pinned: list = [] if raw_text: src = self._find_source_track(raw_text) if src: src_url = src.get("external_urls", {}).get("spotify") diverse = [t for t in diverse if t.get("spotify_url") != src_url] images = src.get("album", {}).get("images", []) pinned = [{ "name": src["name"], "artist": ", ".join(a["name"] for a in src.get("artists", [])), "album": src.get("album", {}).get("name", ""), "album_art": images[0]["url"] if images else None, "spotify_url": src_url, "preview_url": src.get("preview_url"), "popularity": src.get("popularity"), "match_score": 100.0, "audio_features": None, "is_source": True, }] tracks = (pinned + diverse)[:limit] return { "emotion": emotion, "confidence": confidence, "probabilities": predict_result.get("probabilities", {}), "explanation": predict_result.get("explanation", ""), "music_context": predict_result.get("music_context", {}), "targets_used": {**api_params, "seed_genres": seed_genres}, "tracks": tracks, } def _find_source_track(self, raw_text: str) -> Optional[dict]: """Identify the song the input text is from. Primary: Musixmatch lyrics search (same method Spotify uses internally), then looks the result up on Spotify. Fallback: heuristic title-word matching against a direct Spotify search. """ mm_key = os.environ.get("MUSIXMATCH_API_KEY") if mm_key: track = self._musixmatch_lyrics_search(raw_text, mm_key) if track: return track # Fallback: Spotify search + title-word / 5-word-window heuristics try: results = self.sp.search(q=raw_text[:150], type="track", limit=5) candidates = results.get("tracks", {}).get("items", []) or [] except Exception: return None tokens = [w.lower().strip(".,!?\"'-") for w in raw_text.split() if w.strip(".,!?\"'-")] input_set = set(tokens) windows = [" ".join(tokens[i:i + 5]) for i in range(len(tokens) - 4)] for track in candidates: title_words = [ w.lower().strip(".,!?\"'-") for w in track.get("name", "").split() if len(w.strip(".,!?\"'-")) >= 2 ] if len(title_words) >= 2 and all(w in input_set for w in title_words): return track haystack = " ".join([ track.get("name", "").lower(), *[a["name"].lower() for a in track.get("artists", [])], ]) if windows and any(window in haystack for window in windows): return track return None def _musixmatch_lyrics_search(self, raw_text: str, api_key: str) -> Optional[dict]: """Query Musixmatch for a song matching the lyrics, then look it up on Spotify.""" params = urllib.parse.urlencode({ "q_lyrics": raw_text[:200], "apikey": api_key, "page_size": 3, "s_track_rating": "desc", "format": "json", }) try: req = urllib.request.Request( f"https://api.musixmatch.com/ws/1.1/track.search?{params}", headers={"User-Agent": "eumora/1.0"}, ) with urllib.request.urlopen(req, timeout=5) as r: data = json.loads(r.read()) track_list = ( data.get("message", {}) .get("body", {}) .get("track_list", []) ) if not track_list: return None top = track_list[0]["track"] title = top["track_name"] artist = top["artist_name"] except Exception: return None # Look up the identified song on Spotify try: results = self.sp.search( q=f'track:"{title}" artist:"{artist}"', type="track", limit=1, ) items = results.get("tracks", {}).get("items") or [] return items[0] if items else None except Exception: return None def _fetch_audio_features(self, track_ids: list) -> dict: features_map: dict = {} try: for i in range(0, len(track_ids), 100): for feat in (self.sp.audio_features(track_ids[i : i + 100]) or []): if feat and feat.get("id"): features_map[feat["id"]] = feat except Exception: pass # audio-features may be deprecated; degrade gracefully return features_map def _score_tracks(self, tracks: list, features_map: dict, target_vector: list) -> list: scored = [] n = len(tracks) for i, track in enumerate(tracks): feat = features_map.get(track.get("id")) if feat: tv = [feat.get("valence", 0.5), feat.get("energy", 0.5), feat.get("danceability", 0.5)] dist = math.sqrt(sum((a - b) ** 2 for a, b in zip(target_vector, tv))) match_score = round(max(0.0, 100.0 - dist * 100.0), 1) else: # Fallback: use search-rank position (Spotify returns results by relevance). # Rank 0 = 95%, rank n-1 = 50%, spread linearly. match_score = round(95.0 - (i / max(n - 1, 1)) * 45.0, 1) images = (track.get("album") or {}).get("images", []) scored.append({ "name": track["name"], "artist": ", ".join(a["name"] for a in track.get("artists", [])), "album": track.get("album", {}).get("name", ""), "album_art": images[0]["url"] if images else None, "spotify_url": track.get("external_urls", {}).get("spotify"), "preview_url": track.get("preview_url"), "popularity": track.get("popularity"), "match_score": match_score, "audio_features": { k: feat.get(k) for k in ["valence", "energy", "danceability", "tempo", "mode", "acousticness", "speechiness"] } if feat else None, }) scored.sort(key=lambda x: x["match_score"], reverse=True) return scored def _diversity_filter(self, scored: list, limit: int, max_per_artist: int) -> list: seen: dict = {} result: list = [] for track in scored: if seen.get(track["artist"], 0) < max_per_artist: result.append(track) seen[track["artist"]] = seen.get(track["artist"], 0) + 1 if len(result) >= limit: break return result