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