/
/
/
1"""Tests for the RadioArtworkMixin name-matching helpers on MetaDataController."""
2
3import pytest
4from music_assistant_models.enums import ImageType
5from music_assistant_models.media_items import (
6 Artist,
7 ItemMapping,
8 MediaItemImage,
9 MediaItemMetadata,
10)
11from music_assistant_models.unique_list import UniqueList
12
13from music_assistant.controllers.metadata import MetaDataController
14from music_assistant.providers.musicbrainz import MusicBrainzReleaseGroup
15
16
17def _artist(name: str) -> Artist | ItemMapping:
18 """Build a minimal artist ItemMapping with the given name."""
19 return ItemMapping(item_id=name, provider="library", name=name)
20
21
22def _release_group(title: str) -> MusicBrainzReleaseGroup:
23 """Build a minimal release group with the given title."""
24 return MusicBrainzReleaseGroup(id=title, title=title)
25
26
27def _controller() -> MetaDataController:
28 """Create a bare MetaDataController without running __init__."""
29 return MetaDataController.__new__(MetaDataController)
30
31
32class TestNormalizeRadioArtistName:
33 """`normalize_radio_artist_name` flips "Last, First" while sparing edge cases."""
34
35 @pytest.mark.parametrize(
36 ("raw", "expected"),
37 [
38 ("Squier, Billy", "Billy Squier"),
39 ("Beatles, The", "The Beatles"),
40 ("Billy Joel", "Billy Joel"),
41 ("Billy_Squier", "Billy Squier"),
42 ("Lipps, Inc.", "Lipps, Inc."),
43 ("Portugal, The Man", "Portugal, The Man"),
44 ("Crosby, Stills & Nash", "Crosby, Stills & Nash"),
45 ("Crosby, Stills and Nash", "Crosby, Stills and Nash"),
46 ("hello, goodbye", "hello, goodbye"),
47 ],
48 )
49 def test_normalize(self, raw: str, expected: str) -> None:
50 """Each raw radio artist string normalizes to the expected display form."""
51 assert MetaDataController.normalize_radio_artist_name(raw) == expected
52
53
54class TestMatchArtistName:
55 """`_match_artist_name` matches names directly and across "The" prefix variants."""
56
57 def test_exact_match(self) -> None:
58 """An exact name matches."""
59 ctrl = _controller()
60 assert ctrl._match_artist_name("Billy Squier", [_artist("Billy Squier")]) is True
61
62 def test_the_prefix_on_library_artist(self) -> None:
63 """A search without "The" matches a library artist that carries the prefix."""
64 ctrl = _controller()
65 assert ctrl._match_artist_name("Beatles", [_artist("The Beatles")]) is True
66
67 def test_no_match(self) -> None:
68 """Unrelated names do not match."""
69 ctrl = _controller()
70 assert ctrl._match_artist_name("Madonna", [_artist("Prince")]) is False
71
72 def test_empty_artist_list(self) -> None:
73 """No artists means no match."""
74 ctrl = _controller()
75 assert ctrl._match_artist_name("Anyone", []) is False
76
77
78class TestGetThumbImage:
79 """`_get_thumb_image` extracts only the THUMB image from metadata."""
80
81 def test_returns_only_thumb(self) -> None:
82 """A THUMB image is returned in isolation, dropping other image types."""
83 ctrl = _controller()
84 thumb = MediaItemImage(type=ImageType.THUMB, path="thumb.jpg", provider="theaudiodb")
85 fanart = MediaItemImage(type=ImageType.FANART, path="fanart.jpg", provider="theaudiodb")
86 metadata = MediaItemMetadata(images=UniqueList([fanart, thumb]))
87 result = ctrl._get_thumb_image(metadata)
88 assert result is not None
89 assert result.images is not None
90 assert list(result.images) == [thumb]
91
92 def test_returns_none_without_thumb(self) -> None:
93 """Metadata with only non-THUMB images yields None."""
94 ctrl = _controller()
95 fanart = MediaItemImage(type=ImageType.FANART, path="fanart.jpg", provider="theaudiodb")
96 metadata = MediaItemMetadata(images=UniqueList([fanart]))
97 assert ctrl._get_thumb_image(metadata) is None
98
99 def test_returns_none_without_images(self) -> None:
100 """Metadata without any images yields None."""
101 ctrl = _controller()
102 assert ctrl._get_thumb_image(MediaItemMetadata()) is None
103
104
105class TestPrioritizeReleaseGroups:
106 """`_prioritize_release_groups` floats announced-album matches to the front."""
107
108 def test_matching_album_moves_first(self) -> None:
109 """The release group whose title matches the announced album sorts first."""
110 groups = [_release_group("Some Album"), _release_group("Greatest Hits")]
111 result = MetaDataController._prioritize_release_groups(groups, "Greatest Hits")
112 assert [rg.title for rg in result] == ["Greatest Hits", "Some Album"]
113
114 def test_announced_substring_of_title_matches(self) -> None:
115 """A shorter announced album still matches a longer release-group title."""
116 groups = [_release_group("Some Album"), _release_group("Greatest Hits Vol. 1")]
117 result = MetaDataController._prioritize_release_groups(groups, "Greatest Hits")
118 assert result[0].title == "Greatest Hits Vol. 1"
119
120 def test_title_substring_of_announced_matches(self) -> None:
121 """A shorter release-group title still matches a longer announced album."""
122 groups = [_release_group("Live in Tokyo"), _release_group("Hits")]
123 result = MetaDataController._prioritize_release_groups(groups, "Greatest Hits")
124 assert result[0].title == "Hits"
125
126 def test_no_match_preserves_order(self) -> None:
127 """Without any album match the original order is kept (stable sort)."""
128 groups = [_release_group("First"), _release_group("Second")]
129 result = MetaDataController._prioritize_release_groups(groups, "Unrelated")
130 assert [rg.title for rg in result] == ["First", "Second"]
131
132 def test_single_group_returned_unchanged(self) -> None:
133 """A list with fewer than two groups short-circuits and is returned as-is."""
134 groups = [_release_group("Only One")]
135 result = MetaDataController._prioritize_release_groups(groups, "Only One")
136 assert result is groups
137
138 def test_album_normalizing_to_empty_returns_unchanged(self) -> None:
139 """An album name that normalizes to an empty string leaves the order untouched."""
140 groups = [_release_group("First"), _release_group("Second")]
141 result = MetaDataController._prioritize_release_groups(groups, "!!!")
142 assert result is groups
143