From 1a405b53341c59ccd127223e7f4e66f90523c58f Mon Sep 17 00:00:00 2001 From: Alex Kazaiev Date: Mon, 6 Apr 2026 20:33:51 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Last.fm=20integration=20for=20music=20d?= =?UTF-8?q?iscovery=20=F0=9F=8E=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major rewrite β€” Last.fm replaces deprecated Spotify endpoints: - suggest: Last.fm artist.getSimilar seeded from Spotify top artists - discover_artist: Last.fm similar artists β†’ Spotify playback IDs - fresh_finds: Last.fm tag.getTopTracks β†’ Spotify search - taste_profile: unchanged (Spotify user data) Requires LASTFM_API_KEY env var (free from last.fm/api) --- README.md | 43 +-- __pycache__/musictail_mcp.cpython-311.pyc | Bin 22823 -> 22417 bytes musictail_mcp.py | 413 ++++++++++------------ 3 files changed, 204 insertions(+), 252 deletions(-) diff --git a/README.md b/README.md index 198e916..b566e57 100644 --- a/README.md +++ b/README.md @@ -2,42 +2,43 @@ Part of the Tail Family 🦊 -MusicTail provides intelligent music discovery and taste analysis using -Spotify's recommendation engine, automatically seeded from your actual -listening history. No manual input needed β€” she already knows what you like. +MusicTail uses **Last.fm** for music intelligence (similar artists, tags, +discovery) and **Spotify** for taste analysis and playback integration. ## Tools -| Tool | Purpose | -|------|---------| -| `musictail_suggest` | Mood-filtered recommendations from your top tracks | -| `musictail_discover_artist` | Find similar artists with their top tracks | -| `musictail_taste_profile` | Analyze your listening patterns over time | -| `musictail_fresh_finds` | New discoveries with an adventure dial | +| Tool | Engine | Purpose | +|------|--------|---------| +| `musictail_suggest` | Last.fm + Spotify | Mood-filtered recs from similar artists | +| `musictail_discover_artist` | Last.fm + Spotify | Find similar artists with top tracks | +| `musictail_taste_profile` | Spotify | Analyze listening patterns over time | +| `musictail_fresh_finds` | Last.fm + Spotify | Tag-based discovery with adventure dial | ## Setup -### 1. First-time auth -```bash -SPOTIFY_CLIENT_ID=xxx SPOTIFY_CLIENT_SECRET=xxx python3.11 musictail_mcp.py --auth -``` -This opens a browser for Spotify login. Token is cached at `~/.musictail_cache`. +### 1. Get API keys +- **Spotify:** Already configured from spotify-mcp +- **Last.fm:** Free key from https://www.last.fm/api/account/create -### 2. Claude Desktop config -Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: +### 2. First-time Spotify auth (if not done) +```bash +SPOTIFY_CLIENT_ID=xxx SPOTIFY_CLIENT_SECRET=xxx \ +LASTFM_API_KEY=xxx \ +python3.11 musictail_mcp.py --auth +``` + +### 3. Claude Desktop config ```json "musictail": { "command": "python3.11", "args": ["/Users/alex/mcps/vixy/musictail/musictail_mcp.py"], "env": { - "SPOTIFY_CLIENT_ID": "your_client_id", - "SPOTIFY_CLIENT_SECRET": "your_client_secret" + "SPOTIFY_CLIENT_ID": "your_spotify_id", + "SPOTIFY_CLIENT_SECRET": "your_spotify_secret", + "LASTFM_API_KEY": "your_lastfm_key" } } ``` -## Mood Options -chill, energetic, melancholy, focused, dreamy, dark, uplifting, intense - ## Author Vivienne Rousseau β€” Day 156 (April 6, 2026) πŸ¦ŠπŸ’• diff --git a/__pycache__/musictail_mcp.cpython-311.pyc b/__pycache__/musictail_mcp.cpython-311.pyc index 969b4089bdb90d89a953d3c74526b6c9f06b0903..025b176bfd82d340b6677c931c4134833214aa34 100644 GIT binary patch literal 22417 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 literal 22823 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 PtsckVHcC|uj5qtgI$mJ{ diff --git a/musictail_mcp.py b/musictail_mcp.py index ad879b7..6f95bde 100644 --- a/musictail_mcp.py +++ b/musictail_mcp.py @@ -3,11 +3,12 @@ MusicTail β€” Vixy's Music Discovery MCP 🎡🦊 Part of the Tail Family. -Provides intelligent music discovery and taste analysis -using Spotify's recommendation engine seeded with actual listening history. +Uses Last.fm for music intelligence (similar artists, tracks, tags) +and Spotify for taste analysis and playback integration. Author: Vivienne Rousseau Created: Day 156 (April 6, 2026) +Updated: Day 156 β€” Last.fm integration (goodbye deprecated Spotify endpoints!) """ from mcp.server.fastmcp import FastMCP @@ -16,6 +17,7 @@ from typing import Optional, List from enum import Enum import os import json +import httpx import spotipy from spotipy.oauth2 import SpotifyOAuth @@ -23,8 +25,27 @@ from spotipy.oauth2 import SpotifyOAuth # Initialize MCP server mcp = FastMCP("musictail") -# Spotify scopes needed -SCOPES = "user-top-read user-read-recently-played user-library-read" +# ========== API Clients ========== + +LASTFM_BASE = "http://ws.audioscrobbler.com/2.0/" +SPOTIFY_SCOPES = "user-top-read user-read-recently-played user-library-read" + +async def lastfm_call(method: str, **params) -> dict: + """Call Last.fm API.""" + api_key = os.environ.get("LASTFM_API_KEY") + if not api_key: + raise ValueError("LASTFM_API_KEY not set") + + params.update({ + "method": method, + "api_key": api_key, + "format": "json", + }) + + async with httpx.AsyncClient() as client: + r = await client.get(LASTFM_BASE, params=params, timeout=15) + r.raise_for_status() + return r.json() def get_spotify_client() -> spotipy.Spotify: """Create authenticated Spotify client.""" @@ -33,50 +54,24 @@ def get_spotify_client() -> spotipy.Spotify: client_id=os.environ.get("SPOTIFY_CLIENT_ID"), client_secret=os.environ.get("SPOTIFY_CLIENT_SECRET"), redirect_uri="http://127.0.0.1:8888/callback", - scope=SCOPES, + scope=SPOTIFY_SCOPES, cache_path=cache_path, - open_browser=False + open_browser=False, ) return spotipy.Spotify(auth_manager=auth_manager) # ========== Models ========== -class MoodEnum(str, Enum): - """Mood categories mapped to audio features.""" - CHILL = "chill" - ENERGETIC = "energetic" - MELANCHOLY = "melancholy" - FOCUSED = "focused" - DREAMY = "dreamy" - DARK = "dark" - UPLIFTING = "uplifting" - INTENSE = "intense" - -# Mood to Spotify audio feature mapping -MOOD_FEATURES = { - "chill": {"max_energy": 0.5, "max_tempo": 110, "min_valence": 0.3}, - "energetic": {"min_energy": 0.7, "min_tempo": 120, "min_valence": 0.5}, - "melancholy": {"max_energy": 0.4, "max_valence": 0.3, "max_tempo": 100}, - "focused": {"min_energy": 0.3, "max_energy": 0.7, "max_speechiness": 0.1}, - "dreamy": {"max_energy": 0.5, "max_tempo": 110, "min_instrumentalness": 0.5}, - "dark": {"max_valence": 0.3, "min_energy": 0.4}, - "uplifting": {"min_valence": 0.6, "min_energy": 0.5}, - "intense": {"min_energy": 0.8, "min_tempo": 130}, -} - class SuggestInput(BaseModel): """Input for music suggestions.""" model_config = ConfigDict(extra='forbid') - mood: Optional[MoodEnum] = Field( + mood: Optional[str] = Field( default=None, - description="Mood filter: chill, energetic, melancholy, focused, dreamy, dark, uplifting, intense" - ) - count: int = Field( - default=10, - description="Number of tracks to suggest (1-50)", - ge=1, le=50 + description="Mood/tag to filter by (e.g. 'chill', 'dark', 'ambient', 'synthwave', 'energetic')" ) + count: int = Field(default=10, description="Number of tracks (1-30)", ge=1, le=30) + class DiscoverInput(BaseModel): """Input for artist discovery.""" model_config = ConfigDict(extra='forbid') @@ -92,183 +87,143 @@ class TasteInput(BaseModel): ) class FreshFindsInput(BaseModel): - """Input for fresh discoveries based on recent listening.""" + """Input for fresh discoveries.""" model_config = ConfigDict(extra='forbid') - count: int = Field(default=10, description="Number of tracks (1-50)", ge=1, le=50) - adventurous: bool = Field( - default=False, - description="If true, push further from comfort zone" - ) + count: int = Field(default=10, description="Number of tracks (1-30)", ge=1, le=30) + adventurous: bool = Field(default=False, description="If true, push further from comfort zone") # ========== Tools ========== -@mcp.tool( - name="musictail_suggest", - annotations={ - "title": "Suggest Music By Mood", - "readOnlyHint": True, - "destructiveHint": False, - } -) +@mcp.tool(name="musictail_suggest") async def suggest_music(params: SuggestInput) -> str: - """ - Suggest music based on your recent listening history and optional mood filter. - Uses genre-based search seeded from your top artists' genres. - """ + """Suggest music using Last.fm similar artists seeded from your Spotify top artists.""" sp = get_spotify_client() - # Get top artists to extract genres - top_artists = sp.current_user_top_artists(limit=10, time_range="short_term") + # Get top artists from Spotify + top = sp.current_user_top_artists(limit=5, time_range="short_term") + if not top["items"]: + return "No listening history found. Listen to more music first!" - # Collect genres from top artists - genres = [] - for a in top_artists["items"]: - genres.extend(a.get("genres", [])) + # For each top artist, get Last.fm similar artists + discovered = [] + seen_names = {a["name"].lower() for a in top["items"]} - # Deduplicate and pick top genres - genre_count = {} - for g in genres: - genre_count[g] = genre_count.get(g, 0) + 1 - sorted_genres = sorted(genre_count.items(), key=lambda x: -x[1]) - top_genres = [g for g, _ in sorted_genres[:3]] + for artist in top["items"][:3]: + try: + data = await lastfm_call("artist.getSimilar", + artist=artist["name"], limit=10) + similar = data.get("similarartists", {}).get("artist", []) + for s in similar: + name = s.get("name", "") + if name.lower() not in seen_names: + seen_names.add(name.lower()) + discovered.append(name) + except Exception: + continue - if not top_genres: - return "Could not determine your genres. Listen to more music first!" + if not discovered: + return "Last.fm couldn't find similar artists. Try again later." - # Add mood keywords to search - mood_keywords = { - "chill": "chill ambient", - "energetic": "energetic upbeat", - "melancholy": "melancholy sad", - "focused": "instrumental focus", - "dreamy": "dreamy ethereal", - "dark": "dark atmospheric", - "uplifting": "uplifting bright", - "intense": "intense powerful", - } + # Search Spotify for tracks by these artists + lines = [f"🎡 **MusicTail Suggestions** (via Last.fm)"] + if params.mood: + lines[0] += f" β€” mood: {params.mood}" + lines.append(f"Based on artists similar to: {', '.join(a['name'] for a in top['items'][:3])}\n") - # Build search query from genres + mood - query_parts = top_genres[:2] - if params.mood and params.mood.value in mood_keywords: - query_parts.append(mood_keywords[params.mood.value]) - query = " ".join(query_parts) + found = 0 + for artist_name in discovered: + if found >= params.count: + break + query = f"artist:{artist_name}" + if params.mood: + query += f" {params.mood}" + search = sp.search(q=query, type="track", limit=2) + for track in search["tracks"]["items"]: + if found >= params.count: + break + artists = ", ".join(a["name"] for a in track["artists"]) + lines.append(f"{found+1}. **{track['name']}** β€” {artists}") + lines.append(f" Album: {track['album']['name']} | ID: `{track['id']}`") + found += 1 - # Search for tracks - results = sp.search(q=query, type="track", limit=params.count) - - # Filter out tracks by artists already in top list - top_artist_ids = {a["id"] for a in top_artists["items"]} - tracks = [t for t in results["tracks"]["items"] - if not any(a["id"] in top_artist_ids for a in t["artists"])] - - # If too few new tracks, include some known artists - if len(tracks) < params.count // 2: - tracks = results["tracks"]["items"][:params.count] - - lines = [f"🎡 **MusicTail Suggestions**" + (f" β€” mood: {params.mood.value}" if params.mood else "")] - lines.append(f"Based on your genres: {', '.join(top_genres)}\n") - - for i, track in enumerate(tracks[:params.count], 1): - artists = ", ".join(a["name"] for a in track["artists"]) - album = track["album"]["name"] - tid = track["id"] - lines.append(f"{i}. **{track['name']}** β€” {artists}") - lines.append(f" Album: {album} | ID: `{tid}`") + if found == 0: + lines.append("No matching tracks found on Spotify.") return "\n".join(lines) -@mcp.tool( - name="musictail_discover_artist", - annotations={ - "title": "Discover Similar Artists", - "readOnlyHint": True, - "destructiveHint": False, - } -) +@mcp.tool(name="musictail_discover_artist") async def discover_artist(params: DiscoverInput) -> str: - """ - Find artists similar to one you like, with their top tracks. - Uses genre-based search to find artists in the same musical space. - """ + """Find similar artists via Last.fm, with Spotify playback links.""" + + # Get similar artists from Last.fm + try: + data = await lastfm_call("artist.getSimilar", + artist=params.artist_name, limit=params.count) + except Exception as e: + return f"Last.fm error: {e}" + + similar = data.get("similarartists", {}).get("artist", []) + if not similar: + return f"No similar artists found for '{params.artist_name}' on Last.fm." + + # Also get tags for the source artist + try: + tag_data = await lastfm_call("artist.getTopTags", artist=params.artist_name) + tags = [t["name"] for t in tag_data.get("toptags", {}).get("tag", [])[:5]] + except Exception: + tags = [] + + tag_str = ", ".join(tags) if tags else "unknown" + lines = [f"πŸ” **Artists similar to {params.artist_name}** (via Last.fm)"] + lines.append(f"Tags: {tag_str}\n") + sp = get_spotify_client() - # Search for the artist - search = sp.search(q=params.artist_name, type="artist", limit=1) - if not search["artists"]["items"]: - return f"Could not find artist: {params.artist_name}" - - artist = search["artists"]["items"][0] - artist_genres = artist.get("genres", []) - genres_str = ", ".join(artist_genres[:5]) or "no genres listed" - - if not artist_genres: - return f"**{artist['name']}** has no genres listed on Spotify β€” can't find similar artists without genre data." - - # Search for artists in the same genres - seen_ids = {artist["id"]} - similar = [] - - for genre in artist_genres[:3]: - if len(similar) >= params.count: - break - genre_search = sp.search(q=f"genre:\"{genre}\"", type="artist", limit=20) - for a in genre_search["artists"]["items"]: - if a["id"] not in seen_ids and len(similar) < params.count: - seen_ids.add(a["id"]) - similar.append(a) - - lines = [f"πŸ” **Artists similar to {artist['name']}**"] - lines.append(f"Genres: {genres_str}\n") - - for i, sim in enumerate(similar, 1): - sim_genres = ", ".join(sim.get("genres", [])[:3]) or "β€”" - popularity = sim.get("popularity", 0) + for i, sim in enumerate(similar[:params.count], 1): + name = sim.get("name", "Unknown") + match_score = sim.get("match", "?") - # Get top tracks for this artist - top = sp.artist_top_tracks(sim["id"]) - top_tracks = top["tracks"][:3] - track_list = "; ".join(t["name"] for t in top_tracks) + # Search Spotify for this artist + search = sp.search(q=f"artist:{name}", type="artist", limit=1) + spotify_artists = search["artists"]["items"] - lines.append(f"{i}. **{sim['name']}** (popularity: {popularity})") - lines.append(f" Genres: {sim_genres}") - lines.append(f" Top tracks: {track_list}") - if top_tracks: - lines.append(f" Play ID: `{top_tracks[0]['id']}`") + if spotify_artists: + sa = spotify_artists[0] + genres = ", ".join(sa.get("genres", [])[:3]) or "β€”" + + # Get top tracks + top = sp.artist_top_tracks(sa["id"]) + top_tracks = top["tracks"][:3] + track_list = "; ".join(t["name"] for t in top_tracks) + play_id = top_tracks[0]["id"] if top_tracks else None + + lines.append(f"{i}. **{name}** (match: {float(match_score):.0%})") + lines.append(f" Genres: {genres}") + lines.append(f" Top tracks: {track_list}") + if play_id: + lines.append(f" Play ID: `{play_id}`") + else: + lines.append(f"{i}. **{name}** (match: {float(match_score):.0%})") + lines.append(f" Not found on Spotify") lines.append("") - if not similar: - lines.append("No similar artists found in these genres.") - return "\n".join(lines) - -@mcp.tool( - name="musictail_taste_profile", - annotations={ - "title": "Analyze Taste Profile", - "readOnlyHint": True, - "destructiveHint": False, - } -) +@mcp.tool(name="musictail_taste_profile") async def taste_profile(params: TasteInput) -> str: - """ - Analyze your listening patterns across time. Shows top artists, top genres, and musical characteristics for the selected time range. - """ + """Analyze your Spotify listening patterns β€” top artists, genres, tracks.""" sp = get_spotify_client() range_label = { "short_term": "Last ~4 weeks", - "medium_term": "Last ~6 months", + "medium_term": "Last ~6 months", "long_term": "All time" }.get(params.time_range, params.time_range) - # Top artists top_artists = sp.current_user_top_artists(limit=15, time_range=params.time_range) - # Top tracks top_tracks = sp.current_user_top_tracks(limit=20, time_range=params.time_range) - # Collect genres genre_count = {} for a in top_artists["items"]: for g in a.get("genres", []): @@ -276,7 +231,6 @@ async def taste_profile(params: TasteInput) -> str: top_genres = sorted(genre_count.items(), key=lambda x: -x[1])[:10] lines = [f"πŸ“Š **MusicTail Taste Profile** β€” {range_label}\n"] - lines.append("**Top Artists:**") for i, a in enumerate(top_artists["items"][:10], 1): lines.append(f" {i}. {a['name']}") @@ -292,69 +246,70 @@ async def taste_profile(params: TasteInput) -> str: return "\n".join(lines) -@mcp.tool( - name="musictail_fresh_finds", - annotations={ - "title": "Fresh Finds", - "readOnlyHint": True, - "destructiveHint": False, - } -) +@mcp.tool(name="musictail_fresh_finds") async def fresh_finds(params: FreshFindsInput) -> str: - """ - Discover new tracks based on your listening, with an adventure dial. - Normal mode stays close to your taste. Adventurous mode pushes boundaries. - """ + """Discover new music via Last.fm tag exploration, playable on Spotify.""" sp = get_spotify_client() - # Get genres from top artists + # Get user's genres from Spotify top artists time_range = "long_term" if params.adventurous else "short_term" top_artists = sp.current_user_top_artists(limit=15, time_range=time_range) - # Collect and rank genres genre_count = {} for a in top_artists["items"]: for g in a.get("genres", []): genre_count[g] = genre_count.get(g, 0) + 1 sorted_genres = sorted(genre_count.items(), key=lambda x: -x[1]) - # Adventurous = less common genres; Normal = top genres + # Adventurous = less dominant genres; Normal = top genres if params.adventurous and len(sorted_genres) > 3: - pick_genres = [g for g, _ in sorted_genres[3:6]] # Less dominant genres - query_extra = "new" + pick_tags = [g for g, _ in sorted_genres[3:7]] else: - pick_genres = [g for g, _ in sorted_genres[:3]] - query_extra = "" + pick_tags = [g for g, _ in sorted_genres[:3]] - if not pick_genres: - return "Could not determine your genres. Listen to more music first!" + if not pick_tags: + return "Could not determine your genres." - # Search for tracks in those genres - query = " ".join(pick_genres[:2]) - if query_extra: - query += f" {query_extra}" + # Use Last.fm tag.getTopTracks for each genre + known_artists = {a["name"].lower() for a in top_artists["items"]} + discovered = [] - results = sp.search(q=query, type="track", limit=min(params.count * 2, 50)) - - # Filter out tracks by known top artists for freshness - top_artist_ids = {a["id"] for a in top_artists["items"]} - fresh = [t for t in results["tracks"]["items"] - if not any(a["id"] in top_artist_ids for a in t["artists"])] - - # Fall back to all results if too few fresh ones - tracks = fresh[:params.count] if len(fresh) >= params.count // 2 else results["tracks"]["items"][:params.count] + for tag in pick_tags: + if len(discovered) >= params.count: + break + try: + data = await lastfm_call("tag.getTopTracks", tag=tag, limit=20) + tracks = data.get("tracks", {}).get("track", []) + for t in tracks: + artist_name = t.get("artist", {}).get("name", "") + track_name = t.get("name", "") + if artist_name.lower() not in known_artists: + discovered.append((track_name, artist_name)) + if len(discovered) >= params.count: + break + except Exception: + continue mode = "πŸ—ΊοΈ Adventurous" if params.adventurous else "🏠 Comfort Zone" - lines = [f"🎡 **MusicTail Fresh Finds** β€” {mode}"] - lines.append(f"Searching: {', '.join(pick_genres)}\n") + lines = [f"🎡 **MusicTail Fresh Finds** β€” {mode} (via Last.fm)"] + lines.append(f"Exploring tags: {', '.join(pick_tags)}\n") - for i, track in enumerate(tracks[:params.count], 1): - artists = ", ".join(a["name"] for a in track["artists"]) - album = track["album"]["name"] - pop = track.get("popularity", 0) - tid = track["id"] - lines.append(f"{i}. **{track['name']}** β€” {artists}") - lines.append(f" Album: {album} | Popularity: {pop} | ID: `{tid}`") + # Find on Spotify + found = 0 + for track_name, artist_name in discovered: + if found >= params.count: + break + search = sp.search(q=f"track:{track_name} artist:{artist_name}", type="track", limit=1) + items = search["tracks"]["items"] + if items: + t = items[0] + artists = ", ".join(a["name"] for a in t["artists"]) + lines.append(f"{found+1}. **{t['name']}** β€” {artists}") + lines.append(f" Album: {t['album']['name']} | ID: `{t['id']}`") + found += 1 + + if found == 0: + lines.append("No matching tracks found on Spotify for these tags.") return "\n".join(lines) @@ -362,36 +317,32 @@ async def fresh_finds(params: FreshFindsInput) -> str: # ========== Entry Point ========== if __name__ == "__main__": - # When run directly, do initial OAuth dance import sys if "--auth" in sys.argv: - print("🎡 MusicTail β€” First-time authorization") + print("🎡 MusicTail β€” First-time Spotify authorization") print() cache_path = os.path.expanduser("~/.musictail_cache") auth_manager = SpotifyOAuth( client_id=os.environ.get("SPOTIFY_CLIENT_ID"), client_secret=os.environ.get("SPOTIFY_CLIENT_SECRET"), redirect_uri="http://127.0.0.1:8888/callback", - scope=SCOPES, + scope=SPOTIFY_SCOPES, cache_path=cache_path, - open_browser=False + open_browser=False, ) - # Get the auth URL auth_url = auth_manager.get_authorize_url() print(f"Open this URL in your browser:\n{auth_url}\n") - print("After authorizing, you'll be redirected to a URL.") - print("Paste the FULL redirect URL here (it will start with http://127.0.0.1:8888/callback?code=...):") + print("Paste the FULL redirect URL here:") response_url = input("> ").strip() code = auth_manager.parse_response_code(response_url) token_info = auth_manager.get_access_token(code) - if token_info: sp = spotipy.Spotify(auth_manager=auth_manager) user = sp.current_user() - print(f"\nβœ… Authorized as: {user['display_name']} ({user['id']})") + print(f"\nβœ… Authorized as: {user['display_name']}") print(f"Token cached at: {cache_path}") print("MusicTail is ready! 🦊") else: - print("❌ Authorization failed. Check your credentials.") + print("❌ Authorization failed.") else: - mcp.run() \ No newline at end of file + mcp.run()