From 231bf5686238839753eb1394d0bea5d5ab6dfc0b Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy Date: Wed, 15 Apr 2026 07:54:33 +0300 Subject: [PATCH 1/6] feat: add Yandex Music Connect (Ynison) plugin provider Makes Music Assistant players appear as devices in the Yandex Music app via the Ynison protocol (similar to Spotify Connect). - Ynison WebSocket client with two-step connection and reconnect - Audio streaming via linked yandex_music provider with FFmpeg PCM - Bidirectional playback control (play/pause/seek/next/prev) - QR-code authentication via ya-passport-auth - Radio queue management with proactive prefetch - Echo detection to prevent state sync feedback loops - 179 unit tests Depends on: yandex_music MusicProvider Requires: ya-passport-auth==1.2.3 Source: trudenboy/ma-provider-yandex-ynison v1.5.2 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../providers/yandex_ynison/__init__.py | 246 +++ .../providers/yandex_ynison/config_helpers.py | 31 + .../providers/yandex_ynison/constants.py | 55 + .../providers/yandex_ynison/icon.svg | 3 + .../providers/yandex_ynison/manifest.json | 12 + .../providers/yandex_ynison/protocols.py | 39 + .../providers/yandex_ynison/provider.py | 1353 ++++++++++++ .../providers/yandex_ynison/streaming.py | 49 + .../providers/yandex_ynison/yandex_auth.py | 73 + .../providers/yandex_ynison/ynison_client.py | 670 ++++++ requirements_all.txt | 1 + tests/providers/yandex_ynison/__init__.py | 1 + .../providers/yandex_ynison/test_provider.py | 1918 +++++++++++++++++ .../providers/yandex_ynison/test_streaming.py | 83 + .../yandex_ynison/test_yandex_auth.py | 254 +++ .../yandex_ynison/test_ynison_client.py | 1400 ++++++++++++ 16 files changed, 6188 insertions(+) create mode 100644 music_assistant/providers/yandex_ynison/__init__.py create mode 100644 music_assistant/providers/yandex_ynison/config_helpers.py create mode 100644 music_assistant/providers/yandex_ynison/constants.py create mode 100644 music_assistant/providers/yandex_ynison/icon.svg create mode 100644 music_assistant/providers/yandex_ynison/manifest.json create mode 100644 music_assistant/providers/yandex_ynison/protocols.py create mode 100644 music_assistant/providers/yandex_ynison/provider.py create mode 100644 music_assistant/providers/yandex_ynison/streaming.py create mode 100644 music_assistant/providers/yandex_ynison/yandex_auth.py create mode 100644 music_assistant/providers/yandex_ynison/ynison_client.py create mode 100644 tests/providers/yandex_ynison/__init__.py create mode 100644 tests/providers/yandex_ynison/test_provider.py create mode 100644 tests/providers/yandex_ynison/test_streaming.py create mode 100644 tests/providers/yandex_ynison/test_yandex_auth.py create mode 100644 tests/providers/yandex_ynison/test_ynison_client.py diff --git a/music_assistant/providers/yandex_ynison/__init__.py b/music_assistant/providers/yandex_ynison/__init__.py new file mode 100644 index 0000000000..aae6731f15 --- /dev/null +++ b/music_assistant/providers/yandex_ynison/__init__.py @@ -0,0 +1,246 @@ +"""Yandex Music Connect (Ynison) plugin for Music Assistant.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption +from music_assistant_models.enums import ConfigEntryType, ProviderFeature +from music_assistant_models.errors import LoginFailed + +from .config_helpers import find_sibling_token +from .constants import ( + CONF_ACTION_AUTH_QR, + CONF_ACTION_CLEAR_AUTH, + CONF_ALLOW_PLAYER_SWITCH, + CONF_DEVICE_ID, + CONF_MASS_PLAYER_ID, + CONF_OUTPUT_BIT_DEPTH, + CONF_OUTPUT_SAMPLE_RATE, + CONF_PUBLISH_NAME, + CONF_REMEMBER_SESSION, + CONF_TOKEN, + CONF_X_TOKEN, + DEFAULT_DISPLAY_NAME, + OUTPUT_AUTO, + PLAYER_ID_AUTO, +) +from .provider import YandexYnisonProvider +from .yandex_auth import perform_qr_auth + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ConfigValueType, ProviderConfig + from music_assistant_models.provider import ProviderManifest + + from music_assistant.mass import MusicAssistant + from music_assistant.models import ProviderInstanceType + +SUPPORTED_FEATURES = {ProviderFeature.AUDIO_SOURCE} + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + return YandexYnisonProvider(mass, manifest, config, SUPPORTED_FEATURES) + + +async def get_config_entries( + mass: MusicAssistant, + instance_id: str | None = None, + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, +) -> tuple[ConfigEntry, ...]: + """Return Config entries to setup this provider.""" + if values is None: + values = {} + + # Migrate legacy config keys (renamed in v1.5.0) + if "player" in values and CONF_MASS_PLAYER_ID not in values: + values[CONF_MASS_PLAYER_ID] = values.pop("player") + if "display_name" in values and CONF_PUBLISH_NAME not in values: + values[CONF_PUBLISH_NAME] = values.pop("display_name") + + # Pre-fill token from sibling instance if this instance has no token yet + sibling_token, sibling_x_token = find_sibling_token(mass, instance_id) + if not values.get(CONF_TOKEN) and sibling_token: + values[CONF_TOKEN] = sibling_token + if sibling_x_token: + values[CONF_X_TOKEN] = sibling_x_token + + # Handle QR auth action + if action == CONF_ACTION_AUTH_QR: + session_id = values.get("session_id") + if not session_id: + raise LoginFailed("Missing session_id for QR authentication") + x_token, music_token = await perform_qr_auth(mass, str(session_id)) + values[CONF_TOKEN] = music_token + if values.get(CONF_REMEMBER_SESSION, True): + values[CONF_X_TOKEN] = x_token + else: + values[CONF_X_TOKEN] = None + + # Handle clear auth action + if action == CONF_ACTION_CLEAR_AUTH: + values[CONF_TOKEN] = None + values[CONF_X_TOKEN] = None + + # Check if user is authenticated + is_authenticated = bool(values.get(CONF_TOKEN)) + token_from_sibling = is_authenticated and sibling_token == values.get(CONF_TOKEN) + + # Dynamic label text + if not is_authenticated: + label_text = ( + "Scan a QR code with the Yandex app on your phone to authenticate.\n\n" + "Alternatively, you can enter a music token manually in the advanced settings." + ) + elif action == CONF_ACTION_AUTH_QR: + label_text = "Authenticated to Yandex Music. Don't forget to save to complete setup." + elif token_from_sibling: + label_text = ( + "Authenticated to Yandex Music (token reused from existing instance).\n" + "Re-authenticate if you need a different account." + ) + else: + label_text = "Authenticated to Yandex Music." + + return ( + # Status label + ConfigEntry( + key="label_text", + type=ConfigEntryType.LABEL, + label=label_text, + ), + # QR authentication (primary) + ConfigEntry( + key=CONF_ACTION_AUTH_QR, + type=ConfigEntryType.ACTION, + label="Login with QR code", + description="Opens a QR code page — scan it with the Yandex app on your phone.", + action=CONF_ACTION_AUTH_QR, + action_label="Login with QR code", + hidden=is_authenticated, + ), + # Remember session toggle + ConfigEntry( + key=CONF_REMEMBER_SESSION, + type=ConfigEntryType.BOOLEAN, + label="Remember session (auto-refresh token)", + description="When enabled, stores a long-lived session token to automatically " + "refresh your music token when it expires. When disabled, you must " + "re-authenticate manually when the token expires.", + default_value=True, + hidden=is_authenticated, + ), + # Clear auth + ConfigEntry( + key=CONF_ACTION_CLEAR_AUTH, + type=ConfigEntryType.ACTION, + label="Reset authentication", + description="Clear the current authentication details.", + action=CONF_ACTION_CLEAR_AUTH, + action_label="Reset authentication", + hidden=not is_authenticated, + ), + # Token (populated by QR action or manual entry) + ConfigEntry( + key=CONF_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Yandex Music Token", + description="Music token — populated automatically by QR login, " + "or enter manually. See the documentation for how to obtain it.", + required=True, + hidden=is_authenticated, + value=cast("str", values.get(CONF_TOKEN)) if values else None, + ), + # x_token (internal, always hidden) + ConfigEntry( + key=CONF_X_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Session token", + hidden=True, + required=False, + value=cast("str", values.get(CONF_X_TOKEN)) if values else None, + ), + # Target MA player + ConfigEntry( + key=CONF_MASS_PLAYER_ID, + type=ConfigEntryType.STRING, + label="Connected Music Assistant Player", + description="The Music Assistant player connected to this Ynison plugin. " + "When playback is directed to this device in the Yandex Music app, " + "the audio will play on the selected player. " + "Set to 'Auto' to automatically select a currently playing player.", + default_value=PLAYER_ID_AUTO, + options=[ + ConfigValueOption("Auto (prefer playing player)", PLAYER_ID_AUTO), + *( + ConfigValueOption(x.display_name, x.player_id) + for x in sorted( + mass.players.all_players(False, False), + key=lambda p: p.display_name.lower(), + ) + ), + ], + required=True, + ), + # Allow manual player switching + ConfigEntry( + key=CONF_ALLOW_PLAYER_SWITCH, + type=ConfigEntryType.BOOLEAN, + label="Allow manual player switching", + description="When enabled, you can select this plugin as a source on any player " + "to switch playback to that player. When disabled, playback is fixed to the " + "configured default player.", + default_value=True, + ), + # Output sample rate + ConfigEntry( + key=CONF_OUTPUT_SAMPLE_RATE, + type=ConfigEntryType.STRING, + label="Output sample rate", + description="Sample rate for PCM output to the player. " + "'Auto' selects 44.1 kHz for lossy or 48 kHz for lossless sources.", + default_value=OUTPUT_AUTO, + options=[ + ConfigValueOption("Auto (from source quality)", OUTPUT_AUTO), + ConfigValueOption("44100 Hz (CD)", "44100"), + ConfigValueOption("48000 Hz", "48000"), + ConfigValueOption("96000 Hz (Hi-Res)", "96000"), + ], + advanced=True, + ), + # Output bit depth + ConfigEntry( + key=CONF_OUTPUT_BIT_DEPTH, + type=ConfigEntryType.STRING, + label="Output bit depth", + description="Bit depth for PCM output to the player. " + "'Auto' selects 16-bit for lossy or 24-bit for lossless sources.", + default_value=OUTPUT_AUTO, + options=[ + ConfigValueOption("Auto (from source quality)", OUTPUT_AUTO), + ConfigValueOption("16-bit", "16"), + ConfigValueOption("24-bit", "24"), + ], + advanced=True, + ), + # Device name in Yandex Music app + ConfigEntry( + key=CONF_PUBLISH_NAME, + type=ConfigEntryType.STRING, + label="Device name in Yandex Music", + description="How this device appears in the Yandex Music app.", + default_value=DEFAULT_DISPLAY_NAME, + advanced=True, + ), + # Device ID (internal, hidden) + ConfigEntry( + key=CONF_DEVICE_ID, + type=ConfigEntryType.STRING, + label="Device ID", + hidden=True, + required=False, + ), + ) diff --git a/music_assistant/providers/yandex_ynison/config_helpers.py b/music_assistant/providers/yandex_ynison/config_helpers.py new file mode 100644 index 0000000000..fec56a376d --- /dev/null +++ b/music_assistant/providers/yandex_ynison/config_helpers.py @@ -0,0 +1,31 @@ +"""Configuration helpers for the Yandex Ynison plugin.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .constants import CONF_TOKEN, CONF_X_TOKEN + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +def find_sibling_token( + mass: MusicAssistant, + instance_id: str | None, +) -> tuple[str | None, str | None]: + """Find token and x_token from an existing sibling ynison instance.""" + raw_providers = mass.config.get("providers", {}) + for prov_conf in raw_providers.values(): + if prov_conf.get("domain") != "yandex_ynison": + continue + if instance_id and prov_conf.get("instance_id") == instance_id: + continue + prov_values = prov_conf.get("values", {}) + token = prov_values.get(CONF_TOKEN) + if token: + x_token = prov_values.get(CONF_X_TOKEN) + return mass.config.decrypt_string(str(token)), ( + mass.config.decrypt_string(str(x_token)) if x_token else None + ) + return None, None diff --git a/music_assistant/providers/yandex_ynison/constants.py b/music_assistant/providers/yandex_ynison/constants.py new file mode 100644 index 0000000000..8af106ea9f --- /dev/null +++ b/music_assistant/providers/yandex_ynison/constants.py @@ -0,0 +1,55 @@ +"""Constants for the Yandex Ynison plugin.""" + +from __future__ import annotations + +from typing import Final + +# Ynison WebSocket endpoints +YNISON_REDIRECT_URL: Final[str] = ( + "wss://ynison.music.yandex.ru/redirector.YnisonRedirectService/GetRedirectToYnison" +) +YNISON_STATE_PATH: Final[str] = "/ynison_state.YnisonStateService/PutYnisonState" + +# Origin header required by Ynison +YNISON_ORIGIN: Final[str] = "https://music.yandex.ru" + +# Configuration keys +CONF_TOKEN: Final[str] = "token" +CONF_X_TOKEN: Final[str] = "x_token" +CONF_MASS_PLAYER_ID: Final[str] = "mass_player_id" +CONF_PUBLISH_NAME: Final[str] = "publish_name" +CONF_ALLOW_PLAYER_SWITCH: Final[str] = "allow_player_switch" +CONF_DEVICE_ID: Final[str] = "device_id" +CONF_OUTPUT_SAMPLE_RATE: Final[str] = "output_sample_rate" +CONF_OUTPUT_BIT_DEPTH: Final[str] = "output_bit_depth" +CONF_FFMPEG_PACING: Final[str] = "ffmpeg_pacing" + +# ffmpeg pacing mode values +PACING_REALTIME: Final[str] = "realtime" + +# Special value for "auto" config options +OUTPUT_AUTO: Final[str] = "auto" + +# Actions +CONF_ACTION_AUTH_QR: Final[str] = "auth_qr" +CONF_ACTION_CLEAR_AUTH: Final[str] = "clear_auth" +CONF_REMEMBER_SESSION: Final[str] = "remember_session" + +# Player selection +PLAYER_ID_AUTO: Final[str] = "__auto__" + +# Defaults +DEFAULT_DISPLAY_NAME: Final[str] = "Music Assistant" +DEFAULT_APP_NAME: Final[str] = "Music Assistant" +DEFAULT_APP_VERSION: Final[str] = "1.0.0" + +# Device types (from Ynison protobuf DeviceType enum) +DEVICE_TYPE_WEB: Final[str] = "WEB" + +# Reconnect settings +RECONNECT_DELAYS: Final[tuple[float, ...]] = (2.0, 4.0, 8.0, 16.0, 30.0, 60.0) +MAX_RECONNECT_ATTEMPTS: Final[int] = 5 + +# WebSocket timeouts +WS_CONNECT_TIMEOUT: Final[float] = 15.0 +WS_HEARTBEAT: Final[float] = 30.0 diff --git a/music_assistant/providers/yandex_ynison/icon.svg b/music_assistant/providers/yandex_ynison/icon.svg new file mode 100644 index 0000000000..3100b1542c --- /dev/null +++ b/music_assistant/providers/yandex_ynison/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/music_assistant/providers/yandex_ynison/manifest.json b/music_assistant/providers/yandex_ynison/manifest.json new file mode 100644 index 0000000000..54f51ca08b --- /dev/null +++ b/music_assistant/providers/yandex_ynison/manifest.json @@ -0,0 +1,12 @@ +{ + "type": "plugin", + "domain": "yandex_ynison", + "stage": "beta", + "name": "Yandex Music Connect (Ynison)", + "description": "Makes a Music Assistant player appear as a device in the Yandex Music app via the Ynison protocol.", + "codeowners": ["@TrudenBoy"], + "requirements": ["ya-passport-auth==1.2.3"], + "depends_on": "yandex_music", + "documentation": "https://music-assistant.io/plugins/yandex-ynison/", + "multi_instance": true +} diff --git a/music_assistant/providers/yandex_ynison/protocols.py b/music_assistant/providers/yandex_ynison/protocols.py new file mode 100644 index 0000000000..3029a16323 --- /dev/null +++ b/music_assistant/providers/yandex_ynison/protocols.py @@ -0,0 +1,39 @@ +"""Protocol definitions for provider dependencies. + +Allows typing of external provider references without importing concrete classes. +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: + from music_assistant_models.enums import MediaType + from music_assistant_models.streamdetails import StreamDetails + + +@runtime_checkable +class YandexMusicProviderLike(Protocol): + """Structural interface for the yandex_music MusicProvider. + + Only the subset of methods/properties used by the Ynison plugin. + """ + + async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: + """Resolve stream details for a track.""" + ... + + def get_audio_stream(self, stream_details: StreamDetails) -> AsyncGenerator[bytes, None]: + """Return async generator of raw audio bytes.""" + ... + + async def get_rotor_station_tracks( + self, station_id: str, queue: str | int | None = None + ) -> tuple[list[Any], str | None]: + """Fetch tracks from a rotor station for radio queue replenishment.""" + ... + + def get_quality(self) -> str: + """Return the configured audio quality tier (e.g. 'balanced', 'superb').""" + ... diff --git a/music_assistant/providers/yandex_ynison/provider.py b/music_assistant/providers/yandex_ynison/provider.py new file mode 100644 index 0000000000..c0770f473e --- /dev/null +++ b/music_assistant/providers/yandex_ynison/provider.py @@ -0,0 +1,1353 @@ +"""Yandex Ynison plugin provider for Music Assistant.""" + +from __future__ import annotations + +import asyncio +import random +import time +from collections.abc import AsyncGenerator, Callable +from contextlib import suppress +from typing import TYPE_CHECKING, Any, cast + +from music_assistant_models.enums import ( + ContentType, + EventType, + MediaType, + PlaybackState, + ProviderFeature, + ProviderType, + StreamType, +) +from music_assistant_models.errors import LoginFailed, UnsupportedFeaturedException +from music_assistant_models.streamdetails import StreamDetails, StreamMetadata +from ya_passport_auth import SecretStr + +from music_assistant.helpers.ffmpeg import get_ffmpeg_stream +from music_assistant.helpers.throttle_retry import ThrottlerManager +from music_assistant.models.plugin import PluginProvider, PluginSource + +from .constants import ( + CONF_ALLOW_PLAYER_SWITCH, + CONF_DEVICE_ID, + CONF_MASS_PLAYER_ID, + CONF_OUTPUT_BIT_DEPTH, + CONF_OUTPUT_SAMPLE_RATE, + CONF_PUBLISH_NAME, + CONF_TOKEN, + CONF_X_TOKEN, + DEFAULT_DISPLAY_NAME, + OUTPUT_AUTO, + PLAYER_ID_AUTO, +) +from .protocols import YandexMusicProviderLike +from .streaming import ( + PCM_LOSSLESS_PARAMS, + PCM_LOSSY_PARAMS, + PROBE_ARGS, + make_pcm_format, + pacing_args, +) +from .yandex_auth import refresh_music_token +from .ynison_client import YnisonClient, YnisonDeviceInfo, YnisonState, generate_device_id + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ProviderConfig + from music_assistant_models.event import MassEvent + from music_assistant_models.media_items import AudioFormat + from music_assistant_models.provider import ProviderManifest + + from music_assistant.mass import MusicAssistant + +# How often (seconds) to sync progress to MA UI and Ynison. +_PROGRESS_SYNC_INTERVAL = 5.0 + +# Retry settings for transient Yandex API failures +_API_MAX_RETRIES = 3 +_API_INITIAL_BACKOFF = 2.0 +_API_MAX_BACKOFF = 30.0 + +# Cache TTL for stream details (seconds) +_STREAM_DETAILS_CACHE_TTL = 300 # 5 minutes + + +class YandexYnisonProvider(PluginProvider): + """Implementation of the Yandex Music Connect (Ynison) Plugin.""" + + @property + def instance_name_postfix(self) -> str | None: + """Return display name as instance postfix for multi-instance setups.""" + name = self._display_name + return name if name != DEFAULT_DISPLAY_NAME else None + + def __init__( + self, + mass: MusicAssistant, + manifest: ProviderManifest, + config: ProviderConfig, + supported_features: set[ProviderFeature], + ) -> None: + """Initialize the Ynison plugin provider.""" + super().__init__(mass, manifest, config, supported_features) + + # Config values + self._default_player_id: str = ( + cast("str", self.config.get_value(CONF_MASS_PLAYER_ID)) or PLAYER_ID_AUTO + ) + allow_switch_value = self.config.get_value(CONF_ALLOW_PLAYER_SWITCH) + self._allow_player_switch: bool = ( + cast("bool", allow_switch_value) if allow_switch_value is not None else True + ) + self._cfg_sample_rate: str = ( + cast("str", self.config.get_value(CONF_OUTPUT_SAMPLE_RATE)) or OUTPUT_AUTO + ) + self._cfg_bit_depth: str = ( + cast("str", self.config.get_value(CONF_OUTPUT_BIT_DEPTH)) or OUTPUT_AUTO + ) + self._display_name: str = ( + cast("str", self.config.get_value(CONF_PUBLISH_NAME)) or DEFAULT_DISPLAY_NAME + ) + + # Device ID — persist in config so re-registration uses the same ID + device_id = cast("str | None", self.config.get_value(CONF_DEVICE_ID)) + if not device_id: + device_id = generate_device_id() + self._update_config_value(CONF_DEVICE_ID, device_id) + self._device_id: str = device_id + + # Runtime state + self._active_player_id: str | None = None + self._ynison: YnisonClient | None = None + self._runner_task: asyncio.Task[None] | None = None + self._on_unload_callbacks: list[Callable[..., None]] = [] + self._yandex_provider: YandexMusicProviderLike | None = None + self._current_streaming_track_id: str | None = None + self._track_changed_event = asyncio.Event() + self._stream_stop_event = asyncio.Event() + self._seek_position_ms: int = 0 + self._seek_grace_until: float = 0.0 + self._last_player_update_time: float = 0.0 + self._actual_duration_ms: int = 0 + self._prefetched_list: list[dict[str, Any]] | None = None + self._prefetch_task: asyncio.Task[Any] | None = None + self._normalized_params: dict[str, Any] = PCM_LOSSY_PARAMS + self._normalized_format: AudioFormat = make_pcm_format(PCM_LOSSY_PARAMS) + + # Rate limiter for Yandex API calls (max 2 req/s) + self._api_throttler = ThrottlerManager(rate_limit=2, period=1.0) + + # Progress tracking — byte counter is the single source of truth + # during active streaming; Ynison echoes are detected and ignored. + self._streaming_progress_ms: int = 0 + self._last_sent_to_ynison_ms: int = -1 + self._last_sent_to_ynison_time: float = 0.0 + + # PluginSource + self._source_details = PluginSource( + id=self.instance_id, + name=self.name, + passive=not self._allow_player_switch, + can_play_pause=False, + can_seek=False, + can_next_previous=False, + audio_format=self._normalized_format, + metadata=StreamMetadata( + title=f"Yandex Music Connect | {self._display_name}", + ), + stream_type=StreamType.CUSTOM, + ) + self._source_details.on_select = self._on_source_selected + + # ------------------------------------------------------------------ + # Provider lifecycle + # ------------------------------------------------------------------ + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + token = await self._resolve_token() + + device_info = YnisonDeviceInfo( + device_id=self._device_id, + title=self._display_name, + ) + + self._ynison = YnisonClient( + token=token, + device_info=device_info, + on_state_update=self._handle_ynison_state, + on_disconnect=self._handle_ynison_disconnect, + logger=self.logger, + on_auth_failure=self._refresh_ynison_token, + ) + + self._runner_task = self.mass.create_task(self._ynison.connect()) + + # Subscribe to provider events to detect linked yandex_music provider + self._on_unload_callbacks.append( + self.mass.subscribe( + self._on_provider_event, + EventType.PROVIDERS_UPDATED, + ) + ) + # Initial check for matching provider + self.mass.create_task(self._check_yandex_provider_match()) + + async def unload(self, is_removed: bool = False) -> None: + """Handle close/cleanup of the provider.""" + if self._prefetch_task and not self._prefetch_task.done(): + self._prefetch_task.cancel() + with suppress(asyncio.CancelledError): + await self._prefetch_task + + if self._ynison: + await self._ynison.disconnect() + + if self._runner_task and not self._runner_task.done(): + self._runner_task.cancel() + with suppress(asyncio.CancelledError): + await self._runner_task + + for callback in self._on_unload_callbacks: + with suppress(KeyError): + callback() + + def get_source(self) -> PluginSource: + """Get (audio)source details for this plugin.""" + return self._source_details + + async def get_audio_stream(self, player_id: str) -> AsyncGenerator[bytes, None]: # noqa: PLR0915 + """Return continuous audio stream following Ynison track changes. + + Streams the current track, then waits for track changes and streams + the next track automatically. Runs until the source is deselected. + + The PCM format is frozen at session start to match what the outer + ffmpeg captured from ``_source_details.audio_format``. If + ``_update_normalized_format()`` fires mid-session (e.g. a provider + reload), the new format takes effect only on the *next* session — + preventing bit-depth/sample-rate mismatches that cause noise. + """ + self._stream_stop_event.clear() + + # Freeze format for this streaming session so every inner ffmpeg + # produces data matching the outer ffmpeg's captured input_format. + session_params: dict[str, Any] = dict(self._normalized_params) + session_fmt: AudioFormat = make_pcm_format(session_params) + + while not self._stream_stop_event.is_set() and self._source_details.in_use_by == player_id: + if not self._ynison or not self._ynison.state.current_track_id: + # Wait for a track to appear + self._track_changed_event.clear() + try: + await asyncio.wait_for(self._track_changed_event.wait(), timeout=30.0) + except TimeoutError: + continue + continue + + # Clear event before reading state so any subsequent update + # re-sets the event instead of being silently cleared. + self._track_changed_event.clear() + track_id = self._ynison.state.current_track_id + self._current_streaming_track_id = track_id + + # Don't start streaming if Ynison reports paused — wait for resume. + # Poll every 1s because a same-track resume won't trigger + # _track_changed_event (it only fires on track change / seek). + if self._ynison.state.is_paused: + pause_deadline = time.monotonic() + 30.0 + while ( + not self._stream_stop_event.is_set() + and self._source_details.in_use_by == player_id + and self._ynison + and self._ynison.state.current_track_id == track_id + and self._ynison.state.is_paused + and time.monotonic() < pause_deadline + ): + remaining = pause_deadline - time.monotonic() + with suppress(TimeoutError): + await asyncio.wait_for( + self._track_changed_event.wait(), + timeout=min(1.0, remaining), + ) + self._track_changed_event.clear() + continue + + if not self._yandex_provider: + self.logger.warning( + "No linked Yandex Music provider — cannot stream track %s", track_id + ) + self._current_streaming_track_id = None + self._stream_stop_event.set() + if self._source_details.in_use_by == player_id: + self._source_details.in_use_by = None + await self.mass.players.cmd_stop(player_id) + return + + # Stream the current track + seek_ms = self._seek_position_ms + self._seek_position_ms = 0 + bytes_yielded = 0 + self._streaming_progress_ms = seek_ms + last_progress_sync = time.monotonic() + + track_fmt = make_pcm_format(session_params) + async for chunk in self._stream_track( + track_id, seek_ms=seek_ms, session_params=session_params + ): + yield chunk + bytes_yielded += len(chunk) + now_mono = time.monotonic() + if now_mono - last_progress_sync >= _PROGRESS_SYNC_INTERVAL: + last_progress_sync = now_mono + await self._sync_progress(seek_ms, bytes_yielded, player_id, session_fmt) + if ( + self._track_changed_event.is_set() + or self._stream_stop_event.is_set() + or self._source_details.in_use_by != player_id + ): + break + + # Align to PCM frame boundary — prevents misalignment in MA's + # downstream ffmpeg when a track stream is interrupted mid-chunk. + # We pad with zeros (can't un-yield bytes already sent downstream). + frame_size = (track_fmt.bit_depth // 8) * track_fmt.channels + if frame_size > 0: + excess = bytes_yielded % frame_size + if excess: + yield b"\x00" * (frame_size - excess) + + # Don't clear _current_streaming_track_id yet — keep it set + # during advance/wait so Ynison echo of the same track doesn't + # trigger a false track-change detection in _activate_playback. + + if self._stream_stop_event.is_set(): + self._current_streaming_track_id = None + break + + # Track finished naturally — signal completion to Ynison. + # Yandex controls the queue; we just wait for the next track. + if not self._track_changed_event.is_set() and self._ynison: + self.logger.info("Track %s finished, advancing to next", track_id) + await self._signal_track_completion() + if not await self._wait_for_track_change(track_id): + self._stream_stop_event.set() + self._current_streaming_track_id = None + break + + # Clear before next iteration — the new track ID will be set at + # the top of the loop from the latest Ynison state. + self._current_streaming_track_id = None + + async def _wait_for_track_change(self, old_track_id: str, timeout: float = 30.0) -> bool: + """Wait for Ynison to report a different track, ignoring echoes. + + After _signal_track_completion sends update_playing_status, Ynison + echoes back the same track with updated progress. Only return True + once current_track_id actually differs from old_track_id. + """ + deadline = time.monotonic() + timeout + while not self._stream_stop_event.is_set(): + self._track_changed_event.clear() + remaining = deadline - time.monotonic() + if remaining <= 0: + break + try: + await asyncio.wait_for(self._track_changed_event.wait(), timeout=remaining) + except TimeoutError: + break + if self._ynison and self._ynison.state.current_track_id != old_track_id: + return True + self.logger.info("No new track from Ynison after completion, stopping stream") + return False + + async def _stream_track( + self, + track_id: str, + seek_ms: int = 0, + session_params: dict[str, Any] | None = None, + ) -> AsyncGenerator[bytes, None]: + """Stream a single track, normalizing to fixed PCM via per-track ffmpeg. + + Every track is decoded through its own ffmpeg process to produce a + fixed PCM output (s16le or s24le based on YM quality setting). This + ensures MA's single ffmpeg process never encounters mid-stream format + changes (codec, bit depth, sample rate). + + *session_params* — frozen format dict from the enclosing + ``get_audio_stream()`` session. Falls back to the current + ``_normalized_params`` when called outside a session. + """ + try: + stream_details = await self._get_stream_details_with_retry(track_id) + except Exception: + self.logger.exception("Failed to get stream details for track %s", track_id) + self._stream_stop_event.set() + return + + await self._update_metadata_from_stream(stream_details, seek_ms) + + extra_input_args = PROBE_ARGS + pacing_args() + if seek_ms > 0: + extra_input_args += ["-ss", f"{seek_ms / 1000.0:.3f}"] + + # Use session format when available, otherwise current normalized params + params = session_params if session_params is not None else self._normalized_params + out_fmt = make_pcm_format(params) + self.logger.info( + "Streaming track %s → %s: input=%s seek=%dms", + track_id, + out_fmt.content_type.value, + stream_details.audio_format, + seek_ms, + ) + assert self._yandex_provider is not None # guarded by get_audio_stream + async for chunk in get_ffmpeg_stream( + audio_input=self._yandex_provider.get_audio_stream(stream_details), + input_format=stream_details.audio_format, + output_format=out_fmt, + extra_input_args=extra_input_args, + ): + yield chunk + + async def _get_stream_details_with_retry( + self, + track_id: str, + media_type: MediaType = MediaType.TRACK, + ) -> StreamDetails: + """Fetch stream details with caching, throttling, and retry.""" + cache_key = f"ynison_sd_{track_id}" + cached = await self.mass.cache.get( + cache_key, + provider=self.instance_id, + base_class=StreamDetails, + ) + if cached is not None: + self.logger.debug("Stream details cache hit for %s", track_id) + return cast("StreamDetails", cached) + + backoff = _API_INITIAL_BACKOFF + last_err: Exception | None = None + for attempt in range(_API_MAX_RETRIES): + async with self._api_throttler.acquire() as delay: + if delay > 0: + self.logger.debug("get_stream_details throttled %.1fs", delay) + try: + sd = await self._yandex_provider.get_stream_details( # type: ignore[union-attr] + track_id, media_type + ) + # StreamDetails.data has serialize="omit", so to_dict() + # strips it. Manually include it so cached entries keep + # the URL / decryption key needed by get_audio_stream(). + cache_value = sd.to_dict() + cache_value["data"] = sd.data + # Respect the provider's expiration (e.g. yandex_music sets + # 50 s because CDN URLs expire after ~60 s). Fall back to + # our default TTL when the provider does not override. + cache_ttl = min(_STREAM_DETAILS_CACHE_TTL, sd.expiration) + if cache_ttl > 0: + await self.mass.cache.set( + cache_key, + cache_value, + expiration=cache_ttl, + provider=self.instance_id, + ) + return sd + except asyncio.CancelledError: + raise + except Exception as err: + last_err = err + if attempt < _API_MAX_RETRIES - 1: + jitter = backoff * random.uniform(0.75, 1.25) + self.logger.warning( + "get_stream_details attempt %d/%d failed: %s, retrying in %.1fs", + attempt + 1, + _API_MAX_RETRIES, + err, + jitter, + ) + await asyncio.sleep(jitter) + backoff = min(backoff * 2, _API_MAX_BACKOFF) + msg = f"get_stream_details failed after {_API_MAX_RETRIES} attempts for {track_id}" + raise RuntimeError(msg) from last_err + + async def _invalidate_stream_cache(self, track_id: str) -> None: + """Evict cached stream details for a track so the next fetch is fresh.""" + cache_key = f"ynison_sd_{track_id}" + await self.mass.cache.delete(cache_key, provider=self.instance_id) + self.logger.debug("Invalidated stream cache for %s", track_id) + + # ------------------------------------------------------------------ + # Token handling + # ------------------------------------------------------------------ + + async def _resolve_token(self) -> SecretStr: + """Resolve the Yandex Music token, refreshing from x_token if needed. + + Prefers refreshing from x_token when available so that an expired + music token does not permanently break the plugin (self-healing). + Falls back to the stored music token if the refresh attempt fails. + """ + token = cast("str | None", self.config.get_value(CONF_TOKEN)) + x_token = cast("str | None", self.config.get_value(CONF_X_TOKEN)) + + if x_token: + try: + self.logger.debug("Refreshing music token from x_token") + new_token = await refresh_music_token(SecretStr(x_token)) + self._update_config_value(CONF_TOKEN, new_token.get_secret(), encrypted=True) + return new_token + except Exception as err: + if token: + self.logger.warning("Token refresh failed, using stored token: %s", err) + return SecretStr(token) + raise LoginFailed("Failed to refresh Yandex music token from x_token") from err + + if token: + return SecretStr(token) + + raise LoginFailed("No Yandex Music token configured") + + async def _refresh_ynison_token(self) -> SecretStr: + """Refresh the OAuth token for Ynison reconnection. + + Called by YnisonClient on auth failure (401/403) during reconnect. + Uses x_token to obtain a fresh music token. If x_token is not + configured or refresh fails, raises LoginFailed. + """ + x_token = cast("str | None", self.config.get_value(CONF_X_TOKEN)) + if not x_token: + raise LoginFailed("Cannot refresh token: x_token not configured") + self.logger.info("Refreshing Yandex Music token for Ynison reconnect") + new_token = await refresh_music_token(SecretStr(x_token)) + self._update_config_value(CONF_TOKEN, new_token.get_secret(), encrypted=True) + return new_token + + # ------------------------------------------------------------------ + # Ynison state handling + # ------------------------------------------------------------------ + + async def _handle_ynison_state(self, state: YnisonState) -> None: + """Handle state update from Ynison.""" + is_our_device = state.active_device_id == self._device_id + + # Detailed queue logging for diagnostics + queue = state.player_state.get("player_queue", {}) + playable_list = queue.get("playable_list", []) + current_index = queue.get("current_playable_index", -1) + entity_type = queue.get("entity_type", "") + entity_id = queue.get("entity_id", "") + track_id = state.current_track_id + self.logger.debug( + "Ynison state: active_device=%s (ours=%s) track=%s " + "index=%d/%d entity=%s type=%s paused=%s progress=%dms", + state.active_device_id, + is_our_device, + track_id, + current_index, + len(playable_list), + entity_id[:40] if entity_id else "", + entity_type, + state.is_paused, + state.progress_ms, + ) + + if is_our_device and not state.is_paused: + # Pre-fetch next batch when playing second-to-last track + self._maybe_prefetch(current_index, playable_list, entity_id, entity_type) + await self._activate_playback(state) + elif is_our_device and state.is_paused: + # Our device but paused — stop player, keep association + await self._pause_playback() + elif self._source_details.in_use_by: + # Active device switched away — fully release player + self._clear_active_player() + + async def _activate_playback(self, state: YnisonState) -> None: + """Activate playback on the target MA player.""" + target_player_id = self._get_target_player_id() + if not target_player_id: + self.logger.warning("Ynison active on our device but no MA player available") + return + + # Detect resume after pause: stream was stopped but player still associated + needs_reselect = self._stream_stop_event.is_set() + self._stream_stop_event.clear() + + # Select source on the target player if not already active or resuming. + # Guard on _active_player_id (set immediately) rather than in_use_by + # (set by the server callback after DLNA negotiation completes) to + # prevent queuing redundant select_source calls during the ~5s gap. + if self._active_player_id != target_player_id or needs_reselect: + self._active_player_id = target_player_id + self.mass.create_task( + self.mass.players.select_source(target_player_id, self.instance_id) + ) + + # Signal track change if track_id changed + significant_change = False + new_track = state.current_track_id + if new_track and new_track != self._current_streaming_track_id: + self.logger.info("Track changed: %s -> %s", self._current_streaming_track_id, new_track) + self._current_streaming_track_id = new_track + self._seek_position_ms = state.progress_ms + self._track_changed_event.set() + significant_change = True + # Grace period: ignore seek detection for a few seconds after + # track change — Ynison echoes can report stale progress that + # looks like a large drift. + self._seek_grace_until = time.monotonic() + 5.0 + elif new_track and new_track == self._current_streaming_track_id: + # Same-track resume after pause: explicitly seek to the Ynison position + # so the new stream starts at the right offset. + if needs_reselect: + self._seek_position_ms = state.progress_ms + self._track_changed_event.set() + self._seek_grace_until = time.monotonic() + 5.0 + significant_change = True + else: + # Detect seek: compare Ynison progress against our stream position. + # Ignore Ynison echoes (values we recently sent) to prevent + # feedback loops where our own progress updates trigger false seeks. + now = time.monotonic() + if now < self._seek_grace_until: + pass # Skip during grace period after track change or seek + elif self._is_ynison_echo(state.progress_ms): + pass # Echo of our own update — ignore + else: + our_ms = max(self._streaming_progress_ms, self._last_sent_to_ynison_ms) + if our_ms >= 0: + drift_ms = abs(state.progress_ms - our_ms) + if drift_ms > 3000: + self.logger.info( + "Seek detected on track %s: " + "expected ~%dms, Ynison at %dms (drift %dms)", + new_track, + our_ms, + state.progress_ms, + int(drift_ms), + ) + self._seek_position_ms = state.progress_ms + self._track_changed_event.set() + self._seek_grace_until = now + 5.0 + significant_change = True + + # Update metadata from state + self._update_metadata(state) + + # Always trigger player update on significant changes; + # throttle regular updates to avoid UI churn (every 5 seconds). + # Use force_update on seek/track change so the server broadcasts a full + # PLAYER_UPDATED event instead of a lightweight elapsed-time-only one + # that the frontend may not handle for PluginSource players. + now_mono = time.monotonic() + if significant_change or needs_reselect or now_mono - self._last_player_update_time >= 5.0: + self.mass.players.trigger_player_update( + target_player_id, force_update=significant_change + ) + self._last_player_update_time = now_mono + + def _update_metadata(self, state: YnisonState) -> None: + """Update PluginSource metadata from Ynison state.""" + if self._source_details.metadata is None: + self._source_details.metadata = StreamMetadata( + title=f"Yandex Music Connect | {self._display_name}", + ) + + meta = self._source_details.metadata + + # Update duration (prefer actual from stream_details) and elapsed time + best_duration = self._best_duration_ms() + if best_duration: + meta.duration = best_duration // 1000 + # Only update elapsed from Ynison when NOT actively streaming — + # during streaming, _sync_progress provides byte-accurate progress. + if state.progress_ms is not None and not self._source_details.in_use_by: + meta.elapsed_time = state.progress_ms // 1000 + meta.elapsed_time_last_updated = time.time() + + # Extract track info from player state if available + queue = state.player_state.get("player_queue", {}) + playable_list = queue.get("playable_list", []) + index = queue.get("current_playable_index", 0) + if playable_list and 0 <= index < len(playable_list): + playable = playable_list[index] + title = playable.get("title") + if title: + meta.title = title + cover = playable.get("cover_url_optional") + if cover and not cover.startswith("http"): + cover = f"https://{cover}" + if cover: + # Replace %% placeholder with size + cover = cover.replace("%%", "400x400") + meta.image_url = cover + + async def _update_metadata_from_stream( + self, stream_details: StreamDetails, seek_ms: int = 0 + ) -> None: + """Update PluginSource metadata from stream details (authoritative for duration).""" + if self._source_details.metadata is None: + self._source_details.metadata = StreamMetadata( + title=f"Yandex Music Connect | {self._display_name}", + ) + meta = self._source_details.metadata + if stream_details.duration: + meta.duration = stream_details.duration + self._actual_duration_ms = stream_details.duration * 1000 + # Push the real duration to Ynison so the YM app shows + # the correct value (we send duration_ms=0 on advance to + # prevent stale propagation, so this corrects it). + if self._ynison: + await self._send_progress_to_ynison( + progress_ms=seek_ms, + duration_ms=self._actual_duration_ms, + paused=self._ynison.state.is_paused, + ) + meta.elapsed_time = seek_ms // 1000 if seek_ms else 0 + meta.elapsed_time_last_updated = time.time() + if self._source_details.in_use_by: + self.mass.players.trigger_player_update( + self._source_details.in_use_by, force_update=True + ) + + # ------------------------------------------------------------------ + # Ynison echo detection + # ------------------------------------------------------------------ + + _ECHO_TOLERANCE_MS = 2000 + _ECHO_WINDOW_S = 5.0 + + def _is_ynison_echo(self, progress_ms: int) -> bool: + """Check if an incoming Ynison progress value is our own echo. + + After we send update_playing_status, Ynison reflects the value + back within a few seconds. Detecting these echoes prevents the + feedback loop: send progress → echo → false seek → restart. + """ + if self._last_sent_to_ynison_ms < 0: + return False + elapsed = time.monotonic() - self._last_sent_to_ynison_time + if elapsed > self._ECHO_WINDOW_S: + return False + return abs(progress_ms - self._last_sent_to_ynison_ms) < self._ECHO_TOLERANCE_MS + + async def _send_progress_to_ynison( + self, progress_ms: int, duration_ms: int, paused: bool + ) -> None: + """Send progress to Ynison and record it for echo detection. + + Progress is clamped to duration because Ynison rejects updates where + progress > duration (error 400030001) and disconnects the WebSocket. + The byte counter can slightly overshoot duration at end-of-stream. + + Echo-tracking fields are only updated after a message is actually sent + so that silent no-ops during disconnect don't suppress later real + Ynison updates (which would look like "echoes"). + """ + if duration_ms <= 0: + # Ynison rejects progress > duration; skip until duration is known. + return + if not self._ynison or not self._ynison.connected: + return + progress_ms = min(progress_ms, duration_ms) + await self._ynison.update_playing_status( + progress_ms=progress_ms, + duration_ms=duration_ms, + paused=paused, + ) + self._last_sent_to_ynison_ms = progress_ms + self._last_sent_to_ynison_time = time.monotonic() + + def _bytes_to_ms(self, byte_count: int, fmt: AudioFormat | None = None) -> int: + """Convert PCM byte count to milliseconds using the given format.""" + bps = (fmt or self._normalized_format).pcm_sample_size + if bps == 0: + return 0 + return (byte_count * 1000) // bps + + async def _sync_progress( + self, + seek_ms: int, + bytes_yielded: int, + player_id: str | None, + fmt: AudioFormat | None = None, + ) -> None: + """Push real playback progress to MA metadata and Ynison.""" + elapsed_ms = seek_ms + self._bytes_to_ms(bytes_yielded, fmt) + self._streaming_progress_ms = elapsed_ms + # Update MA metadata + meta = self._source_details.metadata + if meta: + meta.elapsed_time = elapsed_ms // 1000 + meta.elapsed_time_last_updated = time.time() + if player_id: + self.mass.players.trigger_player_update(player_id) + # Update Ynison so the Yandex app shows correct position + await self._send_progress_to_ynison( + progress_ms=elapsed_ms, + duration_ms=self._best_duration_ms(), + paused=False, + ) + + async def _pause_playback(self) -> None: + """Handle pause — stop streaming but keep player association for resume.""" + paused_progress_ms = self._streaming_progress_ms + self._stream_stop_event.set() + # Preserve the last known position for same-track resume and reset the + # Ynison echo baseline so resume logic does not suppress the required seek. + self._streaming_progress_ms = paused_progress_ms + self._last_sent_to_ynison_ms = -1 + self._last_sent_to_ynison_time = 0.0 + player_id = self._source_details.in_use_by + if player_id: + try: + await self.mass.players.cmd_stop(player_id) + except Exception: + self.logger.debug("Failed to stop player %s on pause", player_id) + if self._source_details.in_use_by == player_id: + self._source_details.in_use_by = None + self.mass.players.trigger_player_update(player_id) + + async def _handle_ynison_disconnect(self) -> None: + """Handle permanent disconnect from Ynison.""" + self.logger.error("Ynison connection permanently lost") + self._clear_active_player() + + # ------------------------------------------------------------------ + # Player selection + # ------------------------------------------------------------------ + + def _get_target_player_id(self) -> str | None: + """Determine the target player ID for playback.""" + # If there's an active player, validate it still exists + if self._active_player_id: + if self.mass.players.get_player(self._active_player_id): + return self._active_player_id + self._active_player_id = None + + # Auto selection + if self._default_player_id == PLAYER_ID_AUTO: + all_players = list(self.mass.players.all_players(False, False)) + # Prefer currently playing player + for player in all_players: + if player.state.playback_state == PlaybackState.PLAYING: + self.logger.debug("Auto-selecting playing player: %s", player.display_name) + return str(player.player_id) + # Fallback to first available + if all_players: + return str(all_players[0].player_id) + return None + + # Specific configured player + if self.mass.players.get_player(self._default_player_id): + return self._default_player_id + + self.logger.warning( + "Configured default player '%s' no longer exists", + self._default_player_id, + ) + return None + + async def _on_source_selected(self) -> None: + """Handle callback when this source is selected on a player.""" + new_player_id = self._source_details.in_use_by + if not new_player_id: + return + + # Check if manual player switching is allowed + if not self._allow_player_switch: + current_target = self._get_target_player_id() + if new_player_id != current_target: + self.logger.debug( + "Player switching disabled, rejecting selection on %s", + new_player_id, + ) + self._source_details.in_use_by = current_target + # Revert the rejected player's active_source back to its MA queue + # (the controller already set it to our plugin before calling on_select) + try: + await self.mass.players.select_source(new_player_id, new_player_id) + except Exception: + self.logger.debug("Could not revert active_source for %s", new_player_id) + if current_target: + self.mass.players.trigger_player_update(current_target) + msg = ( + "Player switching is disabled; source must remain on " + f"{current_target or 'the configured target player'}" + ) + raise RuntimeError(msg) + + # Stop previous player if switching + if self._active_player_id and self._active_player_id != new_player_id: + self.logger.info( + "Source selected on %s, stopping %s", + new_player_id, + self._active_player_id, + ) + try: + await self.mass.players.cmd_stop(self._active_player_id) + except Exception as err: + self.logger.debug( + "Failed to stop previous player %s: %s", + self._active_player_id, + err, + ) + + self._active_player_id = new_player_id + self.logger.debug("Active player set to: %s", new_player_id) + + def _clear_active_player(self) -> None: + """Clear the active player and reset plugin state.""" + prev_player_id = self._active_player_id + was_in_use = self._source_details.in_use_by == prev_player_id + self._active_player_id = None + self._source_details.in_use_by = None + self._stream_stop_event.set() + self._streaming_progress_ms = 0 + self._last_sent_to_ynison_ms = -1 + self._prefetched_list = None + if self._prefetch_task and not self._prefetch_task.done(): + self._prefetch_task.cancel() + + if prev_player_id: + self.logger.debug( + "Playback ended on player %s, clearing active player", + prev_player_id, + ) + if was_in_use: + self.mass.create_task(self.mass.players.cmd_stop(prev_player_id)) + self.mass.players.trigger_player_update(prev_player_id) + + # ------------------------------------------------------------------ + # Yandex Music provider matching + # ------------------------------------------------------------------ + + def _on_provider_event(self, event: MassEvent) -> None: + """Handle provider added/removed events.""" + self.mass.create_task(self._check_yandex_provider_match()) + + async def _check_yandex_provider_match(self) -> None: + """Check if a Yandex Music provider is available for audio streaming.""" + for provider in self.mass.get_providers(): + if provider.domain == "yandex_music" and provider.type == ProviderType.MUSIC: + self.logger.debug("Found Yandex Music provider — enabling playback control") + self._yandex_provider = cast("YandexMusicProviderLike", provider) + self._update_normalized_format() + self._update_source_capabilities() + return + + if self._yandex_provider is not None: + self.logger.debug( + "Yandex Music provider no longer available — disabling playback control" + ) + self._yandex_provider = None + self._update_source_capabilities() + + def _update_normalized_format(self) -> None: + """Set PCM normalization profile based on config and YM quality. + + Priority: explicit config values > auto-detection from YM quality. + Auto-detection: superb/lossless → 24bit/48kHz, else → 16bit/44.1kHz. + + Creates fresh AudioFormat instances each time to prevent mutation by + MA's FFMpeg._log_reader_task (which sets input_format.codec_type + in-place on the object passed as input_format to the outer ffmpeg). + """ + # Start with auto-detected base from YM quality + quality = "" + if self._yandex_provider and hasattr(self._yandex_provider, "get_quality"): + quality = self._yandex_provider.get_quality() + is_lossless = quality in ("superb", "lossless") + base = PCM_LOSSLESS_PARAMS if is_lossless else PCM_LOSSY_PARAMS + + # Apply config overrides + sample_rate = base["sample_rate"] + bit_depth = base["bit_depth"] + if self._cfg_sample_rate != OUTPUT_AUTO: + sample_rate = int(self._cfg_sample_rate) + if self._cfg_bit_depth != OUTPUT_AUTO: + bit_depth = int(self._cfg_bit_depth) + + content_type = ContentType.PCM_S24LE if bit_depth == 24 else ContentType.PCM_S16LE + new_params: dict[str, Any] = { + "content_type": content_type, + "sample_rate": sample_rate, + "bit_depth": bit_depth, + "channels": 2, + } + + # Warn if format changes while a player is actively streaming — the + # active session keeps using its frozen snapshot; the new format takes + # effect on the next session. + old = self._normalized_params + if self._source_details.in_use_by and ( + old.get("content_type") != content_type + or old.get("sample_rate") != sample_rate + or old.get("bit_depth") != bit_depth + ): + self.logger.warning( + "Normalization format changed while streaming — new format " + "(%s/%dHz/%dbit) will apply on next session", + content_type.value, + sample_rate, + bit_depth, + ) + + self._normalized_params = new_params + # Fresh copy for each caller so no shared mutable state + self._normalized_format = make_pcm_format(self._normalized_params) + self._source_details.audio_format = make_pcm_format(self._normalized_params) + self.logger.debug( + "Normalization format: %s/%dHz/%dbit", + self._normalized_format.content_type.value, + self._normalized_format.sample_rate, + self._normalized_format.bit_depth, + ) + + def _update_source_capabilities(self) -> None: + """Update source capabilities based on linked provider availability.""" + has_provider = self._yandex_provider is not None + self._source_details.can_play_pause = has_provider + self._source_details.can_seek = has_provider + self._source_details.can_next_previous = has_provider + + if has_provider: + self._source_details.on_play = self._on_play + self._source_details.on_pause = self._on_pause + self._source_details.on_next = self._on_next + self._source_details.on_previous = self._on_previous + self._source_details.on_seek = self._on_seek + else: + self._source_details.on_play = None + self._source_details.on_pause = None + self._source_details.on_next = None + self._source_details.on_previous = None + self._source_details.on_seek = None + + if self._source_details.in_use_by: + self.mass.players.trigger_player_update(self._source_details.in_use_by) + + # ------------------------------------------------------------------ + # Playback control callbacks + # ------------------------------------------------------------------ + + def _best_duration_ms(self) -> int: + """Return the best known duration: actual from stream, or Ynison state as fallback.""" + if self._actual_duration_ms > 0: + return self._actual_duration_ms + if self._ynison: + return self._ynison.state.duration_ms + return 0 + + async def _on_play(self) -> None: + """Handle play command — send resume to Ynison.""" + if not self._ynison: + raise UnsupportedFeaturedException("Not connected to Ynison") + state = self._ynison.state + await self._send_progress_to_ynison( + progress_ms=state.progress_ms, + duration_ms=self._best_duration_ms(), + paused=False, + ) + + async def _on_pause(self) -> None: + """Handle pause command — send pause to Ynison.""" + if not self._ynison: + raise UnsupportedFeaturedException("Not connected to Ynison") + state = self._ynison.state + await self._send_progress_to_ynison( + progress_ms=state.progress_ms, + duration_ms=self._best_duration_ms(), + paused=True, + ) + + # Entity types that use server-side "radio" queue replenishment. + # Currently only RADIO (personal wave, genre stations). + # Add "WAVE" here if/when Yandex supports it via the same + # rotor_station_tracks API. + _RADIO_ENTITY_TYPES = {"RADIO"} + + def _maybe_prefetch( + self, + current_index: int, + playable_list: list[dict[str, Any]], + entity_id: str, + entity_type: str, + ) -> None: + """Kick off background prefetch when nearing the end of the queue.""" + if entity_type not in self._RADIO_ENTITY_TYPES: + return + if not self._yandex_provider or not playable_list: + return + # second-to-last or last — trigger prefetch near end of queue + if current_index < len(playable_list) - 2: + return + # Already prefetched or prefetch in progress + if self._prefetched_list is not None: + return + if self._prefetch_task and not self._prefetch_task.done(): + return + + self.logger.info( + "Pre-fetching tracks (at index %d/%d, entity=%s)", + current_index, + len(playable_list), + entity_id[:40] if entity_id else "", + ) + + async def _do_prefetch() -> None: + result = await self._replenish_radio_queue(entity_id, entity_type, playable_list) + if result: + self._prefetched_list = result + # Push expanded queue to Ynison immediately so the YM app + # sees upcoming tracks and enables the "next" button. + await self._update_queue_list(result) + + self._prefetch_task = self.mass.create_task(_do_prefetch()) + + async def _signal_track_completion(self) -> None: + """Signal that the current track finished playing. + + Ynison is a state-sync protocol — the active device must advance + current_playable_index itself. + + If the next index is within the playable list, we advance immediately. + If we're at the end (typical for RADIO/wave with short queues), + we fetch more tracks via the Yandex Music API, append them to the + playable_list, and then advance. + """ + if not self._ynison: + return + state = self._ynison.state + duration = self._best_duration_ms() + queue = state.player_state.get("player_queue", {}) + current_index = queue.get("current_playable_index", 0) + playable_list = queue.get("playable_list", []) + entity_type = queue.get("entity_type", "") + entity_id = queue.get("entity_id", "") + next_index = current_index + 1 + + self.logger.info( + "Track finished at index %d/%d (entity=%s type=%s), " + "advancing to index %d (duration=%dms)", + current_index, + len(playable_list), + entity_id[:40] if entity_id else "", + entity_type, + next_index, + duration, + ) + self._actual_duration_ms = 0 + + # 1. Report that playback reached the end. + # Echo tracking is handled by _send_progress_to_ynison. + await self._send_progress_to_ynison( + progress_ms=duration, duration_ms=duration, paused=False + ) + + if next_index < len(playable_list): + # 2a. Queue has room — advance immediately. + # Clear stale prefetch data so _maybe_prefetch can trigger for + # the new queue tail on subsequent state updates. + self._prefetched_list = None + await self._advance_queue_index(next_index) + elif entity_type in self._RADIO_ENTITY_TYPES: + # 2b. At end of RADIO queue — use prefetched data or fetch now + expanded: list[dict[str, Any]] | None = None + if self._prefetched_list: + self.logger.info("Using pre-fetched queue (%d items)", len(self._prefetched_list)) + expanded = self._prefetched_list + self._prefetched_list = None + elif self._prefetch_task and not self._prefetch_task.done(): + self.logger.info("Waiting for in-flight prefetch...") + await self._prefetch_task + expanded = self._prefetched_list + self._prefetched_list = None + else: + expanded = await self._replenish_radio_queue(entity_id, entity_type, playable_list) + if expanded and next_index < len(expanded): + await self._advance_queue_index(next_index, expanded_list=expanded) + elif expanded: + self.logger.warning( + "Expanded queue has %d items but next_index=%d — re-fetching", + len(expanded), + next_index, + ) + fresh = await self._replenish_radio_queue(entity_id, entity_type, expanded) + if fresh and next_index < len(fresh): + await self._advance_queue_index(next_index, expanded_list=fresh) + else: + self.logger.warning("Still cannot advance after re-fetch") + else: + self.logger.warning( + "Could not replenish queue (entity=%s type=%s), cannot advance", + entity_id, + entity_type, + ) + else: + self.logger.info( + "End of non-radio queue (entity=%s type=%s), playback complete", + entity_id[:40] if entity_id else "", + entity_type, + ) + + async def _replenish_radio_queue( + self, + entity_id: str, + entity_type: str, + playable_list: list[dict[str, Any]], + ) -> list[dict[str, Any]] | None: + """Fetch more tracks from Yandex Music API and return expanded playable_list. + + The active device is responsible for replenishing RADIO/wave queues. + Ynison only syncs state — it does NOT generate new tracks. + """ + if not self._yandex_provider: + self.logger.warning("No yandex_music provider available for radio replenishment") + return None + + # Determine the last track ID for pagination + last_track_id: str | None = None + if playable_list: + last_track_id = playable_list[-1].get("playable_id") + + self.logger.info( + "Fetching more tracks for %s station %s (queue=%s)", + entity_type, + entity_id, + last_track_id, + ) + + try: + tracks, batch_id = await self._yandex_provider.get_rotor_station_tracks( + entity_id, queue=last_track_id + ) + except Exception: + self.logger.exception("Failed to fetch radio tracks for %s", entity_id) + return None + + if not tracks: + self.logger.warning("No tracks returned for station %s", entity_id) + return None + + # Determine the 'from' field from existing items + from_field = "" + if playable_list: + from_field = playable_list[0].get("from", "") + + # Convert tracks to Ynison playable_list format + new_items: list[dict[str, Any]] = [] + for track in tracks: + album_id = "" + if hasattr(track, "albums") and track.albums: + album_id = str(track.albums[0].id) if track.albums[0].id else "" + cover = "" + if hasattr(track, "cover_uri") and track.cover_uri: + cover = track.cover_uri + new_items.append( + { + "playable_id": str(track.id), + "album_id_optional": album_id, + "playable_type": "TRACK", + "from": from_field, + "title": track.title or "", + "cover_url_optional": cover, + } + ) + + self.logger.info( + "Fetched %d new tracks for station %s (batch=%s)", + len(new_items), + entity_id, + batch_id, + ) + + return list(playable_list) + new_items + + async def _advance_queue_index( + self, + next_index: int, + *, + expanded_list: list[dict[str, Any]] | None = None, + ) -> None: + """Send update_player_state to advance the queue to next_index. + + If expanded_list is provided, it replaces the playable_list + (used after radio queue replenishment). + + Waits up to 10 s for reconnection if Ynison is temporarily + disconnected (e.g. after a transient error). + """ + if not self._ynison: + return + if not self._ynison.connected: + self.logger.info("Waiting for Ynison reconnection before advancing queue…") + for _ in range(10): + await asyncio.sleep(1) + if not self._ynison or self._ynison.connected: + break + if not self._ynison or not self._ynison.connected: + self.logger.warning("Cannot advance queue — Ynison still disconnected") + return + state = self._ynison.state + queue = state.player_state.get("player_queue", {}) + new_state = dict(state.player_state) + new_state["player_queue"] = dict(queue) + new_state["player_queue"]["current_playable_index"] = next_index + if expanded_list is not None: + new_state["player_queue"]["playable_list"] = expanded_list + new_state["status"] = dict(new_state.get("status", {})) + new_state["status"]["progress_ms"] = 0 + new_state["status"]["duration_ms"] = 0 + new_state["status"]["paused"] = False + await self._ynison.update_player_state(player_state=new_state) + + async def _update_queue_list(self, expanded_list: list[dict[str, Any]]) -> None: + """Push an expanded playable_list to Ynison without changing index or progress. + + Called right after prefetch completes so the YM app sees upcoming + tracks and enables the "next" button. + """ + if not self._ynison or not self._ynison.connected: + return + state = self._ynison.state + queue = state.player_state.get("player_queue", {}) + new_state = dict(state.player_state) + new_state["player_queue"] = dict(queue) + new_state["player_queue"]["playable_list"] = expanded_list + await self._ynison.update_player_state(player_state=new_state) + + async def _on_next(self) -> None: + """Handle next track command — signal track end so Yandex advances.""" + if not self._ynison: + raise UnsupportedFeaturedException("Not connected to Ynison") + await self._signal_track_completion() + + async def _on_previous(self) -> None: + """Handle previous track command — update queue index in Ynison.""" + if not self._ynison: + raise UnsupportedFeaturedException("Not connected to Ynison") + queue = self._ynison.state.player_state.get("player_queue", {}) + current_index = queue.get("current_playable_index", 0) + if current_index > 0: + self._actual_duration_ms = 0 + await self._advance_queue_index(current_index - 1) + + async def _on_seek(self, position: int) -> None: + """Handle seek command — send position update to Ynison. + + :param position: Position in seconds from Music Assistant. + """ + if not self._ynison: + raise UnsupportedFeaturedException("Not connected to Ynison") + seek_ms = position * 1000 + state = self._ynison.state + await self._send_progress_to_ynison( + progress_ms=seek_ms, + duration_ms=self._best_duration_ms(), + paused=state.is_paused, + ) + # Also trigger local stream restart so seek takes effect + # immediately without waiting for the Ynison echo. + self._seek_position_ms = seek_ms + self._seek_grace_until = time.monotonic() + 5.0 + self._track_changed_event.set() diff --git a/music_assistant/providers/yandex_ynison/streaming.py b/music_assistant/providers/yandex_ynison/streaming.py new file mode 100644 index 0000000000..78a2b4fd5f --- /dev/null +++ b/music_assistant/providers/yandex_ynison/streaming.py @@ -0,0 +1,49 @@ +"""PCM normalization helpers for Yandex Music audio streams. + +Contains format profiles, the AudioFormat factory, and pacing-args builder. +""" + +from __future__ import annotations + +from typing import Any + +from music_assistant_models.enums import ContentType +from music_assistant_models.media_items import AudioFormat + +from .constants import PACING_REALTIME # noqa: F401 — re-exported for tests + +# PCM normalization profiles by YM quality tier. +# Ensures MA's single ffmpeg receives a consistent format between tracks. +# NOTE: AudioFormat is a *mutable* dataclass — MA's FFMpeg._log_reader_task +# mutates input_format.codec_type in-place. We MUST create a fresh copy for +# every place that stores a reference (PluginSource.audio_format, PreBuffer, +# ffmpeg output_format) so that mutation of one doesn't corrupt the others. +PCM_LOSSLESS_PARAMS: dict[str, Any] = { + "content_type": ContentType.PCM_S24LE, + "sample_rate": 48000, + "bit_depth": 24, + "channels": 2, +} +PCM_LOSSY_PARAMS: dict[str, Any] = { + "content_type": ContentType.PCM_S16LE, + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, +} + +# Override MA's default probesize (8096) and analyzeduration (500000). +# Flac-MP4 containers can have moov/stsd atoms beyond 8KB, and slow CDN +# responses over pipe input may cause analyseduration timeouts — both of +# which result in ffmpeg failing to detect the container and producing +# garbage PCM (white noise). +PROBE_ARGS: list[str] = ["-probesize", "65536", "-analyzeduration", "5000000"] + + +def make_pcm_format(params: dict[str, Any]) -> AudioFormat: + """Create a fresh AudioFormat from stored params (safe from mutation).""" + return AudioFormat(**params) + + +def pacing_args() -> list[str]: + """Return ffmpeg extra-input args for realtime pacing (-re).""" + return ["-re"] diff --git a/music_assistant/providers/yandex_ynison/yandex_auth.py b/music_assistant/providers/yandex_ynison/yandex_auth.py new file mode 100644 index 0000000000..abc3c472ac --- /dev/null +++ b/music_assistant/providers/yandex_ynison/yandex_auth.py @@ -0,0 +1,73 @@ +"""Yandex Passport QR authentication flow. + +Delegates all Passport interactions to the ``ya-passport-auth`` library. +This module exposes three helpers consumed by the provider: + +* ``perform_qr_auth`` — full QR login (UI popup → tokens) +* ``refresh_music_token`` — x_token → fresh music token +* ``validate_x_token`` — quick liveness check for an x_token +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from music_assistant_models.errors import LoginFailed +from ya_passport_auth import PassportClient, SecretStr +from ya_passport_auth.exceptions import QRTimeoutError, YaPassportError + +from music_assistant.helpers.auth import AuthenticationHelper + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + +_LOGGER = logging.getLogger(__name__) + + +async def perform_qr_auth(mass: MusicAssistant, session_id: str) -> tuple[str, str]: + """Perform full QR authentication flow. + + Opens a QR code popup via MA frontend, polls for scan confirmation, + then returns tokens as plain strings for MA config storage. + + Returns (x_token, music_token). + """ + try: + async with PassportClient.create() as client: + qr = await client.start_qr_login() + + async with AuthenticationHelper(mass, session_id) as auth_helper: + auth_helper.send_url(qr.qr_url) + creds = await client.poll_qr_until_confirmed(qr) + + x_token = creds.x_token.get_secret() + music_token = creds.music_token + if music_token is None: + raise LoginFailed("QR auth succeeded but no music token was returned") + + _LOGGER.debug("QR auth complete, obtained both tokens") + return x_token, music_token.get_secret() + + except QRTimeoutError as err: + raise LoginFailed("QR authentication timed out. Please try again.") from err + except YaPassportError as err: + raise LoginFailed(f"Yandex auth error: {err}") from err + + +async def refresh_music_token(x_token: SecretStr) -> SecretStr: + """Exchange an x_token for a fresh music-scoped OAuth token.""" + try: + async with PassportClient.create() as client: + return await client.refresh_music_token(x_token) + except YaPassportError as err: + raise LoginFailed(f"Failed to refresh music token: {err}") from err + + +async def validate_x_token(x_token: SecretStr) -> bool: + """Return True if *x_token* is still accepted by Yandex Passport.""" + try: + async with PassportClient.create() as client: + return bool(await client.validate_x_token(x_token)) + except YaPassportError: + return False diff --git a/music_assistant/providers/yandex_ynison/ynison_client.py b/music_assistant/providers/yandex_ynison/ynison_client.py new file mode 100644 index 0000000000..451bea35c1 --- /dev/null +++ b/music_assistant/providers/yandex_ynison/ynison_client.py @@ -0,0 +1,670 @@ +"""Ynison WebSocket client for Yandex Music device synchronization.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import random +import secrets +import uuid +from collections.abc import Awaitable, Callable +from contextlib import suppress +from dataclasses import asdict, dataclass, field +from typing import TYPE_CHECKING, Any + +import aiohttp +from music_assistant_models.errors import LoginFailed + +if TYPE_CHECKING: + from ya_passport_auth import SecretStr + +from .constants import ( + DEFAULT_APP_NAME, + DEFAULT_APP_VERSION, + DEVICE_TYPE_WEB, + MAX_RECONNECT_ATTEMPTS, + RECONNECT_DELAYS, + WS_CONNECT_TIMEOUT, + WS_HEARTBEAT, + YNISON_ORIGIN, + YNISON_REDIRECT_URL, + YNISON_STATE_PATH, +) + + +@dataclass +class YnisonDeviceInfo: + """Device identification for Ynison registration.""" + + device_id: str + title: str + type: str = DEVICE_TYPE_WEB + app_name: str = DEFAULT_APP_NAME + app_version: str = DEFAULT_APP_VERSION + + +@dataclass +class YnisonState: + """Parsed Ynison state from the server.""" + + player_state: dict[str, Any] = field(default_factory=dict) + active_device_id: str | None = None + devices: list[dict[str, Any]] = field(default_factory=list) + + @property + def current_track_id(self) -> str | None: + """Extract current track_id from player queue.""" + queue = self.player_state.get("player_queue", {}) + playable_list = queue.get("playable_list", []) + index = queue.get("current_playable_index", 0) + if playable_list and 0 <= index < len(playable_list): + playable_id = playable_list[index].get("playable_id") + if playable_id: + return str(playable_id) + return None + + @property + def is_paused(self) -> bool: + """Return True if playback is paused.""" + return bool(self.player_state.get("status", {}).get("paused", True)) + + @property + def progress_ms(self) -> int: + """Return current playback progress in milliseconds.""" + return int(self.player_state.get("status", {}).get("progress_ms", 0)) + + @property + def duration_ms(self) -> int: + """Return current track duration in milliseconds.""" + return int(self.player_state.get("status", {}).get("duration_ms", 0)) + + +# Type alias for the state update callback +StateUpdateCallback = Callable[[YnisonState], Awaitable[None]] +DisconnectCallback = Callable[[], Awaitable[None]] +# Callback invoked on auth failure; should return a fresh token (or raise). +AuthRefreshCallback = Callable[[], Awaitable["SecretStr"]] + + +class YnisonClient: + """WebSocket client for the Yandex Ynison protocol. + + Manages the two-step connection (redirector → state service) and + provides methods to send state updates back to Ynison. + """ + + def __init__( + self, + token: SecretStr, + device_info: YnisonDeviceInfo, + on_state_update: StateUpdateCallback, + on_disconnect: DisconnectCallback, + logger: logging.Logger, + http_session: aiohttp.ClientSession | None = None, + on_auth_failure: AuthRefreshCallback | None = None, + ) -> None: + """Initialize Ynison client. + + :param token: Yandex Music OAuth token (wrapped in SecretStr). + :param device_info: Device identification for Ynison. + :param on_state_update: Callback for state updates from Ynison. + :param on_disconnect: Callback when connection is permanently lost. + :param logger: Logger instance. + :param http_session: Optional shared aiohttp session. + :param on_auth_failure: Optional callback invoked on auth failure during + reconnect. Should return a fresh SecretStr token. If not provided or + if the callback raises, reconnect proceeds with the current token. + """ + self._token = token + self._device_info = device_info + self._on_state_update = on_state_update + self._on_disconnect = on_disconnect + self._logger = logger + self._external_session = http_session + self._on_auth_failure = on_auth_failure + + self._ws: aiohttp.ClientWebSocketResponse | None = None + self._session: aiohttp.ClientSession | None = None + self._send_lock = asyncio.Lock() + self._message_task: asyncio.Task[None] | None = None + self._reconnect_task: asyncio.Task[None] | None = None + self._stop_event = asyncio.Event() + self._connected = False + + # Latest state from server + self.state = YnisonState() + + @property + def connected(self) -> bool: + """Return True if connected to Ynison state service.""" + return self._connected + + async def connect(self) -> None: + """Connect to Ynison (redirector → state service). + + Raises on auth failure; auto-reconnects on transient errors. + """ + self._stop_event.clear() + if self._external_session and self._external_session.closed: + raise RuntimeError("Provided http_session is closed") + self._session = self._external_session or aiohttp.ClientSession() + + try: + # Step 1: Get redirect ticket + host, ticket, session_id = await self._get_redirect_ticket() + + # Step 2: Connect to state service + await self._connect_state(host, ticket, session_id) + except LoginFailed: + await self.disconnect() + raise + except asyncio.CancelledError: + await self.disconnect() + raise + except Exception: + # Transient error — schedule reconnect instead of dying + self._logger.warning("Initial connection failed, scheduling reconnect", exc_info=True) + self._connected = False + if self._ws and not self._ws.closed: + await self._ws.close() + self._ws = None + if self._session and not self._external_session: + await self._session.close() + self._session = None + if not self._stop_event.is_set() and ( + self._reconnect_task is None or self._reconnect_task.done() + ): + self._reconnect_task = asyncio.ensure_future(self._reconnect()) + + async def disconnect(self) -> None: + """Gracefully disconnect from Ynison.""" + self._stop_event.set() + self._connected = False + + if self._message_task and not self._message_task.done(): + self._message_task.cancel() + with suppress(asyncio.CancelledError): + await self._message_task + + if self._reconnect_task and not self._reconnect_task.done(): + self._reconnect_task.cancel() + with suppress(asyncio.CancelledError): + await self._reconnect_task + + if self._ws and not self._ws.closed: + await self._ws.close() + self._ws = None + + if self._session and not self._external_session: + await self._session.close() + self._session = None + + def update_token(self, token: SecretStr) -> None: + """Replace the stored OAuth token (e.g. after a refresh).""" + self._token = token + + # ------------------------------------------------------------------ + # Send methods + # ------------------------------------------------------------------ + + @staticmethod + def _message_meta() -> dict[str, Any]: + """Return common envelope fields for state-mutating messages.""" + return { + "rid": str(uuid.uuid4()), + "player_action_timestamp_ms": 0, + "activity_interception_type": "DO_NOT_INTERCEPT_BY_DEFAULT", + } + + async def update_playing_status(self, progress_ms: int, duration_ms: int, paused: bool) -> None: + """Send playback status update to Ynison.""" + self._logger.debug( + "→ update_playing_status: progress=%dms duration=%dms paused=%s", + progress_ms, + duration_ms, + paused, + ) + msg = { + "update_playing_status": { + "playing_status": { + "progress_ms": progress_ms, + "duration_ms": duration_ms, + "paused": paused, + "playback_speed": 1.0, + }, + }, + } + await self._send(msg) + + async def update_active_device(self, device_id: str) -> None: + """Request playback transfer to this device.""" + msg = { + "update_active_device": { + "device_id_optional": device_id, + }, + } + await self._send(msg) + + async def sync_state_from_eov(self, actual_queue_id: str = "") -> None: + """Request queue sync from the EOV (Unified Playback Queue) backend. + + Asks the Ynison server to refresh the queue from the central EOV service. + Only works when this device is the active player. If the EOV queue + differs from actual_queue_id, the server broadcasts the updated state. + + :param actual_queue_id: Current queue ID (empty string forces refresh). + """ + msg = { + "sync_state_from_eov": { + "actual_queue_id": actual_queue_id, + }, + **self._message_meta(), + } + self._logger.info("→ sync_state_from_eov: queue_id=%r", actual_queue_id) + await self._send(msg) + + async def update_player_state(self, player_state: dict[str, Any]) -> None: + """Send player state update (queue changes, track skip). + + Unlike send_full_state, this does NOT reset active device status. + Use this for track advances, queue modifications, repeat/shuffle changes. + """ + queue = player_state.get("player_queue", {}) + self._logger.info( + "→ update_player_state: index=%s queue_len=%d entity_type=%s", + queue.get("current_playable_index"), + len(queue.get("playable_list", [])), + queue.get("entity_type", ""), + ) + msg = { + "update_player_state": { + "player_state": player_state, + }, + **self._message_meta(), + } + self._logger.debug("Sending player state: %s", json.dumps(msg)[:500]) + await self._send(msg) + + async def send_full_state( + self, + player_state: dict[str, Any] | None = None, + ) -> None: + """Send full state update (cold start, reconnect after offline).""" + state = player_state or self._build_initial_state() + msg = { + "update_full_state": { + "player_state": state, + "device": self._build_device_dict(), + "is_currently_active": False, + }, + **self._message_meta(), + } + self._logger.debug("Sending full state: %s", json.dumps(msg)[:500]) + await self._send(msg) + + # ------------------------------------------------------------------ + # Connection internals + # ------------------------------------------------------------------ + + def _build_ws_protocol_header( + self, + redirect_ticket: str | None = None, + session_id: int | None = None, + ) -> str: + """Build Sec-WebSocket-Protocol header value.""" + proto: dict[str, Any] = { + "Ynison-Device-Id": self._device_info.device_id, + "Ynison-Device-Info": json.dumps({"app_name": self._device_info.app_name, "type": 1}), + } + if redirect_ticket is not None: + proto["Ynison-Redirect-Ticket"] = redirect_ticket + if session_id is not None: + proto["Ynison-Session-Id"] = str(session_id) + return f"Bearer, v2, {json.dumps(proto)}" + + def _build_headers( + self, + redirect_ticket: str | None = None, + session_id: int | None = None, + ) -> dict[str, str]: + """Build common WebSocket headers.""" + return { + "Authorization": f"OAuth {self._token.get_secret()}", + "Origin": YNISON_ORIGIN, + "Sec-WebSocket-Protocol": self._build_ws_protocol_header(redirect_ticket, session_id), + } + + def _build_device_dict(self) -> dict[str, Any]: + """Build device info dict for Ynison messages.""" + info = asdict(self._device_info) + return { + "info": info, + "capabilities": { + "can_be_player": True, + "can_be_remote_controller": False, + }, + "is_shadow": False, + } + + def _build_initial_state(self) -> dict[str, Any]: + """Build initial player state (paused, empty queue).""" + device_id = self._device_info.device_id + return { + "status": { + "paused": True, + "duration_ms": 0, + "progress_ms": 0, + "playback_speed": 1, + "version": { + "device_id": device_id, + "version": int(uuid.uuid4().int % (10**18)), + "timestamp_ms": 0, + }, + }, + "player_queue": { + "current_playable_index": -1, + "entity_id": "", + "entity_type": "VARIOUS", + "playable_list": [], + "options": {"repeat_mode": "NONE"}, + "entity_context": "BASED_ON_ENTITY_BY_DEFAULT", + "version": { + "device_id": device_id, + "version": int(uuid.uuid4().int % (10**18)), + "timestamp_ms": 0, + }, + "from_optional": "", + }, + } + + async def _get_redirect_ticket(self) -> tuple[str, str, int]: + """Connect to redirector and obtain redirect ticket. + + :return: (host, redirect_ticket, session_id) + :raises LoginFailed: If authentication fails. + """ + if self._session is None: + raise RuntimeError("HTTP session not initialized — call connect() first") + headers = self._build_headers() + + ws_timeout = aiohttp.ClientWSTimeout(ws_close=WS_CONNECT_TIMEOUT) + try: + ws = await self._session.ws_connect( + YNISON_REDIRECT_URL, + headers=headers, + timeout=ws_timeout, + ) + except aiohttp.WSServerHandshakeError as err: + if err.status in (401, 403): + raise LoginFailed("Ynison authentication failed — invalid token") from err + raise + + try: + msg = await ws.receive(timeout=WS_CONNECT_TIMEOUT) + if msg.type in (aiohttp.WSMsgType.TEXT, aiohttp.WSMsgType.BINARY): + data = json.loads(msg.data) + else: + raise ConnectionError(f"Unexpected message type from redirector: {msg.type}") + finally: + await ws.close() + + host = data.get("host", "") + ticket = data.get("redirect_ticket", "") + session_id = int(data.get("session_id", 0)) + + if not host or not ticket: + raise ConnectionError("Redirector response missing host or ticket") + + self._logger.debug("Ynison redirect: host=%s, session_id=%d", host, session_id) + return host, ticket, session_id + + async def _connect_state(self, host: str, ticket: str, session_id: int) -> None: + """Connect to Ynison state service and start message loop.""" + if self._session is None: + raise RuntimeError("HTTP session not initialized — call connect() first") + url = f"wss://{host}{YNISON_STATE_PATH}" + headers = self._build_headers(redirect_ticket=ticket, session_id=session_id) + + ws_timeout = aiohttp.ClientWSTimeout(ws_close=WS_CONNECT_TIMEOUT) + try: + self._ws = await self._session.ws_connect( + url, headers=headers, timeout=ws_timeout, heartbeat=WS_HEARTBEAT + ) + except aiohttp.WSServerHandshakeError as err: + if err.status in (401, 403): + raise LoginFailed("Ynison authentication failed — invalid token") from err + raise + self._connected = True + self._logger.info("Connected to Ynison state service at %s", host) + + # Send initial state + await self.send_full_state() + + # Start message loop + self._message_task = asyncio.ensure_future(self._message_loop()) + + async def _message_loop(self) -> None: + """Read messages from state service and dispatch callbacks.""" + if self._ws is None: + raise RuntimeError("WebSocket not connected — call connect() first") + try: + async for msg in self._ws: + if self._stop_event.is_set(): + break + + if msg.type == aiohttp.WSMsgType.ERROR: + msg_data_preview = str(self._ws.exception()) + elif not msg.data: + msg_data_preview = "" + elif isinstance(msg.data, str): + msg_data_preview = msg.data[:500] + elif isinstance(msg.data, bytes): + msg_data_preview = msg.data[:500].decode(errors="replace") + else: + msg_data_preview = str(msg.data) + + self._logger.debug( + "Ynison msg type=%s, data=%s", + msg.type, + msg_data_preview, + ) + + if msg.type == aiohttp.WSMsgType.TEXT: + try: + data = json.loads(msg.data) + except json.JSONDecodeError: + self._logger.warning( + "Failed to parse Ynison message: %s", + msg.data[:200] if msg.data else "", + ) + continue + + if "error" in data: + self._logger.warning( + "Ynison error response: %s", + json.dumps(data["error"])[:300], + ) + continue + + self._parse_state(data) + try: + await self._on_state_update(self.state) + except Exception: + self._logger.exception("Error in Ynison state update callback") + elif msg.type == aiohttp.WSMsgType.BINARY: + self._logger.debug( + "Ynison binary message (%d bytes)", len(msg.data) if msg.data else 0 + ) + elif msg.type == aiohttp.WSMsgType.ERROR: + self._logger.warning("Ynison WebSocket error: %s", self._ws.exception()) + break + elif msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.CLOSED, + ): + self._logger.debug( + "Ynison WS close: type=%s, close_code=%s, extra=%s", + msg.type, + self._ws.close_code, + msg.extra, + ) + break + except asyncio.CancelledError: + return + except Exception: + self._logger.exception("Unexpected error in Ynison message loop") + self._logger.debug("Ynison message loop exited") + + self._connected = False + + if not self._stop_event.is_set() and ( + self._reconnect_task is None or self._reconnect_task.done() + ): + self._logger.warning("Ynison connection lost, scheduling reconnect") + self._reconnect_task = asyncio.ensure_future(self._reconnect()) + + def _parse_state(self, data: dict[str, Any]) -> None: + """Parse PutYnisonStateResponse into YnisonState.""" + old_track = self.state.current_track_id + old_index = self.state.player_state.get("player_queue", {}).get( + "current_playable_index", -1 + ) + + # One-level-deep merge of player_state: Ynison sends sub-objects like + # "player_queue" and "status" as complete replacements, so shallow + # dict-union at the first nesting level is sufficient. Deeper recursion + # would risk merging stale list items (e.g. playable_list entries). + incoming_ps = data.get("player_state") + if incoming_ps is not None: + existing_ps = self.state.player_state + for key, value in incoming_ps.items(): + if isinstance(value, dict) and isinstance(existing_ps.get(key), dict): + existing_ps[key] = {**existing_ps[key], **value} + else: + existing_ps[key] = value + self.state.active_device_id = data.get( + "active_device_id_optional", self.state.active_device_id + ) + self.state.devices = data.get("devices", self.state.devices) + + new_track = self.state.current_track_id + queue = self.state.player_state.get("player_queue", {}) + new_index = queue.get("current_playable_index", -1) + queue_len = len(queue.get("playable_list", [])) + entity_type = queue.get("entity_type", "") + + if old_track != new_track or old_index != new_index: + self._logger.info( + "Ynison queue change: track %s→%s index %d→%d queue_len=%d entity_type=%s", + old_track, + new_track, + old_index, + new_index, + queue_len, + entity_type, + ) + else: + self._logger.debug( + "Ynison state update (no queue change): track=%s index=%d progress=%dms paused=%s", + new_track, + new_index, + self.state.progress_ms, + self.state.is_paused, + ) + + async def _reconnect(self) -> None: + """Reconnect with exponential backoff. + + On authentication failure (LoginFailed), attempts to refresh the token + via the on_auth_failure callback before the next retry. + """ + for attempt in range(MAX_RECONNECT_ATTEMPTS): + if self._stop_event.is_set(): + return + + delay = RECONNECT_DELAYS[min(attempt, len(RECONNECT_DELAYS) - 1)] + # Add ±20% jitter to prevent thundering-herd reconnects + jitter = delay * 0.2 * (2 * random.random() - 1) + delay = max(0.5, delay + jitter) + self._logger.info( + "Ynison reconnect attempt %d/%d in %.1fs", + attempt + 1, + MAX_RECONNECT_ATTEMPTS, + delay, + ) + await asyncio.sleep(delay) + + if self._stop_event.is_set(): + return + + try: + # Close stale WebSocket + if self._ws and not self._ws.closed: + await self._ws.close() + self._ws = None + + # Re-create session if needed + if self._session is None or self._session.closed: + if self._external_session is not None: + if self._external_session.closed: + msg = "External HTTP session is closed" + raise RuntimeError(msg) + self._session = self._external_session + else: + self._session = aiohttp.ClientSession() + + host, ticket, session_id = await self._get_redirect_ticket() + await self._connect_state(host, ticket, session_id) + self._logger.info("Ynison reconnected successfully") + return + except LoginFailed: + self._logger.warning("Ynison reconnect attempt %d failed: auth error", attempt + 1) + if self._on_auth_failure: + try: + new_token = await self._on_auth_failure() + self._token = new_token + self._logger.info("Token refreshed, will retry with new token") + except Exception: + self._logger.warning("Token refresh failed", exc_info=True) + except asyncio.CancelledError: + return + except Exception: + self._logger.warning( + "Ynison reconnect attempt %d failed", attempt + 1, exc_info=True + ) + + self._logger.error("Ynison: all %d reconnect attempts failed", MAX_RECONNECT_ATTEMPTS) + self._stop_event.set() + self._connected = False + if self._ws and not self._ws.closed: + with suppress(Exception): + await self._ws.close() + self._ws = None + if self._session and not self._external_session: + with suppress(Exception): + await self._session.close() + self._session = None + await self._on_disconnect() + + async def _send(self, msg: dict[str, Any]) -> None: + """Send a JSON message to the state service (thread-safe).""" + async with self._send_lock: + if self._ws is None or self._ws.closed: + self._logger.debug("Cannot send to Ynison — not connected") + return + try: + await self._ws.send_str(json.dumps(msg)) + except (ConnectionError, aiohttp.ClientError, RuntimeError, OSError): + self._logger.warning("Failed to send message to Ynison, scheduling reconnect") + self._connected = False + if not self._stop_event.is_set() and ( + self._reconnect_task is None or self._reconnect_task.done() + ): + self._reconnect_task = asyncio.ensure_future(self._reconnect()) + + +def generate_device_id() -> str: + """Generate a 16-character hex device ID for Ynison registration.""" + return secrets.token_hex(8) diff --git a/requirements_all.txt b/requirements_all.txt index b3af9bce43..14b56835f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -87,6 +87,7 @@ unidecode==1.4.0 uv>=0.8.0 websocket-client==1.9.0 xmltodict==1.0.4 +ya-passport-auth==1.2.3 yandex-music==2.2.0 ytmusicapi==1.11.5 zeroconf==0.148.0 diff --git a/tests/providers/yandex_ynison/__init__.py b/tests/providers/yandex_ynison/__init__.py new file mode 100644 index 0000000000..4efd321c07 --- /dev/null +++ b/tests/providers/yandex_ynison/__init__.py @@ -0,0 +1 @@ +"""Tests for the Yandex Ynison plugin.""" diff --git a/tests/providers/yandex_ynison/test_provider.py b/tests/providers/yandex_ynison/test_provider.py new file mode 100644 index 0000000000..003078fe5d --- /dev/null +++ b/tests/providers/yandex_ynison/test_provider.py @@ -0,0 +1,1918 @@ +"""Tests for the YandexYnisonProvider.""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from music_assistant_models.enums import ( + ContentType, + PlaybackState, + ProviderFeature, + ProviderType, + StreamType, +) +from music_assistant_models.errors import LoginFailed, UnsupportedFeaturedException +from ya_passport_auth import SecretStr + +from music_assistant.providers.yandex_ynison.config_helpers import find_sibling_token +from music_assistant.providers.yandex_ynison.constants import ( + CONF_ALLOW_PLAYER_SWITCH, + CONF_DEVICE_ID, + CONF_MASS_PLAYER_ID, + CONF_PUBLISH_NAME, + CONF_TOKEN, + CONF_X_TOKEN, + DEFAULT_DISPLAY_NAME, + PLAYER_ID_AUTO, +) +from music_assistant.providers.yandex_ynison.provider import ( + _API_MAX_RETRIES, + YandexYnisonProvider, +) +from music_assistant.providers.yandex_ynison.streaming import ( + PCM_LOSSLESS_PARAMS, + PCM_LOSSY_PARAMS, + make_pcm_format, +) +from music_assistant.providers.yandex_ynison.ynison_client import YnisonState + + +def _make_mock_config(values: dict[str, Any] | None = None) -> MagicMock: + """Create a mock ProviderConfig.""" + defaults: dict[str, Any] = { + CONF_TOKEN: "test-music-token", + CONF_X_TOKEN: None, + CONF_MASS_PLAYER_ID: PLAYER_ID_AUTO, + CONF_ALLOW_PLAYER_SWITCH: True, + CONF_PUBLISH_NAME: DEFAULT_DISPLAY_NAME, + CONF_DEVICE_ID: "test-device-uuid", + "log_level": "GLOBAL", + } + if values: + defaults.update(values) + config = MagicMock() + config.get_value.side_effect = defaults.get + return config + + +def _make_mock_mass() -> MagicMock: + """Create a mock MusicAssistant instance.""" + mass = MagicMock() + mass.cache_path = "/var/cache/test-cache" + + def _create_task(coro: object) -> MagicMock: + if asyncio.iscoroutine(coro): + coro.close() # prevent RuntimeWarning for unawaited coroutine + return MagicMock() + + mass.create_task = MagicMock(side_effect=_create_task) + mass.subscribe = MagicMock(return_value=MagicMock()) + mass.get_providers = MagicMock(return_value=[]) + mass.config.set_raw_provider_config_value = MagicMock() + + # Cache — return None (miss) by default + mass.cache.get = AsyncMock(return_value=None) + mass.cache.set = AsyncMock() + mass.cache.delete = AsyncMock() + + # Players + mass.players.all_players = MagicMock(return_value=[]) + mass.players.get_player = MagicMock(return_value=None) + mass.players.select_source = AsyncMock() + mass.players.cmd_stop = AsyncMock() + mass.players.cmd_volume_set = AsyncMock() + mass.players.trigger_player_update = MagicMock() + + return mass + + +def _make_mock_manifest() -> MagicMock: + """Create a mock ProviderManifest.""" + manifest = MagicMock() + manifest.domain = "yandex_ynison" + return manifest + + +def _make_provider(player_id: str = PLAYER_ID_AUTO) -> YandexYnisonProvider: + """Create a YandexYnisonProvider with mock dependencies.""" + mass = _make_mock_mass() + config = _make_mock_config({CONF_MASS_PLAYER_ID: player_id}) + manifest = _make_mock_manifest() + return YandexYnisonProvider(mass, manifest, config, {ProviderFeature.AUDIO_SOURCE}) + + +# ------------------------------------------------------------------ +# Provider init +# ------------------------------------------------------------------ + + +class TestProviderInit: + """Tests for provider initialization.""" + + def test_source_details(self) -> None: + """PluginSource should be configured correctly.""" + provider = _make_provider() + + source = provider.get_source() + assert source.stream_type == StreamType.CUSTOM + assert source.audio_format.content_type == ContentType.PCM_S16LE + assert source.audio_format.sample_rate == 44100 + assert source.audio_format.bit_depth == 16 + assert source.audio_format.channels == 2 + assert source.can_play_pause is False + assert source.can_seek is False + assert source.can_next_previous is False + assert source.on_select is not None + + def test_device_id_persisted(self) -> None: + """When no device_id in config, should generate and persist.""" + mass = _make_mock_mass() + config = _make_mock_config({CONF_DEVICE_ID: None}) + manifest = _make_mock_manifest() + + provider = YandexYnisonProvider(mass, manifest, config, {ProviderFeature.AUDIO_SOURCE}) + + # Should have generated a device ID and saved it + mass.config.set_raw_provider_config_value.assert_called() + assert provider._device_id # non-empty + + def test_existing_device_id_used(self) -> None: + """When device_id exists in config, should use it.""" + mass = _make_mock_mass() + config = _make_mock_config({CONF_DEVICE_ID: "existing-uuid"}) + manifest = _make_mock_manifest() + + provider = YandexYnisonProvider(mass, manifest, config, {ProviderFeature.AUDIO_SOURCE}) + + assert provider._device_id == "existing-uuid" + + +# ------------------------------------------------------------------ +# Player selection +# ------------------------------------------------------------------ + + +class TestPlayerSelection: + """Tests for _get_target_player_id.""" + + def test_auto_no_players(self) -> None: + """Auto mode returns None when no players available.""" + provider = _make_provider() + assert provider._get_target_player_id() is None + + def test_auto_with_playing_player(self) -> None: + """Auto mode selects the currently playing player.""" + provider = _make_provider() + + player1 = MagicMock() + player1.player_id = "player1" + player1.display_name = "Player 1" + player1.state.playback_state = PlaybackState.IDLE + + player2 = MagicMock() + player2.player_id = "player2" + player2.display_name = "Player 2" + player2.state.playback_state = PlaybackState.PLAYING + + provider.mass.players.all_players.return_value = [player1, player2] # type: ignore[attr-defined] + + assert provider._get_target_player_id() == "player2" + + def test_specific_player_exists(self) -> None: + """Returns configured player when it exists.""" + provider = _make_provider("my-player") + provider.mass.players.get_player.return_value = MagicMock() # type: ignore[attr-defined] + + assert provider._get_target_player_id() == "my-player" + + def test_specific_player_missing(self) -> None: + """Returns None when configured player no longer exists.""" + provider = _make_provider("gone-player") + provider.mass.players.get_player.return_value = None # type: ignore[attr-defined] + + assert provider._get_target_player_id() is None + + def test_active_player_takes_priority(self) -> None: + """Active player takes priority over auto selection.""" + provider = _make_provider() + provider._active_player_id = "active-one" + provider.mass.players.get_player.return_value = MagicMock() # type: ignore[attr-defined] + + assert provider._get_target_player_id() == "active-one" + + +# ------------------------------------------------------------------ +# Source selection +# ------------------------------------------------------------------ + + +class TestSourceSelection: + """Tests for _on_source_selected.""" + + async def test_on_source_selected_sets_active(self) -> None: + """Selecting source sets the active player.""" + provider = _make_provider() + + provider._source_details.in_use_by = "new-player" + await provider._on_source_selected() + assert provider._active_player_id == "new-player" + + async def test_on_source_selected_switching_disabled(self) -> None: + """Rejects source selection when player switching is disabled.""" + mass = _make_mock_mass() + config = _make_mock_config({CONF_ALLOW_PLAYER_SWITCH: False}) + manifest = _make_mock_manifest() + provider = YandexYnisonProvider(mass, manifest, config, {ProviderFeature.AUDIO_SOURCE}) + + # Set default player + provider._default_player_id = "default-player" + mass.players.get_player.return_value = MagicMock() + + provider._source_details.in_use_by = "other-player" + with pytest.raises(RuntimeError, match="Player switching is disabled"): + await provider._on_source_selected() + + # Should have rejected the switch and restored in_use_by + assert provider._active_player_id is None + assert provider._source_details.in_use_by == "default-player" + + +# ------------------------------------------------------------------ +# Clear active player +# ------------------------------------------------------------------ + + +class TestClearActivePlayer: + """Tests for _clear_active_player.""" + + def test_clears_state(self) -> None: + """Clearing active player resets state and triggers update.""" + provider = _make_provider() + + provider._active_player_id = "some-player" + provider._source_details.in_use_by = "some-player" + + provider._clear_active_player() + + assert provider._active_player_id is None + assert provider._source_details.in_use_by is None # type: ignore[unreachable] + provider.mass.players.trigger_player_update.assert_called_with("some-player") + + +# ------------------------------------------------------------------ +# Provider matching +# ------------------------------------------------------------------ + + +class TestProviderMatching: + """Tests for _check_yandex_provider_match.""" + + async def test_finds_yandex_music_provider(self) -> None: + """Links to Yandex Music provider and enables playback control.""" + provider = _make_provider() + + mock_ym = MagicMock() + mock_ym.domain = "yandex_music" + mock_ym.type = ProviderType.MUSIC + provider.mass.get_providers.return_value = [mock_ym] # type: ignore[attr-defined] + + await provider._check_yandex_provider_match() + + assert provider._yandex_provider is mock_ym + assert provider._source_details.can_play_pause is True + assert provider._source_details.on_play is not None + + async def test_no_matching_provider(self) -> None: + """No linked provider disables playback control.""" + provider = _make_provider() + + provider.mass.get_providers.return_value = [] # type: ignore[attr-defined] + await provider._check_yandex_provider_match() + + assert provider._yandex_provider is None + assert provider._source_details.can_play_pause is False + + +# ------------------------------------------------------------------ +# Ynison state handling +# ------------------------------------------------------------------ + + +class TestYnisonStateHandling: + """Tests for _handle_ynison_state.""" + + async def test_activates_on_our_device(self) -> None: + """Activates playback when Ynison reports our device as active.""" + provider = _make_provider() + + # Setup a target player + player = MagicMock() + player.player_id = "player1" + player.display_name = "Player 1" + provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] + provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + + state = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": {"paused": False, "progress_ms": 5000, "duration_ms": 200000}, + "player_queue": { + "current_playable_index": 0, + "playable_list": [{"playable_id": "track1"}], + }, + }, + ) + + await provider._handle_ynison_state(state) + + assert provider._active_player_id == "player1" + + async def test_clears_on_device_switch(self) -> None: + """Clears active player when device switches away.""" + provider = _make_provider() + + provider._active_player_id = "player1" + provider._source_details.in_use_by = "player1" + + state = YnisonState(active_device_id="other-device-id") + await provider._handle_ynison_state(state) + + assert provider._active_player_id is None + assert provider._source_details.in_use_by is None # type: ignore[unreachable] + + async def test_seek_detected_from_ynison(self) -> None: + """Detects seek from Yandex app via progress drift.""" + provider = _make_provider() + + player = MagicMock() + player.player_id = "player1" + provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] + provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + + def _make_state(progress_ms: int) -> YnisonState: + return YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": { + "paused": False, + "progress_ms": progress_ms, + "duration_ms": 200000, + }, + "player_queue": { + "current_playable_index": 0, + "playable_list": [{"playable_id": "track1"}], + }, + }, + ) + + # First state — track starts at 0ms + await provider._handle_ynison_state(_make_state(0)) + assert provider._current_streaming_track_id == "track1" # set eagerly on detection + + # Expire the grace period so the seek detection isn't suppressed + provider._seek_grace_until = 0.0 + + # Second state — seek to 60s (drift 60000ms > 2000ms) + await provider._handle_ynison_state(_make_state(60000)) + assert provider._seek_position_ms == 60000 + assert provider._track_changed_event.is_set() + + # Verify force_update=True was used so the server sends a full + # PLAYER_UPDATED event (not just a lightweight elapsed-time one) + provider.mass.players.trigger_player_update.assert_called_with("player1", force_update=True) # type: ignore[attr-defined] + + async def test_seek_grace_period_after_track_change(self) -> None: + """Seek detection is suppressed during grace period after track change.""" + provider = _make_provider() + + player = MagicMock() + player.player_id = "player1" + provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] + provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + + def _make_state(progress_ms: int) -> YnisonState: + return YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": { + "paused": False, + "progress_ms": progress_ms, + "duration_ms": 200000, + }, + "player_queue": { + "current_playable_index": 0, + "playable_list": [{"playable_id": "track1"}], + }, + }, + ) + + # Track starts — sets grace period + await provider._handle_ynison_state(_make_state(0)) + assert provider._seek_grace_until > 0 + + # Echo with progress=0 arrives during grace period — should NOT + # trigger seek even though drift calculation would exceed threshold + provider._track_changed_event.clear() + await provider._handle_ynison_state(_make_state(0)) + assert provider._seek_position_ms == 0 # unchanged + assert not provider._track_changed_event.is_set() # no false seek + + async def test_progress_throttled_update(self) -> None: + """Regular progress updates trigger player update with throttling.""" + provider = _make_provider() + + player = MagicMock() + player.player_id = "player1" + provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] + provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + + state = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": { + "paused": False, + "progress_ms": 5000, + "duration_ms": 200000, + }, + "player_queue": { + "current_playable_index": 0, + "playable_list": [{"playable_id": "track1"}], + }, + }, + ) + + # First call — significant (new track) → always triggers + await provider._handle_ynison_state(state) + call_count_1 = provider.mass.players.trigger_player_update.call_count # type: ignore[attr-defined] + + # Simulate same track still playing (no seek, no track change) + provider._last_sent_to_ynison_ms = 5000 + + state2 = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": { + "paused": False, + "progress_ms": 6000, + "duration_ms": 200000, + }, + "player_queue": { + "current_playable_index": 0, + "playable_list": [{"playable_id": "track1"}], + }, + }, + ) + + # Second call shortly after — throttled, no trigger + await provider._handle_ynison_state(state2) + call_count_2 = provider.mass.players.trigger_player_update.call_count # type: ignore[attr-defined] + + # Force the throttle to expire + provider._last_player_update_time = 0.0 + await provider._handle_ynison_state(state2) + call_count_3 = provider.mass.players.trigger_player_update.call_count # type: ignore[attr-defined] + + # First call triggered, second was throttled, third triggered + assert call_count_1 >= 1 + assert call_count_2 == call_count_1 + assert call_count_3 > call_count_2 + + # Regular (non-seek) updates should NOT use force_update + provider.mass.players.trigger_player_update.assert_called_with( # type: ignore[attr-defined] + "player1", force_update=False + ) + + async def test_duration_updated_from_stream_details(self) -> None: + """Duration is updated from stream_details and pushed to Ynison.""" + provider = _make_provider() + provider._source_details.in_use_by = "player1" + mock_ynison = MagicMock() + mock_ynison.update_playing_status = AsyncMock() + mock_ynison.state.is_paused = False + provider._ynison = mock_ynison + + stream_details = MagicMock() + stream_details.duration = 185 # seconds + + await provider._update_metadata_from_stream(stream_details, seek_ms=30000) + + meta = provider._source_details.metadata + assert meta is not None + assert meta.duration == 185 + assert meta.elapsed_time == 30 # 30000ms → 30s + assert provider._actual_duration_ms == 185000 + provider.mass.players.trigger_player_update.assert_called_once_with( # type: ignore[attr-defined] + "player1", force_update=True + ) + # Real duration pushed to Ynison + mock_ynison.update_playing_status.assert_awaited_once_with( + progress_ms=30000, duration_ms=185000, paused=False + ) + + async def test_signal_track_completion_advances_index(self) -> None: + """Track completion advances index and reports status.""" + provider = _make_provider() + mock_ynison = MagicMock() + mock_ynison.state = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": {"paused": False, "progress_ms": 180000, "duration_ms": 200000}, + "player_queue": { + "current_playable_index": 0, + "playable_list": [{"playable_id": "t1"}, {"playable_id": "t2"}], + "entity_id": "playlist:123", + "entity_type": "PLAYLIST", + }, + }, + ) + mock_ynison.update_playing_status = AsyncMock() + mock_ynison.update_player_state = AsyncMock() + provider._ynison = mock_ynison + + await provider._signal_track_completion() + + # 1. Reports progress=duration + mock_ynison.update_playing_status.assert_awaited_once_with( + progress_ms=200000, duration_ms=200000, paused=False + ) + # 2. Advances current_playable_index by 1 + call_args = mock_ynison.update_player_state.call_args + sent_state = call_args.kwargs["player_state"] + assert sent_state["player_queue"]["current_playable_index"] == 1 + assert sent_state["status"]["progress_ms"] == 0 + assert sent_state["status"]["paused"] is False + # Resets actual duration for next track + assert provider._actual_duration_ms == 0 + + async def test_signal_track_completion_no_send_full_state(self) -> None: + """Track completion never sends full state reset.""" + provider = _make_provider() + mock_ynison = MagicMock() + mock_ynison.state = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": {"paused": False, "progress_ms": 180000, "duration_ms": 200000}, + "player_queue": { + "current_playable_index": 0, + "playable_list": [{"playable_id": "t1"}, {"playable_id": "t2"}], + "entity_id": "playlist:123", + }, + }, + ) + mock_ynison.update_playing_status = AsyncMock() + mock_ynison.update_player_state = AsyncMock() + mock_ynison.send_full_state = AsyncMock() + provider._ynison = mock_ynison + + await provider._signal_track_completion() + + # Must NOT send full state reset + mock_ynison.send_full_state.assert_not_called() + + async def test_signal_track_completion_uses_actual_duration(self) -> None: + """Track completion prefers _actual_duration_ms over stale state.duration_ms.""" + provider = _make_provider() + provider._actual_duration_ms = 300000 + mock_ynison = MagicMock() + mock_ynison.state = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": {"paused": False, "progress_ms": 180000, "duration_ms": 200000}, + "player_queue": { + "current_playable_index": 0, + "playable_list": [{"playable_id": "t1"}, {"playable_id": "t2"}], + }, + }, + ) + mock_ynison.update_playing_status = AsyncMock() + mock_ynison.update_player_state = AsyncMock() + provider._ynison = mock_ynison + + await provider._signal_track_completion() + + mock_ynison.update_playing_status.assert_awaited_once_with( + progress_ms=300000, duration_ms=300000, paused=False + ) + + async def test_signal_track_completion_radio_replenishes_queue(self) -> None: + """At end of RADIO queue, fetches more tracks via YM API and advances.""" + provider = _make_provider() + mock_ynison = MagicMock() + mock_ynison.state = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": {"paused": False, "progress_ms": 200000, "duration_ms": 215000}, + "player_queue": { + "current_playable_index": 1, + "playable_list": [ + {"playable_id": "t1", "from": "radio-src"}, + {"playable_id": "t2", "from": "radio-src"}, + ], + "entity_id": "user:onyourwave", + "entity_type": "RADIO", + }, + }, + ) + mock_ynison.update_playing_status = AsyncMock() + mock_ynison.update_player_state = AsyncMock() + provider._ynison = mock_ynison + + # Mock YM provider returning new tracks + mock_track = MagicMock() + mock_track.id = "t3" + mock_track.title = "New Track" + mock_track.albums = [MagicMock(id="a3")] + mock_track.cover_uri = "cover3.jpg" + + mock_ym_provider = MagicMock() + mock_ym_provider.get_rotor_station_tracks = AsyncMock( + return_value=([mock_track], "batch-123") + ) + provider._yandex_provider = mock_ym_provider + + await provider._signal_track_completion() + + # Fetched tracks from station + mock_ym_provider.get_rotor_station_tracks.assert_awaited_once_with( + "user:onyourwave", queue="t2" + ) + # Advanced index to 2 with expanded playable_list + call_args = mock_ynison.update_player_state.call_args + sent_state = call_args.kwargs["player_state"] + assert sent_state["player_queue"]["current_playable_index"] == 2 + expanded = sent_state["player_queue"]["playable_list"] + assert len(expanded) == 3 + assert expanded[2]["playable_id"] == "t3" + assert expanded[2]["title"] == "New Track" + assert expanded[2]["from"] == "radio-src" + + async def test_signal_track_completion_radio_no_provider(self) -> None: + """At end of queue without YM provider, does not crash.""" + provider = _make_provider() + mock_ynison = MagicMock() + mock_ynison.state = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": {"paused": False, "progress_ms": 200000, "duration_ms": 215000}, + "player_queue": { + "current_playable_index": 1, + "playable_list": [ + {"playable_id": "t1"}, + {"playable_id": "t2"}, + ], + "entity_id": "user:onyourwave", + "entity_type": "RADIO", + }, + }, + ) + mock_ynison.update_playing_status = AsyncMock() + mock_ynison.update_player_state = AsyncMock() + provider._ynison = mock_ynison + provider._yandex_provider = None + + await provider._signal_track_completion() + + # Status reported + mock_ynison.update_playing_status.assert_awaited_once() + # Cannot advance — no provider to fetch tracks + mock_ynison.update_player_state.assert_not_called() + + async def test_prefetch_on_second_to_last_track(self) -> None: + """Pre-fetches tracks when playing second-to-last item in queue.""" + provider = _make_provider() + mock_ynison = MagicMock() + mock_ynison.connected = True + mock_ynison.update_player_state = AsyncMock() + # 4 tracks, currently at index 2 (second-to-last) + mock_ynison.state = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": {"paused": False, "progress_ms": 10000, "duration_ms": 200000}, + "player_queue": { + "current_playable_index": 2, + "playable_list": [ + {"playable_id": "t1", "from": "src"}, + {"playable_id": "t2", "from": "src"}, + {"playable_id": "t3", "from": "src"}, + {"playable_id": "t4", "from": "src"}, + ], + "entity_id": "user:onyourwave", + "entity_type": "RADIO", + }, + }, + ) + provider._ynison = mock_ynison + + mock_track = MagicMock() + mock_track.id = "t5" + mock_track.title = "Prefetched" + mock_track.albums = [MagicMock(id="a5")] + mock_track.cover_uri = "cover5.jpg" + + mock_ym_provider = MagicMock() + mock_ym_provider.get_rotor_station_tracks = AsyncMock( + return_value=([mock_track], "batch-pfx") + ) + provider._yandex_provider = mock_ym_provider + + # Use real create_task so prefetch coroutine actually runs + provider.mass.create_task = lambda coro: asyncio.get_event_loop().create_task(coro) # type: ignore[method-assign, assignment, misc] + + # Trigger prefetch + provider._maybe_prefetch( + 2, + mock_ynison.state.player_state["player_queue"]["playable_list"], + "user:onyourwave", + "RADIO", + ) + assert provider._prefetch_task is not None + await provider._prefetch_task + + # Prefetched list should contain old + new + assert provider._prefetched_list is not None + assert len(provider._prefetched_list) == 5 + assert provider._prefetched_list[4]["playable_id"] == "t5" + + async def test_signal_completion_uses_prefetched(self) -> None: + """Track completion uses pre-fetched data instead of making API call.""" + provider = _make_provider() + mock_ynison = MagicMock() + mock_ynison.state = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": {"paused": False, "progress_ms": 200000, "duration_ms": 215000}, + "player_queue": { + "current_playable_index": 3, + "playable_list": [ + {"playable_id": "t1"}, + {"playable_id": "t2"}, + {"playable_id": "t3"}, + {"playable_id": "t4"}, + ], + "entity_id": "user:onyourwave", + "entity_type": "RADIO", + }, + }, + ) + mock_ynison.update_playing_status = AsyncMock() + mock_ynison.update_player_state = AsyncMock() + provider._ynison = mock_ynison + + # Simulate pre-fetched data + prefetched = [ + {"playable_id": "t1"}, + {"playable_id": "t2"}, + {"playable_id": "t3"}, + {"playable_id": "t4"}, + {"playable_id": "t5"}, + ] + provider._prefetched_list = prefetched + + mock_ym_provider = MagicMock() + mock_ym_provider.get_rotor_station_tracks = AsyncMock() + provider._yandex_provider = mock_ym_provider + + await provider._signal_track_completion() + + # Should NOT have called API — used prefetched + mock_ym_provider.get_rotor_station_tracks.assert_not_awaited() + # Advanced with prefetched list + call_args = mock_ynison.update_player_state.call_args + sent_state = call_args.kwargs["player_state"] + assert sent_state["player_queue"]["current_playable_index"] == 4 + assert len(sent_state["player_queue"]["playable_list"]) == 5 + # Prefetch consumed + assert provider._prefetched_list is None + + async def test_best_duration_prefers_actual(self) -> None: + """_best_duration_ms prefers _actual_duration_ms over state.duration_ms.""" + provider = _make_provider() + mock_ynison = MagicMock() + mock_ynison.state = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": {"duration_ms": 200000}, + }, + ) + provider._ynison = mock_ynison + + # Fallback to state when actual is 0 + assert provider._best_duration_ms() == 200000 + + # Prefer actual when set + provider._actual_duration_ms = 300000 + assert provider._best_duration_ms() == 300000 + + # Without ynison, only actual + provider._ynison = None + assert provider._best_duration_ms() == 300000 + provider._actual_duration_ms = 0 + assert provider._best_duration_ms() == 0 + + async def test_wait_for_track_change_ignores_echo(self) -> None: + """_wait_for_track_change should ignore echoes and wait for actual change.""" + provider = _make_provider() + mock_ynison = MagicMock() + mock_ynison.state = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": {"progress_ms": 248000, "duration_ms": 248000}, + "player_queue": { + "current_playable_index": 5, + "playable_list": [{"playable_id": "old_track"}], + "entity_id": "user:onyourwave", + "entity_type": "RADIO", + }, + }, + ) + provider._ynison = mock_ynison + + async def simulate_echo_then_change() -> None: + await asyncio.sleep(0.01) + # First event: echo with same track (should be ignored) + provider._track_changed_event.set() + await asyncio.sleep(0.01) + # Second event: actual track change + mock_ynison.state = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": {"progress_ms": 0, "duration_ms": 0}, + "player_queue": { + "current_playable_index": 6, + "playable_list": [{"playable_id": "new_track"}], + "entity_id": "user:onyourwave", + "entity_type": "RADIO", + }, + }, + ) + provider._track_changed_event.set() + + asyncio.create_task(simulate_echo_then_change()) + result = await provider._wait_for_track_change("old_track", timeout=5.0) + assert result is True + + async def test_wait_for_track_change_timeout(self) -> None: + """_wait_for_track_change returns False on timeout.""" + provider = _make_provider() + mock_ynison = MagicMock() + mock_ynison.state = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": {"progress_ms": 248000}, + "player_queue": { + "current_playable_index": 5, + "playable_list": [{"playable_id": "old_track"}], + "entity_id": "user:onyourwave", + "entity_type": "RADIO", + }, + }, + ) + provider._ynison = mock_ynison + + result = await provider._wait_for_track_change("old_track", timeout=0.1) + assert result is False + + +# ------------------------------------------------------------------ +# ------------------------------------------------------------------ +# PCM normalization (per-track ffmpeg → adaptive PCM) +# ------------------------------------------------------------------ + + +class TestPCMNormalization: + """Tests for per-track ffmpeg normalization to PCM.""" + + async def test_stream_track_always_uses_ffmpeg(self) -> None: + """_stream_track always normalizes through ffmpeg, even without seek.""" + provider = _make_provider() + provider._source_details.in_use_by = "player1" + + mock_yandex = MagicMock() + sd = MagicMock() + sd.expiration = 600 + sd.duration = 200 + sd.audio_format = MagicMock() + mock_yandex.get_stream_details = AsyncMock(return_value=sd) + + async def _fake_audio_stream(_details: object) -> Any: + yield b"raw-cdn-data" + + mock_yandex.get_audio_stream = _fake_audio_stream + provider._yandex_provider = mock_yandex + + mock_ynison = MagicMock() + mock_ynison.update_playing_status = AsyncMock() + mock_ynison.state.is_paused = False + provider._ynison = mock_ynison + + async def _fake_ffmpeg(**_kwargs: object) -> Any: + yield b"pcm-normalized" + + with patch( + "music_assistant.providers.yandex_ynison.provider.get_ffmpeg_stream", + side_effect=_fake_ffmpeg, + ) as mock_ffmpeg: + collected: list[bytes] = [] + async for chunk in provider._stream_track("track:123"): + collected.append(chunk) + + assert collected == [b"pcm-normalized"] + mock_ffmpeg.assert_called_once() + call_kwargs = mock_ffmpeg.call_args + # Default (no YM provider linked) → lossy profile + assert call_kwargs.kwargs["output_format"] == provider._normalized_format + assert call_kwargs.kwargs["output_format"].content_type == ContentType.PCM_S16LE + # No seek args when seek_ms=0, but realtime pacing is always present + args = call_kwargs.kwargs.get("extra_input_args", []) + assert "-re" in args + assert "-ss" not in args + + async def test_stream_track_seek_adds_ss_arg(self) -> None: + """With seek > 0, _stream_track adds -ss to ffmpeg args.""" + provider = _make_provider() + provider._source_details.in_use_by = "player1" + + mock_yandex = MagicMock() + sd = MagicMock() + sd.expiration = 600 + sd.duration = 200 + sd.audio_format = MagicMock() + mock_yandex.get_stream_details = AsyncMock(return_value=sd) + + async def _fake_audio_stream(_details: object) -> Any: + yield b"raw-data" + + mock_yandex.get_audio_stream = _fake_audio_stream + provider._yandex_provider = mock_yandex + + mock_ynison = MagicMock() + mock_ynison.update_playing_status = AsyncMock() + mock_ynison.state.is_paused = False + provider._ynison = mock_ynison + + async def _fake_ffmpeg(**_kwargs: object) -> Any: + yield b"pcm-seeked" + + with patch( + "music_assistant.providers.yandex_ynison.provider.get_ffmpeg_stream", + side_effect=_fake_ffmpeg, + ) as mock_ffmpeg: + collected: list[bytes] = [] + async for chunk in provider._stream_track("track:123", seek_ms=5000): + collected.append(chunk) + + assert collected == [b"pcm-seeked"] + mock_ffmpeg.assert_called_once() + call_kwargs = mock_ffmpeg.call_args + args = call_kwargs.kwargs.get("extra_input_args", []) + assert "-ss" in args + assert "-re" in args + + async def test_default_format_is_pcm_s16le(self) -> None: + """Default PluginSource audio_format is PCM s16le (lossy profile).""" + provider = _make_provider() + source = provider.get_source() + assert source.audio_format.content_type == ContentType.PCM_S16LE + assert source.audio_format.sample_rate == 44100 + assert source.audio_format.bit_depth == 16 + assert source.audio_format.channels == 2 + + async def test_superb_quality_uses_lossless_profile(self) -> None: + """When YM quality=superb, format switches to PCM s24le/48kHz.""" + provider = _make_provider() + + mock_yandex = MagicMock() + mock_yandex.domain = "yandex_music" + mock_yandex.type = ProviderType.MUSIC + mock_yandex.get_quality = MagicMock(return_value="superb") + provider._yandex_provider = mock_yandex + provider._update_normalized_format() + + assert provider._normalized_format.content_type == ContentType.PCM_S24LE + assert provider._normalized_format.sample_rate == 48000 + assert provider._normalized_format.bit_depth == 24 + assert provider._source_details.audio_format == provider._normalized_format + + async def test_balanced_quality_uses_lossy_profile(self) -> None: + """When YM quality=balanced, format stays PCM s16le/44.1kHz.""" + provider = _make_provider() + + mock_yandex = MagicMock() + mock_yandex.domain = "yandex_music" + mock_yandex.type = ProviderType.MUSIC + mock_yandex.get_quality = MagicMock(return_value="balanced") + provider._yandex_provider = mock_yandex + provider._update_normalized_format() + + assert provider._normalized_format.content_type == ContentType.PCM_S16LE + assert provider._normalized_format.sample_rate == 44100 + assert provider._normalized_format.bit_depth == 16 + + async def test_audio_format_not_modified_by_stream(self) -> None: + """PluginSource audio_format stays fixed (not updated from stream).""" + provider = _make_provider() + provider._source_details.in_use_by = "player1" + + mock_yandex = MagicMock() + sd = MagicMock() + sd.expiration = 600 + sd.duration = 200 + sd.audio_format = MagicMock() # different format + mock_yandex.get_stream_details = AsyncMock(return_value=sd) + + async def _fake_audio_stream(_details: object) -> Any: + yield b"data" + + mock_yandex.get_audio_stream = _fake_audio_stream + provider._yandex_provider = mock_yandex + + mock_ynison = MagicMock() + mock_ynison.update_playing_status = AsyncMock() + mock_ynison.state.is_paused = False + provider._ynison = mock_ynison + + async def _fake_ffmpeg(**_kwargs: object) -> Any: + yield b"pcm" + + original_format = provider._normalized_format + + with patch( + "music_assistant.providers.yandex_ynison.provider.get_ffmpeg_stream", + side_effect=_fake_ffmpeg, + ): + async for _ in provider._stream_track("track:123"): + pass + + # audio_format should still be _normalized_format, not sd.audio_format + assert provider._source_details.audio_format is original_format + + async def test_stream_track_api_error_returns_empty(self) -> None: + """If get_stream_details fails, _stream_track yields nothing.""" + provider = _make_provider() + mock_yandex = MagicMock() + mock_yandex.get_stream_details = AsyncMock(side_effect=Exception("API error")) + provider._yandex_provider = mock_yandex + + collected: list[bytes] = [] + async for chunk in provider._stream_track("track:bad"): + collected.append(chunk) + + assert collected == [] + + +class TestResolveToken: + """Tests for _resolve_token self-healing logic.""" + + async def test_prefers_x_token_refresh(self) -> None: + """When x_token is available, refreshes music token instead of using stored one.""" + provider = _make_provider() + provider.config = _make_mock_config( + {CONF_TOKEN: "old-stored-token", CONF_X_TOKEN: "my-x-token"} + ) + + with patch( + "music_assistant.providers.yandex_ynison.provider.refresh_music_token", + new_callable=AsyncMock, + return_value=SecretStr("fresh-music-token"), + ) as mock_refresh: + result = await provider._resolve_token() + + assert result.get_secret() == "fresh-music-token" + mock_refresh.assert_awaited_once() + + async def test_falls_back_to_stored_on_refresh_failure(self) -> None: + """Falls back to stored token when x_token refresh fails with non-auth error.""" + provider = _make_provider() + provider.config = _make_mock_config( + {CONF_TOKEN: "old-stored-token", CONF_X_TOKEN: "my-x-token"} + ) + + with patch( + "music_assistant.providers.yandex_ynison.provider.refresh_music_token", + new_callable=AsyncMock, + side_effect=TimeoutError("network"), + ): + result = await provider._resolve_token() + + assert result.get_secret() == "old-stored-token" + + async def test_uses_stored_token_when_no_x_token(self) -> None: + """Returns stored music token when x_token is not configured.""" + provider = _make_provider() + provider.config = _make_mock_config({CONF_TOKEN: "stored", CONF_X_TOKEN: None}) + + result = await provider._resolve_token() + + assert result.get_secret() == "stored" + + async def test_raises_when_no_tokens(self) -> None: + """Raises LoginFailed when neither token is configured.""" + provider = _make_provider() + provider.config = _make_mock_config({CONF_TOKEN: None, CONF_X_TOKEN: None}) + + with pytest.raises(LoginFailed, match="No Yandex Music token"): + await provider._resolve_token() + + +# ------------------------------------------------------------------ +# Instance name postfix +# ------------------------------------------------------------------ + + +class TestInstanceNamePostfix: + """Tests for instance_name_postfix property.""" + + def test_returns_custom_display_name(self) -> None: + """Returns display_name when it differs from the default.""" + config = _make_mock_config({CONF_PUBLISH_NAME: "Living Room"}) + mass = _make_mock_mass() + manifest = _make_mock_manifest() + provider = YandexYnisonProvider(mass, manifest, config, {ProviderFeature.AUDIO_SOURCE}) + assert provider.instance_name_postfix == "Living Room" + + def test_returns_none_for_default_name(self) -> None: + """Returns None when display_name is the default (falls back to index).""" + provider = _make_provider() + assert provider.instance_name_postfix is None + + +# ------------------------------------------------------------------ +# Sibling token detection +# ------------------------------------------------------------------ + + +class TestSiblingTokenDetection: + """Tests for find_sibling_token.""" + + def test_finds_sibling_token(self) -> None: + """Detects token from an existing sibling ynison instance.""" + mass = _make_mock_mass() + mass.config.decrypt_string = lambda s: s + mass.config.get = MagicMock( + return_value={ + "inst1": { + "domain": "yandex_ynison", + "instance_id": "inst1", + "values": {CONF_TOKEN: "sibling-token", CONF_X_TOKEN: "sibling-x-token"}, + } + } + ) + token, x_token = find_sibling_token(mass, instance_id="inst2") + assert token == "sibling-token" + assert x_token == "sibling-x-token" + + def test_skips_own_instance(self) -> None: + """Does not reuse its own token.""" + mass = _make_mock_mass() + mass.config.get = MagicMock( + return_value={ + "inst1": { + "domain": "yandex_ynison", + "instance_id": "inst1", + "values": {CONF_TOKEN: "my-token"}, + } + } + ) + token, x_token = find_sibling_token(mass, instance_id="inst1") + assert token is None + assert x_token is None + + def test_returns_none_when_no_siblings(self) -> None: + """Returns None when no sibling instances exist.""" + mass = _make_mock_mass() + mass.config.get = MagicMock(return_value={}) + token, x_token = find_sibling_token(mass, instance_id=None) + assert token is None + assert x_token is None + + def test_skips_other_domains(self) -> None: + """Ignores instances from other provider domains.""" + mass = _make_mock_mass() + mass.config.get = MagicMock( + return_value={ + "inst1": { + "domain": "yandex_music", + "instance_id": "inst1", + "values": {CONF_TOKEN: "other-token"}, + } + } + ) + token, x_token = find_sibling_token(mass, instance_id=None) + assert token is None + assert x_token is None + + +class TestPCMFrameAlignment: + """Tests for PCM frame alignment padding in get_audio_stream.""" + + async def test_frame_alignment_padding_s24le(self) -> None: + """Verify padding math for s24le stereo (frame_size=6).""" + provider = _make_provider() + provider._normalized_format = make_pcm_format(PCM_LOSSLESS_PARAMS) + fmt = provider._normalized_format + frame_size = (fmt.bit_depth // 8) * fmt.channels + assert frame_size == 6 # 3 bytes x 2 channels + + # 4096 bytes yielded: 4096 % 6 = 4, need 2 bytes padding + bytes_yielded = 4096 + remainder = bytes_yielded % frame_size + assert remainder == 4 + pad = frame_size - remainder + assert pad == 2 + + async def test_frame_alignment_padding_s16le(self) -> None: + """Verify padding math for s16le stereo (frame_size=4).""" + provider = _make_provider() + provider._normalized_format = make_pcm_format(PCM_LOSSY_PARAMS) + fmt = provider._normalized_format + frame_size = (fmt.bit_depth // 8) * fmt.channels + assert frame_size == 4 # 2 bytes x 2 channels + + # 4096 is already aligned to 4 + assert 4096 % frame_size == 0 + + # 4097 needs 3 bytes padding + assert 4097 % frame_size == 1 + assert frame_size - (4097 % frame_size) == 3 + + async def test_no_padding_when_aligned(self) -> None: + """No padding needed when bytes_yielded is already frame-aligned.""" + fmt = make_pcm_format(PCM_LOSSLESS_PARAMS) + frame_size = (fmt.bit_depth // 8) * fmt.channels + # 6000 bytes = 1000 frames of s24le stereo + assert 6000 % frame_size == 0 + + +# ------------------------------------------------------------------ +# Playback controls +# ------------------------------------------------------------------ + + +def _make_ynison_state( + *, + progress_ms: int = 5000, + duration_ms: int = 120000, + paused: bool = False, + current_playable_index: int = 0, + playable_list: list[dict[str, Any]] | None = None, + device_id: str = "test-device-uuid", +) -> YnisonState: + """Build a YnisonState for control-flow tests.""" + if playable_list is None: + playable_list = [{"playable_id": "track1"}] + return YnisonState( + active_device_id=device_id, + player_state={ + "status": { + "paused": paused, + "progress_ms": progress_ms, + "duration_ms": duration_ms, + }, + "player_queue": { + "current_playable_index": current_playable_index, + "playable_list": playable_list, + }, + }, + ) + + +def _mock_ynison( + state: YnisonState | None = None, + connected: bool = True, +) -> MagicMock: + """Create a mock YnisonClient with sensible defaults.""" + mock = MagicMock() + mock.connected = connected + mock.state = state or _make_ynison_state() + mock.update_playing_status = AsyncMock() + mock.update_player_state = AsyncMock() + return mock + + +class TestPlaybackControls: + """Tests for _on_play, _on_pause, _on_next, _on_previous, _on_seek.""" + + async def test_on_play_sends_progress_unpaused(self) -> None: + """_on_play sends update_playing_status with paused=False.""" + provider = _make_provider() + provider._actual_duration_ms = 120000 + state = _make_ynison_state(progress_ms=5000, duration_ms=120000, paused=True) + mock_yn = _mock_ynison(state) + provider._ynison = mock_yn + + await provider._on_play() + + mock_yn.update_playing_status.assert_awaited_once_with( + progress_ms=5000, duration_ms=120000, paused=False + ) + + async def test_on_play_no_ynison_raises(self) -> None: + """_on_play raises when Ynison is not connected.""" + provider = _make_provider() + provider._ynison = None + + with pytest.raises(UnsupportedFeaturedException): + await provider._on_play() + + async def test_on_pause_sends_progress_paused(self) -> None: + """_on_pause sends update_playing_status with paused=True.""" + provider = _make_provider() + provider._actual_duration_ms = 120000 + state = _make_ynison_state(progress_ms=5000, duration_ms=120000, paused=False) + mock_yn = _mock_ynison(state) + provider._ynison = mock_yn + + await provider._on_pause() + + mock_yn.update_playing_status.assert_awaited_once_with( + progress_ms=5000, duration_ms=120000, paused=True + ) + + async def test_on_pause_no_ynison_raises(self) -> None: + """_on_pause raises when Ynison is not connected.""" + provider = _make_provider() + provider._ynison = None + + with pytest.raises(UnsupportedFeaturedException): + await provider._on_pause() + + async def test_on_next_calls_signal_completion(self) -> None: + """_on_next triggers _signal_track_completion.""" + provider = _make_provider() + state = _make_ynison_state( + progress_ms=180000, + duration_ms=200000, + playable_list=[{"playable_id": "t1"}, {"playable_id": "t2"}], + ) + mock_yn = _mock_ynison(state) + provider._ynison = mock_yn + + await provider._on_next() + + # Should have reported completion and advanced + mock_yn.update_playing_status.assert_awaited_once() + mock_yn.update_player_state.assert_awaited_once() + + async def test_on_next_no_ynison_raises(self) -> None: + """_on_next raises when Ynison is not connected.""" + provider = _make_provider() + provider._ynison = None + + with pytest.raises(UnsupportedFeaturedException): + await provider._on_next() + + async def test_on_previous_decrements_index(self) -> None: + """_on_previous decrements current_playable_index by 1.""" + provider = _make_provider() + state = _make_ynison_state( + current_playable_index=2, + playable_list=[ + {"playable_id": "t1"}, + {"playable_id": "t2"}, + {"playable_id": "t3"}, + ], + ) + mock_yn = _mock_ynison(state) + provider._ynison = mock_yn + + await provider._on_previous() + + mock_yn.update_player_state.assert_awaited_once() + sent = mock_yn.update_player_state.call_args.kwargs["player_state"] + assert sent["player_queue"]["current_playable_index"] == 1 + + async def test_on_previous_at_zero_no_op(self) -> None: + """_on_previous at index 0 does nothing.""" + provider = _make_provider() + state = _make_ynison_state(current_playable_index=0) + mock_yn = _mock_ynison(state) + provider._ynison = mock_yn + + await provider._on_previous() + + mock_yn.update_player_state.assert_not_called() + + async def test_on_previous_no_ynison_raises(self) -> None: + """_on_previous raises when Ynison is not connected.""" + provider = _make_provider() + provider._ynison = None + + with pytest.raises(UnsupportedFeaturedException): + await provider._on_previous() + + async def test_on_seek_updates_position(self) -> None: + """_on_seek sends progress and triggers local stream restart.""" + provider = _make_provider() + provider._actual_duration_ms = 200000 + state = _make_ynison_state(progress_ms=5000, duration_ms=200000, paused=False) + mock_yn = _mock_ynison(state) + provider._ynison = mock_yn + + await provider._on_seek(30) # 30 seconds + + assert provider._seek_position_ms == 30000 + assert provider._track_changed_event.is_set() + mock_yn.update_playing_status.assert_awaited_once_with( + progress_ms=30000, duration_ms=200000, paused=False + ) + + async def test_on_seek_no_ynison_raises(self) -> None: + """_on_seek raises when Ynison is not connected.""" + provider = _make_provider() + provider._ynison = None + + with pytest.raises(UnsupportedFeaturedException): + await provider._on_seek(10) + + +# ------------------------------------------------------------------ +# _send_progress_to_ynison +# ------------------------------------------------------------------ + + +class TestSendProgressToYnison: + """Tests for _send_progress_to_ynison.""" + + async def test_clamps_to_duration(self) -> None: + """Progress is clamped to duration_ms.""" + provider = _make_provider() + provider._ynison = _mock_ynison() + + await provider._send_progress_to_ynison(150000, 100000, False) + + provider._ynison.update_playing_status.assert_awaited_once_with( + progress_ms=100000, duration_ms=100000, paused=False + ) + + async def test_zero_duration_no_send(self) -> None: + """Does not send when duration is 0.""" + provider = _make_provider() + provider._ynison = _mock_ynison() + + await provider._send_progress_to_ynison(5000, 0, False) + + provider._ynison.update_playing_status.assert_not_called() + + async def test_not_connected_no_send(self) -> None: + """Does not send when Ynison is disconnected.""" + provider = _make_provider() + provider._ynison = _mock_ynison(connected=False) + + await provider._send_progress_to_ynison(5000, 10000, False) + + provider._ynison.update_playing_status.assert_not_called() + + async def test_records_echo_baseline(self) -> None: + """Records sent value and timestamp for echo detection.""" + provider = _make_provider() + provider._ynison = _mock_ynison() + + await provider._send_progress_to_ynison(5000, 10000, False) + + assert provider._last_sent_to_ynison_ms == 5000 + assert provider._last_sent_to_ynison_time > 0 + + async def test_no_ynison_no_send(self) -> None: + """Does not crash when _ynison is None.""" + provider = _make_provider() + provider._ynison = None + + await provider._send_progress_to_ynison(5000, 10000, False) + + # Should not have changed echo baseline + assert provider._last_sent_to_ynison_ms == -1 + + +# ------------------------------------------------------------------ +# _pause_playback +# ------------------------------------------------------------------ + + +class TestPausePlayback: + """Tests for _pause_playback.""" + + async def test_stops_stream_and_player(self) -> None: + """Pause stops stream, calls cmd_stop, resets echo baseline.""" + provider = _make_provider() + provider._streaming_progress_ms = 50000 + provider._source_details.in_use_by = "player1" + + await provider._pause_playback() + + assert provider._stream_stop_event.is_set() + provider.mass.players.cmd_stop.assert_awaited_once_with("player1") # type: ignore[attr-defined] + assert provider._source_details.in_use_by is None + # Progress is preserved for resume + assert provider._streaming_progress_ms == 50000 # type: ignore[unreachable] + # Echo baseline is reset + assert provider._last_sent_to_ynison_ms == -1 + + async def test_no_active_player(self) -> None: + """Pause with no active player just sets stop event.""" + provider = _make_provider() + provider._source_details.in_use_by = None + + await provider._pause_playback() + + assert provider._stream_stop_event.is_set() + provider.mass.players.cmd_stop.assert_not_called() # type: ignore[attr-defined] + + +# ------------------------------------------------------------------ +# _is_ynison_echo +# ------------------------------------------------------------------ + + +class TestIsYnisonEcho: + """Tests for _is_ynison_echo.""" + + def test_echo_within_window(self) -> None: + """Value within tolerance and time window is detected as echo.""" + provider = _make_provider() + provider._last_sent_to_ynison_ms = 5000 + provider._last_sent_to_ynison_time = time.monotonic() + + assert provider._is_ynison_echo(5200) is True + + def test_echo_outside_window(self) -> None: + """Value outside time window is not an echo.""" + provider = _make_provider() + provider._last_sent_to_ynison_ms = 5000 + provider._last_sent_to_ynison_time = time.monotonic() - 10 + + assert provider._is_ynison_echo(5200) is False + + def test_echo_never_sent(self) -> None: + """When no value was ever sent, nothing is an echo.""" + provider = _make_provider() + provider._last_sent_to_ynison_ms = -1 + + assert provider._is_ynison_echo(5000) is False + + def test_echo_large_diff(self) -> None: + """Large progress difference is not an echo.""" + provider = _make_provider() + provider._last_sent_to_ynison_ms = 5000 + provider._last_sent_to_ynison_time = time.monotonic() + + assert provider._is_ynison_echo(10000) is False + + +# ------------------------------------------------------------------ +# _sync_progress +# ------------------------------------------------------------------ + + +class TestSyncProgress: + """Tests for _sync_progress.""" + + async def test_updates_metadata_and_ynison(self) -> None: + """Sync updates MA metadata and sends progress to Ynison.""" + provider = _make_provider() + provider._actual_duration_ms = 200000 + provider._ynison = _mock_ynison() + provider._source_details.metadata = MagicMock() + + # 5 seconds of 44100Hz/16bit/2ch audio + byte_rate = 44100 * 2 * 2 + bytes_yielded = byte_rate * 5 + + await provider._sync_progress(0, bytes_yielded, "player1") + + meta = provider._source_details.metadata + assert meta.elapsed_time == 5 + provider.mass.players.trigger_player_update.assert_called_with("player1") # type: ignore[attr-defined] + provider._ynison.update_playing_status.assert_awaited_once() + + async def test_with_seek_offset(self) -> None: + """Seek offset is added to byte-based progress.""" + provider = _make_provider() + provider._actual_duration_ms = 200000 + provider._ynison = _mock_ynison() + provider._source_details.metadata = MagicMock() + + byte_rate = 44100 * 2 * 2 + bytes_yielded = byte_rate * 2 # 2 seconds of audio + seek_ms = 30000 + + await provider._sync_progress(seek_ms, bytes_yielded, "player1") + + meta = provider._source_details.metadata + # 30000ms + 2000ms = 32000ms → 32s + assert meta.elapsed_time == 32 + assert provider._streaming_progress_ms == 32000 + + async def test_no_player_id_skips_trigger(self) -> None: + """When player_id is None, does not trigger player update.""" + provider = _make_provider() + provider._actual_duration_ms = 200000 + provider._ynison = _mock_ynison() + provider._source_details.metadata = MagicMock() + + await provider._sync_progress(0, 0, None) + + provider.mass.players.trigger_player_update.assert_not_called() # type: ignore[attr-defined] + + +# ------------------------------------------------------------------ +# _bytes_to_ms +# ------------------------------------------------------------------ + + +class TestBytesToMs: + """Tests for _bytes_to_ms.""" + + def test_16bit(self) -> None: + """16-bit stereo 44100Hz: 176400 bytes = 1000ms.""" + provider = _make_provider() + # Default format is 44100/16/2 + assert provider._bytes_to_ms(176400) == 1000 + + def test_24bit(self) -> None: + """24-bit stereo 48000Hz: 288000 bytes = 1000ms.""" + provider = _make_provider() + provider._normalized_format = make_pcm_format(PCM_LOSSLESS_PARAMS) + assert provider._bytes_to_ms(288000) == 1000 + + def test_zero(self) -> None: + """Zero bytes = zero milliseconds.""" + provider = _make_provider() + assert provider._bytes_to_ms(0) == 0 + + +# ------------------------------------------------------------------ +# _get_stream_details_with_retry +# ------------------------------------------------------------------ + + +@pytest.mark.asyncio +class TestGetStreamDetailsWithRetry: + """Tests for _get_stream_details_with_retry.""" + + async def test_success_first_attempt(self) -> None: + """Returns stream details on first try and caches result.""" + provider = _make_provider() + mock_yp = MagicMock() + sd = MagicMock() + sd.expiration = 600 + sd.to_dict.return_value = {"track_id": "t1"} + sd.data = {"url": "https://cdn.example.com/audio.mp3", "decryption_key": "abc"} + mock_yp.get_stream_details = AsyncMock(return_value=sd) + provider._yandex_provider = mock_yp + + result = await provider._get_stream_details_with_retry("t1") + assert result is sd + mock_yp.get_stream_details.assert_awaited_once() + # Verify cache.set was called with data field preserved + provider.mass.cache.set.assert_awaited_once() # type: ignore[attr-defined] + cached_value = provider.mass.cache.set.call_args[0][1] # type: ignore[attr-defined] + assert cached_value["data"] == sd.data + + async def test_cache_hit_skips_api(self) -> None: + """Returns cached stream details without API call.""" + provider = _make_provider() + cached_sd = MagicMock() + cached_sd.expiration = 600 + provider.mass.cache.get = AsyncMock(return_value=cached_sd) # type: ignore[method-assign] + mock_yp = MagicMock() + mock_yp.get_stream_details = AsyncMock() + provider._yandex_provider = mock_yp + + result = await provider._get_stream_details_with_retry("t1") + assert result is cached_sd + mock_yp.get_stream_details.assert_not_awaited() + + async def test_retries_on_failure(self) -> None: + """Retries on transient error, succeeds on second attempt.""" + provider = _make_provider() + mock_yp = MagicMock() + sd = MagicMock() + sd.expiration = 600 + sd.to_dict.return_value = {"track_id": "t1"} + mock_yp.get_stream_details = AsyncMock(side_effect=[RuntimeError("transient"), sd]) + provider._yandex_provider = mock_yp + + with patch( + "music_assistant.providers.yandex_ynison.provider.asyncio.sleep", new_callable=AsyncMock + ): + result = await provider._get_stream_details_with_retry("t1") + assert result is sd + assert mock_yp.get_stream_details.await_count == 2 + + async def test_raises_after_max_retries(self) -> None: + """Raises RuntimeError after all retries exhausted.""" + provider = _make_provider() + mock_yp = MagicMock() + mock_yp.get_stream_details = AsyncMock(side_effect=RuntimeError("always fails")) + provider._yandex_provider = mock_yp + + with ( + patch( + "music_assistant.providers.yandex_ynison.provider.asyncio.sleep", + new_callable=AsyncMock, + ), + pytest.raises(RuntimeError, match="failed after"), + ): + await provider._get_stream_details_with_retry("t1") + assert mock_yp.get_stream_details.await_count == _API_MAX_RETRIES + + async def test_cancellation_not_retried(self) -> None: + """CancelledError propagates immediately, no retry.""" + provider = _make_provider() + mock_yp = MagicMock() + mock_yp.get_stream_details = AsyncMock(side_effect=asyncio.CancelledError()) + provider._yandex_provider = mock_yp + + with pytest.raises(asyncio.CancelledError): + await provider._get_stream_details_with_retry("t1") + mock_yp.get_stream_details.assert_awaited_once() + + +# ------------------------------------------------------------------ +# _advance_queue_index +# ------------------------------------------------------------------ + + +class TestAdvanceQueueIndex: + """Tests for _advance_queue_index.""" + + async def test_sends_state(self) -> None: + """Advances queue index and sends new state.""" + provider = _make_provider() + state = _make_ynison_state( + current_playable_index=0, + playable_list=[{"playable_id": "t1"}, {"playable_id": "t2"}], + ) + mock_yn = _mock_ynison(state) + provider._ynison = mock_yn + + await provider._advance_queue_index(3) + + mock_yn.update_player_state.assert_awaited_once() + sent = mock_yn.update_player_state.call_args.kwargs["player_state"] + assert sent["player_queue"]["current_playable_index"] == 3 + assert sent["status"]["progress_ms"] == 0 + assert sent["status"]["duration_ms"] == 0 + assert sent["status"]["paused"] is False + + async def test_with_expanded_list(self) -> None: + """Expanded list replaces playable_list in sent state.""" + provider = _make_provider() + state = _make_ynison_state( + playable_list=[{"playable_id": "t1"}], + ) + mock_yn = _mock_ynison(state) + provider._ynison = mock_yn + + expanded = [{"playable_id": "t1"}, {"playable_id": "t2"}] + await provider._advance_queue_index(1, expanded_list=expanded) + + sent = mock_yn.update_player_state.call_args.kwargs["player_state"] + assert sent["player_queue"]["playable_list"] == expanded + + async def test_not_connected_waits_then_sends(self) -> None: + """Waits for reconnection before sending state.""" + provider = _make_provider() + state = _make_ynison_state() + mock_yn = _mock_ynison(state, connected=False) + provider._ynison = mock_yn + + call_count = 0 + + def _get_connected(_self: object) -> bool: + nonlocal call_count + call_count += 1 + # Reconnect after 2 checks + return call_count > 2 + + type(mock_yn).connected = property(_get_connected) + + await provider._advance_queue_index(1) + + mock_yn.update_player_state.assert_awaited_once() + + async def test_timeout_no_send(self) -> None: + """Gives up after timeout when Ynison stays disconnected.""" + provider = _make_provider() + state = _make_ynison_state() + mock_yn = _mock_ynison(state, connected=False) + provider._ynison = mock_yn + + # Patch asyncio.sleep to skip real waiting + with patch("asyncio.sleep", new_callable=AsyncMock): + await provider._advance_queue_index(1) + + mock_yn.update_player_state.assert_not_called() + + async def test_no_ynison_returns(self) -> None: + """Returns immediately when _ynison is None.""" + provider = _make_provider() + provider._ynison = None + + await provider._advance_queue_index(1) + # No crash, no calls + + +# ------------------------------------------------------------------ +# _activate_playback +# ------------------------------------------------------------------ + + +class TestActivatePlayback: + """Tests for _activate_playback.""" + + async def test_selects_source_on_new_player(self) -> None: + """Selects source on target player when not yet active.""" + provider = _make_provider() + provider._active_player_id = None + + player = MagicMock() + player.player_id = "player1" + player.display_name = "Player 1" + player.state.playback_state = PlaybackState.IDLE + provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] + provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + + state = _make_ynison_state(progress_ms=0, paused=False) + + await provider._activate_playback(state) + + assert provider._active_player_id == "player1" + provider.mass.create_task.assert_called() # type: ignore[attr-defined] + + async def test_detects_track_change(self) -> None: + """Detects track change and updates streaming track id.""" + provider = _make_provider() + provider._current_streaming_track_id = "track1" + + player = MagicMock() + player.player_id = "player1" + provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] + provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + provider._active_player_id = "player1" + + state = _make_ynison_state( + progress_ms=0, + paused=False, + playable_list=[{"playable_id": "track2"}], + ) + + await provider._activate_playback(state) + + assert provider._current_streaming_track_id == "track2" + assert provider._track_changed_event.is_set() + + async def test_resume_after_pause(self) -> None: + """Resume after pause triggers reselect and seek.""" + provider = _make_provider() + provider._active_player_id = "player1" + provider._current_streaming_track_id = "track1" + provider._stream_stop_event.set() # simulate paused + + player = MagicMock() + player.player_id = "player1" + provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + + state = _make_ynison_state( + progress_ms=50000, + paused=False, + playable_list=[{"playable_id": "track1"}], + ) + + await provider._activate_playback(state) + + assert provider._seek_position_ms == 50000 + assert provider._track_changed_event.is_set() + + async def test_no_target_player_returns(self) -> None: + """Returns early when no target player is available.""" + provider = _make_provider() + provider.mass.players.all_players.return_value = [] # type: ignore[attr-defined] + provider.mass.players.get_player.return_value = None # type: ignore[attr-defined] + + state = _make_ynison_state() + + await provider._activate_playback(state) + + assert provider._active_player_id is None + + +class TestInvalidateStreamCache: + """Tests for _invalidate_stream_cache method.""" + + async def test_deletes_cache_entry(self) -> None: + """_invalidate_stream_cache calls mass.cache.delete.""" + provider = _make_provider() + provider.mass.cache = MagicMock() + provider.mass.cache.delete = AsyncMock() + + await provider._invalidate_stream_cache("track:42") + + provider.mass.cache.delete.assert_called_once_with( + "ynison_sd_track:42", + provider=provider.instance_id, + ) diff --git a/tests/providers/yandex_ynison/test_streaming.py b/tests/providers/yandex_ynison/test_streaming.py new file mode 100644 index 0000000000..ba93eb31b4 --- /dev/null +++ b/tests/providers/yandex_ynison/test_streaming.py @@ -0,0 +1,83 @@ +"""Tests for provider/streaming.py — PCM helpers.""" + +from __future__ import annotations + +from music_assistant_models.enums import ContentType +from music_assistant_models.media_items import AudioFormat + +from music_assistant.providers.yandex_ynison.streaming import ( + PCM_LOSSLESS_PARAMS, + PCM_LOSSY_PARAMS, + make_pcm_format, +) + +# --------------------------------------------------------------- +# make_pcm_format +# --------------------------------------------------------------- + + +class TestMakePcmFormat: + """Tests for the AudioFormat factory.""" + + def test_lossless_format(self) -> None: + """Lossless params produce s24le/48kHz/24bit/stereo.""" + fmt = make_pcm_format(PCM_LOSSLESS_PARAMS) + assert isinstance(fmt, AudioFormat) + assert fmt.content_type == ContentType.PCM_S24LE + assert fmt.sample_rate == 48000 + assert fmt.bit_depth == 24 + assert fmt.channels == 2 + + def test_lossy_format(self) -> None: + """Lossy params produce s16le/44.1kHz/16bit/stereo.""" + fmt = make_pcm_format(PCM_LOSSY_PARAMS) + assert isinstance(fmt, AudioFormat) + assert fmt.content_type == ContentType.PCM_S16LE + assert fmt.sample_rate == 44100 + assert fmt.bit_depth == 16 + assert fmt.channels == 2 + + def test_returns_fresh_instances(self) -> None: + """Each call must return a NEW AudioFormat to prevent mutation leaks.""" + fmt1 = make_pcm_format(PCM_LOSSY_PARAMS) + fmt2 = make_pcm_format(PCM_LOSSY_PARAMS) + assert fmt1 is not fmt2 + + def test_custom_params(self) -> None: + """Custom params (22050Hz, mono) create matching format.""" + params = { + "content_type": ContentType.PCM_S16LE, + "sample_rate": 22050, + "bit_depth": 16, + "channels": 1, + } + fmt = make_pcm_format(params) + assert fmt.sample_rate == 22050 + assert fmt.channels == 1 + + +# --------------------------------------------------------------- +# Constants +# --------------------------------------------------------------- + + +class TestConstants: + """Verify PCM param dicts.""" + + def test_pcm_lossless_keys(self) -> None: + """Lossless dict has all required keys.""" + assert set(PCM_LOSSLESS_PARAMS.keys()) == { + "content_type", + "sample_rate", + "bit_depth", + "channels", + } + + def test_pcm_lossy_keys(self) -> None: + """Lossy dict has all required keys.""" + assert set(PCM_LOSSY_PARAMS.keys()) == { + "content_type", + "sample_rate", + "bit_depth", + "channels", + } diff --git a/tests/providers/yandex_ynison/test_yandex_auth.py b/tests/providers/yandex_ynison/test_yandex_auth.py new file mode 100644 index 0000000000..e3d64a548b --- /dev/null +++ b/tests/providers/yandex_ynison/test_yandex_auth.py @@ -0,0 +1,254 @@ +"""Unit tests for yandex_auth.py (ya-passport-auth based authentication).""" + +from __future__ import annotations + +from unittest import mock + +import pytest +from music_assistant_models.errors import LoginFailed +from ya_passport_auth import Credentials, QrSession, SecretStr +from ya_passport_auth.exceptions import ( + InvalidCredentialsError, + QRTimeoutError, + RateLimitedError, +) +from ya_passport_auth.exceptions import ( + NetworkError as PassportNetworkError, +) + +from music_assistant.providers.yandex_ynison.yandex_auth import ( + perform_qr_auth, + refresh_music_token, + validate_x_token, +) + +# -- helpers ------------------------------------------------------------------- + + +def _make_credentials( + x_token: str = "test_x_token", # noqa: S107 + music_token: str | None = "test_music_token", # noqa: S107 +) -> Credentials: + """Build a Credentials dataclass for testing.""" + return Credentials( + x_token=SecretStr(x_token), + music_token=SecretStr(music_token) if music_token else None, + ) + + +def _make_qr_session() -> QrSession: + """Build a QrSession for testing.""" + return QrSession( + track_id="track123", + csrf_token="csrf_abc", + qr_url="https://passport.yandex.ru/auth/magic/code/?track_id=track123", + ) + + +# -- perform_qr_auth ---------------------------------------------------------- + + +async def test_perform_qr_auth_success() -> None: + """QR flow returns (x_token, music_token) as plain strings.""" + qr = _make_qr_session() + creds = _make_credentials() + mock_client = mock.AsyncMock() + mock_client.start_qr_login.return_value = qr + mock_client.poll_qr_until_confirmed.return_value = creds + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_ynison.yandex_auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_ynison.yandex_auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + x_token, music_token = await perform_qr_auth(mock_mass, "session_1") + + assert x_token == "test_x_token" + assert music_token == "test_music_token" + mock_client.start_qr_login.assert_awaited_once() + mock_client.poll_qr_until_confirmed.assert_awaited_once_with(qr) + + +async def test_perform_qr_auth_sends_qr_url() -> None: + """QR URL is sent to the AuthenticationHelper.""" + qr = _make_qr_session() + creds = _make_credentials() + mock_client = mock.AsyncMock() + mock_client.start_qr_login.return_value = qr + mock_client.poll_qr_until_confirmed.return_value = creds + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_ynison.yandex_auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_ynison.yandex_auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + await perform_qr_auth(mock_mass, "session_1") + + mock_auth_helper.__aenter__.return_value.send_url.assert_called_once_with(qr.qr_url) + + +async def test_perform_qr_auth_timeout_raises_login_failed() -> None: + """QRTimeoutError from library is mapped to LoginFailed.""" + qr = _make_qr_session() + mock_client = mock.AsyncMock() + mock_client.start_qr_login.return_value = qr + mock_client.poll_qr_until_confirmed.side_effect = QRTimeoutError("timed out") + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_ynison.yandex_auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_ynison.yandex_auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="timed out"): + await perform_qr_auth(mock_mass, "session_1") + + +async def test_perform_qr_auth_passport_error_raises_login_failed() -> None: + """Generic YaPassportError is mapped to LoginFailed.""" + mock_client = mock.AsyncMock() + mock_client.start_qr_login.side_effect = PassportNetworkError("connection lost") + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_ynison.yandex_auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_ynison.yandex_auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="Yandex auth error"): + await perform_qr_auth(mock_mass, "session_1") + + +async def test_perform_qr_auth_no_music_token_raises() -> None: + """Credentials without music_token raises LoginFailed.""" + qr = _make_qr_session() + creds = _make_credentials(music_token=None) + mock_client = mock.AsyncMock() + mock_client.start_qr_login.return_value = qr + mock_client.poll_qr_until_confirmed.return_value = creds + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_ynison.yandex_auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_ynison.yandex_auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="no music token"): + await perform_qr_auth(mock_mass, "session_1") + + +# -- refresh_music_token ------------------------------------------------------- + + +async def test_refresh_music_token_success() -> None: + """Successful refresh returns a SecretStr.""" + mock_client = mock.AsyncMock() + mock_client.refresh_music_token.return_value = SecretStr("new_music_token") + + with mock.patch( + "music_assistant.providers.yandex_ynison.yandex_auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + result = await refresh_music_token(SecretStr("my_x_token")) + + assert result.get_secret() == "new_music_token" + mock_client.refresh_music_token.assert_awaited_once() + + +async def test_refresh_music_token_auth_error_raises_login_failed() -> None: + """Auth failure during refresh is mapped to LoginFailed.""" + mock_client = mock.AsyncMock() + mock_client.refresh_music_token.side_effect = InvalidCredentialsError("bad token") + + with mock.patch( + "music_assistant.providers.yandex_ynison.yandex_auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="Failed to refresh"): + await refresh_music_token(SecretStr("bad_x_token")) + + +# -- validate_x_token ---------------------------------------------------------- + + +async def test_validate_x_token_valid() -> None: + """Valid x_token returns True.""" + mock_client = mock.AsyncMock() + mock_client.validate_x_token.return_value = True + + with mock.patch( + "music_assistant.providers.yandex_ynison.yandex_auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + result = await validate_x_token(SecretStr("good_token")) + + assert result is True + + +async def test_validate_x_token_error_returns_false() -> None: + """Any YaPassportError returns False (graceful degradation).""" + mock_client = mock.AsyncMock() + mock_client.validate_x_token.side_effect = RateLimitedError("429") + + with mock.patch( + "music_assistant.providers.yandex_ynison.yandex_auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + result = await validate_x_token(SecretStr("some_token")) + + assert result is False diff --git a/tests/providers/yandex_ynison/test_ynison_client.py b/tests/providers/yandex_ynison/test_ynison_client.py new file mode 100644 index 0000000000..5ba0bbad8c --- /dev/null +++ b/tests/providers/yandex_ynison/test_ynison_client.py @@ -0,0 +1,1400 @@ +"""Tests for the Ynison WebSocket client.""" + +from __future__ import annotations + +import asyncio +import json +from contextlib import suppress +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp +import pytest +from music_assistant_models.errors import LoginFailed +from ya_passport_auth import SecretStr + +from music_assistant.providers.yandex_ynison.constants import ( + DEFAULT_APP_NAME, + DEVICE_TYPE_WEB, + MAX_RECONNECT_ATTEMPTS, + YNISON_ORIGIN, +) +from music_assistant.providers.yandex_ynison.ynison_client import ( + YnisonClient, + YnisonDeviceInfo, + YnisonState, + generate_device_id, +) + + +@pytest.fixture +def device_info() -> YnisonDeviceInfo: + """Create test device info.""" + return YnisonDeviceInfo( + device_id="test-device-id", + title="Test Device", + ) + + +@pytest.fixture +def mock_callbacks() -> tuple[AsyncMock, AsyncMock]: + """Create mock callbacks for state update and disconnect.""" + return AsyncMock(), AsyncMock() + + +@pytest.fixture +def client( + device_info: YnisonDeviceInfo, + mock_callbacks: tuple[AsyncMock, AsyncMock], +) -> YnisonClient: + """Create a YnisonClient instance for testing.""" + on_state_update, on_disconnect = mock_callbacks + return YnisonClient( + token=SecretStr("test-token"), + device_info=device_info, + on_state_update=on_state_update, + on_disconnect=on_disconnect, + logger=MagicMock(), + ) + + +# ------------------------------------------------------------------ +# YnisonDeviceInfo +# ------------------------------------------------------------------ + + +class TestYnisonDeviceInfo: + """Tests for YnisonDeviceInfo dataclass.""" + + def test_defaults(self) -> None: + """Default type is WEB and app_name is set.""" + info = YnisonDeviceInfo(device_id="abc", title="My Speaker") + assert info.type == DEVICE_TYPE_WEB + assert info.app_name == DEFAULT_APP_NAME + + def test_custom_values(self) -> None: + """Custom values override defaults.""" + info = YnisonDeviceInfo( + device_id="xyz", + title="Custom", + type="TV", + app_name="CustomApp", + app_version="2.0", + ) + assert info.type == "TV" + assert info.app_version == "2.0" + + +# ------------------------------------------------------------------ +# YnisonState +# ------------------------------------------------------------------ + + +class TestYnisonState: + """Tests for YnisonState dataclass.""" + + def test_empty_state(self) -> None: + """Empty state returns safe defaults.""" + state = YnisonState() + assert state.current_track_id is None + assert state.is_paused is True + assert state.progress_ms == 0 + assert state.duration_ms == 0 + + def test_current_track_id(self) -> None: + """Extracts track ID from playable list by index.""" + state = YnisonState( + player_state={ + "player_queue": { + "current_playable_index": 1, + "playable_list": [ + {"playable_id": "track1"}, + {"playable_id": "track2"}, + {"playable_id": "track3"}, + ], + } + } + ) + assert state.current_track_id == "track2" + + def test_current_track_id_out_of_bounds(self) -> None: + """Returns None when index exceeds playable list.""" + state = YnisonState( + player_state={ + "player_queue": { + "current_playable_index": 10, + "playable_list": [{"playable_id": "track1"}], + } + } + ) + assert state.current_track_id is None + + def test_is_paused(self) -> None: + """Reads paused status from player state.""" + state = YnisonState(player_state={"status": {"paused": False}}) + assert state.is_paused is False + + def test_progress_and_duration(self) -> None: + """Reads progress and duration from player state.""" + state = YnisonState( + player_state={ + "status": { + "progress_ms": 30000, + "duration_ms": 180000, + } + } + ) + assert state.progress_ms == 30000 + assert state.duration_ms == 180000 + + +# ------------------------------------------------------------------ +# YnisonClient internals +# ------------------------------------------------------------------ + + +class TestYnisonClientBuildMethods: + """Tests for YnisonClient helper/build methods.""" + + def test_build_headers(self, client: YnisonClient) -> None: + """Headers include auth, origin, and protocol.""" + headers = client._build_headers() + assert headers["Authorization"] == "OAuth test-token" + assert headers["Origin"] == YNISON_ORIGIN + assert "Sec-WebSocket-Protocol" in headers + + def test_build_headers_with_ticket(self, client: YnisonClient) -> None: + """Headers include redirect ticket and session ID when provided.""" + headers = client._build_headers(redirect_ticket="ticket123", session_id=42) + proto = headers["Sec-WebSocket-Protocol"] + assert "Ynison-Redirect-Ticket" in proto + assert "ticket123" in proto + assert "42" in proto + + def test_build_ws_protocol_header(self, client: YnisonClient) -> None: + """Protocol header contains device ID and info.""" + proto = client._build_ws_protocol_header() + assert proto.startswith("Bearer, v2, ") + data = json.loads(proto[len("Bearer, v2, ") :]) + assert data["Ynison-Device-Id"] == "test-device-id" + device_info = json.loads(data["Ynison-Device-Info"]) + assert device_info["app_name"] == DEFAULT_APP_NAME + + def test_build_device_dict(self, client: YnisonClient) -> None: + """Device dict includes capabilities and info.""" + device = client._build_device_dict() + assert device["info"]["device_id"] == "test-device-id" + assert device["capabilities"]["can_be_player"] is True + assert device["capabilities"]["can_be_remote_controller"] is False + + def test_build_initial_state(self, client: YnisonClient) -> None: + """Initial state is paused with empty queue.""" + state = client._build_initial_state() + assert state["status"]["paused"] is True + assert state["player_queue"]["playable_list"] == [] + + +# ------------------------------------------------------------------ +# YnisonClient parse state +# ------------------------------------------------------------------ + + +class TestYnisonClientParseState: + """Tests for state parsing.""" + + def test_parse_state(self, client: YnisonClient) -> None: + """Parses full state response into YnisonState.""" + data: dict[str, Any] = { + "player_state": { + "status": {"paused": False, "progress_ms": 5000, "duration_ms": 200000}, + "player_queue": { + "current_playable_index": 0, + "playable_list": [{"playable_id": "track42"}], + }, + }, + "active_device_id_optional": "test-device-id", + "devices": [{"info": {"device_id": "test-device-id"}}], + } + client._parse_state(data) + assert client.state.current_track_id == "track42" + assert client.state.active_device_id == "test-device-id" + assert client.state.is_paused is False + + def test_parse_state_partial(self, client: YnisonClient) -> None: + """Partial updates should preserve existing state.""" + client.state.active_device_id = "old-device" + client._parse_state({"player_state": {"status": {"paused": True}}}) + assert client.state.active_device_id == "old-device" + + +# ------------------------------------------------------------------ +# YnisonClient send methods +# ------------------------------------------------------------------ + + +class TestYnisonClientSend: + """Tests for send methods.""" + + @pytest.fixture(autouse=True) + def _setup_ws(self, client: YnisonClient) -> None: + """Set up a mock WebSocket.""" + self.mock_ws = AsyncMock() + self.mock_ws.closed = False + client._ws = self.mock_ws + client._connected = True + + async def test_update_playing_status(self, client: YnisonClient) -> None: + """Sends correct playing status message.""" + await client.update_playing_status(1000, 5000, paused=False) + call_args = self.mock_ws.send_str.call_args[0][0] + msg = json.loads(call_args) + status = msg["update_playing_status"]["playing_status"] + assert status["progress_ms"] == 1000 + assert status["duration_ms"] == 5000 + assert status["paused"] is False + + async def test_update_active_device(self, client: YnisonClient) -> None: + """Sends active device update message.""" + await client.update_active_device("device-123") + msg = json.loads(self.mock_ws.send_str.call_args[0][0]) + assert msg["update_active_device"]["device_id_optional"] == "device-123" + + async def test_send_not_connected(self, client: YnisonClient) -> None: + """Should silently skip when not connected.""" + client._ws = None + await client.update_active_device("test") + # No exception raised + + +# ------------------------------------------------------------------ +# generate_device_id +# ------------------------------------------------------------------ + + +class TestGenerateDeviceId: + """Tests for generate_device_id.""" + + def test_format(self) -> None: + """Device ID is 16-char lowercase alphanumeric.""" + device_id = generate_device_id() + assert len(device_id) == 16 + assert device_id.isalnum() + assert device_id.islower() or device_id.isdigit() + + def test_uniqueness(self) -> None: + """Generated IDs are unique.""" + ids = {generate_device_id() for _ in range(10)} + assert len(ids) == 10 + + +# ------------------------------------------------------------------ +# YnisonClient disconnect +# ------------------------------------------------------------------ + + +class TestYnisonClientDisconnect: + """Tests for disconnect handling.""" + + async def test_disconnect_closes_ws(self, client: YnisonClient) -> None: + """Disconnect closes WebSocket and clears state.""" + mock_ws = AsyncMock() + mock_ws.closed = False + client._ws = mock_ws + client._connected = True + + await client.disconnect() + + mock_ws.close.assert_called_once() + assert client._connected is False + assert client._ws is None + + async def test_disconnect_cancels_tasks(self, client: YnisonClient) -> None: + """Disconnect cancels running message task.""" + + # Create a real task that we can cancel + async def _forever() -> None: + await asyncio.Event().wait() + + task = asyncio.ensure_future(_forever()) + client._message_task = task + + await client.disconnect() + + assert task.cancelled() + + async def test_disconnect_when_not_connected(self, client: YnisonClient) -> None: + """Should not raise when already disconnected.""" + await client.disconnect() + + +# ------------------------------------------------------------------ +# Reconnect session ownership +# ------------------------------------------------------------------ + + +class TestReconnectSessionOwnership: + """Tests for _reconnect respecting external session ownership.""" + + async def test_reconnect_reuses_external_session(self) -> None: + """Reconnect reuses a still-open external session instead of creating a new one.""" + on_state, on_disconnect = AsyncMock(), AsyncMock() + ext_session = MagicMock(spec=aiohttp.ClientSession) + ext_session.closed = False + + client = YnisonClient( + token=SecretStr("test-token"), + device_info=YnisonDeviceInfo(device_id="dev1", title="Test"), + on_state_update=on_state, + on_disconnect=on_disconnect, + logger=MagicMock(), + http_session=ext_session, + ) + client._session = None # simulate session lost + client._stop_event.clear() + + def stop_after_session_select() -> None: + client._stop_event.set() + msg = "stop after session selection" + raise RuntimeError(msg) + + sleep_path = "music_assistant.providers.yandex_ynison.ynison_client.asyncio.sleep" + with ( + patch(sleep_path, new_callable=AsyncMock), + patch.object( + client, + "_get_redirect_ticket", + new_callable=AsyncMock, + ) as mock_redir, + ): + mock_redir.side_effect = stop_after_session_select + await client._reconnect() + + assert mock_redir.await_count == 1 + assert client._session is ext_session + + async def test_reconnect_raises_on_closed_external_session(self) -> None: + """Reconnect raises RuntimeError if external session is closed.""" + on_state, on_disconnect = AsyncMock(), AsyncMock() + ext_session = MagicMock(spec=aiohttp.ClientSession) + ext_session.closed = True + + client = YnisonClient( + token=SecretStr("test-token"), + device_info=YnisonDeviceInfo(device_id="dev1", title="Test"), + on_state_update=on_state, + on_disconnect=on_disconnect, + logger=MagicMock(), + http_session=ext_session, + ) + client._session = None # simulate session lost + + # Patch sleep to avoid delays, and redirect to test the session logic + sleep_path = "music_assistant.providers.yandex_ynison.ynison_client.asyncio.sleep" + with ( + patch(sleep_path, new_callable=AsyncMock), + patch.object(client, "_get_redirect_ticket", new_callable=AsyncMock) as mock_redir, + ): + mock_redir.side_effect = AssertionError("should not reach here") + await client._reconnect() + + # All attempts should fail because external session is closed + assert not client._connected + + async def test_connect_raises_on_closed_external_session(self) -> None: + """connect() raises RuntimeError if external session is already closed.""" + on_state, on_disconnect = AsyncMock(), AsyncMock() + ext_session = MagicMock(spec=aiohttp.ClientSession) + ext_session.closed = True + + client = YnisonClient( + token=SecretStr("test-token"), + device_info=YnisonDeviceInfo(device_id="dev1", title="Test"), + on_state_update=on_state, + on_disconnect=on_disconnect, + logger=MagicMock(), + http_session=ext_session, + ) + + with pytest.raises(RuntimeError, match="closed"): + await client.connect() + + +# ------------------------------------------------------------------ +# connect() transient error → reconnect +# ------------------------------------------------------------------ + + +class TestConnectTransientError: + """Tests for connect() scheduling reconnect on transient errors.""" + + async def test_connect_transient_schedules_reconnect(self) -> None: + """Non-auth error during connect schedules _reconnect task.""" + on_state, on_disconnect = AsyncMock(), AsyncMock() + client = YnisonClient( + token=SecretStr("test-token"), + device_info=YnisonDeviceInfo(device_id="d1", title="T"), + on_state_update=on_state, + on_disconnect=on_disconnect, + logger=MagicMock(), + ) + with ( + patch.object( + client, + "_get_redirect_ticket", + new_callable=AsyncMock, + side_effect=ConnectionError("network down"), + ), + patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_reconnect, + ): + await client.connect() + await asyncio.sleep(0) # let ensure_future task run + + assert client._connected is False + assert client._ws is None + assert client._reconnect_task is not None + mock_reconnect.assert_awaited_once() + + async def test_connect_transient_closes_ws_and_session(self) -> None: + """Transient connect error closes stale ws and owned session.""" + on_state, on_disconnect = AsyncMock(), AsyncMock() + client = YnisonClient( + token=SecretStr("test-token"), + device_info=YnisonDeviceInfo(device_id="d1", title="T"), + on_state_update=on_state, + on_disconnect=on_disconnect, + logger=MagicMock(), + ) + mock_ws = AsyncMock() + mock_ws.closed = False + + async def fake_redirect() -> None: + # Simulate ws being set before the error + client._ws = mock_ws + raise OSError("timeout") + + with ( + patch.object( + client, + "_get_redirect_ticket", + new_callable=AsyncMock, + side_effect=fake_redirect, + ), + patch.object(client, "_reconnect", new_callable=AsyncMock), + ): + await client.connect() + + mock_ws.close.assert_awaited_once() + assert client._session is None + + +# ------------------------------------------------------------------ +# disconnect() — reconnect task cancellation +# ------------------------------------------------------------------ + + +class TestDisconnectReconnectCancellation: + """Tests for disconnect() cancelling a running reconnect task.""" + + async def test_disconnect_cancels_reconnect_task(self) -> None: + """disconnect() cancels and awaits pending reconnect task.""" + on_state, on_disconnect = AsyncMock(), AsyncMock() + client = YnisonClient( + token=SecretStr("test-token"), + device_info=YnisonDeviceInfo(device_id="d1", title="T"), + on_state_update=on_state, + on_disconnect=on_disconnect, + logger=MagicMock(), + ) + + async def _forever() -> None: + await asyncio.Event().wait() + + task = asyncio.ensure_future(_forever()) + client._reconnect_task = task + + await client.disconnect() + + assert task.cancelled() + + +# ------------------------------------------------------------------ +# Message building methods +# ------------------------------------------------------------------ + + +class TestMessageBuildingMethods: + """Tests for sync_state_from_eov, update_player_state, send_full_state.""" + + @pytest.fixture(autouse=True) + def _setup_ws(self, client: YnisonClient) -> None: + """Set up a mock WebSocket.""" + self.mock_ws = AsyncMock() + self.mock_ws.closed = False + client._ws = self.mock_ws + client._connected = True + + async def test_sync_state_from_eov(self, client: YnisonClient) -> None: + """sync_state_from_eov builds correct message structure.""" + await client.sync_state_from_eov(actual_queue_id="q123") + call_data = json.loads(self.mock_ws.send_str.call_args[0][0]) + assert call_data["sync_state_from_eov"]["actual_queue_id"] == "q123" + assert "rid" in call_data + assert call_data["activity_interception_type"] == "DO_NOT_INTERCEPT_BY_DEFAULT" + assert call_data["player_action_timestamp_ms"] == 0 + + async def test_update_player_state(self, client: YnisonClient) -> None: + """update_player_state builds correct message and logs queue info.""" + ps = { + "player_queue": { + "current_playable_index": 2, + "playable_list": [{"id": "a"}, {"id": "b"}, {"id": "c"}], + "entity_type": "ALBUM", + } + } + await client.update_player_state(ps) + call_data = json.loads(self.mock_ws.send_str.call_args[0][0]) + assert call_data["update_player_state"]["player_state"] == ps + assert "rid" in call_data + assert call_data["activity_interception_type"] == "DO_NOT_INTERCEPT_BY_DEFAULT" + + async def test_send_full_state_default(self, client: YnisonClient) -> None: + """send_full_state with no args sends initial state and device dict.""" + await client.send_full_state() + call_data = json.loads(self.mock_ws.send_str.call_args[0][0]) + ufs = call_data["update_full_state"] + assert ufs["device"]["info"]["device_id"] == "test-device-id" + assert ufs["player_state"]["status"]["paused"] is True + assert ufs["is_currently_active"] is False + assert "rid" in call_data + + async def test_send_full_state_custom(self, client: YnisonClient) -> None: + """send_full_state with custom player_state uses it.""" + custom_state = {"status": {"paused": False, "progress_ms": 42}} + await client.send_full_state(player_state=custom_state) + call_data = json.loads(self.mock_ws.send_str.call_args[0][0]) + assert call_data["update_full_state"]["player_state"] == custom_state + + +# ------------------------------------------------------------------ +# _get_redirect_ticket +# ------------------------------------------------------------------ + + +class TestGetRedirectTicket: + """Tests for _get_redirect_ticket.""" + + async def test_success(self, client: YnisonClient) -> None: + """Returns (host, ticket, session_id) on success.""" + mock_msg = MagicMock() + mock_msg.type = aiohttp.WSMsgType.TEXT + mock_msg.data = json.dumps( + { + "host": "ynison-node.yandex.net", + "redirect_ticket": "ticket-abc", + "session_id": 42, + } + ) + + mock_ws = AsyncMock() + mock_ws.receive = AsyncMock(return_value=mock_msg) + mock_ws.close = AsyncMock() + + mock_session = AsyncMock() + mock_session.ws_connect = AsyncMock(return_value=mock_ws) + client._session = mock_session + + host, ticket, sid = await client._get_redirect_ticket() + + assert host == "ynison-node.yandex.net" + assert ticket == "ticket-abc" + assert sid == 42 + mock_ws.close.assert_awaited_once() + + async def test_auth_failure_401(self, client: YnisonClient) -> None: + """401 WSServerHandshakeError raises LoginFailed.""" + err = aiohttp.WSServerHandshakeError( + request_info=MagicMock(), + history=(), + status=401, + message="Unauthorized", + headers=MagicMock(), + ) + mock_session = AsyncMock() + mock_session.ws_connect = AsyncMock(side_effect=err) + client._session = mock_session + + with pytest.raises(LoginFailed): + await client._get_redirect_ticket() + + async def test_auth_failure_403(self, client: YnisonClient) -> None: + """403 WSServerHandshakeError raises LoginFailed.""" + err = aiohttp.WSServerHandshakeError( + request_info=MagicMock(), + history=(), + status=403, + message="Forbidden", + headers=MagicMock(), + ) + mock_session = AsyncMock() + mock_session.ws_connect = AsyncMock(side_effect=err) + client._session = mock_session + + with pytest.raises(LoginFailed): + await client._get_redirect_ticket() + + async def test_network_error_500(self, client: YnisonClient) -> None: + """500 WSServerHandshakeError re-raises (not LoginFailed).""" + err = aiohttp.WSServerHandshakeError( + request_info=MagicMock(), + history=(), + status=500, + message="Server Error", + headers=MagicMock(), + ) + mock_session = AsyncMock() + mock_session.ws_connect = AsyncMock(side_effect=err) + client._session = mock_session + + with pytest.raises(aiohttp.WSServerHandshakeError): + await client._get_redirect_ticket() + + async def test_missing_host_ticket(self, client: YnisonClient) -> None: + """Missing host/ticket in response raises ConnectionError.""" + mock_msg = MagicMock() + mock_msg.type = aiohttp.WSMsgType.TEXT + mock_msg.data = json.dumps({"host": "", "redirect_ticket": ""}) + + mock_ws = AsyncMock() + mock_ws.receive = AsyncMock(return_value=mock_msg) + mock_ws.close = AsyncMock() + + mock_session = AsyncMock() + mock_session.ws_connect = AsyncMock(return_value=mock_ws) + client._session = mock_session + + with pytest.raises(ConnectionError, match="missing host or ticket"): + await client._get_redirect_ticket() + + async def test_unexpected_msg_type(self, client: YnisonClient) -> None: + """Non-TEXT/BINARY message type raises ConnectionError.""" + mock_msg = MagicMock() + mock_msg.type = aiohttp.WSMsgType.CLOSE + mock_msg.data = None + + mock_ws = AsyncMock() + mock_ws.receive = AsyncMock(return_value=mock_msg) + mock_ws.close = AsyncMock() + + mock_session = AsyncMock() + mock_session.ws_connect = AsyncMock(return_value=mock_ws) + client._session = mock_session + + with pytest.raises(ConnectionError, match="Unexpected message type"): + await client._get_redirect_ticket() + + async def test_no_session_raises_runtime_error(self, client: YnisonClient) -> None: + """Raises RuntimeError when session is None.""" + client._session = None + with pytest.raises(RuntimeError, match="session not initialized"): + await client._get_redirect_ticket() + + +# ------------------------------------------------------------------ +# _connect_state +# ------------------------------------------------------------------ + + +class TestConnectState: + """Tests for _connect_state.""" + + async def test_success(self, client: YnisonClient) -> None: + """Successful connect sets _connected, calls send_full_state, starts loop.""" + mock_ws = AsyncMock() + + mock_session = AsyncMock() + mock_session.ws_connect = AsyncMock(return_value=mock_ws) + client._session = mock_session + + with patch.object(client, "send_full_state", new_callable=AsyncMock) as mock_sfs: + await client._connect_state("host.yandex.net", "ticket", 42) + + assert client._connected is True + mock_sfs.assert_awaited_once() + assert client._message_task is not None + # Clean up the task + client._message_task.cancel() + with suppress(asyncio.CancelledError): + await client._message_task + + async def test_auth_failure_401(self, client: YnisonClient) -> None: + """401 during state connect raises LoginFailed.""" + err = aiohttp.WSServerHandshakeError( + request_info=MagicMock(), + history=(), + status=401, + message="Unauthorized", + headers=MagicMock(), + ) + mock_session = AsyncMock() + mock_session.ws_connect = AsyncMock(side_effect=err) + client._session = mock_session + + with pytest.raises(LoginFailed): + await client._connect_state("host", "ticket", 1) + + async def test_no_session_raises_runtime_error(self, client: YnisonClient) -> None: + """Raises RuntimeError when session is None.""" + client._session = None + with pytest.raises(RuntimeError, match="session not initialized"): + await client._connect_state("host", "ticket", 1) + + +# ------------------------------------------------------------------ +# _message_loop +# ------------------------------------------------------------------ + + +def _make_ws_msg( + msg_type: aiohttp.WSMsgType, + data: str | bytes | None = None, + extra: Any = None, +) -> MagicMock: + """Create a mock WS message.""" + msg = MagicMock() + msg.type = msg_type + msg.data = data + msg.extra = extra + return msg + + +class TestMessageLoop: + """Tests for _message_loop.""" + + async def _run_loop_with_messages( + self, + client: YnisonClient, + messages: list[MagicMock], + ) -> None: + """Set up mock ws and run _message_loop.""" + + async def _aiter(_self: Any) -> Any: + for m in messages: + yield m + + mock_ws = MagicMock() + mock_ws.__aiter__ = _aiter + mock_ws.exception = MagicMock(return_value=None) + mock_ws.close_code = None + client._ws = mock_ws + client._connected = True + + with patch.object(client, "_reconnect", new_callable=AsyncMock): + await client._message_loop() + + async def test_text_message_parses_and_calls_callback( + self, + client: YnisonClient, + mock_callbacks: tuple[AsyncMock, AsyncMock], + ) -> None: + """TEXT message: parses JSON, updates state, invokes callback.""" + on_state_update, _ = mock_callbacks + payload = { + "player_state": { + "status": {"paused": False, "progress_ms": 1000, "duration_ms": 5000}, + "player_queue": { + "current_playable_index": 0, + "playable_list": [{"playable_id": "t1"}], + }, + }, + "active_device_id_optional": "dev1", + } + msg = _make_ws_msg(aiohttp.WSMsgType.TEXT, json.dumps(payload)) + await self._run_loop_with_messages(client, [msg]) + + on_state_update.assert_awaited_once() + assert client.state.current_track_id == "t1" + assert client.state.is_paused is False + + async def test_text_message_with_error_field(self, client: YnisonClient) -> None: + """TEXT message with 'error' key logs warning, continues.""" + error_msg = _make_ws_msg( + aiohttp.WSMsgType.TEXT, + json.dumps({"error": {"code": 500, "message": "server error"}}), + ) + # Second valid message to confirm the loop continues + valid_msg = _make_ws_msg( + aiohttp.WSMsgType.TEXT, + json.dumps({"player_state": {"status": {"paused": True}}}), + ) + await self._run_loop_with_messages(client, [error_msg, valid_msg]) + + client._logger.warning.assert_called() # type: ignore[attr-defined] + + async def test_text_message_invalid_json(self, client: YnisonClient) -> None: + """TEXT message with invalid JSON logs warning, continues.""" + bad_msg = _make_ws_msg(aiohttp.WSMsgType.TEXT, "not valid json{{{") + valid_msg = _make_ws_msg( + aiohttp.WSMsgType.TEXT, + json.dumps({"player_state": {"status": {"paused": True}}}), + ) + await self._run_loop_with_messages(client, [bad_msg, valid_msg]) + + client._logger.warning.assert_called() # type: ignore[attr-defined] + + async def test_callback_exception_continues( + self, + client: YnisonClient, + mock_callbacks: tuple[AsyncMock, AsyncMock], + ) -> None: + """Exception in state callback is caught, loop continues.""" + on_state_update, _ = mock_callbacks + on_state_update.side_effect = [ValueError("boom"), None] + + msg1 = _make_ws_msg( + aiohttp.WSMsgType.TEXT, + json.dumps({"player_state": {"status": {"paused": True}}}), + ) + msg2 = _make_ws_msg( + aiohttp.WSMsgType.TEXT, + json.dumps({"player_state": {"status": {"paused": False}}}), + ) + await self._run_loop_with_messages(client, [msg1, msg2]) + + assert on_state_update.await_count == 2 + + async def test_binary_message_logged(self, client: YnisonClient) -> None: + """BINARY message is logged, loop continues.""" + bin_msg = _make_ws_msg(aiohttp.WSMsgType.BINARY, b"\x00\x01\x02") + valid_msg = _make_ws_msg( + aiohttp.WSMsgType.TEXT, + json.dumps({"player_state": {"status": {"paused": True}}}), + ) + await self._run_loop_with_messages(client, [bin_msg, valid_msg]) + + client._logger.debug.assert_called() # type: ignore[attr-defined] + + async def test_error_message_breaks_and_reconnects(self, client: YnisonClient) -> None: + """ERROR message breaks loop and schedules reconnect.""" + + async def _aiter(_self: Any) -> Any: + yield _make_ws_msg(aiohttp.WSMsgType.ERROR) + + mock_ws = MagicMock() + mock_ws.__aiter__ = _aiter + mock_ws.exception = MagicMock(return_value=Exception("ws error")) + mock_ws.close_code = None + client._ws = mock_ws + client._connected = True + + with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc: + await client._message_loop() + await asyncio.sleep(0) # let ensure_future task run + + assert client._connected is False + mock_rc.assert_awaited_once() + + async def test_close_message_breaks_and_reconnects(self, client: YnisonClient) -> None: + """CLOSE message breaks loop and schedules reconnect.""" + + async def _aiter(_self: Any) -> Any: + yield _make_ws_msg(aiohttp.WSMsgType.CLOSE, extra="normal close") + + mock_ws = MagicMock() + mock_ws.__aiter__ = _aiter + mock_ws.exception = MagicMock(return_value=None) + mock_ws.close_code = 1000 + client._ws = mock_ws + client._connected = True + + with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc: + await client._message_loop() + await asyncio.sleep(0) # let ensure_future task run + + assert client._connected is False + mock_rc.assert_awaited_once() + + async def test_closing_message_breaks_loop(self, client: YnisonClient) -> None: + """CLOSING message breaks loop.""" + + async def _aiter(_self: Any) -> Any: + yield _make_ws_msg(aiohttp.WSMsgType.CLOSING) + + mock_ws = MagicMock() + mock_ws.__aiter__ = _aiter + mock_ws.exception = MagicMock(return_value=None) + mock_ws.close_code = None + client._ws = mock_ws + client._connected = True + + with patch.object(client, "_reconnect", new_callable=AsyncMock): + await client._message_loop() + + assert client._connected is False + + async def test_stop_event_breaks_loop(self, client: YnisonClient) -> None: + """stop_event set → breaks loop without reconnect.""" + client._stop_event.set() + + async def _aiter(_self: Any) -> Any: + yield _make_ws_msg( + aiohttp.WSMsgType.TEXT, + json.dumps({"player_state": {}}), + ) + + mock_ws = MagicMock() + mock_ws.__aiter__ = _aiter + mock_ws.exception = MagicMock(return_value=None) + mock_ws.close_code = None + client._ws = mock_ws + client._connected = True + + with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc: + await client._message_loop() + + mock_rc.assert_not_awaited() + + async def test_cancelled_error_exits_cleanly(self, client: YnisonClient) -> None: + """CancelledError exits without reconnect.""" + + async def _aiter(_self: Any) -> Any: + raise asyncio.CancelledError + yield # type: ignore[unreachable] + + mock_ws = MagicMock() + mock_ws.__aiter__ = _aiter + mock_ws.exception = MagicMock(return_value=None) + mock_ws.close_code = None + client._ws = mock_ws + client._connected = True + + # CancelledError should be handled cleanly (no reconnect) + with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc: + await client._message_loop() + + mock_rc.assert_not_awaited() + + async def test_empty_data_message(self, client: YnisonClient) -> None: + """Message with empty data gets '' preview.""" + msg = _make_ws_msg(aiohttp.WSMsgType.TEXT, "") + # Empty string → json.loads will fail → warning logged + await self._run_loop_with_messages(client, [msg]) + client._logger.warning.assert_called() # type: ignore[attr-defined] + + async def test_no_ws_raises_runtime_error(self, client: YnisonClient) -> None: + """_message_loop raises RuntimeError when ws is None.""" + client._ws = None + with pytest.raises(RuntimeError, match="not connected"): + await client._message_loop() + + +# ------------------------------------------------------------------ +# _reconnect +# ------------------------------------------------------------------ + +SLEEP_PATH = "music_assistant.providers.yandex_ynison.ynison_client.asyncio.sleep" + + +class TestReconnect: + """Tests for _reconnect.""" + + async def test_success_on_first_attempt(self, client: YnisonClient) -> None: + """Reconnect succeeds on first attempt.""" + client._session = MagicMock() + client._session.closed = False + + with ( + patch(SLEEP_PATH, new_callable=AsyncMock), + patch.object( + client, + "_get_redirect_ticket", + new_callable=AsyncMock, + return_value=("host", "ticket", 1), + ), + patch.object(client, "_connect_state", new_callable=AsyncMock), + ): + await client._reconnect() + + client._logger.info.assert_any_call("Ynison reconnected successfully") # type: ignore[attr-defined] + + async def test_all_attempts_fail(self, client: YnisonClient) -> None: + """All attempts fail → calls _on_disconnect, cleans up.""" + client._session = MagicMock() + client._session.closed = False + + on_disconnect = AsyncMock() + client._on_disconnect = on_disconnect + + with ( + patch(SLEEP_PATH, new_callable=AsyncMock), + patch.object( + client, + "_get_redirect_ticket", + new_callable=AsyncMock, + side_effect=ConnectionError("fail"), + ), + ): + await client._reconnect() + + assert client._connected is False + assert client._stop_event.is_set() + on_disconnect.assert_awaited_once() + client._logger.error.assert_called_once_with( # type: ignore[attr-defined] + "Ynison: all %d reconnect attempts failed", + MAX_RECONNECT_ATTEMPTS, + ) + + async def test_stop_event_before_attempt(self, client: YnisonClient) -> None: + """stop_event set before reconnect → exits immediately.""" + client._stop_event.set() + await client._reconnect() + + async def test_stop_event_after_sleep(self, client: YnisonClient) -> None: + """stop_event set during sleep → exits on next check.""" + + async def set_stop(*_args: Any, **_kwargs: Any) -> None: + client._stop_event.set() + + client._session = MagicMock() + client._session.closed = False + + with patch(SLEEP_PATH, new_callable=AsyncMock, side_effect=set_stop): + await client._reconnect() + + # Should exit without calling _get_redirect_ticket + assert client._stop_event.is_set() + + async def test_cancelled_error_during_reconnect(self, client: YnisonClient) -> None: + """CancelledError during reconnect exits cleanly.""" + client._session = MagicMock() + client._session.closed = False + + with ( + patch(SLEEP_PATH, new_callable=AsyncMock), + patch.object( + client, + "_get_redirect_ticket", + new_callable=AsyncMock, + side_effect=asyncio.CancelledError, + ), + ): + await client._reconnect() + + async def test_creates_new_session_when_none(self, client: YnisonClient) -> None: + """Creates new ClientSession when _session is None and no external.""" + client._session = None + client._external_session = None + + mock_new_session = MagicMock(spec=aiohttp.ClientSession) + mock_new_session.closed = False + mock_new_session.close = AsyncMock() + + def stop_after_session(*_args: Any, **_kwargs: Any) -> None: + client._stop_event.set() + msg = "stop" + raise RuntimeError(msg) + + with ( + patch(SLEEP_PATH, new_callable=AsyncMock), + patch( + "music_assistant.providers.yandex_ynison.ynison_client.aiohttp.ClientSession", + return_value=mock_new_session, + ), + patch.object( + client, + "_get_redirect_ticket", + new_callable=AsyncMock, + side_effect=stop_after_session, + ), + ): + await client._reconnect() + + assert client._session is mock_new_session + + async def test_closes_stale_ws_on_reconnect(self, client: YnisonClient) -> None: + """Stale ws is closed before reconnect attempt.""" + stale_ws = AsyncMock() + stale_ws.closed = False + client._ws = stale_ws + client._session = MagicMock() + client._session.closed = False + + with ( + patch(SLEEP_PATH, new_callable=AsyncMock), + patch.object( + client, + "_get_redirect_ticket", + new_callable=AsyncMock, + return_value=("host", "ticket", 1), + ), + patch.object(client, "_connect_state", new_callable=AsyncMock), + ): + await client._reconnect() + + stale_ws.close.assert_awaited_once() + + +# ------------------------------------------------------------------ +# _send() error handling +# ------------------------------------------------------------------ + + +class TestSendErrorHandling: + """Tests for _send() error handling and reconnect scheduling.""" + + async def test_connection_error_triggers_reconnect(self, client: YnisonClient) -> None: + """ConnectionError during send sets _connected=False, schedules reconnect.""" + mock_ws = AsyncMock() + mock_ws.closed = False + mock_ws.send_str = AsyncMock(side_effect=ConnectionError("broken pipe")) + client._ws = mock_ws + client._connected = True + + with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc: + await client._send({"test": True}) + await asyncio.sleep(0) + + assert client._connected is False + mock_rc.assert_awaited_once() + + async def test_client_error_triggers_reconnect(self, client: YnisonClient) -> None: + """aiohttp.ClientError during send triggers reconnect.""" + mock_ws = AsyncMock() + mock_ws.closed = False + mock_ws.send_str = AsyncMock(side_effect=aiohttp.ClientError("connection lost")) + client._ws = mock_ws + client._connected = True + + with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc: + await client._send({"test": True}) + await asyncio.sleep(0) + + assert client._connected is False + mock_rc.assert_awaited_once() + + async def test_runtime_error_triggers_reconnect(self, client: YnisonClient) -> None: + """RuntimeError during send triggers reconnect.""" + mock_ws = AsyncMock() + mock_ws.closed = False + mock_ws.send_str = AsyncMock(side_effect=RuntimeError("ws closed")) + client._ws = mock_ws + client._connected = True + + with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc: + await client._send({"test": True}) + await asyncio.sleep(0) + + assert client._connected is False + mock_rc.assert_awaited_once() + + async def test_os_error_triggers_reconnect(self, client: YnisonClient) -> None: + """OSError during send triggers reconnect.""" + mock_ws = AsyncMock() + mock_ws.closed = False + mock_ws.send_str = AsyncMock(side_effect=OSError("network")) + client._ws = mock_ws + client._connected = True + + with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc: + await client._send({"test": True}) + await asyncio.sleep(0) + + assert client._connected is False + mock_rc.assert_awaited_once() + + async def test_send_skips_when_ws_closed(self, client: YnisonClient) -> None: + """_send skips when ws is present but closed.""" + mock_ws = AsyncMock() + mock_ws.closed = True + client._ws = mock_ws + client._connected = True + + await client._send({"test": True}) + mock_ws.send_str.assert_not_called() + + +# ------------------------------------------------------------------ +# connect() creates session when none provided +# ------------------------------------------------------------------ + + +class TestConnectSessionCreation: + """Tests for connect() creating an aiohttp session.""" + + async def test_connect_creates_session(self) -> None: + """connect() creates a new session when no external session given.""" + on_state, on_disconnect = AsyncMock(), AsyncMock() + client = YnisonClient( + token=SecretStr("test-token"), + device_info=YnisonDeviceInfo(device_id="d1", title="T"), + on_state_update=on_state, + on_disconnect=on_disconnect, + logger=MagicMock(), + ) + with ( + patch.object( + client, + "_get_redirect_ticket", + new_callable=AsyncMock, + return_value=("host", "ticket", 1), + ), + patch.object(client, "_connect_state", new_callable=AsyncMock), + ): + await client.connect() + + assert client._session is not None + # Clean up + await client.disconnect() + + async def test_disconnect_does_not_close_external_session(self) -> None: + """disconnect() does not close an externally-provided session.""" + on_state, on_disconnect = AsyncMock(), AsyncMock() + ext_session = MagicMock(spec=aiohttp.ClientSession) + ext_session.closed = False + ext_session.close = AsyncMock() + + client = YnisonClient( + token=SecretStr("test-token"), + device_info=YnisonDeviceInfo(device_id="d1", title="T"), + on_state_update=on_state, + on_disconnect=on_disconnect, + logger=MagicMock(), + http_session=ext_session, + ) + client._session = ext_session + + await client.disconnect() + + ext_session.close.assert_not_called() + + +# ------------------------------------------------------------------ +# Token refresh on auth failure during reconnect +# ------------------------------------------------------------------ + + +class TestTokenRefreshOnReconnect: + """Tests for on_auth_failure callback in _reconnect.""" + + async def test_auth_failure_triggers_token_refresh(self) -> None: + """LoginFailed during reconnect invokes on_auth_failure callback.""" + on_state, on_disconnect = AsyncMock(), AsyncMock() + on_auth_failure = AsyncMock(return_value=SecretStr("new-token")) + + client = YnisonClient( + token=SecretStr("old-token"), + device_info=YnisonDeviceInfo(device_id="d1", title="T"), + on_state_update=on_state, + on_disconnect=on_disconnect, + logger=MagicMock(), + on_auth_failure=on_auth_failure, + ) + client._session = MagicMock() + client._session.closed = False + + # First attempt: LoginFailed → refresh → second attempt: success + attempt_count = 0 + + async def redirect_side_effect() -> tuple[str, str, int]: + nonlocal attempt_count + attempt_count += 1 + if attempt_count == 1: + raise LoginFailed("expired") + return ("host", "ticket", 1) + + with ( + patch(SLEEP_PATH, new_callable=AsyncMock), + patch.object( + client, + "_get_redirect_ticket", + new_callable=AsyncMock, + side_effect=redirect_side_effect, + ), + patch.object(client, "_connect_state", new_callable=AsyncMock), + ): + await client._reconnect() + + on_auth_failure.assert_awaited_once() + assert client._token == SecretStr("new-token") + client._logger.info.assert_any_call( # type: ignore[attr-defined] + "Token refreshed, will retry with new token" + ) + + async def test_auth_failure_no_callback(self) -> None: + """LoginFailed without on_auth_failure retries with same token.""" + on_state, on_disconnect = AsyncMock(), AsyncMock() + + client = YnisonClient( + token=SecretStr("old-token"), + device_info=YnisonDeviceInfo(device_id="d1", title="T"), + on_state_update=on_state, + on_disconnect=on_disconnect, + logger=MagicMock(), + ) + client._session = MagicMock() + client._session.closed = False + + with ( + patch(SLEEP_PATH, new_callable=AsyncMock), + patch.object( + client, + "_get_redirect_ticket", + new_callable=AsyncMock, + side_effect=LoginFailed("expired"), + ), + ): + await client._reconnect() + + # All attempts fail → disconnect + on_disconnect.assert_awaited_once() + assert client._token == SecretStr("old-token") + + async def test_auth_failure_callback_raises(self) -> None: + """on_auth_failure raises → logs warning, continues retry.""" + on_state, on_disconnect = AsyncMock(), AsyncMock() + on_auth_failure = AsyncMock(side_effect=RuntimeError("refresh failed")) + + client = YnisonClient( + token=SecretStr("old-token"), + device_info=YnisonDeviceInfo(device_id="d1", title="T"), + on_state_update=on_state, + on_disconnect=on_disconnect, + logger=MagicMock(), + on_auth_failure=on_auth_failure, + ) + client._session = MagicMock() + client._session.closed = False + + with ( + patch(SLEEP_PATH, new_callable=AsyncMock), + patch.object( + client, + "_get_redirect_ticket", + new_callable=AsyncMock, + side_effect=LoginFailed("expired"), + ), + ): + await client._reconnect() + + # Callback was called on every attempt + assert on_auth_failure.await_count == MAX_RECONNECT_ATTEMPTS + # Token unchanged since callback always fails + assert client._token == SecretStr("old-token") + on_disconnect.assert_awaited_once() + + +class TestUpdateToken: + """Tests for update_token method.""" + + def test_update_token_replaces_stored_token(self) -> None: + """update_token swaps the internal _token.""" + on_state, on_disconnect = AsyncMock(), AsyncMock() + client = YnisonClient( + token=SecretStr("old-token"), + device_info=YnisonDeviceInfo(device_id="d1", title="T"), + on_state_update=on_state, + on_disconnect=on_disconnect, + logger=MagicMock(), + ) + assert client._token == SecretStr("old-token") + client.update_token(SecretStr("new-token")) + assert client._token == SecretStr("new-token") From df0d3c5433b01087a621ef3df753d2067ac3a5e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Apr 2026 14:34:26 +0000 Subject: [PATCH 2/6] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.5.3 --- .../providers/yandex_ynison/provider.py | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/music_assistant/providers/yandex_ynison/provider.py b/music_assistant/providers/yandex_ynison/provider.py index c0770f473e..f5cde24b42 100644 --- a/music_assistant/providers/yandex_ynison/provider.py +++ b/music_assistant/providers/yandex_ynison/provider.py @@ -18,7 +18,11 @@ ProviderType, StreamType, ) -from music_assistant_models.errors import LoginFailed, UnsupportedFeaturedException +from music_assistant_models.errors import ( + LoginFailed, + PlayerCommandFailed, + UnsupportedFeaturedException, +) from music_assistant_models.streamdetails import StreamDetails, StreamMetadata from ya_passport_auth import SecretStr @@ -1041,7 +1045,9 @@ def _best_duration_ms(self) -> int: async def _on_play(self) -> None: """Handle play command — send resume to Ynison.""" if not self._ynison: - raise UnsupportedFeaturedException("Not connected to Ynison") + raise UnsupportedFeaturedException("Ynison client not initialized") + if not self._ynison.connected: + raise PlayerCommandFailed("Ynison WebSocket disconnected") state = self._ynison.state await self._send_progress_to_ynison( progress_ms=state.progress_ms, @@ -1052,7 +1058,9 @@ async def _on_play(self) -> None: async def _on_pause(self) -> None: """Handle pause command — send pause to Ynison.""" if not self._ynison: - raise UnsupportedFeaturedException("Not connected to Ynison") + raise UnsupportedFeaturedException("Ynison client not initialized") + if not self._ynison.connected: + raise PlayerCommandFailed("Ynison WebSocket disconnected") state = self._ynison.state await self._send_progress_to_ynison( progress_ms=state.progress_ms, @@ -1319,13 +1327,17 @@ async def _update_queue_list(self, expanded_list: list[dict[str, Any]]) -> None: async def _on_next(self) -> None: """Handle next track command — signal track end so Yandex advances.""" if not self._ynison: - raise UnsupportedFeaturedException("Not connected to Ynison") + raise UnsupportedFeaturedException("Ynison client not initialized") + if not self._ynison.connected: + raise PlayerCommandFailed("Ynison WebSocket disconnected") await self._signal_track_completion() async def _on_previous(self) -> None: """Handle previous track command — update queue index in Ynison.""" if not self._ynison: - raise UnsupportedFeaturedException("Not connected to Ynison") + raise UnsupportedFeaturedException("Ynison client not initialized") + if not self._ynison.connected: + raise PlayerCommandFailed("Ynison WebSocket disconnected") queue = self._ynison.state.player_state.get("player_queue", {}) current_index = queue.get("current_playable_index", 0) if current_index > 0: @@ -1338,7 +1350,9 @@ async def _on_seek(self, position: int) -> None: :param position: Position in seconds from Music Assistant. """ if not self._ynison: - raise UnsupportedFeaturedException("Not connected to Ynison") + raise UnsupportedFeaturedException("Ynison client not initialized") + if not self._ynison.connected: + raise PlayerCommandFailed("Ynison WebSocket disconnected") seek_ms = position * 1000 state = self._ynison.state await self._send_progress_to_ynison( From b09bb8f8f41196900b0214915232ce1061645e11 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 08:21:13 +0000 Subject: [PATCH 3/6] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.5.4 --- .../providers/yandex_ynison/constants.py | 10 ++ .../providers/yandex_ynison/ynison_client.py | 29 ++++- .../yandex_ynison/test_ynison_client.py | 105 +++++++++++++++++- 3 files changed, 138 insertions(+), 6 deletions(-) diff --git a/music_assistant/providers/yandex_ynison/constants.py b/music_assistant/providers/yandex_ynison/constants.py index 8af106ea9f..e3be4436d5 100644 --- a/music_assistant/providers/yandex_ynison/constants.py +++ b/music_assistant/providers/yandex_ynison/constants.py @@ -53,3 +53,13 @@ # WebSocket timeouts WS_CONNECT_TIMEOUT: Final[float] = 15.0 WS_HEARTBEAT: Final[float] = 30.0 + +# Ynison error codes that require immediate reconnection +YNISON_ERROR_REBALANCED: Final[str] = "300100001" +YNISON_ERROR_NOT_SERVED: Final[str] = "300100002" +YNISON_RECONNECT_ERROR_CODES: Final[frozenset[str]] = frozenset( + { + YNISON_ERROR_REBALANCED, + YNISON_ERROR_NOT_SERVED, + } +) diff --git a/music_assistant/providers/yandex_ynison/ynison_client.py b/music_assistant/providers/yandex_ynison/ynison_client.py index 451bea35c1..078e39556c 100644 --- a/music_assistant/providers/yandex_ynison/ynison_client.py +++ b/music_assistant/providers/yandex_ynison/ynison_client.py @@ -28,6 +28,7 @@ WS_CONNECT_TIMEOUT, WS_HEARTBEAT, YNISON_ORIGIN, + YNISON_RECONNECT_ERROR_CODES, YNISON_REDIRECT_URL, YNISON_STATE_PATH, ) @@ -131,6 +132,7 @@ def __init__( self._reconnect_task: asyncio.Task[None] | None = None self._stop_event = asyncio.Event() self._connected = False + self._has_connected_once = False # Latest state from server self.state = YnisonState() @@ -438,13 +440,24 @@ async def _connect_state(self, host: str, ticket: str, session_id: int) -> None: self._connected = True self._logger.info("Connected to Ynison state service at %s", host) - # Send initial state - await self.send_full_state() + # On reconnect, send last known state to avoid blank-state reset. + # On cold start, send initial (empty/paused) state. + if self._has_connected_once and self.state.player_state: + self._logger.info( + "Reconnect: restoring last known state (track=%s paused=%s)", + self.state.current_track_id, + self.state.is_paused, + ) + await self.send_full_state(player_state=self.state.player_state) + else: + await self.send_full_state() + + self._has_connected_once = True # Start message loop self._message_task = asyncio.ensure_future(self._message_loop()) - async def _message_loop(self) -> None: + async def _message_loop(self) -> None: # noqa: PLR0915 """Read messages from state service and dispatch callbacks.""" if self._ws is None: raise RuntimeError("WebSocket not connected — call connect() first") @@ -481,10 +494,18 @@ async def _message_loop(self) -> None: continue if "error" in data: + error_info = data["error"] + error_code = error_info.get("details", {}).get("ynison-error-code", "") self._logger.warning( "Ynison error response: %s", - json.dumps(data["error"])[:300], + json.dumps(error_info)[:300], ) + if error_code in YNISON_RECONNECT_ERROR_CODES: + self._logger.info( + "Ynison re-balance error %s — breaking for immediate reconnect", + error_code, + ) + break continue self._parse_state(data) diff --git a/tests/providers/yandex_ynison/test_ynison_client.py b/tests/providers/yandex_ynison/test_ynison_client.py index 5ba0bbad8c..802a306708 100644 --- a/tests/providers/yandex_ynison/test_ynison_client.py +++ b/tests/providers/yandex_ynison/test_ynison_client.py @@ -719,13 +719,61 @@ async def test_success(self, client: YnisonClient) -> None: await client._connect_state("host.yandex.net", "ticket", 42) assert client._connected is True - mock_sfs.assert_awaited_once() + # Cold start: send_full_state called with no args (blank state) + mock_sfs.assert_awaited_once_with() + assert client._has_connected_once is True assert client._message_task is not None # Clean up the task client._message_task.cancel() with suppress(asyncio.CancelledError): await client._message_task + async def test_reconnect_sends_last_known_state(self, client: YnisonClient) -> None: + """On reconnect, send_full_state is called with the preserved player state.""" + mock_ws = AsyncMock() + mock_session = AsyncMock() + mock_session.ws_connect = AsyncMock(return_value=mock_ws) + client._session = mock_session + + # Simulate prior connection: set flag and populate state + client._has_connected_once = True + client.state.player_state = { + "status": {"paused": False, "progress_ms": 120000, "duration_ms": 300000}, + "player_queue": { + "current_playable_index": 3, + "playable_list": [{"playable_id": "t1"}], + }, + } + + with patch.object(client, "send_full_state", new_callable=AsyncMock) as mock_sfs: + await client._connect_state("host.yandex.net", "ticket", 42) + + mock_sfs.assert_awaited_once_with(player_state=client.state.player_state) + # Clean up + client._message_task.cancel() + with suppress(asyncio.CancelledError): + await client._message_task + + async def test_reconnect_empty_state_falls_back(self, client: YnisonClient) -> None: + """On reconnect with empty player_state, falls back to blank initial state.""" + mock_ws = AsyncMock() + mock_session = AsyncMock() + mock_session.ws_connect = AsyncMock(return_value=mock_ws) + client._session = mock_session + + client._has_connected_once = True + client.state.player_state = {} # empty — no prior state received + + with patch.object(client, "send_full_state", new_callable=AsyncMock) as mock_sfs: + await client._connect_state("host.yandex.net", "ticket", 42) + + # Falls back to no-arg call (blank initial state) + mock_sfs.assert_awaited_once_with() + # Clean up + client._message_task.cancel() + with suppress(asyncio.CancelledError): + await client._message_task + async def test_auth_failure_401(self, client: YnisonClient) -> None: """401 during state connect raises LoginFailed.""" err = aiohttp.WSServerHandshakeError( @@ -816,7 +864,7 @@ async def test_text_message_parses_and_calls_callback( assert client.state.is_paused is False async def test_text_message_with_error_field(self, client: YnisonClient) -> None: - """TEXT message with 'error' key logs warning, continues.""" + """TEXT message with non-reconnect error logs warning, continues.""" error_msg = _make_ws_msg( aiohttp.WSMsgType.TEXT, json.dumps({"error": {"code": 500, "message": "server error"}}), @@ -830,6 +878,59 @@ async def test_text_message_with_error_field(self, client: YnisonClient) -> None client._logger.warning.assert_called() # type: ignore[attr-defined] + async def test_rebalance_error_breaks_loop(self, client: YnisonClient) -> None: + """Ynison re-balance error (300100001) breaks the loop for immediate reconnect.""" + rebalance_msg = _make_ws_msg( + aiohttp.WSMsgType.TEXT, + json.dumps( + { + "error": { + "details": { + "ynison-error-code": "300100001", + "ynison-backoff-millis": "0:100:500:1000:1000:5000", + }, + "grpc_code": 10, + "http_code": 409, + "message": "User re-balanced to another host", + } + } + ), + ) + # This message should NOT be reached because the loop breaks + valid_msg = _make_ws_msg( + aiohttp.WSMsgType.TEXT, + json.dumps({"player_state": {"status": {"paused": True}}}), + ) + mock_callbacks = client._on_state_update + await self._run_loop_with_messages(client, [rebalance_msg, valid_msg]) + + # The valid message was never processed (loop broke on re-balance error) + mock_callbacks.assert_not_awaited() + + async def test_not_served_error_breaks_loop(self, client: YnisonClient) -> None: + """Ynison 'not served' error (300100002) also breaks the loop.""" + not_served_msg = _make_ws_msg( + aiohttp.WSMsgType.TEXT, + json.dumps( + { + "error": { + "details": {"ynison-error-code": "300100002"}, + "grpc_code": 10, + "http_code": 409, + "message": "Current user's not served by this host", + } + } + ), + ) + valid_msg = _make_ws_msg( + aiohttp.WSMsgType.TEXT, + json.dumps({"player_state": {"status": {"paused": True}}}), + ) + mock_callbacks = client._on_state_update + await self._run_loop_with_messages(client, [not_served_msg, valid_msg]) + + mock_callbacks.assert_not_awaited() + async def test_text_message_invalid_json(self, client: YnisonClient) -> None: """TEXT message with invalid JSON logs warning, continues.""" bad_msg = _make_ws_msg(aiohttp.WSMsgType.TEXT, "not valid json{{{") From 8465e7b5d47b965dfb847bd5e8e0c3562c4c8d06 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 08:27:39 +0000 Subject: [PATCH 4/6] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.5.4 --- .../yandex_ynison/test_ynison_client.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/providers/yandex_ynison/test_ynison_client.py b/tests/providers/yandex_ynison/test_ynison_client.py index 802a306708..7eaf96ad80 100644 --- a/tests/providers/yandex_ynison/test_ynison_client.py +++ b/tests/providers/yandex_ynison/test_ynison_client.py @@ -750,6 +750,7 @@ async def test_reconnect_sends_last_known_state(self, client: YnisonClient) -> N mock_sfs.assert_awaited_once_with(player_state=client.state.player_state) # Clean up + assert client._message_task is not None client._message_task.cancel() with suppress(asyncio.CancelledError): await client._message_task @@ -770,6 +771,7 @@ async def test_reconnect_empty_state_falls_back(self, client: YnisonClient) -> N # Falls back to no-arg call (blank initial state) mock_sfs.assert_awaited_once_with() # Clean up + assert client._message_task is not None client._message_task.cancel() with suppress(asyncio.CancelledError): await client._message_task @@ -878,8 +880,13 @@ async def test_text_message_with_error_field(self, client: YnisonClient) -> None client._logger.warning.assert_called() # type: ignore[attr-defined] - async def test_rebalance_error_breaks_loop(self, client: YnisonClient) -> None: + async def test_rebalance_error_breaks_loop( + self, + client: YnisonClient, + mock_callbacks: tuple[AsyncMock, AsyncMock], + ) -> None: """Ynison re-balance error (300100001) breaks the loop for immediate reconnect.""" + on_state_update, _ = mock_callbacks rebalance_msg = _make_ws_msg( aiohttp.WSMsgType.TEXT, json.dumps( @@ -901,14 +908,18 @@ async def test_rebalance_error_breaks_loop(self, client: YnisonClient) -> None: aiohttp.WSMsgType.TEXT, json.dumps({"player_state": {"status": {"paused": True}}}), ) - mock_callbacks = client._on_state_update await self._run_loop_with_messages(client, [rebalance_msg, valid_msg]) # The valid message was never processed (loop broke on re-balance error) - mock_callbacks.assert_not_awaited() + on_state_update.assert_not_awaited() - async def test_not_served_error_breaks_loop(self, client: YnisonClient) -> None: + async def test_not_served_error_breaks_loop( + self, + client: YnisonClient, + mock_callbacks: tuple[AsyncMock, AsyncMock], + ) -> None: """Ynison 'not served' error (300100002) also breaks the loop.""" + on_state_update, _ = mock_callbacks not_served_msg = _make_ws_msg( aiohttp.WSMsgType.TEXT, json.dumps( @@ -926,10 +937,9 @@ async def test_not_served_error_breaks_loop(self, client: YnisonClient) -> None: aiohttp.WSMsgType.TEXT, json.dumps({"player_state": {"status": {"paused": True}}}), ) - mock_callbacks = client._on_state_update await self._run_loop_with_messages(client, [not_served_msg, valid_msg]) - mock_callbacks.assert_not_awaited() + on_state_update.assert_not_awaited() async def test_text_message_invalid_json(self, client: YnisonClient) -> None: """TEXT message with invalid JSON logs warning, continues.""" From 0e9c739ccb923a5db305d26afa3f9eecec1c9506 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 08:36:32 +0000 Subject: [PATCH 5/6] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.5.4 --- .../providers/yandex_ynison/ynison_client.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/music_assistant/providers/yandex_ynison/ynison_client.py b/music_assistant/providers/yandex_ynison/ynison_client.py index 078e39556c..e4e0074b41 100644 --- a/music_assistant/providers/yandex_ynison/ynison_client.py +++ b/music_assistant/providers/yandex_ynison/ynison_client.py @@ -553,18 +553,15 @@ def _parse_state(self, data: dict[str, Any]) -> None: "current_playable_index", -1 ) - # One-level-deep merge of player_state: Ynison sends sub-objects like - # "player_queue" and "status" as complete replacements, so shallow - # dict-union at the first nesting level is sufficient. Deeper recursion - # would risk merging stale list items (e.g. playable_list entries). + # Replace each incoming player_state sub-object at the top level: + # Ynison sends entries like "player_queue" and "status" as complete + # objects, so merging nested dicts would retain stale keys that are + # absent from the update. incoming_ps = data.get("player_state") if incoming_ps is not None: existing_ps = self.state.player_state for key, value in incoming_ps.items(): - if isinstance(value, dict) and isinstance(existing_ps.get(key), dict): - existing_ps[key] = {**existing_ps[key], **value} - else: - existing_ps[key] = value + existing_ps[key] = value self.state.active_device_id = data.get( "active_device_id_optional", self.state.active_device_id ) From a2c5b407d68aca66241333c19f95a20a73d2d153 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 17 Apr 2026 20:39:04 +0000 Subject: [PATCH 6/6] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.5.4 --- .../providers/yandex_ynison/constants.py | 4 ++++ .../providers/yandex_ynison/protocols.py | 4 ---- .../providers/yandex_ynison/provider.py | 18 ++++++++++++++---- tests/providers/yandex_ynison/test_provider.py | 5 +++-- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/music_assistant/providers/yandex_ynison/constants.py b/music_assistant/providers/yandex_ynison/constants.py index e3be4436d5..37b6db4f42 100644 --- a/music_assistant/providers/yandex_ynison/constants.py +++ b/music_assistant/providers/yandex_ynison/constants.py @@ -38,6 +38,10 @@ # Player selection PLAYER_ID_AUTO: Final[str] = "__auto__" +# yandex_music provider config key for audio quality tier (read via provider.config.get_value) +YANDEX_MUSIC_CONF_QUALITY: Final[str] = "quality" +YANDEX_MUSIC_LOSSLESS_QUALITIES: Final[frozenset[str]] = frozenset({"superb", "lossless"}) + # Defaults DEFAULT_DISPLAY_NAME: Final[str] = "Music Assistant" DEFAULT_APP_NAME: Final[str] = "Music Assistant" diff --git a/music_assistant/providers/yandex_ynison/protocols.py b/music_assistant/providers/yandex_ynison/protocols.py index 3029a16323..403b490458 100644 --- a/music_assistant/providers/yandex_ynison/protocols.py +++ b/music_assistant/providers/yandex_ynison/protocols.py @@ -33,7 +33,3 @@ async def get_rotor_station_tracks( ) -> tuple[list[Any], str | None]: """Fetch tracks from a rotor station for radio queue replenishment.""" ... - - def get_quality(self) -> str: - """Return the configured audio quality tier (e.g. 'balanced', 'superb').""" - ... diff --git a/music_assistant/providers/yandex_ynison/provider.py b/music_assistant/providers/yandex_ynison/provider.py index f5cde24b42..482cc7c836 100644 --- a/music_assistant/providers/yandex_ynison/provider.py +++ b/music_assistant/providers/yandex_ynison/provider.py @@ -42,6 +42,8 @@ DEFAULT_DISPLAY_NAME, OUTPUT_AUTO, PLAYER_ID_AUTO, + YANDEX_MUSIC_CONF_QUALITY, + YANDEX_MUSIC_LOSSLESS_QUALITIES, ) from .protocols import YandexMusicProviderLike from .streaming import ( @@ -950,17 +952,25 @@ def _update_normalized_format(self) -> None: """Set PCM normalization profile based on config and YM quality. Priority: explicit config values > auto-detection from YM quality. + Auto-detection reads the quality tier from the linked yandex_music + provider's config (`provider.config.get_value("quality")`), since + yandex_music does not expose a typed accessor method. Auto-detection: superb/lossless → 24bit/48kHz, else → 16bit/44.1kHz. Creates fresh AudioFormat instances each time to prevent mutation by MA's FFMpeg._log_reader_task (which sets input_format.codec_type in-place on the object passed as input_format to the outer ffmpeg). """ - # Start with auto-detected base from YM quality + # Start with auto-detected base from YM quality config + # (yandex_music does not expose get_quality(); read from its ProviderConfig instead) quality = "" - if self._yandex_provider and hasattr(self._yandex_provider, "get_quality"): - quality = self._yandex_provider.get_quality() - is_lossless = quality in ("superb", "lossless") + if self._yandex_provider is not None: + provider_config = getattr(self._yandex_provider, "config", None) + if provider_config is not None and hasattr(provider_config, "get_value"): + config_quality = provider_config.get_value(YANDEX_MUSIC_CONF_QUALITY) + if isinstance(config_quality, str): + quality = config_quality + is_lossless = quality in YANDEX_MUSIC_LOSSLESS_QUALITIES base = PCM_LOSSLESS_PARAMS if is_lossless else PCM_LOSSY_PARAMS # Apply config overrides diff --git a/tests/providers/yandex_ynison/test_provider.py b/tests/providers/yandex_ynison/test_provider.py index 003078fe5d..638f5d5965 100644 --- a/tests/providers/yandex_ynison/test_provider.py +++ b/tests/providers/yandex_ynison/test_provider.py @@ -988,10 +988,11 @@ async def test_superb_quality_uses_lossless_profile(self) -> None: mock_yandex = MagicMock() mock_yandex.domain = "yandex_music" mock_yandex.type = ProviderType.MUSIC - mock_yandex.get_quality = MagicMock(return_value="superb") + mock_yandex.config.get_value = MagicMock(return_value="superb") provider._yandex_provider = mock_yandex provider._update_normalized_format() + mock_yandex.config.get_value.assert_called_with("quality") assert provider._normalized_format.content_type == ContentType.PCM_S24LE assert provider._normalized_format.sample_rate == 48000 assert provider._normalized_format.bit_depth == 24 @@ -1004,7 +1005,7 @@ async def test_balanced_quality_uses_lossy_profile(self) -> None: mock_yandex = MagicMock() mock_yandex.domain = "yandex_music" mock_yandex.type = ProviderType.MUSIC - mock_yandex.get_quality = MagicMock(return_value="balanced") + mock_yandex.config.get_value = MagicMock(return_value="balanced") provider._yandex_provider = mock_yandex provider._update_normalized_format()