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