/
/
/
1"""Tests for playlist artwork integration in metadata controller."""
2
3from __future__ import annotations
4
5from collections.abc import AsyncGenerator, AsyncIterator
6from pathlib import Path
7from typing import Any
8from unittest.mock import AsyncMock, MagicMock
9
10import pytest
11from music_assistant_models.enums import ImageType, ProviderFeature
12from music_assistant_models.errors import ProviderUnavailableError
13from music_assistant_models.media_items import MediaItemImage, Playlist, ProviderMapping, Track
14from music_assistant_models.media_items.metadata import MediaItemMetadata
15from music_assistant_models.unique_list import UniqueList
16
17from music_assistant.controllers.metadata.enrichment import MetadataEnrichmentMixin
18
19
20async def _empty_tracks_iter(
21 _item_id: str, _provider: str, *_args: Any, **_kwargs: Any
22) -> AsyncGenerator[Track]:
23 """Empty async generator for mocking playlist tracks."""
24 if False: # pragma: no cover
25 yield # type: ignore[unreachable]
26
27
28def _make_playlist() -> Playlist:
29 """Create a test playlist."""
30 return Playlist(
31 item_id="test_playlist_1",
32 provider="test_provider",
33 name="Test Playlist",
34 provider_mappings={
35 ProviderMapping(
36 item_id="test_playlist_1",
37 provider_domain="test_provider",
38 provider_instance="test",
39 )
40 },
41 metadata=MediaItemMetadata(),
42 )
43
44
45@pytest.mark.asyncio
46async def test_update_playlist_metadata_calls_provider(tmp_path: Any) -> None:
47 """_update_playlist_metadata should call get_playlist_metadata on providers."""
48 enrichment = MetadataEnrichmentMixin()
49 enrichment.logger = MagicMock()
50 enrichment.mass = MagicMock()
51 enrichment._collage_images_dir = str(tmp_path / "collage")
52 enrichment.create_collage_image = AsyncMock(return_value=None) # type: ignore[method-assign]
53
54 # Mock metadata provider
55 provider = MagicMock()
56 provider.name = "playlist_metadata"
57 provider.supported_features = {ProviderFeature.PLAYLIST_METADATA}
58 provider.get_playlist_metadata = AsyncMock(
59 return_value=MediaItemMetadata(
60 images=UniqueList(
61 [
62 MediaItemImage(
63 type=ImageType.THUMB,
64 path="/fake/thumb.jpg",
65 provider="playlist_metadata",
66 remotely_accessible=False,
67 ),
68 MediaItemImage(
69 type=ImageType.FANART,
70 path="/fake/fanart.jpg",
71 provider="playlist_metadata",
72 remotely_accessible=False,
73 ),
74 ]
75 )
76 )
77 )
78 enrichment.providers = [provider] # type: ignore[misc]
79
80 playlist = _make_playlist()
81 enrichment.mass.music.playlists.tracks = _empty_tracks_iter
82 enrichment.mass.music.playlists.update_item_in_library = AsyncMock()
83
84 await enrichment._update_playlist_metadata(playlist, force_refresh=False)
85
86 provider.get_playlist_metadata.assert_called_once_with(playlist)
87 assert any(img.provider == "playlist_metadata" for img in (playlist.metadata.images or []))
88
89
90@pytest.mark.asyncio
91async def test_update_playlist_metadata_handles_provider_exception(tmp_path: Any) -> None:
92 """_update_playlist_metadata should handle MusicAssistantError exceptions from providers gracefully."""
93 enrichment = MetadataEnrichmentMixin()
94 enrichment.logger = MagicMock()
95 enrichment.mass = MagicMock()
96 enrichment._collage_images_dir = str(tmp_path / "collage")
97 enrichment.create_collage_image = AsyncMock(return_value=None) # type: ignore[method-assign]
98
99 # Mock metadata provider that raises MusicAssistantError
100 provider = MagicMock()
101 provider.name = "playlist_metadata"
102 provider.supported_features = {ProviderFeature.PLAYLIST_METADATA}
103 provider.get_playlist_metadata = AsyncMock(
104 side_effect=ProviderUnavailableError("Test provider unavailable")
105 )
106 enrichment.providers = [provider] # type: ignore[misc]
107
108 playlist = _make_playlist()
109 enrichment.mass.music.playlists.tracks = _empty_tracks_iter
110 enrichment.mass.music.playlists.update_item_in_library = AsyncMock()
111
112 # Should not raise, just log warning
113 await enrichment._update_playlist_metadata(playlist, force_refresh=False)
114
115 enrichment.logger.warning.assert_called_once()
116
117
118@pytest.mark.asyncio
119async def test_update_playlist_metadata_preserves_existing_thumb(tmp_path: Any) -> None:
120 """_update_playlist_metadata should preserve existing non-collage thumb when providers return None."""
121 enrichment = MetadataEnrichmentMixin()
122 enrichment.logger = MagicMock()
123 enrichment.mass = MagicMock()
124 enrichment._collage_images_dir = str(tmp_path / "collage")
125 enrichment.create_collage_image = AsyncMock(return_value=None) # type: ignore[method-assign]
126
127 # Mock metadata provider that returns None
128 provider = MagicMock()
129 provider.name = "playlist_metadata"
130 provider.supported_features = {ProviderFeature.PLAYLIST_METADATA}
131 provider.get_playlist_metadata = AsyncMock(return_value=None)
132 enrichment.providers = [provider] # type: ignore[misc]
133
134 playlist = _make_playlist()
135 existing_thumb = MediaItemImage(
136 type=ImageType.THUMB,
137 path="/existing/thumb.jpg",
138 provider="some_provider",
139 remotely_accessible=False,
140 )
141 playlist.metadata.images = UniqueList([existing_thumb])
142
143 enrichment.mass.music.playlists.tracks = _empty_tracks_iter
144 enrichment.mass.music.playlists.update_item_in_library = AsyncMock()
145
146 await enrichment._update_playlist_metadata(playlist, force_refresh=False)
147
148 # Existing thumb should be preserved
149 assert any(
150 img.type == ImageType.THUMB and img.path == "/existing/thumb.jpg"
151 for img in (playlist.metadata.images or [])
152 )
153
154
155@pytest.mark.asyncio
156async def test_update_playlist_metadata_preserves_collage_thumb_when_no_new_generated(
157 tmp_path: Any,
158) -> None:
159 """_update_playlist_metadata should preserve existing collage thumb when no new thumb is generated."""
160 enrichment = MetadataEnrichmentMixin()
161 enrichment.logger = MagicMock()
162 enrichment.mass = MagicMock()
163 enrichment._collage_images_dir = str(tmp_path / "collage")
164 Path(enrichment._collage_images_dir).mkdir(parents=True, exist_ok=True)
165 enrichment.create_collage_image = AsyncMock() # type: ignore[method-assign]
166
167 # Mock metadata provider that returns None
168 provider = MagicMock()
169 provider.name = "playlist_metadata"
170 provider.supported_features = {ProviderFeature.PLAYLIST_METADATA}
171 provider.get_playlist_metadata = AsyncMock(return_value=None)
172 enrichment.providers = [provider] # type: ignore[misc]
173
174 playlist = _make_playlist()
175 # Existing thumb is a collage
176 old_collage_thumb = MediaItemImage(
177 type=ImageType.THUMB,
178 path=f"{enrichment._collage_images_dir}/old_thumb.jpg",
179 provider="builtin",
180 remotely_accessible=False,
181 )
182 playlist.metadata.images = UniqueList([old_collage_thumb])
183
184 enrichment.mass.music.playlists.tracks = _empty_tracks_iter
185 enrichment.mass.music.playlists.update_item_in_library = AsyncMock()
186
187 await enrichment._update_playlist_metadata(playlist, force_refresh=False)
188
189 # Should preserve old collage, not generate new thumb collage
190 assert any(
191 img.type == ImageType.THUMB and img.path == old_collage_thumb.path
192 for img in (playlist.metadata.images or [])
193 )
194 # create_collage_image may be called for fanart, but not for thumb
195 if enrichment.create_collage_image.called:
196 # All calls should be for fanart=True, never for thumb (fanart=False)
197 for call in enrichment.create_collage_image.call_args_list:
198 assert call.kwargs.get("fanart") is True
199
200
201@pytest.mark.asyncio
202async def test_update_playlist_metadata_skips_providers_without_feature(tmp_path: Any) -> None:
203 """_update_playlist_metadata should skip providers without PLAYLIST_METADATA feature."""
204 enrichment = MetadataEnrichmentMixin()
205 enrichment.logger = MagicMock()
206 enrichment.mass = MagicMock()
207 enrichment._collage_images_dir = str(tmp_path / "collage")
208 enrichment.create_collage_image = AsyncMock(return_value=None) # type: ignore[method-assign]
209
210 # Provider without PLAYLIST_METADATA feature
211 provider_without_feature = MagicMock()
212 provider_without_feature.name = "other_provider"
213 provider_without_feature.supported_features = set()
214
215 enrichment.providers = [provider_without_feature] # type: ignore[misc]
216
217 playlist = _make_playlist()
218 enrichment.mass.music.playlists.tracks = _empty_tracks_iter
219 enrichment.mass.music.playlists.update_item_in_library = AsyncMock()
220
221 # Should not raise, just skip the provider
222 await enrichment._update_playlist_metadata(playlist, force_refresh=False)
223
224 # Should have called update_item_in_library
225 enrichment.mass.music.playlists.update_item_in_library.assert_called_once()
226
227
228@pytest.mark.asyncio
229async def test_update_playlist_metadata_calls_providers_for_dynamic_playlists(
230 tmp_path: Any,
231) -> None:
232 """
233 _update_playlist_metadata should call providers even for dynamic playlists.
234
235 Providers decide themselves whether to return metadata (e.g., playlist_metadata
236 provider skips provider playlists via CONF_SKIP_PROVIDER_PLAYLISTS).
237 """
238 enrichment = MetadataEnrichmentMixin()
239 enrichment.logger = MagicMock()
240 enrichment.mass = MagicMock()
241 enrichment._collage_images_dir = str(tmp_path / "collage")
242
243 # Mock metadata provider that returns None for dynamic playlists
244 provider = MagicMock()
245 provider.name = "playlist_metadata"
246 provider.supported_features = {ProviderFeature.PLAYLIST_METADATA}
247 provider.get_playlist_metadata = AsyncMock(return_value=None)
248 enrichment.providers = [provider] # type: ignore[misc]
249
250 # Mock playlist tracks iterator (returns no tracks)
251 async def mock_tracks(
252 item_id: str, # noqa: ARG001
253 provider: str, # noqa: ARG001
254 ) -> AsyncIterator[Track]:
255 """Empty async generator."""
256 if False:
257 yield # type: ignore[unreachable] # pragma: no cover
258
259 enrichment.mass.music.playlists.tracks = mock_tracks
260
261 # Mock update_item_in_library
262 enrichment.mass.music.playlists.update_item_in_library = AsyncMock()
263
264 # Create dynamic playlist (e.g., Pandora station, Apple Music station)
265 playlist = Playlist(
266 item_id="dynamic_station_1",
267 provider="pandora",
268 name="Dynamic Station",
269 is_dynamic=True,
270 provider_mappings={
271 ProviderMapping(
272 item_id="dynamic_station_1",
273 provider_domain="pandora",
274 provider_instance="pandora_instance",
275 )
276 },
277 metadata=MediaItemMetadata(),
278 )
279
280 await enrichment._update_playlist_metadata(playlist, force_refresh=True)
281
282 # Provider should be called (provider decides whether to return metadata)
283 provider.get_playlist_metadata.assert_called_once_with(playlist)
284
285
286@pytest.mark.asyncio
287async def test_update_playlist_metadata_allows_smart_playlists(tmp_path: Any) -> None:
288 """_update_playlist_metadata should allow smart playlists despite is_dynamic=True."""
289 enrichment = MetadataEnrichmentMixin()
290 enrichment.logger = MagicMock()
291 enrichment.mass = MagicMock()
292 enrichment._collage_images_dir = str(tmp_path / "collage")
293 enrichment.create_collage_image = AsyncMock(return_value=None) # type: ignore[method-assign]
294
295 # Mock metadata provider
296 provider = MagicMock()
297 provider.name = "playlist_metadata"
298 provider.supported_features = {ProviderFeature.PLAYLIST_METADATA}
299 provider.get_playlist_metadata = AsyncMock(
300 return_value=MediaItemMetadata(
301 images=UniqueList(
302 [
303 MediaItemImage(
304 type=ImageType.THUMB,
305 path="/fake/thumb.jpg",
306 provider="playlist_metadata",
307 remotely_accessible=False,
308 )
309 ]
310 )
311 )
312 )
313 enrichment.providers = [provider] # type: ignore[misc]
314
315 # Create smart playlist with is_dynamic=True
316 playlist = Playlist(
317 item_id="smart_playlist_1",
318 provider="library",
319 name="Smart Playlist",
320 is_dynamic=True, # This test covers the dynamic smart-playlist exception path
321 provider_mappings={
322 ProviderMapping(
323 item_id="smart_playlist_1",
324 provider_domain="smart_playlist",
325 provider_instance="smart_playlist",
326 )
327 },
328 metadata=MediaItemMetadata(),
329 )
330
331 enrichment.mass.music.playlists.tracks = _empty_tracks_iter
332 enrichment.mass.music.playlists.update_item_in_library = AsyncMock()
333
334 await enrichment._update_playlist_metadata(playlist, force_refresh=True)
335
336 # Should have called provider.get_playlist_metadata despite is_dynamic=True
337 provider.get_playlist_metadata.assert_called_once_with(playlist)
338 assert any(img.provider == "playlist_metadata" for img in (playlist.metadata.images or []))
339