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")