/
/
/
1"""A slow or failing provider must never stall or break the recommendations endpoints."""
2
3from __future__ import annotations
4
5import asyncio
6from typing import TYPE_CHECKING
7from unittest.mock import AsyncMock, MagicMock, Mock
8
9from music_assistant_models.enums import ProviderType
10from music_assistant_models.media_items import RecommendationFolder, UniqueList
11
12import music_assistant.controllers.music.recommendations.controller as rec_controller
13from music_assistant.mass import MusicAssistant
14from music_assistant.models.music_provider import MusicProvider
15from music_assistant.models.recommendation_payload import RecommendationPayloadMixin
16from music_assistant.providers.recommendations import LibraryRecommendationsProvider
17
18if TYPE_CHECKING:
19 from typing import Any
20
21 import pytest
22 from music_assistant_models.media_items import BrowseFolder, ItemMapping, MediaItemType
23
24
25class _HangingRowsProvider(MusicProvider):
26 async def get_recommendations(self) -> list[RecommendationFolder]:
27 await asyncio.sleep(3600)
28 return []
29
30
31class _RaisingRowsProvider(MusicProvider):
32 async def get_recommendations(self) -> list[RecommendationFolder]:
33 raise RuntimeError("provider boom")
34
35
36class _HealthyRowsProvider(MusicProvider):
37 async def get_recommendations(self) -> list[RecommendationFolder]:
38 return [
39 RecommendationFolder(
40 item_id="healthy_row",
41 provider=self.instance_id,
42 name="Healthy Row",
43 translation_key="healthy_row_key",
44 icon="mdi-healthy",
45 )
46 ]
47
48
49class _HangingItemsProvider(MusicProvider):
50 async def get_recommendation_items(
51 self, item_id: str
52 ) -> UniqueList[MediaItemType | ItemMapping | BrowseFolder]:
53 await asyncio.sleep(3600)
54 return UniqueList()
55
56
57class _RaisingItemsProvider(MusicProvider):
58 async def get_recommendation_items(
59 self, item_id: str
60 ) -> UniqueList[MediaItemType | ItemMapping | BrowseFolder]:
61 raise RuntimeError("provider boom")
62
63
64class _PayloadRowsProvider(MusicProvider, RecommendationPayloadMixin):
65 """A fake provider serving rows via RecommendationPayloadMixin, fetch gated by an event."""
66
67 gate: asyncio.Event
68 payload: list[RecommendationFolder]
69 fetch_count: int = 0
70
71 async def get_recommendations(self) -> list[RecommendationFolder]:
72 return await self._recommendation_rows_from_payload()
73
74 async def _fetch_recommendation_payload(self) -> list[RecommendationFolder]:
75 self.fetch_count += 1
76 await self.gate.wait()
77 return self.payload
78
79
80def _build(provider_cls: type[MusicProvider], instance_id: str = "fake_instance") -> MusicProvider:
81 """Construct a minimal provider with stubbed mass/manifest/config."""
82 mass = MagicMock()
83 manifest = MagicMock()
84 manifest.type = ProviderType.MUSIC
85 manifest.domain = "fake"
86 config = MagicMock()
87 config.name = "Fake Provider"
88 config.instance_id = instance_id
89 config.get_value = MagicMock(return_value="GLOBAL")
90 return provider_cls(mass, manifest, config, supported_features=set())
91
92
93async def test_hanging_provider_rows_dropped_healthy_rows_kept(
94 mass: MusicAssistant, monkeypatch: pytest.MonkeyPatch
95) -> None:
96 """A provider that never returns its rows is skipped; healthy rows still return."""
97 monkeypatch.setattr(rec_controller, "RECOMMENDATIONS_ROWS_TIMEOUT", 0.05)
98 hanging = _build(_HangingRowsProvider, instance_id="hanging")
99 healthy = _build(_HealthyRowsProvider, instance_id="healthy")
100 recommendations_provider = mass.get_provider("recommendations")
101 assert isinstance(recommendations_provider, LibraryRecommendationsProvider)
102 monkeypatch.setattr(
103 mass,
104 "get_providers_supporting_feature",
105 lambda *_a, **_k: [hanging, healthy, recommendations_provider],
106 )
107 folders = await mass.music.recommendations.get_recommendations()
108 item_ids = {f.item_id for f in folders}
109 assert "healthy_row" in item_ids
110 assert "recently_played" in item_ids # builtin rows unaffected
111 assert not any(f.provider == "hanging" for f in folders)
112
113
114async def test_raising_provider_rows_isolated(
115 mass: MusicAssistant, monkeypatch: pytest.MonkeyPatch
116) -> None:
117 """A provider whose rows call raises is isolated; other rows still return."""
118 raising = _build(_RaisingRowsProvider, instance_id="raising")
119 recommendations_provider = mass.get_provider("recommendations")
120 assert isinstance(recommendations_provider, LibraryRecommendationsProvider)
121 monkeypatch.setattr(
122 mass,
123 "get_providers_supporting_feature",
124 lambda *_a, **_k: [raising, recommendations_provider],
125 )
126 folders = await mass.music.recommendations.get_recommendations()
127 assert "recently_played" in {f.item_id for f in folders}
128 assert not any(f.provider == "raising" for f in folders)
129
130
131async def test_provider_items_timeout_returns_empty(
132 mass: MusicAssistant, monkeypatch: pytest.MonkeyPatch
133) -> None:
134 """A provider that never returns its items yields an empty list once the timeout elapses."""
135 monkeypatch.setattr(rec_controller, "RECOMMENDATIONS_ITEMS_TIMEOUT", 0.05)
136 hanging = _build(_HangingItemsProvider)
137 monkeypatch.setattr(mass, "get_provider", lambda *_a, **_k: hanging)
138 items = await mass.music.recommendations.get_recommendation_items("fake_instance", "row1")
139 assert items == []
140
141
142async def test_provider_items_error_returns_empty(
143 mass: MusicAssistant, monkeypatch: pytest.MonkeyPatch
144) -> None:
145 """A provider whose items call raises yields an empty list, not an error."""
146 raising = _build(_RaisingItemsProvider)
147 monkeypatch.setattr(mass, "get_provider", lambda *_a, **_k: raising)
148 items = await mass.music.recommendations.get_recommendation_items("fake_instance", "row1")
149 assert items == []
150
151
152async def test_payload_mixin_rows_timeout_does_not_cancel_shared_fetch(
153 mass: MusicAssistant, monkeypatch: pytest.MonkeyPatch
154) -> None:
155 """
156 The controller's rows timeout degrades gracefully without killing the shared fetch.
157
158 A RecommendationPayloadMixin provider's shared payload fetch outlives the controller's
159 own RECOMMENDATIONS_ROWS_TIMEOUT: the timed-out rows call returns without this provider's
160 rows, but the fetch keeps running in the background, warms the cache, and a later rows
161 call serves it without hitting the backend again.
162 """
163 monkeypatch.setattr(rec_controller, "RECOMMENDATIONS_ROWS_TIMEOUT", 0.05)
164 gate = asyncio.Event()
165 provider = _build(_PayloadRowsProvider, instance_id="payload_prov")
166 assert isinstance(provider, _PayloadRowsProvider)
167 provider.gate = gate
168 provider.payload = [
169 RecommendationFolder(
170 item_id="payload_row",
171 provider="payload_prov",
172 name="Payload Row",
173 translation_key="payload_row_key",
174 icon="mdi-payload",
175 )
176 ]
177
178 cache_store: dict[str, Any] = {}
179
180 async def _cache_get(key: str, **_kwargs: Any) -> tuple[Any, bool, bool]:
181 if key in cache_store:
182 return cache_store[key], True, True
183 return None, False, False
184
185 async def _cache_set(key: str, data: Any, **_kwargs: Any) -> None:
186 cache_store[key] = data
187
188 background_tasks: list[asyncio.Future[Any]] = []
189
190 def _create_task(target: Any, *_args: Any, **_kwargs: Any) -> asyncio.Future[Any]:
191 task: asyncio.Future[Any] = asyncio.ensure_future(target)
192 background_tasks.append(task)
193 return task
194
195 provider.mass.cache.get_with_freshness = AsyncMock(side_effect=_cache_get) # type: ignore[method-assign]
196 provider.mass.cache.set = AsyncMock(side_effect=_cache_set) # type: ignore[method-assign]
197 provider.mass.create_task = Mock(side_effect=_create_task) # type: ignore[method-assign]
198 recommendations_provider = mass.get_provider("recommendations")
199 assert isinstance(recommendations_provider, LibraryRecommendationsProvider)
200 monkeypatch.setattr(
201 mass,
202 "get_providers_supporting_feature",
203 lambda *_a, **_k: [provider, recommendations_provider],
204 )
205
206 folders = await mass.music.recommendations.get_recommendations()
207 assert not any(f.provider == "payload_prov" for f in folders)
208 assert any(f.provider == "recommendations" for f in folders) # other rows unaffected
209 assert provider.fetch_count == 1 # the fetch started but did not finish in time
210
211 # release the gate: the shared fetch, still running in the background, completes
212 gate.set()
213 payload_task = provider._recommendation_payload_task
214 assert payload_task is not None
215 await payload_task
216 await asyncio.gather(*background_tasks) # let the cache-store task land
217
218 # cache is warmed; a later rows call serves it without re-fetching the backend
219 folders_after = await mass.music.recommendations.get_recommendations()
220 assert any(f.provider == "payload_prov" and f.item_id == "payload_row" for f in folders_after)
221 assert provider.fetch_count == 1
222