soulsync/tests/test_tidal_playlist_batch_chunking.py
BoulderBadgeDad 846a9c75a0 #867: Tidal playlist discovery shows all tracks (was capped to ~21)
Two issues in the same path:
1. The shared discovery modal pre-renders one row per track from a
   separately-fetched frontend track list, then the poll dropped any backend
   result without a pre-rendered row (if (!row) return). When the frontend's
   track fetch came back rate-limited/partial (~21) while discovery's own fetch
   got all 59, the surplus results vanished. Now the modal CREATES a row for any
   result lacking one, so authoritative backend results drive the list (fixes
   all sources sharing the modal).
2. get_playlist hydrated a whole relationships page in one _get_tracks_batch
   call, but Tidal caps filter[id] at 20/request, silently truncating larger
   pages. Chunk to the cap like get_album_tracks already does.

Seam + regression tests (tests/test_tidal_playlist_batch_chunking.py).
2026-06-13 10:39:30 -07:00

107 lines
3.5 KiB
Python

"""#867 regression: Tidal playlist track hydration must chunk to the API cap.
Tidal's ``/tracks?filter[id]=...`` endpoint returns at most ``_COLLECTION_BATCH_SIZE``
(20) tracks per request. ``get_playlist`` fetches a page of track-ID links and then
hydrates them via ``_get_tracks_batch``. If it sends a relationships page with more
than 20 IDs in a single hydration call, the surplus is silently dropped — a 59-track
playlist would surface as ~20. ``get_album_tracks`` already chunks; this pins the same
behavior for ``get_playlist`` so discovery sees every track.
"""
from __future__ import annotations
from core.tidal_client import TidalClient, Track
class _FakeResp:
status_code = 200
def raise_for_status(self):
return None
def json(self):
return {
"data": {
"id": "PL1",
"attributes": {"name": "My Playlist", "accessType": "PRIVATE"},
"relationships": {},
}
}
class _FakeSession:
def get(self, *args, **kwargs):
return _FakeResp()
def _make_client():
"""Build a TidalClient without running its network/config __init__."""
client = object.__new__(TidalClient)
client.base_url = "https://api.tidal.test"
client.session = _FakeSession()
return client
def test_get_playlist_chunks_oversized_relationships_page(monkeypatch):
client = _make_client()
cap = TidalClient._COLLECTION_BATCH_SIZE # 20
n = 59
ids = [str(i) for i in range(n)]
monkeypatch.setattr(client, "_ensure_valid_token", lambda: True)
# One relationships page returns ALL 59 ID links at once, then no cursor.
monkeypatch.setattr(
client,
"_get_playlist_tracks_page",
lambda playlist_id, cursor=None: {
"data": [{"type": "tracks", "id": i} for i in ids],
"links": {"meta": {}}, # no nextCursor -> single page
},
)
seen_chunk_sizes = []
def fake_batch(chunk_ids):
# Simulate the real filter[id] cap: never return more than the cap, so a
# single oversized call would lose the surplus (the bug being guarded).
seen_chunk_sizes.append(len(chunk_ids))
capped = chunk_ids[:cap]
return [Track(id=i, name=f"t{i}", artists=["a"]) for i in capped]
monkeypatch.setattr(client, "_get_tracks_batch", fake_batch)
playlist = client.get_playlist("PL1")
assert playlist is not None
# The whole point: all 59 hydrate, not just the first 20.
assert len(playlist.tracks) == n
# Every hydration call stayed within the API cap (so none truncated).
assert seen_chunk_sizes and max(seen_chunk_sizes) <= cap
def test_get_playlist_small_page_single_call(monkeypatch):
"""A page at/under the cap still hydrates in one call (no behavior change)."""
client = _make_client()
ids = [str(i) for i in range(5)]
monkeypatch.setattr(client, "_ensure_valid_token", lambda: True)
monkeypatch.setattr(
client,
"_get_playlist_tracks_page",
lambda playlist_id, cursor=None: {
"data": [{"type": "tracks", "id": i} for i in ids],
"links": {"meta": {}},
},
)
calls = []
def fake_batch(chunk_ids):
calls.append(list(chunk_ids))
return [Track(id=i, name=f"t{i}", artists=["a"]) for i in chunk_ids]
monkeypatch.setattr(client, "_get_tracks_batch", fake_batch)
playlist = client.get_playlist("PL1")
assert len(playlist.tracks) == 5
assert len(calls) == 1 # one chunk, one hydration call