/
/
/
1"""
2Unit tests for YandexMusicProvider (provider.py).
3
4These tests construct a partial provider instance via ``__new__`` (no
5``__init__``), attach the attributes the method-under-test reads, and
6exercise it directly. The pattern avoids the upstream Music Assistant
7provider-init machinery which would otherwise drag in a real
8``MusicAssistant`` instance. Tests that exercise the cache decorator do
9need a server, and attach the minimal one from the ``mass_minimal`` fixture.
10"""
11
12from __future__ import annotations
13
14import asyncio
15from typing import TYPE_CHECKING, Any
16from unittest import mock
17
18import pytest
19from music_assistant_models.errors import ResourceTemporarilyUnavailable
20
21from music_assistant.models.music_provider import MusicProvider
22from music_assistant.providers.yandex_music.constants import (
23 MY_WAVE_PLAYLIST_ID,
24 TRACK_BATCH_SIZE,
25)
26from music_assistant.providers.yandex_music.provider import YandexMusicProvider
27
28if TYPE_CHECKING:
29 from collections.abc import Callable
30
31 from music_assistant.mass import MusicAssistant
32
33
34def _make_provider() -> tuple[YandexMusicProvider, mock.AsyncMock]:
35 """
36 Return a YandexMusicProvider with a mocked Yandex API client.
37
38 The provider is constructed without calling ``__init__`` so the upstream
39 base-class init does not run. ``client`` is a property over ``_client``,
40 so we assign the underlying attribute directly.
41 """
42 provider = YandexMusicProvider.__new__(YandexMusicProvider)
43 mock_client = mock.AsyncMock()
44 mock_client.user_id = 12345
45 provider._client = mock_client
46 provider.logger = mock.MagicMock()
47 # ``instance_id`` and ``domain`` on the base class read from ``self.config``;
48 # tests that need them attach a minimal config stub.
49 return provider, mock_client
50
51
52# -- M4: get_playlist_tracks must not abort on a single empty batch -----------
53
54
55async def test_get_playlist_tracks_continues_on_empty_batch() -> None:
56 """
57 An empty batch mid-load must skip-and-continue, not discard prior batches.
58
59 With ``TRACK_BATCH_SIZE`` of 50, a 150-track playlist resolves in 3 batches.
60 If batch 2 transiently returns an empty list, the previous behavior raised
61 ``ResourceTemporarilyUnavailable`` and threw away the 50 tracks already
62 fetched from batch 1. The fixed behavior logs a warning and continues so
63 the user sees a partial playlist rather than nothing.
64 """
65 provider, mock_client = _make_provider()
66
67 # Build 3 batches of TRACK_BATCH_SIZE tracks (= 150 track refs total)
68 total = TRACK_BATCH_SIZE * 3
69 track_refs = [type("TR", (), {"track_id": str(i)})() for i in range(total)]
70 playlist_obj = type(
71 "PL",
72 (),
73 {
74 "tracks": track_refs,
75 "track_count": total,
76 },
77 )()
78 mock_client.get_playlist = mock.AsyncMock(return_value=playlist_obj)
79
80 batch1 = [type("YT", (), {"id": i})() for i in range(TRACK_BATCH_SIZE)]
81 batch3 = [
82 type("YT", (), {"id": i})() for i in range(2 * TRACK_BATCH_SIZE, 3 * TRACK_BATCH_SIZE)
83 ]
84 mock_client.get_tracks = mock.AsyncMock(side_effect=[batch1, [], batch3])
85
86 # parse_track does heavy MA object construction; bypass it for this test
87 # so we measure the batch-loop behavior, not the parser.
88 with mock.patch(
89 "music_assistant.providers.yandex_music.provider.parse_track",
90 side_effect=lambda _self, t: t,
91 ):
92 cached: Any = YandexMusicProvider._get_regular_playlist_tracks
93 result = await cached.__wrapped__(provider, "12345:67", 0)
94
95 # 50 from batch 1 + 50 from batch 3 = 100 tracks; the empty batch 2 must
96 # not abort the load.
97 assert len(result) == 2 * TRACK_BATCH_SIZE
98 assert mock_client.get_tracks.await_count == 3
99 # The warning must still be emitted so operators can see the empty batch.
100 assert any(
101 "empty" in str(c.args).lower() or "empty" in str(c.kwargs).lower()
102 for c in provider.logger.warning.call_args_list # type: ignore[attr-defined]
103 )
104
105
106async def test_get_playlist_tracks_raises_only_when_every_batch_is_empty() -> None:
107 """If every batch is empty, the existing terminal guard still fires."""
108 provider, mock_client = _make_provider()
109
110 total = TRACK_BATCH_SIZE * 2
111 track_refs = [type("TR", (), {"track_id": str(i)})() for i in range(total)]
112 playlist_obj = type(
113 "PL",
114 (),
115 {
116 "tracks": track_refs,
117 "track_count": total,
118 },
119 )()
120 mock_client.get_playlist = mock.AsyncMock(return_value=playlist_obj)
121 mock_client.get_tracks = mock.AsyncMock(side_effect=[[], []])
122
123 cached: Any = YandexMusicProvider._get_regular_playlist_tracks
124 with pytest.raises(ResourceTemporarilyUnavailable):
125 await cached.__wrapped__(provider, "12345:67", 0)
126
127
128# -- request coalescing is scoped to the branch that must run per call --------
129
130
131class _StubConfig:
132 """Minimal provider config for the @use_cache decorator."""
133
134 instance_id = "yandex_music_test"
135
136 def get_value(self, key: str, default: Any = None) -> Any:
137 """Return the default for every config key."""
138 return default
139
140
141@pytest.fixture
142async def cached_provider(
143 mass_minimal: MusicAssistant,
144) -> tuple[YandexMusicProvider, mock.AsyncMock]:
145 """Return a provider backed by a real (empty) cache controller."""
146 await mass_minimal.cache._setup_database()
147 provider, mock_client = _make_provider()
148 provider.mass = mass_minimal
149 provider.config = _StubConfig() # type: ignore[assignment]
150 provider.manifest = mock.MagicMock(domain="yandex_music")
151 provider._wave_states = {}
152 return provider, mock_client
153
154
155async def _wait_for_gated_fetch(started: Callable[[], bool]) -> None:
156 """Wait until the gated fetch runs, then let the other callers catch up with it."""
157 for _ in range(200):
158 if started():
159 break
160 await asyncio.sleep(0.01)
161 else:
162 pytest.fail("gated fetch never started")
163 # a caller arriving after the gate is released would start a second fetch,
164 # which the await-count assertions below catch
165 await asyncio.sleep(0.05)
166
167
168async def test_regular_playlist_fetch_is_shared_between_callers(
169 cached_provider: tuple[YandexMusicProvider, mock.AsyncMock],
170) -> None:
171 """Concurrent callers for the same regular playlist share one provider fetch."""
172 provider, mock_client = cached_provider
173 gate = asyncio.Event()
174
175 async def _get_playlist(*_args: Any, **_kwargs: Any) -> Any:
176 await gate.wait()
177 return type("PL", (), {"tracks": [], "track_count": 0})()
178
179 mock_client.get_playlist = mock.AsyncMock(side_effect=_get_playlist)
180
181 tasks = [asyncio.create_task(provider.get_playlist_tracks("12345:67")) for _ in range(3)]
182 await _wait_for_gated_fetch(lambda: mock_client.get_playlist.await_count > 0)
183 gate.set()
184
185 assert await asyncio.gather(*tasks) == [[], [], []]
186 assert mock_client.get_playlist.await_count == 1
187
188
189async def test_my_wave_fetch_is_shared_between_callers(
190 cached_provider: tuple[YandexMusicProvider, mock.AsyncMock],
191) -> None:
192 """Concurrent My Wave callers share one fetch, so the rotor advances once."""
193 provider, _ = cached_provider
194 gate = asyncio.Event()
195
196 async def _fetch_batch(*_args: Any, **_kwargs: Any) -> tuple[list[Any], None]:
197 await gate.wait()
198 return [], None
199
200 fetch_batch = mock.AsyncMock(side_effect=_fetch_batch)
201 provider._fetch_rotor_session_batch = fetch_batch # type: ignore[method-assign]
202
203 tasks = [
204 asyncio.create_task(provider.get_playlist_tracks(MY_WAVE_PLAYLIST_ID)) for _ in range(3)
205 ]
206 await _wait_for_gated_fetch(lambda: fetch_batch.await_count > 0)
207 gate.set()
208
209 assert await asyncio.gather(*tasks) == [[], [], []]
210 assert fetch_batch.await_count == 1
211
212
213# -- M11: unload() must clear every per-session cache -------------------------
214
215
216async def test_unload_clears_all_per_session_caches() -> None:
217 """
218 unload() must drop every state dict mirrored in handle_async_init().
219
220 Without this, stale ``asyncio.Lock`` instances bound to the previous
221 event loop survive a provider reload and break wave-session locking
222 after a settings change.
223 """
224 provider = YandexMusicProvider.__new__(YandexMusicProvider)
225 provider._client = None
226 provider._streaming = None
227 provider._wave_states = {"my_wave": mock.MagicMock()}
228 provider._wave_bg_colors = {"img-key": "#abcdef"}
229 provider._liked_albums_cache = (123.0, [])
230 provider._audiobook_chapter_cache = {"book-1": (["track-1"], [1000])}
231 provider._audiobook_play_ids = {"book-1": "play-id-1"}
232 provider.logger = mock.MagicMock()
233
234 with mock.patch.object(MusicProvider, "unload", new=mock.AsyncMock()):
235 await provider.unload()
236
237 # Cast through ``Any`` so mypy does not narrow these attributes to their
238 # post-assignment types â assert against the runtime values after unload.
239 state: Any = vars(provider)
240 assert not state["_wave_states"]
241 assert not state["_wave_bg_colors"]
242 assert state["_liked_albums_cache"] is None
243 assert not state["_audiobook_chapter_cache"]
244 assert not state["_audiobook_play_ids"]
245