soulsync/tests/test_tidal_favorite_albums_artists.py
Broque Thomas f4c433c151 Tidal: rewire favorite albums + artists to V2 user-collection endpoints
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.
2026-05-11 10:03:27 -07:00

442 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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() == []