/
/
/
1"""Test Tidal Media Manager."""
2
3import json
4import pathlib
5from typing import Any, cast
6from unittest.mock import Mock, patch
7
8import pytest
9from aiohttp.client_exceptions import ClientError
10from music_assistant_models.enums import MediaType
11from music_assistant_models.errors import (
12 LoginFailed,
13 MediaNotFoundError,
14 RateLimited,
15 ResourceTemporarilyUnavailable,
16 RetriesExhausted,
17)
18from music_assistant_models.media_items import Playlist
19
20from music_assistant.providers.tidal.jsonapi import JsonApiDocument
21from music_assistant.providers.tidal.media import TidalMediaManager
22
23FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" / "v2"
24LEGACY_FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures"
25
26
27def _load_raw(name: str) -> dict[str, Any]:
28 with open(FIXTURES_DIR / name) as f:
29 data: dict[str, Any] = json.load(f)
30 return data
31
32
33def _load_doc(name: str) -> JsonApiDocument:
34 return JsonApiDocument(_load_raw(name))
35
36
37def _break_resource(raw: dict[str, Any], resource_id: str) -> dict[str, Any]:
38 """Blank the title of one included resource, which its parser cannot handle."""
39 for resource in raw["included"]:
40 if resource["id"] == resource_id:
41 attributes = resource["attributes"]
42 # artists carry a name where tracks and albums carry a title
43 attributes["name" if "name" in attributes else "title"] = None
44 return raw
45 raise AssertionError(f"resource {resource_id} not in fixture")
46
47
48def _corrupt_resource(
49 raw: dict[str, Any], resource_id: str, attribute: str | None, value: Any
50) -> dict[str, Any]:
51 """Corrupt one included resource in a fixture."""
52 for resource in raw["included"]:
53 if resource["id"] != resource_id:
54 continue
55 if attribute is None:
56 resource["attributes"] = value
57 else:
58 resource["attributes"][attribute] = value
59 return raw
60 raise AssertionError(f"resource {resource_id} not in fixture")
61
62
63async def test_search(media_manager: TidalMediaManager, provider_mock: Mock) -> None:
64 """Test search reads all types from the official searchResults endpoint."""
65 provider_mock.api.get_jsonapi.return_value = _load_doc("search.json")
66
67 results = await media_manager.search(
68 "lukas graham",
69 [MediaType.ARTIST, MediaType.ALBUM, MediaType.TRACK, MediaType.PLAYLIST],
70 limit=5,
71 )
72
73 # 20 of each in the fixture, capped to the limit
74 assert len(results.tracks) == 5
75 assert len(results.albums) == 5
76 assert len(results.artists) == 5
77 assert len(results.playlists) == 5
78 assert results.tracks[0].name == "7 Years"
79 first_playlist = results.playlists[0]
80 assert isinstance(first_playlist, Playlist)
81 assert first_playlist.is_editable is False
82
83 provider_mock.api.get_jsonapi.assert_called_with(
84 "searchResults",
85 params={"filter[query]": "lukas graham"},
86 include=[
87 "tracks.artists",
88 "tracks.albums.coverArt",
89 "albums.coverArt",
90 "artists.profileArt",
91 "playlists.coverArt",
92 ],
93 )
94
95
96async def test_search_single_type_passes_query_verbatim(
97 media_manager: TidalMediaManager, provider_mock: Mock
98) -> None:
99 """Test search requests only the includes for the wanted types, query as a filter."""
100 provider_mock.api.get_jsonapi.return_value = _load_doc("search.json")
101
102 results = await media_manager.search("AC/DC", [MediaType.ARTIST])
103
104 assert results.tracks == []
105 provider_mock.api.get_jsonapi.assert_called_with(
106 "searchResults", params={"filter[query]": "AC/DC"}, include=["artists.profileArt"]
107 )
108
109
110async def test_search_no_types_short_circuits(
111 media_manager: TidalMediaManager, provider_mock: Mock
112) -> None:
113 """Test search with no supported media types does not hit the API."""
114 results = await media_manager.search("x", [])
115
116 assert results.tracks == []
117 provider_mock.api.get_jsonapi.assert_not_called()
118
119
120@patch("music_assistant.providers.tidal.media.parse_artist_v2")
121async def test_get_artist(
122 mock_parse_artist: Mock, media_manager: TidalMediaManager, provider_mock: Mock
123) -> None:
124 """Test get_artist uses the official JSON:API endpoint."""
125 doc = Mock()
126 provider_mock.api.get_jsonapi.return_value = doc
127 mock_parse_artist.return_value = Mock(item_id="1")
128
129 artist = await media_manager.get_artist("1")
130
131 assert artist.item_id == "1"
132 provider_mock.api.get_jsonapi.assert_called_with(
133 "artists/1", include=["profileArt", "biography"]
134 )
135 mock_parse_artist.assert_called_once_with(provider_mock, doc, doc.data)
136
137
138@patch("music_assistant.providers.tidal.media.parse_album_v2")
139async def test_get_album(
140 mock_parse_album: Mock, media_manager: TidalMediaManager, provider_mock: Mock
141) -> None:
142 """Test get_album uses the official JSON:API endpoint."""
143 doc = Mock()
144 provider_mock.api.get_jsonapi.return_value = doc
145 mock_parse_album.return_value = Mock(item_id="1")
146
147 album = await media_manager.get_album("1")
148
149 assert album.item_id == "1"
150 provider_mock.api.get_jsonapi.assert_called_with(
151 "albums/1", include=["artists", "coverArt", "genres"]
152 )
153 mock_parse_album.assert_called_once_with(provider_mock, doc, doc.data)
154
155
156@patch("music_assistant.providers.tidal.media.parse_track_v2")
157async def test_get_track(
158 mock_parse_track: Mock, media_manager: TidalMediaManager, provider_mock: Mock
159) -> None:
160 """Test get_track fetches the official track and applies unofficial lyrics."""
161 doc = Mock()
162 provider_mock.api.get_jsonapi.return_value = doc
163 track = Mock(item_id="1")
164 track.metadata = Mock()
165 mock_parse_track.return_value = track
166 provider_mock.api.get.return_value = {"lyrics": "Test Lyrics", "subtitles": "Synced"}
167
168 result = await media_manager.get_track("1")
169
170 assert result.item_id == "1"
171 provider_mock.api.get_jsonapi.assert_called_with(
172 "tracks/1", include=["artists", "albums", "albums.coverArt", "genres", "credits"]
173 )
174 provider_mock.api.get.assert_called_with("tracks/1/lyrics")
175 assert track.metadata.lyrics == "Test Lyrics"
176 assert track.metadata.lrc_lyrics == "Synced"
177
178
179@patch("music_assistant.providers.tidal.media.parse_track_v2")
180async def test_get_track_tolerates_lyrics_failure(
181 mock_parse_track: Mock, media_manager: TidalMediaManager, provider_mock: Mock
182) -> None:
183 """Test get_track still returns the track when the lyrics lookup fails."""
184 doc = Mock()
185 provider_mock.api.get_jsonapi.return_value = doc
186 track = Mock(item_id="1")
187 mock_parse_track.return_value = track
188 provider_mock.api.get.side_effect = RetriesExhausted("lyrics lookup failed")
189
190 result = await media_manager.get_track("1")
191
192 assert result.item_id == "1"
193 mock_parse_track.assert_called_once_with(provider_mock, doc, doc.data)
194
195
196async def test_get_album_tracks(media_manager: TidalMediaManager, provider_mock: Mock) -> None:
197 """Test album tracks read from the official relationship endpoint with ordering."""
198 doc = _load_doc("album_items.json")
199
200 async def _pages(*_a: Any, **_k: Any) -> Any:
201 yield doc
202
203 provider_mock.api.paginate_jsonapi = _pages
204
205 tracks = await media_manager.get_album_tracks("58756127")
206
207 assert len(tracks) == 11
208 assert tracks[0].name == "7 Years"
209 assert tracks[0].track_number == 1
210 assert tracks[0].disc_number == 1
211 assert tracks[0].album is not None
212
213
214async def test_get_album_tracks_skips_videos(
215 media_manager: TidalMediaManager, provider_mock: Mock
216) -> None:
217 """Test a music video in the items relationship is not parsed as a track."""
218 raw = _load_raw("album_items.json")
219 raw["data"].insert(
220 0, {"id": "99999", "type": "videos", "meta": {"volumeNumber": 1, "trackNumber": 1}}
221 )
222 raw["included"].append(
223 {"id": "99999", "type": "videos", "attributes": {"title": "Bonus Video"}}
224 )
225 doc = JsonApiDocument(raw)
226
227 async def _pages(*_a: Any, **_k: Any) -> Any:
228 yield doc
229
230 provider_mock.api.paginate_jsonapi = _pages
231
232 tracks = await media_manager.get_album_tracks("58756127")
233
234 assert len(tracks) == 11
235 assert all(track.item_id != "99999" for track in tracks)
236
237
238@pytest.mark.parametrize(
239 ("attribute", "value"),
240 [
241 (None, None),
242 ("copyright", "invalid"),
243 ("externalLinks", ["invalid"]),
244 ],
245 ids=["null-attributes", "string-copyright", "string-external-link"],
246)
247async def test_get_album_tracks_skips_unparsable_track(
248 attribute: str | None, value: Any, media_manager: TidalMediaManager, provider_mock: Mock
249) -> None:
250 """Test one unusable track does not take the rest of the album down with it."""
251 doc = JsonApiDocument(
252 _corrupt_resource(_load_raw("album_items.json"), "58756128", attribute, value)
253 )
254
255 async def _pages(*_a: Any, **_k: Any) -> Any:
256 yield doc
257
258 provider_mock.api.paginate_jsonapi = _pages
259
260 tracks = await media_manager.get_album_tracks("58756127")
261
262 assert len(tracks) == 10
263 assert all(track.item_id != "58756128" for track in tracks)
264 provider_mock.logger.warning.assert_called_once()
265
266
267def test_process_tracks_skips_track_with_invalid_media_metadata(
268 media_manager: TidalMediaManager, provider_mock: Mock
269) -> None:
270 """Test a malformed playlist track is skipped without dropping valid siblings."""
271 with open(LEGACY_FIXTURES_DIR / "tracks" / "track.json", encoding="utf-8") as f:
272 malformed = json.load(f)
273 malformed["item"]["mediaMetadata"] = "invalid"
274 valid = {
275 "id": 2,
276 "title": "Valid Track",
277 "duration": 120,
278 "artists": [{"id": 2, "name": "Valid Artist"}],
279 }
280
281 tracks = media_manager._process_tracks([malformed, valid], 0)
282
283 assert [track.item_id for track in tracks] == ["2"]
284 provider_mock.logger.warning.assert_called_once()
285
286
287def test_process_tracks_skips_track_with_invalid_item_wrapper(
288 media_manager: TidalMediaManager, provider_mock: Mock
289) -> None:
290 """Test logging a malformed track wrapper does not abort the collection."""
291 valid = {
292 "id": 2,
293 "title": "Valid Track",
294 "duration": 120,
295 "artists": [{"id": 2, "name": "Valid Artist"}],
296 }
297
298 tracks = media_manager._process_tracks([{"item": "invalid"}, valid], 0)
299
300 assert [track.item_id for track in tracks] == ["2"]
301 provider_mock.logger.warning.assert_called_once()
302
303
304def test_process_tracks_skips_non_mapping_item(
305 media_manager: TidalMediaManager, provider_mock: Mock
306) -> None:
307 """Test logging a non-mapping track does not abort the collection."""
308 invalid = cast("dict[str, Any]", None)
309 valid = {
310 "id": 2,
311 "title": "Valid Track",
312 "duration": 120,
313 "artists": [{"id": 2, "name": "Valid Artist"}],
314 }
315
316 tracks = media_manager._process_tracks([invalid, valid], 0)
317
318 assert [track.item_id for track in tracks] == ["2"]
319 provider_mock.logger.warning.assert_called_once()
320
321
322@pytest.mark.parametrize(
323 "error",
324 [
325 ClientError("connection lost"),
326 LoginFailed("login failed"),
327 RateLimited("rate limited", backoff_time=1),
328 ResourceTemporarilyUnavailable("temporarily unavailable"),
329 MediaNotFoundError("not found"),
330 ],
331 ids=lambda error: type(error).__name__,
332)
333@patch("music_assistant.providers.tidal.media.parse_track")
334def test_process_tracks_propagates_fetch_errors(
335 mock_parse_track: Mock,
336 error: Exception,
337 media_manager: TidalMediaManager,
338) -> None:
339 """Test fetch-level errors are never treated as skippable item errors."""
340 mock_parse_track.side_effect = error
341
342 with pytest.raises(type(error)):
343 media_manager._process_tracks([{"id": "1"}], 0)
344
345
346async def test_get_album_tracks_keeps_tracks_from_earlier_pages(
347 media_manager: TidalMediaManager, provider_mock: Mock
348) -> None:
349 """Test an unusable track on a later page does not discard the earlier pages."""
350 first_page = _load_doc("album_items.json")
351 second_page = JsonApiDocument(
352 {
353 "data": [
354 {"id": "998", "type": "tracks", "meta": {"volumeNumber": 1, "trackNumber": 12}},
355 {"id": "999", "type": "tracks", "meta": {"volumeNumber": 1, "trackNumber": 13}},
356 ],
357 "included": [
358 {"id": "998", "type": "tracks", "attributes": {"title": None}},
359 {
360 "id": "999",
361 "type": "tracks",
362 "attributes": {"title": "Hidden Track", "duration": "PT3M0S"},
363 },
364 ],
365 }
366 )
367
368 async def _pages(*_a: Any, **_k: Any) -> Any:
369 yield first_page
370 yield second_page
371
372 provider_mock.api.paginate_jsonapi = _pages
373
374 tracks = await media_manager.get_album_tracks("58756127")
375
376 assert len(tracks) == 12
377 assert tracks[0].name == "7 Years"
378 assert tracks[-1].name == "Hidden Track"
379 assert tracks[-1].track_number == 13
380
381
382async def test_get_album_tracks_fetch_failure_propagates(
383 media_manager: TidalMediaManager, provider_mock: Mock
384) -> None:
385 """Test a failure to read a page is raised instead of returning a partial album."""
386
387 async def _pages(*_a: Any, **_k: Any) -> Any:
388 yield _load_doc("album_items.json")
389 raise ClientError("connection lost")
390
391 provider_mock.api.paginate_jsonapi = _pages
392
393 with pytest.raises(ClientError):
394 await media_manager.get_album_tracks("58756127")
395
396
397async def test_get_album_tracks_missing_album_still_not_found(
398 media_manager: TidalMediaManager, provider_mock: Mock
399) -> None:
400 """Test an album that is gone is still reported as not found."""
401
402 async def _pages(*_a: Any, **_k: Any) -> Any:
403 for _ in (): # never yields: the api client raises while fetching the first page
404 yield
405 raise MediaNotFoundError("Item not found: albums/1/relationships/items")
406
407 provider_mock.api.paginate_jsonapi = _pages
408
409 with pytest.raises(MediaNotFoundError):
410 await media_manager.get_album_tracks("1")
411
412
413async def test_get_artist_albums(media_manager: TidalMediaManager, provider_mock: Mock) -> None:
414 """Test artist albums read from the official relationship endpoint."""
415 doc = _load_doc("artist_albums.json")
416
417 async def _pages(*_a: Any, **_k: Any) -> Any:
418 yield doc
419
420 provider_mock.api.paginate_jsonapi = _pages
421
422 albums = await media_manager.get_artist_albums("4184211")
423
424 assert len(albums) == 20
425 assert all(album.item_id for album in albums)
426
427
428async def test_get_artist_toptracks(media_manager: TidalMediaManager, provider_mock: Mock) -> None:
429 """Test artist top tracks read from the official relationship endpoint."""
430 provider_mock.api.get_jsonapi.return_value = _load_doc("artist_toptracks.json")
431
432 tracks = await media_manager.get_artist_toptracks("4184211")
433
434 assert len(tracks) == 20
435 provider_mock.api.get_jsonapi.assert_called_with(
436 "artists/4184211/relationships/tracks",
437 params={"collapseBy": "FINGERPRINT"},
438 include=["tracks.artists", "tracks.albums.coverArt"],
439 replace_media="tracks",
440 )
441
442
443async def test_get_artist_albums_skips_unparsable_album(
444 media_manager: TidalMediaManager, provider_mock: Mock
445) -> None:
446 """Test one unusable album does not empty the artist's discography."""
447 doc = JsonApiDocument(_break_resource(_load_raw("artist_albums.json"), "541774424"))
448
449 async def _pages(*_a: Any, **_k: Any) -> Any:
450 yield doc
451
452 provider_mock.api.paginate_jsonapi = _pages
453
454 albums = await media_manager.get_artist_albums("4184211")
455
456 assert len(albums) == 19
457 assert all(album.item_id != "541774424" for album in albums)
458
459
460async def test_get_artist_toptracks_skips_unparsable_track(
461 media_manager: TidalMediaManager, provider_mock: Mock
462) -> None:
463 """Test one unusable track does not empty the artist's top tracks."""
464 provider_mock.api.get_jsonapi.return_value = JsonApiDocument(
465 _break_resource(_load_raw("artist_toptracks.json"), "58503071")
466 )
467
468 tracks = await media_manager.get_artist_toptracks("4184211")
469
470 assert len(tracks) == 19
471 assert all(track.item_id != "58503071" for track in tracks)
472
473
474async def test_get_artist_toptracks_fetch_failure_propagates(
475 media_manager: TidalMediaManager, provider_mock: Mock
476) -> None:
477 """Test a failure to read the top tracks is raised instead of reported as empty."""
478 provider_mock.api.get_jsonapi.side_effect = ClientError("connection lost")
479
480 with pytest.raises(ClientError):
481 await media_manager.get_artist_toptracks("4184211")
482
483
484async def test_get_similar_tracks_respects_limit(
485 media_manager: TidalMediaManager, provider_mock: Mock
486) -> None:
487 """Test similar tracks are read from the official endpoint and capped to the limit."""
488 provider_mock.api.get_jsonapi.return_value = _load_doc("similar_tracks.json")
489
490 tracks = await media_manager.get_similar_tracks("58756128", limit=5)
491
492 assert len(tracks) == 5
493
494
495async def test_get_similar_tracks_fills_the_limit_around_an_unparsable_track(
496 media_manager: TidalMediaManager, provider_mock: Mock
497) -> None:
498 """Test the limit is still filled when one of the similar tracks cannot be parsed."""
499 raw = _load_raw("similar_tracks.json")
500 provider_mock.api.get_jsonapi.return_value = JsonApiDocument(
501 _break_resource(raw, raw["data"][0]["id"])
502 )
503
504 tracks = await media_manager.get_similar_tracks("58756128", limit=5)
505
506 assert len(tracks) == 5
507 assert all(track.item_id != raw["data"][0]["id"] for track in tracks)
508
509
510async def test_get_similar_artists(media_manager: TidalMediaManager, provider_mock: Mock) -> None:
511 """Test similar artists read from the official relationship endpoint."""
512 provider_mock.api.get_jsonapi.return_value = _load_doc("similar_artists.json")
513
514 artists = await media_manager.get_similar_artists("4184211")
515
516 assert len(artists) == 20
517 provider_mock.api.get_jsonapi.assert_called_with(
518 "artists/4184211/relationships/similarArtists",
519 include=["similarArtists.profileArt"],
520 )
521
522
523async def test_get_similar_artists_skips_unparsable_artist(
524 media_manager: TidalMediaManager, provider_mock: Mock
525) -> None:
526 """Test one unusable artist does not empty the similar artists."""
527 raw = _load_raw("similar_artists.json")
528 provider_mock.api.get_jsonapi.return_value = JsonApiDocument(
529 _break_resource(raw, raw["data"][0]["id"])
530 )
531
532 artists = await media_manager.get_similar_artists("4184211")
533
534 assert len(artists) == 19
535 assert all(artist.item_id != raw["data"][0]["id"] for artist in artists)
536
537
538async def test_search_skips_unparsable_track(
539 media_manager: TidalMediaManager, provider_mock: Mock
540) -> None:
541 """Test one unusable track does not drop the whole search result."""
542 raw = _load_raw("search.json")
543 broken_id = raw["data"][0]["relationships"]["tracks"]["data"][0]["id"]
544 provider_mock.api.get_jsonapi.return_value = JsonApiDocument(_break_resource(raw, broken_id))
545
546 results = await media_manager.search("lukas graham", [MediaType.TRACK], limit=5)
547
548 assert len(results.tracks) == 4
549 assert all(track.item_id != broken_id for track in results.tracks)
550
551
552@patch("music_assistant.providers.tidal.media.parse_track_v2")
553async def test_favorite_tracks_skips_unparsable_track(
554 mock_parse_track: Mock, media_manager: TidalMediaManager, provider_mock: Mock
555) -> None:
556 """Test one unusable favourite does not fail the whole favourites playlist."""
557 doc = JsonApiDocument(
558 {
559 "data": [
560 {"type": "tracks", "id": "1"},
561 {"type": "tracks", "id": "2"},
562 {"type": "tracks", "id": "3"},
563 ],
564 "included": [
565 {"type": "tracks", "id": "1", "attributes": {}},
566 {"type": "tracks", "id": "2", "attributes": {}},
567 {"type": "tracks", "id": "3", "attributes": {}},
568 ],
569 }
570 )
571
572 async def _pages(*_a: Any, **_k: Any) -> Any:
573 yield doc
574
575 provider_mock.api.paginate_jsonapi = _pages
576 mock_parse_track.side_effect = [Mock(item_id="1"), KeyError("id"), Mock(item_id="3")]
577
578 tracks = await media_manager.get_playlist_tracks("favorite_tracks", page=0)
579
580 assert [track.item_id for track in tracks] == ["1", "3"]
581 assert [track.position for track in tracks] == [1, 2]
582
583
584@patch("music_assistant.providers.tidal.media.parse_playlist")
585async def test_get_playlist(
586 mock_parse_playlist: Mock, media_manager: TidalMediaManager, provider_mock: Mock
587) -> None:
588 """Test get_playlist."""
589 provider_mock.api.get.return_value = {"uuid": "1", "title": "Test Playlist"}
590 mock_parse_playlist.return_value = Mock(item_id="1")
591
592 playlist = await media_manager.get_playlist("1")
593
594 assert playlist.item_id == "1"
595 provider_mock.api.get.assert_called_with("playlists/1")
596 mock_parse_playlist.assert_called_once()
597
598
599@patch("music_assistant.providers.tidal.media.parse_track")
600async def test_get_playlist_tracks(
601 mock_parse_track: Mock, media_manager: TidalMediaManager, provider_mock: Mock
602) -> None:
603 """Test get_playlist_tracks."""
604 provider_mock.api.get.return_value = {"items": [{"id": 1}]}
605 mock_parse_track.return_value = Mock(item_id="1")
606
607 tracks = await media_manager.get_playlist_tracks("1")
608
609 assert len(tracks) == 1
610 assert tracks[0].item_id == "1"
611 provider_mock.api.get.assert_called_with(
612 "playlists/1/tracks",
613 params={"limit": 200, "offset": 0},
614 )
615
616
617async def test_get_playlist_favorite_tracks(
618 media_manager: TidalMediaManager, provider_mock: Mock
619) -> None:
620 """Test get_playlist returns the virtual favorite tracks playlist without an API call."""
621 playlist = await media_manager.get_playlist("favorite_tracks")
622
623 assert playlist.item_id == "favorite_tracks"
624 assert playlist.name == "Favorite Tracks"
625 assert not playlist.is_editable
626 provider_mock.api.get.assert_not_called()
627
628
629@patch("music_assistant.providers.tidal.media.parse_track_v2")
630async def test_get_playlist_tracks_favorite_tracks(
631 mock_parse_track: Mock, media_manager: TidalMediaManager, provider_mock: Mock
632) -> None:
633 """Test get_playlist_tracks walks the official collection with sequential positions."""
634 doc = JsonApiDocument(
635 {
636 "data": [
637 {"type": "tracks", "id": "1"},
638 {"type": "tracks", "id": "2"},
639 ],
640 "included": [
641 {"type": "tracks", "id": "1", "attributes": {}},
642 {"type": "tracks", "id": "2", "attributes": {}},
643 ],
644 }
645 )
646
647 async def _pages(*_a: Any, **_k: Any) -> Any:
648 yield doc
649
650 provider_mock.api.paginate_jsonapi = _pages
651 mock_parse_track.side_effect = [Mock(item_id="1"), Mock(item_id="2")]
652
653 tracks = await media_manager.get_playlist_tracks("favorite_tracks", page=0)
654
655 assert len(tracks) == 2
656 assert tracks[0].item_id == "1"
657 assert tracks[0].position == 1
658 assert tracks[1].item_id == "2"
659 assert tracks[1].position == 2
660
661
662@patch("music_assistant.providers.tidal.media.parse_track_v2")
663async def test_favorite_tracks_walk_feeds_churn_cache(
664 mock_parse_track: Mock, media_manager: TidalMediaManager, provider_mock: Mock
665) -> None:
666 """
667 Test the favourites walk hands each linkage item to note_replaced_track.
668
669 The read is made with replaceMedia, so Tidal already computed any stale->live
670 replacement pairs; discarding them would waste the largest healing source.
671 """
672 items = [{"type": "tracks", "id": "1"}, {"type": "tracks", "id": "2"}]
673 doc = JsonApiDocument(
674 {
675 "data": items,
676 "included": [
677 {"type": "tracks", "id": "1", "attributes": {}},
678 {"type": "tracks", "id": "2", "attributes": {}},
679 ],
680 }
681 )
682
683 async def _pages(*_a: Any, **_k: Any) -> Any:
684 yield doc
685
686 provider_mock.api.paginate_jsonapi = _pages
687 mock_parse_track.side_effect = [Mock(item_id="1"), Mock(item_id="2")]
688
689 await media_manager.get_playlist_tracks("favorite_tracks", page=0)
690
691 assert provider_mock.note_replaced_track.call_count == 2
692 provider_mock.note_replaced_track.assert_any_call(items[0])
693 provider_mock.note_replaced_track.assert_any_call(items[1])
694
695
696async def test_get_playlist_tracks_favorite_tracks_later_page(
697 media_manager: TidalMediaManager, provider_mock: Mock
698) -> None:
699 """Test get_playlist_tracks returns nothing for pages after the first, without a fetch."""
700 provider_mock.api.paginate_jsonapi = Mock()
701
702 tracks = await media_manager.get_playlist_tracks("favorite_tracks", page=1)
703
704 assert tracks == []
705 provider_mock.api.paginate_jsonapi.assert_not_called()
706