-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
247 lines (207 loc) · 8.18 KB
/
server.py
File metadata and controls
247 lines (207 loc) · 8.18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
from __future__ import annotations
import os
from collections import Counter
from typing import Any
from dotenv import load_dotenv
load_dotenv()
from mcp.server.fastmcp import FastMCP
import setlistfm
import spotify
from matching import best_match
mcp = FastMCP("setlistify")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _resolve_artist(artist: str) -> dict:
result = setlistfm.search_artist(artist)
if result is None:
raise ValueError(
f"Artist '{artist}' not found on setlist.fm. "
"Check spelling or try the full official name."
)
return result
def _find_spotify_track(title: str, artist_name: str) -> dict | None:
track = spotify.search_track(title, artist_name)
if track:
return track
# fuzzy: search by title only, then pick best name match
import spotipy # noqa: F401 — ensure client initialised
sp = spotify._client()
result = sp.search(q=title, type="track", limit=10)
items = result.get("tracks", {}).get("items", [])
if not items:
return None
return best_match(title, items)
# ---------------------------------------------------------------------------
# Tool: get_setlists
# ---------------------------------------------------------------------------
@mcp.tool()
def get_setlists(
artist: str,
year: int | None = None,
city: str | None = None,
limit: int = 5,
) -> list[dict]:
"""
Return recent setlists for an artist as structured data.
Args:
artist: Artist name.
year: Filter by year (optional).
city: Filter by city name (optional).
limit: Max number of setlists to return (default 5).
"""
info = _resolve_artist(artist)
mbid = info["mbid"]
raw_setlists = setlistfm.search_artist_setlists(mbid, year=year, city=city, limit=limit)
if not raw_setlists:
return []
return [setlistfm.parse_setlist(s) for s in raw_setlists]
# ---------------------------------------------------------------------------
# Tool: create_playlist_from_setlist
# ---------------------------------------------------------------------------
@mcp.tool()
def create_playlist_from_setlist(
artist: str,
year: int | None = None,
venue: str | None = None,
city: str | None = None,
mode: str = "latest",
n_setlists: int = 10,
) -> dict[str, Any]:
"""
Create a Spotify playlist from a live setlist.
Args:
artist: Artist name.
year: Filter by year (optional).
venue: Filter by venue name (optional).
city: Filter by city name (optional).
mode: 'latest' uses the most recent setlist; 'best-of' aggregates
the last n_setlists and ranks by play frequency.
n_setlists: How many setlists to aggregate in best-of mode (default 10).
Returns:
Dict with 'playlist_url', 'matched', 'unmatched', 'description'.
"""
info = _resolve_artist(artist)
mbid = info["mbid"]
artist_name: str = info.get("name", artist)
fetch_limit = n_setlists if mode != "latest" else (20 if (city or venue) else 1)
raw_setlists = setlistfm.search_artist_setlists(
mbid, year=year, city=city, venue=venue, limit=fetch_limit
)
if not raw_setlists:
raise ValueError(f"No setlists found for '{artist_name}' with the given filters.")
if mode == "latest":
if city or venue:
city_lower = city.lower() if city else None
venue_lower = venue.lower() if venue else None
for raw in raw_setlists:
p = setlistfm.parse_setlist(raw)
city_match = not city_lower or city_lower in p["city"].lower()
venue_match = not venue_lower or venue_lower in p["venue"].lower()
if city_match and venue_match and p["tracks"]:
raw_setlists = [raw]
break
else:
raise ValueError(
f"No setlist with tracks found for '{artist_name}' matching city='{city}' venue='{venue}'."
)
parsed = setlistfm.parse_setlist(raw_setlists[0])
tracks_ordered = parsed["tracks"]
playlist_name = f"{artist_name} — Live Setlist ({parsed['date']})"
playlist_desc = (
f"Setlist from {parsed['venue']}, {parsed['city']} — {parsed['date']}."
)
source_label = f"{parsed['venue']}, {parsed['city']}, {parsed['date']}"
else:
counter: Counter[str] = Counter()
for raw in raw_setlists:
p = setlistfm.parse_setlist(raw)
for t in p["tracks"]:
counter[t] += 1
tracks_ordered = [t for t, _ in counter.most_common()]
playlist_name = f"{artist_name} — Best Of Live (last {len(raw_setlists)} shows)"
playlist_desc = (
f"Most-played songs across {len(raw_setlists)} recent {artist_name} setlists."
)
source_label = f"aggregated {len(raw_setlists)} shows"
# Match each track on Spotify
track_uris: list[str] = []
unmatched: list[str] = []
for title in tracks_ordered:
t = _find_spotify_track(title, artist_name)
if t:
track_uris.append(t["uri"])
else:
unmatched.append(title)
matched_count = len(track_uris)
total_count = len(tracks_ordered)
full_desc = (
f"{playlist_desc} {matched_count}/{total_count} tracks matched."
+ (f" Unmatched: {', '.join(unmatched[:5])}{'…' if len(unmatched) > 5 else ''}." if unmatched else "")
)
playlist = spotify.create_playlist(playlist_name, description=full_desc[:300])
if track_uris:
spotify.add_tracks_to_playlist(playlist["id"], track_uris)
return {
"playlist_url": playlist["external_urls"]["spotify"],
"playlist_name": playlist_name,
"matched": matched_count,
"unmatched": len(unmatched),
"unmatched_tracks": unmatched,
"total": total_count,
"source": source_label,
"description": full_desc,
}
# ---------------------------------------------------------------------------
# Tool: diff_setlist_vs_discography
# ---------------------------------------------------------------------------
@mcp.tool()
def diff_setlist_vs_discography(artist: str) -> dict[str, Any]:
"""
Compare an artist's live setlists against their full Spotify discography.
Returns songs they always play, songs they never play live, and rarities.
Args:
artist: Artist name.
"""
info = _resolve_artist(artist)
mbid = info["mbid"]
artist_name: str = info.get("name", artist)
# Collect live tracks from last 10 setlists
raw_setlists = setlistfm.search_artist_setlists(mbid, limit=10)
if not raw_setlists:
raise ValueError(f"No setlists found for '{artist_name}'.")
live_counter: Counter[str] = Counter()
n_shows = len(raw_setlists)
for raw in raw_setlists:
p = setlistfm.parse_setlist(raw)
for t in p["tracks"]:
live_counter[t.lower()] += 1
# Collect studio discography from Spotify
albums = spotify.get_artist_albums(artist_name)
seen_album_ids: set[str] = set()
studio_tracks: set[str] = set()
for album in albums:
aid = album["id"]
if aid in seen_album_ids:
continue
seen_album_ids.add(aid)
for t in spotify.get_album_tracks(aid):
studio_tracks.add(t["name"].lower())
live_set = set(live_counter.keys())
always_played = [t for t, count in live_counter.items() if count == n_shows]
never_played = sorted(studio_tracks - live_set)
rarities = [t for t, count in live_counter.items() if count == 1]
return {
"artist": artist_name,
"shows_analysed": n_shows,
"always_played": sorted(always_played),
"never_played_live": never_played,
"rarities": sorted(rarities),
"studio_track_count": len(studio_tracks),
"unique_live_songs": len(live_set),
}
# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------
if __name__ == "__main__":
mcp.run()