moodwave / spotify_client.py
Radowana's picture
Upload 2 files
b6d847f verified
Raw
History Blame Contribute Delete
4.75 kB
"""
Music integration for Moodwave (Apple iTunes Search API).
Uses Apple's free, keyless iTunes Search API (https://itunes.apple.com/search)
to find tracks that match a detected mood. No account, API key, or paid
subscription of any kind is required.
NOTE: This module previously used the Spotify Web API (Client Credentials
flow). As of Feb 2026 Spotify requires a Spotify Premium account for any
Web API "Development Mode" access (see Spotify's Feb 2026 developer
changelog), so this module was switched to the iTunes Search API instead,
which has no such restriction.
"""
import random
import requests
SEARCH_URL = "https://itunes.apple.com/search"
# Hand-picked free-text search phrases per mood. Plain free-text search
# performs better against the iTunes track index than trying to filter
# by genre.
MOOD_QUERIES = {
"joy": [
"happy upbeat pop",
"feel good dance hits",
"uplifting summer anthems",
"energetic feel-good songs",
],
"sadness": [
"sad acoustic ballads",
"melancholy piano songs",
"heartbreak slow songs",
"rainy day sad songs",
],
"anger": [
"aggressive rock anthems",
"angry metal songs",
"rage rap",
"intense hard rock",
],
"fear": [
"dark ambient tense",
"eerie atmospheric soundtrack",
"anxious moody electronic",
"suspenseful score",
],
"neutral": [
"chill lofi beats",
"calm instrumental",
"relaxing background music",
"easy listening chill",
],
}
MOOD_EMOJI = {
"joy": "🟢",
"sadness": "🔵",
"anger": "🔴",
"fear": "🟣",
"neutral": "⚪",
}
def spotify_configured() -> bool:
"""Kept for backward compatibility with app.py's import - the iTunes
Search API needs no credentials, so this is always available."""
return True
def search_tracks_for_mood(mood: str, n: int = 5):
"""
Return up to `n` tracks matching the given mood via the iTunes Search API.
Each track is a dict: {name, artists, url, preview_url, image}.
Raises RuntimeError with a human-readable message on failure.
"""
mood = (mood or "neutral").lower()
if mood not in MOOD_QUERIES:
mood = "neutral"
query = random.choice(MOOD_QUERIES[mood])
try:
resp = requests.get(
SEARCH_URL,
params={
"term": query,
"media": "music",
"entity": "song",
"limit": 25,
"country": "US",
},
timeout=10,
)
resp.raise_for_status()
except requests.RequestException as e:
raise RuntimeError(f"Couldn't reach the music search service ({e}). Try again.")
items = resp.json().get("results", []) or []
# Only keep results that actually have a playable 30-second preview.
items = [t for t in items if t.get("previewUrl")]
if not items:
return []
random.shuffle(items)
chosen = items[:n]
tracks = []
for t in chosen:
artwork = t.get("artworkUrl100", "")
tracks.append(
{
"name": t.get("trackName", "Unknown"),
"artists": t.get("artistName", ""),
"url": t.get("trackViewUrl", "#"),
"preview_url": t.get("previewUrl", ""),
"image": artwork.replace("100x100", "300x300") if artwork else "",
}
)
return tracks
def tracks_to_html(tracks, mood: str) -> str:
"""Render a list of track dicts as a column of preview-player cards."""
if not tracks:
return "<p>No tracks found — try again.</p>"
cards = "\n".join(
'<div style="display:flex;align-items:center;gap:12px;background:rgba(255,255,255,0.04);'
'border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:10px;">'
f'<img src="{t["image"]}" style="width:56px;height:56px;border-radius:8px;object-fit:cover;flex-shrink:0;" />'
'<div style="flex:1;min-width:0;">'
f'<div style="font-weight:700;font-size:0.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">'
f'<a href="{t["url"]}" target="_blank" style="color:#fff;text-decoration:none;">{t["name"]}</a></div>'
f'<div style="font-size:0.8rem;color:rgba(255,255,255,0.55);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{t["artists"]}</div>'
f'<audio controls preload="none" style="width:100%;height:32px;margin-top:6px;">'
f'<source src="{t["preview_url"]}" type="audio/mp4"></audio>'
'</div></div>'
for t in tracks
)
return f'<div style="display:flex;flex-direction:column;gap:10px;">{cards}</div>'