8ecff44811
Spotify's February 2026 changes did not just move /playlists/{id}/tracks to
/items: for apps on the new behavior the per-entry payload key was renamed
from "track" to "item" (tracks.tracks.track -> items.items.item). Extended
Quota Mode (grandfathered) apps keep the old key, which is why this never
reproduced locally. Every parser in alembic read only "track", so on a new
app each entry looked like a null/local track and was silently skipped: the
CSV came out header-only, sldl no-opped with exit 0, and the playlist page
showed an empty tracklist. Confirmed live on a new app against a playlist
the connected account owns.
All /items consumers (spotify_client.py, spotify-playlist-csv.py,
spotify-retag.py) now parse both key names, and the fields query filter is
gone: it selects by key name, so filtering on track(...) is itself what
returned empty pages on the renamed shape.
Also per the migration guide, new apps only receive playlist contents for
playlists the connected account owns or collaborates on; other playlists
return metadata with no items field at all (public is no longer enough).
That case now raises a pointed error (UI and CSV fetch) instead of reading
as an empty playlist, run-playlist.sh logs the fetched track count and warns
loudly when it is zero, and the README guidance is updated to match.
New tests pin get_playlist_tracks against both response shapes, the
metadata-only error, and pagination.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
72 lines
2.4 KiB
Python
72 lines
2.4 KiB
Python
"""get_playlist_tracks must parse BOTH /items response shapes: entries keyed
|
|
"track" (grandfathered / extended-quota apps) and "item" (apps on Spotify's
|
|
February 2026 renamed shape), and must fail loudly, not return [], when
|
|
Spotify withholds a playlist's contents (no items field at all)."""
|
|
import pytest
|
|
|
|
from app.services import spotify_client
|
|
|
|
URL = "https://open.spotify.com/playlist/XYZ123"
|
|
|
|
|
|
class _Resp:
|
|
def __init__(self, data):
|
|
self._data = data
|
|
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return self._data
|
|
|
|
|
|
def _fake_api(monkeypatch, pages):
|
|
it = iter(pages)
|
|
monkeypatch.setattr(spotify_client, "_get_token", lambda db: "tok")
|
|
monkeypatch.setattr(
|
|
spotify_client.httpx,
|
|
"get",
|
|
lambda url, params=None, headers=None, timeout=None: _Resp(next(it)),
|
|
)
|
|
|
|
|
|
def _track(name, artist, isrc=None):
|
|
return {
|
|
"name": name,
|
|
"artists": [{"name": artist}],
|
|
"external_ids": {"isrc": isrc} if isrc else {},
|
|
}
|
|
|
|
|
|
def test_parses_legacy_track_shape(monkeypatch):
|
|
_fake_api(monkeypatch, [{"items": [{"track": _track("Sandstorm", "Darude", "FIDAR9900011")}], "next": None}])
|
|
tracks = spotify_client.get_playlist_tracks(None, URL)
|
|
assert tracks == [{"artist": "Darude", "title": "Sandstorm", "isrc": "FIDAR9900011"}]
|
|
|
|
|
|
def test_parses_renamed_item_shape(monkeypatch):
|
|
# February 2026 shape: payload under "item", not "track"
|
|
_fake_api(monkeypatch, [{"items": [{"item": _track("Sandstorm", "Darude")}], "next": None}])
|
|
tracks = spotify_client.get_playlist_tracks(None, URL)
|
|
assert tracks == [{"artist": "Darude", "title": "Sandstorm", "isrc": None}]
|
|
|
|
|
|
def test_missing_items_field_raises_not_empty(monkeypatch):
|
|
# metadata-only response (playlist not owned by the connected account on a
|
|
# new app) must be an error the UI can show, never a silent empty list
|
|
_fake_api(monkeypatch, [{"name": "DJ-USB", "public": True}])
|
|
with pytest.raises(RuntimeError, match="without its contents"):
|
|
spotify_client.get_playlist_tracks(None, URL)
|
|
|
|
|
|
def test_skips_null_entries_and_paginates(monkeypatch):
|
|
_fake_api(
|
|
monkeypatch,
|
|
[
|
|
{"items": [{"track": None}, {"track": _track("A", "X")}], "next": "page2"},
|
|
{"items": [{"item": _track("B", "Y")}], "next": None},
|
|
],
|
|
)
|
|
tracks = spotify_client.get_playlist_tracks(None, URL)
|
|
assert [t["title"] for t in tracks] == ["A", "B"]
|