/
/
1"""Tests for detecting playlist entries that lost their manually set name or artwork."""
2
3from __future__ import annotations
4
5from unittest.mock import MagicMock
6
7from music_assistant_models.enums import ImageType, MediaType
8
9from music_assistant.helpers.playlists import (
10 ArtistInfo,
11 ImageInfo,
12 PlaylistItem,
13 ProviderMappingInfo,
14)
15from music_assistant.providers.builtin import BuiltinProvider
16from music_assistant.providers.builtin.constants import StoredItem
17
18STREAM_URL = "http://stream.example.com/live"
19
20
21def _make_provider() -> BuiltinProvider:
22 """Create a minimal BuiltinProvider with a mocked manifest and mass."""
23 prov = object.__new__(BuiltinProvider)
24 prov.mass = MagicMock()
25 prov.logger = MagicMock()
26 prov.manifest = MagicMock()
27 prov.manifest.domain = "builtin"
28 return prov
29
30
31def _make_entry(
32 name: str | None = "My Station",
33 image_path: str | None = None,
34 domain: str = "builtin",
35 media_type: str = MediaType.RADIO.value,
36) -> PlaylistItem:
37 """Create a playlist entry as parsed from an M3U file."""
38 metadata = {"media_type": media_type}
39 if name is not None:
40 metadata["name"] = name
41 return PlaylistItem(
42 path=f"builtin://radio/{STREAM_URL}",
43 title=name,
44 metadata=metadata,
45 providers=[ProviderMappingInfo(domain=domain, item_id=STREAM_URL)],
46 images=[ImageInfo(type=ImageType.THUMB.value, path=image_path, provider="builtin")]
47 if image_path
48 else [],
49 )
50
51
52def _stored(
53 name: str = "My Station", image_url: str | None = None
54) -> dict[str, dict[str, StoredItem]]:
55 """Create the per-media-type lookup of stored items keyed on item_id."""
56 stored_item = StoredItem(item_id=STREAM_URL, name=name)
57 if image_url:
58 stored_item["image_url"] = image_url
59 return {
60 MediaType.RADIO.value: {STREAM_URL: stored_item},
61 MediaType.TRACK.value: {},
62 }
63
64
65def test_no_builtin_mapping_is_never_stale() -> None:
66 """An entry without a builtin #EXTPROV is not ours to repair."""
67 prov = _make_provider()
68
69 assert prov._stored_details_differ(_make_entry(domain="spotify"), _stored()) is False
70
71
72def test_no_stored_item_is_never_stale() -> None:
73 """An entry that was not added manually has no stored details to compare against."""
74 prov = _make_provider()
75 lookup: dict[str, dict[str, StoredItem]] = {
76 MediaType.RADIO.value: {},
77 MediaType.TRACK.value: {},
78 }
79
80 assert prov._stored_details_differ(_make_entry(), lookup) is False
81
82
83def test_other_media_type_is_never_stale() -> None:
84 """Only manually added tracks and radio stations carry stored details."""
85 prov = _make_provider()
86
87 entry = _make_entry(media_type=MediaType.PODCAST_EPISODE.value)
88 assert prov._stored_details_differ(entry, _stored()) is False
89
90
91def test_matching_details_are_not_stale() -> None:
92 """An entry that already carries the stored name and image is left alone."""
93 prov = _make_provider()
94
95 entry = _make_entry(name="My Station", image_path="http://img.example.com/logo.png")
96 lookup = _stored(name="My Station", image_url="http://img.example.com/logo.png")
97 assert prov._stored_details_differ(entry, lookup) is False
98
99
100def test_differing_name_is_stale() -> None:
101 """An entry holding the name broadcast by the stream needs repair."""
102 prov = _make_provider()
103
104 entry = _make_entry(name="ICY BROADCAST NAME")
105 assert prov._stored_details_differ(entry, _stored(name="My Station")) is True
106
107
108def test_missing_stored_image_is_stale() -> None:
109 """An entry that lost the manually set artwork needs repair."""
110 prov = _make_provider()
111
112 entry = _make_entry(image_path=STREAM_URL)
113 lookup = _stored(image_url="http://img.example.com/logo.png")
114 assert prov._stored_details_differ(entry, lookup) is True
115
116
117def test_stored_image_as_another_type_is_stale() -> None:
118 """The stored image only counts as present when it is the thumbnail that shows."""
119 prov = _make_provider()
120 entry = _make_entry(image_path=STREAM_URL)
121 entry.images.append(
122 ImageInfo(
123 type=ImageType.FANART.value, path="http://img.example.com/logo.png", provider="builtin"
124 )
125 )
126
127 lookup = _stored(image_url="http://img.example.com/logo.png")
128 assert prov._stored_details_differ(entry, lookup) is True
129
130
131def test_stored_item_without_image_is_not_stale() -> None:
132 """Cover art from the stream stays as long as the user set no image of their own."""
133 prov = _make_provider()
134
135 entry = _make_entry(image_path=STREAM_URL)
136 assert prov._stored_details_differ(entry, _stored()) is False
137
138
139def test_restore_writes_stored_name_and_image() -> None:
140 """Restoring a radio entry puts the stored name in both #EXTMA and #EXTINF."""
141 prov = _make_provider()
142 entry = _make_entry(name="ICY BROADCAST NAME", image_path=STREAM_URL)
143 lookup = _stored(image_url="http://img.example.com/logo.png")
144
145 prov._restore_stored_details(entry, lookup)
146
147 assert entry.metadata is not None
148 assert entry.metadata["name"] == "My Station"
149 assert entry.title == "My Station"
150 assert [(x.path, x.remotely_accessible) for x in entry.images] == [
151 ("http://img.example.com/logo.png", True)
152 ]
153 # a repaired entry must not be picked up again, or every run rewrites the file
154 assert prov._stored_details_differ(entry, lookup) is False
155
156
157def test_a_padded_stored_name_settles() -> None:
158 """An M3U file cannot hold surrounding whitespace, so it must not count as a difference."""
159 prov = _make_provider()
160 entry = _make_entry(name="My Station")
161
162 assert prov._stored_details_differ(entry, _stored(name=" My Station ")) is False
163
164
165def test_restore_keeps_non_thumb_images() -> None:
166 """Restoring the artwork replaces the thumbnail but keeps other image types."""
167 prov = _make_provider()
168 entry = _make_entry(image_path=STREAM_URL)
169 entry.images.append(
170 ImageInfo(type=ImageType.FANART.value, path="http://img.example.com/art.png", provider="x")
171 )
172
173 prov._restore_stored_details(entry, _stored(image_url="http://img.example.com/logo.png"))
174
175 assert [x.type for x in entry.images] == [ImageType.THUMB.value, ImageType.FANART.value]
176
177
178def test_restore_rebuilds_a_track_title_from_its_artists() -> None:
179 """A track's #EXTINF holds "<artists> - <name>", the way the entry was written."""
180 prov = _make_provider()
181 entry = _make_entry(name="Some Artist - Old Name", media_type=MediaType.TRACK.value)
182 entry.artists = [
183 ArtistInfo(
184 name="Some Artist", provider_domain="builtin", item_id="x", provider_instance="builtin"
185 )
186 ]
187 stored_item = StoredItem(item_id=STREAM_URL, name="My Song")
188 lookup = {MediaType.RADIO.value: {}, MediaType.TRACK.value: {STREAM_URL: stored_item}}
189
190 prov._restore_stored_details(entry, lookup)
191
192 assert entry.metadata is not None
193 assert entry.metadata["name"] == "My Song"
194 assert entry.title == "Some Artist - My Song"
195 assert prov._stored_details_differ(entry, lookup) is False
196
197
198def test_restore_without_stored_item_changes_nothing() -> None:
199 """An entry that was not added manually is left untouched."""
200 prov = _make_provider()
201 entry = _make_entry(name="Broadcast Name")
202 lookup: dict[str, dict[str, StoredItem]] = {
203 MediaType.RADIO.value: {},
204 MediaType.TRACK.value: {},
205 }
206
207 prov._restore_stored_details(entry, lookup)
208
209 assert entry.metadata is not None
210 assert entry.metadata["name"] == "Broadcast Name"
211 assert entry.title == "Broadcast Name"
212