/
/
/
1"""Tests for LocalFileSystemProvider._resolve_artists_with_mbids."""
2
3from unittest.mock import AsyncMock, MagicMock, patch
4
5import pytest
6from music_assistant_models.media_items import Album, Artist
7
8from music_assistant.providers.filesystem_local import LocalFileSystemProvider
9
10MBID_A = "11111111-1111-1111-1111-111111111111"
11MBID_B = "22222222-2222-2222-2222-222222222222"
12
13
14def _make_artist(name: str, mbid: str | None = None) -> Artist:
15 """Create a minimal Artist, optionally carrying a MusicBrainz ID."""
16 artist = Artist(item_id=name, provider="test", name=name, provider_mappings=set())
17 if mbid:
18 artist.mbid = mbid
19 return artist
20
21
22def _make_album(*artists: Artist) -> Album:
23 """Create a minimal Album with the given artists."""
24 album = Album(item_id="album", provider="test", name="Album", provider_mappings=set())
25 for artist in artists:
26 album.artists.append(artist)
27 return album
28
29
30def _create_provider(mb_provider: object | None = None) -> LocalFileSystemProvider:
31 """
32 Create a bare LocalFileSystemProvider with a mocked MusicBrainz lookup.
33
34 :param mb_provider: Object returned by ``mass.get_provider("musicbrainz")``;
35 ``None`` simulates the provider not being loaded.
36 """
37 with patch.object(LocalFileSystemProvider, "__init__", lambda *_a, **_kw: None):
38 provider = LocalFileSystemProvider.__new__(LocalFileSystemProvider)
39
40 provider.mass = MagicMock()
41 provider.mass.get_provider = MagicMock(return_value=mb_provider)
42 provider.logger = MagicMock()
43 return provider
44
45
46def _make_mb_provider(result: list[tuple[str, str, str] | None]) -> MagicMock:
47 """Create a fake MusicBrainz provider whose lookup returns ``result``."""
48 mb_provider = MagicMock()
49 mb_provider.resolve_artists_from_mbids = AsyncMock(return_value=result)
50 return mb_provider
51
52
53class TestResolveArtistsWithMbids:
54 """Test the parsed-name vs MBID reconciliation logic."""
55
56 @pytest.mark.asyncio
57 async def test_counts_match_uses_tags_without_lookup(self) -> None:
58 """When name and MBID counts agree, tag data is used and MB is not queried."""
59 provider = _create_provider()
60 result = await provider._resolve_artists_with_mbids(
61 parsed_names=("Artist A", "Artist B"),
62 mbids=("mbid-a", "mbid-b"),
63 sort_names=("A, Artist", "B, Artist"),
64 log_label="ARTISTS tag",
65 )
66 assert result == [
67 ("Artist A", "mbid-a", "A, Artist"),
68 ("Artist B", "mbid-b", "B, Artist"),
69 ]
70 provider.mass.get_provider.assert_not_called() # type: ignore[attr-defined]
71
72 @pytest.mark.asyncio
73 async def test_no_mbids_uses_tags_without_lookup(self) -> None:
74 """With no MBIDs at all, tag-parsed names are returned with None MBIDs."""
75 provider = _create_provider()
76 result = await provider._resolve_artists_with_mbids(
77 parsed_names=("Artist A", "Artist B"),
78 mbids=(),
79 sort_names=("A, Artist", "B, Artist"),
80 log_label="ARTISTS tag",
81 )
82 assert result == [
83 ("Artist A", None, "A, Artist"),
84 ("Artist B", None, "B, Artist"),
85 ]
86 provider.mass.get_provider.assert_not_called() # type: ignore[attr-defined]
87
88 @pytest.mark.asyncio
89 async def test_short_sort_names_pad_with_none(self) -> None:
90 """A sort_names tuple shorter than parsed_names yields None for the tail."""
91 provider = _create_provider()
92 result = await provider._resolve_artists_with_mbids(
93 parsed_names=("Artist A", "Artist B", "Artist C"),
94 mbids=("mbid-a", "mbid-b", "mbid-c"),
95 sort_names=("A, Artist",),
96 log_label="ARTISTS tag",
97 )
98 assert result == [
99 ("Artist A", "mbid-a", "A, Artist"),
100 ("Artist B", "mbid-b", None),
101 ("Artist C", "mbid-c", None),
102 ]
103
104 @pytest.mark.asyncio
105 async def test_mismatch_without_provider_falls_back_to_tags(self) -> None:
106 """A count mismatch with no MusicBrainz provider falls back to tag names."""
107 provider = _create_provider(mb_provider=None)
108 result = await provider._resolve_artists_with_mbids(
109 parsed_names=("Artist A & Artist B",),
110 mbids=("mbid-a", "mbid-b"),
111 sort_names=(),
112 log_label="ARTISTS tag",
113 )
114 assert result == [("Artist A & Artist B", "mbid-a", None)]
115 provider.logger.warning.assert_called_once() # type: ignore[attr-defined]
116
117 @pytest.mark.asyncio
118 async def test_mismatch_resolves_canonical_names(self) -> None:
119 """A count mismatch queries MusicBrainz and returns its canonical triples."""
120 mb_provider = _make_mb_provider(
121 [
122 ("Artist A", "mbid-a", "A, Artist"),
123 ("Artist B", "mbid-b", "B, Artist"),
124 ]
125 )
126 provider = _create_provider(mb_provider=mb_provider)
127 result = await provider._resolve_artists_with_mbids(
128 parsed_names=("Artist A & Artist B",),
129 mbids=("mbid-a", "mbid-b"),
130 sort_names=(),
131 log_label="ARTISTS tag",
132 )
133 assert result == [
134 ("Artist A", "mbid-a", "A, Artist"),
135 ("Artist B", "mbid-b", "B, Artist"),
136 ]
137 mb_provider.resolve_artists_from_mbids.assert_awaited_once_with(("mbid-a", "mbid-b"))
138
139 @pytest.mark.asyncio
140 async def test_mismatch_drops_failed_lookups(self) -> None:
141 """MBIDs whose lookup returned None are dropped from the resolved list."""
142 mb_provider = _make_mb_provider(
143 [
144 ("Artist A", "mbid-a", "A, Artist"),
145 None,
146 ("Artist C", "mbid-c", "C, Artist"),
147 ]
148 )
149 provider = _create_provider(mb_provider=mb_provider)
150 result = await provider._resolve_artists_with_mbids(
151 parsed_names=("Artist A & Artist C",),
152 mbids=("mbid-a", "mbid-b", "mbid-c"),
153 sort_names=(),
154 log_label="ARTISTS tag",
155 )
156 assert result == [
157 ("Artist A", "mbid-a", "A, Artist"),
158 ("Artist C", "mbid-c", "C, Artist"),
159 ]
160
161 @pytest.mark.asyncio
162 async def test_all_lookups_fail_falls_back_to_tags(self) -> None:
163 """When every MusicBrainz lookup fails, tag-parsed names are used instead."""
164 mb_provider = _make_mb_provider([None, None])
165 provider = _create_provider(mb_provider=mb_provider)
166 result = await provider._resolve_artists_with_mbids(
167 parsed_names=("Artist A & Artist B",),
168 mbids=("mbid-a", "mbid-b"),
169 sort_names=(),
170 log_label="ARTISTS tag",
171 )
172 assert result == [("Artist A & Artist B", "mbid-a", None)]
173 provider.logger.warning.assert_called_once() # type: ignore[attr-defined]
174
175
176class TestMatchAlbumArtist:
177 """Test reuse of an existing album artist when parsing track artists."""
178
179 def test_no_album_returns_none(self) -> None:
180 """With no album there is nothing to match against."""
181 provider = _create_provider()
182 assert provider._match_album_artist(None, "Artist A", MBID_A) is None
183
184 def test_matches_on_mbid_despite_different_name(self) -> None:
185 """
186 The album artist is reused when MBIDs agree even if names differ.
187
188 This is the mixed case: the album-artist tag count matched its MBID
189 count (so it kept the tag-parsed name), while the track-artist count
190 mismatched and was resolved to a canonical MusicBrainz name.
191 """
192 album_artist = _make_artist("Above & Beyond", mbid=MBID_A)
193 album = _make_album(album_artist)
194 provider = _create_provider()
195 assert provider._match_album_artist(album, "Above and Beyond", MBID_A) is album_artist
196
197 def test_matches_on_name_when_no_mbid(self) -> None:
198 """Without a track-artist MBID, fall back to an exact name match."""
199 album_artist = _make_artist("Artist A")
200 album = _make_album(album_artist)
201 provider = _create_provider()
202 assert provider._match_album_artist(album, "Artist A", None) is album_artist
203
204 def test_no_match_creates_nothing(self) -> None:
205 """A different artist (no shared MBID, no shared name) does not match."""
206 album = _make_album(_make_artist("Artist A", mbid=MBID_A))
207 provider = _create_provider()
208 assert provider._match_album_artist(album, "Artist B", MBID_B) is None
209
210 def test_name_match_still_applies_with_unset_album_mbid(self) -> None:
211 """
212 A track MBID does not prevent the legacy name match when album has none.
213
214 Matching is additive (MBID or name), so an album artist with no MBID is
215 still reused by name even when the track artist carries one.
216 """
217 album_artist = _make_artist("Artist A")
218 album = _make_album(album_artist)
219 provider = _create_provider()
220 assert provider._match_album_artist(album, "Artist A", MBID_A) is album_artist
221