/
/
1"""Tests for the MusicBrainz provider."""
2
3from __future__ import annotations
4
5from typing import Any
6from unittest.mock import AsyncMock, MagicMock, patch
7
8from music_assistant_models.errors import RateLimited
9
10from music_assistant.constants import VARIOUS_ARTISTS_MBID
11from music_assistant.providers.musicbrainz.provider import MusicbrainzProvider
12
13# ---------------------------------------------------------------------------
14# helpers
15# ---------------------------------------------------------------------------
16
17
18def _provider(
19 response: Any, release_group_response: Any = None
20) -> tuple[MusicbrainzProvider, AsyncMock]:
21 """
22 Return a MusicbrainzProvider whose API client answers with the given response.
23
24 :param response: Answer to every request but the release group lookup.
25 :param release_group_response: Answer to the release group lookup.
26 """
27 with patch.object(MusicbrainzProvider, "__init__", lambda *_a, **_kw: None):
28 provider = MusicbrainzProvider.__new__(MusicbrainzProvider)
29
30 async def _answer(endpoint: str, **_kwargs: Any) -> Any:
31 return release_group_response if endpoint == "release-group" else response
32
33 get_data = AsyncMock(side_effect=_answer)
34 api_client = MagicMock()
35 api_client.get_data = get_data
36 provider._api_client = api_client
37 return provider, get_data
38
39
40def _recordings(*first_release_dates: str | None) -> dict[str, Any]:
41 """Return an isrc lookup response with a recording per given release date."""
42 return {
43 "isrc": "GBAYE8600477",
44 "recordings": [
45 {"id": f"stub-{i}", "title": "stub"}
46 if date is None
47 else {"id": f"stub-{i}", "title": "stub", "first-release-date": date}
48 for i, date in enumerate(first_release_dates)
49 ],
50 }
51
52
53# ---------------------------------------------------------------------------
54# get_release_year_by_isrc
55# ---------------------------------------------------------------------------
56
57
58async def test_release_year_parses_all_date_precisions() -> None:
59 """Accept a year, year-month or full date as the first release date."""
60 for release_date in ("1986", "1986-06", "1986-06-23"):
61 provider, _ = _provider(_recordings(release_date))
62 assert await provider.get_release_year_by_isrc("GBAYE8600477") == 1986
63
64
65async def test_release_year_uses_bare_isrc_lookup() -> None:
66 """Look the recording up on the isrc resource without inc parameters."""
67 provider, get_data = _provider(_recordings("1986"))
68
69 await provider.get_release_year_by_isrc("GB-AYE-86-00477")
70
71 get_data.assert_awaited_once_with("isrc/GBAYE8600477")
72
73
74async def test_release_year_returns_earliest_of_multiple_recordings() -> None:
75 """Date the song by the oldest recording the ISRC covers."""
76 provider, _ = _provider(_recordings("2009-05-01", "1986-06", "1994"))
77 assert await provider.get_release_year_by_isrc("GBAYE8600477") == 1986
78
79
80async def test_release_year_is_none_without_a_usable_date() -> None:
81 """Return None when MusicBrainz has no parseable first release date."""
82 for response in (
83 None,
84 {"isrc": "GBAYE8600477"},
85 {"isrc": "GBAYE8600477", "recordings": []},
86 _recordings(None),
87 _recordings("????-06"),
88 ):
89 provider, _ = _provider(response)
90 assert await provider.get_release_year_by_isrc("GBAYE8600477") is None
91
92
93async def test_release_year_rejects_a_malformed_isrc() -> None:
94 """Never put an ISRC that cannot be part of a URL path in the request."""
95 provider, get_data = _provider(_recordings("1986"))
96
97 assert await provider.get_release_year_by_isrc("../artist/1") is None
98 get_data.assert_not_awaited()
99
100
101# ---------------------------------------------------------------------------
102# get_recordings_by_isrc
103# ---------------------------------------------------------------------------
104
105
106_YELLOW_SUBMARINE = {
107 "isrc": "GBAYE0601498",
108 "recordings": [
109 {
110 "id": "b2181aae-5cba-496c-bb0c-b4cc0109ebf8",
111 "title": "Yellow Submarine",
112 "length": 160000,
113 "first-release-date": "1966-08-05",
114 "disambiguation": "original stereo studio mix",
115 "video": False,
116 }
117 ],
118}
119
120
121async def test_recordings_by_isrc_parses_a_realistic_payload() -> None:
122 """Parse id, title and first-release-date from a real isrc lookup response."""
123 provider, _ = _provider(_YELLOW_SUBMARINE)
124
125 recordings = await provider.get_recordings_by_isrc("GBAYE0601498")
126
127 assert len(recordings) == 1
128 recording = recordings[0]
129 assert recording.id == "b2181aae-5cba-496c-bb0c-b4cc0109ebf8"
130 assert recording.title == "Yellow Submarine"
131 assert recording.first_release_date == "1966-08-05"
132
133
134async def test_recordings_by_isrc_returns_all_recordings() -> None:
135 """Return every recording an ISRC covers, not just the first."""
136 provider, _ = _provider(_recordings("2009-05-01", "1986-06", "1994"))
137
138 recordings = await provider.get_recordings_by_isrc("GBAYE8600477")
139
140 assert len(recordings) == 3
141 assert [r.first_release_date for r in recordings] == ["2009-05-01", "1986-06", "1994"]
142
143
144async def test_recordings_by_isrc_skips_a_malformed_entry() -> None:
145 """Skip a recording missing a required field while keeping its valid siblings."""
146 response = {
147 "isrc": "GBAYE8600477",
148 "recordings": [
149 {"title": "no id here"},
150 {"id": "good-1", "title": "stub", "first-release-date": "1986"},
151 ],
152 }
153 provider, _ = _provider(response)
154
155 recordings = await provider.get_recordings_by_isrc("GBAYE8600477")
156
157 assert len(recordings) == 1
158 assert recordings[0].id == "good-1"
159
160
161async def test_recordings_by_isrc_is_empty_without_usable_data() -> None:
162 """Return an empty list for every shape of "MusicBrainz has nothing" response."""
163 for response in (
164 None,
165 {"isrc": "GBAYE8600477"},
166 {"isrc": "GBAYE8600477", "recordings": []},
167 ):
168 provider, _ = _provider(response)
169 assert await provider.get_recordings_by_isrc("GBAYE8600477") == []
170
171
172async def test_recordings_by_isrc_rejects_a_malformed_isrc() -> None:
173 """Never put an ISRC that cannot be part of a URL path in the request."""
174 provider, get_data = _provider(_YELLOW_SUBMARINE)
175
176 assert await provider.get_recordings_by_isrc("../artist/1") == []
177 get_data.assert_not_awaited()
178
179
180# ---------------------------------------------------------------------------
181# get_release_year_by_track_name
182# ---------------------------------------------------------------------------
183
184
185def _credit(name: str, artist_id: str) -> dict[str, Any]:
186 """Return one artist credit of a release."""
187 return {"name": name, "artist": {"id": artist_id, "name": name, "sort-name": name}}
188
189
190def _release(
191 date: str,
192 *,
193 title: str = "A Night at the Opera",
194 primary_type: str = "Album",
195 secondary_types: list[str] | None = None,
196 status: str = "Official",
197 credit: dict[str, Any] | None = None,
198) -> dict[str, Any]:
199 """Return one release of a searched recording."""
200 release: dict[str, Any] = {
201 "id": f"release-{date}",
202 "title": title,
203 "date": date,
204 "status": status,
205 "release-group": {
206 "id": f"rg-{title}-{primary_type}",
207 "title": title,
208 "primary-type": primary_type,
209 },
210 }
211 if secondary_types:
212 release["release-group"]["secondary-types"] = secondary_types
213 if credit is not None:
214 release["artist-credit"] = [credit]
215 return release
216
217
218def _search_result(*recordings: dict[str, Any]) -> dict[str, Any]:
219 """Return a recording search response holding the given recordings."""
220 return {"count": len(recordings), "recordings": list(recordings)}
221
222
223def _recording(
224 *releases: dict[str, Any],
225 title: str = "Bohemian Rhapsody",
226 artist: str = "Queen",
227 artist_id: str = "artist-1",
228 first_release: str | None = None,
229) -> dict[str, Any]:
230 """Return one searched recording credited to the given artist."""
231 recording: dict[str, Any] = {
232 "id": f"recording-{title}-{releases[0]['date'] if releases else 'none'}",
233 "title": title,
234 "artist-credit": [{"artist": {"id": artist_id, "name": artist, "sort-name": artist}}],
235 "releases": list(releases),
236 }
237 if first_release is not None:
238 recording["first-release-date"] = first_release
239 return recording
240
241
242async def test_release_year_by_track_name_returns_the_earliest_studio_release() -> None:
243 """Date a song by the oldest studio album any matching recording appeared on."""
244 provider, get_data = _provider(
245 _search_result(
246 _recording(_release("2011-05-16", title="The Platinum Collection")),
247 _recording(_release("1992-08-25", title="Classic Queen")),
248 _recording(_release("1975-11-21")),
249 )
250 )
251
252 assert await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody") == 1975
253 assert get_data.await_args_list[0].args == ("recording",)
254 assert get_data.await_args_list[0].kwargs == {
255 "query": '"Bohemian Rhapsody" AND artist:"Queen"',
256 "limit": "100",
257 }
258
259
260async def test_release_year_by_track_name_ignores_untrustworthy_releases() -> None:
261 """Never date a song by a compilation, a live album, a bootleg or an unrelated single."""
262 provider, _ = _provider(
263 _search_result(
264 _recording(
265 # every untrusted release predates the studio album, so each filter has to
266 # hold on its own for the studio year to win
267 _release("1968-10-26", title="Greatest Hits", secondary_types=["Compilation"]),
268 _release("1969-06-22", title="Live Killers", secondary_types=["Live"]),
269 _release("1970-01-01", title="Some Other Song", primary_type="Single"),
270 _release("1971-03-01", title="Bootleg Tape", status="Bootleg"),
271 _release("1972-05-05", title="A Tribute", primary_type="Other"),
272 _release("1975-11-21"),
273 )
274 )
275 )
276
277 assert await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody") == 1975
278
279
280async def test_release_year_by_track_name_dates_a_song_by_its_soundtrack() -> None:
281 """Date a song written for a film by that film's soundtrack."""
282 provider, _ = _provider(
283 _search_result(
284 _recording(
285 _release(
286 "1994-05-31",
287 title="The Lion King: Original Motion Picture Soundtrack",
288 secondary_types=["Soundtrack"],
289 ),
290 _release("2013-09-13", title="The Diving Board"),
291 title="Circle of Life",
292 artist="Elton John",
293 )
294 )
295 )
296
297 assert await provider.get_release_year_by_track_name("Elton John", "Circle of Life") == 1994
298
299
300async def test_release_year_by_track_name_ignores_a_soundtrack_compilation() -> None:
301 """Never date a song by a film compilation of songs released before it."""
302 provider, _ = _provider(
303 _search_result(
304 _recording(
305 # the compilation predates the studio album, so the secondary type filter
306 # has to hold on its own for the studio year to win
307 _release(
308 "1968-10-26",
309 title="Music From the Motion Picture",
310 secondary_types=["Compilation", "Soundtrack"],
311 ),
312 _release("1975-11-21"),
313 )
314 )
315 )
316
317 assert await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody") == 1975
318
319
320async def test_release_year_by_track_name_ignores_a_various_artists_soundtrack() -> None:
321 """Never date a song by a film compilation credited to Various Artists."""
322 provider, _ = _provider(
323 _search_result(
324 _recording(
325 # most film soundtracks are compilations of several artists, and the credit
326 # filter is what keeps them out now that soundtracks are allowed through
327 _release(
328 "1968-10-26",
329 title="Music From the Motion Picture",
330 secondary_types=["Soundtrack"],
331 credit=_credit("Various Artists", VARIOUS_ARTISTS_MBID),
332 ),
333 _release("1975-11-21", credit=_credit("Queen", "artist-1")),
334 )
335 )
336 )
337
338 assert await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody") == 1975
339
340
341async def test_release_year_by_track_name_ignores_a_various_artists_release() -> None:
342 """Never date a song by a hits compilation that carries no Compilation type."""
343 provider, _ = _provider(
344 _search_result(
345 _recording(
346 # the compilation predates the studio album, so the credit filter has to
347 # hold on its own for the studio year to win
348 _release(
349 "1968-10-26",
350 title="Hits of the 60s",
351 credit=_credit("Various Artists", VARIOUS_ARTISTS_MBID),
352 ),
353 _release("1975-11-21", credit=_credit("Queen", "artist-1")),
354 )
355 )
356 )
357
358 assert await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody") == 1975
359
360
361async def test_release_year_by_track_name_identifies_various_artists_by_id() -> None:
362 """
363 Recognise the Various Artists entity by its id rather than by its name.
364
365 MusicBrainz localizes the name it credits that entity under, and unrelated artists
366 are named after it.
367 """
368 provider, _ = _provider(
369 _search_result(
370 _recording(
371 _release(
372 "1968-10-26",
373 title="Artisti Vari Compilation",
374 credit=_credit("Artisti Vari", VARIOUS_ARTISTS_MBID),
375 ),
376 _release(
377 "1975-11-21",
378 credit=_credit("Various Artist", "artist-named-like-various"),
379 ),
380 )
381 )
382 )
383
384 assert await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody") == 1975
385
386
387async def test_release_group_by_track_name_drops_a_various_artists_release() -> None:
388 """Offer no artwork candidate when a song is only listed on a hits compilation."""
389 provider, _ = _provider(
390 _search_result(
391 _recording(
392 _release(
393 "1981-10-26",
394 title="Hits of the 80s",
395 credit=_credit("Various Artists", VARIOUS_ARTISTS_MBID),
396 )
397 )
398 )
399 )
400
401 result = await provider.get_release_group_by_track_name("Queen", "Bohemian Rhapsody")
402
403 assert result is not None
404 artist, release_groups = result
405 assert artist.name == "Queen"
406 assert release_groups == []
407
408
409async def test_release_group_by_track_name_offers_a_soundtrack_as_artwork() -> None:
410 """Offer the soundtrack of a song written for a film as an artwork candidate."""
411 provider, _ = _provider(
412 _search_result(
413 _recording(
414 _release(
415 "1994-05-31",
416 title="The Lion King: Original Motion Picture Soundtrack",
417 secondary_types=["Soundtrack"],
418 ),
419 title="Circle of Life",
420 artist="Elton John",
421 )
422 )
423 )
424
425 result = await provider.get_release_group_by_track_name("Elton John", "Circle of Life")
426
427 assert result is not None
428 _, release_groups = result
429 assert [rg.title for rg in release_groups] == [
430 "The Lion King: Original Motion Picture Soundtrack"
431 ]
432
433
434async def test_release_year_by_track_name_ignores_an_undated_release_group() -> None:
435 """Date a song by the oldest release group that has a date, not by an undated one."""
436 provider, _ = _provider(
437 _search_result(
438 _recording(_release("", title="Unknown Pressing")),
439 _recording(_release("1975-11-21")),
440 )
441 )
442
443 assert await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody") == 1975
444
445
446async def test_release_year_by_track_name_accepts_a_single_named_after_the_song() -> None:
447 """Date a song by its own single when no studio album carries it."""
448 provider, _ = _provider(
449 _search_result(
450 _recording(_release("1975-10-31", title="Bohemian Rhapsody", primary_type="Single"))
451 )
452 )
453
454 assert await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody") == 1975
455
456
457async def test_release_year_by_track_name_is_none_without_a_confident_match() -> None:
458 """Return no year at all rather than guessing from a name that does not match."""
459 for response in (
460 None,
461 {"count": 0, "recordings": []},
462 _search_result(_recording(_release("1975-11-21"), artist="Not Queen")),
463 _search_result(_recording(_release("1975-11-21"), title="Another Song")),
464 _search_result(_recording()),
465 _search_result(_recording(_release(""))),
466 ):
467 provider, _ = _provider(response)
468 assert await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody") is None
469
470
471def _release_groups(*groups: tuple[str, str]) -> dict[str, Any]:
472 """
473 Return a release group search response holding the given release groups.
474
475 :param groups: Release groups as (id, first release date) pairs.
476 """
477 return {
478 "count": len(groups),
479 "release-groups": [
480 {"id": group_id, "title": group_id, "first-release-date": date}
481 for group_id, date in groups
482 ],
483 }
484
485
486async def test_release_year_by_track_name_prefers_the_release_group_first_release() -> None:
487 """Date a much reissued song by its album's first release, not by the reissue found."""
488 provider, _ = _provider(
489 _search_result(_recording(_release("2021-11-12"))),
490 _release_groups(("rg-A Night at the Opera-Album", "1975-11-21")),
491 )
492
493 assert await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody") == 1975
494
495
496async def test_release_year_by_track_name_keeps_a_close_release_date() -> None:
497 """Keep the found release when the album barely predates it, as a single ahead of it would."""
498 provider, _ = _provider(
499 _search_result(_recording(_release("1975-11-21"))),
500 _release_groups(("rg-A Night at the Opera-Album", "1974-10-31")),
501 )
502
503 assert await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody") == 1975
504
505
506async def test_release_year_by_track_name_corrects_only_beyond_the_threshold() -> None:
507 """Correct the year only once the album predates the found release by enough years."""
508 for first_release_year, expected in ((1970, 1975), (1969, 1969)):
509 provider, _ = _provider(
510 _search_result(_recording(_release("1975-11-21"))),
511 _release_groups(("rg-A Night at the Opera-Album", f"{first_release_year}-10-31")),
512 )
513
514 assert (
515 await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody") == expected
516 )
517
518
519async def test_release_year_by_track_name_keeps_the_found_release_when_the_lookup_fails() -> None:
520 """Never lose the year the search already supplied when the release group lookup fails."""
521 search_result = _search_result(_recording(_release("1975-11-21")))
522 provider, get_data = _provider(search_result)
523
524 async def _answer(endpoint: str, **_kwargs: Any) -> Any:
525 if endpoint == "release-group":
526 raise RateLimited("rate limited")
527 return search_result
528
529 get_data.side_effect = _answer
530
531 assert await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody") == 1975
532
533
534async def test_release_year_by_track_name_looks_up_every_release_group_at_once() -> None:
535 """Resolve all release groups of a song with a single, escaped, request."""
536 provider, get_data = _provider(
537 _search_result(
538 _recording(_release("2011-05-16", title="The Platinum Collection")),
539 _recording(_release("1975-11-21")),
540 ),
541 _release_groups(("rg-A Night at the Opera-Album", "1975-11-21")),
542 )
543
544 await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody")
545
546 # one release group lookup, however many groups the search turned up
547 assert [call.args for call in get_data.await_args_list] == [("recording",), ("release-group",)]
548 assert get_data.await_args_list[1].kwargs["query"] == (
549 r"rgid:(rg\-A Night at the Opera\-Album OR rg\-The Platinum Collection\-Album)"
550 )
551
552
553async def test_release_year_by_track_name_falls_back_to_the_found_release() -> None:
554 """Keep the found release when MusicBrainz does not know when the album first came out."""
555 for release_group_response in (None, _release_groups(), {"release-groups": [{"id": "rg-x"}]}):
556 provider, _ = _provider(
557 _search_result(_recording(_release("1975-11-21"))), release_group_response
558 )
559
560 assert await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody") == 1975
561
562
563async def test_release_year_by_track_name_dates_an_undated_release_group() -> None:
564 """Date a song whose found releases carry no date at all by its album's first release."""
565 provider, _ = _provider(
566 _search_result(_recording(_release(""))),
567 _release_groups(("rg-A Night at the Opera-Album", "1975-11-21")),
568 )
569
570 assert await provider.get_release_year_by_track_name("Queen", "Bohemian Rhapsody") == 1975
571
572
573async def test_release_group_by_track_name_costs_a_single_request() -> None:
574 """Never spend the release group lookup on callers that only want the release groups."""
575 provider, get_data = _provider(
576 _search_result(_recording(_release("1975-11-21"))),
577 _release_groups(("rg-A Night at the Opera-Album", "1975-11-21")),
578 )
579
580 await provider.get_release_group_by_track_name("Queen", "Bohemian Rhapsody")
581
582 assert [call.args for call in get_data.await_args_list] == [("recording",)]
583
584
585async def test_release_group_by_track_name_returns_the_artist_and_oldest_groups_first() -> None:
586 """Hand out the artist of the oldest recording, and their release groups oldest first."""
587 provider, _ = _provider(
588 _search_result(
589 _recording(
590 _release("2011-05-16", title="The Platinum Collection"),
591 first_release="2011-05-16",
592 artist_id="artist-reissue",
593 ),
594 _recording(
595 _release("1975-11-21"),
596 first_release="1975-11-21",
597 artist_id="artist-original",
598 ),
599 )
600 )
601
602 result = await provider.get_release_group_by_track_name("Queen", "Bohemian Rhapsody")
603
604 assert result is not None
605 artist, release_groups = result
606 # MusicBrainz can hold several artist entries under one name, and the oldest recording
607 # is the one that identifies the original
608 assert artist.id == "artist-original"
609 assert [group.title for group in release_groups] == [
610 "A Night at the Opera",
611 "The Platinum Collection",
612 ]
613
614
615async def test_release_group_by_track_name_returns_the_artist_without_release_groups() -> None:
616 """Still identify the artist when no matched recording carries a usable release group."""
617 provider, _ = _provider(
618 _search_result(_recording(_release("1981-10-26", secondary_types=["Compilation"])))
619 )
620
621 result = await provider.get_release_group_by_track_name("Queen", "Bohemian Rhapsody")
622
623 assert result is not None
624 artist, release_groups = result
625 assert artist.name == "Queen"
626 assert release_groups == []
627
628
629async def test_release_year_by_track_name_escapes_lucene_specials() -> None:
630 """Escape characters that would otherwise change the meaning of the search query."""
631 provider, get_data = _provider(_search_result())
632
633 await provider.get_release_year_by_track_name("AC/DC", "T.N.T. (live!)")
634
635 get_data.assert_awaited_once_with(
636 "recording",
637 query='"T.N.T. \\(live\\!\\)" AND artist:"AC\\/DC"',
638 limit="100",
639 )
640