diff --git a/music_assistant/providers/yandex_ynison/__init__.py b/music_assistant/providers/yandex_ynison/__init__.py new file mode 100644 index 0000000000..6dffcda6d2 --- /dev/null +++ b/music_assistant/providers/yandex_ynison/__init__.py @@ -0,0 +1,311 @@ +"""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 .auth import perform_qr_auth +from .config_helpers import list_yandex_music_instances +from .constants import ( + CONF_ACCOUNT_LOGIN, + 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, + CONF_YM_INSTANCE, + DEFAULT_DISPLAY_NAME, + OUTPUT_AUTO, + PLAYER_ID_AUTO, + YM_INSTANCE_OWN, +) +from .provider import YandexYnisonProvider + +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( # noqa: PLR0915 — flow naturally returns ~12 ConfigEntry objects + mass: MusicAssistant, + instance_id: str | None = None, # noqa: ARG001 — required by MA callback signature + 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") + + # Discover available yandex_music instances for borrow-mode dropdown + ym_instances = list_yandex_music_instances(mass) + ym_instance_ids = {inst_id for inst_id, _ in ym_instances} + + # Determine the currently selected source (borrow vs own) + selected = cast("str | None", values.get(CONF_YM_INSTANCE)) + if selected is None: + # Preserve existing own-token configs on upgrade (CONF_TOKEN already set + # but CONF_YM_INSTANCE absent). Only auto-select borrowing for truly + # fresh installs with no stored token and exactly one YM instance. + has_manual_token = bool(values.get(CONF_TOKEN)) + if has_manual_token: + selected = YM_INSTANCE_OWN + else: + selected = ym_instances[0][0] if len(ym_instances) == 1 else YM_INSTANCE_OWN + # Normalize a stale selection (referenced YM instance was removed) up front + # so borrowing/label/default downstream read consistent values, and a Save + # without touching the dropdown persists the corrected id. + if selected != YM_INSTANCE_OWN and selected not in ym_instance_ids: + selected = YM_INSTANCE_OWN + values[CONF_YM_INSTANCE] = YM_INSTANCE_OWN + borrowing = selected != YM_INSTANCE_OWN + + # ------------------------------------------------------------------ + # Own-mode action handling: QR login / reset auth + # ------------------------------------------------------------------ + # The buttons are only surfaced in own mode, but the action callback is + # invoked with whatever `values` the frontend has cached — guard against + # a stale-state save that fires the action while the dropdown points at + # a (possibly missing) yandex_music instance. Otherwise we'd overwrite + # token/x_token in a config that won't even use them. + remember_session = bool(values.get(CONF_REMEMBER_SESSION, True)) + if action in (CONF_ACTION_AUTH_QR, CONF_ACTION_CLEAR_AUTH) and selected != YM_INSTANCE_OWN: + raise LoginFailed( + f"Cannot run own-mode action '{action}' while the source is set to " + f"'{selected}'. Switch the dropdown to 'Use own credentials' first." + ) + 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, display_login = await perform_qr_auth(mass, str(session_id)) + values[CONF_TOKEN] = music_token + values[CONF_X_TOKEN] = x_token if remember_session else None + values[CONF_ACCOUNT_LOGIN] = display_login + elif action == CONF_ACTION_CLEAR_AUTH: + values[CONF_TOKEN] = None + values[CONF_X_TOKEN] = None + values[CONF_ACCOUNT_LOGIN] = None + + # In own mode, treat presence of a music token OR a stored x_token as + # "authenticated" — both can drive the connection (token directly, or + # x_token via in-memory refresh). + own_authenticated = bool(values.get(CONF_TOKEN) or values.get(CONF_X_TOKEN)) + account_login = cast("str | None", values.get(CONF_ACCOUNT_LOGIN)) + + # ------------------------------------------------------------------ + # Status label + # ------------------------------------------------------------------ + if borrowing: + ym_name = next((name for inst_id, name in ym_instances if inst_id == selected), selected) + label_text = f"Borrowing credentials from Yandex Music instance '{ym_name}'." + elif action == CONF_ACTION_AUTH_QR: + who = f" as {account_login}" if account_login else "" + label_text = f"Authenticated to Yandex Music{who}. Don't forget to save to complete setup." + elif own_authenticated: + who = f" as {account_login}" if account_login else "" + label_text = f"Authenticated to Yandex Music{who}." + else: + label_text = ( + "Not authenticated. Click 'Login with QR code' to scan with the " + "Yandex app, or paste a music token manually below." + ) + + # Build dropdown options: one per YM instance + "Use own credentials" sentinel + source_options = [ + ConfigValueOption(f"Yandex Music: {name}", inst_id) for inst_id, name in ym_instances + ] + source_options.append(ConfigValueOption("Use own credentials (QR or token)", YM_INSTANCE_OWN)) + + # `selected` is normalized above, so it is always either a known instance + # id (borrowing) or YM_INSTANCE_OWN — safe to use directly as the default. + dropdown_default = selected + + # Own-mode-only entries are hidden when borrowing. + own_hidden = borrowing + # Token field requirement: in own mode it's only required when there's no + # alternative path (no stored x_token to refresh from). + token_required = not borrowing and not bool(values.get(CONF_X_TOKEN)) + + return ( + ConfigEntry( + key="label_text", + type=ConfigEntryType.LABEL, + label=label_text, + ), + ConfigEntry( + key=CONF_YM_INSTANCE, + type=ConfigEntryType.STRING, + label="Yandex Music source", + description="Borrow OAuth credentials from a linked Yandex Music provider " + "instance, or use your own credentials (QR-scan login or manual token paste). " + "Per-instance own credentials let you bind separate players to separate " + "Yandex accounts without sharing tokens with a Yandex Music provider.", + options=source_options, + default_value=dropdown_default, + required=True, + ), + # Own-mode: QR login button + ConfigEntry( + key=CONF_ACTION_AUTH_QR, + type=ConfigEntryType.ACTION, + label="Login with QR code", + description="Open a QR code in a popup and scan it with the Yandex app on " + "your phone. Populates the token automatically — no manual paste needed.", + action=CONF_ACTION_AUTH_QR, + action_label="Login with QR code", + hidden=own_hidden or own_authenticated, + ), + # Own-mode: remember-session toggle + ConfigEntry( + key=CONF_REMEMBER_SESSION, + type=ConfigEntryType.BOOLEAN, + label="Remember session (auto-refresh token)", + description="Store a long-lived session token (x_token) alongside the music " + "token so this plugin can refresh on its own when the token expires. " + "Disable to keep only the short-lived music token (re-QR required on expiry).", + default_value=True, + hidden=own_hidden or own_authenticated, + ), + # Own-mode: reset authentication + ConfigEntry( + key=CONF_ACTION_CLEAR_AUTH, + type=ConfigEntryType.ACTION, + label="Reset authentication", + description="Clear the current authentication details " + "(music token, session token, and stored login).", + action=CONF_ACTION_CLEAR_AUTH, + action_label="Reset authentication", + hidden=own_hidden or not own_authenticated, + ), + ConfigEntry( + key=CONF_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Yandex Music Token", + description="Manually pasted Yandex Music OAuth token. Populated " + "automatically after a successful QR login; only fill in by hand if " + "you can't use QR (e.g. headless setup).", + required=token_required, + hidden=borrowing, + value=cast("str", values.get(CONF_TOKEN)) if values else None, + ), + # Hidden: long-lived session token used for reactive 401 refresh + ConfigEntry( + key=CONF_X_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Session token (x_token)", + hidden=True, + required=False, + value=cast("str", values.get(CONF_X_TOKEN)) if values else None, + ), + # Hidden: cached display login for the status label + ConfigEntry( + key=CONF_ACCOUNT_LOGIN, + type=ConfigEntryType.STRING, + label="Account login", + hidden=True, + required=False, + value=cast("str", values.get(CONF_ACCOUNT_LOGIN)) if values else None, + ), + 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, + ), + 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, + ), + 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, + ), + 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, + ), + 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, + ), + ConfigEntry( + key=CONF_DEVICE_ID, + type=ConfigEntryType.STRING, + label="Device ID", + hidden=True, + required=False, + ), + ) diff --git a/music_assistant/providers/yandex_ynison/auth.py b/music_assistant/providers/yandex_ynison/auth.py new file mode 100644 index 0000000000..425c687c44 --- /dev/null +++ b/music_assistant/providers/yandex_ynison/auth.py @@ -0,0 +1,70 @@ +"""Yandex Passport authentication helpers. + +Delegates Passport interactions to the ``ya-passport-auth`` library. Two +helpers are exposed to the rest of the plugin: + +* :func:`perform_qr_auth` — full QR login (UI popup → tokens), used by the + own-mode config flow when the user clicks "Login with QR code". +* :func:`refresh_music_token` — exchange ``x_token`` for a fresh music-scoped + OAuth token. Called both in borrow mode (against the linked yandex_music + provider's x_token) and in own mode (against the plugin's own stored + x_token, when the user opted in to "Remember session"). +""" + +from __future__ import annotations + +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 + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +async def perform_qr_auth(mass: MusicAssistant, session_id: str) -> tuple[str, str, str | None]: + """Run a QR login flow and return ``(x_token, music_token, display_login)``. + + Opens the QR popup in the MA frontend via + :class:`music_assistant.helpers.auth.AuthenticationHelper`, polls the + Yandex Passport endpoint until the user confirms the scan in the Yandex + app, then returns the resulting tokens as plain strings (suitable for + MA config storage). + + ``display_login`` is the Yandex login name when the server returns it + (used by the config UI to render "Logged in as X"); may be ``None``. + """ + # AuthenticationHelper lives in the music_assistant *server* package, + # which isn't always installed in the plugin's standalone test/dev + # environment. Lazy import keeps `provider.auth` importable for unit + # tests that don't exercise this code path. + from music_assistant.helpers.auth import AuthenticationHelper # noqa: PLC0415 + + 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) + + if creds.music_token is None: + raise LoginFailed("QR auth succeeded but no music token was returned") + return ( + creds.x_token.get_secret(), + creds.music_token.get_secret(), + creds.display_login, + ) + 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 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..3b344df089 --- /dev/null +++ b/music_assistant/providers/yandex_ynison/config_helpers.py @@ -0,0 +1,20 @@ +"""Configuration helpers for the Yandex Ynison plugin.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +def list_yandex_music_instances(mass: MusicAssistant) -> list[tuple[str, str]]: + """List configured yandex_music provider instances as (instance_id, display_name) pairs.""" + instances: list[tuple[str, str]] = [] + raw_providers = mass.config.get("providers", {}) + for instance_id, prov_conf in raw_providers.items(): + if prov_conf.get("domain") != "yandex_music": + continue + display_name = prov_conf.get("name") or instance_id + instances.append((str(instance_id), str(display_name))) + return instances diff --git a/music_assistant/providers/yandex_ynison/constants.py b/music_assistant/providers/yandex_ynison/constants.py new file mode 100644 index 0000000000..fa73ff0a5c --- /dev/null +++ b/music_assistant/providers/yandex_ynison/constants.py @@ -0,0 +1,72 @@ +"""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_ACCOUNT_LOGIN: Final[str] = "account_login" +CONF_REMEMBER_SESSION: Final[str] = "remember_session" +CONF_YM_INSTANCE: Final[str] = "ym_instance" +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" + +# Action keys (own-mode QR auth flow) +CONF_ACTION_AUTH_QR: Final[str] = "auth_qr" +CONF_ACTION_CLEAR_AUTH: Final[str] = "clear_auth" + +# Special value for "auto" config options +OUTPUT_AUTO: Final[str] = "auto" + +# Sentinel value for CONF_YM_INSTANCE — use own manually entered token +YM_INSTANCE_OWN: Final[str] = "__own__" + +# Player selection +PLAYER_ID_AUTO: Final[str] = "__auto__" + +# yandex_music provider config keys (read via provider.config.get_value) +YANDEX_MUSIC_CONF_QUALITY: Final[str] = "quality" +YANDEX_MUSIC_CONF_TOKEN: Final[str] = "token" +YANDEX_MUSIC_CONF_X_TOKEN: Final[str] = "x_token" +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" +DEFAULT_APP_VERSION: Final[str] = "1.0.0" + +# Device types (from Ynison protobuf DeviceType enum) +DEVICE_TYPE_WEB: Final[str] = "WEB" + +# Reconnect settings — indexed by attempt number; attempts past the tuple +# saturate at the last entry (so reconnect continues forever at 60 s intervals). +RECONNECT_DELAYS: Final[tuple[float, ...]] = (5.0, 10.0, 30.0, 60.0) + +# 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/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..57f3334544 --- /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.3.0"], + "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..403b490458 --- /dev/null +++ b/music_assistant/providers/yandex_ynison/protocols.py @@ -0,0 +1,35 @@ +"""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.""" + ... diff --git a/music_assistant/providers/yandex_ynison/provider.py b/music_assistant/providers/yandex_ynison/provider.py new file mode 100644 index 0000000000..92eab3464f --- /dev/null +++ b/music_assistant/providers/yandex_ynison/provider.py @@ -0,0 +1,1479 @@ +"""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, + PlayerCommandFailed, + 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 .auth import refresh_music_token +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, + CONF_YM_INSTANCE, + DEFAULT_DISPLAY_NAME, + OUTPUT_AUTO, + PLAYER_ID_AUTO, + YANDEX_MUSIC_CONF_QUALITY, + YANDEX_MUSIC_CONF_TOKEN, + YANDEX_MUSIC_CONF_X_TOKEN, + YANDEX_MUSIC_LOSSLESS_QUALITIES, + YM_INSTANCE_OWN, +) +from .protocols import YandexMusicProviderLike +from .streaming import ( + PCM_LOSSLESS_PARAMS, + PCM_LOSSY_PARAMS, + PROBE_ARGS, + make_pcm_format, + pacing_args, +) +from .ynison_client import ( + YnisonClient, + YnisonDeviceInfo, + YnisonState, + generate_device_id, + make_version_block, +) + +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 + +# Accepted non-auto values for output format overrides; mirrors the options +# offered in CONF_OUTPUT_SAMPLE_RATE / CONF_OUTPUT_BIT_DEPTH config entries. +# Used defensively to reject stale/tampered values without raising. +_VALID_SAMPLE_RATES: frozenset[str] = frozenset({"44100", "48000", "96000"}) +_VALID_BIT_DEPTHS: frozenset[str] = frozenset({"16", "24"}) + + +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 + ) + + # Token source — None = own (manually entered CONF_TOKEN); + # otherwise the instance_id of a linked yandex_music provider to borrow from. + ym_instance_value = cast("str | None", self.config.get_value(CONF_YM_INSTANCE)) + self._ym_instance_id: str | None = ( + ym_instance_value + if ym_instance_value and ym_instance_value != YM_INSTANCE_OWN + else None + ) + + # 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 via + # YnisonState.last_update_is_echo and ignored. + self._streaming_progress_ms: int = 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.""" + if self._ym_instance_id is not None: + self.logger.info( + "Borrowing credentials from yandex_music instance '%s'", + self._ym_instance_id, + ) + else: + self.logger.info("Using manually configured Yandex Music token (no auto-refresh)") + 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, + 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(): + # Check state BEFORE clearing the event. Ynison may have already + # advanced between _signal_track_completion() returning and this + # method running; clearing first would drop the set() that went + # with the state update, leaving us to wait until timeout. + # Check is race-free: no await between the read and clear() below. + # None means empty/unreadable queue — treat as "not advanced." + if self._ynison: + current = self._ynison.state.current_track_id + if current is not None and current != old_track_id: + return True + 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 + 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 + + # Re-capture the provider after the above await: _yandex_provider may + # have flipped to None while we were fetching stream details. Using + # the attribute directly below would race with + # _check_yandex_provider_match. + provider = self._yandex_provider + if provider is None: + self.logger.warning( + "Linked Yandex Music provider unloaded mid-stream — stopping 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, + ) + async for chunk in get_ffmpeg_stream( + audio_input=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.""" + # Capture the linked yandex_music provider into a local ref at entry. + # self._yandex_provider can flip to None mid-await when the linked + # MusicProvider is unloaded (see _check_yandex_provider_match, which + # runs as a background task on provider-loaded/unloaded events). + # Dereferencing the attribute after an await would raise + # AttributeError and hard-stop the audio generator. + provider = self._yandex_provider + if provider is None: + raise LoginFailed( + "Linked Yandex Music provider is not loaded — cannot fetch stream details" + ) + + 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 provider.get_stream_details(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 + # ------------------------------------------------------------------ + + def _read_ym_tokens(self) -> tuple[str | None, str | None]: + """Read token/x_token from the linked yandex_music provider's config. + + Borrow-mode only — callers must check ``self._ym_instance_id is not None`` + before calling. Raises LoginFailed with a distinct message when the + linked YM provider is not currently loaded — separate from the + "loaded but unauthenticated" case so operators can tell the two apart. + """ + assert self._ym_instance_id is not None, "Caller must check borrow mode before calling" + ym_provider = self.mass.get_provider(self._ym_instance_id) + if ym_provider is None: + raise LoginFailed( + f"Linked Yandex Music instance '{self._ym_instance_id}' is not loaded. " + "Check that the Yandex Music provider is enabled and configured." + ) + # Guard against a stale/manually-edited instance id pointing at a non-YM + # provider — otherwise reading unrelated config keys yields a misleading + # "no credentials" error further down. + if ym_provider.domain != "yandex_music" or ym_provider.type != ProviderType.MUSIC: + raise LoginFailed( + f"Linked provider instance '{self._ym_instance_id}' is not a Yandex Music " + f"music provider (domain={ym_provider.domain!r}, type={ym_provider.type!r}). " + "Re-select the Yandex Music source in this plugin's configuration." + ) + token = cast("str | None", ym_provider.config.get_value(YANDEX_MUSIC_CONF_TOKEN)) + x_token = cast("str | None", ym_provider.config.get_value(YANDEX_MUSIC_CONF_X_TOKEN)) + return (token, x_token) + + async def _resolve_token(self) -> SecretStr: + """Resolve the Yandex Music OAuth token for the Ynison connection. + + In borrow mode: read from the linked yandex_music provider's config. + If only x_token is present (YM hasn't refreshed yet), do a one-shot + in-memory refresh without writing back — YM owns token persistence. + + In own mode: return CONF_TOKEN if set; otherwise, when CONF_X_TOKEN + is present (QR-with-Remember-session path), refresh in-memory. + """ + if self._ym_instance_id is not None: + token, x_token = self._read_ym_tokens() + if token: + return SecretStr(token) + if x_token: + self.logger.debug("YM token not yet refreshed — refreshing in-memory") + return await refresh_music_token(SecretStr(x_token)) + raise LoginFailed(f"Yandex Music instance '{self._ym_instance_id}' has no credentials") + + token = cast("str | None", self.config.get_value(CONF_TOKEN)) + if token: + return SecretStr(token) + x_token = cast("str | None", self.config.get_value(CONF_X_TOKEN)) + if x_token: + self.logger.debug("Own-mode token not present — refreshing from stored x_token") + return await refresh_music_token(SecretStr(x_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. + + In borrow mode: re-read the linked YM instance's x_token and refresh + in-memory only (no config writes — YM owns token persistence). + + In own mode: refresh from stored CONF_X_TOKEN when present (QR with + "Remember session" enabled). When absent (manual token paste only), + surface LoginFailed so the user knows to paste a new token. + """ + if self._ym_instance_id is not None: + _, x_token = self._read_ym_tokens() + if not x_token: + raise LoginFailed("Cannot refresh: linked Yandex Music instance has no x_token") + self.logger.info("Refreshing Yandex Music token for Ynison reconnect (borrow mode)") + return await refresh_music_token(SecretStr(x_token)) + + x_token = cast("str | None", self.config.get_value(CONF_X_TOKEN)) + if x_token: + self.logger.info("Refreshing Yandex Music token for Ynison reconnect (own mode)") + return await refresh_music_token(SecretStr(x_token)) + + raise LoginFailed( + "Token expired and no stored x_token to refresh from. Re-authenticate " + "via QR or paste a fresh Yandex Music 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 (updates authored by our own device_id) to + # prevent feedback loops where our own progress triggers false seeks. + now = time.monotonic() + if now < self._seek_grace_until: + pass # Skip during grace period after track change or seek + elif state.last_update_is_echo: + pass # Echo of our own update — ignore + else: + our_ms = self._streaming_progress_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 + ) + + async def _send_progress_to_ynison( + self, progress_ms: int, duration_ms: int, paused: bool + ) -> None: + """Send progress to Ynison. + + 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 detection is done upstream via YnisonState.last_update_is_echo, + which is set when Ynison rebroadcasts an update we authored. + """ + 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, + ) + + 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. + self._streaming_progress_ms = paused_progress_ms + 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) + + # ------------------------------------------------------------------ + # 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._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. + + In borrow mode (self._ym_instance_id set), match strictly by instance_id + so that audio and credentials come from the same account. In own mode, + accept any yandex_music music-provider (prior behavior). + """ + for provider in self.mass.get_providers(): + if provider.domain != "yandex_music" or provider.type != ProviderType.MUSIC: + continue + if self._ym_instance_id is not None and provider.instance_id != self._ym_instance_id: + continue + 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 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 config + # (yandex_music does not expose get_quality(); read from its ProviderConfig instead) + quality = "" + 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. MA's ConfigEntry options constrain the UI to + # known-good strings, but a stale persisted value or hand-edited config + # could still surface something unparsable or off-list — fall back to + # the auto-detected base with a warning instead of crashing the load. + sample_rate = base["sample_rate"] + bit_depth = base["bit_depth"] + if self._cfg_sample_rate != OUTPUT_AUTO: + if self._cfg_sample_rate in _VALID_SAMPLE_RATES: + sample_rate = int(self._cfg_sample_rate) + else: + self.logger.warning( + "Invalid %s=%r; falling back to auto-detected %d Hz", + CONF_OUTPUT_SAMPLE_RATE, + self._cfg_sample_rate, + sample_rate, + ) + if self._cfg_bit_depth != OUTPUT_AUTO: + if self._cfg_bit_depth in _VALID_BIT_DEPTHS: + bit_depth = int(self._cfg_bit_depth) + else: + self.logger.warning( + "Invalid %s=%r; falling back to auto-detected %d-bit", + CONF_OUTPUT_BIT_DEPTH, + self._cfg_bit_depth, + 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("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, + 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("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, + 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", {}) + device_id = self._ynison.device_id + new_state = dict(state.player_state) + new_state["player_queue"] = dict(queue) + new_state["player_queue"]["current_playable_index"] = next_index + new_state["player_queue"]["version"] = make_version_block(device_id) + 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 + new_state["status"]["version"] = make_version_block(device_id) + 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", {}) + device_id = self._ynison.device_id + new_state = dict(state.player_state) + new_state["player_queue"] = dict(queue) + new_state["player_queue"]["playable_list"] = expanded_list + new_state["player_queue"]["version"] = make_version_block(device_id) + 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("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("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: + 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("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( + 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..e47b0a02d4 --- /dev/null +++ b/music_assistant/providers/yandex_ynison/streaming.py @@ -0,0 +1,47 @@ +"""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 + +# 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/ynison_client.py b/music_assistant/providers/yandex_ynison/ynison_client.py new file mode 100644 index 0000000000..106e67a312 --- /dev/null +++ b/music_assistant/providers/yandex_ynison/ynison_client.py @@ -0,0 +1,734 @@ +"""Ynison WebSocket client for Yandex Music device synchronization.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import random +import secrets +import time +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, + RECONNECT_DELAYS, + WS_CONNECT_TIMEOUT, + WS_HEARTBEAT, + YNISON_ORIGIN, + YNISON_RECONNECT_ERROR_CODES, + YNISON_REDIRECT_URL, + YNISON_STATE_PATH, +) + + +def make_version_block(device_id: str) -> dict[str, Any]: + """Build a version sub-object authored by the given device. + + Ynison expects string types for version and timestamp fields; + passing integers triggers 500 responses that terminate the WebSocket. + """ + return { + "device_id": device_id, + "version": str(time.time_ns()), + "timestamp_ms": "0", + } + + +def _stringify_version(version: Any) -> None: + """Coerce int `version.version`/`version.timestamp_ms` fields to str in-place.""" + if not isinstance(version, dict): + return + for key in ("version", "timestamp_ms"): + val = version.get(key) + if isinstance(val, int) and not isinstance(val, bool): + version[key] = str(val) + + +def normalize_player_state_timestamps(player_state: dict[str, Any]) -> None: + """Coerce Ynison timestamp fields to strings in-place. + + Ynison rejects integer `status.progress_ms`/`duration_ms`/`version.*` + (HTTP 500 + WS teardown), so we normalize inbound state at the ingestion + boundary. This guarantees that every outbound echo — whether via + `update_player_state` (from shallow-copied state) or `send_full_state` + on reconnect — carries string-typed timestamps by construction. + """ + status = player_state.get("status") + if isinstance(status, dict): + for key in ("progress_ms", "duration_ms", "player_action_timestamp_ms"): + val = status.get(key) + if isinstance(val, int) and not isinstance(val, bool): + status[key] = str(val) + _stringify_version(status.get("version")) + queue = player_state.get("player_queue") + if isinstance(queue, dict): + _stringify_version(queue.get("version")) + + +@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) + # True iff the most recent state update carried a version block + # (on player_queue or status) authored by our own device_id — i.e. + # it is Ynison echoing back an update we originated. Consumers can + # inspect this to suppress feedback loops. False when no authored + # version block is present (e.g. status-only update from a peer + # that did not round-trip via our device). + last_update_is_echo: bool = False + + @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]] +# 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, + 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 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._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 + self._has_connected_once = False + + # Latest state from server + self.state = YnisonState() + + @property + def connected(self) -> bool: + """Return True if connected to Ynison state service.""" + return self._connected + + @property + def device_id(self) -> str: + """Return our Ynison device_id (used when authoring outgoing state).""" + return self._device_info.device_id + + 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.create_task(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. + + Ynison expects string-typed timestamps; integers cause 500 responses. + """ + return { + "rid": str(uuid.uuid4()), + "player_action_timestamp_ms": str(int(time.time() * 1000)), + "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": str(progress_ms), + "duration_ms": str(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": make_version_block(device_id), + }, + "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": make_version_block(device_id), + "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) + + # 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.create_task(self._message_loop()) + + 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") + 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: + error_info = data["error"] + error_code = error_info.get("details", {}).get("ynison-error-code", "") + self._logger.warning( + "Ynison error response: %s", + 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) + 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.create_task(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 + ) + + # 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: + # Normalize timestamp fields before storing: Ynison rejects int + # `status.progress_ms`/`duration_ms`/`version.*` on outbound + # messages, and stored state is round-tripped via send_full_state + # (on reconnect) and update_player_state (on queue edits). + normalize_player_state_timestamps(incoming_ps) + existing_ps = self.state.player_state + for key, value in incoming_ps.items(): + existing_ps[key] = value + # Echo detection via version.device_id: Ynison preserves the + # `version` block we authored, so a broadcast whose version + # author matches us is our own update round-tripping back. + # Check both player_queue and status so status-only echoes + # (e.g. of update_playing_status) are caught too. + own_id = self._device_info.device_id + queue_author = ( + (incoming_ps.get("player_queue") or {}).get("version", {}).get("device_id") + ) + status_author = (incoming_ps.get("status") or {}).get("version", {}).get("device_id") + self.state.last_update_is_echo = own_id in (queue_author, status_author) + else: + self.state.last_update_is_echo = False + 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, retrying indefinitely. + + On authentication failure (LoginFailed), attempts to refresh the token + via the on_auth_failure callback before the next retry. The loop only + exits when `_stop_event` is set (via disconnect()) or on successful + reconnection; a reliable long-running plugin never permanently gives up. + """ + attempt = 0 + while not self._stop_event.is_set(): + 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 in %.1fs", attempt + 1, delay) + await asyncio.sleep(delay) + + if self._stop_event.is_set(): + return + + attempt += 1 + 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) + 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, exc_info=True) + + 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.create_task(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/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_auth.py b/tests/providers/yandex_ynison/test_auth.py new file mode 100644 index 0000000000..14c4cc66f2 --- /dev/null +++ b/tests/providers/yandex_ynison/test_auth.py @@ -0,0 +1,166 @@ +"""Unit tests for provider/auth.py (ya-passport-auth wrapper).""" + +from __future__ import annotations + +from unittest import mock + +import pytest +from music_assistant_models.errors import LoginFailed +from ya_passport_auth import SecretStr +from ya_passport_auth.exceptions import ( + InvalidCredentialsError, + QRTimeoutError, + YaPassportError, +) + +from music_assistant.providers.yandex_ynison.auth import perform_qr_auth, refresh_music_token + +# --------------------------------------------------------------- +# 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.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.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")) + + +# --------------------------------------------------------------- +# perform_qr_auth +# --------------------------------------------------------------- + + +def _make_creds( + *, + music_token: str | None = "music-tok", # noqa: S107 — fixture stub, not a credential + display_login: str | None = "alice", +) -> mock.MagicMock: + """Build a stub Credentials object with a SecretStr-shaped x_token/music_token.""" + creds = mock.MagicMock() + creds.x_token = SecretStr("x-tok") + creds.music_token = SecretStr(music_token) if music_token is not None else None + creds.display_login = display_login + return creds + + +def _patched_passport(client: mock.AsyncMock) -> mock._patch[mock.MagicMock]: + """Patch PassportClient.create() to yield the given mock client.""" + patcher = mock.patch("music_assistant.providers.yandex_ynison.auth.PassportClient.create") + mock_create = patcher.start() + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + return patcher + + +def _patched_auth_helper() -> tuple[mock._patch[mock.MagicMock], mock.MagicMock]: + """Patch music_assistant.helpers.auth.AuthenticationHelper at its source. + + perform_qr_auth lazy-imports this symbol inside the function body, so the + patch target is the original module path — not ``provider.auth.``. + """ + patcher = mock.patch("music_assistant.helpers.auth.AuthenticationHelper") + mock_cls = patcher.start() + helper = mock.MagicMock() + helper.send_url = mock.MagicMock() + mock_cls.return_value.__aenter__ = mock.AsyncMock(return_value=helper) + mock_cls.return_value.__aexit__ = mock.AsyncMock(return_value=False) + return patcher, helper + + +async def test_perform_qr_auth_success() -> None: + """Happy path: returns (x_token, music_token, display_login) and pushes QR URL.""" + qr = mock.MagicMock(qr_url="https://passport.yandex.ru/auth/magic/code/?track_id=abc") + client = mock.AsyncMock() + client.start_qr_login.return_value = qr + client.poll_qr_until_confirmed.return_value = _make_creds() + + p1 = _patched_passport(client) + p2, helper = _patched_auth_helper() + try: + x_token, music_token, login = await perform_qr_auth(mock.MagicMock(), "session-1") + finally: + p1.stop() + p2.stop() + + assert x_token == "x-tok" + assert music_token == "music-tok" + assert login == "alice" + helper.send_url.assert_called_once_with(qr.qr_url) + + +async def test_perform_qr_auth_no_music_token_raises_login_failed() -> None: + """If creds.music_token is None we cannot proceed — surface LoginFailed.""" + qr = mock.MagicMock(qr_url="https://example/qr") + client = mock.AsyncMock() + client.start_qr_login.return_value = qr + client.poll_qr_until_confirmed.return_value = _make_creds(music_token=None) + + p1 = _patched_passport(client) + p2, _helper = _patched_auth_helper() + try: + with pytest.raises(LoginFailed, match="no music token"): + await perform_qr_auth(mock.MagicMock(), "session-1") + finally: + p1.stop() + p2.stop() + + +async def test_perform_qr_auth_timeout_maps_to_login_failed() -> None: + """QRTimeoutError from polling becomes a friendly LoginFailed message.""" + qr = mock.MagicMock(qr_url="https://example/qr") + client = mock.AsyncMock() + client.start_qr_login.return_value = qr + client.poll_qr_until_confirmed.side_effect = QRTimeoutError("expired") + + p1 = _patched_passport(client) + p2, _helper = _patched_auth_helper() + try: + with pytest.raises(LoginFailed, match="timed out"): + await perform_qr_auth(mock.MagicMock(), "session-1") + finally: + p1.stop() + p2.stop() + + +async def test_perform_qr_auth_passport_error_maps_to_login_failed() -> None: + """Generic YaPassportError becomes LoginFailed with a context-bearing message.""" + qr = mock.MagicMock(qr_url="https://example/qr") + client = mock.AsyncMock() + client.start_qr_login.return_value = qr + client.poll_qr_until_confirmed.side_effect = YaPassportError("network down") + + p1 = _patched_passport(client) + p2, _helper = _patched_auth_helper() + try: + with pytest.raises(LoginFailed, match="Yandex auth error"): + await perform_qr_auth(mock.MagicMock(), "session-1") + finally: + p1.stop() + p2.stop() diff --git a/tests/providers/yandex_ynison/test_config_entries.py b/tests/providers/yandex_ynison/test_config_entries.py new file mode 100644 index 0000000000..cf2b37df99 --- /dev/null +++ b/tests/providers/yandex_ynison/test_config_entries.py @@ -0,0 +1,313 @@ +"""Tests for get_config_entries source-selection behavior.""" + +from __future__ import annotations + +from typing import Any +from unittest import mock +from unittest.mock import MagicMock + +import pytest +from music_assistant_models.errors import LoginFailed + +from music_assistant.providers.yandex_ynison import get_config_entries +from music_assistant.providers.yandex_ynison.constants import ( + CONF_ACCOUNT_LOGIN, + CONF_ACTION_AUTH_QR, + CONF_ACTION_CLEAR_AUTH, + CONF_REMEMBER_SESSION, + CONF_TOKEN, + CONF_X_TOKEN, + CONF_YM_INSTANCE, + YM_INSTANCE_OWN, +) + + +def _make_mock_mass(providers_config: dict[str, Any] | None = None) -> MagicMock: + """Build a MusicAssistant stub with a configurable providers registry.""" + mass = MagicMock() + mass.config.get = MagicMock(return_value=providers_config or {}) + mass.players.all_players = MagicMock(return_value=[]) + return mass + + +def _entries_by_key(entries: tuple[Any, ...]) -> dict[str, Any]: + return {entry.key: entry for entry in entries} + + +async def test_no_ym_instances_defaults_to_own_and_shows_token_field() -> None: + """With 0 YM instances, dropdown has only Own and token field is visible.""" + mass = _make_mock_mass({}) + entries = await get_config_entries(mass) + by_key = _entries_by_key(entries) + + ym_source = by_key[CONF_YM_INSTANCE] + option_values = [opt.value for opt in ym_source.options] + assert option_values == [YM_INSTANCE_OWN] + assert ym_source.default_value == YM_INSTANCE_OWN + + token = by_key[CONF_TOKEN] + assert token.hidden is False + assert token.required is True + + +async def test_single_ym_instance_defaults_to_borrow_and_hides_token() -> None: + """With exactly 1 YM instance, default borrows and token field is hidden.""" + mass = _make_mock_mass({"ym-a": {"domain": "yandex_music", "name": "Primary"}}) + entries = await get_config_entries(mass) + by_key = _entries_by_key(entries) + + ym_source = by_key[CONF_YM_INSTANCE] + assert ym_source.default_value == "ym-a" + + token = by_key[CONF_TOKEN] + assert token.hidden is True + assert token.required is False + + +async def test_multiple_ym_instances_default_to_own_requiring_explicit_choice() -> None: + """With 2+ YM instances, default is OWN (user must pick explicitly).""" + mass = _make_mock_mass( + { + "ym-a": {"domain": "yandex_music", "name": "A"}, + "ym-b": {"domain": "yandex_music", "name": "B"}, + } + ) + entries = await get_config_entries(mass) + by_key = _entries_by_key(entries) + + ym_source = by_key[CONF_YM_INSTANCE] + option_values = {opt.value for opt in ym_source.options} + assert {"ym-a", "ym-b", YM_INSTANCE_OWN} == option_values + assert ym_source.default_value == YM_INSTANCE_OWN + + token = by_key[CONF_TOKEN] + assert token.hidden is False + assert token.required is True + + +async def test_selected_ym_instance_hides_token() -> None: + """When values selects a real YM instance, token field is hidden/optional.""" + mass = _make_mock_mass({"ym-a": {"domain": "yandex_music", "name": "Primary"}}) + entries = await get_config_entries(mass, values={CONF_YM_INSTANCE: "ym-a"}) + by_key = _entries_by_key(entries) + + token = by_key[CONF_TOKEN] + assert token.hidden is True + assert token.required is False + + +async def test_selected_own_shows_token() -> None: + """When values selects OWN, token field is visible and required.""" + mass = _make_mock_mass({"ym-a": {"domain": "yandex_music", "name": "Primary"}}) + entries = await get_config_entries(mass, values={CONF_YM_INSTANCE: YM_INSTANCE_OWN}) + by_key = _entries_by_key(entries) + + token = by_key[CONF_TOKEN] + assert token.hidden is False + assert token.required is True + + +async def test_upgrade_with_existing_token_preserves_own_mode() -> None: + """Upgrade from own-mode (CONF_TOKEN set, CONF_YM_INSTANCE absent) stays OWN. + + Even if exactly one yandex_music instance exists, we must not silently + switch the user's auth source on a no-op Save after upgrade. + """ + mass = _make_mock_mass({"ym-a": {"domain": "yandex_music", "name": "Primary"}}) + entries = await get_config_entries(mass, values={CONF_TOKEN: "legacy-token"}) + by_key = _entries_by_key(entries) + + ym_source = by_key[CONF_YM_INSTANCE] + assert ym_source.default_value == YM_INSTANCE_OWN + + token = by_key[CONF_TOKEN] + assert token.hidden is False + assert token.required is True + + +async def test_own_mode_surfaces_qr_login_button() -> None: + """Own mode unauthenticated → QR action visible, reset action hidden.""" + mass = _make_mock_mass({}) + entries = await get_config_entries(mass, values={CONF_YM_INSTANCE: YM_INSTANCE_OWN}) + by_key = _entries_by_key(entries) + + qr = by_key[CONF_ACTION_AUTH_QR] + reset = by_key[CONF_ACTION_CLEAR_AUTH] + remember = by_key[CONF_REMEMBER_SESSION] + assert qr.hidden is False + assert qr.action == CONF_ACTION_AUTH_QR + assert remember.hidden is False + assert remember.default_value is True + assert reset.hidden is True + + +async def test_borrow_mode_hides_own_mode_actions() -> None: + """Borrow mode → QR / reset / remember-session entries are all hidden.""" + mass = _make_mock_mass({"ym-a": {"domain": "yandex_music", "name": "Primary"}}) + entries = await get_config_entries(mass, values={CONF_YM_INSTANCE: "ym-a"}) + by_key = _entries_by_key(entries) + + assert by_key[CONF_ACTION_AUTH_QR].hidden is True + assert by_key[CONF_ACTION_CLEAR_AUTH].hidden is True + assert by_key[CONF_REMEMBER_SESSION].hidden is True + + +async def test_authenticated_own_mode_shows_reset_hides_qr() -> None: + """Once a token is stored, hide the QR/remember entries and show reset.""" + mass = _make_mock_mass({}) + entries = await get_config_entries( + mass, + values={ + CONF_YM_INSTANCE: YM_INSTANCE_OWN, + CONF_TOKEN: "music-tok", + CONF_X_TOKEN: "x-tok", + CONF_ACCOUNT_LOGIN: "alice", + }, + ) + by_key = _entries_by_key(entries) + + assert by_key[CONF_ACTION_AUTH_QR].hidden is True + assert by_key[CONF_REMEMBER_SESSION].hidden is True + assert by_key[CONF_ACTION_CLEAR_AUTH].hidden is False + + +async def test_qr_action_persists_tokens_into_values() -> None: + """CONF_ACTION_AUTH_QR action calls perform_qr_auth and stores both tokens.""" + mass = _make_mock_mass({}) + values: dict[str, Any] = { + CONF_YM_INSTANCE: YM_INSTANCE_OWN, + "session_id": "sess-1", + } + + with mock.patch( + "music_assistant.providers.yandex_ynison.perform_qr_auth", + new=mock.AsyncMock(return_value=("x-tok", "music-tok", "alice")), + ) as mocked: + await get_config_entries(mass, action=CONF_ACTION_AUTH_QR, values=values) + + mocked.assert_awaited_once_with(mass, "sess-1") + assert values[CONF_TOKEN] == "music-tok" + assert values[CONF_X_TOKEN] == "x-tok" + assert values[CONF_ACCOUNT_LOGIN] == "alice" + + +async def test_qr_action_without_remember_session_skips_x_token() -> None: + """remember_session=False → music token stored, x_token cleared.""" + mass = _make_mock_mass({}) + values: dict[str, Any] = { + CONF_YM_INSTANCE: YM_INSTANCE_OWN, + CONF_REMEMBER_SESSION: False, + "session_id": "sess-1", + } + + with mock.patch( + "music_assistant.providers.yandex_ynison.perform_qr_auth", + new=mock.AsyncMock(return_value=("x-tok", "music-tok", "alice")), + ): + await get_config_entries(mass, action=CONF_ACTION_AUTH_QR, values=values) + + assert values[CONF_TOKEN] == "music-tok" + assert values[CONF_X_TOKEN] is None + assert values[CONF_ACCOUNT_LOGIN] == "alice" + + +async def test_qr_action_in_borrow_mode_is_refused() -> None: + """A stray QR action while the dropdown is on borrow must not mutate values.""" + mass = _make_mock_mass({"ym-a": {"domain": "yandex_music", "name": "Primary"}}) + values: dict[str, Any] = {CONF_YM_INSTANCE: "ym-a", "session_id": "sess-1"} + + with ( + mock.patch( + "music_assistant.providers.yandex_ynison.perform_qr_auth", new=mock.AsyncMock() + ) as mocked, + pytest.raises(LoginFailed, match="own-mode action"), + ): + await get_config_entries(mass, action=CONF_ACTION_AUTH_QR, values=values) + + mocked.assert_not_awaited() + assert CONF_TOKEN not in values + assert CONF_X_TOKEN not in values + + +async def test_clear_action_in_borrow_mode_is_refused() -> None: + """Clear-auth must also be refused outside own mode.""" + mass = _make_mock_mass({"ym-a": {"domain": "yandex_music", "name": "Primary"}}) + values: dict[str, Any] = { + CONF_YM_INSTANCE: "ym-a", + CONF_TOKEN: "leftover", + CONF_X_TOKEN: "leftover-x", + } + + with pytest.raises(LoginFailed, match="own-mode action"): + await get_config_entries(mass, action=CONF_ACTION_CLEAR_AUTH, values=values) + + # Borrow-mode token fields must be untouched on refusal. + assert values[CONF_TOKEN] == "leftover" + assert values[CONF_X_TOKEN] == "leftover-x" + + +async def test_qr_action_without_session_id_raises() -> None: + """Missing session_id is a programmer error from the MA frontend.""" + mass = _make_mock_mass({}) + with pytest.raises(LoginFailed, match="session_id"): + await get_config_entries( + mass, + action=CONF_ACTION_AUTH_QR, + values={CONF_YM_INSTANCE: YM_INSTANCE_OWN}, + ) + + +async def test_clear_auth_action_zeroes_token_x_token_login() -> None: + """CONF_ACTION_CLEAR_AUTH wipes all three persisted auth fields.""" + mass = _make_mock_mass({}) + values: dict[str, Any] = { + CONF_YM_INSTANCE: YM_INSTANCE_OWN, + CONF_TOKEN: "music-tok", + CONF_X_TOKEN: "x-tok", + CONF_ACCOUNT_LOGIN: "alice", + } + + await get_config_entries(mass, action=CONF_ACTION_CLEAR_AUTH, values=values) + + assert values[CONF_TOKEN] is None + assert values[CONF_X_TOKEN] is None + assert values[CONF_ACCOUNT_LOGIN] is None + + +async def test_own_mode_with_only_x_token_marks_token_optional() -> None: + """Stored x_token alone is enough — the token field becomes optional.""" + mass = _make_mock_mass({}) + entries = await get_config_entries( + mass, + values={CONF_YM_INSTANCE: YM_INSTANCE_OWN, CONF_X_TOKEN: "x-tok"}, + ) + by_key = _entries_by_key(entries) + token = by_key[CONF_TOKEN] + assert token.required is False + + +async def test_stale_ym_selection_normalizes_to_own() -> None: + """A saved selection pointing at a removed YM instance is normalized to OWN. + + Guards against the dropdown rendering with a default_value that is not in + its options, AND ensures the in-memory `values` dict is rewritten so a + no-touch Save persists the correction (otherwise the stored config stays + stale and the provider keeps trying borrow-mode against a missing instance + until the user manually re-saves). + """ + mass = _make_mock_mass({"ym-b": {"domain": "yandex_music", "name": "B"}}) + values: dict[str, object] = {CONF_YM_INSTANCE: "ym-removed"} + entries = await get_config_entries(mass, values=values) # type: ignore[arg-type] + by_key = _entries_by_key(entries) + + ym_source = by_key[CONF_YM_INSTANCE] + option_values = {opt.value for opt in ym_source.options} + assert ym_source.default_value == YM_INSTANCE_OWN + assert ym_source.default_value in option_values + # The stale id must be rewritten in `values` so a Save without touching the + # dropdown persists the corrected selection. + assert values[CONF_YM_INSTANCE] == YM_INSTANCE_OWN + + token = by_key[CONF_TOKEN] + assert token.hidden is False + assert token.required is True diff --git a/tests/providers/yandex_ynison/test_provider.py b/tests/providers/yandex_ynison/test_provider.py new file mode 100644 index 0000000000..7cc26ecb1f --- /dev/null +++ b/tests/providers/yandex_ynison/test_provider.py @@ -0,0 +1,2232 @@ +"""Tests for the YandexYnisonProvider.""" + +from __future__ import annotations + +import asyncio +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 list_yandex_music_instances +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, + CONF_YM_INSTANCE, + DEFAULT_DISPLAY_NAME, + OUTPUT_AUTO, + PLAYER_ID_AUTO, + YM_INSTANCE_OWN, +) +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 _stub_attr(obj: object, name: str, value: Any) -> None: + """Assign ``value`` to ``obj.name`` bypassing mypy method-assign and ruff B010. + + Used in tests to replace a real instance method on a strictly typed object + (e.g. ``provider.mass.get_provider``) with a MagicMock. + """ + setattr(obj, name, value) + + +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_YM_INSTANCE: YM_INSTANCE_OWN, + 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). + # Mark as echo so the seek-detection branch stays quiet. + 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"}], + }, + }, + last_update_is_echo=True, + ) + + # 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": 0, + "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": 0, + "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_returns_immediately_if_already_advanced( + self, + ) -> None: + """If Ynison already advanced before the call, return True without waiting. + + Regression: _wait_for_track_change used to clear _track_changed_event + before checking state, so a state update that arrived between + _signal_track_completion() and this method losing the signal and + stalled for the full 30s timeout. + """ + provider = _make_provider() + mock_ynison = MagicMock() + # State already shows the NEW track at the time of entry + mock_ynison.state = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": {"progress_ms": 0, "duration_ms": 0}, + "player_queue": { + "current_playable_index": 0, + "playable_list": [{"playable_id": "new_track"}], + "entity_id": "user:onyourwave", + "entity_type": "RADIO", + }, + }, + ) + provider._ynison = mock_ynison + # Event is already set (from the _activate_playback that ran before us) + # but pre-check in _wait_for_track_change should catch this regardless. + provider._track_changed_event.set() + + # Tight timeout would fail if pre-check were absent — state check must + # happen before clear()+wait(). + result = await provider._wait_for_track_change("old_track", timeout=0.1) + 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.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 + 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.config.get_value = 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_invalid_sample_rate_override_falls_back_to_auto(self) -> None: + """Stale/tampered output_sample_rate values fall back to auto-detected, not crash.""" + provider = _make_provider() + provider._cfg_sample_rate = "bogus" + provider._cfg_bit_depth = OUTPUT_AUTO + + mock_yandex = MagicMock() + mock_yandex.domain = "yandex_music" + mock_yandex.type = ProviderType.MUSIC + mock_yandex.config.get_value = MagicMock(return_value="superb") + provider._yandex_provider = mock_yandex + provider._update_normalized_format() + + assert provider._normalized_format.sample_rate == 48000 + assert provider._normalized_format.bit_depth == 24 + assert provider._normalized_format.content_type == ContentType.PCM_S24LE + + async def test_invalid_bit_depth_override_falls_back_to_auto(self) -> None: + """Off-list output_bit_depth falls back to auto base, keeping content_type consistent.""" + provider = _make_provider() + provider._cfg_sample_rate = OUTPUT_AUTO + # 32-bit is not offered; previously this would silently become S16LE + provider._cfg_bit_depth = "32" + + mock_yandex = MagicMock() + mock_yandex.domain = "yandex_music" + mock_yandex.type = ProviderType.MUSIC + mock_yandex.config.get_value = MagicMock(return_value="superb") + provider._yandex_provider = mock_yandex + provider._update_normalized_format() + + assert provider._normalized_format.bit_depth == 24 + assert provider._normalized_format.content_type == ContentType.PCM_S24LE + + 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 == [] + + async def test_stream_track_provider_unloaded_mid_stream_aborts_cleanly( + self, + ) -> None: + """Unloaded linked provider mid-stream aborts cleanly (no AttributeError). + + Regression: the path between `await _get_stream_details_with_retry` + and the ffmpeg stream builder used to dereference + `self._yandex_provider` directly, racing with + `_check_yandex_provider_match` which nulls the attribute on unload. + """ + provider = _make_provider() + mock_yandex = MagicMock() + sd = MagicMock() + sd.expiration = 600 + sd.audio_format = MagicMock() + sd.to_dict.return_value = {"track_id": "t1"} + sd.data = {"url": "https://cdn.example.com/audio.mp3"} + + async def fetch_and_null(_track_id: str, _media_type: Any = None) -> Any: + # Simulate the background unload task firing while we awaited. + provider._yandex_provider = None + return sd + + mock_yandex.get_stream_details = AsyncMock(side_effect=fetch_and_null) + provider._yandex_provider = mock_yandex + + collected: list[bytes] = [] + async for chunk in provider._stream_track("t1"): + collected.append(chunk) + + assert collected == [] + assert provider._stream_stop_event.is_set() + + +def _make_ym_provider_stub( + instance_id: str = "ym-inst", + token: str | None = None, + x_token: str | None = None, +) -> MagicMock: + """Build a stub yandex_music provider with a config exposing token/x_token.""" + values: dict[str, Any] = {"token": token, "x_token": x_token} + ym_config = MagicMock() + ym_config.get_value.side_effect = values.get + ym = MagicMock() + ym.instance_id = instance_id + ym.domain = "yandex_music" + ym.type = ProviderType.MUSIC + ym.config = ym_config + return ym + + +class TestResolveTokenOwnMode: + """_resolve_token in own mode (manual token, no refresh).""" + + async def test_returns_stored_token(self) -> None: + """Returns the manually configured music token as-is.""" + provider = _make_provider() + provider.config = _make_mock_config( + {CONF_TOKEN: "manual-token", CONF_YM_INSTANCE: YM_INSTANCE_OWN} + ) + provider._ym_instance_id = None + + result = await provider._resolve_token() + + assert result.get_secret() == "manual-token" + + async def test_raises_when_no_token(self) -> None: + """Raises LoginFailed when CONF_TOKEN and CONF_X_TOKEN are both empty.""" + provider = _make_provider() + provider.config = _make_mock_config( + {CONF_TOKEN: None, CONF_X_TOKEN: None, CONF_YM_INSTANCE: YM_INSTANCE_OWN} + ) + provider._ym_instance_id = None + + with pytest.raises(LoginFailed, match="No Yandex Music token"): + await provider._resolve_token() + + async def test_falls_back_to_x_token_refresh_when_token_missing(self) -> None: + """Own mode with stored x_token but no music token refreshes in-memory.""" + provider = _make_provider() + provider.config = _make_mock_config( + {CONF_TOKEN: None, CONF_X_TOKEN: "own-x-token", CONF_YM_INSTANCE: YM_INSTANCE_OWN} + ) + provider._ym_instance_id = None + + with patch( + "music_assistant.providers.yandex_ynison.provider.refresh_music_token", + new_callable=AsyncMock, + return_value=SecretStr("refreshed"), + ) as mock_refresh: + result = await provider._resolve_token() + + assert result.get_secret() == "refreshed" + mock_refresh.assert_awaited_once() + await_args = mock_refresh.await_args + assert await_args is not None + sent: SecretStr = await_args.args[0] + assert sent.get_secret() == "own-x-token" + + +class TestResolveTokenBorrowMode: + """_resolve_token in borrow mode (reads from linked yandex_music instance).""" + + async def test_uses_ym_token_when_available(self) -> None: + """Returns the music token from the linked YM instance config.""" + provider = _make_provider() + provider._ym_instance_id = "ym-inst" + ym = _make_ym_provider_stub(token="ym-music-token") + _stub_attr(provider.mass, "get_provider", MagicMock(return_value=ym)) + + result = await provider._resolve_token() + + assert result.get_secret() == "ym-music-token" + + async def test_refreshes_in_memory_when_only_x_token(self) -> None: + """Falls back to in-memory refresh via x_token; does not write config.""" + provider = _make_provider() + provider._ym_instance_id = "ym-inst" + ym = _make_ym_provider_stub(token=None, x_token="ym-x-token") + _stub_attr(provider.mass, "get_provider", MagicMock(return_value=ym)) + + with patch( + "music_assistant.providers.yandex_ynison.provider.refresh_music_token", + new_callable=AsyncMock, + return_value=SecretStr("fresh-token"), + ) as mock_refresh: + result = await provider._resolve_token() + + assert result.get_secret() == "fresh-token" + mock_refresh.assert_awaited_once() + + async def test_raises_when_ym_has_no_credentials(self) -> None: + """Raises LoginFailed when YM instance config has neither token nor x_token.""" + provider = _make_provider() + provider._ym_instance_id = "ym-inst" + ym = _make_ym_provider_stub(token=None, x_token=None) + _stub_attr(provider.mass, "get_provider", MagicMock(return_value=ym)) + + with pytest.raises(LoginFailed, match="no credentials"): + await provider._resolve_token() + + async def test_raises_when_ym_instance_unavailable(self) -> None: + """Raises LoginFailed with a distinct 'not loaded' message when YM is missing.""" + provider = _make_provider() + provider._ym_instance_id = "ym-inst" + _stub_attr(provider.mass, "get_provider", MagicMock(return_value=None)) + + with pytest.raises(LoginFailed, match="not loaded"): + await provider._resolve_token() + + async def test_raises_when_linked_provider_is_not_yandex_music(self) -> None: + """Stale/edited instance id pointing at a non-YM provider yields a clear error.""" + provider = _make_provider() + provider._ym_instance_id = "some-other-id" + wrong = _make_ym_provider_stub() + wrong.domain = "spotify" # not yandex_music + _stub_attr(provider.mass, "get_provider", MagicMock(return_value=wrong)) + + with pytest.raises(LoginFailed, match="not a Yandex Music"): + await provider._resolve_token() + + +class TestRefreshYnisonToken: + """_refresh_ynison_token on YnisonClient auth-failure callback.""" + + async def test_own_mode_no_x_token_raises_login_failed(self) -> None: + """Own mode with neither token nor stored x_token — surface LoginFailed.""" + provider = _make_provider() + provider._ym_instance_id = None + # Stub the config: no token, no x_token. + provider.config = MagicMock() + provider.config.get_value = MagicMock(return_value=None) + + with pytest.raises(LoginFailed, match="Re-authenticate"): + await provider._refresh_ynison_token() + + async def test_own_mode_with_stored_x_token_refreshes(self) -> None: + """Own mode with CONF_X_TOKEN set refreshes in-memory via passport.""" + provider = _make_provider() + provider._ym_instance_id = None + provider.config = MagicMock() + provider.config.get_value = MagicMock( + side_effect=lambda key: "own-x-token" if key == CONF_X_TOKEN else None + ) + + with patch( + "music_assistant.providers.yandex_ynison.provider.refresh_music_token", + new_callable=AsyncMock, + return_value=SecretStr("fresh"), + ) as mock_refresh: + result = await provider._refresh_ynison_token() + + assert result.get_secret() == "fresh" + mock_refresh.assert_awaited_once() + # The refresh argument is a SecretStr wrapping the stored x_token. + await_args = mock_refresh.await_args + assert await_args is not None + sent: SecretStr = await_args.args[0] + assert sent.get_secret() == "own-x-token" + + async def test_borrow_mode_refreshes_from_ym_x_token(self) -> None: + """Reads x_token from linked YM and refreshes in-memory only.""" + provider = _make_provider() + provider._ym_instance_id = "ym-inst" + ym = _make_ym_provider_stub(token="stale", x_token="ym-x-token") + _stub_attr(provider.mass, "get_provider", MagicMock(return_value=ym)) + # Ensure config writes are not invoked + mock_update_config = MagicMock() + _stub_attr(provider, "_update_config_value", mock_update_config) + + with patch( + "music_assistant.providers.yandex_ynison.provider.refresh_music_token", + new_callable=AsyncMock, + return_value=SecretStr("fresh-token"), + ) as mock_refresh: + result = await provider._refresh_ynison_token() + + assert result.get_secret() == "fresh-token" + mock_refresh.assert_awaited_once() + mock_update_config.assert_not_called() + + async def test_borrow_mode_raises_without_x_token(self) -> None: + """Raises LoginFailed when YM has no x_token for refresh.""" + provider = _make_provider() + provider._ym_instance_id = "ym-inst" + ym = _make_ym_provider_stub(token="only-token", x_token=None) + _stub_attr(provider.mass, "get_provider", MagicMock(return_value=ym)) + + with pytest.raises(LoginFailed, match="no x_token"): + await provider._refresh_ynison_token() + + async def test_borrow_mode_raises_when_ym_not_loaded(self) -> None: + """Raises LoginFailed with a distinct 'not loaded' message on reactive refresh.""" + provider = _make_provider() + provider._ym_instance_id = "ym-inst" + _stub_attr(provider.mass, "get_provider", MagicMock(return_value=None)) + + with pytest.raises(LoginFailed, match="not loaded"): + await provider._refresh_ynison_token() + + +class TestYandexProviderMatch: + """_check_yandex_provider_match obeys _ym_instance_id in borrow mode.""" + + async def test_borrow_mode_ignores_other_ym_instances(self) -> None: + """Does not link to a YM instance with a different instance_id.""" + provider = _make_provider() + provider._ym_instance_id = "wanted" + other = _make_ym_provider_stub(instance_id="other") + _stub_attr(provider.mass, "get_providers", MagicMock(return_value=[other])) + + await provider._check_yandex_provider_match() + + assert provider._yandex_provider is None + + async def test_borrow_mode_matches_on_instance_id(self) -> None: + """Links to the specific YM instance requested by config.""" + provider = _make_provider() + provider._ym_instance_id = "wanted" + wanted = _make_ym_provider_stub(instance_id="wanted") + other = _make_ym_provider_stub(instance_id="other") + _stub_attr(provider.mass, "get_providers", MagicMock(return_value=[other, wanted])) + + await provider._check_yandex_provider_match() + + assert provider._yandex_provider is wanted + + async def test_own_mode_accepts_any_ym(self) -> None: + """In own mode, the first available yandex_music provider is used.""" + provider = _make_provider() + provider._ym_instance_id = None + ym = _make_ym_provider_stub(instance_id="any") + _stub_attr(provider.mass, "get_providers", MagicMock(return_value=[ym])) + + await provider._check_yandex_provider_match() + + assert provider._yandex_provider is ym + + +# ------------------------------------------------------------------ +# 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 + + +# ------------------------------------------------------------------ +# Yandex Music instance enumeration +# ------------------------------------------------------------------ + + +class TestListYandexMusicInstances: + """Tests for list_yandex_music_instances.""" + + def test_returns_empty_when_none_configured(self) -> None: + """Empty list when no yandex_music instances exist.""" + mass = _make_mock_mass() + mass.config.get = MagicMock(return_value={}) + assert list_yandex_music_instances(mass) == [] + + def test_lists_instances_with_display_name(self) -> None: + """Returns (instance_id, display_name) pairs for yandex_music domains.""" + mass = _make_mock_mass() + mass.config.get = MagicMock( + return_value={ + "ym-a": {"domain": "yandex_music", "name": "Main Account"}, + "ym-b": {"domain": "yandex_music", "name": "Family"}, + "ynison-1": {"domain": "yandex_ynison", "name": "Ynison"}, + } + ) + result = list_yandex_music_instances(mass) + assert sorted(result) == [("ym-a", "Main Account"), ("ym-b", "Family")] + + def test_falls_back_to_instance_id_when_name_missing(self) -> None: + """Uses instance_id as display name when 'name' is absent.""" + mass = _make_mock_mass() + mass.config.get = MagicMock(return_value={"ym-a": {"domain": "yandex_music"}}) + result = list_yandex_music_instances(mass) + assert result == [("ym-a", "ym-a")] + + +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, + device_id: str = "test-device-uuid", +) -> MagicMock: + """Create a mock YnisonClient with sensible defaults.""" + mock = MagicMock() + mock.connected = connected + mock.state = state or _make_ynison_state() + mock.device_id = device_id + 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_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) + # No assertion — just verify no crash. + + +# ------------------------------------------------------------------ +# _pause_playback +# ------------------------------------------------------------------ + + +class TestPausePlayback: + """Tests for _pause_playback.""" + + async def test_stops_stream_and_player(self) -> None: + """Pause stops stream, calls cmd_stop, preserves progress.""" + 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] + + 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] + + +# ------------------------------------------------------------------ +# Echo suppression via YnisonState.last_update_is_echo +# ------------------------------------------------------------------ + + +class TestEchoSuppression: + """Seek detection in _handle_ynison_state honours the state echo flag.""" + + def _player(self, provider: YandexYnisonProvider) -> MagicMock: + player = MagicMock() + player.player_id = "player1" + player.display_name = "Player 1" + player.state.playback_state = PlaybackState.PLAYING + provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] + provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + return player + + async def _prime_same_track(self, provider: YandexYnisonProvider) -> None: + """Set provider state so seek detection is the only active branch.""" + self._player(provider) + provider._current_streaming_track_id = "track1" + provider._active_player_id = "player1" + provider._source_details.in_use_by = "player1" + provider._streaming_progress_ms = 1000 + provider._seek_grace_until = 0.0 # grace expired + + async def test_echo_suppresses_seek_detection(self) -> None: + """last_update_is_echo=True makes large drift ignored.""" + provider = _make_provider() + await self._prime_same_track(provider) + + state = _make_ynison_state(progress_ms=10000) # drift 9000ms vs 1000 + state.last_update_is_echo = True + + await provider._handle_ynison_state(state) + + assert not provider._track_changed_event.is_set() + + async def test_non_echo_triggers_seek(self) -> None: + """last_update_is_echo=False with large drift triggers seek.""" + provider = _make_provider() + await self._prime_same_track(provider) + + state = _make_ynison_state(progress_ms=10000) + state.last_update_is_echo = False + + await provider._handle_ynison_state(state) + + assert provider._track_changed_event.is_set() + assert provider._seek_position_ms == 10000 + + async def test_default_echo_flag_false(self) -> None: + """YnisonState default last_update_is_echo is False — seek still fires.""" + provider = _make_provider() + await self._prime_same_track(provider) + + state = _make_ynison_state(progress_ms=10000) + # No explicit override — the dataclass default is False. + + await provider._handle_ynison_state(state) + + assert provider._track_changed_event.is_set() + + +# ------------------------------------------------------------------ +# _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() + + async def test_unloaded_provider_raises_login_failed_not_attribute_error( + self, + ) -> None: + """Linked yandex_music unloaded → LoginFailed, not AttributeError. + + Regression: _yandex_provider can be set to None by the background + _check_yandex_provider_match task between awaits in this function. + Prior code dereferenced `self._yandex_provider.get_stream_details` + directly, raising AttributeError and hard-stopping the audio + generator. We now capture a local ref at entry and surface a + clean LoginFailed instead. + """ + provider = _make_provider() + provider._yandex_provider = None + + with pytest.raises(LoginFailed, match="not loaded"): + await provider._get_stream_details_with_retry("t1") + + +# ------------------------------------------------------------------ +# _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, device_id="own-device-id") + 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 + # Outgoing state authored by our device_id, timestamps as strings. + queue_version = sent["player_queue"]["version"] + status_version = sent["status"]["version"] + assert queue_version["device_id"] == "own-device-id" + assert status_version["device_id"] == "own-device-id" + assert isinstance(queue_version["version"], str) + assert queue_version["timestamp_ms"] == "0" + assert isinstance(status_version["version"], str) + assert status_version["timestamp_ms"] == "0" + + 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, device_id="own-device-id") + 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 + assert sent["player_queue"]["version"]["device_id"] == "own-device-id" + + 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_ynison_client.py b/tests/providers/yandex_ynison/test_ynison_client.py new file mode 100644 index 0000000000..ca477fd08c --- /dev/null +++ b/tests/providers/yandex_ynison/test_ynison_client.py @@ -0,0 +1,1676 @@ +"""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, + YNISON_ORIGIN, +) +from music_assistant.providers.yandex_ynison.ynison_client import ( + YnisonClient, + YnisonDeviceInfo, + YnisonState, + generate_device_id, + make_version_block, +) + + +@pytest.fixture +def device_info() -> YnisonDeviceInfo: + """Create test device info.""" + return YnisonDeviceInfo( + device_id="test-device-id", + title="Test Device", + ) + + +@pytest.fixture +def mock_state_callback() -> AsyncMock: + """Create a mock callback for state updates.""" + return AsyncMock() + + +@pytest.fixture +def client( + device_info: YnisonDeviceInfo, + mock_state_callback: AsyncMock, +) -> YnisonClient: + """Create a YnisonClient instance for testing.""" + return YnisonClient( + token=SecretStr("test-token"), + device_info=device_info, + on_state_update=mock_state_callback, + 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"] == [] + + def test_build_initial_state_string_fields(self, client: YnisonClient) -> None: + """Ynison rejects integer timestamps; all time/version fields must be str.""" + state = client._build_initial_state() + for block in (state["status"], state["player_queue"]): + version = block["version"] + assert version["device_id"] == "test-device-id" + assert isinstance(version["version"], str) + assert version["version"].isdigit() + assert version["timestamp_ms"] == "0" + assert state["status"]["progress_ms"] == "0" + assert state["status"]["duration_ms"] == "0" + + def test_device_id_property(self, client: YnisonClient) -> None: + """device_id property exposes the registered device id.""" + assert client.device_id == "test-device-id" + + +class TestMakeVersionBlock: + """Tests for the module-level make_version_block helper.""" + + def test_fields_are_strings(self) -> None: + """Version and timestamp_ms must be strings (Ynison 500s on ints).""" + block = make_version_block("dev-42") + assert block["device_id"] == "dev-42" + assert isinstance(block["version"], str) + assert block["version"].isdigit() + assert block["timestamp_ms"] == "0" + + +# ------------------------------------------------------------------ +# 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" + + def test_echo_flag_true_on_own_authored_queue(self, client: YnisonClient) -> None: + """player_queue.version.device_id == own → last_update_is_echo True.""" + client._parse_state( + { + "player_state": { + "player_queue": { + "playable_list": [{"playable_id": "t1"}], + "current_playable_index": 0, + "version": { + "device_id": "test-device-id", + "version": "42", + "timestamp_ms": "0", + }, + }, + }, + } + ) + assert client.state.last_update_is_echo is True + + def test_echo_flag_false_on_foreign_author(self, client: YnisonClient) -> None: + """player_queue.version.device_id != own → not an echo.""" + client._parse_state( + { + "player_state": { + "player_queue": { + "playable_list": [{"playable_id": "t1"}], + "current_playable_index": 0, + "version": { + "device_id": "some-other-device", + "version": "42", + "timestamp_ms": "0", + }, + }, + }, + } + ) + assert client.state.last_update_is_echo is False + + def test_echo_flag_false_when_version_missing(self, client: YnisonClient) -> None: + """No version block in player_queue → not an echo (safe default).""" + client._parse_state( + { + "player_state": { + "player_queue": {"playable_list": [], "current_playable_index": -1}, + }, + } + ) + assert client.state.last_update_is_echo is False + + def test_echo_flag_false_when_player_state_missing(self, client: YnisonClient) -> None: + """status-only or non-player_state updates cannot be echoes.""" + client.state.last_update_is_echo = True # sticky from a prior update + client._parse_state({"active_device_id_optional": "some-device"}) + assert client.state.last_update_is_echo is False + + def test_echo_flag_true_on_own_authored_status(self, client: YnisonClient) -> None: + """status.version.device_id == own → echo True even without player_queue version.""" + client._parse_state( + { + "player_state": { + "status": { + "paused": False, + "progress_ms": "1000", + "duration_ms": "5000", + "version": { + "device_id": "test-device-id", + "version": "42", + "timestamp_ms": "0", + }, + }, + }, + } + ) + assert client.state.last_update_is_echo is True + + def test_parse_state_coerces_int_timestamps_to_strings(self, client: YnisonClient) -> None: + """Inbound int timestamps are stringified so outbound echoes stay safe. + + Guards the reconnect path (send_full_state echoes self.state.player_state) + and queue-mutating update_player_state calls that shallow-copy status. + """ + client._parse_state( + { + "player_state": { + "status": { + "paused": False, + "progress_ms": 1234, + "duration_ms": 56789, + "player_action_timestamp_ms": 111, + "version": { + "device_id": "peer", + "version": 42, + "timestamp_ms": 0, + }, + }, + "player_queue": { + "current_playable_index": 0, + "playable_list": [], + "version": { + "device_id": "peer", + "version": 99, + "timestamp_ms": 0, + }, + }, + } + } + ) + status = client.state.player_state["status"] + assert status["progress_ms"] == "1234" + assert status["duration_ms"] == "56789" + assert status["player_action_timestamp_ms"] == "111" + assert status["version"]["version"] == "42" + assert status["version"]["timestamp_ms"] == "0" + queue_version = client.state.player_state["player_queue"]["version"] + assert queue_version["version"] == "99" + assert queue_version["timestamp_ms"] == "0" + + +# ------------------------------------------------------------------ +# 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 with string-typed timestamps.""" + 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"] + # Ynison expects strings for timestamp fields (integers trigger 500) + 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 = 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, + 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_retries_on_closed_external_session_until_stopped( + self, + ) -> None: + """Reconnect with closed external session retries until stop_event is set.""" + on_state = 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, + logger=MagicMock(), + http_session=ext_session, + ) + client._session = None # simulate session lost + + # Simulate an operator calling disconnect() after a few failures — + # without this the reconnect loop would retry forever. + sleep_calls = 0 + + async def stop_after_n_sleeps(*_args: object, **_kw: object) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 3: + client._stop_event.set() + + sleep_path = "music_assistant.providers.yandex_ynison.ynison_client.asyncio.sleep" + with ( + patch(sleep_path, side_effect=stop_after_n_sleeps), + patch.object(client, "_get_redirect_ticket", new_callable=AsyncMock) as mock_redir, + ): + mock_redir.side_effect = AssertionError("should not reach here") + await client._reconnect() + + # Never reached the redirect step because session is closed. + mock_redir.assert_not_awaited() + 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 = 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, + 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 = AsyncMock() + client = YnisonClient( + token=SecretStr("test-token"), + device_info=YnisonDeviceInfo(device_id="d1", title="T"), + on_state_update=on_state, + 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 = AsyncMock() + client = YnisonClient( + token=SecretStr("test-token"), + device_info=YnisonDeviceInfo(device_id="d1", title="T"), + on_state_update=on_state, + 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 = AsyncMock() + client = YnisonClient( + token=SecretStr("test-token"), + device_info=YnisonDeviceInfo(device_id="d1", title="T"), + on_state_update=on_state, + 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" + # Ynison expects string-typed timestamps (integers cause 500s) + assert isinstance(call_data["player_action_timestamp_ms"], str) + assert call_data["player_action_timestamp_ms"].isdigit() + + 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 + # 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 + assert client._message_task is not None + 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 + assert client._message_task is not None + 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_state_callback: AsyncMock, + ) -> None: + """TEXT message: parses JSON, updates state, invokes callback.""" + on_state_update = mock_state_callback + 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 non-reconnect error 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_rebalance_error_breaks_loop( + self, + client: YnisonClient, + mock_state_callback: AsyncMock, + ) -> None: + """Ynison re-balance error (300100001) breaks the loop for immediate reconnect.""" + on_state_update = mock_state_callback + 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}}}), + ) + await self._run_loop_with_messages(client, [rebalance_msg, valid_msg]) + + # The valid message was never processed (loop broke on re-balance error) + on_state_update.assert_not_awaited() + + async def test_not_served_error_breaks_loop( + self, + client: YnisonClient, + mock_state_callback: AsyncMock, + ) -> None: + """Ynison 'not served' error (300100002) also breaks the loop.""" + on_state_update = mock_state_callback + 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}}}), + ) + await self._run_loop_with_messages(client, [not_served_msg, valid_msg]) + + 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.""" + 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_state_callback: AsyncMock, + ) -> None: + """Exception in state callback is caught, loop continues.""" + on_state_update = mock_state_callback + 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_retries_indefinitely_until_stopped(self, client: YnisonClient) -> None: + """Reconnect keeps retrying past the old 5-attempt cap until stop_event.""" + client._session = MagicMock() + client._session.closed = False + + attempt_count = 0 + stop_after = 8 # well past the old MAX_RECONNECT_ATTEMPTS of 5 + + async def failing_redirect() -> tuple[str, str, int]: + nonlocal attempt_count + attempt_count += 1 + if attempt_count >= stop_after: + client._stop_event.set() + msg = "fail" + raise ConnectionError(msg) + + with ( + patch(SLEEP_PATH, new_callable=AsyncMock), + patch.object( + client, + "_get_redirect_ticket", + new_callable=AsyncMock, + side_effect=failing_redirect, + ), + ): + await client._reconnect() + + assert attempt_count >= stop_after + + 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 = AsyncMock() + client = YnisonClient( + token=SecretStr("test-token"), + device_info=YnisonDeviceInfo(device_id="d1", title="T"), + on_state_update=on_state, + 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 = 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, + 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 = 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, + 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 keeps retrying on the same token.""" + on_state = AsyncMock() + + client = YnisonClient( + token=SecretStr("old-token"), + device_info=YnisonDeviceInfo(device_id="d1", title="T"), + on_state_update=on_state, + logger=MagicMock(), + ) + client._session = MagicMock() + client._session.closed = False + + attempt_count = 0 + + async def failing_redirect() -> tuple[str, str, int]: + nonlocal attempt_count + attempt_count += 1 + if attempt_count >= 4: + client._stop_event.set() + raise LoginFailed("expired") + + with ( + patch(SLEEP_PATH, new_callable=AsyncMock), + patch.object( + client, + "_get_redirect_ticket", + new_callable=AsyncMock, + side_effect=failing_redirect, + ), + ): + await client._reconnect() + + assert attempt_count >= 4 + assert client._token == SecretStr("old-token") + + async def test_auth_failure_callback_raises(self) -> None: + """on_auth_failure raises → logs warning, keeps retrying until stopped.""" + on_state = 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, + logger=MagicMock(), + on_auth_failure=on_auth_failure, + ) + client._session = MagicMock() + client._session.closed = False + + attempt_count = 0 + stop_after = 6 + + async def failing_redirect() -> tuple[str, str, int]: + nonlocal attempt_count + attempt_count += 1 + if attempt_count >= stop_after: + client._stop_event.set() + raise LoginFailed("expired") + + with ( + patch(SLEEP_PATH, new_callable=AsyncMock), + patch.object( + client, + "_get_redirect_ticket", + new_callable=AsyncMock, + side_effect=failing_redirect, + ), + ): + await client._reconnect() + + # Callback was called on every attempt — no cap + assert on_auth_failure.await_count == attempt_count + # Token unchanged since callback always fails + assert client._token == SecretStr("old-token") + + +class TestUpdateToken: + """Tests for update_token method.""" + + def test_update_token_replaces_stored_token(self) -> None: + """update_token swaps the internal _token.""" + on_state = AsyncMock() + client = YnisonClient( + token=SecretStr("old-token"), + device_info=YnisonDeviceInfo(device_id="d1", title="T"), + on_state_update=on_state, + logger=MagicMock(), + ) + assert client._token == SecretStr("old-token") + client.update_token(SecretStr("new-token")) + assert client._token == SecretStr("new-token")