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.
443 lines
19 KiB
Python
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
|