Skip to content

Commit d853497

Browse files
committed
Add new tools + batch support for tracks/audio features
New tools: - get_album_info - album metadata (release date, label, total tracks, etc) - get_saved_tracks - paginated access to Liked Songs library - get_recommendations - seed-based recommendations (artists/tracks/genres) - get_audio_features - tempo, key, energy, danceability, valence, etc Enhanced existing tools: - search_tracks - now supports year, year_range, genre, artist, album filters - get_track_info - accepts single ID or list (up to 50) for batch lookups - get_audio_features - accepts single ID or list (up to 100) Track responses now include release_date and album_id fields.
1 parent d4e36c0 commit d853497

File tree

2 files changed

+312
-17
lines changed

2 files changed

+312
-17
lines changed

src/spotify_mcp/fastmcp_server.py

Lines changed: 307 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ class Track(BaseModel):
4545
artist: str
4646
artists: list[str] | None = None
4747
album: str | None = None
48+
album_id: str | None = None
49+
release_date: str | None = None
4850
duration_ms: int | None = None
4951
popularity: int | None = None
5052
external_urls: dict[str, str] | None = None
@@ -84,14 +86,52 @@ class Artist(BaseModel):
8486
followers: int | None = None
8587

8688

89+
class Album(BaseModel):
90+
"""A Spotify album."""
91+
92+
name: str
93+
id: str
94+
artist: str
95+
artists: list[str] | None = None
96+
release_date: str | None = None
97+
release_date_precision: str | None = None
98+
total_tracks: int | None = None
99+
album_type: str | None = None
100+
label: str | None = None
101+
genres: list[str] | None = None
102+
popularity: int | None = None
103+
external_urls: dict[str, str] | None = None
104+
105+
106+
class AudioFeatures(BaseModel):
107+
"""Audio features for a track."""
108+
109+
id: str
110+
tempo: float | None = None
111+
key: int | None = None
112+
mode: int | None = None
113+
time_signature: int | None = None
114+
danceability: float | None = None
115+
energy: float | None = None
116+
valence: float | None = None
117+
loudness: float | None = None
118+
speechiness: float | None = None
119+
acousticness: float | None = None
120+
instrumentalness: float | None = None
121+
liveness: float | None = None
122+
123+
87124
def parse_track(item: dict[str, Any]) -> Track:
88125
"""Parse Spotify track data into Track model."""
126+
album_data = item.get("album", {})
89127
return Track(
90128
name=item["name"],
91129
id=item["id"],
92130
artist=item["artists"][0]["name"] if item.get("artists") else "Unknown",
93131
artists=[a["name"] for a in item.get("artists", [])],
94-
album=item.get("album", {}).get("name"),
132+
album=album_data.get("name"),
133+
album_id=album_data.get("id"),
134+
release_date=album_data.get("release_date"),
95135
duration_ms=item.get("duration_ms"),
96136
popularity=item.get("popularity"),
97137
external_urls=item.get("external_urls"),
@@ -231,7 +271,15 @@ def playback_control(
231271
@mcp.tool()
232272
@log_tool_execution
233273
def search_tracks(
234-
query: str, qtype: str = "track", limit: int = 10, offset: int = 0
274+
query: str,
275+
qtype: str = "track",
276+
limit: int = 10,
277+
offset: int = 0,
278+
year: str | None = None,
279+
year_range: str | None = None,
280+
genre: str | None = None,
281+
artist: str | None = None,
282+
album: str | None = None,
235283
) -> dict[str, Any]:
236284
"""Search Spotify for tracks, albums, artists, or playlists.
237285
@@ -240,20 +288,40 @@ def search_tracks(
240288
qtype: Type ('track', 'album', 'artist', 'playlist')
241289
limit: Max results per page (1-50, default 10)
242290
offset: Number of results to skip for pagination (default 0)
291+
year: Filter by year (e.g., '2024')
292+
year_range: Filter by year range (e.g., '2020-2024')
293+
genre: Filter by genre (e.g., 'electronic', 'hip-hop')
294+
artist: Filter by artist name
295+
album: Filter by album name
243296
244297
Returns:
245298
Dict with 'items' (list of tracks) and pagination info ('total', 'limit', 'offset')
246-
Note: For large result sets, use offset to paginate through results.
247-
Example: offset=0 gets results 1-10, offset=10 gets results 11-20, etc.
299+
300+
Note: Filters use Spotify's search syntax. For large result sets, use offset to paginate.
301+
Example: query='love', year='2024', genre='pop' searches for 'love year:2024 genre:pop'
248302
"""
249303
try:
250-
# Validate limit (Spotify API accepts 1-50)
251304
limit = max(1, min(50, limit))
252305

306+
# Build filtered query
307+
filters = []
308+
if artist:
309+
filters.append(f"artist:{artist}")
310+
if album:
311+
filters.append(f"album:{album}")
312+
if year:
313+
filters.append(f"year:{year}")
314+
if year_range:
315+
filters.append(f"year:{year_range}")
316+
if genre:
317+
filters.append(f"genre:{genre}")
318+
319+
full_query = " ".join([query] + filters) if filters else query
320+
253321
logger.info(
254-
f"🔍 Searching {qtype}s: '{query}' (limit={limit}, offset={offset})"
322+
f"🔍 Searching {qtype}s: '{full_query}' (limit={limit}, offset={offset})"
255323
)
256-
result = spotify_client.search(q=query, type=qtype, limit=limit, offset=offset)
324+
result = spotify_client.search(q=full_query, type=qtype, limit=limit, offset=offset)
257325

258326
tracks = []
259327
items_key = f"{qtype}s"
@@ -337,18 +405,39 @@ def get_queue() -> dict[str, Any]:
337405

338406
@mcp.tool()
339407
@log_tool_execution
340-
def get_track_info(track_id: str) -> dict[str, Any]:
341-
"""Get detailed information about a Spotify track.
408+
def get_track_info(track_ids: str | list[str]) -> dict[str, Any]:
409+
"""Get detailed information about one or more Spotify tracks.
342410
343411
Args:
344-
track_id: Spotify track ID
412+
track_ids: Single track ID or list of track IDs (up to 50)
413+
345414
Returns:
346-
Dict with complete track metadata
415+
Dict with 'tracks' list containing track metadata including release_date.
416+
For single ID, returns {'tracks': [track]}.
417+
418+
Note: Batch lookup is much more efficient - 50 tracks = 1 API call instead of 50.
347419
"""
348420
try:
349-
logger.info(f"🎵 Getting track info: {track_id}")
350-
result = spotify_client.track(track_id)
351-
return parse_track(result).model_dump()
421+
# Normalize to list
422+
ids = [track_ids] if isinstance(track_ids, str) else track_ids
423+
424+
if len(ids) > 50:
425+
raise ValueError("Maximum 50 track IDs per request (Spotify API limit)")
426+
427+
logger.info(f"🎵 Getting track info for {len(ids)} track(s)")
428+
429+
if len(ids) == 1:
430+
result = spotify_client.track(ids[0])
431+
tracks = [parse_track(result).model_dump()]
432+
else:
433+
result = spotify_client.tracks(ids)
434+
tracks = [
435+
parse_track(item).model_dump()
436+
for item in result.get("tracks", [])
437+
if item
438+
]
439+
440+
return {"tracks": tracks}
352441
except SpotifyException as e:
353442
raise convert_spotify_error(e) from e
354443

@@ -648,6 +737,210 @@ def modify_playlist_details(
648737
raise convert_spotify_error(e) from e
649738

650739

740+
@mcp.tool()
741+
@log_tool_execution
742+
def get_album_info(album_id: str) -> dict[str, Any]:
743+
"""Get detailed information about a Spotify album.
744+
745+
Args:
746+
album_id: Spotify album ID
747+
748+
Returns:
749+
Dict with album metadata including release_date, label, tracks
750+
"""
751+
try:
752+
logger.info(f"💿 Getting album info: {album_id}")
753+
result = spotify_client.album(album_id)
754+
755+
album = Album(
756+
name=result["name"],
757+
id=result["id"],
758+
artist=result["artists"][0]["name"] if result.get("artists") else "Unknown",
759+
artists=[a["name"] for a in result.get("artists", [])],
760+
release_date=result.get("release_date"),
761+
release_date_precision=result.get("release_date_precision"),
762+
total_tracks=result.get("total_tracks"),
763+
album_type=result.get("album_type"),
764+
label=result.get("label"),
765+
genres=result.get("genres", []),
766+
popularity=result.get("popularity"),
767+
external_urls=result.get("external_urls"),
768+
)
769+
770+
# Parse album tracks
771+
tracks = []
772+
for item in result.get("tracks", {}).get("items", []):
773+
if item:
774+
# Album track items don't have album info, add it
775+
item["album"] = {
776+
"name": result["name"],
777+
"id": result["id"],
778+
"release_date": result.get("release_date"),
779+
}
780+
tracks.append(parse_track(item).model_dump())
781+
782+
return {
783+
"album": album.model_dump(),
784+
"tracks": tracks,
785+
}
786+
except SpotifyException as e:
787+
raise convert_spotify_error(e) from e
788+
789+
790+
@mcp.tool()
791+
@log_tool_execution
792+
def get_audio_features(track_ids: str | list[str]) -> dict[str, Any]:
793+
"""Get audio features for one or more tracks (tempo, key, energy, danceability, etc).
794+
795+
Args:
796+
track_ids: Single track ID or list of track IDs (up to 100)
797+
798+
Returns:
799+
Dict with 'features' list containing audio features for each track.
800+
Features include: tempo, key, mode, time_signature, danceability, energy,
801+
valence, loudness, speechiness, acousticness, instrumentalness, liveness.
802+
803+
Note: Batch lookup is efficient - 100 tracks = 1 API call.
804+
"""
805+
try:
806+
# Normalize to list
807+
ids = [track_ids] if isinstance(track_ids, str) else track_ids
808+
809+
if len(ids) > 100:
810+
raise ValueError("Maximum 100 track IDs per request (Spotify API limit)")
811+
812+
logger.info(f"🎼 Getting audio features for {len(ids)} track(s)")
813+
result = spotify_client.audio_features(ids)
814+
815+
features_list = []
816+
for features in result:
817+
if features:
818+
audio = AudioFeatures(
819+
id=features["id"],
820+
tempo=features.get("tempo"),
821+
key=features.get("key"),
822+
mode=features.get("mode"),
823+
time_signature=features.get("time_signature"),
824+
danceability=features.get("danceability"),
825+
energy=features.get("energy"),
826+
valence=features.get("valence"),
827+
loudness=features.get("loudness"),
828+
speechiness=features.get("speechiness"),
829+
acousticness=features.get("acousticness"),
830+
instrumentalness=features.get("instrumentalness"),
831+
liveness=features.get("liveness"),
832+
)
833+
features_list.append(audio.model_dump())
834+
835+
return {"features": features_list}
836+
except SpotifyException as e:
837+
raise convert_spotify_error(e) from e
838+
839+
840+
@mcp.tool()
841+
@log_tool_execution
842+
def get_saved_tracks(limit: int = 20, offset: int = 0) -> dict[str, Any]:
843+
"""Get user's saved/liked tracks (Liked Songs library).
844+
845+
Args:
846+
limit: Max tracks to return per page (1-50, default 20)
847+
offset: Number of tracks to skip for pagination (default 0)
848+
849+
Returns:
850+
Dict with 'items' (list of tracks with added_at timestamp) and pagination info
851+
"""
852+
try:
853+
limit = max(1, min(50, limit))
854+
855+
logger.info(f"❤️ Getting saved tracks (limit={limit}, offset={offset})")
856+
result = spotify_client.current_user_saved_tracks(limit=limit, offset=offset)
857+
858+
tracks = []
859+
for item in result.get("items", []):
860+
if item and item.get("track"):
861+
track_data = parse_track(item["track"]).model_dump()
862+
track_data["added_at"] = item.get("added_at")
863+
tracks.append(track_data)
864+
865+
log_pagination_info("get_saved_tracks", result.get("total", 0), limit, offset)
866+
867+
return {
868+
"items": tracks,
869+
"total": result.get("total", 0),
870+
"limit": result.get("limit", limit),
871+
"offset": result.get("offset", offset),
872+
"next": result.get("next"),
873+
"previous": result.get("previous"),
874+
}
875+
except SpotifyException as e:
876+
raise convert_spotify_error(e) from e
877+
878+
879+
@mcp.tool()
880+
@log_tool_execution
881+
def get_recommendations(
882+
seed_artists: list[str] | None = None,
883+
seed_tracks: list[str] | None = None,
884+
seed_genres: list[str] | None = None,
885+
limit: int = 20,
886+
) -> dict[str, Any]:
887+
"""Get track recommendations based on seed artists, tracks, or genres.
888+
889+
Args:
890+
seed_artists: List of artist IDs (up to 5 total seeds combined)
891+
seed_tracks: List of track IDs (up to 5 total seeds combined)
892+
seed_genres: List of genres (up to 5 total seeds combined)
893+
limit: Number of recommendations to return (1-100, default 20)
894+
895+
Returns:
896+
Dict with 'tracks' list of recommended tracks
897+
898+
Note: Total seeds (artists + tracks + genres) must be between 1 and 5.
899+
Use search_tracks to find seed track/artist IDs, or common genres like:
900+
'pop', 'rock', 'hip-hop', 'electronic', 'jazz', 'classical', 'r-n-b', 'country'
901+
"""
902+
try:
903+
# Validate seeds
904+
total_seeds = (
905+
len(seed_artists or [])
906+
+ len(seed_tracks or [])
907+
+ len(seed_genres or [])
908+
)
909+
if total_seeds == 0:
910+
raise ValueError("At least one seed (artist, track, or genre) is required")
911+
if total_seeds > 5:
912+
raise ValueError("Maximum 5 total seeds allowed (artists + tracks + genres)")
913+
914+
limit = max(1, min(100, limit))
915+
916+
logger.info(
917+
f"🎲 Getting recommendations (artists={seed_artists}, "
918+
f"tracks={seed_tracks}, genres={seed_genres}, limit={limit})"
919+
)
920+
result = spotify_client.recommendations(
921+
seed_artists=seed_artists,
922+
seed_tracks=seed_tracks,
923+
seed_genres=seed_genres,
924+
limit=limit,
925+
)
926+
927+
tracks = []
928+
for item in result.get("tracks", []):
929+
if item:
930+
tracks.append(parse_track(item).model_dump())
931+
932+
return {
933+
"tracks": tracks,
934+
"seeds": {
935+
"artists": seed_artists or [],
936+
"tracks": seed_tracks or [],
937+
"genres": seed_genres or [],
938+
},
939+
}
940+
except SpotifyException as e:
941+
raise convert_spotify_error(e) from e
942+
943+
651944
# === RESOURCES ===
652945

653946

tests/test_fastmcp_tools.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,11 @@ def test_get_track_info(self, mock_spotify_api, sample_track_data):
209209

210210
result = get_track_info("4iV5W9uYEdYUVa79Axb7Rh")
211211

212-
assert isinstance(result, dict) # Returns dict, not Track object
213-
assert result["name"] == "Never Gonna Give You Up"
214-
assert result["artist"] == "Rick Astley"
212+
assert isinstance(result, dict)
213+
assert "tracks" in result
214+
assert len(result["tracks"]) == 1
215+
assert result["tracks"][0]["name"] == "Never Gonna Give You Up"
216+
assert result["tracks"][0]["artist"] == "Rick Astley"
215217
mock_spotify_api.track.assert_called_once_with("4iV5W9uYEdYUVa79Axb7Rh")
216218

217219
def test_get_playlist_info(self, mock_spotify_api, sample_playlist_data):

0 commit comments

Comments
 (0)