feat: Last.fm integration for music discovery 🎵

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)
This commit is contained in:
Alex Kazaiev
2026-04-06 20:33:51 -05:00
parent 065dd35ceb
commit 1a405b5334
3 changed files with 204 additions and 252 deletions

View File

@@ -2,42 +2,43 @@
Part of the Tail Family 🦊 Part of the Tail Family 🦊
MusicTail provides intelligent music discovery and taste analysis using MusicTail uses **Last.fm** for music intelligence (similar artists, tags,
Spotify's recommendation engine, automatically seeded from your actual discovery) and **Spotify** for taste analysis and playback integration.
listening history. No manual input needed — she already knows what you like.
## Tools ## Tools
| Tool | Purpose | | Tool | Engine | Purpose |
|------|---------| |------|--------|---------|
| `musictail_suggest` | Mood-filtered recommendations from your top tracks | | `musictail_suggest` | Last.fm + Spotify | Mood-filtered recs from similar artists |
| `musictail_discover_artist` | Find similar artists with their top tracks | | `musictail_discover_artist` | Last.fm + Spotify | Find similar artists with top tracks |
| `musictail_taste_profile` | Analyze your listening patterns over time | | `musictail_taste_profile` | Spotify | Analyze listening patterns over time |
| `musictail_fresh_finds` | New discoveries with an adventure dial | | `musictail_fresh_finds` | Last.fm + Spotify | Tag-based discovery with adventure dial |
## Setup ## Setup
### 1. First-time auth ### 1. Get API keys
```bash - **Spotify:** Already configured from spotify-mcp
SPOTIFY_CLIENT_ID=xxx SPOTIFY_CLIENT_SECRET=xxx python3.11 musictail_mcp.py --auth - **Last.fm:** Free key from https://www.last.fm/api/account/create
```
This opens a browser for Spotify login. Token is cached at `~/.musictail_cache`.
### 2. Claude Desktop config ### 2. First-time Spotify auth (if not done)
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: ```bash
SPOTIFY_CLIENT_ID=xxx SPOTIFY_CLIENT_SECRET=xxx \
LASTFM_API_KEY=xxx \
python3.11 musictail_mcp.py --auth
```
### 3. Claude Desktop config
```json ```json
"musictail": { "musictail": {
"command": "python3.11", "command": "python3.11",
"args": ["/Users/alex/mcps/vixy/musictail/musictail_mcp.py"], "args": ["/Users/alex/mcps/vixy/musictail/musictail_mcp.py"],
"env": { "env": {
"SPOTIFY_CLIENT_ID": "your_client_id", "SPOTIFY_CLIENT_ID": "your_spotify_id",
"SPOTIFY_CLIENT_SECRET": "your_client_secret" "SPOTIFY_CLIENT_SECRET": "your_spotify_secret",
"LASTFM_API_KEY": "your_lastfm_key"
} }
} }
``` ```
## Mood Options
chill, energetic, melancholy, focused, dreamy, dark, uplifting, intense
## Author ## Author
Vivienne Rousseau — Day 156 (April 6, 2026) 🦊💕 Vivienne Rousseau — Day 156 (April 6, 2026) 🦊💕

View File

@@ -3,11 +3,12 @@
MusicTail — Vixy's Music Discovery MCP 🎵🦊 MusicTail — Vixy's Music Discovery MCP 🎵🦊
Part of the Tail Family. Part of the Tail Family.
Provides intelligent music discovery and taste analysis Uses Last.fm for music intelligence (similar artists, tracks, tags)
using Spotify's recommendation engine seeded with actual listening history. and Spotify for taste analysis and playback integration.
Author: Vivienne Rousseau Author: Vivienne Rousseau
Created: Day 156 (April 6, 2026) Created: Day 156 (April 6, 2026)
Updated: Day 156 — Last.fm integration (goodbye deprecated Spotify endpoints!)
""" """
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
@@ -16,6 +17,7 @@ from typing import Optional, List
from enum import Enum from enum import Enum
import os import os
import json import json
import httpx
import spotipy import spotipy
from spotipy.oauth2 import SpotifyOAuth from spotipy.oauth2 import SpotifyOAuth
@@ -23,8 +25,27 @@ from spotipy.oauth2 import SpotifyOAuth
# Initialize MCP server # Initialize MCP server
mcp = FastMCP("musictail") mcp = FastMCP("musictail")
# Spotify scopes needed # ========== API Clients ==========
SCOPES = "user-top-read user-read-recently-played user-library-read"
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: def get_spotify_client() -> spotipy.Spotify:
"""Create authenticated Spotify client.""" """Create authenticated Spotify client."""
@@ -33,50 +54,24 @@ def get_spotify_client() -> spotipy.Spotify:
client_id=os.environ.get("SPOTIFY_CLIENT_ID"), client_id=os.environ.get("SPOTIFY_CLIENT_ID"),
client_secret=os.environ.get("SPOTIFY_CLIENT_SECRET"), client_secret=os.environ.get("SPOTIFY_CLIENT_SECRET"),
redirect_uri="http://127.0.0.1:8888/callback", redirect_uri="http://127.0.0.1:8888/callback",
scope=SCOPES, scope=SPOTIFY_SCOPES,
cache_path=cache_path, cache_path=cache_path,
open_browser=False open_browser=False,
) )
return spotipy.Spotify(auth_manager=auth_manager) return spotipy.Spotify(auth_manager=auth_manager)
# ========== Models ========== # ========== 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): class SuggestInput(BaseModel):
"""Input for music suggestions.""" """Input for music suggestions."""
model_config = ConfigDict(extra='forbid') model_config = ConfigDict(extra='forbid')
mood: Optional[MoodEnum] = Field( mood: Optional[str] = Field(
default=None, default=None,
description="Mood filter: chill, energetic, melancholy, focused, dreamy, dark, uplifting, intense" description="Mood/tag to filter by (e.g. 'chill', 'dark', 'ambient', 'synthwave', 'energetic')"
)
count: int = Field(
default=10,
description="Number of tracks to suggest (1-50)",
ge=1, le=50
) )
count: int = Field(default=10, description="Number of tracks (1-30)", ge=1, le=30)
class DiscoverInput(BaseModel): class DiscoverInput(BaseModel):
"""Input for artist discovery.""" """Input for artist discovery."""
model_config = ConfigDict(extra='forbid') model_config = ConfigDict(extra='forbid')
@@ -92,169 +87,132 @@ class TasteInput(BaseModel):
) )
class FreshFindsInput(BaseModel): class FreshFindsInput(BaseModel):
"""Input for fresh discoveries based on recent listening.""" """Input for fresh discoveries."""
model_config = ConfigDict(extra='forbid') model_config = ConfigDict(extra='forbid')
count: int = Field(default=10, description="Number of tracks (1-50)", ge=1, le=50) count: int = Field(default=10, description="Number of tracks (1-30)", ge=1, le=30)
adventurous: bool = Field( adventurous: bool = Field(default=False, description="If true, push further from comfort zone")
default=False,
description="If true, push further from comfort zone"
)
# ========== Tools ========== # ========== Tools ==========
@mcp.tool( @mcp.tool(name="musictail_suggest")
name="musictail_suggest",
annotations={
"title": "Suggest Music By Mood",
"readOnlyHint": True,
"destructiveHint": False,
}
)
async def suggest_music(params: SuggestInput) -> str: async def suggest_music(params: SuggestInput) -> str:
""" """Suggest music using Last.fm similar artists seeded from your Spotify top artists."""
Suggest music based on your recent listening history and optional mood filter.
Uses genre-based search seeded from your top artists' genres.
"""
sp = get_spotify_client() sp = get_spotify_client()
# Get top artists to extract genres # Get top artists from Spotify
top_artists = sp.current_user_top_artists(limit=10, time_range="short_term") 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 # For each top artist, get Last.fm similar artists
genres = [] discovered = []
for a in top_artists["items"]: seen_names = {a["name"].lower() for a in top["items"]}
genres.extend(a.get("genres", []))
# Deduplicate and pick top genres for artist in top["items"][:3]:
genre_count = {} try:
for g in genres: data = await lastfm_call("artist.getSimilar",
genre_count[g] = genre_count.get(g, 0) + 1 artist=artist["name"], limit=10)
sorted_genres = sorted(genre_count.items(), key=lambda x: -x[1]) similar = data.get("similarartists", {}).get("artist", [])
top_genres = [g for g, _ in sorted_genres[:3]] 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: if not discovered:
return "Could not determine your genres. Listen to more music first!" return "Last.fm couldn't find similar artists. Try again later."
# Add mood keywords to search # Search Spotify for tracks by these artists
mood_keywords = { lines = [f"🎵 **MusicTail Suggestions** (via Last.fm)"]
"chill": "chill ambient", if params.mood:
"energetic": "energetic upbeat", lines[0] += f" — mood: {params.mood}"
"melancholy": "melancholy sad", lines.append(f"Based on artists similar to: {', '.join(a['name'] for a in top['items'][:3])}\n")
"focused": "instrumental focus",
"dreamy": "dreamy ethereal",
"dark": "dark atmospheric",
"uplifting": "uplifting bright",
"intense": "intense powerful",
}
# Build search query from genres + mood found = 0
query_parts = top_genres[:2] for artist_name in discovered:
if params.mood and params.mood.value in mood_keywords: if found >= params.count:
query_parts.append(mood_keywords[params.mood.value]) break
query = " ".join(query_parts) query = f"artist:{artist_name}"
if params.mood:
# Search for tracks query += f" {params.mood}"
results = sp.search(q=query, type="track", limit=params.count) search = sp.search(q=query, type="track", limit=2)
for track in search["tracks"]["items"]:
# Filter out tracks by artists already in top list if found >= params.count:
top_artist_ids = {a["id"] for a in top_artists["items"]} break
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"]) artists = ", ".join(a["name"] for a in track["artists"])
album = track["album"]["name"] lines.append(f"{found+1}. **{track['name']}** — {artists}")
tid = track["id"] lines.append(f" Album: {track['album']['name']} | ID: `{track['id']}`")
lines.append(f"{i}. **{track['name']}** — {artists}") found += 1
lines.append(f" Album: {album} | ID: `{tid}`")
if found == 0:
lines.append("No matching tracks found on Spotify.")
return "\n".join(lines) return "\n".join(lines)
@mcp.tool( @mcp.tool(name="musictail_discover_artist")
name="musictail_discover_artist",
annotations={
"title": "Discover Similar Artists",
"readOnlyHint": True,
"destructiveHint": False,
}
)
async def discover_artist(params: DiscoverInput) -> str: async def discover_artist(params: DiscoverInput) -> str:
""" """Find similar artists via Last.fm, with Spotify playback links."""
Find artists similar to one you like, with their top tracks.
Uses genre-based search to find artists in the same musical space. # 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() sp = get_spotify_client()
# Search for the artist for i, sim in enumerate(similar[:params.count], 1):
search = sp.search(q=params.artist_name, type="artist", limit=1) name = sim.get("name", "Unknown")
if not search["artists"]["items"]: match_score = sim.get("match", "?")
return f"Could not find artist: {params.artist_name}"
artist = search["artists"]["items"][0] # Search Spotify for this artist
artist_genres = artist.get("genres", []) search = sp.search(q=f"artist:{name}", type="artist", limit=1)
genres_str = ", ".join(artist_genres[:5]) or "no genres listed" spotify_artists = search["artists"]["items"]
if not artist_genres: if spotify_artists:
return f"**{artist['name']}** has no genres listed on Spotify — can't find similar artists without genre data." sa = spotify_artists[0]
genres = ", ".join(sa.get("genres", [])[:3]) or ""
# Search for artists in the same genres # Get top tracks
seen_ids = {artist["id"]} top = sp.artist_top_tracks(sa["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)
# Get top tracks for this artist
top = sp.artist_top_tracks(sim["id"])
top_tracks = top["tracks"][:3] top_tracks = top["tracks"][:3]
track_list = "; ".join(t["name"] for t in top_tracks) 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}. **{sim['name']}** (popularity: {popularity})") lines.append(f"{i}. **{name}** (match: {float(match_score):.0%})")
lines.append(f" Genres: {sim_genres}") lines.append(f" Genres: {genres}")
lines.append(f" Top tracks: {track_list}") lines.append(f" Top tracks: {track_list}")
if top_tracks: if play_id:
lines.append(f" Play ID: `{top_tracks[0]['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("") lines.append("")
if not similar:
lines.append("No similar artists found in these genres.")
return "\n".join(lines) return "\n".join(lines)
@mcp.tool(name="musictail_taste_profile")
@mcp.tool(
name="musictail_taste_profile",
annotations={
"title": "Analyze Taste Profile",
"readOnlyHint": True,
"destructiveHint": False,
}
)
async def taste_profile(params: TasteInput) -> str: async def taste_profile(params: TasteInput) -> str:
""" """Analyze your Spotify listening patterns — top artists, genres, tracks."""
Analyze your listening patterns across time. Shows top artists, top genres, and musical characteristics for the selected time range.
"""
sp = get_spotify_client() sp = get_spotify_client()
range_label = { range_label = {
@@ -263,12 +221,9 @@ async def taste_profile(params: TasteInput) -> str:
"long_term": "All time" "long_term": "All time"
}.get(params.time_range, params.time_range) }.get(params.time_range, params.time_range)
# Top artists
top_artists = sp.current_user_top_artists(limit=15, time_range=params.time_range) 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) top_tracks = sp.current_user_top_tracks(limit=20, time_range=params.time_range)
# Collect genres
genre_count = {} genre_count = {}
for a in top_artists["items"]: for a in top_artists["items"]:
for g in a.get("genres", []): 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] top_genres = sorted(genre_count.items(), key=lambda x: -x[1])[:10]
lines = [f"📊 **MusicTail Taste Profile** — {range_label}\n"] lines = [f"📊 **MusicTail Taste Profile** — {range_label}\n"]
lines.append("**Top Artists:**") lines.append("**Top Artists:**")
for i, a in enumerate(top_artists["items"][:10], 1): for i, a in enumerate(top_artists["items"][:10], 1):
lines.append(f" {i}. {a['name']}") lines.append(f" {i}. {a['name']}")
@@ -292,69 +246,70 @@ async def taste_profile(params: TasteInput) -> str:
return "\n".join(lines) return "\n".join(lines)
@mcp.tool( @mcp.tool(name="musictail_fresh_finds")
name="musictail_fresh_finds",
annotations={
"title": "Fresh Finds",
"readOnlyHint": True,
"destructiveHint": False,
}
)
async def fresh_finds(params: FreshFindsInput) -> str: async def fresh_finds(params: FreshFindsInput) -> str:
""" """Discover new music via Last.fm tag exploration, playable on Spotify."""
Discover new tracks based on your listening, with an adventure dial.
Normal mode stays close to your taste. Adventurous mode pushes boundaries.
"""
sp = get_spotify_client() 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" time_range = "long_term" if params.adventurous else "short_term"
top_artists = sp.current_user_top_artists(limit=15, time_range=time_range) top_artists = sp.current_user_top_artists(limit=15, time_range=time_range)
# Collect and rank genres
genre_count = {} genre_count = {}
for a in top_artists["items"]: for a in top_artists["items"]:
for g in a.get("genres", []): for g in a.get("genres", []):
genre_count[g] = genre_count.get(g, 0) + 1 genre_count[g] = genre_count.get(g, 0) + 1
sorted_genres = sorted(genre_count.items(), key=lambda x: -x[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: if params.adventurous and len(sorted_genres) > 3:
pick_genres = [g for g, _ in sorted_genres[3:6]] # Less dominant genres pick_tags = [g for g, _ in sorted_genres[3:7]]
query_extra = "new"
else: else:
pick_genres = [g for g, _ in sorted_genres[:3]] pick_tags = [g for g, _ in sorted_genres[:3]]
query_extra = ""
if not pick_genres: if not pick_tags:
return "Could not determine your genres. Listen to more music first!" return "Could not determine your genres."
# Search for tracks in those genres # Use Last.fm tag.getTopTracks for each genre
query = " ".join(pick_genres[:2]) known_artists = {a["name"].lower() for a in top_artists["items"]}
if query_extra: discovered = []
query += f" {query_extra}"
results = sp.search(q=query, type="track", limit=min(params.count * 2, 50)) for tag in pick_tags:
if len(discovered) >= params.count:
# Filter out tracks by known top artists for freshness break
top_artist_ids = {a["id"] for a in top_artists["items"]} try:
fresh = [t for t in results["tracks"]["items"] data = await lastfm_call("tag.getTopTracks", tag=tag, limit=20)
if not any(a["id"] in top_artist_ids for a in t["artists"])] tracks = data.get("tracks", {}).get("track", [])
for t in tracks:
# Fall back to all results if too few fresh ones artist_name = t.get("artist", {}).get("name", "")
tracks = fresh[:params.count] if len(fresh) >= params.count // 2 else results["tracks"]["items"][:params.count] 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" mode = "🗺️ Adventurous" if params.adventurous else "🏠 Comfort Zone"
lines = [f"🎵 **MusicTail Fresh Finds** — {mode}"] lines = [f"🎵 **MusicTail Fresh Finds** — {mode} (via Last.fm)"]
lines.append(f"Searching: {', '.join(pick_genres)}\n") lines.append(f"Exploring tags: {', '.join(pick_tags)}\n")
for i, track in enumerate(tracks[:params.count], 1): # Find on Spotify
artists = ", ".join(a["name"] for a in track["artists"]) found = 0
album = track["album"]["name"] for track_name, artist_name in discovered:
pop = track.get("popularity", 0) if found >= params.count:
tid = track["id"] break
lines.append(f"{i}. **{track['name']}** — {artists}") search = sp.search(q=f"track:{track_name} artist:{artist_name}", type="track", limit=1)
lines.append(f" Album: {album} | Popularity: {pop} | ID: `{tid}`") 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) return "\n".join(lines)
@@ -362,36 +317,32 @@ async def fresh_finds(params: FreshFindsInput) -> str:
# ========== Entry Point ========== # ========== Entry Point ==========
if __name__ == "__main__": if __name__ == "__main__":
# When run directly, do initial OAuth dance
import sys import sys
if "--auth" in sys.argv: if "--auth" in sys.argv:
print("🎵 MusicTail — First-time authorization") print("🎵 MusicTail — First-time Spotify authorization")
print() print()
cache_path = os.path.expanduser("~/.musictail_cache") cache_path = os.path.expanduser("~/.musictail_cache")
auth_manager = SpotifyOAuth( auth_manager = SpotifyOAuth(
client_id=os.environ.get("SPOTIFY_CLIENT_ID"), client_id=os.environ.get("SPOTIFY_CLIENT_ID"),
client_secret=os.environ.get("SPOTIFY_CLIENT_SECRET"), client_secret=os.environ.get("SPOTIFY_CLIENT_SECRET"),
redirect_uri="http://127.0.0.1:8888/callback", redirect_uri="http://127.0.0.1:8888/callback",
scope=SCOPES, scope=SPOTIFY_SCOPES,
cache_path=cache_path, cache_path=cache_path,
open_browser=False open_browser=False,
) )
# Get the auth URL
auth_url = auth_manager.get_authorize_url() auth_url = auth_manager.get_authorize_url()
print(f"Open this URL in your browser:\n{auth_url}\n") 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:")
print("Paste the FULL redirect URL here (it will start with http://127.0.0.1:8888/callback?code=...):")
response_url = input("> ").strip() response_url = input("> ").strip()
code = auth_manager.parse_response_code(response_url) code = auth_manager.parse_response_code(response_url)
token_info = auth_manager.get_access_token(code) token_info = auth_manager.get_access_token(code)
if token_info: if token_info:
sp = spotipy.Spotify(auth_manager=auth_manager) sp = spotipy.Spotify(auth_manager=auth_manager)
user = sp.current_user() 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(f"Token cached at: {cache_path}")
print("MusicTail is ready! 🦊") print("MusicTail is ready! 🦊")
else: else:
print("❌ Authorization failed. Check your credentials.") print("❌ Authorization failed.")
else: else:
mcp.run() mcp.run()