/
/
/
1"""Test parsing of official Tidal API (v2) JSON:API responses."""
2
3import json
4import pathlib
5from typing import TYPE_CHECKING
6from unittest.mock import Mock
7
8import pytest
9from music_assistant_models.enums import AlbumType, ExternalID, ImageType, LinkType
10from music_assistant_models.media_items import ItemMapping
11
12from music_assistant.providers.tidal.jsonapi import JsonApiDocument
13from music_assistant.providers.tidal.parsers_v2 import (
14 _map_album_type,
15 _parse_iso_duration,
16 _select_image_url,
17 _split_title_version,
18 parse_album,
19 parse_artist,
20 parse_playlist,
21 parse_track,
22)
23
24if TYPE_CHECKING:
25 from music_assistant_models.enums import MediaType
26
27FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" / "v2"
28
29
30@pytest.fixture
31def provider_mock() -> Mock:
32 """Return a mock provider."""
33 provider = Mock()
34 provider.domain = "tidal"
35 provider.instance_id = "tidal_instance"
36
37 def get_item_mapping(media_type: MediaType, key: str, name: str) -> ItemMapping:
38 return ItemMapping(
39 media_type=media_type, item_id=key, provider=provider.instance_id, name=name
40 )
41
42 provider.get_item_mapping.side_effect = get_item_mapping
43 return provider
44
45
46def _load(name: str) -> JsonApiDocument:
47 with open(FIXTURES_DIR / name) as f:
48 return JsonApiDocument(json.load(f))
49
50
51def test_parse_track(provider_mock: Mock) -> None:
52 """Test parsing a track resource with resolved artists, album and image."""
53 doc = _load("track.json")
54 track = parse_track(provider_mock, doc, doc.data)
55
56 assert track.item_id == "58756128"
57 assert track.name == "7 Years"
58 assert track.duration == 237 # PT3M57S
59 assert (ExternalID.ISRC, "USWB11506516") in track.external_ids
60 assert track.metadata.popularity == 77 # 0.7728 scaled to 0..100
61 assert track.audio_metadata is not None
62 assert track.audio_metadata.bpm == 120.0
63 assert track.audio_metadata.musical_key == "Eb major"
64 assert [a.name for a in track.artists] == ["Lukas Graham"]
65 assert track.album is not None
66 assert track.album.item_id == "58756127"
67 assert track.metadata.images
68 assert track.metadata.images[0].type == ImageType.THUMB
69 assert "750x750" in track.metadata.images[0].path
70 assert track.metadata.genres == {"Pop"}
71 mapping = next(iter(track.provider_mappings))
72 assert mapping.available is True
73 assert mapping.audio_format.bit_depth == 16 # LOSSLESS, not hi-res
74
75
76def test_parse_album(provider_mock: Mock) -> None:
77 """Test parsing an album resource with resolved artists and cover art."""
78 doc = _load("album.json")
79 album = parse_album(provider_mock, doc, doc.data)
80
81 assert album.item_id == "58756127"
82 # explicit version attribute is trusted and stripped from the title
83 assert album.name == "Lukas Graham (Blue Album)"
84 assert album.version == "International Version"
85 assert album.album_type == AlbumType.ALBUM
86 assert album.year == 2015
87 assert (ExternalID.BARCODE, "00602547852748") in album.external_ids
88 assert [a.name for a in album.artists] == ["Lukas Graham"]
89 assert album.metadata.images
90 assert "750x750" in album.metadata.images[0].path
91
92
93def test_parse_artist(provider_mock: Mock) -> None:
94 """Test parsing an artist resource with resolved profile art."""
95 doc = _load("artist.json")
96 artist = parse_artist(provider_mock, doc, doc.data)
97
98 assert artist.item_id == "4184211"
99 assert artist.name == "Lukas Graham"
100 assert artist.metadata.popularity is not None
101 assert artist.metadata.images
102 assert artist.metadata.description # from the biography include
103 link_types = {link.type for link in (artist.metadata.links or [])}
104 assert LinkType.FACEBOOK in link_types
105 assert LinkType.TWITTER in link_types
106
107
108def test_parse_artist_strips_wimplink_markup(provider_mock: Mock) -> None:
109 """Test Tidal's internal [wimpLink] bio markup is stripped from the description."""
110 doc = _load("artist_wimplink.json")
111 artist = parse_artist(provider_mock, doc, doc.data)
112
113 assert artist.metadata.description
114 assert "wimpLink" not in artist.metadata.description
115 assert "[" not in artist.metadata.description.split("~")[0]
116 # inner link text is preserved
117 assert "Asian Dub Foundation" in artist.metadata.description
118
119
120@pytest.mark.parametrize(
121 ("title", "version", "expected_name", "expected_version"),
122 [
123 (
124 "Lukas Graham (Blue Album) (International Version)",
125 "International Version",
126 "Lukas Graham (Blue Album)",
127 "International Version",
128 ),
129 ("Song (Remastered)", "Remastered", "Song", "Remastered"),
130 ("Song - Live", "Live", "Song", "Live"),
131 ("Plain Title", "Deluxe", "Plain Title", "Deluxe"), # version not in title
132 ("7 Years", None, "7 Years", ""), # no explicit version -> heuristic fallback
133 ],
134)
135def test_split_title_version(
136 title: str, version: str | None, expected_name: str, expected_version: str
137) -> None:
138 """Test the title/version split trusts the explicit version attribute."""
139 assert _split_title_version(title, version) == (expected_name, expected_version)
140
141
142def test_parse_playlist_editable(provider_mock: Mock) -> None:
143 """Test a playlist owned by the authenticated user is marked editable."""
144 doc = JsonApiDocument(
145 {
146 "data": {
147 "id": "pl-1",
148 "type": "playlists",
149 "attributes": {"name": "My List", "description": "mine", "playlistType": "USER"},
150 "relationships": {"owners": {"data": [{"id": "12345", "type": "owners"}]}},
151 }
152 }
153 )
154 provider_mock.auth.user_id = "12345"
155 provider_mock.auth.user.profile_name = "Me"
156 playlist = parse_playlist(provider_mock, doc, doc.data)
157
158 assert playlist.item_id == "pl-1"
159 assert playlist.name == "My List"
160 assert playlist.is_editable is True
161 assert playlist.owner == "Me"
162 assert playlist.metadata.description == "mine"
163
164
165def test_parse_playlist_not_owned(provider_mock: Mock) -> None:
166 """Test a playlist not owned by the user is not editable."""
167 doc = JsonApiDocument(
168 {
169 "data": {
170 "id": "pl-2",
171 "type": "playlists",
172 "attributes": {"name": "Editorial", "playlistType": "EDITORIAL"},
173 "relationships": {"owners": {"data": []}},
174 }
175 }
176 )
177 provider_mock.auth.user_id = "12345"
178 playlist = parse_playlist(provider_mock, doc, doc.data)
179
180 assert playlist.is_editable is False
181 assert playlist.owner == "Tidal"
182
183
184def test_parse_track_credits(provider_mock: Mock) -> None:
185 """Test track credits are resolved into performers."""
186 doc = _load("track_credits.json")
187 track = parse_track(provider_mock, doc, doc.data)
188
189 assert track.metadata.performers
190 assert "Lukas Forchhammer" in track.metadata.performers
191
192
193def test_parse_track_no_includes(provider_mock: Mock) -> None:
194 """Test a track with unresolved relationships still parses core fields."""
195 doc = JsonApiDocument(
196 {
197 "data": {
198 "id": "1",
199 "type": "tracks",
200 "attributes": {
201 "title": "Bare Track",
202 "duration": "PT2M3S",
203 "explicit": False,
204 "isrc": "AAA000000000",
205 "popularity": 0.5,
206 "mediaTags": ["HIRES_LOSSLESS"],
207 },
208 "relationships": {},
209 }
210 }
211 )
212 track = parse_track(provider_mock, doc, doc.data)
213 assert track.name == "Bare Track"
214 assert track.duration == 123
215 assert track.artists == []
216 assert track.album is None
217 assert next(iter(track.provider_mappings)).audio_format.bit_depth == 24
218
219
220def test_parse_track_availability_empty_array(provider_mock: Mock) -> None:
221 """Test a track with a present-but-empty availability array is marked unavailable."""
222 doc = JsonApiDocument(
223 {
224 "data": {
225 "id": "1",
226 "type": "tracks",
227 "attributes": {
228 "title": "No Stream",
229 "duration": "PT1M0S",
230 "availability": [],
231 },
232 "relationships": {},
233 }
234 }
235 )
236 track = parse_track(provider_mock, doc, doc.data)
237 assert next(iter(track.provider_mappings)).available is False
238
239
240def test_parse_track_availability_missing(provider_mock: Mock) -> None:
241 """Test a track with no availability key defaults to available."""
242 doc = JsonApiDocument(
243 {
244 "data": {
245 "id": "1",
246 "type": "tracks",
247 "attributes": {"title": "No Info", "duration": "PT1M0S"},
248 "relationships": {},
249 }
250 }
251 )
252 track = parse_track(provider_mock, doc, doc.data)
253 assert next(iter(track.provider_mappings)).available is True
254
255
256def test_parse_album_genres(provider_mock: Mock) -> None:
257 """Test album genres are resolved from the genres relationship."""
258 doc = JsonApiDocument(
259 {
260 "data": {
261 "id": "1",
262 "type": "albums",
263 "attributes": {"title": "A", "albumType": "ALBUM", "explicit": False},
264 "relationships": {"genres": {"data": [{"id": "g1", "type": "genres"}]}},
265 },
266 "included": [{"id": "g1", "type": "genres", "attributes": {"genreName": "Rock"}}],
267 }
268 )
269 album = parse_album(provider_mock, doc, doc.data)
270 assert album.metadata.genres == {"Rock"}
271
272
273def test_parse_album_availability_empty_array(provider_mock: Mock) -> None:
274 """Test an album with a present-but-empty availability array is marked unavailable."""
275 doc = JsonApiDocument(
276 {
277 "data": {
278 "id": "1",
279 "type": "albums",
280 "attributes": {
281 "title": "No Stream",
282 "albumType": "ALBUM",
283 "explicit": False,
284 "availability": [],
285 },
286 "relationships": {},
287 }
288 }
289 )
290 album = parse_album(provider_mock, doc, doc.data)
291 assert next(iter(album.provider_mappings)).available is False
292
293
294def test_parse_album_availability_missing(provider_mock: Mock) -> None:
295 """Test an album with no availability key defaults to available."""
296 doc = JsonApiDocument(
297 {
298 "data": {
299 "id": "1",
300 "type": "albums",
301 "attributes": {"title": "No Info", "albumType": "ALBUM", "explicit": False},
302 "relationships": {},
303 }
304 }
305 )
306 album = parse_album(provider_mock, doc, doc.data)
307 assert next(iter(album.provider_mappings)).available is True
308
309
310@pytest.mark.parametrize(
311 ("album_type", "various_artists", "expected"),
312 [
313 ("ALBUM", True, AlbumType.COMPILATION), # various artists overrides
314 ("ALBUM", False, AlbumType.ALBUM),
315 ("EP", False, AlbumType.EP),
316 ("SINGLE", False, AlbumType.SINGLE),
317 (None, False, AlbumType.ALBUM), # unknown -> default
318 ],
319)
320def test_map_album_type(album_type: str | None, various_artists: bool, expected: AlbumType) -> None:
321 """Test album type mapping across the official albumType values."""
322 assert _map_album_type(album_type, "Some Album", None, various_artists) == expected
323
324
325@pytest.mark.parametrize(
326 ("widths", "expected_width"),
327 [
328 ([80, 160, 320, 640, 750, 1080, 1280], 750), # exact preferred width
329 ([800, 1280], 800), # none at 750 -> smallest at/above
330 ([80, 160, 320], 320), # all below -> largest available
331 ],
332)
333def test_select_image_url(widths: list[int], expected_width: int) -> None:
334 """Test artwork selection prefers the smallest file at/above the target width."""
335 files = [{"href": f"http://img/{w}.jpg", "meta": {"width": w}} for w in widths]
336 assert _select_image_url(files) == f"http://img/{expected_width}.jpg"
337
338
339def test_select_image_url_empty() -> None:
340 """Test artwork selection returns None when there are no usable files."""
341 assert _select_image_url([]) is None
342 assert _select_image_url([{"meta": {"width": 750}}]) is None # no href
343
344
345@pytest.mark.parametrize(
346 ("value", "expected"),
347 [
348 ("PT1H2M3S", 3723),
349 ("PT3M57S", 237),
350 ("PT45S", 45),
351 ("", 0),
352 ("garbage", 0),
353 ],
354)
355def test_parse_iso_duration(value: str, expected: int) -> None:
356 """Test ISO-8601 duration parsing including the hours component."""
357 assert _parse_iso_duration(value) == expected
358