music-assistant-server

45 KBPY
test_streaming.py
45 KB1,207 lines • python
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