diff --git a/src/spotify_mcp/fastmcp_server.py b/src/spotify_mcp/fastmcp_server.py index 3803ea4..9c38d1d 100644 --- a/src/spotify_mcp/fastmcp_server.py +++ b/src/spotify_mcp/fastmcp_server.py @@ -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( @@ -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"), ) @@ -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: diff --git a/src/spotify_mcp/spotify_api.py b/src/spotify_mcp/spotify_api.py index 6bb5f41..847ce3c 100644 --- a/src/spotify_mcp/spotify_api.py +++ b/src/spotify_mcp/spotify_api.py @@ -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: diff --git a/tests/test_fastmcp_tools.py b/tests/test_fastmcp_tools.py index c1c24ff..cc73397 100644 --- a/tests/test_fastmcp_tools.py +++ b/tests/test_fastmcp_tools.py @@ -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 @@ -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