/
/
1"""Test Tidal Library Manager."""
2
3import json
4import pathlib
5from typing import Any
6from unittest.mock import AsyncMock, Mock
7
8import pytest
9from aiohttp.client_exceptions import ClientError
10from music_assistant_models.enums import MediaType
11
12from music_assistant.providers.tidal.jsonapi import JsonApiDocument
13from music_assistant.providers.tidal.library import TidalLibraryManager
14
15FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" / "v2"
16
17
18def _load_doc(name: str) -> JsonApiDocument:
19 with open(FIXTURES_DIR / name) as f:
20 return JsonApiDocument(json.load(f))
21
22
23@pytest.fixture
24def library_manager(provider_mock: Mock) -> TidalLibraryManager:
25 """Return a TidalLibraryManager instance."""
26 return TidalLibraryManager(provider_mock)
27
28
29async def test_get_artists(library_manager: TidalLibraryManager, provider_mock: Mock) -> None:
30 """Test library artists read from the official userCollection endpoint."""
31 doc = _load_doc("lib_artists.json")
32
33 async def _pages(*_a: Any, **_k: Any) -> Any:
34 yield doc
35
36 provider_mock.api.paginate_jsonapi = _pages
37
38 artists = [a async for a in library_manager.get_artists()]
39
40 assert len(artists) == 20
41 assert artists[0].date_added is not None # from the addedAt linkage meta
42
43
44async def test_get_albums(library_manager: TidalLibraryManager, provider_mock: Mock) -> None:
45 """Test library albums read from the official userCollection endpoint."""
46 doc = _load_doc("lib_albums.json")
47
48 async def _pages(*_a: Any, **_k: Any) -> Any:
49 yield doc
50
51 provider_mock.api.paginate_jsonapi = _pages
52
53 albums = [a async for a in library_manager.get_albums()]
54
55 assert len(albums) == 20
56 assert albums[0].date_added is not None
57
58
59async def test_get_tracks(library_manager: TidalLibraryManager, provider_mock: Mock) -> None:
60 """Test library tracks read from the official userCollection endpoint."""
61 doc = _load_doc("lib_tracks.json")
62
63 async def _pages(*_a: Any, **_k: Any) -> Any:
64 yield doc
65
66 provider_mock.api.paginate_jsonapi = _pages
67
68 tracks = [t async for t in library_manager.get_tracks()]
69
70 assert len(tracks) == 20
71 assert tracks[0].date_added is not None
72 # every linkage item feeds the churn cache (the read passes replaceMedia)
73 assert provider_mock.note_replaced_track.call_count == 20
74
75
76async def test_get_playlists(library_manager: TidalLibraryManager, provider_mock: Mock) -> None:
77 """Test library playlists include mixes and the virtual favorites playlist."""
78 doc = _load_doc("lib_playlists.json")
79
80 async def _pages(*_a: Any, **_k: Any) -> Any:
81 yield doc
82
83 provider_mock.api.paginate_jsonapi = _pages
84
85 playlists = [p async for p in library_manager.get_playlists()]
86
87 # 19 resolved playlists + the trailing virtual "favorite tracks" playlist
88 assert playlists[-1].item_id == "favorite_tracks"
89 # mixes come through the same collection with the "mix_" item id prefix
90 assert any(p.item_id.startswith("mix_") for p in playlists)
91 assert any(not p.item_id.startswith("mix_") for p in playlists[:-1])
92
93
94_COLLECTION_CASES = [
95 (MediaType.ARTIST, "userCollectionArtists", "artists"),
96 (MediaType.ALBUM, "userCollectionAlbums", "albums"),
97 (MediaType.TRACK, "userCollectionTracks", "tracks"),
98 (MediaType.PLAYLIST, "userCollectionPlaylists", "playlists"),
99]
100
101
102@pytest.mark.parametrize(("media_type", "collection", "resource_type"), _COLLECTION_CASES)
103async def test_add_item(
104 media_type: MediaType,
105 collection: str,
106 resource_type: str,
107 library_manager: TidalLibraryManager,
108 provider_mock: Mock,
109) -> None:
110 """Test add_item POSTs to the official user collection endpoint."""
111 item = Mock(item_id="123", media_type=media_type)
112 # Only consulted for the TRACK/POST healing path; harmless for other media types.
113 provider_mock.api.write_jsonapi.return_value = {"data": [{"type": resource_type, "id": "123"}]}
114
115 assert await library_manager.add_item(item) is True
116 provider_mock.api.write_jsonapi.assert_called_with(
117 "POST",
118 f"{collection}/me/relationships/items",
119 {"data": [{"type": resource_type, "id": "123"}]},
120 )
121 provider_mock.resolve_live_track_id.assert_not_called()
122
123
124@pytest.mark.parametrize(("media_type", "collection", "resource_type"), _COLLECTION_CASES)
125async def test_remove_item(
126 media_type: MediaType,
127 collection: str,
128 resource_type: str,
129 library_manager: TidalLibraryManager,
130 provider_mock: Mock,
131) -> None:
132 """Test remove_item DELETEs from the official user collection endpoint."""
133 assert await library_manager.remove_item("123", media_type) is True
134 provider_mock.api.write_jsonapi.assert_called_with(
135 "DELETE",
136 f"{collection}/me/relationships/items",
137 {"data": [{"type": resource_type, "id": "123"}]},
138 )
139
140
141async def test_add_mix_strips_prefix(
142 library_manager: TidalLibraryManager, provider_mock: Mock
143) -> None:
144 """Test a mix favorite is written to the playlists collection without the mix_ prefix."""
145 item = Mock(item_id="mix_abc123", media_type=MediaType.PLAYLIST)
146
147 await library_manager.add_item(item)
148 provider_mock.api.write_jsonapi.assert_called_with(
149 "POST",
150 "userCollectionPlaylists/me/relationships/items",
151 {"data": [{"type": "playlists", "id": "abc123"}]},
152 )
153
154
155async def test_add_item_returns_false_on_error(
156 library_manager: TidalLibraryManager, provider_mock: Mock
157) -> None:
158 """Test add_item returns False when the write fails."""
159 provider_mock.api.write_jsonapi.side_effect = ClientError()
160 item = Mock(item_id="123", media_type=MediaType.TRACK)
161
162 assert await library_manager.add_item(item) is False
163
164
165async def test_add_item_track_stale_id_heals(
166 library_manager: TidalLibraryManager, provider_mock: Mock
167) -> None:
168 """Test a favorite track reported NOT_FOUND in meta.skipped is resolved and retried."""
169 provider_mock.api.write_jsonapi.return_value = {
170 "meta": {"skipped": [{"id": "123", "reason": "NOT_FOUND", "type": "tracks"}]}
171 }
172 provider_mock.resolve_live_track_id = AsyncMock(return_value="123_live")
173 item = Mock(item_id="123", media_type=MediaType.TRACK)
174
175 assert await library_manager.add_item(item) is True
176
177 provider_mock.resolve_live_track_id.assert_called_once_with("123")
178 assert provider_mock.api.write_jsonapi.call_count == 2
179 provider_mock.api.write_jsonapi.assert_called_with(
180 "POST",
181 "userCollectionTracks/me/relationships/items",
182 {"data": [{"type": "tracks", "id": "123_live"}]},
183 )
184
185
186async def test_add_item_track_paginated_listing_does_not_false_heal(
187 library_manager: TidalLibraryManager, provider_mock: Mock
188) -> None:
189 """
190 Test a successful add is not misread as stale just because data omits the id.
191
192 The response data is the paginated collection listing, not an accepted-items echo;
193 a newly added track appends at the end and is absent from page one. With an empty
194 meta.skipped, that must not trigger a heal that would add a different ISRC match.
195 """
196 provider_mock.api.write_jsonapi.return_value = {
197 "data": [{"type": "tracks", "id": "999"}],
198 "meta": {"skipped": []},
199 }
200 provider_mock.resolve_live_track_id = AsyncMock(return_value="123_live")
201 item = Mock(item_id="123", media_type=MediaType.TRACK)
202
203 assert await library_manager.add_item(item) is True
204
205 provider_mock.resolve_live_track_id.assert_not_called()
206 assert provider_mock.api.write_jsonapi.call_count == 1
207
208
209async def test_add_item_track_already_present_no_heal(
210 library_manager: TidalLibraryManager, provider_mock: Mock
211) -> None:
212 """Test an ALREADY_PRESENT skip is a success and never triggers healing."""
213 provider_mock.api.write_jsonapi.return_value = {
214 "meta": {"skipped": [{"id": "123", "reason": "ALREADY_PRESENT", "type": "tracks"}]}
215 }
216 provider_mock.resolve_live_track_id = AsyncMock(return_value="123_live")
217 item = Mock(item_id="123", media_type=MediaType.TRACK)
218
219 assert await library_manager.add_item(item) is True
220
221 provider_mock.resolve_live_track_id.assert_not_called()
222 assert provider_mock.api.write_jsonapi.call_count == 1
223
224
225async def test_add_item_track_no_skipped_meta(
226 library_manager: TidalLibraryManager, provider_mock: Mock
227) -> None:
228 """Test a response without a skipped meta (e.g. a 204 body) skips healing entirely."""
229 provider_mock.api.write_jsonapi.return_value = {"success": True}
230 item = Mock(item_id="123", media_type=MediaType.TRACK)
231
232 assert await library_manager.add_item(item) is True
233
234 provider_mock.resolve_live_track_id.assert_not_called()
235 assert provider_mock.api.write_jsonapi.call_count == 1
236
237
238async def test_add_item_track_stale_id_unresolvable(
239 library_manager: TidalLibraryManager, provider_mock: Mock
240) -> None:
241 """Test an unresolvable NOT_FOUND id reports failure: nothing was added."""
242 provider_mock.api.write_jsonapi.return_value = {
243 "meta": {"skipped": [{"id": "123", "reason": "NOT_FOUND", "type": "tracks"}]}
244 }
245 # resolve_live_track_id default (from fixture) already returns None.
246 item = Mock(item_id="123", media_type=MediaType.TRACK)
247
248 assert await library_manager.add_item(item) is False
249
250 provider_mock.resolve_live_track_id.assert_called_once_with("123")
251 assert provider_mock.api.write_jsonapi.call_count == 1
252
253
254async def test_add_item_track_healed_retry_also_skipped(
255 library_manager: TidalLibraryManager, provider_mock: Mock
256) -> None:
257 """Test failure is reported when the healed id is itself rejected as NOT_FOUND."""
258 provider_mock.api.write_jsonapi.side_effect = [
259 {"meta": {"skipped": [{"id": "123", "reason": "NOT_FOUND", "type": "tracks"}]}},
260 {"meta": {"skipped": [{"id": "123_live", "reason": "NOT_FOUND", "type": "tracks"}]}},
261 ]
262 provider_mock.resolve_live_track_id = AsyncMock(return_value="123_live")
263 item = Mock(item_id="123", media_type=MediaType.TRACK)
264
265 assert await library_manager.add_item(item) is False
266
267 assert provider_mock.api.write_jsonapi.call_count == 2
268
269
270async def test_add_item_non_track_no_healing(
271 library_manager: TidalLibraryManager, provider_mock: Mock
272) -> None:
273 """Test a non-track favorite add never consults the healing resolver."""
274 item = Mock(item_id="123", media_type=MediaType.ALBUM)
275
276 assert await library_manager.add_item(item) is True
277
278 provider_mock.resolve_live_track_id.assert_not_called()
279 assert provider_mock.api.write_jsonapi.call_count == 1
280
281
282async def test_remove_item_track_redirects_cached_stale_id(
283 library_manager: TidalLibraryManager, provider_mock: Mock
284) -> None:
285 """
286 Test a track removal maps a cache-known stale id to the live id in the collection.
287
288 A DELETE for a churned id is skipped server-side while looking successful, so the
289 cache-only redirect must rewrite it to the live id actually stored.
290 """
291 provider_mock.redirect_cached_id = AsyncMock(return_value="123_live")
292
293 assert await library_manager.remove_item("123", MediaType.TRACK) is True
294
295 provider_mock.redirect_cached_id.assert_called_once_with("123")
296 provider_mock.api.write_jsonapi.assert_called_with(
297 "DELETE",
298 "userCollectionTracks/me/relationships/items",
299 {"data": [{"type": "tracks", "id": "123_live"}]},
300 )
301
302
303async def test_remove_item_track_no_healing(
304 library_manager: TidalLibraryManager, provider_mock: Mock
305) -> None:
306 """Test a track favorite removal (DELETE) never consults the healing resolver."""
307 assert await library_manager.remove_item("123", MediaType.TRACK) is True
308
309 provider_mock.resolve_live_track_id.assert_not_called()
310 assert provider_mock.api.write_jsonapi.call_count == 1
311
312
313async def test_add_item_unsupported_media_type_returns_false(
314 library_manager: TidalLibraryManager, provider_mock: Mock
315) -> None:
316 """Test add_item returns False for a media type without a collection mapping."""
317 item = Mock(item_id="123", media_type=MediaType.RADIO)
318
319 assert await library_manager.add_item(item) is False
320 provider_mock.api.write_jsonapi.assert_not_called()
321
322
323async def test_remove_mix_strips_prefix(
324 library_manager: TidalLibraryManager, provider_mock: Mock
325) -> None:
326 """Test a mix is removed from the playlists collection without the mix_ prefix."""
327 await library_manager.remove_item("mix_abc123", MediaType.PLAYLIST)
328 provider_mock.api.write_jsonapi.assert_called_with(
329 "DELETE",
330 "userCollectionPlaylists/me/relationships/items",
331 {"data": [{"type": "playlists", "id": "abc123"}]},
332 )
333
334
335async def test_get_playlists_includes_favorite_tracks(
336 library_manager: TidalLibraryManager, provider_mock: Mock
337) -> None:
338 """Test that get_playlists yields the virtual favorite tracks playlist."""
339 empty = JsonApiDocument({"data": [], "included": []})
340
341 async def _pages(*_a: Any, **_k: Any) -> Any:
342 yield empty
343
344 provider_mock.api.paginate_jsonapi = _pages
345
346 playlists = [p async for p in library_manager.get_playlists()]
347
348 assert len(playlists) == 1
349 assert playlists[0].item_id == "favorite_tracks"
350 assert playlists[0].name == "Favorite Tracks"
351 assert not playlists[0].is_editable
352