Discord: Discover → Your Albums (and Your Artists) was returning nothing for Tidal users regardless of how many albums/artists they'd favorited. Audit found `get_favorite_albums` and `get_favorite_artists` called the deprecated `/v2/favorites?filter[type]=ALBUMS|ARTISTS` endpoint — that endpoint returns 404 for personal favorites because it's scoped to collections the third-party app created itself. The V1 fallback (`/v1/users/<id>/favorites/...`) is also dead because modern OAuth tokens carry `collection.read` instead of the legacy `r_usr` scope V1 demands (returns 403). Same root cause as the favorited-tracks fix from #502. Fix: rewire to the working V2 user-collection endpoints — `/v2/userCollectionAlbums/me/relationships/items` and `/v2/userCollectionArtists/me/relationships/items` — using the same cursor-paginated pattern shipped for tracks. Architecture: * ID enumeration lifted into a generic `_iter_collection_resource_ids(path, expected_type, max_ids)` helper so tracks / albums / artists all share one walker. Three thin wrappers preserve the per-resource public surface (`_iter_collection_track_ids`, `_iter_collection_album_ids`, `_iter_collection_artist_ids`). Net deduped ~80 lines that would otherwise be three near-identical copies. * Batch hydration via `/v2/{albums|artists}?filter[id]=...&include=...` with extended JSON:API include semantics. One request returns up to 20 albums + their artists + cover artworks all in `included[]` (or 20 artists + their profile artworks). Three static helpers parse the response: - `_build_included_maps(included)` → indexes the array by type so per-resource lookup is O(1) per relationship ref - `_first_artist_name(rels, artists_map)` → resolves primary artist from relationships block; '' on missing/unknown - `_first_artwork_url(rel, artworks_map)` → picks `files[0]` (Tidal returns artwork files largest-first, so this gets the highest-resolution variant — typically 1280×1280) * Public methods (`get_favorite_albums`, `get_favorite_artists`) preserve the prior return shape — list of dicts matching what `database.upsert_liked_album` / `upsert_liked_artist` consume — so the discover aggregator path in `web_server.py` stays byte-identical. No caller changes needed. * Deleted ~240 lines of dead code: the V2-favorites paths AND the V1 fallback paths from the old method bodies. Both are dead against modern OAuth tokens. 24 new tests in `tests/test_tidal_favorite_albums_artists.py` pin: * Cursor-walker dispatch (album/artist iters pass correct path + expected_type to the generic walker) * Included-map building (groups by type, skips items missing id) * Artist + artwork relationship resolution (full + missing rels + unknown id + no files cases) * Batch hydration parse for albums (full attributes, missing relationships fall through to defaults, type-filter excludes non-album entries, `filter[id]` param is comma-joined) * Batch hydration parse for artists (same shape coverage) * End-to-end orchestrator behavior (walk → batch → return, empty-input short-circuits without API call, BATCH_SIZE chunking on 41 IDs → 20/20/1, exception-from-iter returns []) Endpoint paths empirically verified against live Tidal API: `userCollectionArtists/me/relationships/items` returned 200 + 5 real artist refs for the test account. `userCollectionAlbums/...` returned 200 + empty (account has 0 album favorites currently) but the response shape is correct. The deprecated `/v2/favorites?filter[type]=ALBUMS` returned 404. The V1 `/v1/users/<id>/favorites/albums` returned 403 with explicit "Token is missing required scope. Required scopes: r_usr" message. WHATS_NEW entry under existing '2.5.1' block. Full pytest: 2678 passed.
442 lines
18 KiB
Python
442 lines
18 KiB
Python
"""Pin Tidal favorite albums + artists fetch via V2 user-collection
|
||
endpoints.
|
||
|
||
Discord report: Discover → Your Albums section showed nothing for
|
||
Tidal users regardless of how many albums they'd favorited. Audit
|
||
found `get_favorite_albums` (and `get_favorite_artists`) called the
|
||
deprecated `/v2/favorites?filter[type]=ALBUMS|ARTISTS` endpoint
|
||
which returns 404 for personal favorites — that endpoint is scoped
|
||
to collections the third-party app created itself, not the user's
|
||
app-level favorites. The V1 fallback (`/v1/users/<id>/favorites/...`)
|
||
returns 403 for modern OAuth tokens because they carry
|
||
`collection.read` instead of the legacy `r_usr` scope.
|
||
|
||
Fix: rewire to the same V2 user-collection cursor-paginated
|
||
endpoints we shipped for tracks (issue #502):
|
||
- `/v2/userCollectionAlbums/me/relationships/items`
|
||
- `/v2/userCollectionArtists/me/relationships/items`
|
||
|
||
Plus per-resource batch hydration via `/v2/{albums|artists}` with
|
||
extended-include semantics (`include=artists,coverArt` for albums,
|
||
`include=profileArt` for artists) so artist names + image URLs come
|
||
back in a single request per batch instead of N+1 lookups.
|
||
|
||
These tests pin:
|
||
- Cursor walkers dispatch correct path + type to the generic
|
||
`_iter_collection_resource_ids` helper
|
||
- Batch hydrators parse JSON:API `data[]` + `included[]` into the
|
||
legacy return shape that `database.upsert_liked_album` /
|
||
`upsert_liked_artist` consume — preserves byte-identical wiring
|
||
in `web_server.py`'s discover aggregator
|
||
- Image URL resolution picks largest variant from artwork files[]
|
||
- Artist-name resolution falls through to '' when relationships
|
||
are missing (so the upsert path doesn't trip on None)
|
||
- Empty-input + HTTP-error paths return [] without raising
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from types import SimpleNamespace
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from core.tidal_client import TidalClient
|
||
|
||
|
||
def _make_client():
|
||
"""Bare TidalClient with auth state primed — no real connection.
|
||
Mirrors the helper in test_tidal_collection_tracks.py."""
|
||
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"
|
||
client.session = MagicMock()
|
||
return client
|
||
|
||
|
||
class _FakeResp:
|
||
def __init__(self, status_code=200, json_body=None, text=""):
|
||
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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Cursor-walker dispatch
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestCollectionWalkerDispatch:
|
||
def test_album_iter_passes_album_path_and_type(self):
|
||
"""`_iter_collection_album_ids` must dispatch to the generic
|
||
walker with the albums path + 'albums' expected_type. If the
|
||
wrapper drifts (e.g. typoed path) the IDs come back empty."""
|
||
client = _make_client()
|
||
with patch.object(client, '_iter_collection_resource_ids',
|
||
return_value=['111', '222']) as mock_walk:
|
||
ids = client._iter_collection_album_ids(max_ids=50)
|
||
|
||
mock_walk.assert_called_once_with(
|
||
'userCollectionAlbums/me/relationships/items', 'albums', 50,
|
||
)
|
||
assert ids == ['111', '222']
|
||
|
||
def test_artist_iter_passes_artist_path_and_type(self):
|
||
client = _make_client()
|
||
with patch.object(client, '_iter_collection_resource_ids',
|
||
return_value=['17275']) as mock_walk:
|
||
ids = client._iter_collection_artist_ids()
|
||
|
||
mock_walk.assert_called_once_with(
|
||
'userCollectionArtists/me/relationships/items', 'artists', None,
|
||
)
|
||
assert ids == ['17275']
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helper: included map + relationship resolution
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestIncludedMaps:
|
||
def test_build_included_maps_groups_by_type(self):
|
||
included = [
|
||
{'id': 'a1', 'type': 'artists', 'attributes': {'name': 'Foo'}},
|
||
{'id': 'art1', 'type': 'artworks', 'attributes': {'files': []}},
|
||
{'id': 'a2', 'type': 'artists', 'attributes': {'name': 'Bar'}},
|
||
{'id': 'unknown1', 'type': 'something_else'},
|
||
{'type': 'artworks'}, # missing id — should be skipped
|
||
]
|
||
artists, artworks = TidalClient._build_included_maps(included)
|
||
assert set(artists.keys()) == {'a1', 'a2'}
|
||
assert set(artworks.keys()) == {'art1'}
|
||
assert artists['a1']['attributes']['name'] == 'Foo'
|
||
|
||
def test_first_artist_name_resolves_from_map(self):
|
||
artists_map = {'a1': {'attributes': {'name': 'Eminem'}}}
|
||
rels = {'artists': {'data': [{'id': 'a1', 'type': 'artists'}]}}
|
||
assert TidalClient._first_artist_name(rels, artists_map) == 'Eminem'
|
||
|
||
def test_first_artist_name_empty_when_no_refs(self):
|
||
"""Defensive: relationships block missing or empty → '' so
|
||
upsert path doesn't trip on None."""
|
||
assert TidalClient._first_artist_name({}, {}) == ''
|
||
assert TidalClient._first_artist_name(
|
||
{'artists': {'data': []}}, {}
|
||
) == ''
|
||
|
||
def test_first_artist_name_empty_when_unknown_id(self):
|
||
"""Artist ref points at an ID not in included map — fall
|
||
through to '' rather than crash."""
|
||
rels = {'artists': {'data': [{'id': 'missing'}]}}
|
||
artists_map = {'other': {'attributes': {'name': 'X'}}}
|
||
assert TidalClient._first_artist_name(rels, artists_map) == ''
|
||
|
||
def test_first_artwork_url_picks_first_file(self):
|
||
"""Tidal returns artwork files largest-first. Picking files[0]
|
||
gets the highest-resolution variant (typically 1280×1280)."""
|
||
artworks_map = {
|
||
'art1': {'attributes': {'files': [
|
||
{'href': 'https://big.jpg', 'meta': {'width': 1280}},
|
||
{'href': 'https://small.jpg', 'meta': {'width': 320}},
|
||
]}}
|
||
}
|
||
rel = {'data': [{'id': 'art1', 'type': 'artworks'}]}
|
||
url = TidalClient._first_artwork_url(rel, artworks_map)
|
||
assert url == 'https://big.jpg'
|
||
|
||
def test_first_artwork_url_none_when_no_relationship(self):
|
||
assert TidalClient._first_artwork_url({}, {}) is None
|
||
assert TidalClient._first_artwork_url({'data': []}, {}) is None
|
||
|
||
def test_first_artwork_url_none_when_no_files(self):
|
||
"""Defensive: artwork resource exists but has no files array.
|
||
Return None rather than IndexError."""
|
||
artworks_map = {'art1': {'attributes': {'files': []}}}
|
||
rel = {'data': [{'id': 'art1'}]}
|
||
assert TidalClient._first_artwork_url(rel, artworks_map) is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Batch hydration — albums
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
_ALBUM_BATCH_RESPONSE = {
|
||
'data': [
|
||
{
|
||
'id': '141121273',
|
||
'type': 'albums',
|
||
'attributes': {
|
||
'title': 'Mr. Morale & The Big Steppers',
|
||
'releaseDate': '2022-05-13',
|
||
'numberOfItems': 18,
|
||
},
|
||
'relationships': {
|
||
'artists': {'data': [{'id': '5034248', 'type': 'artists'}]},
|
||
'coverArt': {'data': [{'id': 'cover-uuid', 'type': 'artworks'}]},
|
||
},
|
||
},
|
||
{
|
||
'id': '999',
|
||
'type': 'albums',
|
||
'attributes': {'title': 'Album Without Artist or Cover'},
|
||
'relationships': {},
|
||
},
|
||
],
|
||
'included': [
|
||
{
|
||
'id': '5034248', 'type': 'artists',
|
||
'attributes': {'name': 'Kendrick Lamar'},
|
||
},
|
||
{
|
||
'id': 'cover-uuid', 'type': 'artworks',
|
||
'attributes': {'files': [
|
||
{'href': 'https://resources.tidal.com/images/cover/1280x1280.jpg'},
|
||
]},
|
||
},
|
||
],
|
||
}
|
||
|
||
|
||
class TestGetAlbumsBatch:
|
||
def test_parses_full_album_response(self):
|
||
client = _make_client()
|
||
client.session.get = MagicMock(
|
||
return_value=_FakeResp(200, _ALBUM_BATCH_RESPONSE)
|
||
)
|
||
results = client._get_albums_batch(['141121273', '999'])
|
||
|
||
assert len(results) == 2
|
||
# First album — full attributes resolved from included
|
||
first = results[0]
|
||
assert first['tidal_id'] == '141121273'
|
||
assert first['album_name'] == 'Mr. Morale & The Big Steppers'
|
||
assert first['artist_name'] == 'Kendrick Lamar'
|
||
assert first['image_url'] == 'https://resources.tidal.com/images/cover/1280x1280.jpg'
|
||
assert first['release_date'] == '2022-05-13'
|
||
assert first['total_tracks'] == 18
|
||
# Second album — missing relationships fall through to defaults
|
||
second = results[1]
|
||
assert second['album_name'] == 'Album Without Artist or Cover'
|
||
assert second['artist_name'] == ''
|
||
assert second['image_url'] is None
|
||
assert second['release_date'] == ''
|
||
assert second['total_tracks'] == 0
|
||
|
||
def test_empty_input_returns_empty_without_request(self):
|
||
client = _make_client()
|
||
client.session.get = MagicMock()
|
||
results = client._get_albums_batch([])
|
||
assert results == []
|
||
client.session.get.assert_not_called()
|
||
|
||
def test_http_error_returns_empty(self):
|
||
client = _make_client()
|
||
client.session.get = MagicMock(
|
||
return_value=_FakeResp(500, text='server error')
|
||
)
|
||
results = client._get_albums_batch(['111'])
|
||
assert results == []
|
||
|
||
def test_skips_data_entries_with_wrong_type(self):
|
||
"""Forward-compat: response shape might surface non-album
|
||
resources alongside the request — only collect entries whose
|
||
type is 'albums'."""
|
||
client = _make_client()
|
||
client.session.get = MagicMock(return_value=_FakeResp(200, {
|
||
'data': [
|
||
{'id': '1', 'type': 'albums', 'attributes': {'title': 'A'}, 'relationships': {}},
|
||
{'id': '2', 'type': 'tracks', 'attributes': {'title': 'Skip Me'}},
|
||
],
|
||
'included': [],
|
||
}))
|
||
results = client._get_albums_batch(['1', '2'])
|
||
assert len(results) == 1
|
||
assert results[0]['album_name'] == 'A'
|
||
|
||
def test_filter_id_param_is_comma_joined(self):
|
||
"""The Tidal API expects `filter[id]=a,b,c` — verify our
|
||
param construction. Drift here would break batching against
|
||
production silently."""
|
||
client = _make_client()
|
||
captured_params = {}
|
||
|
||
def fake_get(url, params=None, headers=None, timeout=None):
|
||
captured_params.update(params or {})
|
||
return _FakeResp(200, {'data': [], 'included': []})
|
||
|
||
client.session.get = MagicMock(side_effect=fake_get)
|
||
client._get_albums_batch(['111', '222', '333'])
|
||
assert captured_params['filter[id]'] == '111,222,333'
|
||
assert captured_params['include'] == 'artists,coverArt'
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Batch hydration — artists
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
_ARTIST_BATCH_RESPONSE = {
|
||
'data': [
|
||
{
|
||
'id': '17275',
|
||
'type': 'artists',
|
||
'attributes': {'name': 'Eminem'},
|
||
'relationships': {
|
||
'profileArt': {'data': [{'id': 'profile-uuid', 'type': 'artworks'}]},
|
||
},
|
||
},
|
||
],
|
||
'included': [
|
||
{
|
||
'id': 'profile-uuid', 'type': 'artworks',
|
||
'attributes': {'files': [
|
||
{'href': 'https://resources.tidal.com/images/profile/750x750.jpg'},
|
||
]},
|
||
},
|
||
],
|
||
}
|
||
|
||
|
||
class TestGetArtistsBatch:
|
||
def test_parses_full_artist_response(self):
|
||
client = _make_client()
|
||
client.session.get = MagicMock(
|
||
return_value=_FakeResp(200, _ARTIST_BATCH_RESPONSE)
|
||
)
|
||
results = client._get_artists_batch(['17275'])
|
||
|
||
assert len(results) == 1
|
||
assert results[0]['tidal_id'] == '17275'
|
||
assert results[0]['name'] == 'Eminem'
|
||
assert results[0]['image_url'] == 'https://resources.tidal.com/images/profile/750x750.jpg'
|
||
|
||
def test_empty_input_returns_empty_without_request(self):
|
||
client = _make_client()
|
||
client.session.get = MagicMock()
|
||
assert client._get_artists_batch([]) == []
|
||
client.session.get.assert_not_called()
|
||
|
||
def test_http_error_returns_empty(self):
|
||
client = _make_client()
|
||
client.session.get = MagicMock(
|
||
return_value=_FakeResp(404, text='not found')
|
||
)
|
||
assert client._get_artists_batch(['17275']) == []
|
||
|
||
def test_filter_id_and_include_params(self):
|
||
client = _make_client()
|
||
captured = {}
|
||
|
||
def fake_get(url, params=None, headers=None, timeout=None):
|
||
captured.update(params or {})
|
||
return _FakeResp(200, {'data': [], 'included': []})
|
||
|
||
client.session.get = MagicMock(side_effect=fake_get)
|
||
client._get_artists_batch(['17275', '721'])
|
||
assert captured['filter[id]'] == '17275,721'
|
||
assert captured['include'] == 'profileArt'
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public methods — orchestrator behavior
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestGetFavoriteAlbums:
|
||
def test_walks_then_batches_then_returns(self):
|
||
"""End-to-end: iter returns IDs, batch hydrates them, result
|
||
is the concatenation. Backward-compatible shape preserved
|
||
for `database.upsert_liked_album` callers."""
|
||
client = _make_client()
|
||
with patch.object(client, '_iter_collection_album_ids',
|
||
return_value=['1', '2', '3']) as mock_iter, \
|
||
patch.object(client, '_get_albums_batch',
|
||
return_value=[
|
||
{'tidal_id': '1', 'album_name': 'A',
|
||
'artist_name': 'X', 'image_url': 'u',
|
||
'release_date': '2020', 'total_tracks': 10},
|
||
{'tidal_id': '2', 'album_name': 'B',
|
||
'artist_name': 'Y', 'image_url': None,
|
||
'release_date': '', 'total_tracks': 0},
|
||
]) as mock_batch:
|
||
results = client.get_favorite_albums(limit=100)
|
||
|
||
mock_iter.assert_called_once_with(max_ids=100)
|
||
# Single batch call since 3 IDs fit in one BATCH_SIZE chunk (20)
|
||
assert mock_batch.call_count == 1
|
||
assert len(results) == 2
|
||
assert results[0]['tidal_id'] == '1'
|
||
# Verify shape compatibility with upsert_liked_album kwargs
|
||
expected_keys = {'tidal_id', 'album_name', 'artist_name',
|
||
'image_url', 'release_date', 'total_tracks'}
|
||
assert set(results[0].keys()) == expected_keys
|
||
|
||
def test_no_ids_returns_empty_without_batch(self):
|
||
client = _make_client()
|
||
with patch.object(client, '_iter_collection_album_ids', return_value=[]), \
|
||
patch.object(client, '_get_albums_batch') as mock_batch:
|
||
assert client.get_favorite_albums() == []
|
||
mock_batch.assert_not_called()
|
||
|
||
def test_chunks_into_batch_size(self):
|
||
"""41 IDs at BATCH_SIZE 20 → three batches of 20/20/1.
|
||
Tidal's filter[id] cap is the per-request limit; orchestrator
|
||
must respect it."""
|
||
client = _make_client()
|
||
ids = [str(i) for i in range(41)]
|
||
captured_batches = []
|
||
|
||
def fake_batch(batch):
|
||
captured_batches.append(list(batch))
|
||
return [{'tidal_id': b, 'album_name': f'A{b}', 'artist_name': '',
|
||
'image_url': None, 'release_date': '', 'total_tracks': 0}
|
||
for b in batch]
|
||
|
||
with patch.object(client, '_iter_collection_album_ids', return_value=ids), \
|
||
patch.object(client, '_get_albums_batch', side_effect=fake_batch):
|
||
results = client.get_favorite_albums()
|
||
|
||
assert len(results) == 41
|
||
assert [len(b) for b in captured_batches] == [20, 20, 1]
|
||
|
||
|
||
class TestGetFavoriteArtists:
|
||
def test_walks_then_batches(self):
|
||
client = _make_client()
|
||
with patch.object(client, '_iter_collection_artist_ids',
|
||
return_value=['17275']) as mock_iter, \
|
||
patch.object(client, '_get_artists_batch',
|
||
return_value=[{'tidal_id': '17275', 'name': 'Eminem',
|
||
'image_url': 'https://eminem.jpg'}]) as mock_batch:
|
||
results = client.get_favorite_artists(limit=200)
|
||
|
||
mock_iter.assert_called_once_with(max_ids=200)
|
||
mock_batch.assert_called_once()
|
||
assert len(results) == 1
|
||
assert results[0]['name'] == 'Eminem'
|
||
# Backward-compat shape — exactly the keys the prior
|
||
# implementation returned
|
||
assert set(results[0].keys()) == {'tidal_id', 'name', 'image_url'}
|
||
|
||
def test_no_ids_returns_empty(self):
|
||
client = _make_client()
|
||
with patch.object(client, '_iter_collection_artist_ids', return_value=[]), \
|
||
patch.object(client, '_get_artists_batch') as mock_batch:
|
||
assert client.get_favorite_artists() == []
|
||
mock_batch.assert_not_called()
|
||
|
||
def test_swallows_iter_exception_returns_empty(self):
|
||
"""Defensive: if the cursor walker blows up mid-page, the
|
||
public method should return [] (no partial corruption of the
|
||
liked-artists table)."""
|
||
client = _make_client()
|
||
with patch.object(client, '_iter_collection_artist_ids',
|
||
side_effect=RuntimeError('boom')):
|
||
assert client.get_favorite_artists() == []
|