soulsync/tests/test_tidal_collection_tracks.py
Broque Thomas f28f9808db Tidal: surface Favorite Tracks as virtual playlist (issue #502)
Adds the user's Tidal favorited tracks ("My Collection" in the Tidal
app) as a virtual playlist alongside their real playlists, mirroring
how Spotify's "Liked Songs" is treated.

Reporter (yug1900) located the working endpoint after the prior
`/v2/favorites?filter[type]=TRACKS` attempt returned empty data —
that endpoint is scoped to collections the third-party app created
itself, not personal favorites. Real endpoint:

    GET /v2/userCollectionTracks/me/relationships/items
        ?countryCode=US&locale=en-US&include=items

Cursor-paginated (20 per page, follow `links.next` with
`page[cursor]=...` until exhausted). Response only carries
track-level attributes — artist + album NAMES come back as
relationship-link stubs, not embedded data.

Implementation:

* Two-phase fetch — `_iter_collection_track_ids` walks the cursor
  chain to enumerate every track id (cheap, IDs only), then
  `get_collection_tracks` batch-hydrates 20 IDs at a time through
  the existing `_get_tracks_batch` helper which already knows how
  to `include=artists,albums`. No duplication of the JSON:API
  artist/album parse, no new dataclass shape.
* Virtual playlist `tidal-favorites` appended to the end of
  `/api/tidal/playlists`. ID intentionally has no colon —
  sync-services.js renderer interpolates IDs into CSS selectors
  via template literals (`#tidal-card-${p.id} .foo`) and a `:`
  would parse as a CSS pseudo-class operator.
* `tidal_client.get_playlist("tidal-favorites")` recognizes the
  virtual id and dispatches to the collection path internally, so
  every per-id consumer gets it for free: detail endpoint, mirror
  auto-refresh automation, "build Spotify discovery from Tidal
  playlist" flow.

OAuth scope expansion:

* Added `collection.read` to both OAuth flows (the
  `core/tidal_client.py::authenticate` standalone path AND the
  `web_server.py::auth_tidal` web flow — they were independent
  scope strings that both needed updating).
* Added `prompt=consent` to both flows — without it Tidal silently
  returns a token carrying only the ORIGINAL scope set even after
  re-authentication, because Tidal treats the existing
  authorization as still valid.
* New `disconnect()` method + `POST /api/tidal/disconnect`
  endpoint + Disconnect button next to Authenticate in Settings →
  Connections → Tidal — required for users whose existing token
  predates the scope expansion (forces a clean grant).

Reconnect-needed UI hint:

* `_collection_needs_reconnect` flag set on 401/403 from the
  collection endpoint, cleared on next successful walk, NOT set
  on 5xx (transient server errors must not falsely tell the user
  to reconnect).
* Listing endpoint reads the flag and surfaces a placeholder card
  titled "Favorite Tracks (reconnect Tidal to enable)" with a
  description pointing at Settings, so the user has something
  visible to act on instead of a silently missing row.

Diagnostic logging — collection request URL + response status +
first 300 bytes of body now logged at info level so future "why
is my collection empty" reports can be diagnosed from app.log
without needing live reproduction.

22 new tests pin: cursor walk (full chain, max-ids cap mid-page +
at page boundary), auth gates (no token / 401 / 403 all bail
clean), reconnect-flag lifecycle (set on 401/403, cleared on next
successful walk, NOT set on 5xx), forward-compat type filter
(non-track entries skipped), count helper, batch hydration
delegation + chunking at the 20-per-batch cap, partial-batch
failure containment, virtual-id dispatch (real playlist ids still
flow through the normal path).

Closes #502.
2026-05-10 21:36:22 -07:00

443 lines
19 KiB
Python

"""Pin Tidal "Favorite Tracks" virtual-playlist behavior.
GitHub issue #502 (Yug1900): expose the user's favorited tracks
(My Collection) as a virtual playlist alongside their real playlists,
mirroring how Spotify's "Liked Songs" is treated. The endpoint Tidal
exposes is cursor-paginated (`GET /v2/userCollectionTracks/me/
relationships/items?include=items`) and the response only carries
track-level attributes — artist + album NAMES need a second pass via
the existing `_get_tracks_batch` hydration helper.
These tests pin:
- ID enumeration via the cursor chain (single page, multi-page,
short-circuit on `max_ids`)
- Auth + permission failure paths (no token, 401/403 from
`collection.read` scope missing)
- Hydration delegates to `_get_tracks_batch` (no duplication of
the JSON:API artist/album parse)
- `get_playlist("tidal-favorites")` dispatches to the virtual
path (so every existing playlist-by-id consumer — mirror refresh,
discovery, detail endpoint — gets My Collection support for free)
- Count helper sums IDs across pages without hydrating
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from core.tidal_client import Track, Playlist, TidalClient
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _FakeResp:
"""Minimal `requests.Response` stand-in — only the fields the
collection-fetch path reads."""
def __init__(self, status_code: int = 200, json_body=None, text: str = ""):
self.status_code = status_code
self._body = json_body if json_body is not None else {}
self.text = text or str(self._body)
def json(self):
return self._body
def _make_authed_client():
"""Build a minimal TidalClient with the auth-related state every
collection method checks. Avoids touching disk / network in
`__init__`."""
client = TidalClient.__new__(TidalClient)
client.access_token = "fake-token"
client.token_expires_at = 9_999_999_999
client.base_url = "https://openapi.tidal.com/v2"
client.alt_base_url = "https://api.tidal.com/v1"
return client
# Two-page collection response that exercises the cursor chain.
_PAGE_ONE = {
'data': [
{'id': '1001', 'type': 'tracks'},
{'id': '1002', 'type': 'tracks'},
{'id': '1003', 'type': 'tracks'},
],
'links': {
'next': '/userCollectionTracks/me/relationships/items?cursor=ABC',
},
}
_PAGE_TWO = {
'data': [
{'id': '1004', 'type': 'tracks'},
{'id': '1005', 'type': 'tracks'},
],
'links': {}, # no `next` — end of cursor chain
}
# ---------------------------------------------------------------------------
# _iter_collection_track_ids
# ---------------------------------------------------------------------------
class TestIterCollectionTrackIds:
def test_walks_full_cursor_chain(self):
"""Both pages enumerated, IDs preserved in cursor order."""
client = _make_authed_client()
responses = iter([_FakeResp(200, _PAGE_ONE), _FakeResp(200, _PAGE_TWO)])
with patch.object(client, '_ensure_valid_token', return_value=True), \
patch('core.tidal_client.requests.get', side_effect=lambda *a, **kw: next(responses)), \
patch('core.tidal_client.time.sleep'):
ids = client._iter_collection_track_ids()
assert ids == ['1001', '1002', '1003', '1004', '1005']
def test_max_ids_short_circuits_mid_page(self):
"""`max_ids` cap stops enumeration without fetching the next
page — important for the count-with-cap callers we may add
later. Cap of 2 returns only the first two IDs."""
client = _make_authed_client()
with patch.object(client, '_ensure_valid_token', return_value=True), \
patch('core.tidal_client.requests.get', return_value=_FakeResp(200, _PAGE_ONE)), \
patch('core.tidal_client.time.sleep'):
ids = client._iter_collection_track_ids(max_ids=2)
assert ids == ['1001', '1002']
def test_max_ids_short_circuits_at_page_boundary(self):
"""Cap exactly equal to one page's worth — we should NOT make
the second request even though the cursor chain says there is
a next page."""
client = _make_authed_client()
call_count = {'n': 0}
def fake_get(*args, **kwargs):
call_count['n'] += 1
return _FakeResp(200, _PAGE_ONE)
with patch.object(client, '_ensure_valid_token', return_value=True), \
patch('core.tidal_client.requests.get', side_effect=fake_get), \
patch('core.tidal_client.time.sleep'):
ids = client._iter_collection_track_ids(max_ids=3)
assert ids == ['1001', '1002', '1003']
assert call_count['n'] == 1, "Should not have fetched the second cursor page"
def test_no_token_returns_empty_without_request(self):
"""Auth precheck failure short-circuits before any HTTP."""
client = _make_authed_client()
with patch.object(client, '_ensure_valid_token', return_value=False), \
patch('core.tidal_client.requests.get') as mock_get:
ids = client._iter_collection_track_ids()
assert ids == []
assert not mock_get.called
def test_401_response_breaks_loop(self):
"""Tokens predating the `collection.read` scope expansion will
return 401. We log + bail rather than retry endlessly."""
client = _make_authed_client()
with patch.object(client, '_ensure_valid_token', return_value=True), \
patch('core.tidal_client.requests.get', return_value=_FakeResp(401, text="unauthorized")), \
patch('core.tidal_client.time.sleep'):
ids = client._iter_collection_track_ids()
assert ids == []
def test_403_response_breaks_loop(self):
"""Same defensive bail for 403 (forbidden — scope or product
tier issue)."""
client = _make_authed_client()
with patch.object(client, '_ensure_valid_token', return_value=True), \
patch('core.tidal_client.requests.get', return_value=_FakeResp(403, text="forbidden")), \
patch('core.tidal_client.time.sleep'):
ids = client._iter_collection_track_ids()
assert ids == []
def test_401_sets_needs_reconnect_flag(self):
"""The single most common 'why is my collection empty' cause:
existing token predates the `collection.read` scope. Listing
endpoint reads `collection_needs_reconnect()` and surfaces a
user-actionable hint instead of silently hiding the row."""
client = _make_authed_client()
with patch.object(client, '_ensure_valid_token', return_value=True), \
patch('core.tidal_client.requests.get', return_value=_FakeResp(401)), \
patch('core.tidal_client.time.sleep'):
client._iter_collection_track_ids()
assert client.collection_needs_reconnect() is True
def test_403_sets_needs_reconnect_flag(self):
"""403 = scope/product-tier issue — same surface treatment as 401."""
client = _make_authed_client()
with patch.object(client, '_ensure_valid_token', return_value=True), \
patch('core.tidal_client.requests.get', return_value=_FakeResp(403)), \
patch('core.tidal_client.time.sleep'):
client._iter_collection_track_ids()
assert client.collection_needs_reconnect() is True
def test_successful_walk_clears_stale_reconnect_flag(self):
"""User reconnects → next iter call MUST clear the prior
flag. Otherwise the listing endpoint keeps showing the
reconnect hint forever even after the scope is granted."""
client = _make_authed_client()
client._collection_needs_reconnect = True # Simulate stale flag
with patch.object(client, '_ensure_valid_token', return_value=True), \
patch('core.tidal_client.requests.get', return_value=_FakeResp(200, _PAGE_TWO)), \
patch('core.tidal_client.time.sleep'):
client._iter_collection_track_ids()
assert client.collection_needs_reconnect() is False
def test_500_does_not_set_reconnect_flag(self):
"""Server-side errors (5xx, network timeout) are NOT a scope
problem — must NOT poison the flag. User shouldn't be told
to reconnect just because Tidal had a hiccup."""
client = _make_authed_client()
with patch.object(client, '_ensure_valid_token', return_value=True), \
patch('core.tidal_client.requests.get', return_value=_FakeResp(500, text="server error")), \
patch('core.tidal_client.time.sleep'):
client._iter_collection_track_ids()
assert client.collection_needs_reconnect() is False
def test_skips_non_tracks_data_entries(self):
"""The endpoint may surface non-track relationship entries on
future schema additions — we only collect `type == 'tracks'`
IDs so a forward-compatible response shape doesn't poison the
ID list with unrelated resources."""
client = _make_authed_client()
weird_page = {
'data': [
{'id': '999', 'type': 'tracks'},
{'id': 'pl-1', 'type': 'playlists'}, # ignored
{'id': '1000', 'type': 'tracks'},
],
'links': {},
}
with patch.object(client, '_ensure_valid_token', return_value=True), \
patch('core.tidal_client.requests.get', return_value=_FakeResp(200, weird_page)), \
patch('core.tidal_client.time.sleep'):
ids = client._iter_collection_track_ids()
assert ids == ['999', '1000']
def test_empty_data_on_first_page_returns_empty(self):
"""Empty collection — clean empty list, no errors."""
client = _make_authed_client()
empty = {'data': [], 'links': {}}
with patch.object(client, '_ensure_valid_token', return_value=True), \
patch('core.tidal_client.requests.get', return_value=_FakeResp(200, empty)), \
patch('core.tidal_client.time.sleep'):
ids = client._iter_collection_track_ids()
assert ids == []
# ---------------------------------------------------------------------------
# get_collection_tracks_count
# ---------------------------------------------------------------------------
class TestGetCollectionTracksCount:
def test_returns_total_across_pages(self):
"""Count = sum of IDs across the full cursor chain."""
client = _make_authed_client()
responses = iter([_FakeResp(200, _PAGE_ONE), _FakeResp(200, _PAGE_TWO)])
with patch.object(client, '_ensure_valid_token', return_value=True), \
patch('core.tidal_client.requests.get', side_effect=lambda *a, **kw: next(responses)), \
patch('core.tidal_client.time.sleep'):
assert client.get_collection_tracks_count() == 5
def test_returns_zero_on_failure(self):
"""Wrapping handler swallows exceptions — caller treats any
failure as 'no collection tracks' rather than propagating."""
client = _make_authed_client()
with patch.object(client, '_iter_collection_track_ids', side_effect=RuntimeError("boom")):
assert client.get_collection_tracks_count() == 0
def test_returns_zero_when_unauthenticated(self):
client = _make_authed_client()
with patch.object(client, '_ensure_valid_token', return_value=False):
assert client.get_collection_tracks_count() == 0
# ---------------------------------------------------------------------------
# get_collection_tracks
# ---------------------------------------------------------------------------
class TestGetCollectionTracks:
def test_hydrates_via_existing_batch_helper(self):
"""Hydration MUST delegate to `_get_tracks_batch` rather than
reimplement the JSON:API artist/album parse — that's the
existing battle-tested path. This test verifies the dispatch
+ that the hydrated tracks come back in the same order the
IDs were enumerated."""
client = _make_authed_client()
ordered_ids = ['1001', '1002', '1003']
fake_tracks = [
Track(id='1001', name='Times Like These', artists=['Foo Fighters'], album='One by One'),
Track(id='1002', name='Innerbloom', artists=['RÜFÜS DU SOL'], album='Bloom'),
Track(id='1003', name='Set Fire to the Rain', artists=['Adele'], album='21'),
]
captured_batches = []
def fake_batch(ids):
captured_batches.append(list(ids))
id_to_track = {t.id: t for t in fake_tracks}
return [id_to_track[i] for i in ids if i in id_to_track]
with patch.object(client, '_iter_collection_track_ids', return_value=ordered_ids), \
patch.object(client, '_get_tracks_batch', side_effect=fake_batch):
result = client.get_collection_tracks()
assert [t.id for t in result] == ['1001', '1002', '1003']
assert [t.name for t in result] == ['Times Like These', 'Innerbloom', 'Set Fire to the Rain']
# First (and only) batch should contain all three IDs since
# default _COLLECTION_BATCH_SIZE is well above 3.
assert captured_batches == [['1001', '1002', '1003']]
def test_chunks_into_batch_size(self):
"""Pin the batching: 41 IDs at batch size 20 → three batches
of 20 / 20 / 1. The Tidal `filter[id]` cap is 20 so we can't
send everything in one request."""
client = _make_authed_client()
ids = [str(1000 + i) for i in range(41)]
captured_batches = []
def fake_batch(batch):
captured_batches.append(list(batch))
return [Track(id=tid, name=f'Track {tid}', artists=['A'], album='Alb') for tid in batch]
with patch.object(client, '_iter_collection_track_ids', return_value=ids), \
patch.object(client, '_get_tracks_batch', side_effect=fake_batch):
result = client.get_collection_tracks()
assert len(result) == 41
assert [len(b) for b in captured_batches] == [20, 20, 1]
def test_partial_batch_failure_continues(self):
"""One failed batch shouldn't abort the whole fetch — the rest
of the collection should still come back. Defensive against
transient Tidal errors mid-walk."""
client = _make_authed_client()
ids = ['1001', '1002', '1003']
def fake_batch(batch):
if batch == ['1002']: # won't actually hit since batch_size > 1, but illustrate
raise RuntimeError("transient")
return [Track(id=tid, name=f'T{tid}', artists=['A'], album='Alb') for tid in batch]
with patch.object(client, '_iter_collection_track_ids', return_value=ids), \
patch.object(client, '_get_tracks_batch', side_effect=lambda b: (_ for _ in ()).throw(RuntimeError("transient")) if b == ids else []):
result = client.get_collection_tracks()
# All batches failed → empty result, but no exception bubbled
assert result == []
def test_no_ids_returns_empty_without_hydrating(self):
"""Empty collection short-circuits before any batch call."""
client = _make_authed_client()
with patch.object(client, '_iter_collection_track_ids', return_value=[]), \
patch.object(client, '_get_tracks_batch') as mock_batch:
result = client.get_collection_tracks()
assert result == []
assert not mock_batch.called
def test_limit_passed_through_to_iter(self):
"""`limit` arg caps the ID walk so we don't hydrate everything
when the caller only wants a slice."""
client = _make_authed_client()
captured_max = {'value': None}
def fake_iter(max_ids=None):
captured_max['value'] = max_ids
return ['1001', '1002']
with patch.object(client, '_iter_collection_track_ids', side_effect=fake_iter), \
patch.object(client, '_get_tracks_batch', return_value=[]):
client.get_collection_tracks(limit=50)
assert captured_max['value'] == 50
# ---------------------------------------------------------------------------
# get_playlist virtual-id dispatch
# ---------------------------------------------------------------------------
class TestGetPlaylistVirtualId:
def test_my_collection_id_returns_virtual_playlist(self):
"""Pin the dispatch — `get_playlist("tidal-favorites")`
must NOT hit the real /playlists/<id> endpoint and must NOT
require token validation (the collection methods do their own).
It returns a synthetic Playlist with the hydrated collection
tracks, so every existing call site (mirror refresh @ line
1192, discovery start @ line 20835, detail endpoint @ line
20725) gets My Collection support without per-site changes."""
client = _make_authed_client()
fake_collection = [
Track(id='1001', name='Times Like These', artists=['Foo Fighters']),
Track(id='1002', name='Innerbloom', artists=['RÜFÜS DU SOL']),
]
with patch.object(client, 'get_collection_tracks', return_value=fake_collection), \
patch.object(client, '_ensure_valid_token') as mock_token, \
patch('core.tidal_client.requests.get') as mock_get:
playlist = client.get_playlist("tidal-favorites")
assert isinstance(playlist, Playlist)
assert playlist.id == "tidal-favorites"
assert playlist.name == "Favorite Tracks"
assert playlist.description == "Your favorited tracks on Tidal"
assert len(playlist.tracks) == 2
assert playlist.tracks[0].id == '1001'
# Virtual path should NOT touch the real /playlists/<id>
# endpoint OR the auth precheck (get_collection_tracks
# handles its own auth gate downstream).
assert not mock_get.called
assert not mock_token.called
def test_real_playlist_id_falls_through_to_normal_path(self):
"""Sanity: a real playlist ID must NOT route to the virtual
handler. Token check + HTTP request still happen."""
client = _make_authed_client()
client.session = SimpleNamespace(
get=lambda *a, **kw: _FakeResp(404, text="not found"),
headers={},
)
with patch.object(client, 'get_collection_tracks') as mock_collection, \
patch.object(client, '_ensure_valid_token', return_value=True):
# 404 from the fake session → returns None, but more
# importantly the virtual-handler MUST NOT have been called.
client.get_playlist("real-playlist-uuid")
assert not mock_collection.called