Skip to content

fix: read playlist entries from the "item" key, honor limit by rows - #18

Open
tedeuxx wants to merge 2 commits into
jamiew:mainfrom
tedeuxx:fix/playlist-tracks-robustness
Open

fix: read playlist entries from the "item" key, honor limit by rows#18
tedeuxx wants to merge 2 commits into
jamiew:mainfrom
tedeuxx:fix/playlist-tracks-robustness

Conversation

@tedeuxx

@tedeuxx tedeuxx commented Jul 21, 2026

Copy link
Copy Markdown

get_playlist_tracks returns zero tracks on every playlist, and times out on large ones even with a small limit.

Root cause

The Web API returns playlist-items rows with the entry under item; the track key is gone from those rows entirely. Confirmed live — row keys are ['added_at', 'added_by', 'is_local', 'item', 'primary_color', 'video_thumbnail'].

item.get("track") therefore resolved to None for every row, and the skip-on-missing-id guard dropped 100% of tracks. Two symptoms, one cause:

  • a 100-track playlist returned total=100, returned=0
  • a 1263-track playlist with limit=5 timed out at 60s: remaining was decremented by parsed tracks (always 0) rather than rows fetched, so a small limit paged through the entire playlist (~253 requests)

Changes

  • extract_playlist_entry() accepts item or the legacy track key, with an isinstance(dict) check — the inner entry object has its own boolean track field
  • limit/offset count playlist positions: remaining -= len(rows), not parsed tracks. An unparseable page can no longer hold remaining at its starting value and page forever.
  • local/unavailable entries are returned marked (id=None, is_local=True) instead of silently disappearing; a null entry keeps its slot as an "Unavailable" placeholder so positions stay aligned for reorder/remove-by-index
  • Track.id is now optional and Track gains is_local
  • the pagination total comes from playlist_items(limit=1, fields="total") instead of playlist(fields="tracks.total") — lighter request, and the authoritative total for the cursor being paginated (Use playlist_items(limit=1, fields="total") instead of playlist(fields="tracks.total") for the total count #17)

current_user_saved_tracks still uses the track key and is unaffected; no other call site reads playlist-items rows.

Verification

Live, against two real playlists:

case before after
1263-track playlist, limit=5 60s timeout 0.56s, 5 tracks
1263-track playlist, limit=5, offset=10 0.54s, 5 tracks
100-track playlist, limit=None returned=0 0.73s, 100/100
1263-track playlist, limit=250 (3 pages) 1.52s, 250 tracks

124 tests pass, mypy clean. New coverage: entries under the item key, null entries returned as placeholders, local tracks marked rather than dropped, and a regression test that a fully unparseable page issues exactly one request instead of scanning the playlist.

Closes #16
Closes #17

tedeuxx and others added 2 commits July 21, 2026 20:47
Skip playlist entries with no resolvable track ID (local files and
unavailable/removed tracks come back with "id": None), which previously
raised an unhandled pydantic ValidationError and failed the whole tool call.

Fetch the pagination total via playlist_items(limit=1, fields="total")
instead of playlist(fields="tracks.total") - lighter request and the
authoritative total for the cursor being paginated.

Closes jamiew#16
Closes jamiew#17

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
The Web API returns playlist-items rows with the entry under "item"; the
parser only looked at "track", which no longer exists on those rows. Every
entry resolved to None, so the previous skip-on-missing-id guard dropped
100% of tracks.

Two symptoms, one cause:
- get_playlist_tracks(<100-track playlist>) returned total=100, returned=0
- get_playlist_tracks(<1263-track playlist>, limit=5) timed out at 60s,
  because `remaining` was decremented by *parsed* tracks (always 0) instead
  of rows fetched, so a small limit paged through the entire playlist

Fixes:
- extract_playlist_entry() accepts "item" or the legacy "track" key
- limit/offset now count playlist positions: consume len(rows) per batch
- local/unavailable entries are returned marked (id=None, is_local=True)
  instead of silently disappearing; null entries keep their slot as an
  "Unavailable" placeholder so positions stay aligned
- Track.id is now optional and Track gains is_local

Verified live: 1263-track playlist with limit=5 returns in 0.56s (was a
60s timeout); 100-track playlist returns 100/100 (was 0).

Refs jamiew#16
Refs jamiew#17

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
@tedeuxx

tedeuxx commented Jul 23, 2026

Copy link
Copy Markdown
Author

Pushed a follow-up commit that fixes the actual root cause — the original patch traded a crash for a silent drop, and the drop turned into unbounded pagination.

The Web API returns playlist-items rows with the entry under item; track no longer exists on those rows, so item.get("track") was None for every row. Changes:

  • extract_playlist_entry() accepts item or the legacy track key (with an isinstance(dict) check — the inner object has a boolean track field)
  • limit/offset count playlist positions: remaining -= len(rows), not parsed tracks. An unparseable page can no longer hold remaining at its starting value.
  • local/unavailable entries come back marked (id=None, is_local=True); null entries keep their slot as an "Unavailable" placeholder so positions stay aligned for reorder/remove-by-index
  • Track.id is now optional, Track gains is_local

Verified live: 1263-track playlist with limit=5 → 0.56s / 5 tracks (was a 60s timeout); 100-track playlist → 100/100 (was 0). 124 tests pass, mypy clean; added coverage for the item key, null entries, marked local tracks, and the limit regression.

@tedeuxx tedeuxx changed the title fix: robust playlist track parsing + lighter total lookup fix: read playlist entries from the "item" key, honor limit by rows Jul 23, 2026
jonico added a commit to jonico/spotify-mcp that referenced this pull request Aug 25, 2026
get_playlist_info and playlist_resource both report total_tracks: null, even
for a 65-track playlist. They ask for it via
playlist(fields="...,tracks.total"), and a restricted app does not get that
field back -- requesting it alone yields a bare `{}`.

A one-item page of the items endpoint still reports the true total, so read it
from there when the playlist object withholds it. A reported count is trusted
as-is, so the extra request only happens when the field is actually missing,
and both call sites share one helper so they cannot drift.

Scoped deliberately to the two metadata paths. jamiew#18 already fixes the same
stripped field for get_playlist_tracks' pagination total (jamiew#17), so that call
site is left untouched here to avoid competing with it. get_user_playlists is
also left alone: filling counts there would mean one request per playlist on a
list endpoint.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant