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