/
/
1"""Additional tests for Tidal Media Manager - Mix operations and similar tracks."""
2
3from unittest.mock import Mock, patch
4
5import pytest
6from music_assistant_models.enums import MediaType
7from music_assistant_models.errors import MediaNotFoundError
8
9from music_assistant.providers.tidal.jsonapi import JsonApiDocument
10from music_assistant.providers.tidal.media import TidalMediaManager
11
12
13@patch("music_assistant.providers.tidal.media.parse_playlist")
14async def test_get_playlist_mix(
15 mock_parse_playlist: Mock, media_manager: TidalMediaManager, provider_mock: Mock
16) -> None:
17 """Test get_playlist with mix ID."""
18 provider_mock.api.get.return_value = {
19 "title": "My Mix",
20 "rows": [
21 {"modules": [{"mix": {"images": {"MEDIUM": {"url": "http://example.com/mix.jpg"}}}}]},
22 ],
23 "lastUpdated": "2023-01-01",
24 }
25 mock_parse_playlist.return_value = Mock(item_id="mix_123")
26
27 playlist = await media_manager.get_playlist("mix_123")
28
29 assert playlist.item_id == "mix_123"
30 provider_mock.api.get.assert_called_with(
31 "pages/mix",
32 params={"mixId": "123", "deviceType": "BROWSER"},
33 )
34 mock_parse_playlist.assert_called_once()
35 # Verify is_mix=True was passed
36 assert mock_parse_playlist.call_args[1]["is_mix"] is True
37
38
39@patch("music_assistant.providers.tidal.media.parse_playlist")
40async def test_get_playlist_fallback_to_mix(
41 mock_parse_playlist: Mock, media_manager: TidalMediaManager, provider_mock: Mock
42) -> None:
43 """Test get_playlist falls back to mix lookup on MediaNotFoundError."""
44 # First call raises error, second succeeds
45 provider_mock.api.get.side_effect = [
46 MediaNotFoundError("Playlist not found"),
47 {
48 "title": "My Mix",
49 "rows": [{"modules": [{"mix": {"images": {}}}]}],
50 },
51 ]
52 mock_parse_playlist.return_value = Mock(item_id="123")
53
54 playlist = await media_manager.get_playlist("123")
55
56 assert playlist.item_id == "123"
57 assert provider_mock.api.get.call_count == 2
58 # First call as playlist
59 provider_mock.api.get.assert_any_call("playlists/123")
60 # Second call as mix
61 provider_mock.api.get.assert_any_call(
62 "pages/mix",
63 params={"mixId": "123", "deviceType": "BROWSER"},
64 )
65
66
67@patch("music_assistant.providers.tidal.media.parse_track_v2")
68async def test_get_similar_tracks(
69 mock_parse_track: Mock, media_manager: TidalMediaManager, provider_mock: Mock
70) -> None:
71 """Test get_similar_tracks reads from the official relationship endpoint."""
72 doc = JsonApiDocument(
73 {
74 "data": [{"type": "tracks", "id": str(i)} for i in range(10)],
75 "included": [{"type": "tracks", "id": str(i), "attributes": {}} for i in range(10)],
76 }
77 )
78 provider_mock.api.get_jsonapi.return_value = doc
79 mock_parse_track.return_value = Mock(item_id="1")
80
81 tracks = await media_manager.get_similar_tracks("123", limit=3)
82
83 assert len(tracks) == 3
84 provider_mock.api.get_jsonapi.assert_called_with(
85 "tracks/123/relationships/similarTracks",
86 include=["similarTracks.artists", "similarTracks.albums.coverArt"],
87 replace_media="similarTracks",
88 )
89
90
91@patch("music_assistant.providers.tidal.media.parse_track")
92async def test_get_playlist_tracks_mix(
93 mock_parse_track: Mock, media_manager: TidalMediaManager, provider_mock: Mock
94) -> None:
95 """Test get_playlist_tracks with mix ID."""
96 provider_mock.api.get.return_value = {
97 "rows": [
98 {}, # First row is mix info
99 { # Second row has tracks
100 "modules": [{"pagedList": {"items": [{"id": 1}, {"id": 2}]}}]
101 },
102 ]
103 }
104
105 # Mock track with position attribute
106 def create_track(item_id: int, position: int) -> Mock:
107 track = Mock(item_id=str(item_id))
108 track.position = position
109 return track
110
111 mock_parse_track.side_effect = [
112 create_track(1, 1),
113 create_track(2, 2),
114 ]
115
116 tracks = await media_manager.get_playlist_tracks("mix_123")
117
118 assert len(tracks) == 2
119 assert tracks[0].position == 1
120 assert tracks[1].position == 2
121 provider_mock.api.get.assert_called_with(
122 "pages/mix",
123 params={"mixId": "123", "deviceType": "BROWSER"},
124 )
125
126
127async def test_get_mix_details_no_rows(
128 media_manager: TidalMediaManager, provider_mock: Mock
129) -> None:
130 """Test _get_mix_details raises error when no rows."""
131 provider_mock.api.get.return_value = {"rows": []}
132
133 with pytest.raises(MediaNotFoundError, match="Mix 123 has no tracks"):
134 await media_manager.get_playlist_tracks("mix_123")
135
136
137@patch("music_assistant.providers.tidal.media.parse_track")
138@patch("music_assistant.providers.tidal.media.parse_playlist")
139async def test_mix_feed_fetched_once(
140 mock_parse_playlist: Mock,
141 mock_parse_track: Mock,
142 media_manager: TidalMediaManager,
143 provider_mock: Mock,
144) -> None:
145 """Test opening a mix (details then tracks) fetches the shared pages/mix feed once."""
146 feed = {
147 "title": "My Mix",
148 "rows": [
149 {"modules": [{"mix": {"images": {}}}]},
150 {"modules": [{"pagedList": {"items": [{"id": 1}, {"id": 2}]}}]},
151 ],
152 }
153 # Back the cache with a real dict so the second fetch is a hit.
154 store: dict[str, object] = {}
155
156 async def _get(key: str, **_kw: object) -> object:
157 return store.get(key)
158
159 async def _set(key: str, data: object, **_kw: object) -> None:
160 store[key] = data
161
162 provider_mock.mass.cache.get = _get
163 provider_mock.mass.cache.set = _set
164 provider_mock.api.get.return_value = feed
165 mock_parse_playlist.return_value = Mock(item_id="mix_123")
166 mock_parse_track.side_effect = [Mock(item_id="1"), Mock(item_id="2")]
167
168 await media_manager.get_playlist("mix_123")
169 await media_manager.get_playlist_tracks("mix_123")
170
171 provider_mock.api.get.assert_called_once()
172
173
174@patch("music_assistant.providers.tidal.media.parse_track")
175@patch("music_assistant.providers.tidal.media.parse_playlist")
176async def test_mix_modules_found_regardless_of_row_order(
177 mock_parse_playlist: Mock,
178 mock_parse_track: Mock,
179 media_manager: TidalMediaManager,
180 provider_mock: Mock,
181) -> None:
182 """Test the mix header and track list are located by content, not a fixed row index."""
183 # pagedList in row 0, mix header in row 1 (reverse of the usual layout).
184 provider_mock.api.get.return_value = {
185 "title": "My Mix",
186 "rows": [
187 {"modules": [{"pagedList": {"items": [{"id": 1}]}}]},
188 {"modules": [{"mix": {"images": {"MEDIUM": {"url": "http://img"}}}}]},
189 ],
190 }
191 mock_parse_playlist.return_value = Mock(item_id="mix_123")
192 mock_parse_track.side_effect = [Mock(item_id="1")]
193
194 await media_manager.get_playlist("mix_123")
195 tracks = await media_manager.get_playlist_tracks("mix_123")
196
197 assert mock_parse_playlist.call_args.args[1]["images"] == {"MEDIUM": {"url": "http://img"}}
198 assert [t.item_id for t in tracks] == ["1"]
199
200
201async def test_search_empty_results(media_manager: TidalMediaManager, provider_mock: Mock) -> None:
202 """Test search with a result resource carrying no hits."""
203 provider_mock.api.get_jsonapi.return_value = JsonApiDocument(
204 {"data": [{"id": "opaque123", "type": "searchResults", "relationships": {}}]}
205 )
206
207 results = await media_manager.search("query", [MediaType.ARTIST])
208
209 assert len(results.artists) == 0
210 assert len(results.albums) == 0
211 assert len(results.tracks) == 0
212
213
214async def test_search_empty_collection(
215 media_manager: TidalMediaManager, provider_mock: Mock
216) -> None:
217 """Test search returns empty results when the collection carries no resource."""
218 provider_mock.api.get_jsonapi.return_value = JsonApiDocument({"data": []})
219
220 results = await media_manager.search("query", [MediaType.ARTIST])
221
222 assert len(results.artists) == 0
223 assert len(results.playlists) == 0
224