Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/spotify_mcp/fastmcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,13 @@ def get_artist_info(artist_id: str) -> ArtistInfo:
raise convert_spotify_error(e) from e


def _total_tracks(playlist_id: str, reported: object) -> int | None:
"""Prefer the count Spotify reported, else read it off the items endpoint."""
if isinstance(reported, int):
return reported
return spotify_api.playlist_total(spotify_client, playlist_id)


@mcp.tool(
title="Get Playlist Info",
annotations=ToolAnnotations(
Expand Down Expand Up @@ -864,7 +871,7 @@ def get_playlist_info(playlist_id: str) -> Playlist:
owner=owner.get("display_name"),
description=result.get("description"),
tracks=None, # No tracks - use get_playlist_tracks
total_tracks=tracks.get("total"),
total_tracks=_total_tracks(playlist_id, tracks.get("total")),
public=result.get("public"),
)

Expand Down Expand Up @@ -1607,7 +1614,7 @@ def playlist_resource(playlist_id: str) -> str:
id=result["id"],
owner=owner.get("display_name"),
description=result.get("description"),
total_tracks=tracks.get("total"),
total_tracks=_total_tracks(playlist_id, tracks.get("total")),
public=result.get("public"),
).model_dump_json()
except Exception as e:
Expand Down
13 changes: 13 additions & 0 deletions src/spotify_mcp/spotify_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,19 @@ def playlist_items(
)


def playlist_total(sp: spotipy.Spotify, playlist_id: str) -> int | None:
"""How many entries a playlist holds, or None if Spotify won't say.

`playlist(fields="tracks.total")` is the obvious source and does not work:
restricted apps get the field stripped, so it comes back absent (asking for
it alone yields a bare `{}`). A one-item page of the items endpoint still
reports the real `total`, so read it from there.
"""
page = playlist_items(sp, playlist_id, limit=1, offset=0)
total = page.get("total")
return total if isinstance(total, int) else None


def create_playlist(
sp: spotipy.Spotify, name: str, description: str, public: bool
) -> dict:
Expand Down
23 changes: 23 additions & 0 deletions tests/test_fastmcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,19 @@ def test_success(self, mock_spotify_api, sample_playlist_data):
"37i9dQZF1DX0XUsuxWHRQd",
fields="id,name,description,owner,public,tracks.total",
)
# a reported count is trusted, so no extra lookup is made
mock_spotify_api._get.assert_not_called()

def test_falls_back_to_the_items_endpoint_for_a_stripped_count(
self, mock_spotify_api, sample_playlist_data
):
"""Restricted apps get `tracks.total` stripped; total_tracks must still fill."""
mock_spotify_api.playlist.return_value = {**sample_playlist_data, "tracks": {}}
mock_spotify_api._get.return_value = {"items": [], "total": 65}

result = get_playlist_info("37i9dQZF1DX0XUsuxWHRQd")

assert result.total_tracks == 65

def test_spotify_error(self, mock_spotify_api):
mock_spotify_api.playlist.side_effect = SPOTIFY_ERROR
Expand Down Expand Up @@ -856,6 +869,16 @@ def test_playlist_resource(self, mock_spotify_api, sample_playlist_data):

assert result["name"] == "RapCaviar"

def test_playlist_resource_fills_a_stripped_count(
self, mock_spotify_api, sample_playlist_data
):
mock_spotify_api.playlist.return_value = {**sample_playlist_data, "tracks": {}}
mock_spotify_api._get.return_value = {"items": [], "total": 65}

result = json.loads(playlist_resource("pl1"))

assert result["total_tracks"] == 65

def test_artist_resource(self, mock_spotify_api, sample_artist_data):
mock_spotify_api.artist.return_value = sample_artist_data

Expand Down