/
/
/
1"""Unit tests for Yandex Music streaming quality selection."""
2
3from __future__ import annotations
4
5import unittest.mock
6from typing import TYPE_CHECKING, Any, Self
7
8import pytest
9from aiohttp import ClientPayloadError, ServerDisconnectedError
10from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
11from music_assistant_models.enums import ContentType, StreamType
12from music_assistant_models.errors import MediaNotFoundError
13from music_assistant_models.media_items import AudioFormat
14from music_assistant_models.streamdetails import StreamDetails
15
16from music_assistant.providers.yandex_music import streaming as _streaming_mod
17from music_assistant.providers.yandex_music.constants import (
18 QUALITY_BALANCED,
19 QUALITY_EFFICIENT,
20 QUALITY_HIGH,
21 QUALITY_SUPERB,
22)
23from music_assistant.providers.yandex_music.streaming import YandexMusicStreamingManager
24
25if TYPE_CHECKING:
26 from tests.providers.yandex_music.conftest import (
27 StreamingProviderStub,
28 StreamingProviderStubWithTracking,
29 )
30
31
32def _make_download_info(
33 codec: str,
34 bitrate_in_kbps: int,
35 direct_link: str = "https://example.com/track",
36) -> Any:
37 """Build DownloadInfo-like object."""
38 return type(
39 "DownloadInfo",
40 (),
41 {
42 "codec": codec,
43 "bitrate_in_kbps": bitrate_in_kbps,
44 "direct_link": direct_link,
45 },
46 )()
47
48
49@pytest.fixture
50def streaming_manager(
51 streaming_provider_stub: StreamingProviderStub,
52) -> YandexMusicStreamingManager:
53 """Create streaming manager with real stub (no Mock)."""
54 return YandexMusicStreamingManager(streaming_provider_stub) # type: ignore[arg-type]
55
56
57@pytest.fixture
58def streaming_manager_with_tracking(
59 streaming_provider_stub_with_tracking: StreamingProviderStubWithTracking,
60) -> YandexMusicStreamingManager:
61 """Create streaming manager with tracking logger for assertions."""
62 return YandexMusicStreamingManager(streaming_provider_stub_with_tracking) # type: ignore[arg-type]
63
64
65def test_select_best_quality_lossless_returns_flac(
66 streaming_manager: YandexMusicStreamingManager,
67) -> None:
68 """When preferred_quality is 'lossless' and list has MP3 and FLAC, FLAC is selected."""
69 mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3")
70 flac = _make_download_info("flac", 0, "https://example.com/track.flac")
71 download_infos = [mp3, flac]
72
73 result = streaming_manager._select_best_quality(download_infos, QUALITY_SUPERB)
74
75 assert result is not None
76 assert result.codec == "flac"
77 assert result.direct_link == "https://example.com/track.flac"
78
79
80def test_select_best_quality_balanced_falls_back_to_highest(
81 streaming_manager: YandexMusicStreamingManager,
82) -> None:
83 """When preferred is 'balanced' and no option in 128-256kbps range, highest bitrate is used."""
84 mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3")
85 flac = _make_download_info("flac", 0, "https://example.com/track.flac")
86 download_infos = [mp3, flac]
87
88 result = streaming_manager._select_best_quality(download_infos, QUALITY_BALANCED)
89
90 assert result is not None
91 assert result.codec == "mp3"
92 assert result.bitrate_in_kbps == 320
93
94
95def test_select_best_quality_legacy_lossless_alias_returns_flac(
96 streaming_manager: YandexMusicStreamingManager,
97) -> None:
98 """
99 Legacy stored value 'lossless' (pre-Superb rename) still maps to FLAC.
100
101 Current UI writes ``superb``; older configs may still hold the literal
102 ``lossless`` string. The selector must treat the two as synonyms.
103 """
104 mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3")
105 flac = _make_download_info("flac", 0, "https://example.com/track.flac")
106 download_infos = [mp3, flac]
107
108 result = streaming_manager._select_best_quality(download_infos, "lossless")
109
110 assert result is not None
111 assert result.codec == "flac"
112
113
114def test_select_best_quality_lossless_no_flac_returns_fallback(
115 streaming_manager_with_tracking: YandexMusicStreamingManager,
116) -> None:
117 """When lossless requested but no FLAC in list, returns best available (fallback)."""
118 mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3")
119 download_infos = [mp3]
120
121 result = streaming_manager_with_tracking._select_best_quality(download_infos, QUALITY_SUPERB)
122
123 assert result is not None
124 assert result.codec == "mp3"
125 assert streaming_manager_with_tracking.provider.logger._warning_count == 1 # type: ignore[attr-defined]
126
127
128def test_select_best_quality_empty_list_returns_none(
129 streaming_manager: YandexMusicStreamingManager,
130) -> None:
131 """Empty download_infos returns None."""
132 result = streaming_manager._select_best_quality([], QUALITY_SUPERB)
133 assert result is None
134
135
136def test_select_best_quality_none_preferred_returns_highest_bitrate(
137 streaming_manager: YandexMusicStreamingManager,
138) -> None:
139 """When preferred_quality is None, returns highest bitrate."""
140 mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3")
141 flac = _make_download_info("flac", 0, "https://example.com/track.flac")
142 download_infos = [mp3, flac]
143
144 result = streaming_manager._select_best_quality(download_infos, None)
145
146 assert result is not None
147 assert result.codec == "mp3"
148 assert result.bitrate_in_kbps == 320
149
150
151def test_get_content_type_flac_mp4_returns_flac_with_flac_codec(
152 streaming_manager: YandexMusicStreamingManager,
153) -> None:
154 """flac-mp4 codec: content_type=FLAC (lossless), codec_type=FLAC (ffmpeg decoder)."""
155 assert streaming_manager._get_content_type("flac-mp4") == (ContentType.FLAC, ContentType.FLAC)
156 assert streaming_manager._get_content_type("FLAC-MP4") == (ContentType.FLAC, ContentType.FLAC)
157
158
159def test_get_content_type_flac_returns_flac_container_with_unknown_codec(
160 streaming_manager: YandexMusicStreamingManager,
161) -> None:
162 """Plain FLAC codec is mapped to FLAC container with UNKNOWN codec."""
163 assert streaming_manager._get_content_type("flac") == (ContentType.FLAC, ContentType.UNKNOWN)
164 assert streaming_manager._get_content_type("FLAC") == (ContentType.FLAC, ContentType.UNKNOWN)
165
166
167def test_get_content_type_aac_variants_return_aac(
168 streaming_manager: YandexMusicStreamingManager,
169) -> None:
170 """All AAC codec variants are mapped correctly (MP4 container or plain AAC)."""
171 # Plain AAC variants
172 assert streaming_manager._get_content_type("aac") == (ContentType.AAC, ContentType.UNKNOWN)
173 assert streaming_manager._get_content_type("AAC") == (ContentType.AAC, ContentType.UNKNOWN)
174 assert streaming_manager._get_content_type("he-aac") == (ContentType.AAC, ContentType.UNKNOWN)
175 assert streaming_manager._get_content_type("HE-AAC") == (ContentType.AAC, ContentType.UNKNOWN)
176 # MP4 container variants â content_type=AAC (audio codec), codec_type=AAC (ffmpeg decoder)
177 assert streaming_manager._get_content_type("aac-mp4") == (ContentType.AAC, ContentType.AAC)
178 assert streaming_manager._get_content_type("AAC-MP4") == (ContentType.AAC, ContentType.AAC)
179 assert streaming_manager._get_content_type("he-aac-mp4") == (ContentType.AAC, ContentType.AAC)
180 assert streaming_manager._get_content_type("HE-AAC-MP4") == (ContentType.AAC, ContentType.AAC)
181
182
183# --- Efficient quality tests ---
184
185
186def test_select_best_quality_efficient_prefers_lowest_aac(
187 streaming_manager: YandexMusicStreamingManager,
188) -> None:
189 """Efficient quality prefers lowest bitrate AAC over higher bitrate options."""
190 mp3_320 = _make_download_info("mp3", 320)
191 aac_64 = _make_download_info("aac", 64)
192 aac_192 = _make_download_info("aac", 192)
193
194 result = streaming_manager._select_best_quality([mp3_320, aac_64, aac_192], QUALITY_EFFICIENT)
195
196 assert result is not None
197 assert result.codec == "aac"
198 assert result.bitrate_in_kbps == 64
199
200
201def test_select_best_quality_efficient_aac_mp4_variant(
202 streaming_manager: YandexMusicStreamingManager,
203) -> None:
204 """Efficient quality recognizes aac-mp4 container variant."""
205 mp3_320 = _make_download_info("mp3", 320)
206 aac_mp4_64 = _make_download_info("aac-mp4", 64)
207
208 result = streaming_manager._select_best_quality([mp3_320, aac_mp4_64], QUALITY_EFFICIENT)
209
210 assert result is not None
211 assert result.codec == "aac-mp4"
212 assert result.bitrate_in_kbps == 64
213
214
215def test_select_best_quality_efficient_fallback_to_mp3(
216 streaming_manager: YandexMusicStreamingManager,
217) -> None:
218 """Efficient quality falls back to MP3 when no AAC available."""
219 mp3_128 = _make_download_info("mp3", 128)
220 flac = _make_download_info("flac", 0)
221
222 result = streaming_manager._select_best_quality([mp3_128, flac], QUALITY_EFFICIENT)
223
224 assert result is not None
225 assert result.codec == "mp3"
226
227
228def test_select_best_quality_efficient_fallback_to_lowest(
229 streaming_manager: YandexMusicStreamingManager,
230) -> None:
231 """Efficient quality falls back to lowest bitrate when no AAC/MP3."""
232 flac = _make_download_info("flac", 1411)
233
234 result = streaming_manager._select_best_quality([flac], QUALITY_EFFICIENT)
235
236 assert result is not None
237 assert result.codec == "flac"
238
239
240# --- High quality tests ---
241
242
243def test_select_best_quality_high_prefers_mp3_320(
244 streaming_manager: YandexMusicStreamingManager,
245) -> None:
246 """High quality prefers MP3 with bitrate >= 256kbps."""
247 mp3_320 = _make_download_info("mp3", 320)
248 mp3_128 = _make_download_info("mp3", 128)
249 aac_192 = _make_download_info("aac", 192)
250 flac = _make_download_info("flac", 1411)
251
252 result = streaming_manager._select_best_quality([mp3_320, mp3_128, aac_192, flac], QUALITY_HIGH)
253
254 assert result is not None
255 assert result.codec == "mp3"
256 assert result.bitrate_in_kbps == 320
257
258
259def test_select_best_quality_high_fallback_to_any_mp3(
260 streaming_manager: YandexMusicStreamingManager,
261) -> None:
262 """High quality falls back to any MP3 when no high-bitrate MP3 available."""
263 mp3_128 = _make_download_info("mp3", 128)
264 aac_192 = _make_download_info("aac", 192)
265
266 result = streaming_manager._select_best_quality([mp3_128, aac_192], QUALITY_HIGH)
267
268 assert result is not None
269 assert result.codec == "mp3"
270 assert result.bitrate_in_kbps == 128
271
272
273def test_select_best_quality_high_no_mp3_uses_non_flac(
274 streaming_manager: YandexMusicStreamingManager,
275) -> None:
276 """High quality uses highest non-FLAC when no MP3 available."""
277 aac_192 = _make_download_info("aac", 192)
278 flac = _make_download_info("flac", 1411)
279
280 result = streaming_manager._select_best_quality([aac_192, flac], QUALITY_HIGH)
281
282 assert result is not None
283 assert result.codec == "aac"
284 assert result.bitrate_in_kbps == 192
285
286
287def test_select_best_quality_high_only_flac_returns_flac(
288 streaming_manager: YandexMusicStreamingManager,
289) -> None:
290 """High quality returns FLAC as last resort when nothing else available."""
291 flac = _make_download_info("flac", 1411)
292
293 result = streaming_manager._select_best_quality([flac], QUALITY_HIGH)
294
295 assert result is not None
296 assert result.codec == "flac"
297
298
299# --- _build_audio_format tests ---
300
301
302def test_build_audio_format_passes_api_params(
303 streaming_manager: YandexMusicStreamingManager,
304) -> None:
305 """_build_audio_format forwards API-provided params to AudioFormat."""
306 fmt = streaming_manager._build_audio_format(
307 "flac-mp4",
308 bit_rate=0,
309 sample_rate=48000,
310 bit_depth=24,
311 )
312 assert fmt.content_type == ContentType.FLAC
313 assert fmt.sample_rate == 48000
314 assert fmt.bit_depth == 24
315
316
317def test_build_audio_format_keeps_defaults_when_zero(
318 streaming_manager: YandexMusicStreamingManager,
319) -> None:
320 """Without explicit params, AudioFormat keeps its defaults (44100/16)."""
321 fmt = streaming_manager._build_audio_format("mp3")
322 assert fmt.content_type == ContentType.MP3
323 assert fmt.sample_rate == 44100
324 assert fmt.bit_depth == 16
325
326
327# --- Container probe parser tests ---
328
329
330def test_parse_flac_streaminfo_valid(
331 streaming_manager: YandexMusicStreamingManager,
332) -> None:
333 """Parse real FLAC STREAMINFO: 48kHz, 24-bit."""
334 # Build a minimal FLAC header: magic + block header + 34-byte STREAMINFO
335 # STREAMINFO bytes 10-13: sample_rate(20) | channels(3) | bps(5) | total(36 high bits)
336 # 48000 Hz = 0xBB80, 24-bit = 23 (stored as bps-1), stereo = 1 (channels-1)
337 # bits: 00001011101110000000 001 10111 0000...
338 # = 0x0BB80 << 12 | 0x1 << 9 | 23 << 4 | 0x0 = 0x0BB80BE0 ... but let's compute:
339 sr = 48000
340 channels_minus1 = 1 # stereo
341 bps_minus1 = 23 # 24-bit
342 val = (sr << 12) | (channels_minus1 << 9) | (bps_minus1 << 4)
343 # Build 34-byte STREAMINFO payload
344 payload = bytearray(34)
345 payload[10:14] = val.to_bytes(4, "big")
346 # Full header: "fLaC" + block header (type=0, length=34) + payload
347 block_header = b"\x80" + (34).to_bytes(3, "big") # last-metadata-block flag + length
348 header = b"fLaC" + block_header + bytes(payload)
349
350 result = streaming_manager._parse_flac_streaminfo(header)
351 assert result == (48000, 24)
352
353
354def test_parse_flac_streaminfo_invalid(
355 streaming_manager: YandexMusicStreamingManager,
356) -> None:
357 """Non-FLAC data returns (0, 0)."""
358 assert streaming_manager._parse_flac_streaminfo(b"not flac data") == (0, 0)
359 assert streaming_manager._parse_flac_streaminfo(b"") == (0, 0)
360
361
362def test_parse_mp4_dfla_box(
363 streaming_manager: YandexMusicStreamingManager,
364) -> None:
365 """Parse dfLa box (FLAC-in-MP4) with STREAMINFO inside."""
366 sr = 48000
367 bps_minus1 = 23
368 val = (sr << 12) | (1 << 9) | (bps_minus1 << 4)
369 streaminfo = bytearray(34)
370 streaminfo[10:14] = val.to_bytes(4, "big")
371 # dfLa box: size(4) + "dfLa" + version/flags(4) + block_header(4) + STREAMINFO(34)
372 block_header = b"\x80\x00\x00\x22" # type=0 (last), length=34
373 box_size = (4 + 4 + 4 + 4 + 34).to_bytes(4, "big")
374 dfla_box = box_size + b"dfLa" + b"\x00\x00\x00\x00" + block_header + bytes(streaminfo)
375 # Wrap in some padding to simulate real MP4 structure
376 header = b"\x00" * 100 + dfla_box + b"\x00" * 100
377
378 result = streaming_manager._parse_mp4_audio_params(header)
379 assert result == (48000, 24)
380
381
382# --- get_audio_stream tests ---
383
384
385def _make_encrypted_stream_details(
386 key_hex: str,
387 url: str = "https://example.com/encrypted.flac",
388) -> StreamDetails:
389 """Build StreamDetails for encrypted FLAC stream tests."""
390 return StreamDetails(
391 item_id="test_track_123",
392 provider="yandex_music_instance",
393 audio_format=AudioFormat(content_type=ContentType.FLAC),
394 stream_type=StreamType.CUSTOM,
395 data={
396 "url": url,
397 "decryption_key": key_hex,
398 "codec": "flac-mp4",
399 "transport": "encraw",
400 "fi_quality": "lossless",
401 "fi_codecs": "flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4",
402 },
403 )
404
405
406class _MockContent:
407 """Async iterable content for mock HTTP responses."""
408
409 def __init__(
410 self,
411 chunks: list[bytes],
412 *,
413 drop_payload_error: bool = False,
414 drop_error: Exception | None = None,
415 ) -> None:
416 self._chunks = chunks
417 self._drop_error: Exception | None = (
418 ClientPayloadError("connection reset by peer") if drop_payload_error else drop_error
419 )
420
421 async def iter_chunked(self, size: int) -> Any:
422 for chunk in self._chunks:
423 yield chunk
424 if self._drop_error is not None:
425 raise self._drop_error
426
427
428class _MockResponse:
429 """Fake aiohttp ClientResponse for streaming tests."""
430
431 def __init__(
432 self,
433 chunks: list[bytes],
434 *,
435 status: int = 200,
436 error: Exception | None = None,
437 drop_payload_error: bool = False,
438 drop_error: Exception | None = None,
439 headers: dict[str, str] | None = None,
440 ) -> None:
441 self.content = _MockContent(
442 chunks,
443 drop_payload_error=drop_payload_error,
444 drop_error=drop_error,
445 )
446 self.status = status
447 self._error = error
448 self.headers: dict[str, str] = headers or {}
449
450 def raise_for_status(self) -> None:
451 """Raise stored error if set, simulating a non-2xx HTTP response."""
452 if self._error is not None:
453 raise self._error
454
455 async def __aenter__(self) -> Self:
456 return self
457
458 async def __aexit__(self, *args: object) -> None:
459 pass
460
461
462class _MockHttpSession:
463 """Fake aiohttp ClientSession for streaming tests."""
464
465 def __init__(self, response: _MockResponse) -> None:
466 self._response = response
467
468 def get(self, url: str, **kwargs: object) -> _MockResponse:
469 return self._response
470
471
472class _MultiCallHttpSession:
473 """Fake aiohttp ClientSession returning successive responses and recording calls."""
474
475 def __init__(self, responses: list[_MockResponse]) -> None:
476 self._responses = responses
477 self.calls: list[dict[str, Any]] = []
478
479 def get(self, url: str, **kwargs: object) -> _MockResponse:
480 self.calls.append({"url": url, "headers": kwargs.get("headers", {})})
481 return self._responses[len(self.calls) - 1]
482
483
484async def test_get_audio_stream_invalid_key_length(
485 streaming_manager: YandexMusicStreamingManager,
486) -> None:
487 """Invalid AES key length raises MediaNotFoundError before any HTTP request."""
488 sd = _make_encrypted_stream_details("deadbeef") # 4 bytes â invalid
489
490 with pytest.raises(MediaNotFoundError, match="Unsupported AES key length"):
491 async for _ in streaming_manager.get_audio_stream(sd):
492 pass
493
494
495async def test_get_audio_stream_http_error_raises_media_not_found(
496 streaming_manager: YandexMusicStreamingManager,
497 streaming_provider_stub: StreamingProviderStub,
498) -> None:
499 """HTTP error from encrypted URL is converted to MediaNotFoundError."""
500 key = b"\x00" * 32
501 sd = _make_encrypted_stream_details(key.hex())
502 streaming_provider_stub.mass.http_session = _MockHttpSession(
503 _MockResponse([], error=RuntimeError("403 Forbidden"))
504 )
505
506 with pytest.raises(MediaNotFoundError, match="Failed to fetch stream"):
507 async for _ in streaming_manager.get_audio_stream(sd):
508 pass
509
510
511async def test_get_audio_stream_http_error_does_not_leak_signed_url(
512 streaming_manager: YandexMusicStreamingManager,
513 streaming_provider_stub: StreamingProviderStub,
514) -> None:
515 """
516 Re-raised stream error must not include the signed CDN URL.
517
518 ``aiohttp``'s ``ClientResponseError.__str__`` embeds the request URL, which
519 for Yandex audio responses carries an expiring signature in the query
520 string. The ``MediaNotFoundError`` we re-raise must not propagate that
521 payload to logs or the frontend.
522 """
523 key = b"\x00" * 32
524 sd = _make_encrypted_stream_details(
525 key.hex(),
526 url="https://cdn.example.com/stream.flac?sign=SECRET_SIGNATURE_TOKEN&ts=999",
527 )
528 error_with_url = RuntimeError(
529 "500 Server Error for url 'https://cdn.example.com/stream.flac"
530 "?sign=SECRET_SIGNATURE_TOKEN&ts=999'"
531 )
532 streaming_provider_stub.mass.http_session = _MockHttpSession(
533 _MockResponse([], status=500, error=error_with_url)
534 )
535
536 with pytest.raises(MediaNotFoundError) as exc_info:
537 async for _ in streaming_manager.get_audio_stream(sd):
538 pass
539
540 message = str(exc_info.value)
541 assert "SECRET_SIGNATURE_TOKEN" not in message
542 assert "?sign=" not in message
543 assert "HTTP 500" in message
544
545
546async def test_get_audio_stream_decrypts_aes_ctr_correctly(
547 streaming_manager: YandexMusicStreamingManager,
548 streaming_provider_stub: StreamingProviderStub,
549) -> None:
550 """Encrypted stream is decrypted correctly with AES-256-CTR and zero IV."""
551 key = b"\x42" * 32
552 plaintext = b"Hello, Yandex Music FLAC data!\n" * 50
553
554 # Encrypt with the same algorithm used in get_audio_stream
555 nonce_16 = bytes(16)
556 encryptor = Cipher(algorithms.AES(key), modes.CTR(nonce_16)).encryptor()
557 ciphertext = encryptor.update(plaintext) + encryptor.finalize()
558
559 sd = _make_encrypted_stream_details(key.hex())
560 streaming_provider_stub.mass.http_session = _MockHttpSession(_MockResponse([ciphertext]))
561
562 result = b""
563 async for chunk in streaming_manager.get_audio_stream(sd):
564 result += chunk
565
566 assert result == plaintext
567
568
569async def test_get_audio_stream_reconnects_with_range_header(
570 streaming_manager: YandexMusicStreamingManager,
571 streaming_provider_stub: StreamingProviderStub,
572) -> None:
573 """On ClientPayloadError, reconnects with correct Range header and full plaintext restored."""
574 key = b"\x11" * 32
575 # 96 bytes = 6 AES-CTR blocks; split at byte 48 (block boundary)
576 plaintext = b"AAAAAAAAAAAAAAAA" * 3 + b"BBBBBBBBBBBBBBBB" * 3
577
578 nonce_16 = bytes(16)
579 encryptor = Cipher(algorithms.AES(key), modes.CTR(nonce_16)).encryptor()
580 ciphertext = encryptor.update(plaintext) + encryptor.finalize()
581
582 drop_at = 48 # exactly 3 blocks â clean block boundary
583
584 # First request drops after 48 bytes; second serves the remainder with 206 Partial Content
585 first_resp = _MockResponse([ciphertext[:drop_at]], drop_payload_error=True)
586 second_resp = _MockResponse([ciphertext[drop_at:]], status=206)
587 session = _MultiCallHttpSession([first_resp, second_resp])
588 streaming_provider_stub.mass.http_session = session
589
590 result = b""
591 with unittest.mock.patch("asyncio.sleep"):
592 async for chunk in streaming_manager.get_audio_stream(
593 _make_encrypted_stream_details(key.hex())
594 ):
595 result += chunk
596
597 assert result == plaintext
598 assert len(session.calls) == 2
599 assert session.calls[0]["headers"] == {"Range": "bytes=0-4194303"}
600 assert session.calls[1]["headers"] == {"Range": f"bytes={drop_at}-{drop_at + 4194304 - 1}"}
601
602
603async def test_get_audio_stream_refreshes_url_on_410(
604 streaming_manager: YandexMusicStreamingManager,
605 streaming_provider_stub: StreamingProviderStub,
606) -> None:
607 """On HTTP 410 (URL expired), a fresh URL is fetched and streaming resumes."""
608 key = b"\x33" * 32
609 plaintext = b"LOSSLESS" * 32
610
611 nonce_16 = bytes(16)
612 encryptor = Cipher(algorithms.AES(key), modes.CTR(nonce_16)).encryptor()
613 ciphertext = encryptor.update(plaintext) + encryptor.finalize()
614
615 fresh_url = "https://cdn.yandex.net/fresh.flac"
616 expired_resp = _MockResponse([], status=410)
617 fresh_resp = _MockResponse([ciphertext])
618
619 call_count = 0
620
621 def _get(_url: str, **_kwargs: object) -> _MockResponse:
622 nonlocal call_count
623 call_count += 1
624 return expired_resp if call_count == 1 else fresh_resp
625
626 streaming_provider_stub.mass.http_session = unittest.mock.MagicMock()
627 streaming_provider_stub.mass.http_session.get = _get
628
629 # Mock get_track_file_info to return a fresh URL
630 streaming_provider_stub.client = unittest.mock.AsyncMock()
631 streaming_provider_stub.client.get_track_file_info = unittest.mock.AsyncMock(
632 return_value={"url": fresh_url, "codec": "flac-mp4", "key": key.hex()}
633 )
634 streaming_manager.client = streaming_provider_stub.client
635
636 result = b""
637 with unittest.mock.patch("asyncio.sleep"):
638 async for chunk in streaming_manager.get_audio_stream(
639 _make_encrypted_stream_details(key.hex())
640 ):
641 result += chunk
642
643 assert result == plaintext
644 streaming_provider_stub.client.get_track_file_info.assert_called_once_with(
645 "test_track_123",
646 quality="lossless",
647 codecs="flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4",
648 transport="encraw",
649 )
650
651
652async def test_get_audio_stream_raises_after_all_retries_on_410(
653 streaming_manager: YandexMusicStreamingManager,
654 streaming_provider_stub: StreamingProviderStub,
655) -> None:
656 """MediaNotFoundError is raised after all retries exhausted on persistent 410."""
657 key = b"\x44" * 32
658 sd = _make_encrypted_stream_details(key.hex())
659 streaming_provider_stub.mass.http_session = _MockHttpSession(_MockResponse([], status=410))
660 streaming_provider_stub.client = unittest.mock.AsyncMock()
661 streaming_provider_stub.client.get_track_file_info = unittest.mock.AsyncMock(
662 return_value={"url": "https://cdn.example.com/still-expired.flac", "key": key.hex()}
663 )
664 streaming_manager.client = streaming_provider_stub.client
665
666 with (
667 pytest.raises(MediaNotFoundError, match="retries exhausted"),
668 unittest.mock.patch("asyncio.sleep"),
669 ):
670 async for _ in streaming_manager.get_audio_stream(sd):
671 pass
672
673
674async def test_get_audio_stream_retries_on_server_disconnected(
675 streaming_manager: YandexMusicStreamingManager,
676 streaming_provider_stub: StreamingProviderStub,
677) -> None:
678 """On ServerDisconnectedError, reconnects with Range header and full plaintext is restored."""
679 key = b"\x55" * 32
680 plaintext = b"CCCCCCCCCCCCCCCC" * 3 + b"DDDDDDDDDDDDDDDD" * 3 # 96 bytes
681
682 nonce_16 = bytes(16)
683 encryptor = Cipher(algorithms.AES(key), modes.CTR(nonce_16)).encryptor()
684 ciphertext = encryptor.update(plaintext) + encryptor.finalize()
685
686 drop_at = 48
687 first_resp = _MockResponse(
688 [ciphertext[:drop_at]], drop_error=ServerDisconnectedError("server closed")
689 )
690 second_resp = _MockResponse([ciphertext[drop_at:]], status=206)
691 session = _MultiCallHttpSession([first_resp, second_resp])
692 streaming_provider_stub.mass.http_session = session
693
694 result = b""
695 with unittest.mock.patch("asyncio.sleep"):
696 async for chunk in streaming_manager.get_audio_stream(
697 _make_encrypted_stream_details(key.hex())
698 ):
699 result += chunk
700
701 assert result == plaintext
702 assert len(session.calls) == 2
703 assert session.calls[1]["headers"] == {"Range": f"bytes={drop_at}-{drop_at + 4194304 - 1}"}
704
705
706async def test_get_audio_stream_retries_on_read_timeout(
707 streaming_manager: YandexMusicStreamingManager,
708 streaming_provider_stub: StreamingProviderStub,
709) -> None:
710 """On asyncio.TimeoutError (read stall), reconnects with Range header and stream completes."""
711 key = b"\x66" * 32
712 plaintext = b"EEEEEEEEEEEEEEEE" * 3 + b"FFFFFFFFFFFFFFFF" * 3 # 96 bytes
713
714 nonce_16 = bytes(16)
715 encryptor = Cipher(algorithms.AES(key), modes.CTR(nonce_16)).encryptor()
716 ciphertext = encryptor.update(plaintext) + encryptor.finalize()
717
718 drop_at = 48
719 first_resp = _MockResponse([ciphertext[:drop_at]], drop_error=TimeoutError("read timeout"))
720 second_resp = _MockResponse([ciphertext[drop_at:]], status=206)
721 session = _MultiCallHttpSession([first_resp, second_resp])
722 streaming_provider_stub.mass.http_session = session
723
724 result = b""
725 with unittest.mock.patch("asyncio.sleep"):
726 async for chunk in streaming_manager.get_audio_stream(
727 _make_encrypted_stream_details(key.hex())
728 ):
729 result += chunk
730
731 assert result == plaintext
732 assert len(session.calls) == 2
733 assert session.calls[1]["headers"] == {"Range": f"bytes={drop_at}-{drop_at + 4194304 - 1}"}
734
735
736async def test_get_audio_stream_resets_decrypt_when_range_ignored(
737 streaming_manager: YandexMusicStreamingManager,
738 streaming_provider_stub: StreamingProviderStub,
739) -> None:
740 """If server returns 200 instead of 206 after Range request, decryptor resets to position 0."""
741 key = b"\x77" * 32
742 plaintext = b"GGGGGGGGGGGGGGGG" * 6 # 96 bytes
743
744 nonce_16 = bytes(16)
745 encryptor = Cipher(algorithms.AES(key), modes.CTR(nonce_16)).encryptor()
746 ciphertext = encryptor.update(plaintext) + encryptor.finalize()
747
748 drop_at = 48
749 # First response drops after 48 bytes; second ignores Range and sends full ciphertext with 200
750 first_resp = _MockResponse([ciphertext[:drop_at]], drop_payload_error=True)
751 second_resp = _MockResponse([ciphertext], status=200) # server ignored Range
752 session = _MultiCallHttpSession([first_resp, second_resp])
753 streaming_provider_stub.mass.http_session = session
754
755 result = b""
756 with unittest.mock.patch("asyncio.sleep"):
757 async for chunk in streaming_manager.get_audio_stream(
758 _make_encrypted_stream_details(key.hex())
759 ):
760 result += chunk
761
762 # Decryptor was reset to 0 on the second request, so full plaintext is recovered
763 assert result == plaintext
764
765
766async def test_get_audio_stream_fails_immediately_when_url_refresh_returns_nothing(
767 streaming_manager: YandexMusicStreamingManager,
768 streaming_provider_stub: StreamingProviderStub,
769) -> None:
770 """If get_track_file_info returns no URL, stream fails without wasting retries."""
771 key = b"\x88" * 32
772 sd = _make_encrypted_stream_details(key.hex())
773 streaming_provider_stub.mass.http_session = _MockHttpSession(_MockResponse([], status=410))
774 streaming_provider_stub.client = unittest.mock.AsyncMock()
775 # Simulate API returning no usable URL (None result)
776 streaming_provider_stub.client.get_track_file_info = unittest.mock.AsyncMock(return_value=None)
777 streaming_manager.client = streaming_provider_stub.client
778
779 with (
780 pytest.raises(MediaNotFoundError, match="retries exhausted"),
781 unittest.mock.patch("asyncio.sleep"),
782 ):
783 async for _ in streaming_manager.get_audio_stream(sd):
784 pass
785
786 # Should have given up after attempt 0 (refresh returned None â no stale URL reuse)
787 assert streaming_provider_stub.client.get_track_file_info.call_count == 1
788
789
790async def test_get_audio_stream_exact_window_boundary(
791 streaming_manager: YandexMusicStreamingManager,
792 streaming_provider_stub: StreamingProviderStub,
793) -> None:
794 """
795 File whose size is an exact multiple of _RANGE_WINDOW does not trigger a 416 error.
796
797 Without the Content-Range EOF guard the loop would request the next window after
798 receiving exactly _RANGE_WINDOW bytes in a 206 response, which would result in a
799 416 Range Not Satisfiable error raised as MediaNotFoundError. With the fix the
800 loop detects EOF via the Content-Range header and returns cleanly.
801 """
802 # Use a tiny window (16 bytes = one AES block) so the test stays fast.
803 small_window = 16
804 key = b"\xab" * 32
805 plaintext = b"x" * small_window # file size == window size (exact boundary)
806
807 nonce_16 = bytes(16)
808 encryptor = Cipher(algorithms.AES(key), modes.CTR(nonce_16)).encryptor()
809 ciphertext = encryptor.update(plaintext) + encryptor.finalize()
810
811 # Content-Range: bytes 0-15/16 â signals that byte 15 is the last one
812 first_resp = _MockResponse(
813 [ciphertext],
814 status=206,
815 headers={"Content-Range": f"bytes 0-{small_window - 1}/{small_window}"},
816 )
817 # Second response must never be reached; if it is, the test will fail.
818 second_resp = _MockResponse([], status=416, error=RuntimeError("should not be requested"))
819 session = _MultiCallHttpSession([first_resp, second_resp])
820 streaming_provider_stub.mass.http_session = session
821
822 result = b""
823 with unittest.mock.patch.object(_streaming_mod, "_RANGE_WINDOW", small_window):
824 async for chunk in streaming_manager.get_audio_stream(
825 _make_encrypted_stream_details(key.hex())
826 ):
827 result += chunk
828
829 assert result == plaintext
830 assert len(session.calls) == 1, "second window must not be requested when EOF is detected"
831
832
833async def test_get_audio_stream_continues_after_non_block_boundary_drop(
834 streaming_manager: YandexMusicStreamingManager,
835 streaming_provider_stub: StreamingProviderStub,
836) -> None:
837 """
838 TCP drop at a non-AES-block boundary must not cause premature EOF on reconnect.
839
840 Scenario (patched window = 32 bytes = 2 AES blocks, 50-byte file):
841 - Window 1 (bytes=0-31) drops at byte 17 (not on a 16-byte AES boundary).
842 - Reconnect re-requests from block_start=16; server returns full 32 bytes.
843 - Old bug: window_got = 31 < _RANGE_WINDOW = 32 â stream terminates at byte 48,
844 losing the final 2 bytes of the file.
845 - Fixed: received = window_got + block_skip = 31 + 1 = 32 = _RANGE_WINDOW
846 â stream continues to window 2, which delivers the remaining 2 bytes.
847 """
848 small_window = 32 # 2 AES blocks
849 key = b"\xcc" * 32
850 plaintext = b"X" * 50 # 50 bytes â two windows (32 + 2 remaining)
851
852 nonce_16 = bytes(16)
853 encryptor = Cipher(algorithms.AES(key), modes.CTR(nonce_16)).encryptor()
854 ciphertext = encryptor.update(plaintext) + encryptor.finalize()
855
856 drop_at = 17 # non-block boundary (17 % 16 != 0)
857
858 # Window 1: bytes=0-31, drops after delivering 17 bytes
859 resp1 = _MockResponse([ciphertext[:drop_at]], drop_payload_error=True)
860 # Reconnect: block_start=16, requests bytes=16-47, server returns full 32 bytes
861 resp2 = _MockResponse([ciphertext[16:48]], status=206)
862 # Window 2: bytes=48-79, only 2 bytes remain in the file
863 resp3 = _MockResponse([ciphertext[48:50]], status=206)
864
865 session = _MultiCallHttpSession([resp1, resp2, resp3])
866 streaming_provider_stub.mass.http_session = session
867
868 result = b""
869 with (
870 unittest.mock.patch.object(_streaming_mod, "_RANGE_WINDOW", small_window),
871 unittest.mock.patch("asyncio.sleep"),
872 ):
873 async for chunk in streaming_manager.get_audio_stream(
874 _make_encrypted_stream_details(key.hex())
875 ):
876 result += chunk
877
878 assert result == plaintext, f"Expected {len(plaintext)} bytes, got {len(result)}"
879 assert len(session.calls) == 3
880 assert session.calls[0]["headers"] == {"Range": "bytes=0-31"}
881 assert session.calls[1]["headers"] == {"Range": "bytes=16-47"} # AES-aligned reconnect
882 assert session.calls[2]["headers"] == {"Range": "bytes=48-79"} # second window
883
884
885# --- Raw (unencrypted) windowed streaming tests ---
886
887
888def _make_raw_stream_details(
889 url: str = "https://cdn.example.com/track.flac",
890 codec: str = "flac-mp4",
891 bit_rate: int = 0,
892) -> StreamDetails:
893 """Build StreamDetails for raw (unencrypted) windowed stream tests."""
894 return StreamDetails(
895 item_id="test_track_123",
896 provider="yandex_music_instance",
897 audio_format=AudioFormat(content_type=ContentType.FLAC),
898 stream_type=StreamType.CUSTOM,
899 data={
900 "url": url,
901 "codec": codec,
902 "transport": "raw",
903 "bit_rate": bit_rate,
904 "fi_quality": "lossless",
905 "fi_codecs": "flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4",
906 },
907 )
908
909
910async def test_get_audio_stream_raw_single_window(
911 streaming_manager: YandexMusicStreamingManager,
912 streaming_provider_stub: StreamingProviderStub,
913) -> None:
914 """Raw stream smaller than _RANGE_WINDOW is fetched in one request."""
915 plaintext = b"Hello raw FLAC data!" * 50 # 1000 bytes
916 sd = _make_raw_stream_details()
917 streaming_provider_stub.mass.http_session = _MockHttpSession(_MockResponse([plaintext]))
918
919 result = b""
920 async for chunk in streaming_manager.get_audio_stream(sd):
921 result += chunk
922
923 assert result == plaintext
924
925
926async def test_get_audio_stream_raw_multi_window(
927 streaming_manager: YandexMusicStreamingManager,
928 streaming_provider_stub: StreamingProviderStub,
929) -> None:
930 """Raw stream larger than _RANGE_WINDOW uses multiple windowed requests."""
931 small_window = 32
932 plaintext = b"A" * 50 # 50 bytes â two windows (32 + 18)
933
934 resp1 = _MockResponse([plaintext[:small_window]], status=206)
935 resp2 = _MockResponse([plaintext[small_window:]], status=206)
936 session = _MultiCallHttpSession([resp1, resp2])
937 streaming_provider_stub.mass.http_session = session
938
939 result = b""
940 with unittest.mock.patch.object(_streaming_mod, "_RANGE_WINDOW", small_window):
941 async for chunk in streaming_manager.get_audio_stream(_make_raw_stream_details()):
942 result += chunk
943
944 assert result == plaintext
945 assert len(session.calls) == 2
946 assert session.calls[0]["headers"] == {"Range": "bytes=0-31"}
947 assert session.calls[1]["headers"] == {"Range": "bytes=32-63"}
948
949
950async def test_get_audio_stream_raw_retry_on_drop(
951 streaming_manager: YandexMusicStreamingManager,
952 streaming_provider_stub: StreamingProviderStub,
953) -> None:
954 """Raw stream reconnects with correct Range header after TCP drop."""
955 plaintext = b"B" * 96
956 drop_at = 48
957
958 first_resp = _MockResponse([plaintext[:drop_at]], drop_payload_error=True)
959 second_resp = _MockResponse([plaintext[drop_at:]], status=206)
960 session = _MultiCallHttpSession([first_resp, second_resp])
961 streaming_provider_stub.mass.http_session = session
962
963 result = b""
964 with unittest.mock.patch("asyncio.sleep"):
965 async for chunk in streaming_manager.get_audio_stream(_make_raw_stream_details()):
966 result += chunk
967
968 assert result == plaintext
969 assert len(session.calls) == 2
970 # Raw uses exact byte offset (no AES block alignment)
971 assert session.calls[1]["headers"] == {"Range": f"bytes={drop_at}-{drop_at + 4194304 - 1}"}
972
973
974async def test_get_audio_stream_raw_url_refresh_on_403(
975 streaming_manager: YandexMusicStreamingManager,
976 streaming_provider_stub: StreamingProviderStub,
977) -> None:
978 """Raw stream refreshes URL on 403 and continues."""
979 plaintext = b"C" * 64
980 fresh_url = "https://cdn.example.com/refreshed-track.flac"
981
982 expired_resp = _MockResponse([], status=403)
983 fresh_resp = _MockResponse([plaintext])
984
985 call_count = 0
986
987 def _get(_url: str, **_kwargs: object) -> _MockResponse:
988 nonlocal call_count
989 call_count += 1
990 return expired_resp if call_count == 1 else fresh_resp
991
992 streaming_provider_stub.mass.http_session = unittest.mock.MagicMock()
993 streaming_provider_stub.mass.http_session.get = _get
994
995 streaming_provider_stub.client = unittest.mock.AsyncMock()
996 streaming_provider_stub.client.get_track_file_info = unittest.mock.AsyncMock(
997 return_value={"url": fresh_url, "codec": "flac-mp4"}
998 )
999 streaming_manager.client = streaming_provider_stub.client
1000
1001 result = b""
1002 with unittest.mock.patch("asyncio.sleep"):
1003 async for chunk in streaming_manager.get_audio_stream(_make_raw_stream_details()):
1004 result += chunk
1005
1006 assert result == plaintext
1007 streaming_provider_stub.client.get_track_file_info.assert_called_once_with(
1008 "test_track_123",
1009 quality="lossless",
1010 codecs="flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4",
1011 transport="raw",
1012 )
1013
1014
1015async def test_get_audio_stream_raw_resets_on_range_ignored(
1016 streaming_manager: YandexMusicStreamingManager,
1017 streaming_provider_stub: StreamingProviderStub,
1018) -> None:
1019 """If server returns 200 instead of 206 after raw reconnect, skip already-delivered bytes."""
1020 small_window = 32
1021 plaintext = b"G" * 96 # 96 bytes
1022
1023 drop_at = 48
1024 # First response drops after 48 bytes
1025 first_resp = _MockResponse([plaintext[:drop_at]], drop_payload_error=True)
1026 # Second response ignores Range and returns full file with 200
1027 second_resp = _MockResponse([plaintext], status=200)
1028 session = _MultiCallHttpSession([first_resp, second_resp])
1029 streaming_provider_stub.mass.http_session = session
1030
1031 result = b""
1032 with (
1033 unittest.mock.patch.object(_streaming_mod, "_RANGE_WINDOW", small_window),
1034 unittest.mock.patch("asyncio.sleep"),
1035 ):
1036 async for chunk in streaming_manager.get_audio_stream(_make_raw_stream_details()):
1037 result += chunk
1038
1039 # Should get the full plaintext without duplication
1040 assert result == plaintext
1041
1042
1043async def test_get_audio_stream_raw_seek_starts_from_byte_offset(
1044 streaming_manager: YandexMusicStreamingManager,
1045 streaming_provider_stub: StreamingProviderStub,
1046) -> None:
1047 """Raw stream with seek_position starts Range requests from calculated byte offset."""
1048 # 320 kbps = 40000 bytes/sec; seek to 10s â offset 400000
1049 bit_rate = 320
1050 seek_seconds = 10
1051 expected_offset = int(seek_seconds * bit_rate * 1000 / 8) # 400000
1052
1053 plaintext = b"S" * 64
1054 resp = _MockResponse([plaintext], status=206)
1055 session = _MultiCallHttpSession([resp])
1056 streaming_provider_stub.mass.http_session = session
1057
1058 sd = _make_raw_stream_details(bit_rate=bit_rate)
1059 result = b""
1060 async for chunk in streaming_manager.get_audio_stream(sd, seek_position=seek_seconds):
1061 result += chunk
1062
1063 assert result == plaintext
1064 assert len(session.calls) == 1
1065 range_header = session.calls[0]["headers"]["Range"]
1066 assert range_header.startswith(f"bytes={expected_offset}-")
1067
1068
1069async def test_get_audio_stream_raw_seek_zero_bitrate_starts_from_zero(
1070 streaming_manager: YandexMusicStreamingManager,
1071 streaming_provider_stub: StreamingProviderStub,
1072) -> None:
1073 """When bit_rate is 0, seek_position is ignored and stream starts from byte 0."""
1074 plaintext = b"Z" * 64
1075 resp = _MockResponse([plaintext])
1076 session = _MultiCallHttpSession([resp])
1077 streaming_provider_stub.mass.http_session = session
1078
1079 sd = _make_raw_stream_details(bit_rate=0)
1080 result = b""
1081 async for chunk in streaming_manager.get_audio_stream(sd, seek_position=30):
1082 result += chunk
1083
1084 assert result == plaintext
1085 assert session.calls[0]["headers"]["Range"].startswith("bytes=0-")
1086
1087
1088async def test_get_audio_stream_encrypted_ignores_seek_position(
1089 streaming_manager: YandexMusicStreamingManager,
1090 streaming_provider_stub: StreamingProviderStub,
1091) -> None:
1092 """Encrypted stream always starts from byte 0 regardless of seek_position."""
1093 key = b"\x01" * 16
1094 key_hex = key.hex()
1095 plaintext = b"E" * 64
1096 cipher = Cipher(algorithms.AES(key), modes.CTR(b"\x00" * 16))
1097 encryptor = cipher.encryptor()
1098 ciphertext = encryptor.update(plaintext) + encryptor.finalize()
1099
1100 resp = _MockResponse([ciphertext])
1101 session = _MultiCallHttpSession([resp])
1102 streaming_provider_stub.mass.http_session = session
1103
1104 sd = _make_encrypted_stream_details(key_hex)
1105 result = b""
1106 async for chunk in streaming_manager.get_audio_stream(sd, seek_position=30):
1107 result += chunk
1108
1109 assert result == plaintext
1110 assert session.calls[0]["headers"]["Range"].startswith("bytes=0-")
1111
1112
1113# --- M16: get_stream_details â happy path + fallback + both-fail -----------
1114
1115
1116def _make_track_stub(track_id: str = "track_123", duration: int = 240) -> Any:
1117 """Build a minimal track-like object with the attributes get_stream_details reads."""
1118 return type(
1119 "Track",
1120 (),
1121 {"id": track_id, "track_id": track_id, "duration": duration},
1122 )()
1123
1124
1125def _attach_get_track(
1126 manager: YandexMusicStreamingManager,
1127 track_stub: Any,
1128) -> None:
1129 """Bind a ``get_track`` coroutine onto the provider stub."""
1130
1131 async def _get_track(_item_id: str) -> Any:
1132 return track_stub
1133
1134 manager.provider.get_track = _get_track # type: ignore[method-assign,assignment]
1135
1136
1137async def test_get_stream_details_happy_path_uses_get_file_info(
1138 streaming_manager: YandexMusicStreamingManager,
1139) -> None:
1140 """
1141 When ``get_track_file_info`` returns a URL, build StreamDetails directly.
1142
1143 The fast path skips the legacy ``download-info`` fallback entirely.
1144 """
1145 _attach_get_track(streaming_manager, _make_track_stub("999", duration=180))
1146 streaming_manager.client = unittest.mock.AsyncMock()
1147 streaming_manager.client.get_track_file_info = unittest.mock.AsyncMock(
1148 return_value={
1149 "url": "https://cdn.example.com/999.flac?sign=signed",
1150 "codec": "flac-mp4",
1151 "bitrate": 0,
1152 "needs_decryption": False,
1153 "sample_rate": 44100,
1154 "bit_depth": 16,
1155 }
1156 )
1157 # No download-info call expected on the happy path.
1158 streaming_manager.client.get_track_download_info = unittest.mock.AsyncMock(
1159 side_effect=AssertionError("download-info should not be called on happy path")
1160 )
1161
1162 sd = await streaming_manager.get_stream_details("999")
1163
1164 assert sd.item_id == "999"
1165 assert sd.stream_type == StreamType.CUSTOM
1166 assert sd.data["url"] == "https://cdn.example.com/999.flac?sign=signed"
1167 assert sd.duration == 180
1168
1169
1170async def test_get_stream_details_falls_back_to_download_info_when_file_info_empty(
1171 streaming_manager: YandexMusicStreamingManager,
1172) -> None:
1173 """
1174 When ``get_track_file_info`` returns ``None``, use the download-info path.
1175
1176 The fallback uses ``StreamType.HTTP`` with the direct CDN link from the
1177 legacy ``/tracks/{id}/download-info`` endpoint.
1178 """
1179 _attach_get_track(streaming_manager, _make_track_stub("888", duration=240))
1180 streaming_manager.client = unittest.mock.AsyncMock()
1181 streaming_manager.client.get_track_file_info = unittest.mock.AsyncMock(return_value=None)
1182 download_info = _make_download_info("mp3", 320, "https://cdn.example.com/888.mp3")
1183 streaming_manager.client.get_track_download_info = unittest.mock.AsyncMock(
1184 return_value=[download_info]
1185 )
1186
1187 sd = await streaming_manager.get_stream_details("888")
1188
1189 assert sd.stream_type == StreamType.HTTP
1190 assert sd.path == "https://cdn.example.com/888.mp3"
1191 assert sd.duration == 240
1192 streaming_manager.client.get_track_file_info.assert_awaited_once()
1193 streaming_manager.client.get_track_download_info.assert_awaited_once()
1194
1195
1196async def test_get_stream_details_raises_when_both_paths_fail(
1197 streaming_manager: YandexMusicStreamingManager,
1198) -> None:
1199 """When both endpoints come back empty, raise ``MediaNotFoundError``."""
1200 _attach_get_track(streaming_manager, _make_track_stub("777"))
1201 streaming_manager.client = unittest.mock.AsyncMock()
1202 streaming_manager.client.get_track_file_info = unittest.mock.AsyncMock(return_value=None)
1203 streaming_manager.client.get_track_download_info = unittest.mock.AsyncMock(return_value=[])
1204
1205 with pytest.raises(MediaNotFoundError):
1206 await streaming_manager.get_stream_details("777")
1207