/
/
/
1"""Unit tests for YandexMusicClient (api_client.py)."""
2
3from __future__ import annotations
4
5import asyncio
6import base64
7import hashlib
8import hmac
9import re
10import time
11from collections.abc import Mapping
12from datetime import UTC, datetime
13from typing import Any, cast
14from unittest import mock
15
16import pytest
17from music_assistant_models.errors import LoginFailed, ResourceTemporarilyUnavailable
18from ya_passport_auth import SecretStr
19from yandex_music.exceptions import BadRequestError, NetworkError, UnauthorizedError
20from yandex_music.rotor.dashboard import Dashboard
21from yandex_music.rotor.station_result import StationResult
22from yandex_music.utils.sign_request import DEFAULT_SIGN_KEY
23
24from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER
25from music_assistant.providers.yandex_music.api_client import (
26 GET_FILE_INFO_CODECS,
27 YandexMusicClient,
28)
29from music_assistant.providers.yandex_music.constants import (
30 CAPTCHA_COOLDOWN_LADDER_S,
31 INITIAL_SYNC_JITTER_S,
32 INITIAL_SYNC_WINDOW_S,
33 RESTRICTIVE_GLOBAL_CONCURRENCY,
34 THROTTLE_DEFAULT_RPS,
35 THROTTLE_METADATA_RPS,
36)
37
38
39def _make_client() -> tuple[YandexMusicClient, mock.AsyncMock]:
40 """
41 Create a YandexMusicClient with a mocked underlying ClientAsync.
42
43 Also mocks connect() so that _reconnect() restores the mock client
44 instead of trying to create a real connection.
45
46 :return: Tuple of (YandexMusicClient, mock_underlying_client).
47 """
48 client = YandexMusicClient(token=SecretStr("fake_token"))
49 mock_underlying = mock.AsyncMock()
50 client._client = mock_underlying
51 client._user_id = 12345
52 # Disable throttling in unit tests â replace every kind with an AsyncMock.
53 for kind in client._throttlers:
54 client._throttlers[kind] = mock.AsyncMock()
55
56 async def _fake_connect() -> bool:
57 client._client = mock_underlying
58 client._user_id = 12345
59 return True
60
61 client.connect = _fake_connect # type: ignore[method-assign]
62 return client, mock_underlying
63
64
65# -- get_liked_albums: batching -------------------------------------------------
66
67
68async def test_get_liked_albums_batching() -> None:
69 """Albums are fetched in batch via client.albums() for full metadata."""
70 client, underlying = _make_client()
71
72 # Build 3 minimal "like" objects with album stubs (no cover_uri)
73 likes = []
74 for album_id in (1, 2, 3):
75 album_stub = type("Album", (), {"id": album_id, "cover_uri": None})()
76 like = type("Like", (), {"album": album_stub})()
77 likes.append(like)
78
79 # Full album objects returned by client.albums()
80 full_albums = [
81 type("Album", (), {"id": aid, "cover_uri": f"cover_{aid}"})() for aid in (1, 2, 3)
82 ]
83
84 underlying.users_likes_albums = mock.AsyncMock(return_value=likes)
85 underlying.albums = mock.AsyncMock(return_value=full_albums)
86
87 result = await client.get_liked_albums()
88
89 underlying.albums.assert_awaited_once_with(["1", "2", "3"])
90 assert result == full_albums
91 assert all(a.cover_uri is not None for a in result)
92
93
94async def test_get_liked_albums_batch_fallback_on_network_error() -> None:
95 """When client.albums() fails, fallback returns minimal album data from likes."""
96 client, underlying = _make_client()
97
98 album_stub_1 = type("Album", (), {"id": 10, "cover_uri": None})()
99 album_stub_2 = type("Album", (), {"id": 20, "cover_uri": None})()
100 likes = [
101 type("Like", (), {"album": album_stub_1})(),
102 type("Like", (), {"album": album_stub_2})(),
103 ]
104
105 underlying.users_likes_albums = mock.AsyncMock(return_value=likes)
106 underlying.albums = mock.AsyncMock(side_effect=NetworkError("timeout"))
107
108 result = await client.get_liked_albums()
109
110 # Should fall back to the minimal album objects from likes
111 assert len(result) == 2
112 assert {a.id for a in result} == {10, 20}
113
114
115# -- get_tracks: retry on NetworkError -------------------------------------------
116
117
118async def test_get_tracks_retry_on_network_error_then_success() -> None:
119 """First call fails with NetworkError; retry succeeds."""
120 client, underlying = _make_client()
121
122 track = type("Track", (), {"id": 400, "title": "Test Track"})()
123 underlying.tracks = mock.AsyncMock(side_effect=[NetworkError("timeout"), [track]])
124
125 result = await client.get_tracks(["400"])
126
127 assert result == [track]
128 assert underlying.tracks.await_count == 2
129
130
131async def test_get_tracks_retry_on_network_error_both_fail() -> None:
132 """Both attempts fail with NetworkError â ResourceTemporarilyUnavailable."""
133 client, underlying = _make_client()
134
135 underlying.tracks = mock.AsyncMock(
136 side_effect=[NetworkError("timeout"), NetworkError("timeout again")]
137 )
138
139 with pytest.raises(ResourceTemporarilyUnavailable):
140 await client.get_tracks(["400"])
141
142 assert underlying.tracks.await_count == 2
143
144
145async def test_send_rotor_station_feedback_track_started() -> None:
146 """send_rotor_station_feedback delegates trackStarted to public helper."""
147 client, underlying = _make_client()
148 underlying.rotor_station_feedback_track_started = mock.AsyncMock(return_value=True)
149
150 result = await client.send_rotor_station_feedback(
151 "user:onyourwave",
152 "trackStarted",
153 track_id="12345",
154 batch_id="batch_xyz",
155 )
156
157 assert result is True
158 underlying.rotor_station_feedback_track_started.assert_awaited_once()
159 args, kwargs = underlying.rotor_station_feedback_track_started.await_args
160 assert args[0] == "user:onyourwave"
161 assert kwargs["track_id"] == "12345"
162 assert kwargs["batch_id"] == "batch_xyz"
163 assert "timestamp" in kwargs
164
165
166async def test_send_rotor_station_feedback_radio_started() -> None:
167 """send_rotor_station_feedback delegates radioStarted to public helper with from_."""
168 client, underlying = _make_client()
169 underlying.rotor_station_feedback_radio_started = mock.AsyncMock(return_value=True)
170
171 result = await client.send_rotor_station_feedback(
172 "user:onyourwave",
173 "radioStarted",
174 batch_id="batch_xyz",
175 )
176
177 assert result is True
178 underlying.rotor_station_feedback_radio_started.assert_awaited_once()
179 _, kwargs = underlying.rotor_station_feedback_radio_started.await_args
180 assert kwargs["from_"] == "YandexMusicDesktopAppWindows"
181 assert kwargs["batch_id"] == "batch_xyz"
182
183
184async def test_send_rotor_station_feedback_track_finished() -> None:
185 """send_rotor_station_feedback delegates trackFinished with total_played_seconds."""
186 client, underlying = _make_client()
187 underlying.rotor_station_feedback_track_finished = mock.AsyncMock(return_value=True)
188
189 result = await client.send_rotor_station_feedback(
190 "user:onyourwave",
191 "trackFinished",
192 track_id="12345",
193 total_played_seconds=42,
194 batch_id="batch_xyz",
195 )
196
197 assert result is True
198 underlying.rotor_station_feedback_track_finished.assert_awaited_once()
199 _, kwargs = underlying.rotor_station_feedback_track_finished.await_args
200 assert kwargs["track_id"] == "12345"
201 assert kwargs["total_played_seconds"] == 42.0
202 assert kwargs["batch_id"] == "batch_xyz"
203
204
205async def test_send_rotor_station_feedback_skip() -> None:
206 """send_rotor_station_feedback delegates skip to public helper."""
207 client, underlying = _make_client()
208 underlying.rotor_station_feedback_skip = mock.AsyncMock(return_value=True)
209
210 result = await client.send_rotor_station_feedback(
211 "user:onyourwave",
212 "skip",
213 track_id="12345",
214 total_played_seconds=10,
215 )
216
217 assert result is True
218 underlying.rotor_station_feedback_skip.assert_awaited_once()
219 _, kwargs = underlying.rotor_station_feedback_skip.await_args
220 assert kwargs["track_id"] == "12345"
221 assert kwargs["total_played_seconds"] == 10.0
222
223
224# -- rotor session API (/rotor/session/*) --------------------------------------
225
226
227def _patch_rotor_session_request(client: YandexMusicClient, response: object) -> mock.AsyncMock:
228 """Install a mocked _rotor_session_request on the client and return the mock."""
229 req_mock = mock.AsyncMock(return_value=response)
230 client._rotor_session_request = req_mock # type: ignore[method-assign]
231 return req_mock
232
233
234def _patch_get_tracks(client: YandexMusicClient, tracks: list[object]) -> mock.AsyncMock:
235 """Install a mocked get_tracks on the client and return the mock."""
236 tracks_mock = mock.AsyncMock(return_value=tracks)
237 client.get_tracks = tracks_mock # type: ignore[method-assign]
238 return tracks_mock
239
240
241def _call_args(m: mock.AsyncMock) -> tuple[tuple[Any, ...], Mapping[str, Any]]:
242 """
243 Return (args, kwargs) from the most recent await on ``m``.
244
245 Raises AssertionError when the mock was never awaited â intentionally
246 surfacing missed setup rather than letting mypy's `None is not iterable`
247 propagate into destructuring sites.
248 """
249 call = m.await_args
250 assert call is not None, "mock was not awaited"
251 return call.args, call.kwargs
252
253
254async def test_rotor_session_new_posts_expected_body_and_returns_session() -> None:
255 """rotor_session_new POSTs to /rotor/session/new with wave-model flags and parses result."""
256 client, underlying = _make_client()
257 del underlying # unused; session API bypasses MarshalX client
258 response = {
259 "radioSessionId": "sess_abc",
260 "batchId": "batch_1",
261 "sequence": [{"track": {"id": 100, "title": "T"}, "liked": False}],
262 }
263 req_mock = _patch_rotor_session_request(client, response)
264 _patch_get_tracks(client, [type("T", (), {"id": 100})()])
265
266 session_id, tracks, batch_id = await client.rotor_session_new("user:onyourwave")
267
268 req_mock.assert_awaited_once()
269 args, _ = _call_args(req_mock)
270 path, body = args[0], args[1]
271 assert path == "new"
272 assert body["seeds"] == ["user:onyourwave"]
273 assert body["queue"] == []
274 assert body["includeTracksInResponse"] is True
275 assert body["includeWaveModel"] is True
276 assert body["interactive"] is True
277 assert session_id == "sess_abc"
278 assert batch_id == "batch_1"
279 assert len(tracks) == 1
280 assert tracks[0].id == 100
281
282
283async def test_rotor_session_new_appends_settings_as_seeds() -> None:
284 """rotor_session_new appends settingDiversity / settingMoodEnergy / settingLanguage seeds."""
285 client, underlying = _make_client()
286 del underlying
287 req_mock = _patch_rotor_session_request(
288 client, {"radioSessionId": "s1", "batchId": "b1", "sequence": []}
289 )
290 _patch_get_tracks(client, [])
291
292 await client.rotor_session_new(
293 "user:onyourwave",
294 settings={"diversity": "discover", "moodEnergy": "calm", "language": "russian"},
295 )
296
297 args, _ = _call_args(req_mock)
298 body = args[1]
299 assert body["seeds"] == [
300 "user:onyourwave",
301 "settingDiversity:discover",
302 "settingMoodEnergy:calm",
303 "settingLanguage:russian",
304 ]
305
306
307async def test_rotor_session_new_returns_empty_on_missing_session_id() -> None:
308 """If the response lacks radioSessionId the call returns (None, [], None) without raising."""
309 client, underlying = _make_client()
310 del underlying
311 _patch_rotor_session_request(client, None)
312
313 session_id, tracks, batch_id = await client.rotor_session_new("user:onyourwave")
314
315 assert session_id is None
316 assert tracks == []
317 assert batch_id is None
318
319
320async def test_rotor_session_tracks_posts_current_track_queue() -> None:
321 """rotor_session_tracks POSTs {queue: [current_track_id]} and returns tracks + batch_id."""
322 client, underlying = _make_client()
323 del underlying
324 response = {
325 "batchId": "batch_2",
326 "sequence": [{"track": {"id": 200}}, {"track": {"id": 201}}],
327 }
328 req_mock = _patch_rotor_session_request(client, response)
329 _patch_get_tracks(client, [type("T", (), {"id": 200})(), type("T", (), {"id": 201})()])
330
331 tracks, batch_id = await client.rotor_session_tracks("sess_abc", current_track_id="100")
332
333 args, _ = _call_args(req_mock)
334 path, body = args[0], args[1]
335 assert path == "sess_abc/tracks"
336 assert body == {"queue": ["100"]}
337 assert batch_id == "batch_2"
338 assert [t.id for t in tracks] == [200, 201]
339
340
341async def test_rotor_session_feedback_radio_started_sends_from_field() -> None:
342 """RadioStarted event uses event.from=track_id (not trackId)."""
343 client, underlying = _make_client()
344 del underlying
345 req_mock = _patch_rotor_session_request(client, {"result": "ok"})
346
347 result = await client.rotor_session_feedback(
348 "sess_abc", "radioStarted", track_id="100", batch_id="batch_1"
349 )
350
351 assert result is True
352 args, _ = _call_args(req_mock)
353 path, body = args[0], args[1]
354 assert path == "sess_abc/feedback"
355 assert body["batchId"] == "batch_1"
356 event = body["event"]
357 assert event["type"] == "radioStarted"
358 assert event["from"] == "100"
359 assert "trackId" not in event
360 assert "timestamp" in event
361 assert re.match(r"^\d{4}-\d{2}-\d{2}T", event["timestamp"])
362
363
364async def test_rotor_session_feedback_track_started_sends_track_id() -> None:
365 """TrackStarted event uses event.trackId (not from)."""
366 client, underlying = _make_client()
367 del underlying
368 req_mock = _patch_rotor_session_request(client, {"result": "ok"})
369
370 await client.rotor_session_feedback(
371 "sess_abc", "trackStarted", track_id="100", batch_id="batch_1"
372 )
373
374 args, _ = _call_args(req_mock)
375 body = args[1]
376 event = body["event"]
377 assert event["type"] == "trackStarted"
378 assert event["trackId"] == "100"
379 assert "from" not in event
380 assert "totalPlayedSeconds" not in event
381
382
383async def test_rotor_session_feedback_track_finished_includes_seconds() -> None:
384 """TrackFinished event includes totalPlayedSeconds."""
385 client, underlying = _make_client()
386 del underlying
387 req_mock = _patch_rotor_session_request(client, {"result": "ok"})
388
389 await client.rotor_session_feedback(
390 "sess_abc",
391 "trackFinished",
392 track_id="100",
393 total_played_seconds=42,
394 batch_id="batch_1",
395 )
396
397 args, _ = _call_args(req_mock)
398 body = args[1]
399 event = body["event"]
400 assert event["type"] == "trackFinished"
401 assert event["trackId"] == "100"
402 assert event["totalPlayedSeconds"] == 42
403
404
405async def test_rotor_session_feedback_skip_includes_seconds() -> None:
406 """Skip event includes totalPlayedSeconds and trackId."""
407 client, underlying = _make_client()
408 del underlying
409 req_mock = _patch_rotor_session_request(client, {"result": "ok"})
410
411 await client.rotor_session_feedback(
412 "sess_abc", "skip", track_id="100", total_played_seconds=10, batch_id="batch_1"
413 )
414
415 args, _ = _call_args(req_mock)
416 body = args[1]
417 event = body["event"]
418 assert event["type"] == "skip"
419 assert event["trackId"] == "100"
420 assert event["totalPlayedSeconds"] == 10
421
422
423async def test_rotor_session_feedback_like_uses_trackid_without_seconds() -> None:
424 """like/dislike events use trackId but do NOT include totalPlayedSeconds."""
425 client, underlying = _make_client()
426 del underlying
427 req_mock = _patch_rotor_session_request(client, {"result": "ok"})
428
429 await client.rotor_session_feedback("sess_abc", "like", track_id="100", batch_id="batch_1")
430
431 args, _ = _call_args(req_mock)
432 body = args[1]
433 event = body["event"]
434 assert event["type"] == "like"
435 assert event["trackId"] == "100"
436 assert "totalPlayedSeconds" not in event
437
438
439async def test_rotor_session_request_maps_unauthorized_to_login_failed() -> None:
440 """
441 Expired/invalid token during /rotor/session/* surfaces as LoginFailed.
442
443 Without this mapping the raw ``UnauthorizedError`` from the MarshalX
444 client would bubble up through browse / play paths and crash the
445 provider instead of triggering MA's re-auth prompt.
446 """
447 client, underlying = _make_client()
448 # _do is awaited via _call_with_retry â _ensure_connected â returns our
449 # AsyncMock underlying client. The underlying client's ._request.post is
450 # what actually raises.
451 underlying._request = mock.MagicMock()
452 underlying._request.post = mock.AsyncMock(side_effect=UnauthorizedError("stale token"))
453
454 with pytest.raises(LoginFailed):
455 await client._rotor_session_request("new", {"seeds": ["user:onyourwave"]})
456
457
458# -- get_similar_artists ------------------------------------------------------
459
460
461async def test_get_similar_artists_returns_list() -> None:
462 """get_similar_artists returns the similar_artists list from artists_similar()."""
463 client, underlying = _make_client()
464 similar = [type("Artist", (), {"id": i, "name": f"A{i}"})() for i in (1, 2, 3)]
465 result_obj = type("ArtistSimilar", (), {"similar_artists": similar})()
466 underlying.artists_similar = mock.AsyncMock(return_value=result_obj)
467
468 result = await client.get_similar_artists("100")
469
470 underlying.artists_similar.assert_awaited_once_with("100")
471 assert result == similar
472
473
474async def test_get_similar_artists_respects_limit() -> None:
475 """get_similar_artists truncates results to the requested limit."""
476 client, underlying = _make_client()
477 similar = [type("Artist", (), {"id": i})() for i in range(10)]
478 result_obj = type("ArtistSimilar", (), {"similar_artists": similar})()
479 underlying.artists_similar = mock.AsyncMock(return_value=result_obj)
480
481 result = await client.get_similar_artists("100", limit=3)
482
483 assert len(result) == 3
484 assert [a.id for a in result] == [0, 1, 2]
485
486
487async def test_get_similar_artists_handles_none_response() -> None:
488 """get_similar_artists returns [] when underlying call returns None."""
489 client, underlying = _make_client()
490 underlying.artists_similar = mock.AsyncMock(return_value=None)
491
492 result = await client.get_similar_artists("100")
493
494 assert result == []
495
496
497async def test_get_similar_artists_handles_empty_field() -> None:
498 """get_similar_artists returns [] when similar_artists is empty/None."""
499 client, underlying = _make_client()
500 result_obj = type("ArtistSimilar", (), {"similar_artists": None})()
501 underlying.artists_similar = mock.AsyncMock(return_value=result_obj)
502
503 result = await client.get_similar_artists("100")
504
505 assert result == []
506
507
508async def test_get_similar_artists_returns_empty_on_network_error() -> None:
509 """get_similar_artists returns [] when underlying raises NetworkError."""
510 client, underlying = _make_client()
511 underlying.artists_similar = mock.AsyncMock(
512 side_effect=[NetworkError("timeout"), NetworkError("again")]
513 )
514
515 result = await client.get_similar_artists("100")
516
517 assert result == []
518
519
520# -- get_pins / get_music_history / get_artist_about -------------------------
521
522
523async def test_get_pins_returns_list_object() -> None:
524 """get_pins forwards the underlying pins() result."""
525 client, underlying = _make_client()
526 pins_obj = type("PinsList", (), {"pins": [type("Pin", (), {"type": "album_item"})()]})()
527 underlying.pins = mock.AsyncMock(return_value=pins_obj)
528
529 result = await client.get_pins()
530
531 underlying.pins.assert_awaited_once_with()
532 assert result is pins_obj
533
534
535async def test_get_pins_returns_none_on_network_error() -> None:
536 """get_pins returns None when retries are exhausted."""
537 client, underlying = _make_client()
538 underlying.pins = mock.AsyncMock(side_effect=NetworkError("boom"))
539
540 result = await client.get_pins()
541
542 assert result is None
543
544
545async def test_get_music_history_returns_object() -> None:
546 """get_music_history forwards the underlying music_history() result."""
547 client, underlying = _make_client()
548 history = type("MusicHistory", (), {"history_tabs": []})()
549 underlying.music_history = mock.AsyncMock(return_value=history)
550
551 result = await client.get_music_history()
552
553 underlying.music_history.assert_awaited_once_with()
554 assert result is history
555
556
557async def test_get_music_history_returns_none_on_network_error() -> None:
558 """get_music_history returns None on persistent NetworkError."""
559 client, underlying = _make_client()
560 underlying.music_history = mock.AsyncMock(side_effect=NetworkError("boom"))
561
562 assert await client.get_music_history() is None
563
564
565async def test_get_artist_about_returns_object() -> None:
566 """get_artist_about forwards the underlying artists_about() result."""
567 client, underlying = _make_client()
568 about = type("ArtistAbout", (), {"description": "x", "stats": None})()
569 underlying.artists_about = mock.AsyncMock(return_value=about)
570
571 result = await client.get_artist_about("42")
572
573 underlying.artists_about.assert_awaited_once_with("42")
574 assert result is about
575
576
577async def test_get_artist_about_returns_none_on_network_error() -> None:
578 """get_artist_about returns None on persistent NetworkError."""
579 client, underlying = _make_client()
580 underlying.artists_about = mock.AsyncMock(side_effect=NetworkError("boom"))
581
582 assert await client.get_artist_about("42") is None
583
584
585# -- LRC regex tests ---------------------------------------------------------
586
587
588def test_lrc_regex_matches_valid_synced_lyrics() -> None:
589 """
590 LRC regex matches valid synced lyrics with proper format [mm:ss.xx].
591
592 Uses re.search (no ^ anchor) matching the implementation in api_client.py,
593 which intentionally allows timestamps anywhere in the text so that LRC
594 metadata lines like [ar:Artist] before the first timestamp don't prevent
595 detection.
596 """
597 pattern = r"\[\d{2}:\d{2}(?:\.\d{2,3})?\]"
598
599 # Valid LRC formats that should match
600 valid_cases = [
601 "[00:12]", # Basic format (no fractional part)
602 "[00:12.34]", # With centiseconds (2-digit fractional part â lower bound of \d{2,3})
603 "[00:12.345]", # With milliseconds (3-digit fractional part â upper bound of \d{2,3})
604 "[12:34]", # Another basic format
605 "[99:59.99]", # Edge case
606 "Some [00:12] text", # Timestamp embedded in text â re.search finds it
607 ]
608
609 for case in valid_cases:
610 assert re.search(pattern, case), f"Should match: {case}"
611
612
613def test_lrc_regex_rejects_invalid_formats() -> None:
614 """LRC regex rejects invalid formats (no closing bracket, wrong format)."""
615 pattern = r"\[\d{2}:\d{2}(?:\.\d{2,3})?\]"
616
617 # Invalid formats that should NOT match
618 invalid_cases = [
619 "[00:12", # Missing closing bracket
620 "00:12]", # Missing opening bracket
621 "[0:12]", # Single digit minute
622 "[00:1]", # Single digit second
623 "[00:12.1]", # Single digit centiseconds (should be 2-3 digits)
624 "[00:12.1234]", # Four digit milliseconds
625 ]
626
627 for case in invalid_cases:
628 assert not re.search(pattern, case), f"Should NOT match: {case}"
629
630
631# -- HMAC sign construction tests --------------------------------------------
632
633
634def test_hmac_sign_construction_explicit() -> None:
635 """HMAC sign is constructed explicitly with commas stripped from codecs."""
636 # Simulate the parameters
637 timestamp = 1234567890
638 track_id = "12345"
639
640 # The correct way (explicit construction)
641 codecs_for_sign = GET_FILE_INFO_CODECS.replace(",", "")
642 param_string = f"{timestamp}{track_id}lossless{codecs_for_sign}encraw"
643
644 # Verify codecs_for_sign has no commas
645 assert "," not in codecs_for_sign
646
647 # Verify the construction is correct
648 expected = f"1234567890{track_id}lossless{codecs_for_sign}encraw"
649 assert param_string == expected
650
651 # Verify HMAC can be constructed
652 hmac_sign = hmac.new(
653 DEFAULT_SIGN_KEY.encode(),
654 param_string.encode(),
655 hashlib.sha256,
656 )
657 sign = base64.b64encode(hmac_sign.digest()).decode()[:-1]
658
659 # Verify sign is 43 characters (SHA-256 base64 with one "=" removed)
660 assert len(sign) == 43
661 assert not sign.endswith("=")
662
663
664# -- rate-limit detection -----------------------------------------------------
665
666
667def test_is_rate_limit_error_detects_429() -> None:
668 """_is_rate_limit_error returns True for NetworkError with '429' in the message."""
669 client, _ = _make_client()
670 err = NetworkError("Bad Request (429): Too Many Requests")
671 assert client._is_rate_limit_error(err) is True
672
673
674def test_is_rate_limit_error_detects_too_many() -> None:
675 """_is_rate_limit_error returns True when message contains 'too many requests'."""
676 client, _ = _make_client()
677 err = NetworkError("too many requests from this IP")
678 assert client._is_rate_limit_error(err) is True
679
680
681def test_is_rate_limit_error_false_for_ordinary_network_error() -> None:
682 """_is_rate_limit_error returns False for ordinary connection errors."""
683 client, _ = _make_client()
684 err = NetworkError("timeout")
685 assert client._is_rate_limit_error(err) is False
686
687
688def test_is_rate_limit_error_false_for_non_network_error() -> None:
689 """_is_rate_limit_error returns False for non-NetworkError, even with 'too many' in msg."""
690 client, _ = _make_client()
691 err = ValueError("too many values to unpack")
692 assert client._is_rate_limit_error(err) is False
693
694
695async def test_call_with_retry_raises_resource_unavailable_on_rate_limit() -> None:
696 """_call_with_retry raises ResourceTemporarilyUnavailable when rate-limit is detected."""
697 client, underlying = _make_client()
698
699 underlying.tracks = mock.AsyncMock(
700 side_effect=NetworkError("Bad Request (429): Too Many Requests")
701 )
702
703 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
704 await client.get_tracks(["42"])
705
706 assert exc_info.value.backoff_time == 60
707 # Should not have retried â rate limit errors are not connection errors
708 assert underlying.tracks.await_count == 1
709
710
711async def test_is_connection_error_excludes_rate_limit() -> None:
712 """_is_connection_error returns False for rate-limit NetworkErrors (they have 429 in msg)."""
713 client, _ = _make_client()
714 err = NetworkError("Bad Request (429): Too Many Requests")
715 # Rate limit errors should NOT trigger reconnect logic
716 assert client._is_connection_error(err) is False
717
718
719async def test_get_dashboard_stations_returns_personalized_stations() -> None:
720 """get_dashboard_stations() returns stations from rotor/stations/dashboard."""
721 client, underlying = _make_client()
722
723 _de_client = type("C", (), {"report_unknown_fields": False})()
724
725 station_result = StationResult.de_json(
726 {
727 "station": {
728 "id": {"type": "mood", "tag": "sad"},
729 "name": "ÐÑÑÑÑное",
730 "restrictions": {},
731 "restrictions2": {},
732 "full_image_url": None,
733 "id_for_from": "mood-sad",
734 "icon": None,
735 },
736 "settings": None,
737 "settings2": None,
738 "ad_params": None,
739 "rup_title": "Sad Songs",
740 "rup_description": "",
741 },
742 _de_client,
743 )
744
745 dashboard = mock.MagicMock(spec=Dashboard)
746 dashboard.stations = [station_result]
747 underlying.rotor_stations_dashboard.return_value = dashboard
748
749 stations = await client.get_dashboard_stations()
750
751 assert len(stations) == 1
752 station_id, name, _image_url = stations[0]
753 assert station_id == "mood:sad"
754 assert name == "ÐÑÑÑÑное" # station.name takes priority over rup_title
755 underlying.rotor_stations_dashboard.assert_called_once()
756
757
758# -- get_track_file_info: response key normalization -------------------------
759
760
761async def test_get_track_file_info_parses_camelcase_download_info() -> None:
762 """
763 get_track_file_info parses the v3-style camelCase ``downloadInfo`` key.
764
765 The yandex-music v3 client no longer recursively normalises camelCase keys
766 inside ``Response.result``. The raw JSON for /get-file-info comes back as
767 ``{"downloadInfo": {...}}`` â the provider must accept both shapes.
768 """
769 client, underlying = _make_client()
770
771 raw_response = {
772 "downloadInfo": {
773 "trackId": "132401416",
774 "quality": "lossless",
775 "codec": "flac-mp4",
776 "bitrate": 0,
777 "transport": "raw",
778 "url": "https://example.com/flac-mp4.bin",
779 "realId": "132401416",
780 }
781 }
782 underlying._request = mock.MagicMock()
783 underlying._request.get = mock.AsyncMock(return_value=raw_response)
784 underlying.base_url = "https://api.music.yandex.net"
785
786 result = await client.get_track_file_info("132401416")
787
788 assert result is not None
789 assert result["url"] == "https://example.com/flac-mp4.bin"
790 assert result["codec"] == "flac-mp4"
791 assert result["needs_decryption"] is False
792
793
794async def test_get_dashboard_stations_empty_on_error() -> None:
795 """get_dashboard_stations() returns empty list on network error."""
796 client, underlying = _make_client()
797 underlying.rotor_stations_dashboard.side_effect = NetworkError("timeout")
798
799 stations = await client.get_dashboard_stations()
800
801 assert stations == []
802
803
804async def test_get_dashboard_stations_skips_user_type() -> None:
805 """get_dashboard_stations() filters out personal 'user' type stations."""
806 client, underlying = _make_client()
807
808 _de_client = type("C", (), {"report_unknown_fields": False})()
809
810 personal_station = StationResult.de_json(
811 {
812 "station": {
813 "id": {"type": "user", "tag": "onyourwave"},
814 "name": "My Wave",
815 "restrictions": {},
816 "restrictions2": {},
817 "full_image_url": None,
818 "id_for_from": "user-onyourwave",
819 "icon": None,
820 },
821 "settings": None,
822 "settings2": None,
823 "ad_params": None,
824 "rup_title": "My Wave",
825 "rup_description": "",
826 },
827 _de_client,
828 )
829
830 dashboard = mock.MagicMock(spec=Dashboard)
831 dashboard.stations = [personal_station]
832 underlying.rotor_stations_dashboard.return_value = dashboard
833
834 stations = await client.get_dashboard_stations()
835
836 assert stations == []
837
838
839# -- _classify_429 + _truncate_err_msg ----------------------------------------
840
841
842_CAPTCHA_HTML_SNIPPET = (
843 "HTTPError (429): <!DOCTYPE html><html><head><title>429</title></head>"
844 '<body class="smart-captcha">'
845 '<script src="/captcha_smart_qrcode.min.js"></script>'
846 'See <a href="https://yandex.ru/support/smart-captcha/about-429.html">'
847 "service support form</a>. ÐоÑÑÑп к ÑеÑвиÑÑ Ð²Ñеменно запÑеÑÑн â Yandex "
848 "anti-bot edge protection. Try again in a few minutes."
849)
850# Padding for the captcha truncation test â we need >200 chars to trigger
851# the _truncate_err_msg cap and verify production behaviour.
852assert len(_CAPTCHA_HTML_SNIPPET) > 200, "captcha snippet must exceed truncate limit"
853
854
855def test_classify_429_captcha_detects_smart_captcha_html() -> None:
856 """_classify_429 returns 'captcha' when the body contains smart-captcha markers."""
857 client, _ = _make_client()
858 err = NetworkError(_CAPTCHA_HTML_SNIPPET)
859 assert client._classify_429(err) == "captcha"
860
861
862def test_classify_429_plain_429_returns_rate_limit() -> None:
863 """_classify_429 returns 'rate_limit' for a bare 429 without captcha markers."""
864 client, _ = _make_client()
865 err = NetworkError("Bad Request (429): Too Many Requests")
866 assert client._classify_429(err) == "rate_limit"
867
868
869def test_classify_429_non_network_error_returns_other() -> None:
870 """_classify_429 returns 'other' for non-NetworkError exceptions even with '429' in msg."""
871 client, _ = _make_client()
872 err = ValueError("HTTP 429 from some other source")
873 assert client._classify_429(err) == "other"
874
875
876def test_truncate_err_msg_caps_long_html() -> None:
877 """_truncate_err_msg never leaks more than `limit` characters of the payload."""
878 big = NetworkError("X" * 5000)
879 truncated = YandexMusicClient._truncate_err_msg(big, limit=200)
880 assert len(truncated) <= 200 + len("...[truncated]")
881 assert truncated.endswith("...[truncated]")
882
883
884# -- captcha vs plain 429 in _call_with_retry ---------------------------------
885
886
887async def test_call_with_retry_captcha_raises_with_first_strike_backoff() -> None:
888 """Captcha response triggers a 15s cooldown on first strike and the HTML body is truncated out."""
889 client, underlying = _make_client()
890 underlying.tracks = mock.AsyncMock(side_effect=NetworkError(_CAPTCHA_HTML_SNIPPET))
891
892 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
893 await client.get_tracks(["42"])
894
895 assert exc_info.value.backoff_time == 15
896 # The "default" kind owns c.tracks() â block deadline must be set.
897 assert client._block_until["default"] > 0
898 # The other kinds must remain untouched.
899 assert client._block_until["file_info"] == 0.0
900 assert client._block_until["rotor"] == 0.0
901 # The exception chain must carry a truncated message, not the full HTML.
902 cause = exc_info.value.__cause__
903 assert cause is not None
904 cause_str = str(cause)
905 assert cause_str.endswith("...[truncated]")
906 # Truncated length is bounded â limit=200 + the truncation suffix.
907 assert len(cause_str) <= 200 + len("...[truncated]")
908
909
910async def test_call_with_retry_plain_429_keeps_60s_backoff_and_no_block() -> None:
911 """Plain 429 (no captcha markers) raises with 60s backoff but does NOT engage a block."""
912 client, underlying = _make_client()
913 underlying.tracks = mock.AsyncMock(
914 side_effect=NetworkError("Bad Request (429): Too Many Requests")
915 )
916
917 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
918 await client.get_tracks(["42"])
919
920 assert exc_info.value.backoff_time == 60
921 # No kind should be quarantined for a plain 429.
922 assert all(v == 0.0 for v in client._block_until.values())
923
924
925# -- per-kind circuit breaker --------------------------------------------------
926
927
928async def test_circuit_breaker_blocks_only_affected_kind() -> None:
929 """A captcha on 'default' must NOT block 'file_info' or 'rotor' calls."""
930 client, underlying = _make_client()
931 client._block_until["default"] = time.monotonic() + 600
932
933 # 'default' kind: c.tracks() must fail fast without ever being awaited.
934 underlying.tracks = mock.AsyncMock(return_value=[])
935 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
936 await client.get_tracks(["1"])
937 assert "default" in str(exc_info.value) or "cooldown" in str(exc_info.value)
938 underlying.tracks.assert_not_awaited()
939
940 # 'rotor' kind: rotor_stations_dashboard must still reach the network.
941 dashboard = mock.MagicMock(spec=Dashboard)
942 dashboard.stations = []
943 underlying.rotor_stations_dashboard = mock.AsyncMock(return_value=dashboard)
944 _ = await client.get_dashboard_stations()
945 underlying.rotor_stations_dashboard.assert_awaited()
946
947
948async def test_circuit_breaker_captcha_on_file_info_doesnt_block_default() -> None:
949 """A captcha-driven file_info block must not affect default-kind calls."""
950 client, underlying = _make_client()
951 client._block_until["file_info"] = time.monotonic() + 600
952 underlying.tracks = mock.AsyncMock(return_value=[])
953
954 # default kind call should pass through.
955 await client.get_tracks(["1"])
956 underlying.tracks.assert_awaited()
957
958
959async def test_circuit_breaker_clears_after_deadline() -> None:
960 """Once monotonic time passes _block_until, the call proceeds normally."""
961 client, underlying = _make_client()
962 client._block_until["default"] = time.monotonic() - 1.0 # past
963 underlying.tracks = mock.AsyncMock(return_value=[])
964
965 await client.get_tracks(["1"])
966 underlying.tracks.assert_awaited()
967
968
969async def test_bypass_throttler_bypasses_block() -> None:
970 """BYPASS_THROTTLER must allow refresh paths through even while a kind is blocked."""
971 client, underlying = _make_client()
972 client._block_until["file_info"] = time.monotonic() + 600
973
974 raw_response = {
975 "downloadInfo": {
976 "url": "https://example.com/x",
977 "codec": "flac-mp4",
978 }
979 }
980 underlying._request = mock.MagicMock()
981 underlying._request.get = mock.AsyncMock(return_value=raw_response)
982 underlying.base_url = "https://api.music.yandex.net"
983
984 token = BYPASS_THROTTLER.set(True)
985 try:
986 result = await client.get_track_file_info("42")
987 finally:
988 BYPASS_THROTTLER.reset(token)
989
990 assert result is not None
991 assert result["url"] == "https://example.com/x"
992
993
994async def test_captcha_during_bypass_still_engages_block() -> None:
995 """
996 Captcha received during a BYPASS_THROTTLER call must still quarantine the kind.
997
998 Stream URL refresh runs under BYPASS_THROTTLER to keep an in-flight track
999 alive â but if Yandex returns smart-captcha on that very refresh, we DO
1000 want the file_info kind quarantined so that subsequent NEW-track plays
1001 fail fast instead of hitting Yandex and prolonging the edge ban.
1002 The bypass itself still works for the next refresh of the same track.
1003 """
1004 client, underlying = _make_client()
1005 underlying._request = mock.MagicMock()
1006 underlying._request.get = mock.AsyncMock(side_effect=NetworkError(_CAPTCHA_HTML_SNIPPET))
1007 underlying.base_url = "https://api.music.yandex.net"
1008
1009 # Pre-condition: file_info kind is NOT blocked.
1010 assert client._block_until["file_info"] == 0.0
1011
1012 token = BYPASS_THROTTLER.set(True)
1013 try:
1014 # get_track_file_info swallows ResourceTemporarilyUnavailable and returns None.
1015 result = await client.get_track_file_info("42")
1016 finally:
1017 BYPASS_THROTTLER.reset(token)
1018
1019 assert result is None
1020 # The block must have been engaged despite the bypass.
1021 assert client._block_until["file_info"] > time.monotonic() + 10
1022 # Other kinds remain free.
1023 assert client._block_until["default"] == 0.0
1024 assert client._block_until["rotor"] == 0.0
1025
1026
1027# -- per-kind throttler routing ------------------------------------------------
1028
1029
1030async def test_file_info_kind_routes_to_file_info_throttler() -> None:
1031 """get_track_file_info must acquire the file_info throttler, not default."""
1032 client, underlying = _make_client()
1033
1034 raw_response = {
1035 "downloadInfo": {
1036 "url": "https://example.com/x",
1037 "codec": "flac-mp4",
1038 }
1039 }
1040 underlying._request = mock.MagicMock()
1041 underlying._request.get = mock.AsyncMock(return_value=raw_response)
1042 underlying.base_url = "https://api.music.yandex.net"
1043
1044 await client.get_track_file_info("42")
1045
1046 file_info_acquire = cast("mock.AsyncMock", client._throttlers["file_info"].acquire)
1047 default_acquire = cast("mock.AsyncMock", client._throttlers["default"].acquire)
1048 file_info_acquire.assert_awaited()
1049 default_acquire.assert_not_awaited()
1050
1051
1052async def test_rotor_kind_routes_to_rotor_throttler() -> None:
1053 """get_dashboard_stations must acquire the rotor throttler, not default."""
1054 client, underlying = _make_client()
1055
1056 dashboard = mock.MagicMock(spec=Dashboard)
1057 dashboard.stations = []
1058 underlying.rotor_stations_dashboard = mock.AsyncMock(return_value=dashboard)
1059
1060 await client.get_dashboard_stations()
1061
1062 rotor_acquire = cast("mock.AsyncMock", client._throttlers["rotor"].acquire)
1063 default_acquire = cast("mock.AsyncMock", client._throttlers["default"].acquire)
1064 rotor_acquire.assert_awaited()
1065 default_acquire.assert_not_awaited()
1066
1067
1068# -- get_track_file_info short-TTL cache --------------------------------------
1069
1070
1071def _make_file_info_response(url: str = "https://example.com/x") -> dict[str, Any]:
1072 return {
1073 "downloadInfo": {
1074 "url": url,
1075 "codec": "flac-mp4",
1076 "quality": "lossless",
1077 "transport": "raw",
1078 }
1079 }
1080
1081
1082async def test_file_info_cache_hit_skips_network() -> None:
1083 """Second call within TTL returns the cached entry and doesn't hit the network."""
1084 client, underlying = _make_client()
1085 underlying._request = mock.MagicMock()
1086 underlying._request.get = mock.AsyncMock(return_value=_make_file_info_response())
1087 underlying.base_url = "https://api.music.yandex.net"
1088
1089 first = await client.get_track_file_info("42")
1090 second = await client.get_track_file_info("42")
1091
1092 assert first == second
1093 underlying._request.get.assert_awaited_once()
1094
1095
1096async def test_file_info_cache_separates_entries_by_codecs() -> None:
1097 """
1098 Different codec preference lists must NOT share a cache slot.
1099
1100 Yandex picks the codec (and download URL) based on the codec order, so a
1101 cached response for codecs="flac-mp4,flac" must not be reused when the
1102 caller requests codecs="mp3".
1103 """
1104 client, underlying = _make_client()
1105 underlying._request = mock.MagicMock()
1106 underlying._request.get = mock.AsyncMock(return_value=_make_file_info_response())
1107 underlying.base_url = "https://api.music.yandex.net"
1108
1109 await client.get_track_file_info("42", codecs="flac-mp4,flac")
1110 await client.get_track_file_info("42", codecs="mp3")
1111
1112 # Two different codec lists â two distinct cache entries and two network hits.
1113 assert underlying._request.get.await_count == 2
1114 assert ("42", "lossless", "flac-mp4,flac", "raw") in client._file_info_cache
1115 assert ("42", "lossless", "mp3", "raw") in client._file_info_cache
1116
1117
1118async def test_file_info_cache_expiry_hits_network_again(
1119 monkeypatch: pytest.MonkeyPatch,
1120) -> None:
1121 """When the cached entry's TTL has elapsed, the next call goes back to network."""
1122 client, underlying = _make_client()
1123 underlying._request = mock.MagicMock()
1124 underlying._request.get = mock.AsyncMock(return_value=_make_file_info_response())
1125 underlying.base_url = "https://api.music.yandex.net"
1126
1127 base = time.monotonic()
1128 current = {"t": base}
1129
1130 def _fake_monotonic() -> float:
1131 return current["t"]
1132
1133 monkeypatch.setattr(
1134 "music_assistant.providers.yandex_music.api_client.time.monotonic",
1135 _fake_monotonic,
1136 )
1137
1138 await client.get_track_file_info("42")
1139 current["t"] = base + 9999.0 # well past the TTL
1140 await client.get_track_file_info("42")
1141
1142 assert underlying._request.get.await_count == 2
1143
1144
1145async def test_file_info_cache_invalidated_on_bad_request() -> None:
1146 """A BadRequestError on the underlying call invalidates the cache for that track."""
1147 client, underlying = _make_client()
1148 underlying._request = mock.MagicMock()
1149 underlying.base_url = "https://api.music.yandex.net"
1150
1151 cache_key = ("42", "lossless", GET_FILE_INFO_CODECS, "raw")
1152
1153 # First call: populate cache.
1154 underlying._request.get = mock.AsyncMock(return_value=_make_file_info_response())
1155 await client.get_track_file_info("42")
1156 assert cache_key in client._file_info_cache
1157
1158 # Trigger the BadRequest code path. A second call WITHOUT bypass would
1159 # short-circuit on the cache hit and never reach the network â so we use
1160 # BYPASS_THROTTLER (the same context that stream URL refresh uses) to skip
1161 # the cache lookup. The 4xx-invalidation runs regardless of bypass.
1162 underlying._request.get = mock.AsyncMock(side_effect=BadRequestError("nope"))
1163 token = BYPASS_THROTTLER.set(True)
1164 try:
1165 result = await client.get_track_file_info("42")
1166 finally:
1167 BYPASS_THROTTLER.reset(token)
1168 assert result is None
1169 # Cache invalidated by the BadRequest handler.
1170 assert cache_key not in client._file_info_cache
1171
1172
1173async def test_file_info_cache_bypassed_when_bypass_throttler_set() -> None:
1174 """Under BYPASS_THROTTLER, refresh must hit the network even with cached entry."""
1175 client, underlying = _make_client()
1176 underlying._request = mock.MagicMock()
1177 underlying._request.get = mock.AsyncMock(return_value=_make_file_info_response())
1178 underlying.base_url = "https://api.music.yandex.net"
1179
1180 await client.get_track_file_info("42") # populate cache
1181
1182 token = BYPASS_THROTTLER.set(True)
1183 try:
1184 await client.get_track_file_info("42")
1185 finally:
1186 BYPASS_THROTTLER.reset(token)
1187
1188 assert underlying._request.get.await_count == 2
1189
1190
1191async def test_file_info_cache_lru_eviction(monkeypatch: pytest.MonkeyPatch) -> None:
1192 """When the cache exceeds FILE_INFO_CACHE_MAX, the oldest entry is evicted."""
1193 monkeypatch.setattr(
1194 "music_assistant.providers.yandex_music.api_client.FILE_INFO_CACHE_MAX",
1195 2,
1196 )
1197
1198 client, underlying = _make_client()
1199 underlying._request = mock.MagicMock()
1200 underlying.base_url = "https://api.music.yandex.net"
1201
1202 counter = {"n": 0}
1203
1204 async def _vary_response(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
1205 # Return distinct URLs so the cache entries are distinguishable.
1206 return _make_file_info_response(url=f"https://example.com/{counter['n']}")
1207
1208 underlying._request.get = mock.AsyncMock(side_effect=_vary_response)
1209
1210 for tid in ("1", "2", "3"):
1211 counter["n"] += 1
1212 await client.get_track_file_info(tid)
1213
1214 assert len(client._file_info_cache) == 2
1215 # Oldest ("1") must have been evicted.
1216 assert ("1", "lossless", GET_FILE_INFO_CODECS, "raw") not in client._file_info_cache
1217 assert ("2", "lossless", GET_FILE_INFO_CODECS, "raw") in client._file_info_cache
1218 assert ("3", "lossless", GET_FILE_INFO_CODECS, "raw") in client._file_info_cache
1219
1220
1221# -- regression tests for upstream Copilot review (PR #3882) -----------------
1222
1223
1224async def test_check_block_runs_again_after_throttler_acquire() -> None:
1225 """
1226 A concurrent request that passed the pre-check must bail after acquire().
1227
1228 Without the post-acquire re-check, requests already queued in the
1229 throttler when another request engages the cooldown would proceed to
1230 the network and prolong Yandex's edge ban.
1231 """
1232 client, underlying = _make_client()
1233 underlying.tracks = mock.AsyncMock(return_value=[])
1234
1235 # Simulate the race: while we're queued in acquire(), another request
1236 # engages the captcha block. Model this by setting _block_until as a
1237 # side effect of the throttler's acquire().
1238 async def _engage_block_during_queue() -> None:
1239 client._block_until["default"] = time.monotonic() + 600
1240
1241 default_acquire = cast("mock.AsyncMock", client._throttlers["default"].acquire)
1242 default_acquire.side_effect = _engage_block_during_queue
1243
1244 with pytest.raises(ResourceTemporarilyUnavailable):
1245 await client.get_tracks(["42"])
1246
1247 # The actual network call must NEVER have fired.
1248 underlying.tracks.assert_not_awaited()
1249
1250
1251async def test_rotor_feedback_no_retry_propagates_429_to_engage_block() -> None:
1252 """
1253 Rotor session feedback (with_retry=False) must propagate 429s.
1254
1255 The inner `_do` swallows ordinary NetworkErrors for fire-and-forget
1256 paths, but a captcha 429 must reach `_call_no_retry` so the rotor
1257 cooldown is engaged; otherwise feedback events keep hammering Yandex
1258 during an active edge ban.
1259 """
1260 client, underlying = _make_client()
1261 underlying._request = mock.MagicMock()
1262 underlying._request.post = mock.AsyncMock(side_effect=NetworkError(_CAPTCHA_HTML_SNIPPET))
1263 underlying.base_url = "https://api.music.yandex.net"
1264
1265 # Pre-condition: rotor kind not blocked.
1266 assert client._block_until["rotor"] == 0.0
1267
1268 result = await client.rotor_session_feedback(
1269 "session-xyz",
1270 "trackStarted",
1271 track_id="42",
1272 )
1273
1274 # Feedback is fire-and-forget â caller gets False, no raise.
1275 assert result is False
1276 # But the rotor cooldown MUST have been engaged (first-strike: 60s).
1277 assert client._block_until["rotor"] > time.monotonic() + 10
1278 # Other kinds untouched.
1279 assert client._block_until["default"] == 0.0
1280 assert client._block_until["file_info"] == 0.0
1281
1282
1283async def test_retry_path_classifies_captcha_after_reconnect() -> None:
1284 """
1285 A captcha 429 on the reconnect-retry attempt must engage the block.
1286
1287 Without classification on the retry, the raw NetworkError propagates
1288 with the full HTML body and the kind cooldown is never set.
1289 """
1290 client, underlying = _make_client()
1291 # First attempt: connection error â triggers reconnect.
1292 # Retry attempt: captcha 429 â must be classified.
1293 underlying.tracks = mock.AsyncMock(
1294 side_effect=[
1295 NetworkError("Server disconnected"),
1296 NetworkError(_CAPTCHA_HTML_SNIPPET),
1297 ]
1298 )
1299
1300 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
1301 await client.get_tracks(["42"])
1302
1303 # Should have backed off for the first-strike captcha cooldown.
1304 assert exc_info.value.backoff_time == 15
1305 # Block engaged on default kind.
1306 assert client._block_until["default"] > time.monotonic() + 10
1307 # Both attempts ran (connection error + retry).
1308 assert underlying.tracks.await_count == 2
1309 # The HTML body must be truncated in the chain, not propagated raw.
1310 cause = exc_info.value.__cause__
1311 assert cause is not None
1312 assert str(cause).endswith("...[truncated]")
1313
1314
1315async def test_retry_path_re_checks_block_before_retry() -> None:
1316 """
1317 A retry after reconnect must re-check the per-kind block.
1318
1319 Another concurrent task may engage the cooldown while the reconnect is
1320 in flight; without a re-check, the retry would still hit Yandex during
1321 the cooldown and prolong the edge ban.
1322 """
1323 client, underlying = _make_client()
1324
1325 async def _fake_reconnect() -> None:
1326 # Simulate that while this task is reconnecting, another concurrent
1327 # task hits captcha on the same kind and engages the cooldown.
1328 client._block_until["default"] = time.monotonic() + 600
1329
1330 client._reconnect = _fake_reconnect # type: ignore[method-assign]
1331 underlying.tracks = mock.AsyncMock(side_effect=NetworkError("Server disconnected"))
1332
1333 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
1334 await client.get_tracks(["42"])
1335
1336 # The retry must have bailed BEFORE making a second network call.
1337 assert underlying.tracks.await_count == 1
1338 # And the surfaced error must reflect the cooldown, not the connection error.
1339 assert "cooldown" in str(exc_info.value).lower()
1340
1341
1342async def test_file_info_cache_hit_blocked_during_cooldown() -> None:
1343 """
1344 A populated cache must not be served while the file_info kind is blocked.
1345
1346 Otherwise the streaming layer would happily replay a pre-cooldown URL
1347 while Yandex is actively rate-limiting our IP/account, defeating the
1348 fail-fast guarantee.
1349 """
1350 client, underlying = _make_client()
1351 underlying._request = mock.MagicMock()
1352 underlying._request.get = mock.AsyncMock(return_value=_make_file_info_response())
1353 underlying.base_url = "https://api.music.yandex.net"
1354
1355 # Populate cache.
1356 await client.get_track_file_info("42")
1357 assert ("42", "lossless", GET_FILE_INFO_CODECS, "raw") in client._file_info_cache
1358 assert underlying._request.get.await_count == 1
1359
1360 # Engage the file_info cooldown.
1361 client._block_until["file_info"] = time.monotonic() + 600
1362
1363 # Subsequent call must NOT serve the cached URL.
1364 result = await client.get_track_file_info("42")
1365 assert result is None
1366 # And no extra network call (block fast-fails before the cache lookup).
1367 assert underlying._request.get.await_count == 1
1368
1369
1370async def test_bypass_refresh_replaces_cached_entry() -> None:
1371 """
1372 BYPASS_THROTTLER refresh must overwrite the existing cache entry.
1373
1374 Otherwise the next non-bypass caller keeps receiving the old URL until
1375 the TTL expires, even though refresh has just proven that entry stale.
1376 """
1377 client, underlying = _make_client()
1378 underlying._request = mock.MagicMock()
1379 underlying.base_url = "https://api.music.yandex.net"
1380
1381 # First call: populate cache with the OLD URL.
1382 underlying._request.get = mock.AsyncMock(
1383 return_value=_make_file_info_response(url="https://example.com/old")
1384 )
1385 first = await client.get_track_file_info("42")
1386 assert first is not None
1387 assert first["url"] == "https://example.com/old"
1388
1389 # Refresh under BYPASS_THROTTLER with a fresh URL.
1390 underlying._request.get = mock.AsyncMock(
1391 return_value=_make_file_info_response(url="https://example.com/new")
1392 )
1393 token = BYPASS_THROTTLER.set(True)
1394 try:
1395 refreshed = await client.get_track_file_info("42")
1396 finally:
1397 BYPASS_THROTTLER.reset(token)
1398 assert refreshed is not None
1399 assert refreshed["url"] == "https://example.com/new"
1400
1401 # Now a non-bypass caller must get the REFRESHED URL from cache, not the old one.
1402 underlying._request.get = mock.AsyncMock(
1403 side_effect=AssertionError("should hit cache, not network")
1404 )
1405 cached = await client.get_track_file_info("42")
1406 assert cached is not None
1407 assert cached["url"] == "https://example.com/new"
1408
1409
1410async def test_file_info_cache_invalidated_on_unauthorized() -> None:
1411 """
1412 UnauthorizedError on a refresh must clear the cached entry for the track.
1413
1414 Otherwise post-re-auth callers could be served a URL tied to the
1415 expired session.
1416 """
1417 client, underlying = _make_client()
1418 underlying._request = mock.MagicMock()
1419 underlying.base_url = "https://api.music.yandex.net"
1420 cache_key = ("42", "lossless", GET_FILE_INFO_CODECS, "raw")
1421
1422 # Populate cache.
1423 underlying._request.get = mock.AsyncMock(return_value=_make_file_info_response())
1424 await client.get_track_file_info("42")
1425 assert cache_key in client._file_info_cache
1426
1427 # UnauthorizedError on a bypass refresh â must invalidate.
1428 underlying._request.get = mock.AsyncMock(side_effect=UnauthorizedError("token expired"))
1429 token = BYPASS_THROTTLER.set(True)
1430 try:
1431 result = await client.get_track_file_info("42")
1432 finally:
1433 BYPASS_THROTTLER.reset(token)
1434 assert result is None
1435 assert cache_key not in client._file_info_cache
1436
1437
1438# -- captcha cooldown ladder + decay (#146) -----------------------------------
1439
1440
1441async def test_captcha_first_strike_uses_short_cooldown() -> None:
1442 """First captcha strike picks the short rung â empirical Yandex recovery ~15s."""
1443 client, underlying = _make_client()
1444 underlying.tracks = mock.AsyncMock(side_effect=NetworkError(_CAPTCHA_HTML_SNIPPET))
1445
1446 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
1447 await client.get_tracks(["42"])
1448
1449 assert exc_info.value.backoff_time == 15
1450 assert len(client._captcha_strikes["default"]) == 1
1451
1452
1453async def test_captcha_second_strike_uses_medium_cooldown() -> None:
1454 """Second strike in the retention window escalates to 60s."""
1455 client, underlying = _make_client()
1456 underlying.tracks = mock.AsyncMock(side_effect=NetworkError(_CAPTCHA_HTML_SNIPPET))
1457
1458 # First strike
1459 with pytest.raises(ResourceTemporarilyUnavailable):
1460 await client.get_tracks(["42"])
1461 # Clear the block so the second call is allowed to reach the API and trip again.
1462 client._block_until["default"] = 0.0
1463
1464 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
1465 await client.get_tracks(["42"])
1466
1467 assert exc_info.value.backoff_time == 60
1468 assert len(client._captcha_strikes["default"]) == 2
1469
1470
1471async def test_captcha_third_strike_uses_max_cooldown() -> None:
1472 """Third and later strikes cap at 120s."""
1473 client, underlying = _make_client()
1474 underlying.tracks = mock.AsyncMock(side_effect=NetworkError(_CAPTCHA_HTML_SNIPPET))
1475
1476 for _ in range(2):
1477 with pytest.raises(ResourceTemporarilyUnavailable):
1478 await client.get_tracks(["42"])
1479 client._block_until["default"] = 0.0
1480
1481 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
1482 await client.get_tracks(["42"])
1483
1484 assert exc_info.value.backoff_time == 120
1485 assert len(client._captcha_strikes["default"]) == 3
1486
1487
1488async def test_captcha_fourth_strike_stays_at_max_cooldown() -> None:
1489 """Strikes beyond the ladder length stay capped at the last rung (120s)."""
1490 client, underlying = _make_client()
1491 underlying.tracks = mock.AsyncMock(side_effect=NetworkError(_CAPTCHA_HTML_SNIPPET))
1492
1493 for _ in range(3):
1494 with pytest.raises(ResourceTemporarilyUnavailable):
1495 await client.get_tracks(["42"])
1496 client._block_until["default"] = 0.0
1497
1498 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
1499 await client.get_tracks(["42"])
1500
1501 assert exc_info.value.backoff_time == 120
1502
1503
1504async def test_captcha_strikes_decay_after_retention_window() -> None:
1505 """Strikes outside CAPTCHA_STRIKE_RETENTION_S are forgotten â ladder resets."""
1506 client, underlying = _make_client()
1507 underlying.tracks = mock.AsyncMock(side_effect=NetworkError(_CAPTCHA_HTML_SNIPPET))
1508
1509 # Two strikes in quick succession.
1510 with pytest.raises(ResourceTemporarilyUnavailable):
1511 await client.get_tracks(["42"])
1512 client._block_until["default"] = 0.0
1513 with pytest.raises(ResourceTemporarilyUnavailable):
1514 await client.get_tracks(["42"])
1515 client._block_until["default"] = 0.0
1516 assert len(client._captcha_strikes["default"]) == 2
1517
1518 # Age both strikes past the retention window.
1519 aged = time.monotonic() - 3700.0 # > CAPTCHA_STRIKE_RETENTION_S (3600s)
1520 client._captcha_strikes["default"].clear()
1521 client._captcha_strikes["default"].extend([aged, aged])
1522
1523 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
1524 await client.get_tracks(["42"])
1525
1526 # Aged strikes were trimmed; this is a "fresh" first strike again.
1527 assert exc_info.value.backoff_time == 15
1528 assert len(client._captcha_strikes["default"]) == 1
1529
1530
1531async def test_captcha_strikes_per_kind_isolated() -> None:
1532 """A captcha on file_info must not bump the default strike counter."""
1533 client, underlying = _make_client()
1534 underlying._request = mock.MagicMock()
1535 underlying._request.get = mock.AsyncMock(side_effect=NetworkError(_CAPTCHA_HTML_SNIPPET))
1536 underlying.base_url = "https://api.music.yandex.net"
1537
1538 # Trip captcha on file_info via the BYPASS_THROTTLER + get_track_file_info path
1539 # (which swallows the exception and returns None).
1540 token = BYPASS_THROTTLER.set(True)
1541 try:
1542 result = await client.get_track_file_info("42")
1543 finally:
1544 BYPASS_THROTTLER.reset(token)
1545 assert result is None
1546
1547 assert len(client._captcha_strikes["file_info"]) == 1
1548 assert len(client._captcha_strikes["default"]) == 0
1549
1550
1551# -- metadata throttler kind (#146) -------------------------------------------
1552
1553
1554def test_metadata_kind_uses_separate_throttler() -> None:
1555 """`metadata` resolves to a different Throttler than `default`."""
1556 client = YandexMusicClient(token=SecretStr("fake_token"))
1557 assert client._get_throttler("metadata") is not client._get_throttler("default")
1558 assert client._get_throttler("metadata") is not client._get_throttler("file_info")
1559 assert client._get_throttler("metadata") is not client._get_throttler("rotor")
1560
1561
1562async def test_metadata_captcha_does_not_block_default() -> None:
1563 """A captcha-driven `metadata` block must not stop `default` calls."""
1564 client, underlying = _make_client()
1565 client._block_until["metadata"] = time.monotonic() + 600
1566
1567 underlying.tracks = mock.AsyncMock(return_value=[])
1568 await client.get_tracks(["1"])
1569 underlying.tracks.assert_awaited()
1570
1571
1572async def test_default_captcha_does_not_block_metadata() -> None:
1573 """A captcha-driven `default` block must not stop `metadata` calls."""
1574 client, underlying = _make_client()
1575 client._block_until["default"] = time.monotonic() + 600
1576
1577 underlying.artists = mock.AsyncMock(return_value=[mock.MagicMock()])
1578 result = await client.get_artist("42")
1579 assert result is not None
1580 underlying.artists.assert_awaited()
1581
1582
1583@pytest.mark.parametrize(
1584 ("method_name", "underlying_attr", "underlying_return", "call_args"),
1585 [
1586 ("get_album", "albums", [mock.MagicMock()], ("42",)),
1587 (
1588 "get_album_with_tracks",
1589 "albums_with_tracks",
1590 mock.MagicMock(),
1591 ("42",),
1592 ),
1593 ("get_artist", "artists", [mock.MagicMock()], ("42",)),
1594 (
1595 "get_artist_albums",
1596 "artists_direct_albums",
1597 mock.MagicMock(albums=[mock.MagicMock()]),
1598 ("42",),
1599 ),
1600 ("get_artist_about", "artists_about", mock.MagicMock(), ("42",)),
1601 (
1602 "get_artist_tracks",
1603 "artists_tracks",
1604 mock.MagicMock(tracks=[mock.MagicMock()]),
1605 ("42",),
1606 ),
1607 ],
1608)
1609async def test_metadata_methods_use_metadata_throttler(
1610 method_name: str,
1611 underlying_attr: str,
1612 underlying_return: Any,
1613 call_args: tuple[str, ...],
1614) -> None:
1615 """Each metadata-refresh method must acquire the metadata throttler."""
1616 client, underlying = _make_client()
1617 setattr(underlying, underlying_attr, mock.AsyncMock(return_value=underlying_return))
1618
1619 method = getattr(client, method_name)
1620 await method(*call_args)
1621
1622 metadata_throttler = cast("mock.AsyncMock", client._throttlers["metadata"])
1623 default_throttler = cast("mock.AsyncMock", client._throttlers["default"])
1624 metadata_throttler.acquire.assert_awaited()
1625 default_throttler.acquire.assert_not_awaited()
1626
1627
1628# -- initial-sync jitter window (#146) ----------------------------------------
1629
1630
1631async def test_jitter_applied_for_default_within_initial_sync_window() -> None:
1632 """`default` calls within INITIAL_SYNC_WINDOW_S get a positive jitter delay."""
1633 client, underlying = _make_client()
1634 client._connected_at = time.monotonic() # window is currently active
1635 underlying.tracks = mock.AsyncMock(return_value=[])
1636
1637 with (
1638 mock.patch(
1639 "music_assistant.providers.yandex_music.api_client.random.uniform",
1640 return_value=0.25,
1641 ),
1642 mock.patch(
1643 "music_assistant.providers.yandex_music.api_client.asyncio.sleep",
1644 new_callable=mock.AsyncMock,
1645 ) as sleep_mock,
1646 ):
1647 await client.get_tracks(["1"])
1648
1649 sleep_mock.assert_awaited()
1650 assert sleep_mock.await_args is not None
1651 delay = sleep_mock.await_args.args[0]
1652 assert 0.0 <= delay <= 0.5 # INITIAL_SYNC_JITTER_S = 0.5
1653
1654
1655async def test_jitter_applied_for_metadata_within_initial_sync_window() -> None:
1656 """`metadata` calls within INITIAL_SYNC_WINDOW_S get a positive jitter delay."""
1657 client, underlying = _make_client()
1658 client._connected_at = time.monotonic()
1659 underlying.artists = mock.AsyncMock(return_value=[mock.MagicMock()])
1660
1661 with (
1662 mock.patch(
1663 "music_assistant.providers.yandex_music.api_client.random.uniform",
1664 return_value=0.25,
1665 ),
1666 mock.patch(
1667 "music_assistant.providers.yandex_music.api_client.asyncio.sleep",
1668 new_callable=mock.AsyncMock,
1669 ) as sleep_mock,
1670 ):
1671 await client.get_artist("1")
1672
1673 sleep_mock.assert_awaited()
1674
1675
1676async def test_jitter_skipped_after_initial_sync_window() -> None:
1677 """Outside INITIAL_SYNC_WINDOW_S the helper is a no-op."""
1678 client, underlying = _make_client()
1679 # Connected 120s ago â well past the 60s window.
1680 client._connected_at = time.monotonic() - 120.0
1681 underlying.tracks = mock.AsyncMock(return_value=[])
1682
1683 with mock.patch(
1684 "music_assistant.providers.yandex_music.api_client.asyncio.sleep",
1685 new_callable=mock.AsyncMock,
1686 ) as sleep_mock:
1687 await client.get_tracks(["1"])
1688
1689 sleep_mock.assert_not_awaited()
1690
1691
1692async def test_jitter_skipped_when_never_connected() -> None:
1693 """If _connected_at is None (no successful connect yet), jitter is skipped."""
1694 client, underlying = _make_client()
1695 client._connected_at = None
1696 underlying.tracks = mock.AsyncMock(return_value=[])
1697
1698 with mock.patch(
1699 "music_assistant.providers.yandex_music.api_client.asyncio.sleep",
1700 new_callable=mock.AsyncMock,
1701 ) as sleep_mock:
1702 await client.get_tracks(["1"])
1703
1704 sleep_mock.assert_not_awaited()
1705
1706
1707async def test_jitter_skipped_for_file_info_kind() -> None:
1708 """file_info is on the streaming hot path â jitter must never apply."""
1709 client, underlying = _make_client()
1710 client._connected_at = time.monotonic() # window active
1711 raw_response = {
1712 "downloadInfo": {
1713 "url": "https://example.com/x",
1714 "codec": "flac-mp4",
1715 }
1716 }
1717 underlying._request = mock.MagicMock()
1718 underlying._request.get = mock.AsyncMock(return_value=raw_response)
1719 underlying.base_url = "https://api.music.yandex.net"
1720
1721 with mock.patch(
1722 "music_assistant.providers.yandex_music.api_client.asyncio.sleep",
1723 new_callable=mock.AsyncMock,
1724 ) as sleep_mock:
1725 await client.get_track_file_info("42")
1726
1727 sleep_mock.assert_not_awaited()
1728
1729
1730async def test_jitter_skipped_for_rotor_kind() -> None:
1731 """Rotor has its own bucket â jitter must never apply."""
1732 client, underlying = _make_client()
1733 client._connected_at = time.monotonic()
1734 dashboard = mock.MagicMock(spec=Dashboard)
1735 dashboard.stations = []
1736 underlying.rotor_stations_dashboard = mock.AsyncMock(return_value=dashboard)
1737
1738 with mock.patch(
1739 "music_assistant.providers.yandex_music.api_client.asyncio.sleep",
1740 new_callable=mock.AsyncMock,
1741 ) as sleep_mock:
1742 await client.get_dashboard_stations()
1743
1744 sleep_mock.assert_not_awaited()
1745 assert len(client._captcha_strikes["metadata"]) == 0
1746
1747
1748# -- regression pins (#146) ---------------------------------------------------
1749
1750
1751def test_throttle_default_rps_is_5() -> None:
1752 """Pin the default RPS â empirical probing showed Yandex tolerates â¥10."""
1753 assert THROTTLE_DEFAULT_RPS == 5
1754
1755
1756def test_throttle_metadata_rps_is_3() -> None:
1757 """Pin the metadata RPS."""
1758 assert THROTTLE_METADATA_RPS == 3
1759
1760
1761def test_captcha_cooldown_ladder_is_15_60_120() -> None:
1762 """Pin the shortened ladder â empirical recovery time was ~15s, not 60s."""
1763 assert CAPTCHA_COOLDOWN_LADDER_S == (15.0, 60.0, 120.0)
1764
1765
1766def test_initial_sync_window_constants() -> None:
1767 """Pin the jitter window defaults."""
1768 assert INITIAL_SYNC_JITTER_S == 0.5
1769 assert INITIAL_SYNC_WINDOW_S == 60.0
1770
1771
1772def test_classify_429_behavior_unchanged_smart_captcha() -> None:
1773 """Existing captcha classification still detects smart-captcha markers."""
1774 client, _ = _make_client()
1775 err = NetworkError(_CAPTCHA_HTML_SNIPPET)
1776 assert client._classify_429(err) == "captcha"
1777
1778
1779def test_classify_429_behavior_unchanged_plain_429() -> None:
1780 """Existing classification still returns 'rate_limit' for bare 429."""
1781 client, _ = _make_client()
1782 err = NetworkError("Bad Request (429): Too Many Requests")
1783 assert client._classify_429(err) == "rate_limit"
1784
1785
1786def test_classify_429_behavior_unchanged_non_network() -> None:
1787 """Existing classification still returns 'other' for non-NetworkError."""
1788 client, _ = _make_client()
1789 err = ValueError("HTTP 429 from some other source")
1790 assert client._classify_429(err) == "other"
1791
1792
1793# -- RTU propagation regression (#146): metadata methods must NOT swallow ----
1794# the captcha cooldown. ResourceTemporarilyUnavailable is a sibling of
1795# ProviderUnavailableError under MusicAssistantError, not a descendant, so
1796# the (BadRequestError, NetworkError, ProviderUnavailableError) catch tuple
1797# correctly lets RTU propagate. These tests pin that contract â a future
1798# refactor widening the catch to MusicAssistantError would silently defeat
1799# the entire #146 cooldown mechanism.
1800
1801
1802async def test_get_album_propagates_captcha_rtu() -> None:
1803 """A captcha trip in get_album must raise RTU, not return None."""
1804 client, underlying = _make_client()
1805 underlying.albums = mock.AsyncMock(side_effect=NetworkError(_CAPTCHA_HTML_SNIPPET))
1806 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
1807 await client.get_album("42")
1808 assert exc_info.value.backoff_time == 15
1809
1810
1811async def test_get_album_with_tracks_propagates_captcha_rtu() -> None:
1812 """A captcha trip in get_album_with_tracks must raise RTU, not return None."""
1813 client, underlying = _make_client()
1814 underlying.albums_with_tracks = mock.AsyncMock(side_effect=NetworkError(_CAPTCHA_HTML_SNIPPET))
1815 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
1816 await client.get_album_with_tracks("42")
1817 assert exc_info.value.backoff_time == 15
1818
1819
1820async def test_get_artist_propagates_captcha_rtu() -> None:
1821 """A captcha trip in get_artist must raise RTU, not return None."""
1822 client, underlying = _make_client()
1823 underlying.artists = mock.AsyncMock(side_effect=NetworkError(_CAPTCHA_HTML_SNIPPET))
1824 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
1825 await client.get_artist("42")
1826 assert exc_info.value.backoff_time == 15
1827
1828
1829async def test_get_artist_albums_propagates_captcha_rtu() -> None:
1830 """A captcha trip in get_artist_albums must raise RTU, not return []."""
1831 client, underlying = _make_client()
1832 underlying.artists_direct_albums = mock.AsyncMock(
1833 side_effect=NetworkError(_CAPTCHA_HTML_SNIPPET)
1834 )
1835 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
1836 await client.get_artist_albums("42")
1837 assert exc_info.value.backoff_time == 15
1838
1839
1840async def test_get_artist_about_propagates_captcha_rtu() -> None:
1841 """A captcha trip in get_artist_about must raise RTU, not return None."""
1842 client, underlying = _make_client()
1843 underlying.artists_about = mock.AsyncMock(side_effect=NetworkError(_CAPTCHA_HTML_SNIPPET))
1844 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
1845 await client.get_artist_about("42")
1846 assert exc_info.value.backoff_time == 15
1847
1848
1849async def test_get_artist_tracks_propagates_captcha_rtu() -> None:
1850 """A captcha trip in get_artist_tracks must raise RTU, not return []."""
1851 client, underlying = _make_client()
1852 underlying.artists_tracks = mock.AsyncMock(side_effect=NetworkError(_CAPTCHA_HTML_SNIPPET))
1853 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
1854 await client.get_artist_tracks("42")
1855 assert exc_info.value.backoff_time == 15
1856
1857
1858# -- jitter respects BYPASS_THROTTLER (#146) ---------------------------------
1859
1860
1861async def test_jitter_skipped_under_bypass_throttler() -> None:
1862 """
1863 Stream URL refresh paths run under BYPASS_THROTTLER â jitter must not fire.
1864
1865 The helper sits inside the ``if not BYPASS_THROTTLER.get():`` block in
1866 both _call_with_retry and _call_no_retry. If a future refactor lifts
1867 the jitter call out of that block, stream URL refresh would eat up to
1868 INITIAL_SYNC_JITTER_S of avoidable latency during the first
1869 INITIAL_SYNC_WINDOW_S after every connect â exactly when reconnect
1870 storms make latency hurt the most.
1871 """
1872 client, underlying = _make_client()
1873 client._connected_at = time.monotonic() # window is active
1874
1875 raw_response = {
1876 "downloadInfo": {
1877 "url": "https://example.com/x",
1878 "codec": "flac-mp4",
1879 }
1880 }
1881 underlying._request = mock.MagicMock()
1882 underlying._request.get = mock.AsyncMock(return_value=raw_response)
1883 underlying.base_url = "https://api.music.yandex.net"
1884
1885 with mock.patch(
1886 "music_assistant.providers.yandex_music.api_client.asyncio.sleep",
1887 new_callable=mock.AsyncMock,
1888 ) as sleep_mock:
1889 token = BYPASS_THROTTLER.set(True)
1890 try:
1891 await client.get_track_file_info("42")
1892 finally:
1893 BYPASS_THROTTLER.reset(token)
1894
1895 sleep_mock.assert_not_awaited()
1896
1897
1898# -- M5: BadRequestError handling (4xx is terminal, not retryable) -----------
1899
1900
1901async def test_search_swallows_bad_request_as_empty_result() -> None:
1902 """
1903 A 4xx from Yandex search is terminal â return None, do not signal retry.
1904
1905 Wrapping ``BadRequestError`` as ``ResourceTemporarilyUnavailable`` tells
1906 Music Assistant the request can be retried, which reproduces the same
1907 failure in a loop. The right answer is "no result".
1908 """
1909 client, underlying = _make_client()
1910 underlying.search = mock.AsyncMock(side_effect=BadRequestError("malformed query"))
1911
1912 result = await client.search("any query")
1913
1914 assert result is None
1915 underlying.search.assert_awaited_once()
1916
1917
1918async def test_get_liked_tracks_swallows_bad_request_as_empty_list() -> None:
1919 """Terminal 4xx for liked tracks returns ``[]`` â not a retryable failure."""
1920 client, underlying = _make_client()
1921 underlying.users_likes_tracks = mock.AsyncMock(side_effect=BadRequestError("not allowed"))
1922
1923 result = await client.get_liked_tracks()
1924
1925 assert result == []
1926
1927
1928async def test_get_liked_albums_swallows_bad_request_as_empty_list() -> None:
1929 """Terminal 4xx for liked albums returns ``[]`` â not a retryable failure."""
1930 client, underlying = _make_client()
1931 underlying.users_likes_albums = mock.AsyncMock(side_effect=BadRequestError("not allowed"))
1932
1933 result = await client.get_liked_albums()
1934
1935 assert result == []
1936
1937
1938# -- M8: get_liked_tracks tolerates naive timestamps from yandex-music --------
1939
1940
1941async def test_get_liked_tracks_sort_survives_naive_timestamp() -> None:
1942 """
1943 Sorting must not crash when ``TrackShort.timestamp`` is timezone-naive.
1944
1945 The upstream ``yandex-music`` library is inconsistent about tz on
1946 ``TrackShort.timestamp``. Comparing a naive ``datetime`` against the
1947 previous ``datetime.min.replace(tzinfo=UTC)`` sentinel raises
1948 ``TypeError: can't compare offset-naive and offset-aware datetimes``
1949 and the whole liked-tracks collection fails to load.
1950 """
1951 client, underlying = _make_client()
1952
1953 naive_ts = datetime(2024, 1, 1, 12, 0, 0) # noqa: DTZ001 â naive on purpose
1954 aware_ts = datetime(2024, 6, 1, 12, 0, 0, tzinfo=UTC)
1955 track_naive = type("T", (), {"id": 1, "timestamp": naive_ts})()
1956 track_aware = type("T", (), {"id": 2, "timestamp": aware_ts})()
1957 track_missing = type("T", (), {"id": 3})() # no .timestamp at all
1958
1959 result_obj = type("R", (), {"tracks": [track_naive, track_aware, track_missing]})()
1960 underlying.users_likes_tracks = mock.AsyncMock(return_value=result_obj)
1961
1962 result = await client.get_liked_tracks()
1963
1964 assert {t.id for t in result} == {1, 2, 3}
1965
1966
1967# -- M9: _call_with_retry re-acquires the throttler on reconnect retry ---------
1968
1969
1970async def test_call_with_retry_reacquires_throttler_on_reconnect() -> None:
1971 """
1972 The reconnect-retry path must consume a throttler token too.
1973
1974 Skipping ``throttler.acquire()`` on the second attempt doubles the
1975 effective request rate during connection flap â exactly the conditions
1976 that already increase the risk of Yandex's smart-captcha tripping.
1977 """
1978 client, underlying = _make_client()
1979
1980 # Make .tracks fail once with a connection error, then succeed.
1981 track = type("T", (), {"id": 42})()
1982 underlying.tracks = mock.AsyncMock(side_effect=[NetworkError("ECONNRESET"), [track]])
1983
1984 result = await client.get_tracks(["42"])
1985
1986 assert result == [track]
1987 # The throttler used by get_tracks falls under the "default" kind.
1988 default_throttler = client._throttlers["default"]
1989 assert default_throttler.acquire.await_count == 2, ( # type: ignore[attr-defined]
1990 "throttler must be re-acquired on the reconnect-retry attempt"
1991 )
1992
1993
1994async def test_jitter_skipped_when_kind_already_blocked() -> None:
1995 """
1996 A blocked kind must fast-fail BEFORE the jitter sleep.
1997
1998 Order contract in _call_with_retry: _check_block -> jitter -> acquire ->
1999 _check_block. The pre-check raises RTU immediately when the kind is
2000 quarantined, so the jitter sleep never runs. A refactor that reorders
2001 these calls would turn a fast-fail circuit breaker into a slow-fail
2002 one during the first INITIAL_SYNC_WINDOW_S after connect â exactly
2003 when MA's library walker is hammering the provider hardest.
2004 """
2005 client, underlying = _make_client()
2006 client._connected_at = time.monotonic() # window is active
2007 client._block_until["default"] = time.monotonic() + 600 # kind quarantined
2008 underlying.tracks = mock.AsyncMock(return_value=[])
2009
2010 with (
2011 mock.patch(
2012 "music_assistant.providers.yandex_music.api_client.asyncio.sleep",
2013 new_callable=mock.AsyncMock,
2014 ) as sleep_mock,
2015 pytest.raises(ResourceTemporarilyUnavailable),
2016 ):
2017 await client.get_tracks(["1"])
2018
2019 sleep_mock.assert_not_awaited()
2020 # Fast-fail: underlying API was never called.
2021 underlying.tracks.assert_not_awaited()
2022
2023
2024# -- Per-endpoint concurrency lock (defense-in-depth vs Yandex captcha) -------
2025
2026
2027async def test_parallel_same_endpoint_calls_serialize() -> None:
2028 """
2029 Parallel calls to the same endpoint must run one-at-a-time.
2030
2031 Yandex's edge treats concurrent requests to the same URL family as a
2032 scraper signature and trips captcha within ~460 ms. The per-endpoint
2033 lock in ``_call_with_retry`` is the defense-in-depth that prevents a
2034 future ``asyncio.gather`` from re-introducing the same burst pattern.
2035 """
2036 client, underlying = _make_client()
2037
2038 concurrent_peak = 0
2039 in_flight = 0
2040 lock = asyncio.Lock()
2041
2042 async def _slow_tracks(_track_ids: list[str]) -> list[Any]:
2043 nonlocal concurrent_peak, in_flight
2044 async with lock:
2045 in_flight += 1
2046 concurrent_peak = max(concurrent_peak, in_flight)
2047 try:
2048 await asyncio.sleep(0.05)
2049 return []
2050 finally:
2051 async with lock:
2052 in_flight -= 1
2053
2054 underlying.tracks = _slow_tracks
2055
2056 # Fire 5 parallel calls to the SAME method; per-endpoint lock should
2057 # serialise them despite ``asyncio.gather`` queueing them simultaneously.
2058 await asyncio.gather(*(client.get_tracks([str(i)]) for i in range(5)))
2059
2060 assert concurrent_peak == 1, (
2061 f"per-endpoint lock failed to serialise; saw {concurrent_peak} concurrent calls"
2062 )
2063
2064
2065async def test_restrictive_mode_caps_global_concurrency() -> None:
2066 """
2067 Restrictive mode caps total in-flight requests to ``RESTRICTIVE_GLOBAL_CONCURRENCY``.
2068
2069 Yandex's edge enforces a per-token concurrency limit on datacenter /
2070 VPN IPs (empirically ~6 simultaneous before captcha). The
2071 restrictive_rate_limits toggle adds a token-wide semaphore so the
2072 provider stays under that ceiling regardless of how the call sites
2073 fan out.
2074 """
2075 client = YandexMusicClient(token=SecretStr("fake"), restrictive_rate_limits=True)
2076 mock_underlying = mock.AsyncMock()
2077 client._client = mock_underlying
2078 client._user_id = 12345
2079 for kind in client._throttlers:
2080 client._throttlers[kind] = mock.AsyncMock()
2081
2082 async def _fake_connect() -> bool:
2083 client._client = mock_underlying
2084 return True
2085
2086 client.connect = _fake_connect # type: ignore[method-assign]
2087
2088 concurrent_peak = 0
2089 in_flight = 0
2090 state_lock = asyncio.Lock()
2091
2092 # Stub direct on YandexMusicClient methods (each one different) so the
2093 # per-endpoint lock cannot also bound this â only the global semaphore
2094 # should. We hijack ``_call_with_retry`` itself: it's the place every
2095 # method funnels through, and instrumenting it lets us count true
2096 # in-flight invocations without re-shaping every yandex_music response.
2097 real_invoke = client._invoke_under_endpoint_lock
2098
2099 async def _instrumented(_func: Any, _real_client: Any, _endpoint: Any) -> Any:
2100 nonlocal concurrent_peak, in_flight
2101 async with state_lock:
2102 in_flight += 1
2103 concurrent_peak = max(concurrent_peak, in_flight)
2104 try:
2105 await asyncio.sleep(0.05)
2106 finally:
2107 async with state_lock:
2108 in_flight -= 1
2109 # Bypass the actual HTTP call after measuring â we only care about
2110 # how many entered the gate at once, not what they return.
2111 return mock.MagicMock()
2112
2113 client._invoke_under_endpoint_lock = _instrumented # type: ignore[method-assign,assignment]
2114
2115 async def _call(i: int) -> Any:
2116 # Each iteration uses a different ``__qualname__`` so per-endpoint
2117 # locks don't interfere with the measurement.
2118 async def _fake(_c: Any) -> Any:
2119 return None
2120
2121 _fake.__qualname__ = f"YandexMusicClient.synthetic_{i}.<locals>.<lambda>"
2122 return await client._call_with_retry(_fake, kind="default")
2123
2124 # Fire 8 parallel calls. Without the global semaphore, peak concurrency
2125 # would be 8. With it, peak ⤠RESTRICTIVE_GLOBAL_CONCURRENCY.
2126 await asyncio.gather(*(_call(i) for i in range(8)))
2127
2128 # restore
2129 client._invoke_under_endpoint_lock = real_invoke # type: ignore[method-assign]
2130
2131 assert concurrent_peak <= RESTRICTIVE_GLOBAL_CONCURRENCY, (
2132 f"restrictive mode failed to cap global concurrency; "
2133 f"peak={concurrent_peak} > {RESTRICTIVE_GLOBAL_CONCURRENCY}"
2134 )
2135
2136
2137async def test_parallel_different_endpoints_run_concurrently() -> None:
2138 """
2139 Calls to different endpoint methods must NOT block each other.
2140
2141 The per-endpoint lock is keyed on the calling method's qualname, so
2142 parallel calls to distinct YandexMusicClient methods proceed in
2143 parallel (subject to throttler/RPS).
2144 """
2145 client, underlying = _make_client()
2146
2147 concurrent_peak = 0
2148 in_flight = 0
2149 lock = asyncio.Lock()
2150
2151 async def _slow(*_args: Any, **_kwargs: Any) -> Any:
2152 nonlocal concurrent_peak, in_flight
2153 async with lock:
2154 in_flight += 1
2155 concurrent_peak = max(concurrent_peak, in_flight)
2156 try:
2157 await asyncio.sleep(0.05)
2158 return []
2159 finally:
2160 async with lock:
2161 in_flight -= 1
2162
2163 underlying.tracks = _slow
2164 underlying.users_likes_albums = _slow
2165
2166 await asyncio.gather(
2167 client.get_tracks(["1"]),
2168 client.get_liked_albums(),
2169 )
2170
2171 assert concurrent_peak == 2, (
2172 f"different endpoints should run in parallel; saw peak={concurrent_peak}"
2173 )
2174