/
/
/
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
23def _load_raw(name: str) -> dict[str, Any]:
24 with open(FIXTURES_DIR / name) as f:
25 data: dict[str, Any] = json.load(f)
26 return data
27
28
29def _break_resource(raw: dict[str, Any], resource_id: str) -> dict[str, Any]:
30 """Blank the name or title of one included resource so its parser rejects it."""
31 for resource in raw["included"]:
32 if resource["id"] == resource_id:
33 attributes = resource["attributes"]
34 attributes["name" if "name" in attributes else "title"] = None
35 return raw
36 raise AssertionError(f"resource {resource_id} not in fixture")
37
38
39@pytest.fixture
40def library_manager(provider_mock: Mock) -> TidalLibraryManager:
41 """Return a TidalLibraryManager instance."""
42 return TidalLibraryManager(provider_mock)
43
44
45async def test_get_artists(library_manager: TidalLibraryManager, provider_mock: Mock) -> None:
46 """Test library artists read from the official userCollection endpoint."""
47 doc = _load_doc("lib_artists.json")
48
49 async def _pages(*_a: Any, **_k: Any) -> Any:
50 yield doc
51
52 provider_mock.api.paginate_jsonapi = _pages
53
54 artists = [a async for a in library_manager.get_artists()]
55
56 assert len(artists) == 20
57 assert artists[0].date_added is not None # from the addedAt linkage meta
58
59
60async def test_get_albums(library_manager: TidalLibraryManager, provider_mock: Mock) -> None:
61 """Test library albums read from the official userCollection endpoint."""
62 doc = _load_doc("lib_albums.json")
63
64 async def _pages(*_a: Any, **_k: Any) -> Any:
65 yield doc
66
67 provider_mock.api.paginate_jsonapi = _pages
68
69 albums = [a async for a in library_manager.get_albums()]
70
71 assert len(albums) == 20
72 assert albums[0].date_added is not None
73
74
75async def test_get_tracks(library_manager: TidalLibraryManager, provider_mock: Mock) -> None:
76 """Test library tracks read from the official userCollection endpoint."""
77 doc = _load_doc("lib_tracks.json")
78
79 async def _pages(*_a: Any, **_k: Any) -> Any:
80 yield doc
81
82 provider_mock.api.paginate_jsonapi = _pages
83
84 tracks = [t async for t in library_manager.get_tracks()]
85
86 assert len(tracks) == 20
87 assert tracks[0].date_added is not None
88 # every linkage item feeds the churn cache (the read passes replaceMedia)
89 assert provider_mock.note_replaced_track.call_count == 20
90
91
92async def test_get_playlists(library_manager: TidalLibraryManager, provider_mock: Mock) -> None:
93 """Test library playlists include mixes and the virtual favorites playlist."""
94 doc = _load_doc("lib_playlists.json")
95
96 async def _pages(*_a: Any, **_k: Any) -> Any:
97 yield doc
98
99 provider_mock.api.paginate_jsonapi = _pages
100
101 playlists = [p async for p in library_manager.get_playlists()]
102
103 # 19 resolved playlists + the trailing virtual "favorite tracks" playlist
104 assert playlists[-1].item_id == "favorite_tracks"
105 # mixes come through the same collection with the "mix_" item id prefix
106 assert any(p.item_id.startswith("mix_") for p in playlists)
107 assert any(not p.item_id.startswith("mix_") for p in playlists[:-1])
108
109
110_LIBRARY_READ_CASES = [
111 ("get_artists", "lib_artists.json", MediaType.ARTIST, 19),
112 ("get_albums", "lib_albums.json", MediaType.ALBUM, 19),
113 ("get_tracks", "lib_tracks.json", MediaType.TRACK, 19),
114 ("get_playlists", "lib_playlists.json", MediaType.PLAYLIST, 19),
115]
116
117
118@pytest.mark.parametrize(
119 ("method_name", "fixture_name", "media_type", "expected_count"), _LIBRARY_READ_CASES
120)
121async def test_library_listing_skips_unparsable_item(
122 method_name: str,
123 fixture_name: str,
124 media_type: MediaType,
125 expected_count: int,
126 library_manager: TidalLibraryManager,
127 provider_mock: Mock,
128) -> None:
129 """Test one unusable item does not prevent later library items from being yielded."""
130 raw = _load_raw(fixture_name)
131 broken_id = raw["data"][0]["id"]
132 doc = JsonApiDocument(_break_resource(raw, broken_id))
133
134 async def _pages(*_a: Any, **_k: Any) -> Any:
135 yield doc
136
137 provider_mock.api.paginate_jsonapi = _pages
138
139 items = [item async for item in getattr(library_manager, method_name)()]
140
141 assert len(items) == expected_count
142 assert all(item.item_id != broken_id for item in items)
143 provider_mock.logger.warning.assert_not_called()
144 skipped_media_type, skipped_id, err = next(
145 call.args
146 for call in provider_mock.report_skipped_sync_item.call_args_list
147 if call.args[1] == broken_id
148 )
149 assert skipped_media_type == media_type
150 assert skipped_id == broken_id
151 assert isinstance(err, (AttributeError, KeyError, TypeError, ValueError))
152
153
154async def test_library_playlists_report_unparsable_mix_id(
155 library_manager: TidalLibraryManager, provider_mock: Mock
156) -> None:
157 """Test an unusable mix is protected under its prefixed library item ID."""
158 raw = _load_raw("lib_playlists.json")
159 mix = next(
160 resource
161 for resource in raw["included"]
162 if resource["type"] == "playlists" and resource["attributes"].get("playlistType") == "MIX"
163 )
164 broken_id = mix["id"]
165 doc = JsonApiDocument(_break_resource(raw, broken_id))
166
167 async def _pages(*_a: Any, **_k: Any) -> Any:
168 yield doc
169
170 provider_mock.api.paginate_jsonapi = _pages
171
172 playlists = [item async for item in library_manager.get_playlists()]
173
174 assert all(item.item_id != f"mix_{broken_id}" for item in playlists)
175 skipped_media_type, skipped_id, err = provider_mock.report_skipped_sync_item.call_args.args
176 assert skipped_media_type == MediaType.PLAYLIST
177 assert skipped_id == f"mix_{broken_id}"
178 assert isinstance(err, (AttributeError, KeyError, TypeError, ValueError))
179
180
181async def test_library_playlists_hold_cleanup_when_item_type_is_unreadable(
182 library_manager: TidalLibraryManager, provider_mock: Mock
183) -> None:
184 """Test an unidentifiable playlist item holds cleanup without ending the listing."""
185 raw = _load_raw("lib_playlists.json")
186 playlist = next(resource for resource in raw["included"] if resource["type"] == "playlists")
187 playlist["attributes"] = None
188 doc = JsonApiDocument(raw)
189
190 async def _pages(*_a: Any, **_k: Any) -> Any:
191 yield doc
192
193 provider_mock.api.paginate_jsonapi = _pages
194
195 playlists = [item async for item in library_manager.get_playlists()]
196
197 assert len(playlists) == 19
198 skipped_media_type, skipped_id, err = provider_mock.report_skipped_sync_item.call_args.args
199 assert skipped_media_type == MediaType.PLAYLIST
200 assert skipped_id is None
201 assert isinstance(err, AttributeError)
202
203
204async def test_library_tracks_hold_cleanup_for_unparsable_replacement(
205 library_manager: TidalLibraryManager, provider_mock: Mock
206) -> None:
207 """Test an unusable replacement track holds cleanup when its stored ID is ambiguous."""
208 raw = _load_raw("lib_tracks.json")
209 broken_id = raw["data"][0]["id"]
210 raw["data"][0]["meta"]["replacement"] = {
211 "status": "REPLACED",
212 "original": {"type": "tracks", "id": "stale_track_id"},
213 }
214 doc = JsonApiDocument(_break_resource(raw, broken_id))
215
216 async def _pages(*_a: Any, **_k: Any) -> Any:
217 yield doc
218
219 provider_mock.api.paginate_jsonapi = _pages
220
221 tracks = [item async for item in library_manager.get_tracks()]
222
223 assert len(tracks) == 19
224 skipped_media_type, skipped_id, err = provider_mock.report_skipped_sync_item.call_args.args
225 assert skipped_media_type == MediaType.TRACK
226 assert skipped_id is None
227 assert isinstance(err, (AttributeError, KeyError, TypeError, ValueError))
228
229
230async def test_library_tracks_hold_cleanup_for_invalid_replacement_metadata(
231 library_manager: TidalLibraryManager, provider_mock: Mock
232) -> None:
233 """Test invalid replacement metadata cannot abort a track listing."""
234 raw = _load_raw("lib_tracks.json")
235 raw["data"][0]["meta"]["replacement"] = {
236 "status": "REPLACED",
237 "original": "invalid",
238 }
239 doc = JsonApiDocument(raw)
240
241 async def _pages(*_a: Any, **_k: Any) -> Any:
242 yield doc
243
244 provider_mock.api.paginate_jsonapi = _pages
245 provider_mock.note_replaced_track.side_effect = [
246 AttributeError("invalid replacement"),
247 *([None] * 19),
248 ]
249
250 tracks = [track async for track in library_manager.get_tracks()]
251
252 assert len(tracks) == 20
253 skipped_media_type, skipped_id, err = provider_mock.report_skipped_sync_item.call_args.args
254 assert skipped_media_type == MediaType.TRACK
255 assert skipped_id is None
256 assert isinstance(err, AttributeError)
257
258
259@pytest.mark.parametrize(
260 ("method_name", "fixture_name", "media_type", "expected_count", "protect_exact_id"),
261 [
262 ("get_artists", "lib_artists.json", MediaType.ARTIST, 19, True),
263 ("get_albums", "lib_albums.json", MediaType.ALBUM, 19, True),
264 ("get_tracks", "lib_tracks.json", MediaType.TRACK, 19, True),
265 ("get_playlists", "lib_playlists.json", MediaType.PLAYLIST, 19, False),
266 ],
267)
268async def test_library_listing_reports_missing_resource(
269 method_name: str,
270 fixture_name: str,
271 media_type: MediaType,
272 expected_count: int,
273 protect_exact_id: bool,
274 library_manager: TidalLibraryManager,
275 provider_mock: Mock,
276) -> None:
277 """Test a missing included resource does not look like a library deletion."""
278 raw = _load_raw(fixture_name)
279 broken_id = raw["data"][0]["id"]
280 raw["included"] = [
281 resource
282 for resource in raw["included"]
283 if not (resource["type"] == raw["data"][0]["type"] and resource["id"] == broken_id)
284 ]
285 doc = JsonApiDocument(raw)
286
287 async def _pages(*_a: Any, **_k: Any) -> Any:
288 yield doc
289
290 provider_mock.api.paginate_jsonapi = _pages
291
292 items = [item async for item in getattr(library_manager, method_name)()]
293
294 assert len(items) == expected_count
295 skipped_media_type, skipped_id, err = provider_mock.report_skipped_sync_item.call_args.args
296 assert skipped_media_type == media_type
297 assert skipped_id == (broken_id if protect_exact_id else None)
298 assert isinstance(err, ValueError)
299
300
301@pytest.mark.parametrize(
302 ("method_name", "fixture_name", "media_type", "expected_count"), _LIBRARY_READ_CASES[:3]
303)
304async def test_library_listing_holds_cleanup_for_invalid_resource_id(
305 method_name: str,
306 fixture_name: str,
307 media_type: MediaType,
308 expected_count: int,
309 library_manager: TidalLibraryManager,
310 provider_mock: Mock,
311) -> None:
312 """Test an invalid resource ID holds cleanup when its identity is unknown."""
313 raw = _load_raw(fixture_name)
314 original_id = raw["data"][0]["id"]
315 raw["data"][0]["id"] = 123
316 next(resource for resource in raw["included"] if resource["id"] == original_id)["id"] = 123
317 doc = JsonApiDocument(raw)
318
319 async def _pages(*_a: Any, **_k: Any) -> Any:
320 yield doc
321
322 provider_mock.api.paginate_jsonapi = _pages
323
324 items = [item async for item in getattr(library_manager, method_name)()]
325
326 assert len(items) == expected_count
327 skipped_media_type, skipped_id, err = provider_mock.report_skipped_sync_item.call_args.args
328 assert skipped_media_type == media_type
329 assert skipped_id is None
330 assert isinstance(err, (TypeError, ValueError))
331
332
333async def test_library_playlists_hold_cleanup_for_invalid_resource_id(
334 library_manager: TidalLibraryManager,
335 provider_mock: Mock,
336) -> None:
337 """Test an invalid resolved playlist ID holds cleanup."""
338 raw = _load_raw("lib_playlists.json")
339 original_id = raw["data"][0]["id"]
340 raw["data"][0]["id"] = 123
341 next(resource for resource in raw["included"] if resource["id"] == original_id)["id"] = 123
342 doc = JsonApiDocument(raw)
343
344 async def _pages(*_a: Any, **_k: Any) -> Any:
345 yield doc
346
347 provider_mock.api.paginate_jsonapi = _pages
348
349 items = [item async for item in library_manager.get_playlists()]
350
351 assert len(items) == 19
352 skipped_media_type, skipped_id, err = next(
353 call.args
354 for call in provider_mock.report_skipped_sync_item.call_args_list
355 if isinstance(call.args[2], TypeError)
356 )
357 assert skipped_media_type == MediaType.PLAYLIST
358 assert skipped_id is None
359 assert isinstance(err, TypeError)
360
361
362@pytest.mark.parametrize(
363 ("method_name", "fixture_name", "_media_type", "_expected_count"), _LIBRARY_READ_CASES
364)
365async def test_library_listing_ignores_invalid_date_added(
366 method_name: str,
367 fixture_name: str,
368 _media_type: MediaType,
369 _expected_count: int,
370 library_manager: TidalLibraryManager,
371 provider_mock: Mock,
372) -> None:
373 """Test invalid optional date metadata does not abort a library listing."""
374 raw = _load_raw(fixture_name)
375 raw["data"][0]["meta"]["addedAt"] = ["invalid"]
376 doc = JsonApiDocument(raw)
377
378 async def _pages(*_a: Any, **_k: Any) -> Any:
379 yield doc
380
381 provider_mock.api.paginate_jsonapi = _pages
382
383 items = [item async for item in getattr(library_manager, method_name)()]
384
385 assert len(items) == 20
386 assert items[0].date_added is None
387
388
389@pytest.mark.parametrize(
390 ("method_name", "fixture_name", "_media_type", "_expected_count"), _LIBRARY_READ_CASES
391)
392async def test_library_listing_fetch_failure_propagates(
393 method_name: str,
394 fixture_name: str,
395 _media_type: MediaType,
396 _expected_count: int,
397 library_manager: TidalLibraryManager,
398 provider_mock: Mock,
399) -> None:
400 """Test a later page failure aborts the listing instead of returning partial results."""
401
402 async def _pages(*_a: Any, **_k: Any) -> Any:
403 yield _load_doc(fixture_name)
404 raise ClientError("connection lost")
405
406 provider_mock.api.paginate_jsonapi = _pages
407
408 with pytest.raises(ClientError):
409 [item async for item in getattr(library_manager, method_name)()]
410 assert all(
411 not isinstance(call.args[2], ClientError)
412 for call in provider_mock.report_skipped_sync_item.call_args_list
413 )
414
415
416_COLLECTION_CASES = [
417 (MediaType.ARTIST, "userCollectionArtists", "artists"),
418 (MediaType.ALBUM, "userCollectionAlbums", "albums"),
419 (MediaType.TRACK, "userCollectionTracks", "tracks"),
420 (MediaType.PLAYLIST, "userCollectionPlaylists", "playlists"),
421]
422
423
424@pytest.mark.parametrize(("media_type", "collection", "resource_type"), _COLLECTION_CASES)
425async def test_add_item(
426 media_type: MediaType,
427 collection: str,
428 resource_type: str,
429 library_manager: TidalLibraryManager,
430 provider_mock: Mock,
431) -> None:
432 """Test add_item POSTs to the official user collection endpoint."""
433 item = Mock(item_id="123", media_type=media_type)
434 # Only consulted for the TRACK/POST healing path; harmless for other media types.
435 provider_mock.api.write_jsonapi.return_value = {"data": [{"type": resource_type, "id": "123"}]}
436
437 assert await library_manager.add_item(item) is True
438 provider_mock.api.write_jsonapi.assert_called_with(
439 "POST",
440 f"{collection}/me/relationships/items",
441 {"data": [{"type": resource_type, "id": "123"}]},
442 )
443 provider_mock.resolve_live_track_id.assert_not_called()
444
445
446@pytest.mark.parametrize(("media_type", "collection", "resource_type"), _COLLECTION_CASES)
447async def test_remove_item(
448 media_type: MediaType,
449 collection: str,
450 resource_type: str,
451 library_manager: TidalLibraryManager,
452 provider_mock: Mock,
453) -> None:
454 """Test remove_item DELETEs from the official user collection endpoint."""
455 assert await library_manager.remove_item("123", media_type) is True
456 provider_mock.api.write_jsonapi.assert_called_with(
457 "DELETE",
458 f"{collection}/me/relationships/items",
459 {"data": [{"type": resource_type, "id": "123"}]},
460 )
461
462
463async def test_add_mix_strips_prefix(
464 library_manager: TidalLibraryManager, provider_mock: Mock
465) -> None:
466 """Test a mix favorite is written to the playlists collection without the mix_ prefix."""
467 item = Mock(item_id="mix_abc123", media_type=MediaType.PLAYLIST)
468
469 await library_manager.add_item(item)
470 provider_mock.api.write_jsonapi.assert_called_with(
471 "POST",
472 "userCollectionPlaylists/me/relationships/items",
473 {"data": [{"type": "playlists", "id": "abc123"}]},
474 )
475
476
477async def test_add_item_returns_false_on_error(
478 library_manager: TidalLibraryManager, provider_mock: Mock
479) -> None:
480 """Test add_item returns False when the write fails."""
481 provider_mock.api.write_jsonapi.side_effect = ClientError()
482 item = Mock(item_id="123", media_type=MediaType.TRACK)
483
484 assert await library_manager.add_item(item) is False
485
486
487async def test_add_item_track_stale_id_heals(
488 library_manager: TidalLibraryManager, provider_mock: Mock
489) -> None:
490 """Test a favorite track reported NOT_FOUND in meta.skipped is resolved and retried."""
491 provider_mock.api.write_jsonapi.return_value = {
492 "meta": {"skipped": [{"id": "123", "reason": "NOT_FOUND", "type": "tracks"}]}
493 }
494 provider_mock.resolve_live_track_id = AsyncMock(return_value="123_live")
495 item = Mock(item_id="123", media_type=MediaType.TRACK)
496
497 assert await library_manager.add_item(item) is True
498
499 provider_mock.resolve_live_track_id.assert_called_once_with("123")
500 assert provider_mock.api.write_jsonapi.call_count == 2
501 provider_mock.api.write_jsonapi.assert_called_with(
502 "POST",
503 "userCollectionTracks/me/relationships/items",
504 {"data": [{"type": "tracks", "id": "123_live"}]},
505 )
506
507
508async def test_add_item_track_paginated_listing_does_not_false_heal(
509 library_manager: TidalLibraryManager, provider_mock: Mock
510) -> None:
511 """
512 Test a successful add is not misread as stale just because data omits the id.
513
514 The response data is the paginated collection listing, not an accepted-items echo;
515 a newly added track appends at the end and is absent from page one. With an empty
516 meta.skipped, that must not trigger a heal that would add a different ISRC match.
517 """
518 provider_mock.api.write_jsonapi.return_value = {
519 "data": [{"type": "tracks", "id": "999"}],
520 "meta": {"skipped": []},
521 }
522 provider_mock.resolve_live_track_id = AsyncMock(return_value="123_live")
523 item = Mock(item_id="123", media_type=MediaType.TRACK)
524
525 assert await library_manager.add_item(item) is True
526
527 provider_mock.resolve_live_track_id.assert_not_called()
528 assert provider_mock.api.write_jsonapi.call_count == 1
529
530
531async def test_add_item_track_already_present_no_heal(
532 library_manager: TidalLibraryManager, provider_mock: Mock
533) -> None:
534 """Test an ALREADY_PRESENT skip is a success and never triggers healing."""
535 provider_mock.api.write_jsonapi.return_value = {
536 "meta": {"skipped": [{"id": "123", "reason": "ALREADY_PRESENT", "type": "tracks"}]}
537 }
538 provider_mock.resolve_live_track_id = AsyncMock(return_value="123_live")
539 item = Mock(item_id="123", media_type=MediaType.TRACK)
540
541 assert await library_manager.add_item(item) is True
542
543 provider_mock.resolve_live_track_id.assert_not_called()
544 assert provider_mock.api.write_jsonapi.call_count == 1
545
546
547async def test_add_item_track_no_skipped_meta(
548 library_manager: TidalLibraryManager, provider_mock: Mock
549) -> None:
550 """Test a response without a skipped meta (e.g. a 204 body) skips healing entirely."""
551 provider_mock.api.write_jsonapi.return_value = {"success": True}
552 item = Mock(item_id="123", media_type=MediaType.TRACK)
553
554 assert await library_manager.add_item(item) is True
555
556 provider_mock.resolve_live_track_id.assert_not_called()
557 assert provider_mock.api.write_jsonapi.call_count == 1
558
559
560async def test_add_item_track_stale_id_unresolvable(
561 library_manager: TidalLibraryManager, provider_mock: Mock
562) -> None:
563 """Test an unresolvable NOT_FOUND id reports failure: nothing was added."""
564 provider_mock.api.write_jsonapi.return_value = {
565 "meta": {"skipped": [{"id": "123", "reason": "NOT_FOUND", "type": "tracks"}]}
566 }
567 # resolve_live_track_id default (from fixture) already returns None.
568 item = Mock(item_id="123", media_type=MediaType.TRACK)
569
570 assert await library_manager.add_item(item) is False
571
572 provider_mock.resolve_live_track_id.assert_called_once_with("123")
573 assert provider_mock.api.write_jsonapi.call_count == 1
574
575
576async def test_add_item_track_healed_retry_also_skipped(
577 library_manager: TidalLibraryManager, provider_mock: Mock
578) -> None:
579 """Test failure is reported when the healed id is itself rejected as NOT_FOUND."""
580 provider_mock.api.write_jsonapi.side_effect = [
581 {"meta": {"skipped": [{"id": "123", "reason": "NOT_FOUND", "type": "tracks"}]}},
582 {"meta": {"skipped": [{"id": "123_live", "reason": "NOT_FOUND", "type": "tracks"}]}},
583 ]
584 provider_mock.resolve_live_track_id = AsyncMock(return_value="123_live")
585 item = Mock(item_id="123", media_type=MediaType.TRACK)
586
587 assert await library_manager.add_item(item) is False
588
589 assert provider_mock.api.write_jsonapi.call_count == 2
590
591
592async def test_add_item_non_track_no_healing(
593 library_manager: TidalLibraryManager, provider_mock: Mock
594) -> None:
595 """Test a non-track favorite add never consults the healing resolver."""
596 item = Mock(item_id="123", media_type=MediaType.ALBUM)
597
598 assert await library_manager.add_item(item) is True
599
600 provider_mock.resolve_live_track_id.assert_not_called()
601 assert provider_mock.api.write_jsonapi.call_count == 1
602
603
604async def test_remove_item_track_redirects_cached_stale_id(
605 library_manager: TidalLibraryManager, provider_mock: Mock
606) -> None:
607 """
608 Test a track removal maps a cache-known stale id to the live id in the collection.
609
610 A DELETE for a churned id is skipped server-side while looking successful, so the
611 cache-only redirect must rewrite it to the live id actually stored.
612 """
613 provider_mock.redirect_cached_id = AsyncMock(return_value="123_live")
614
615 assert await library_manager.remove_item("123", MediaType.TRACK) is True
616
617 provider_mock.redirect_cached_id.assert_called_once_with("123")
618 provider_mock.api.write_jsonapi.assert_called_with(
619 "DELETE",
620 "userCollectionTracks/me/relationships/items",
621 {"data": [{"type": "tracks", "id": "123_live"}]},
622 )
623
624
625async def test_remove_item_track_no_healing(
626 library_manager: TidalLibraryManager, provider_mock: Mock
627) -> None:
628 """Test a track favorite removal (DELETE) never consults the healing resolver."""
629 assert await library_manager.remove_item("123", MediaType.TRACK) is True
630
631 provider_mock.resolve_live_track_id.assert_not_called()
632 assert provider_mock.api.write_jsonapi.call_count == 1
633
634
635async def test_add_item_unsupported_media_type_returns_false(
636 library_manager: TidalLibraryManager, provider_mock: Mock
637) -> None:
638 """Test add_item returns False for a media type without a collection mapping."""
639 item = Mock(item_id="123", media_type=MediaType.RADIO)
640
641 assert await library_manager.add_item(item) is False
642 provider_mock.api.write_jsonapi.assert_not_called()
643
644
645async def test_remove_mix_strips_prefix(
646 library_manager: TidalLibraryManager, provider_mock: Mock
647) -> None:
648 """Test a mix is removed from the playlists collection without the mix_ prefix."""
649 await library_manager.remove_item("mix_abc123", MediaType.PLAYLIST)
650 provider_mock.api.write_jsonapi.assert_called_with(
651 "DELETE",
652 "userCollectionPlaylists/me/relationships/items",
653 {"data": [{"type": "playlists", "id": "abc123"}]},
654 )
655
656
657async def test_get_playlists_includes_favorite_tracks(
658 library_manager: TidalLibraryManager, provider_mock: Mock
659) -> None:
660 """Test that get_playlists yields the virtual favorite tracks playlist."""
661 empty = JsonApiDocument({"data": [], "included": []})
662
663 async def _pages(*_a: Any, **_k: Any) -> Any:
664 yield empty
665
666 provider_mock.api.paginate_jsonapi = _pages
667
668 playlists = [p async for p in library_manager.get_playlists()]
669
670 assert len(playlists) == 1
671 assert playlists[0].item_id == "favorite_tracks"
672 assert playlists[0].name == "Favorite Tracks"
673 assert not playlists[0].is_editable
674