From b9c7539ee5372e9b6f0450372a7745ee41a69465 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 13:56:00 +0000 Subject: [PATCH 01/56] feat(yandex_smarthome): add yandex_smarthome provider v1.4.0 --- .../providers/yandex_smarthome/__init__.py | 543 +++---- .../providers/yandex_smarthome/auto_skill.py | 1281 +++++++++++++++++ .../yandex_smarthome/auto_skill_logo.png | Bin 0 -> 43607 bytes .../yandex_smarthome/auto_skill_state.py | 121 ++ .../yandex_smarthome/auto_skill_ui.py | 842 +++++++++++ .../providers/yandex_smarthome/constants.py | 5 + .../__snapshots__/test_auto_skill.ambr | 121 ++ .../yandex_smarthome/test_auto_skill.py | 940 ++++++++++++ .../yandex_smarthome/test_auto_skill_state.py | 129 ++ .../yandex_smarthome/test_auto_skill_ui.py | 383 +++++ .../yandex_smarthome/test_config_actions.py | 366 +++++ .../providers/yandex_smarthome/test_direct.py | 43 +- 12 files changed, 4424 insertions(+), 350 deletions(-) create mode 100644 music_assistant/providers/yandex_smarthome/auto_skill.py create mode 100644 music_assistant/providers/yandex_smarthome/auto_skill_logo.png create mode 100644 music_assistant/providers/yandex_smarthome/auto_skill_state.py create mode 100644 music_assistant/providers/yandex_smarthome/auto_skill_ui.py create mode 100644 tests/providers/yandex_smarthome/__snapshots__/test_auto_skill.ambr create mode 100644 tests/providers/yandex_smarthome/test_auto_skill.py create mode 100644 tests/providers/yandex_smarthome/test_auto_skill_state.py create mode 100644 tests/providers/yandex_smarthome/test_auto_skill_ui.py create mode 100644 tests/providers/yandex_smarthome/test_config_actions.py diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index 8e02b43438..936cc64946 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -16,6 +16,8 @@ from __future__ import annotations +import contextlib +import dataclasses import logging import uuid from typing import TYPE_CHECKING, cast @@ -25,15 +27,23 @@ from music_assistant_models.enums import ConfigEntryType, ProviderFeature from ._compat import SecretStr +from .auto_skill import ( + auto_create_skill, + load_default_logo_bytes, +) +from .auto_skill_state import ( + SkillCreationState, + dump_artifacts, + load_artifacts, +) +from .auto_skill_ui import build_cloud_plus_entries, build_direct_entries from .cloud import get_cloud_otp, register_cloud_instance from .constants import ( - CLOUD_OAUTH_AUTHORIZE_URL, - CLOUD_OAUTH_TOKEN_URL, - CLOUD_SKILL_CLIENT_ID_TEMPLATE, - CLOUD_SKILL_CLIENT_SECRET, - CLOUD_SKILL_WEBHOOK_TEMPLATE, + CONF_ACTION_AUTO_CREATE, CONF_ACTION_GET_OTP, CONF_ACTION_REGISTER, + CONF_AUTO_CREATE_ARTIFACTS, + CONF_AUTO_CREATE_SESSION_ID, CONF_CLOUD_CONNECTION_TOKEN, CONF_CLOUD_INSTANCE_ID, CONF_CLOUD_INSTANCE_PASSWORD, @@ -47,11 +57,6 @@ CONNECTION_TYPE_CLOUD, CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT, - DIRECT_API_BASE_PATH, - DIRECT_AUTH_BASE_PATH, - DIRECT_OAUTH_CLIENT_ID, - YANDEX_DIALOGS_DEVELOPER_URL, - YANDEX_OAUTH_URL, ) from .plugin import YandexSmartHomePlugin @@ -94,25 +99,6 @@ def _build_status_label(otp_code: str | None, is_cloud_plus: bool, is_registered ) -def _build_cloud_plus_label(is_cloud_plus: bool, is_registered: bool) -> str: - """Build the Cloud Plus instruction label.""" - if not is_cloud_plus: - return "" - if is_registered: - return ( - "Cloud Plus setup: " - "1) Open Yandex.Dialogs console (link below) → Smart Home → Create skill. " - "2) Fill 'Basic info': Backend URL = webhook URL below, Access = Private. " - "3) Save, then fill 'Account linking' section with values below. " - "4) Save & Publish. " - "5) Get OAuth token → enter skill_id and token → Save." - ) - return ( - "Cloud Plus mode requires a private skill in Yandex.Dialogs. " - "First register a cloud instance, then follow the setup instructions." - ) - - async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: @@ -120,14 +106,37 @@ async def setup( return YandexSmartHomePlugin(mass, manifest, config, SUPPORTED_FEATURES) +def _resolve_direct_client_secret( + mass: MusicAssistant, + instance_id: str | None, + values: dict[str, ConfigValueType], +) -> str: + """Return the direct-mode OAuth client secret for the current install. + + `CONF_DIRECT_CLIENT_SECRET` is a SECURE_STRING: MA's frontend does + not echo saved secrets back into ``values`` on re-open, so reading + from ``values`` alone returns an empty string for existing instances. + Prefer the persisted value from saved config and fall back to + ``values`` only for first-time setup before any save. + """ + if instance_id: + prov = mass.get_provider(instance_id) + if prov and prov.config: + saved = prov.config.get_value(CONF_DIRECT_CLIENT_SECRET) + if saved: + return str(saved) + return str(values.get(CONF_DIRECT_CLIENT_SECRET) or "") + + async def _handle_config_actions( mass: MusicAssistant, action: str | None, values: dict[str, ConfigValueType], instance_id: str | None, is_cloud_plus: bool, + connection_type: str, ) -> str | None: - """Execute register/OTP actions and return OTP code if obtained.""" + """Execute config-flow actions and return OTP code if obtained.""" saved_config = None if instance_id: prov = mass.get_provider(instance_id) @@ -161,19 +170,74 @@ async def _handle_config_actions( except Exception: _LOGGER.exception("Failed to get OTP code") - if action == CONF_ACTION_REGISTER and not otp_code: - cloud_id = str(values.get(CONF_CLOUD_INSTANCE_ID, "")) - cloud_token = str(values.get(CONF_CLOUD_CONNECTION_TOKEN, "")) - if cloud_id and cloud_token: - try: - async with aiohttp.ClientSession() as session: - otp_code = await get_cloud_otp(session, cloud_id, SecretStr(cloud_token)) - except Exception: - _LOGGER.exception("Failed to get OTP after registration") + # NOTE: the old flow used to auto-fetch OTP right after Register so + # the user saw the code immediately. In the 3-step cloud_plus flow + # (Register → Create skill → Get OTP), that leaks the OTP into Step 1. + # OTP is now fetched only when the user explicitly presses Get OTP + # in Step 3. + + if action == CONF_ACTION_AUTO_CREATE: + await _run_auto_create_action(mass, values, connection_type, instance_id) return otp_code +async def _run_auto_create_action( + mass: MusicAssistant, + values: dict[str, ConfigValueType], + connection_type: str, + instance_id: str | None, +) -> None: + """Execute the experimental auto-create-skill action. + + Never re-raises: all errors are persisted into the artifacts blob so + the UI can show a FAILED state on the next render rather than + crashing the config form. + """ + # MA's frontend supplies ``values["session_id"]`` when it triggers an + # action — AuthenticationHelper listens on that exact id to open + # and later close the popup. If we roll our own id nothing listens + # and the popup never appears. Fall back to a local uuid only if the + # frontend happened not to pass one (shouldn't happen in practice). + session_id = str(values.get("session_id") or uuid.uuid4().hex) + values[CONF_AUTO_CREATE_SESSION_ID] = session_id + artifacts_raw = values.get(CONF_AUTO_CREATE_ARTIFACTS) + artifacts = load_artifacts(str(artifacts_raw) if artifacts_raw else None) + + try: + new_artifacts = await auto_create_skill( + mass=mass, + connection_type=connection_type, + skill_name=str(values.get(CONF_INSTANCE_NAME) or "Music Assistant"), + artifacts=artifacts, + cloud_instance_id=str(values.get(CONF_CLOUD_INSTANCE_ID, "")), + direct_client_secret=_resolve_direct_client_secret(mass, instance_id, values), + logo_bytes=load_default_logo_bytes(), + session_id=session_id, + ) + except ValueError as exc: + # Precondition failures come back here — surface as FAILED. + new_artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=str(exc), + ) + _LOGGER.warning("auto-create precondition failed: %s", exc) + except Exception as exc: # defensive — never crash the config form + new_artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=repr(exc), + ) + _LOGGER.exception("auto-create hit unexpected error") + + values[CONF_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) + if new_artifacts.state == SkillCreationState.DONE and new_artifacts.skill_id: + # Only set CONF_SKILL_ID on full success so the runtime doesn't + # try to use a half-built skill mid-pipeline. + values[CONF_SKILL_ID] = new_artifacts.skill_id + + async def get_config_entries( mass: MusicAssistant, instance_id: str | None = None, @@ -185,10 +249,24 @@ async def get_config_entries( values = {} connection_type = str(values.get(CONF_CONNECTION_TYPE, CONNECTION_TYPE_CLOUD)) + is_cloud = connection_type == CONNECTION_TYPE_CLOUD is_cloud_plus = connection_type == CONNECTION_TYPE_CLOUD_PLUS is_direct = connection_type == CONNECTION_TYPE_DIRECT - otp_code = await _handle_config_actions(mass, action, values, instance_id, is_cloud_plus) + otp_code = await _handle_config_actions( + mass, action, values, instance_id, is_cloud_plus, connection_type + ) + + # Auto-create-skill state — loaded once and threaded through the + # per-mode builders below. + artifacts_raw = values.get(CONF_AUTO_CREATE_ARTIFACTS) + artifacts_str = str(artifacts_raw) if artifacts_raw else None + artifacts = load_artifacts(artifacts_str) + session_id_val = values.get(CONF_AUTO_CREATE_SESSION_ID) + session_id_str = str(session_id_val) if session_id_val else None + ma_base_url_for_ui = "" + with contextlib.suppress(Exception): + ma_base_url_for_ui = str(mass.webserver.base_url) is_registered = bool(values.get(CONF_CLOUD_INSTANCE_ID)) and bool( values.get(CONF_CLOUD_CONNECTION_TOKEN) @@ -196,27 +274,6 @@ async def get_config_entries( cloud_instance_id = str(values.get(CONF_CLOUD_INSTANCE_ID, "")) label_text = _build_status_label(otp_code, is_cloud_plus, is_registered) - cloud_plus_label = _build_cloud_plus_label(is_cloud_plus, is_registered) - - # Compute copyable values for Cloud Plus mode - webhook_url = "" - client_id = "" - if is_cloud_plus and is_registered: - webhook_url = CLOUD_SKILL_WEBHOOK_TEMPLATE - client_id = CLOUD_SKILL_CLIENT_ID_TEMPLATE.format(instance_id=cloud_instance_id) - - # Compute direct mode endpoint URLs - direct_base_url = "" - direct_auth_url = "" - direct_token_url = "" - if is_direct: - try: - ma_base_url = mass.webserver.base_url.rstrip("/") - except Exception: - ma_base_url = "https://" - direct_base_url = f"{ma_base_url}{DIRECT_API_BASE_PATH}" - direct_auth_url = f"{ma_base_url}{DIRECT_AUTH_BASE_PATH}/authorize" - direct_token_url = f"{ma_base_url}{DIRECT_AUTH_BASE_PATH}/token" # Build player options for exposed players filter player_options: list[ConfigValueOption] = [] @@ -229,7 +286,7 @@ async def get_config_entries( except Exception: # noqa: S110 pass - return ( + entries: list[ConfigEntry] = [ # Instance name ConfigEntry( key=CONF_INSTANCE_NAME, @@ -243,6 +300,17 @@ async def get_config_entries( required=False, default_value="Music Assistant", ), + # Save-and-reopen notice — the form doesn't re-render on + # dropdown change, so the user has to Save + reopen to see + # the next mode's fields. + ConfigEntry( + key="label_connection_type_notice", + type=ConfigEntryType.LABEL, + label=( + "💡 After changing Connection Type below, click Save and " + "reopen this settings page to see the fields for the new mode." + ), + ), # Connection type selector ConfigEntry( key=CONF_CONNECTION_TYPE, @@ -260,16 +328,93 @@ async def get_config_entries( ConfigValueOption(title="Cloud Plus (private skill)", value="cloud_plus"), ConfigValueOption(title="Direct (no relay, requires public URL)", value="direct"), ], - advanced=True, + # NOTE: immediate_apply produced glitchy mixed-mode renders + # (entries from old mode stayed on screen next to new ones), + # so users need Save + reopen after changing Connection + # Type. Kept here to stop someone re-adding it. + ), + ] + + # -- Per-mode sections (each builder returns only the fields for its mode) + if is_cloud: + entries.extend(_cloud_mode_entries(label_text, otp_code, is_registered)) + elif is_cloud_plus: + entries.extend( + build_cloud_plus_entries( + otp_code=otp_code, + is_registered=is_registered, + cloud_instance_id=cloud_instance_id, + artifacts=artifacts, + session_id=session_id_str, + user_code=None, # popup URL carries the code + verification_url=None, + existing_artifacts_raw=artifacts_str, + base_url=ma_base_url_for_ui, + skill_id=str(values.get(CONF_SKILL_ID) or ""), + skill_token_set=bool(values.get(CONF_SKILL_TOKEN)), + ) + ) + elif is_direct: + # Pre-generate the per-install direct client secret once so it + # survives round-trips (auto-skill pipeline reads it later). + # SECURE_STRING is not echoed back into ``values`` on re-open, + # so prefer the persisted value from saved config first and + # only mint a fresh UUID on true first-time setup. + direct_secret = _resolve_direct_client_secret(mass, instance_id, values) + if not direct_secret: + direct_secret = uuid.uuid4().hex + values[CONF_DIRECT_CLIENT_SECRET] = direct_secret + entries.extend( + build_direct_entries( + artifacts=artifacts, + session_id=session_id_str, + user_code=None, + verification_url=None, + existing_artifacts_raw=artifacts_str, + base_url=ma_base_url_for_ui, + direct_client_secret=direct_secret, + skill_id=str(values.get(CONF_SKILL_ID) or ""), + skill_token_set=bool(values.get(CONF_SKILL_TOKEN)), + ) + ) + # NB: CONF_DIRECT_CLIENT_SECRET is now emitted by the manual + # fallback block (advanced/hidden per state), so we don't add a + # duplicate hidden round-trip entry here. + + # -- Tail: player filter + hidden round-trip fields (all modes) -- + entries.extend(_common_tail_entries(player_options, values)) + return tuple(entries) + + +def _cloud_mode_entries( + label_text: str, otp_code: str | None, is_registered: bool +) -> list[ConfigEntry]: + """Public-cloud mode: simple register + get-OTP flow.""" + return [ + # Advisory — the public Yaha Cloud skill can only be linked to one + # instance per Yandex account, so users who already set up Yaha + # Cloud in Home Assistant (or another MA install) need Cloud Plus. + # There's no pre-flight API to detect this, so the warning is + # static — cheaper than a failed OTP attempt. + ConfigEntry( + key="label_cloud_conflict_warning", + type=ConfigEntryType.LABEL, + label=( + "⚠️ If this Yandex account already uses the Yaha Cloud skill " + "via Home Assistant or another Music Assistant install, " + "pick 'Cloud Plus' above instead — the public skill can " + "only be linked to one instance per account." + ), + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD, ), - # Status label (cloud modes only) ConfigEntry( key="label_status", type=ConfigEntryType.LABEL, label=label_text, - hidden=is_direct, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD, ), - # OTP code — copyable text field (shown only when OTP is available) ConfigEntry( key="otp_code", type=ConfigEntryType.STRING, @@ -277,9 +422,10 @@ async def get_config_entries( description="Copy this code and enter it in the Yandex app.", required=False, value=otp_code, - hidden=not otp_code or is_direct, + hidden=not otp_code, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD, ), - # Register action (hidden after registration or in direct mode) ConfigEntry( key=CONF_ACTION_REGISTER, type=ConfigEntryType.ACTION, @@ -287,9 +433,11 @@ async def get_config_entries( description="Register a new instance on yaha-cloud.ru relay service.", action=CONF_ACTION_REGISTER, action_label="Register with cloud", - hidden=is_registered or is_direct, + hidden=is_registered, + # No depends_on — MA disables actions with an unsaved + # dependency value until the user clicks Save, which breaks + # the flow right after picking a connection type. ), - # Get OTP action (shown after registration, hidden in direct mode) ConfigEntry( key=CONF_ACTION_GET_OTP, type=ConfigEntryType.ACTION, @@ -297,252 +445,16 @@ async def get_config_entries( description="Get a fresh one-time password to link with Yandex Smart Home app.", action=CONF_ACTION_GET_OTP, action_label="Get OTP code", - hidden=not is_registered or is_direct, - ), - # --- Direct connection section --- - ConfigEntry( - key="label_direct", - type=ConfigEntryType.LABEL, - label=( - "Direct connection setup: " - "1) Create a private skill in Yandex.Dialogs (Smart Home type). " - "2) Set Backend URL, Authorization URL, Token URL from values below. " - "3) Set Client ID and Client Secret from values below. " - "4) Publish skill, then link account in Yandex app. " - "5) Fill Skill ID and Skill Token below and Save." - ), - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Direct Connection Setup", - ), - # Yandex Dialogs developer console link (direct) - ConfigEntry( - key="direct_dialogs_url", - type=ConfigEntryType.STRING, - label="Yandex.Dialogs Console (create skill here)", - required=False, - default_value=YANDEX_DIALOGS_DEVELOPER_URL, - help_link=YANDEX_DIALOGS_DEVELOPER_URL, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Direct Connection Setup", - ), - # Backend URL (for Yandex.Dialogs skill config) - ConfigEntry( - key="direct_backend_url", - type=ConfigEntryType.STRING, - label="Backend URL (→ Basic info)", - description="Copy to your skill's Backend URL field in Yandex.Dialogs.", - required=False, - value=direct_base_url or None, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Copy to Yandex.Dialogs skill", - ), - # Authorization URL (direct) - ConfigEntry( - key="direct_auth_url", - type=ConfigEntryType.STRING, - label="Authorization URL (→ Account linking)", - description="Copy to 'Account linking' → 'Authorization URL' field.", - required=False, - value=direct_auth_url or None, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Copy to Yandex.Dialogs skill", - ), - # Token URL (direct) - ConfigEntry( - key="direct_token_url", - type=ConfigEntryType.STRING, - label="Token URL (→ Account linking, both fields)", - description=("Copy to both 'Token endpoint' and 'Refresh token URL' fields."), - required=False, - value=direct_token_url or None, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Copy to Yandex.Dialogs skill", - ), - # Client ID (direct — always the same) - ConfigEntry( - key="direct_client_id", - type=ConfigEntryType.STRING, - label="Client ID (→ Account linking)", - description="Copy to 'Account linking' → 'Client identifier' field.", - required=False, - default_value=DIRECT_OAUTH_CLIENT_ID, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Copy to Yandex.Dialogs skill", - ), - # Client Secret (direct — auto-generated per install) - ConfigEntry( - key=CONF_DIRECT_CLIENT_SECRET, - type=ConfigEntryType.SECURE_STRING, - label="Client Secret (→ Account linking)", - description=( - "Copy to 'Account linking' → 'Client secret' field. Auto-generated on first setup." - ), - required=False, - default_value=( - cast("str", values.get(CONF_DIRECT_CLIENT_SECRET)) - if values and values.get(CONF_DIRECT_CLIENT_SECRET) - else uuid.uuid4().hex - ), - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Copy to Yandex.Dialogs skill", - ), - # OAuth URL for getting skill token (direct) - ConfigEntry( - key="direct_oauth_url", - type=ConfigEntryType.STRING, - label="OAuth URL (open to get skill token)", - required=False, - default_value=YANDEX_OAUTH_URL, - help_link=YANDEX_OAUTH_URL, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Fill in from Yandex.Dialogs", - ), - # Skill ID (cloud_plus and direct) - ConfigEntry( - key=CONF_SKILL_ID, - type=ConfigEntryType.STRING, - label="Skill ID", - description=( - "UUID of your private Smart Home skill from Yandex.Dialogs. " - "Find it in the skill URL: /developer/skills/{skill_id}/" - ), - required=False, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value_not=CONNECTION_TYPE_CLOUD, - category="Fill in from Yandex.Dialogs", - ), - # Skill OAuth Token (cloud_plus and direct) - ConfigEntry( - key=CONF_SKILL_TOKEN, - type=ConfigEntryType.SECURE_STRING, - label="Skill OAuth Token", - description="Paste the OAuth token obtained from the URL above.", - required=False, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value_not=CONNECTION_TYPE_CLOUD, - category="Fill in from Yandex.Dialogs", - ), - # --- Cloud Plus section (advanced) --- - # Cloud Plus instructions - ConfigEntry( - key="label_cloud_plus", - type=ConfigEntryType.LABEL, - label=cloud_plus_label, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Cloud Plus Setup", - ), - # Yandex Dialogs developer console link - ConfigEntry( - key="dialogs_url", - type=ConfigEntryType.STRING, - label="Yandex.Dialogs Console (create skill here)", - required=False, - default_value=YANDEX_DIALOGS_DEVELOPER_URL, - help_link=YANDEX_DIALOGS_DEVELOPER_URL, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Cloud Plus Setup", - ), - # --- Copy to Yandex.Dialogs --- - # Webhook URL - ConfigEntry( - key="webhook_url", - type=ConfigEntryType.STRING, - label="Backend URL (→ Basic info)", - description="Copy and paste into your private skill's Backend URL field.", - required=False, - value=webhook_url or None, - hidden=not webhook_url, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Copy to Yandex.Dialogs skill", - ), - # Client ID - ConfigEntry( - key="skill_client_id", - type=ConfigEntryType.STRING, - label="Client ID (→ Account linking)", - description="Copy to 'Account linking' → 'Client identifier' field.", - required=False, - value=client_id or None, - hidden=not client_id, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Copy to Yandex.Dialogs skill", - ), - # Client Secret - ConfigEntry( - key="skill_client_secret", - type=ConfigEntryType.STRING, - label="Client Secret (→ Account linking)", - description="Copy to 'Account linking' → 'Client secret' field.", - required=False, - default_value=CLOUD_SKILL_CLIENT_SECRET, hidden=not is_registered, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Copy to Yandex.Dialogs skill", ), - # Authorization URL - ConfigEntry( - key="skill_auth_url", - type=ConfigEntryType.STRING, - label="Authorization URL (→ Account linking)", - description="Copy to 'Account linking' → 'Authorization URL' field.", - required=False, - default_value=CLOUD_OAUTH_AUTHORIZE_URL, - hidden=not is_registered, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Copy to Yandex.Dialogs skill", - ), - # Token URL - ConfigEntry( - key="skill_token_url", - type=ConfigEntryType.STRING, - label="Token URL (→ Account linking, both fields)", - description=( - "Copy to both 'Token endpoint' and 'Refresh token URL' fields " - "in the 'Account linking' section." - ), - required=False, - default_value=CLOUD_OAUTH_TOKEN_URL, - hidden=not is_registered, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Copy to Yandex.Dialogs skill", - ), - # OAuth URL — link to get skill token (Cloud Plus) - ConfigEntry( - key="oauth_url", - type=ConfigEntryType.STRING, - label="OAuth URL (open to get token)", - required=False, - default_value=YANDEX_OAUTH_URL, - help_link=YANDEX_OAUTH_URL, - hidden=not is_registered, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Fill in from Yandex.Dialogs", - ), - # --- Player filter --- + ] + + +def _common_tail_entries( + player_options: list[ConfigValueOption], values: dict[str, ConfigValueType] +) -> list[ConfigEntry]: + """Player filter + hidden round-trip fields shared by every mode.""" + return [ ConfigEntry( key=CONF_EXPOSED_PLAYERS, type=ConfigEntryType.STRING, @@ -556,7 +468,6 @@ async def get_config_entries( default_value=[], options=list(player_options) if player_options else [], ), - # --- Auto-managed fields (hidden, populated by actions) --- ConfigEntry( key=CONF_CLOUD_INSTANCE_ID, type=ConfigEntryType.STRING, @@ -589,4 +500,4 @@ async def get_config_entries( required=False, value=(cast("str", values.get(CONF_DIRECT_ACCESS_TOKEN)) if values else None), ), - ) + ] diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py new file mode 100644 index 0000000000..1bc59c7547 --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -0,0 +1,1281 @@ +"""Low-level client for the undocumented dialogs.yandex.ru developer API. + +Implements the 8-step sequence captured from Chrome DevTools HAR for +creating a Smart Home skill with account-linking: + + 1. GET /developer → extract CSRF (secretkey) + 2. GET /developer/app-store-api/snapshot → existing skills list (optional) + 3. POST /developer/app-store-api/apps → skill_id + 4. POST /developer/app-store-api/apps/{id}/draft/upload-logo → logo_id + 5. PATCH /developer/app-store-api/apps/{id}/draft/update → settings + 6. POST /developer/app-store-api/oauth/apps → oauth_app_id + 7. POST /developer/app-store-api/apps/{id}/oauthApp → bind oauth + 8. POST /developer/app-store-api/apps/{id}/draft/request-deploy → publish + +This is an UNDOCUMENTED, PRIVATE API. It may break at any time. The +caller is responsible for surfacing that risk to the user (see +``provider.auto_skill_ui``). + +Authentication: passport session cookies (``Session_id`` / ``sessionid2``) +must already be present in the supplied ``aiohttp.ClientSession``'s +cookie jar. Obtain them via ``ya_passport_auth.PassportClient``: + + creds = await client.login_device_code(...) + await client.refresh_passport_cookies(creds.x_token) + creator = DialogsSkillCreator(client._session) + +The CSRF token (returned by ``fetch_csrf``) must be passed as the +``x-csrf-token`` header on every mutating request. +""" + +from __future__ import annotations + +import dataclasses +import json +import logging +import re +from contextlib import asynccontextmanager +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import aiohttp + +from .auto_skill_state import SkillCreationArtifacts, SkillCreationState +from .constants import ( + CLOUD_OAUTH_AUTHORIZE_URL, + CLOUD_OAUTH_TOKEN_URL, + CLOUD_SKILL_CLIENT_ID_TEMPLATE, + CLOUD_SKILL_CLIENT_SECRET, + CLOUD_SKILL_WEBHOOK_TEMPLATE, + CONNECTION_TYPE_CLOUD_PLUS, + CONNECTION_TYPE_DIRECT, + DIRECT_API_BASE_PATH, + DIRECT_AUTH_BASE_PATH, + DIRECT_OAUTH_CLIENT_ID, +) + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Awaitable, Callable, Mapping + + from music_assistant.mass import MusicAssistant + +__all__ = [ + "DEVICE_FLOW_TIMEOUT_SECONDS", + "DIALOGS_API_BASE", + "DIALOGS_CSRF_REGEX", + "DIALOGS_DEV_BASE", + "DIALOGS_DEV_HTML_URL", + "DialogsApiError", + "DialogsCsrfError", + "DialogsDuplicateSkillError", + "DialogsSkillCreator", + "auto_create_skill", + "build_draft_payload", + "build_oauth_app_payload", + "check_preconditions", + "derive_auth_urls", + "derive_backend_uri", + "derive_client_id", + "load_default_logo_bytes", +] + +_LOGGER = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Endpoints / patterns +# --------------------------------------------------------------------------- +DIALOGS_DEV_BASE = "https://dialogs.yandex.ru" +DIALOGS_DEV_HTML_URL = f"{DIALOGS_DEV_BASE}/developer" +DIALOGS_API_BASE = f"{DIALOGS_DEV_BASE}/developer/app-store-api" + +# The developer console embeds a CSRF token in its HTML as: +# ..."secretkey":"u9c94f1aca53bf156be4..."... +# Captured from HAR 2026-04-24. If Yandex re-renders differently, this +# regex will miss and ``fetch_csrf`` raises ``DialogsCsrfError`` so the +# user falls back to manual setup. +DIALOGS_CSRF_REGEX = re.compile(r'"secretkey":"([^"]+)"') + +SMART_HOME_CHANNEL = "smartHome" +_MAX_HTML_RESPONSE_BYTES = 2 * 1024 * 1024 # 2 MiB + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +class DialogsApiError(Exception): + """Base error for dialogs.yandex.ru API failures.""" + + def __init__( + self, + message: str, + *, + step: str, + http_status: int | None = None, + yandex_error: str | None = None, + ) -> None: + """Initialise with the pipeline step that failed for clearer messages.""" + super().__init__(message) + self.step = step + self.http_status = http_status + self.yandex_error = yandex_error + + +class DialogsCsrfError(DialogsApiError): + """Raised when the CSRF token cannot be extracted from the developer page.""" + + +class DialogsDuplicateSkillError(DialogsApiError): + """Raised when create_app rejects because a skill with the same name exists.""" + + +# --------------------------------------------------------------------------- +# Client +# --------------------------------------------------------------------------- + + +class DialogsSkillCreator: + """Thin async wrapper over dialogs.yandex.ru developer-console API. + + Every method is idempotent on the transport layer: a single call + either succeeds or raises. Retry / state-machine logic lives in the + orchestrator (see :func:`auto_create_skill`). + """ + + __slots__ = ("_logger", "_session") + + def __init__( + self, + session: aiohttp.ClientSession, + logger: logging.Logger | None = None, + ) -> None: + """Take a session that already carries Passport auth cookies.""" + self._session = session + self._logger = logger or _LOGGER + + # ----------------------------------------------------------------------- + # Step 1: CSRF token extraction + # ----------------------------------------------------------------------- + + async def fetch_csrf(self) -> str: + """Fetch the developer page HTML and extract the CSRF ``secretkey``. + + Caller uses the returned value as the ``x-csrf-token`` header on + all mutating requests. Returns a fresh token on every call; the + orchestrator caches it for the duration of a single attempt. + """ + async with self._session.get(DIALOGS_DEV_HTML_URL) as resp: + if resp.status == 401: + raise DialogsApiError( + "not authenticated — passport session cookies missing or expired", + step="fetch_csrf", + http_status=401, + ) + if resp.status != 200: + raise DialogsApiError( + f"dialogs.yandex.ru/developer returned HTTP {resp.status}", + step="fetch_csrf", + http_status=resp.status, + ) + # Enforce the size cap while reading so an oversized + # response can't buffer fully in memory (T5 pattern from + # ya-passport-auth). + body = bytearray() + async for chunk in resp.content.iter_chunked(8192): + body.extend(chunk) + if len(body) > _MAX_HTML_RESPONSE_BYTES: + raise DialogsApiError( + "developer page response exceeded size cap", + step="fetch_csrf", + ) + html = body.decode(resp.get_encoding() or "utf-8", errors="replace") + + match = DIALOGS_CSRF_REGEX.search(html) + if not match: + raise DialogsCsrfError( + "could not locate CSRF token in developer page HTML — " + "Yandex may have changed the rendering format", + step="fetch_csrf", + ) + token = match.group(1).strip() + if not token: + raise DialogsCsrfError( + "CSRF token matched but is empty", + step="fetch_csrf", + ) + self._logger.debug("dialogs CSRF token fetched (len=%d)", len(token)) + return token + + # ----------------------------------------------------------------------- + # Step 2: list existing skills (for duplicate-name detection) + # ----------------------------------------------------------------------- + + async def list_existing_skills(self, csrf: str) -> list[dict[str, Any]]: + """Return the user's existing skills from the snapshot endpoint. + + The dashboard uses this to populate its skill list; we use it to + warn the user before they hit a duplicate-name 4xx on create_app. + """ + url = f"{DIALOGS_API_BASE}/snapshot" + data = await self._get_json(url, csrf=csrf, step="list_existing_skills") + result = data.get("result") + if not isinstance(result, dict): + return [] + skills = result.get("skills") + if not isinstance(skills, list): + return [] + return [s for s in skills if isinstance(s, dict)] + + # ----------------------------------------------------------------------- + # Step 3: create the skill app + # ----------------------------------------------------------------------- + + async def create_app(self, csrf: str, name: str) -> str: + """Create a Smart Home skill with the given name. + + Returns the newly-minted ``skill_id`` (UUID). Raises + :class:`DialogsDuplicateSkillError` if the name is already taken + by another skill on this account. + """ + url = f"{DIALOGS_API_BASE}/apps" + payload = { + "channel": SMART_HOME_CHANNEL, + "language": "ru", + "isYangoConsole": False, + "appName": name, + } + data = await self._post_json(url, payload, csrf=csrf, step="create_app") + result = data.get("result") + if not isinstance(result, dict): + raise DialogsApiError( + "create_app response missing 'result' object", + step="create_app", + ) + skill_id = result.get("id") or result.get("skill_id") + if not isinstance(skill_id, str) or not skill_id: + raise DialogsApiError( + "create_app response missing skill id", + step="create_app", + ) + self._logger.info("dialogs skill created: id=%s name=%r", skill_id, name) + return skill_id + + # ----------------------------------------------------------------------- + # Step 4: upload logo + # ----------------------------------------------------------------------- + + async def upload_logo(self, csrf: str, skill_id: str, png: bytes) -> str: + """Upload a PNG logo for the skill. + + Returns a ``logo_id`` that must be referenced in ``update_draft``. + The logo file is sent as multipart with the field name ``file`` + and filename ``icon.png`` (matching the HAR capture). + """ + url = ( + f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/upload-logo" + f"?channel={SMART_HOME_CHANNEL}" + ) + form = aiohttp.FormData() + form.add_field( + "file", + png, + filename="icon.png", + content_type="image/png", + ) + headers = {"x-csrf-token": csrf} + async with self._session.post(url, data=form, headers=headers) as resp: + body = await resp.text() + if resp.status != 200: + raise DialogsApiError( + f"upload_logo HTTP {resp.status}: {body[:200]}", + step="upload_logo", + http_status=resp.status, + ) + data = _try_json(body) + result = data.get("result") if isinstance(data, dict) else None + if not isinstance(result, dict): + raise DialogsApiError( + "upload_logo response missing 'result'", + step="upload_logo", + ) + logo_id = result.get("id") + if not isinstance(logo_id, str) or not logo_id: + raise DialogsApiError( + "upload_logo response missing logo id", + step="upload_logo", + ) + return logo_id + + # ----------------------------------------------------------------------- + # Step 5: update draft settings + # ----------------------------------------------------------------------- + + async def update_draft( + self, csrf: str, skill_id: str, payload: Mapping[str, Any] + ) -> None: + """PATCH the skill draft with backend URL / publishing metadata.""" + url = f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/update" + await self._patch_json(url, dict(payload), csrf=csrf, step="update_draft") + + # ----------------------------------------------------------------------- + # Step 6: create OAuth app (account-linking) + # ----------------------------------------------------------------------- + + async def create_oauth_app( + self, + csrf: str, + *, + name: str, + client_id: str, + client_secret: str, + authorize_url: str, + token_url: str, + refresh_url: str, + ) -> str: + """Create the OAuth app that powers account-linking in the skill. + + Returns the OAuth-app UUID which is then bound to the skill via + ``attach_oauth``. + """ + url = f"{DIALOGS_API_BASE}/oauth/apps" + payload = { + "name": name, + "clientId": client_id, + "clientSecret": client_secret, + "authorizationUrl": authorize_url, + "tokenUrl": token_url, + "refreshTokenUrl": refresh_url, + "scope": "", + "yandexClientId": "", + } + data = await self._post_json(url, payload, csrf=csrf, step="create_oauth_app") + result = data.get("result") + if not isinstance(result, dict): + raise DialogsApiError( + "create_oauth_app response missing 'result'", + step="create_oauth_app", + ) + oauth_app_id = result.get("id") + if not isinstance(oauth_app_id, str) or not oauth_app_id: + raise DialogsApiError( + "create_oauth_app response missing oauth app id", + step="create_oauth_app", + ) + return oauth_app_id + + # ----------------------------------------------------------------------- + # Step 7: bind OAuth app to the skill + # ----------------------------------------------------------------------- + + async def attach_oauth( + self, csrf: str, skill_id: str, oauth_app_id: str + ) -> None: + """Attach an existing OAuth app to the skill's account-linking slot.""" + url = ( + f"{DIALOGS_API_BASE}/apps/{skill_id}/oauthApp" + f"?channel={SMART_HOME_CHANNEL}" + ) + payload = {"oauthAppId": oauth_app_id} + await self._post_json(url, payload, csrf=csrf, step="attach_oauth") + + # ----------------------------------------------------------------------- + # Step 8: publish (send for moderation) + # ----------------------------------------------------------------------- + + async def request_deploy(self, csrf: str, skill_id: str) -> None: + """Send the draft to moderation / publish. + + Body is empty; all params are in the query string. Returns on + 2xx; otherwise raises. + """ + url = ( + f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/request-deploy" + f"?channel={SMART_HOME_CHANNEL}" + ) + headers = {"x-csrf-token": csrf} + async with self._session.post(url, headers=headers) as resp: + body = await resp.text() + if resp.status not in (200, 201, 202, 204): + raise DialogsApiError( + f"request_deploy HTTP {resp.status}: {body[:200]}", + step="request_deploy", + http_status=resp.status, + ) + + # ----------------------------------------------------------------------- + # Internal helpers + # ----------------------------------------------------------------------- + + async def _get_json( + self, url: str, *, csrf: str, step: str + ) -> dict[str, Any]: + headers = {"x-csrf-token": csrf} + async with self._session.get(url, headers=headers) as resp: + body = await resp.text() + if resp.status != 200: + raise DialogsApiError( + f"GET {url} HTTP {resp.status}: {body[:200]}", + step=step, + http_status=resp.status, + ) + data = _try_json(body) + if not isinstance(data, dict): + raise DialogsApiError( + f"GET {url} returned non-object JSON", + step=step, + ) + return data + + async def _post_json( + self, url: str, payload: dict[str, Any], *, csrf: str, step: str + ) -> dict[str, Any]: + return await self._send_json("POST", url, payload, csrf=csrf, step=step) + + async def _patch_json( + self, url: str, payload: dict[str, Any], *, csrf: str, step: str + ) -> dict[str, Any]: + return await self._send_json("PATCH", url, payload, csrf=csrf, step=step) + + async def _send_json( + self, + method: str, + url: str, + payload: dict[str, Any], + *, + csrf: str, + step: str, + ) -> dict[str, Any]: + headers = { + "x-csrf-token": csrf, + "content-type": "application/json", + } + async with self._session.request( + method, url, json=payload, headers=headers + ) as resp: + body = await resp.text() + # Only ``create_app`` can fail with duplicate-name errors — + # other endpoints use 409 for unrelated conflicts and would + # be misclassified as duplicates if the mapping were global. + duplicate_candidate = step == "create_app" and ( + resp.status == 409 + or (resp.status in (400, 422) and _looks_like_duplicate(body)) + ) + if duplicate_candidate: + raise DialogsDuplicateSkillError( + f"{step}: skill with this name already exists", + step=step, + http_status=resp.status, + yandex_error=_extract_error_code(body), + ) + if resp.status not in (200, 201, 202): + raise DialogsApiError( + f"{method} {url} HTTP {resp.status}: {body[:200]}", + step=step, + http_status=resp.status, + yandex_error=_extract_error_code(body), + ) + data = _try_json(body) + if not isinstance(data, dict): + raise DialogsApiError( + f"{method} {url} returned non-object JSON", + step=step, + ) + return data + + +# --------------------------------------------------------------------------- +# Module-private helpers +# --------------------------------------------------------------------------- + + +def _try_json(body: str) -> Any: + """Parse JSON defensively — return None on any error.""" + if not body: + return None + try: + return json.loads(body) + except (ValueError, TypeError): + return None + + +def _looks_like_duplicate(body: str) -> bool: + """Heuristic for whether a 4xx body indicates a duplicate-name error.""" + if not body: + return False + lowered = body.lower() + return any( + kw in lowered + for kw in ("already exists", "duplicate", "exists with name", "not_unique") + ) + + +def _extract_error_code(body: str) -> str | None: + """Pull Yandex error code/message out of a 4xx response body (best-effort).""" + data = _try_json(body) + if not isinstance(data, dict): + return None + for key in ("error", "errorCode", "message", "code"): + value = data.get(key) + if isinstance(value, str) and value: + return value + return None + + +# --------------------------------------------------------------------------- +# Pure helpers: backend/oauth URLs, payload builders, preconditions +# +# All of these are side-effect-free and separately unit-testable; the +# orchestrator in a later commit wires them together. +# --------------------------------------------------------------------------- + + +def derive_backend_uri(mass: MusicAssistant, connection_type: str) -> str: + """Return the Backend URL the skill should point at for *connection_type*. + + cloud_plus → yaha-cloud.ru relay (fixed URL). + direct → ``{mass.webserver.base_url}`` + our API path (requires + HTTPS base URL; see :func:`check_preconditions`). + """ + if connection_type == CONNECTION_TYPE_CLOUD_PLUS: + return CLOUD_SKILL_WEBHOOK_TEMPLATE + if connection_type == CONNECTION_TYPE_DIRECT: + base = str(mass.webserver.base_url).rstrip("/") + return f"{base}{DIRECT_API_BASE_PATH}" + msg = f"auto-create is not supported for connection_type={connection_type!r}" + raise ValueError(msg) + + +def derive_auth_urls( + mass: MusicAssistant, connection_type: str +) -> tuple[str, str]: + """Return (authorize_url, token_url) for the OAuth app. + + cloud_plus uses the yaha-cloud relay's OAuth endpoints; direct + uses the MA webserver's own authorize/token endpoints (served by + provider/direct.py). + """ + if connection_type == CONNECTION_TYPE_CLOUD_PLUS: + return CLOUD_OAUTH_AUTHORIZE_URL, CLOUD_OAUTH_TOKEN_URL + if connection_type == CONNECTION_TYPE_DIRECT: + base = str(mass.webserver.base_url).rstrip("/") + return ( + f"{base}{DIRECT_AUTH_BASE_PATH}/authorize", + f"{base}{DIRECT_AUTH_BASE_PATH}/token", + ) + msg = f"auto-create is not supported for connection_type={connection_type!r}" + raise ValueError(msg) + + +def derive_client_id(connection_type: str, cloud_instance_id: str) -> str: + """Return the OAuth client_id to register in the skill's account linking. + + cloud_plus uses ``yandex_smart_home:{instance_id}`` (yaha-cloud + protocol); direct uses the fixed Yandex social redirect base URL + (its existing Yandex OAuth client expects this exact ID). + """ + if connection_type == CONNECTION_TYPE_CLOUD_PLUS: + if not cloud_instance_id: + msg = "cloud_plus requires a registered cloud_instance_id" + raise ValueError(msg) + return CLOUD_SKILL_CLIENT_ID_TEMPLATE.format(instance_id=cloud_instance_id) + if connection_type == CONNECTION_TYPE_DIRECT: + return DIRECT_OAUTH_CLIENT_ID + msg = f"auto-create is not supported for connection_type={connection_type!r}" + raise ValueError(msg) + + +def build_draft_payload( + *, + connection_type: str, + skill_name: str, + backend_uri: str, + logo_id: str | None, + developer_name: str = "Music Assistant user", +) -> dict[str, Any]: + """Compose the PATCH /draft/update body for a Smart Home skill. + + Matches the HAR sample field-for-field; every key that the + dashboard UI sends on save is reproduced so Yandex's validator + sees a complete draft and allows ``request-deploy`` afterwards. + """ + if connection_type not in (CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT): + msg = f"auto-create is not supported for connection_type={connection_type!r}" + raise ValueError(msg) + + return { + "logo2": None, + "name": skill_name, + "voice": "shitova.us", + "logoId": logo_id, + "skillAccess": "private", + "hideInStore": False, + "noteForModerator": "", + "backendSettings": { + "uri": backend_uri, + "functionId": "", + "backendType": "webhook", + }, + "publishingSettings": { + "brandVerificationWebsite": "", + "category": "smart_home", + "developerName": developer_name, + "secondaryTitle": "", + "email": "", # server pulls from the authenticated session + "smartHome": { + "deepLinks": { + "android": {"url": ""}, + "ios": {"url": "", "fallbackUrl": ""}, + }, + }, + "multilingualSettings": { + "ru": { + "name": skill_name, + "secondaryTitle": "", + "externalSettingsDescription": skill_name, + "supportedUnitsDescription": skill_name, + }, + }, + }, + "oauthAppId": None, + "isTrustedSmartHomeSkill": False, + "enableAllAvailableRegions": True, + "selectedRegions": [], + "channel": SMART_HOME_CHANNEL, + } + + +def build_oauth_app_payload( + *, + skill_name: str, + client_id: str, + client_secret: str, + authorize_url: str, + token_url: str, +) -> dict[str, Any]: + """Compose the POST /oauth/apps body for account-linking. + + Values come from :func:`derive_client_id`, :func:`derive_auth_urls`, + and :func:`derive_backend_uri`'s caller context. ``refreshTokenUrl`` + always equals ``token_url`` — Yandex's flow uses the same endpoint + for both grant types. + """ + return { + "name": skill_name, + "clientId": client_id, + "clientSecret": client_secret, + "authorizationUrl": authorize_url, + "tokenUrl": token_url, + "refreshTokenUrl": token_url, + "scope": "", + "yandexClientId": "", + } + + +def check_preconditions( + *, + connection_type: str, + mass: MusicAssistant, + cloud_instance_id: str, + direct_client_secret: str, +) -> None: + """Validate that auto-create can run for the given connection type. + + Raises :class:`ValueError` with a human-readable message on failure. + Called before any network I/O so the UI can surface the error + without a half-created skill on Yandex's side. + """ + if connection_type == CONNECTION_TYPE_CLOUD_PLUS: + if not cloud_instance_id: + msg = ( + "Cloud Plus requires a registered yaha-cloud instance first. " + "Use the 'Register with cloud' action." + ) + raise ValueError(msg) + return + + if connection_type == CONNECTION_TYPE_DIRECT: + if not direct_client_secret: + msg = "Direct mode requires a generated Client Secret" + raise ValueError(msg) + try: + base = str(mass.webserver.base_url) + except Exception as exc: + msg = f"MA webserver base URL is not available: {exc}" + raise ValueError(msg) from exc + if not base.startswith("https://"): + msg = ( + "Direct mode requires MA to be reachable over HTTPS from the " + f"public internet (got base_url={base!r}). Yandex will reject " + "a skill with a non-HTTPS backend." + ) + raise ValueError(msg) + return + + msg = ( + f"auto-create is not supported for connection_type={connection_type!r}; " + "use cloud_plus or direct." + ) + raise ValueError(msg) + + +# --------------------------------------------------------------------------- +# Orchestrator: device flow + resumable pipeline +# --------------------------------------------------------------------------- + +DEVICE_FLOW_TIMEOUT_SECONDS = 300.0 +"""Hard cap on how long we'll wait for the user to enter the code.""" + +_DEVICE_CODE_PAGE_PATH = "/yandex_smarthome/device_code" +# Keep the intermediate HTML page alive long enough for one more poll +# after state flips to done/failed — ~1s is plenty, the page polls +# every 2s so we're just covering the in-flight window. +_POST_AUTH_GRACE_SECONDS = 1 +# Server-suggested interval from Yandex is 5s (RFC 8628) but after the +# user has confirmed the code we want to detect it promptly; 2s is the +# RFC-recommended minimum. If Yandex ever returns SLOW_DOWN, the library +# bumps the interval automatically. +_DEVICE_FLOW_POLL_INTERVAL = 2.0 +_SAFE_SESSION_ID_RE = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z") + + +def _build_device_code_page( + user_code: str, verification_url: str, status_url: str +) -> str: + """Render the HTML page shown during Device Flow login. + + Yandex's ya.ru/device page does not pre-fill from query params and + strips them on redirect-to-login, so the only reliable way to show + the code is to host our own page in MA's webserver that displays + the code prominently and opens ya.ru/device in a new tab. + + Pattern copied from ``ma-provider-yandex-station/provider/auth.py``. + """ + import html # noqa: PLC0415 + + safe_code = html.escape(user_code) + safe_url = html.escape(verification_url, quote=True) + safe_status_url = json.dumps(status_url).replace(" + + + + Yandex Smart Home — Device Code + + + + +
+

Authorise Music Assistant for skill creation

+

Open the link below, log in to your Yandex account, and enter this code.

+
{safe_code}
+
+ +
+ Continue to Yandex +
+ + +""" + + +async def _default_authenticator( + *, + mass: MusicAssistant, + session_id: str, + timeout: float, +) -> AsyncIterator[aiohttp.ClientSession]: + """Real-world authentication path — runs Device Flow and yields a session. + + Serves an intermediate HTML page through MA's webserver so the user + sees the short ``user_code`` (Yandex's ya.ru/device does not pre-fill + from query params). The popup is opened via + :class:`AuthenticationHelper` using the frontend-provided + ``session_id`` — that's how MA's UI knows which popup session to + render and later close. + + Pattern copied from ``ma-provider-yandex-station/provider/auth.py``. + """ + import asyncio # noqa: PLC0415 + + from aiohttp import web # noqa: PLC0415 + from ya_passport_auth import ClientConfig, PassportClient # noqa: PLC0415 + from ya_passport_auth.config import DEFAULT_ALLOWED_HOSTS # noqa: PLC0415 + + from music_assistant.helpers.auth import AuthenticationHelper # noqa: PLC0415 + + if not _SAFE_SESSION_ID_RE.match(session_id): + msg = "invalid session_id for device authentication" + raise ValueError(msg) + + allowed = DEFAULT_ALLOWED_HOSTS | frozenset({"dialogs.yandex.ru"}) + config = ClientConfig(allowed_hosts=allowed) + + async with PassportClient.create(config=config) as client: + device_session = await client.start_device_login() + # Don't log user_code — it's a time-limited credential (grants + # Yandex sign-in for the device-flow window) and writing it to + # shared log backends would leak access. + _LOGGER.info( + "device flow started — verification_url=%s", + device_session.verification_url, + ) + + page_path = f"{_DEVICE_CODE_PAGE_PATH}/{session_id}" + status_path = f"{page_path}/status" + # MA frontend requires an absolute URL in signal_event(AUTH_SESSION, + # ...). The URL comes from mass.webserver.base_url which the user + # configures in Settings → Core → Webserver → Base URL. If they + # haven't touched it and MA is behind Docker/reverse-proxy, it may + # point at an unreachable internal address — the warning log below + # gives them the path so they can open it manually if the popup + # fails to load. + base_url = str(mass.webserver.base_url).rstrip("/") + status_url = f"{base_url}{status_path}" + page_url = f"{base_url}{page_path}" + state = {"value": "pending"} + + page_html = _build_device_code_page( + device_session.user_code, + device_session.verification_url, + status_url, + ) + + async def _serve_page(_request: web.Request) -> web.Response: + return web.Response( + text=page_html, + content_type="text/html", + charset="utf-8", + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + "Expires": "0", + }, + ) + + async def _serve_status(_request: web.Request) -> web.Response: + return web.json_response( + {"state": state["value"]}, + headers={"Cache-Control": "no-store"}, + ) + + mass.webserver.register_dynamic_route(page_path, _serve_page, "GET") + mass.webserver.register_dynamic_route(status_path, _serve_status, "GET") + _LOGGER.warning( + "auto-skill: device-code popup URL %s (path=%s) " + "— if the popup does not open or points at an unreachable " + "address, open the path directly in your browser (the page " + "displays the user_code) or fix Settings → Core → Webserver " + "→ Base URL", + page_url, + page_path, + ) + try: + async with AuthenticationHelper(mass, session_id) as auth_helper: + auth_helper.send_url(page_url) + try: + creds = await client.poll_device_until_confirmed( + device_session, + total_timeout=timeout, + poll_interval=_DEVICE_FLOW_POLL_INTERVAL, + ) + except asyncio.CancelledError: + raise + except Exception: + state["value"] = "failed" + await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) + raise + state["value"] = "done" + await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) + finally: + mass.webserver.unregister_dynamic_route(page_path, "GET") + mass.webserver.unregister_dynamic_route(status_path, "GET") + + await client.refresh_passport_cookies(creds.x_token) + yield client._session + + +def _build_authenticator_cm( + authenticator: Callable[..., AsyncIterator[aiohttp.ClientSession]], + *, + mass: MusicAssistant, + session_id: str, + timeout: float, +) -> Any: + """Wrap *authenticator* so it supports ``async with`` uniformly. + + The default implementation is a plain async generator; tests may + pass either a generator or an already-decorated + ``@asynccontextmanager``. ``asynccontextmanager`` is idempotent on + an already-decorated callable, so we wrap unconditionally. + """ + cm_factory = asynccontextmanager(authenticator) + return cm_factory(mass=mass, session_id=session_id, timeout=timeout) + + +async def auto_create_skill( # noqa: PLR0913 + *, + mass: MusicAssistant, + connection_type: str, + skill_name: str, + artifacts: SkillCreationArtifacts, + cloud_instance_id: str, + direct_client_secret: str, + logo_bytes: bytes, + session_id: str, + progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None = None, + authenticator: Callable[..., AsyncIterator[aiohttp.ClientSession]] | None = None, + creator_factory: Callable[[aiohttp.ClientSession], DialogsSkillCreator] | None = None, + timeout: float = DEVICE_FLOW_TIMEOUT_SECONDS, + developer_name: str = "Music Assistant user", +) -> SkillCreationArtifacts: + """End-to-end flow: Device Flow → passport cookies → skill pipeline. + + Resumes from ``artifacts.state`` — steps that already completed + (skill_id present, etc.) are skipped. On any exception, returns + artifacts with ``state=FAILED`` and a human-readable ``last_error`` + instead of re-raising, so the config-flow UI can render the message + without crashing. + + ``progress_cb`` is invoked after each successful step with the + updated artifacts; the caller uses it to persist state to MA config + so a subsequent retry resumes from the latest completed step. + + ``authenticator`` and ``creator_factory`` are injection points for + tests; production callers leave them as ``None`` to use the real + Device Flow and a real :class:`DialogsSkillCreator`. + """ + # Precondition failures surface unmodified (caller decides message). + check_preconditions( + connection_type=connection_type, + mass=mass, + cloud_instance_id=cloud_instance_id, + direct_client_secret=direct_client_secret, + ) + + auth_fn = authenticator or _default_authenticator + creator_fn = creator_factory or DialogsSkillCreator + + try: + async with _build_authenticator_cm( + auth_fn, mass=mass, session_id=session_id, timeout=timeout + ) as session: + creator = creator_fn(session) + return await _run_pipeline_with_recovery( + creator=creator, + artifacts=artifacts, + connection_type=connection_type, + skill_name=skill_name, + cloud_instance_id=cloud_instance_id, + direct_client_secret=direct_client_secret, + logo_bytes=logo_bytes, + mass=mass, + developer_name=developer_name, + progress_cb=progress_cb, + ) + except ValueError: + raise + except Exception as exc: + _LOGGER.exception("auto-create hit unexpected error") + return dataclasses.replace( + artifacts, state=SkillCreationState.FAILED, last_error=repr(exc) + ) + + +async def _run_pipeline_with_recovery( + *, + creator: DialogsSkillCreator, + artifacts: SkillCreationArtifacts, + connection_type: str, + skill_name: str, + cloud_instance_id: str, + direct_client_secret: str, + logo_bytes: bytes, + mass: MusicAssistant, + developer_name: str, + progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, +) -> SkillCreationArtifacts: + """Fetch CSRF and run the pipeline, preserving partial state on failure. + + Holds a ``current`` reference that ``_execute_pipeline`` updates via + ``progress_cb``, so a mid-pipeline raise lets us surface whatever + progress was captured (skill_id / logo_id / oauth_app_id) as a + FAILED artifact instead of losing it. + """ + current = artifacts + + async def _track(a: SkillCreationArtifacts) -> None: + nonlocal current + current = a + if progress_cb is not None: + await progress_cb(a) + + try: + _LOGGER.info("auto-skill: fetching CSRF from dialogs.yandex.ru") + csrf = await creator.fetch_csrf() + _LOGGER.info("auto-skill: CSRF acquired, starting skill pipeline") + return await _execute_pipeline( + creator=creator, + csrf=csrf, + artifacts=artifacts, + connection_type=connection_type, + skill_name=skill_name, + cloud_instance_id=cloud_instance_id, + direct_client_secret=direct_client_secret, + logo_bytes=logo_bytes, + mass=mass, + developer_name=developer_name, + progress_cb=_track, + ) + except DialogsApiError as exc: + _LOGGER.warning( + "auto-create failed at %s: %s", exc.step, exc, exc_info=True + ) + return dataclasses.replace( + current, state=SkillCreationState.FAILED, last_error=str(exc) + ) + + +async def _execute_pipeline( # noqa: PLR0913 + *, + creator: DialogsSkillCreator, + csrf: str, + artifacts: SkillCreationArtifacts, + connection_type: str, + skill_name: str, + cloud_instance_id: str, + direct_client_secret: str, + logo_bytes: bytes, + mass: MusicAssistant, + developer_name: str, + progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, +) -> SkillCreationArtifacts: + """Advance through states sequentially, skipping completed steps.""" + state = artifacts.state + + # -- Step 3: create app -- + if state in (SkillCreationState.NONE, SkillCreationState.FAILED): + _LOGGER.info("auto-skill: [1/5] creating skill app") + new_skill_id = await creator.create_app(csrf, skill_name) + artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.APP_CREATED, + skill_id=new_skill_id, + last_error=None, + ) + await _maybe_save(progress_cb, artifacts) + state = artifacts.state + + if artifacts.skill_id is None: + msg = "internal error: skill_id missing after create_app" + raise RuntimeError(msg) + skill_id: str = artifacts.skill_id + + # -- Step 4+5: upload logo and update draft (merged step) -- + if state == SkillCreationState.APP_CREATED: + logo_id = artifacts.logo_id + if logo_id is None: + _LOGGER.info("auto-skill: [2/5] uploading logo") + logo_id = await creator.upload_logo(csrf, skill_id, logo_bytes) + artifacts = dataclasses.replace(artifacts, logo_id=logo_id) + + backend_uri = derive_backend_uri(mass, connection_type) + draft = build_draft_payload( + connection_type=connection_type, + skill_name=skill_name, + backend_uri=backend_uri, + logo_id=logo_id, + developer_name=developer_name, + ) + _LOGGER.info("auto-skill: [3/5] updating draft with settings") + await creator.update_draft(csrf, skill_id, draft) + artifacts = dataclasses.replace( + artifacts, state=SkillCreationState.DRAFT_UPDATED + ) + await _maybe_save(progress_cb, artifacts) + state = artifacts.state + + # -- Step 6: create OAuth app -- + if state == SkillCreationState.DRAFT_UPDATED: + client_id = derive_client_id(connection_type, cloud_instance_id) + client_secret = ( + CLOUD_SKILL_CLIENT_SECRET + if connection_type == CONNECTION_TYPE_CLOUD_PLUS + else direct_client_secret + ) + authorize_url, token_url = derive_auth_urls(mass, connection_type) + _LOGGER.info("auto-skill: [4/5] creating OAuth app + attaching") + oauth_app_id = await creator.create_oauth_app( + csrf, + name=skill_name, + client_id=client_id, + client_secret=client_secret, + authorize_url=authorize_url, + token_url=token_url, + refresh_url=token_url, + ) + artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.OAUTH_CREATED, + oauth_app_id=oauth_app_id, + ) + await _maybe_save(progress_cb, artifacts) + state = artifacts.state + + if artifacts.oauth_app_id is None: + msg = "internal error: oauth_app_id missing after create_oauth_app" + raise RuntimeError(msg) + oauth_app_id_str: str = artifacts.oauth_app_id + + # -- Step 7: attach OAuth app to skill -- + if state == SkillCreationState.OAUTH_CREATED: + await creator.attach_oauth(csrf, skill_id, oauth_app_id_str) + artifacts = dataclasses.replace( + artifacts, state=SkillCreationState.OAUTH_ATTACHED + ) + await _maybe_save(progress_cb, artifacts) + state = artifacts.state + + # -- Step 8: publish -- + if state in ( + SkillCreationState.OAUTH_ATTACHED, + SkillCreationState.DEPLOY_REQUESTED, + ): + _LOGGER.info("auto-skill: [5/5] publishing skill") + await creator.request_deploy(csrf, skill_id) + artifacts = dataclasses.replace( + artifacts, state=SkillCreationState.DONE + ) + await _maybe_save(progress_cb, artifacts) + + return artifacts + + +# Minimal 1x1 transparent PNG — used when the packaged logo is missing +# (e.g. during unit tests before the asset commit lands). Real installs +# pick up provider/auto_skill_logo.png instead. +_FALLBACK_LOGO_PNG = bytes.fromhex( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" + "0000000d49444154789c6300010000050001d0a0c9a30000000049454e44ae426082" +) + + +def load_default_logo_bytes() -> bytes: + """Return PNG bytes for the skill logo. + + Reads ``provider/auto_skill_logo.png`` if it exists; otherwise + returns a 1x1 transparent PNG so tests can run without the asset. + """ + path = Path(__file__).parent / "auto_skill_logo.png" + if path.is_file(): + return path.read_bytes() + return _FALLBACK_LOGO_PNG + + +async def _maybe_save( + progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, + artifacts: SkillCreationArtifacts, +) -> None: + """Call ``progress_cb`` if provided, swallowing any save errors.""" + if progress_cb is None: + return + try: + await progress_cb(artifacts) + except Exception: + _LOGGER.exception("progress_cb raised; continuing pipeline anyway") diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_logo.png b/music_assistant/providers/yandex_smarthome/auto_skill_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..86904a3613f1ca190f15249cb3df0ac60f7c9546 GIT binary patch literal 43607 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 t4zz8O3}a}e;Otzn>Et7`Y?e*ir+p$Py0 literal 0 HcmV?d00001 diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_state.py b/music_assistant/providers/yandex_smarthome/auto_skill_state.py new file mode 100644 index 0000000000..67182568fa --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/auto_skill_state.py @@ -0,0 +1,121 @@ +"""State model for experimental auto-create-skill feature. + +Tracks progress of the multi-step skill creation flow against +dialogs.yandex.ru so partial failures can be retried from the +last successful step rather than starting over. +""" + +from __future__ import annotations + +import dataclasses +import json +import logging +from dataclasses import dataclass +from enum import StrEnum + +_LOGGER = logging.getLogger(__name__) + +__all__ = [ + "SkillCreationArtifacts", + "SkillCreationState", + "dump_artifacts", + "load_artifacts", +] + + +class SkillCreationState(StrEnum): + """Progress marker for the skill-creation pipeline. + + Linear states advance through the 6 HAR-captured API calls. + ``FAILED`` replaces the stored linear state in the artifact; + failure details are kept separately in ``last_error``, while + captured artifact IDs (``skill_id`` / ``logo_id`` / ``oauth_app_id``) + stay intact so a retry can resume from the partial results. + """ + + NONE = "none" + APP_CREATED = "app_created" + DRAFT_UPDATED = "draft_updated" + OAUTH_CREATED = "oauth_created" + OAUTH_ATTACHED = "oauth_attached" + DEPLOY_REQUESTED = "deploy_requested" + DONE = "done" + FAILED = "failed" + + +@dataclass(frozen=True, slots=True) +class SkillCreationArtifacts: + """Persistent state for a skill-creation attempt. + + Stored as a JSON blob in the ``CONF_AUTO_CREATE_ARTIFACTS`` + config entry and round-tripped through every call to + ``get_config_entries``. + """ + + state: SkillCreationState = SkillCreationState.NONE + skill_id: str | None = None + logo_id: str | None = None + oauth_app_id: str | None = None + last_error: str | None = None + + +def dump_artifacts(artifacts: SkillCreationArtifacts) -> str: + """Serialise artifacts to a JSON string for config storage.""" + return json.dumps( + { + "state": artifacts.state.value, + "skill_id": artifacts.skill_id, + "logo_id": artifacts.logo_id, + "oauth_app_id": artifacts.oauth_app_id, + "last_error": artifacts.last_error, + }, + ensure_ascii=False, + ) + + +def load_artifacts(raw: str | None) -> SkillCreationArtifacts: + """Deserialise artifacts from a config-stored JSON string. + + Returns a fresh ``SkillCreationArtifacts`` on any parse error + or missing input — the feature is optional so config stays + usable even if the blob is corrupted. + """ + if not raw: + return SkillCreationArtifacts() + try: + data = json.loads(raw) + except (ValueError, TypeError): + _LOGGER.warning("auto-skill artifacts corrupt, resetting") + return SkillCreationArtifacts() + if not isinstance(data, dict): + return SkillCreationArtifacts() + + try: + state = SkillCreationState(str(data.get("state", SkillCreationState.NONE.value))) + except ValueError: + state = SkillCreationState.NONE + + def _opt_str(key: str) -> str | None: + value = data.get(key) + if value is None: + return None + return str(value) if value else None + + return SkillCreationArtifacts( + state=state, + skill_id=_opt_str("skill_id"), + logo_id=_opt_str("logo_id"), + oauth_app_id=_opt_str("oauth_app_id"), + last_error=_opt_str("last_error"), + ) + + +def mark_failed( + artifacts: SkillCreationArtifacts, error: str +) -> SkillCreationArtifacts: + """Return a copy of *artifacts* flipped to ``FAILED`` with an error.""" + return dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=error, + ) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py new file mode 100644 index 0000000000..48822e6853 --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py @@ -0,0 +1,842 @@ +"""ConfigEntry builders for the Yandex Smart Home provider. + +Builds the numbered-step config form per connection type: + +* ``cloud_plus`` → 3 steps: Register cloud → Create skill → Link via OTP. +* ``direct`` → 1 step: Create skill (skill is linked by Yandex Dialogs + account-linking UI, no OTP). +* ``cloud`` → 2 steps: Register → Link via OTP (unchanged). + +Each step hides until the previous completes, so the user always sees +the single next action they need to take. + +Kept separate from ``__init__.py`` so the long field list doesn't bloat +``get_config_entries`` and so it can be unit-tested in isolation from +the network-facing parts of the feature. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import ConfigEntryType + +from .auto_skill_state import SkillCreationArtifacts, SkillCreationState +from .constants import ( + CLOUD_OAUTH_AUTHORIZE_URL, + CLOUD_OAUTH_TOKEN_URL, + CLOUD_SKILL_CLIENT_ID_TEMPLATE, + CLOUD_SKILL_CLIENT_SECRET, + CLOUD_SKILL_WEBHOOK_TEMPLATE, + CONF_ACTION_AUTO_CREATE, + CONF_ACTION_GET_OTP, + CONF_ACTION_REGISTER, + CONF_AUTO_CREATE_ARTIFACTS, + CONF_AUTO_CREATE_SESSION_ID, + CONF_CONNECTION_TYPE, + CONF_DIRECT_CLIENT_SECRET, + CONF_SKILL_ID, + CONF_SKILL_TOKEN, + CONNECTION_TYPE_CLOUD, + CONNECTION_TYPE_CLOUD_PLUS, + CONNECTION_TYPE_DIRECT, + DIRECT_API_BASE_PATH, + DIRECT_AUTH_BASE_PATH, + DIRECT_OAUTH_CLIENT_ID, + YANDEX_DIALOGS_DEVELOPER_URL, + YANDEX_OAUTH_URL, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + +__all__ = [ + "AUTO_CREATE_CATEGORY", + "auto_create_entries", + "build_cloud_plus_entries", + "build_direct_entries", + "should_show_button", +] + +AUTO_CREATE_CATEGORY = "Auto-create skill" + +# Category names are used as visual group headers in the MA UI. Numbered +# so they render in order and users see the flow as a sequence. +_CAT_STEP_1_REGISTER = "Step 1 — Register cloud instance" +_CAT_STEP_2_CREATE = "Step 2 — Create Smart Home skill" +_CAT_STEP_3_LINK = "Step 3 — Link skill to Yandex" +# Direct mode has only one step — no cloud registration, no OTP linking. +_CAT_STEP_DIRECT_CREATE = "Create Smart Home skill" + + +def _status_label(state: SkillCreationState, last_error: str | None) -> str: + """Human-readable status line shown in the UI.""" + if state == SkillCreationState.DONE: + return ( + "✅ Skill created and published. Now get the OAuth token " + "(link below) and paste it into 'Skill OAuth Token'." + ) + if state == SkillCreationState.FAILED: + err = last_error or "unknown error" + return ( + f"❌ Creation failed: {err}\n" + f"Press '{_action_label(state)}' to try again, or fill in " + "Skill ID / Skill OAuth Token manually below." + ) + if state == SkillCreationState.NONE: + return "Ready to create skill. Press the button below to start." + # Any partial state — resume is possible. + return ( + f"Partial progress saved ({state.value}). " + f"Press '{_action_label(state)}' to finish, or fill Skill ID manually." + ) + + +def _action_label(state: SkillCreationState) -> str: + if state == SkillCreationState.NONE: + return "Create skill automatically" + if state == SkillCreationState.FAILED: + return "Retry" + return "Retry from last step" + + +def should_show_button( + *, + connection_type: str, + state: SkillCreationState, + cloud_instance_id: str, + base_url: str, +) -> bool: + """Return True iff the auto-create action button is actionable now. + + Hides the button when: + - Mode is plain ``cloud`` (no custom skill exists there). + - Skill creation already reached DONE. + - cloud_plus is selected but no cloud instance has been registered. + - direct is selected but MA base_url is not HTTPS. + """ + if connection_type == CONNECTION_TYPE_CLOUD: + return False + if state == SkillCreationState.DONE: + return False + if connection_type == CONNECTION_TYPE_CLOUD_PLUS and not cloud_instance_id: + return False + return not ( + connection_type == CONNECTION_TYPE_DIRECT + and not base_url.startswith("https://") + ) + + +def auto_create_entries( + *, + connection_type: str, + artifacts: SkillCreationArtifacts, + cloud_instance_id: str, + base_url: str, + session_id: str | None, + user_code: str | None, + verification_url: str | None, + existing_artifacts_raw: str | None, +) -> Sequence[ConfigEntry]: + """Build the auto-create section of the config form. + + Empty list for ``cloud`` mode — the feature is meaningless without + a custom skill. + """ + if connection_type == CONNECTION_TYPE_CLOUD: + return () + + entries: list[ConfigEntry] = [] + + # Device-flow user code — shown when the flow obtained one this round. + if user_code: + entries.append( + ConfigEntry( + key="auto_create_user_code", + type=ConfigEntryType.STRING, + label="Device code for ya.ru/device", + description=( + "Open the URL below in your browser, log in to your " + "Yandex account, and enter this code." + ), + value=user_code, + required=False, + help_link=verification_url or "https://ya.ru/device", + depends_on=CONF_CONNECTION_TYPE, + depends_on_value_not=CONNECTION_TYPE_CLOUD, + category=AUTO_CREATE_CATEGORY, + ) + ) + + # Status label — dynamic based on current artifact state. + entries.append( + ConfigEntry( + key="label_auto_create_status", + type=ConfigEntryType.LABEL, + label=_status_label(artifacts.state, artifacts.last_error), + depends_on=CONF_CONNECTION_TYPE, + depends_on_value_not=CONNECTION_TYPE_CLOUD, + category=AUTO_CREATE_CATEGORY, + ) + ) + + # The action button — hidden in states where it can't run. + show_button = should_show_button( + connection_type=connection_type, + state=artifacts.state, + cloud_instance_id=cloud_instance_id, + base_url=base_url, + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_AUTO_CREATE, + type=ConfigEntryType.ACTION, + label=_action_label(artifacts.state), + description=( + "Runs the Yandex Device Flow login, then creates and " + "publishes the private Smart Home skill. Takes ~30 seconds " + "after you enter the code." + ), + action=CONF_ACTION_AUTO_CREATE, + action_label=_action_label(artifacts.state), + hidden=not show_button, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value_not=CONNECTION_TYPE_CLOUD, + category=AUTO_CREATE_CATEGORY, + ) + ) + + entries.extend(_hidden_state_entries(existing_artifacts_raw, session_id)) + return entries + + +def _hidden_state_entries( + existing_artifacts_raw: str | None, session_id: str | None +) -> list[ConfigEntry]: + """Round-trip the artifact blob and session id through the config form.""" + return [ + ConfigEntry( + key=CONF_AUTO_CREATE_ARTIFACTS, + type=ConfigEntryType.STRING, + label="Auto-create artifacts (internal)", + hidden=True, + required=False, + value=existing_artifacts_raw, + ), + ConfigEntry( + key=CONF_AUTO_CREATE_SESSION_ID, + type=ConfigEntryType.STRING, + label="Auto-create session id (internal)", + hidden=True, + required=False, + value=session_id, + ), + ] + + +# --------------------------------------------------------------------------- +# Cloud Plus step-flow +# --------------------------------------------------------------------------- + + +def build_cloud_plus_entries( # noqa: PLR0913 + *, + otp_code: str | None, + is_registered: bool, + cloud_instance_id: str, + artifacts: SkillCreationArtifacts, + session_id: str | None, + user_code: str | None, + verification_url: str | None, + existing_artifacts_raw: str | None, + base_url: str, + skill_id: str = "", + skill_token_set: bool = False, +) -> list[ConfigEntry]: + """Return the cloud_plus-mode config entries as three visible steps. + + Step 1 (Register) — always visible. + Step 2 (Create skill) — visible once cloud instance is registered. + Step 3 (Link via OTP) — visible once the skill (id + token) is set. + """ + skill_id_set = bool(skill_id) + fully_configured = skill_id_set and skill_token_set + + entries: list[ConfigEntry] = [] + entries.extend(_step1_register_entries(is_registered, cloud_instance_id)) + entries.extend( + _step2_create_skill_entries( + is_registered=is_registered, + cloud_instance_id=cloud_instance_id, + artifacts=artifacts, + user_code=user_code, + verification_url=verification_url, + base_url=base_url, + skill_id=skill_id, + fully_configured=fully_configured, + ) + ) + entries.extend( + _step3_link_entries( + is_registered=is_registered, + skill_id_set=skill_id_set, + otp_code=otp_code, + ) + ) + entries.extend(_hidden_state_entries(existing_artifacts_raw, session_id)) + return entries + + +def _step1_register_entries( + is_registered: bool, cloud_instance_id: str +) -> list[ConfigEntry]: + """Step 1 — yaha-cloud.ru instance registration.""" + status_text = ( + f"✅ Cloud instance registered (id: {cloud_instance_id})." + if is_registered + else ( + "Click 'Register with cloud' to create a yaha-cloud.ru relay " + "instance. This is free and takes a second." + ) + ) + return [ + ConfigEntry( + key="label_step1_status", + type=ConfigEntryType.LABEL, + label=status_text, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + category=_CAT_STEP_1_REGISTER, + ), + ConfigEntry( + key=CONF_ACTION_REGISTER, + type=ConfigEntryType.ACTION, + label="Register cloud instance", + description="Registers a new instance on yaha-cloud.ru relay.", + action=CONF_ACTION_REGISTER, + action_label="Register with cloud", + hidden=is_registered, + # No depends_on — see note above action_auto_create. + category=_CAT_STEP_1_REGISTER, + ), + ] + + +def _create_skill_step_entries( + *, + connection_type: str, + category: str, + cloud_instance_id: str, + artifacts: SkillCreationArtifacts, + user_code: str | None, + verification_url: str | None, + base_url: str, + direct_client_secret: str = "", + skill_id: str = "", + fully_configured: bool = False, +) -> list[ConfigEntry]: + """Shared builder for the Create-Skill step. + + Used by both cloud_plus Step 2 and direct single-step mode. + + Skill ID / Skill OAuth Token are shown after DONE (happy path) or + FAILED (so the user can finish manually). FAILED additionally shows + manual copy-paste fields (Backend URL / Client ID / Secret / Auth + URLs / Dialogs console link) so the user can create the skill by + hand in Yandex.Dialogs without leaving the form. + """ + entries: list[ConfigEntry] = [] + + if user_code: + entries.append( + ConfigEntry( + key="auto_create_user_code", + type=ConfigEntryType.STRING, + label="Device code for ya.ru/device", + description=( + "Open the URL below, log in to your Yandex account, " + "and enter this code." + ), + value=user_code, + required=False, + help_link=verification_url or "https://ya.ru/device", + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ) + ) + + entries.append( + ConfigEntry( + key="label_create_skill_status", + type=ConfigEntryType.LABEL, + label=_status_label(artifacts.state, artifacts.last_error), + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ) + ) + + # direct mode prerequisite: Yandex Dialogs only accepts HTTPS + # backends. If MA's Base URL is not HTTPS, show the user what's + # wrong (and what URL we'd have used) so they can fix it in + # Settings → Core → Webserver → Base URL. + direct_https_missing = ( + connection_type == CONNECTION_TYPE_DIRECT + and not base_url.startswith("https://") + ) + if direct_https_missing: + entries.append( + ConfigEntry( + key="label_direct_https_warning", + type=ConfigEntryType.LABEL, + label=( + f"⚠️ MA's Base URL is {base_url or ''}. " + "Direct mode requires a **publicly reachable HTTPS URL** — " + "Yandex refuses to talk to a non-HTTPS backend. " + "Set a reverse proxy with a real certificate and " + "update Settings → Core → Webserver → Base URL, then " + "reopen these settings." + ), + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ) + ) + entries.append( + ConfigEntry( + key="current_ma_base_url", + type=ConfigEntryType.STRING, + label="Current MA Base URL (read-only)", + description=( + "For reference. Change it in Settings → Core → Webserver " + "→ Base URL; provider doesn't own this setting." + ), + required=False, + default_value=base_url or "", + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ) + ) + + show_button = should_show_button( + connection_type=connection_type, + state=artifacts.state, + cloud_instance_id=cloud_instance_id, + base_url=base_url, + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_AUTO_CREATE, + type=ConfigEntryType.ACTION, + label=_action_label(artifacts.state), + description=( + "Runs the Yandex Device Flow login, then creates and " + "publishes the private Smart Home skill." + ), + action=CONF_ACTION_AUTO_CREATE, + action_label=_action_label(artifacts.state), + hidden=not show_button, + # No depends_on — MA disables actions with unsaved-dependency + # fields until the user clicks Save, which breaks the flow. + # Visibility is already correctly gated via `hidden`. + category=category, + ) + ) + + # Manual-fallback copy-paste fields — always emitted so advanced + # users can edit them, but on any state other than FAILED they're + # marked ``advanced=True`` so default view stays clean. On FAILED + # they show up unconditionally (auto-fallback UX). + entries.extend( + _manual_fallback_entries( + connection_type=connection_type, + category=category, + cloud_instance_id=cloud_instance_id, + base_url=base_url, + direct_client_secret=direct_client_secret, + advanced=artifacts.state != SkillCreationState.FAILED, + ) + ) + + # Skill ID / Skill OAuth Token / OAuth URL — the actual fields the + # provider needs at runtime. Auto-shown once auto-create reached + # DONE or FAILED; hidden under Advanced once the user has fully + # configured them (so a clean default view stays clean after setup). + token_fields_advanced = fully_configured or artifacts.state not in ( + SkillCreationState.DONE, + SkillCreationState.FAILED, + ) + entries.extend( + [ + ConfigEntry( + key="oauth_url", + type=ConfigEntryType.STRING, + label="OAuth URL (open to get token)", + description=( + "Open this URL in your browser, approve, and copy the " + "access_token from the resulting URL into the field below." + ), + required=False, + default_value=YANDEX_OAUTH_URL, + help_link=YANDEX_OAUTH_URL, + advanced=token_fields_advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ConfigEntry( + key=CONF_SKILL_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Skill OAuth Token", + description="Paste the OAuth token obtained from the URL above.", + required=False, + advanced=token_fields_advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ConfigEntry( + key=CONF_SKILL_ID, + type=ConfigEntryType.STRING, + label="Skill ID", + description=( + "UUID of your private Smart Home skill. Set automatically " + "when auto-create succeeds; you can paste it manually if " + "you created the skill by hand." + ), + required=False, + advanced=token_fields_advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ] + ) + + # Once setup is complete, replace the cluster of edit fields with + # a single non-editable link to the skill in Yandex.Dialogs so the + # user can quickly open it (the fields are still available under + # Advanced if they need to re-edit). + if fully_configured and skill_id: + skill_url = f"https://dialogs.yandex.ru/developer/skills/{skill_id}/" + entries.append( + ConfigEntry( + key="skill_dialogs_link", + type=ConfigEntryType.STRING, + label="Open skill in Yandex.Dialogs", + description="Click the link to open the skill's page.", + required=False, + default_value=skill_url, + help_link=skill_url, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ) + ) + + return entries + + +def _manual_fallback_entries( + *, + connection_type: str, + category: str, + cloud_instance_id: str, + base_url: str, + direct_client_secret: str, + advanced: bool, +) -> list[ConfigEntry]: + """Copy-paste fields for creating the skill by hand. + + ``advanced=True`` → hidden from the default view, shown when the + user toggles Advanced. Used when auto-create is expected to work + but power users still want to see/edit everything. + + ``advanced=False`` → visible unconditionally. Used on FAILED state + so the user has everything they need to finish manually without + needing to click Advanced. + """ + if connection_type == CONNECTION_TYPE_CLOUD_PLUS: + backend_uri = CLOUD_SKILL_WEBHOOK_TEMPLATE + client_id = CLOUD_SKILL_CLIENT_ID_TEMPLATE.format( + instance_id=cloud_instance_id + ) + client_secret = CLOUD_SKILL_CLIENT_SECRET + auth_url = CLOUD_OAUTH_AUTHORIZE_URL + token_url = CLOUD_OAUTH_TOKEN_URL + elif connection_type == CONNECTION_TYPE_DIRECT: + base = base_url.rstrip("/") or "https://" + backend_uri = f"{base}{DIRECT_API_BASE_PATH}" + client_id = DIRECT_OAUTH_CLIENT_ID + client_secret = direct_client_secret or "(auto-generated on save)" + auth_url = f"{base}{DIRECT_AUTH_BASE_PATH}/authorize" + token_url = f"{base}{DIRECT_AUTH_BASE_PATH}/token" + else: + return [] + + label_text = ( + "Auto-create failed — you can create the skill by hand instead. " + "Open Yandex.Dialogs (link below), create a private Smart Home " + "skill, paste the values below into the skill's Basic info and " + "Account linking tabs, then put the skill UUID in the Skill ID " + "field below." + if not advanced + else ( + "Manual setup values — copy these into Yandex.Dialogs if you " + "prefer to create the skill by hand, or want to verify what " + "auto-create used." + ) + ) + + # For direct mode, also surface the hidden generated secret as an + # editable field so user can copy it. + extra: list[ConfigEntry] = [] + if connection_type == CONNECTION_TYPE_DIRECT and direct_client_secret: + extra.append( + ConfigEntry( + key=CONF_DIRECT_CLIENT_SECRET, + type=ConfigEntryType.SECURE_STRING, + label="Client Secret (→ Account linking)", + description="Copy to 'Account linking' → 'Client secret' field.", + required=False, + default_value=direct_client_secret, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ) + ) + + return [ + ConfigEntry( + key="manual_fallback_label", + type=ConfigEntryType.LABEL, + label=label_text, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ConfigEntry( + key="manual_dialogs_url", + type=ConfigEntryType.STRING, + label="Yandex.Dialogs Console", + required=False, + default_value=YANDEX_DIALOGS_DEVELOPER_URL, + help_link=YANDEX_DIALOGS_DEVELOPER_URL, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ConfigEntry( + key="manual_backend_url", + type=ConfigEntryType.STRING, + label="Backend URL (→ Basic info)", + description="Copy to 'Basic info' → 'Backend URL' in your skill.", + required=False, + # Reference-only fields use default_value so MA UI renders + # the text without storing it as a mutable config value + # (``value=`` was returning empty on first render before the + # user clicked Save). + default_value=backend_uri, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ConfigEntry( + key="manual_client_id", + type=ConfigEntryType.STRING, + label="Client ID (→ Account linking)", + description="Copy to 'Account linking' → 'Client identifier' field.", + required=False, + default_value=client_id, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + *extra, + # manual_client_secret is a plain STRING so it's readable for + # copy-paste in cloud_plus (where the value is the literal + # "secret"). For direct mode we skip it: the real per-install + # UUID is surfaced via the SECURE_STRING CONF_DIRECT_CLIENT_SECRET + # entry in ``extra`` above, and showing the same value in a + # second unmasked STRING would leak it. + *( + [ + ConfigEntry( + key="manual_client_secret", + type=ConfigEntryType.STRING, + label="Client Secret value (for reference)", + description=( + "Copy this string into 'Account linking' → 'Client secret'." + ), + required=False, + default_value=client_secret, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ) + ] + if connection_type != CONNECTION_TYPE_DIRECT + else [] + ), + ConfigEntry( + key="manual_auth_url", + type=ConfigEntryType.STRING, + label="Authorization URL (→ Account linking)", + required=False, + default_value=auth_url, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ConfigEntry( + key="manual_token_url", + type=ConfigEntryType.STRING, + label="Token URL (→ Account linking, both fields)", + description="Paste into BOTH 'Token endpoint' and 'Refresh token URL'.", + required=False, + default_value=token_url, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ] + + +def _step2_create_skill_entries( + *, + is_registered: bool, # noqa: ARG001 — kept for call-site symmetry + cloud_instance_id: str, + artifacts: SkillCreationArtifacts, + user_code: str | None, + verification_url: str | None, + base_url: str, + skill_id: str = "", + fully_configured: bool = False, +) -> list[ConfigEntry]: + """cloud_plus Step 2 — always emitted. + + The Create-Skill action button self-hides via ``should_show_button`` + when no cloud instance exists yet, but the section's other fields + (status label + advanced manual-setup references) stay visible so + power users can see everything under Advanced without first going + through the register step. + """ + return _create_skill_step_entries( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + category=_CAT_STEP_2_CREATE, + cloud_instance_id=cloud_instance_id, + artifacts=artifacts, + user_code=user_code, + verification_url=verification_url, + base_url=base_url, + skill_id=skill_id, + fully_configured=fully_configured, + ) + + +def _step3_link_entries( + *, is_registered: bool, skill_id_set: bool, otp_code: str | None +) -> list[ConfigEntry]: + """Step 3 — get an OTP from the cloud and enter it in the Yandex app. + + Hidden until both Step 1 (cloud registration) and Step 2 (skill + created — ``skill_id_set``) are done: OTP linking only makes sense + once the private skill exists in Yandex.Dialogs for the user to + link against in the Yandex app. + """ + if not is_registered or not skill_id_set: + return [] + + # Banner priority: fresh OTP > linked (skill configured). + if otp_code: + banner_text = f"Enter this OTP in the Yandex app: {otp_code}" + else: + banner_text = ( + "✅ Skill configured. Press 'Get OTP code' to link it with your " + "Yandex account (or re-link if you ever unlinked it)." + ) + + entries: list[ConfigEntry] = [ + ConfigEntry( + key="label_step3_status", + type=ConfigEntryType.LABEL, + label=banner_text, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + category=_CAT_STEP_3_LINK, + ), + ConfigEntry( + key="otp_code", + type=ConfigEntryType.STRING, + label="OTP Code", + description="Copy this code and enter it in the Yandex app.", + required=False, + value=otp_code, + hidden=not otp_code, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + category=_CAT_STEP_3_LINK, + ), + ConfigEntry( + key=CONF_ACTION_GET_OTP, + type=ConfigEntryType.ACTION, + label="Get OTP code", + description="Get a fresh one-time password to link with Yandex.", + action=CONF_ACTION_GET_OTP, + action_label="Get OTP code", + # No depends_on — see note above action_auto_create. + category=_CAT_STEP_3_LINK, + ), + ] + return entries + + +# --------------------------------------------------------------------------- +# Direct-mode flow (auto-create only; no cloud registration, no OTP) +# --------------------------------------------------------------------------- + + +def build_direct_entries( + *, + artifacts: SkillCreationArtifacts, + session_id: str | None, + user_code: str | None, + verification_url: str | None, + existing_artifacts_raw: str | None, + base_url: str, + direct_client_secret: str = "", + skill_id: str = "", + skill_token_set: bool = False, +) -> list[ConfigEntry]: + """Return the direct-mode config entries as a single Create-Skill step. + + direct mode has no yaha-cloud registration (Step 1) and no OTP + linking (Step 3) — Yandex Dialogs' account-linking UI handles that + once the skill exists. + """ + fully_configured = bool(skill_id) and skill_token_set + entries = _create_skill_step_entries( + connection_type=CONNECTION_TYPE_DIRECT, + category=_CAT_STEP_DIRECT_CREATE, + cloud_instance_id="", + artifacts=artifacts, + user_code=user_code, + verification_url=verification_url, + base_url=base_url, + direct_client_secret=direct_client_secret, + skill_id=skill_id, + fully_configured=fully_configured, + ) + entries.extend(_hidden_state_entries(existing_artifacts_raw, session_id)) + return entries diff --git a/music_assistant/providers/yandex_smarthome/constants.py b/music_assistant/providers/yandex_smarthome/constants.py index 3f70005dab..9df04d3ea8 100644 --- a/music_assistant/providers/yandex_smarthome/constants.py +++ b/music_assistant/providers/yandex_smarthome/constants.py @@ -14,11 +14,16 @@ CONF_SKILL_TOKEN = "skill_token" CONF_EXPOSED_PLAYERS = "exposed_players" +# Auto-create-skill feature state (round-trips through the config form) +CONF_AUTO_CREATE_ARTIFACTS = "auto_create_artifacts" +CONF_AUTO_CREATE_SESSION_ID = "auto_create_session_id" + # --------------------------------------------------------------------------- # Config actions # --------------------------------------------------------------------------- CONF_ACTION_REGISTER = "register_cloud" CONF_ACTION_GET_OTP = "get_otp" +CONF_ACTION_AUTO_CREATE = "auto_create_skill" # --------------------------------------------------------------------------- # Connection types diff --git a/tests/providers/yandex_smarthome/__snapshots__/test_auto_skill.ambr b/tests/providers/yandex_smarthome/__snapshots__/test_auto_skill.ambr new file mode 100644 index 0000000000..52f586be00 --- /dev/null +++ b/tests/providers/yandex_smarthome/__snapshots__/test_auto_skill.ambr @@ -0,0 +1,121 @@ +# serializer version: 1 +# name: TestBuildDraftPayload.test_cloud_plus_snapshot + dict({ + 'backendSettings': dict({ + 'backendType': 'webhook', + 'functionId': '', + 'uri': 'https://yaha-cloud.ru/api/yandex_smart_home', + }), + 'channel': 'smartHome', + 'enableAllAvailableRegions': True, + 'hideInStore': False, + 'isTrustedSmartHomeSkill': False, + 'logo2': None, + 'logoId': 'be043706-a868-4999-83c8-f17bbd60745d', + 'name': 'Music Assistant', + 'noteForModerator': '', + 'oauthAppId': None, + 'publishingSettings': dict({ + 'brandVerificationWebsite': '', + 'category': 'smart_home', + 'developerName': 'alice', + 'email': '', + 'multilingualSettings': dict({ + 'ru': dict({ + 'externalSettingsDescription': 'Music Assistant', + 'name': 'Music Assistant', + 'secondaryTitle': '', + 'supportedUnitsDescription': 'Music Assistant', + }), + }), + 'secondaryTitle': '', + 'smartHome': dict({ + 'deepLinks': dict({ + 'android': dict({ + 'url': '', + }), + 'ios': dict({ + 'fallbackUrl': '', + 'url': '', + }), + }), + }), + }), + 'selectedRegions': list([ + ]), + 'skillAccess': 'private', + 'voice': 'shitova.us', + }) +# --- +# name: TestBuildDraftPayload.test_direct_snapshot + dict({ + 'backendSettings': dict({ + 'backendType': 'webhook', + 'functionId': '', + 'uri': 'https://ma.example.com/api/yandex_smarthome/v1.0', + }), + 'channel': 'smartHome', + 'enableAllAvailableRegions': True, + 'hideInStore': False, + 'isTrustedSmartHomeSkill': False, + 'logo2': None, + 'logoId': None, + 'name': 'Music Assistant', + 'noteForModerator': '', + 'oauthAppId': None, + 'publishingSettings': dict({ + 'brandVerificationWebsite': '', + 'category': 'smart_home', + 'developerName': 'alice', + 'email': '', + 'multilingualSettings': dict({ + 'ru': dict({ + 'externalSettingsDescription': 'Music Assistant', + 'name': 'Music Assistant', + 'secondaryTitle': '', + 'supportedUnitsDescription': 'Music Assistant', + }), + }), + 'secondaryTitle': '', + 'smartHome': dict({ + 'deepLinks': dict({ + 'android': dict({ + 'url': '', + }), + 'ios': dict({ + 'fallbackUrl': '', + 'url': '', + }), + }), + }), + }), + 'selectedRegions': list([ + ]), + 'skillAccess': 'private', + 'voice': 'shitova.us', + }) +# --- +# name: TestBuildOAuthAppPayload.test_cloud_plus_snapshot + dict({ + 'authorizationUrl': 'https://yaha-cloud.ru/oauth/authorize', + 'clientId': 'yandex_smart_home:abc123', + 'clientSecret': 'secret', + 'name': 'Music Assistant', + 'refreshTokenUrl': 'https://yaha-cloud.ru/oauth/token', + 'scope': '', + 'tokenUrl': 'https://yaha-cloud.ru/oauth/token', + 'yandexClientId': '', + }) +# --- +# name: TestBuildOAuthAppPayload.test_direct_snapshot + dict({ + 'authorizationUrl': 'https://ma.example.com/api/yandex_smarthome/auth/authorize', + 'clientId': 'https://social.yandex.net/', + 'clientSecret': 'abc123deadbeef', + 'name': 'Music Assistant', + 'refreshTokenUrl': 'https://ma.example.com/api/yandex_smarthome/auth/token', + 'scope': '', + 'tokenUrl': 'https://ma.example.com/api/yandex_smarthome/auth/token', + 'yandexClientId': '', + }) +# --- diff --git a/tests/providers/yandex_smarthome/test_auto_skill.py b/tests/providers/yandex_smarthome/test_auto_skill.py new file mode 100644 index 0000000000..bb9f64bc5e --- /dev/null +++ b/tests/providers/yandex_smarthome/test_auto_skill.py @@ -0,0 +1,940 @@ +"""Tests for provider/auto_skill.py — DialogsSkillCreator low-level client.""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import aiohttp +import pytest + +from music_assistant.providers.yandex_smarthome.auto_skill import ( + DIALOGS_API_BASE, + DIALOGS_DEV_HTML_URL, + DialogsApiError, + DialogsCsrfError, + DialogsDuplicateSkillError, + DialogsSkillCreator, + auto_create_skill, + build_draft_payload, + build_oauth_app_payload, + check_preconditions, + derive_auth_urls, + derive_backend_uri, + derive_client_id, + load_default_logo_bytes, +) +from music_assistant.providers.yandex_smarthome.auto_skill_state import ( + SkillCreationArtifacts, + SkillCreationState, +) +from music_assistant.providers.yandex_smarthome.constants import ( + CONNECTION_TYPE_CLOUD, + CONNECTION_TYPE_CLOUD_PLUS, + CONNECTION_TYPE_DIRECT, +) + +# --------------------------------------------------------------------------- +# Test helpers: mock aiohttp session matching existing test_cloud.py pattern +# --------------------------------------------------------------------------- + + +def _mock_response( + *, status: int = 200, body_text: str = "", body_json: Any = None +) -> AsyncMock: + """Build a mock aiohttp.ClientResponse. + + If *body_json* is given, ``text()`` returns its JSON-encoded form; + otherwise ``body_text`` is used verbatim. Also wires up + ``content.iter_chunked(size)`` as a single-chunk async iterator over + ``body_text.encode()`` so ``fetch_csrf``'s streaming reader works. + """ + resp = AsyncMock() + resp.status = status + if body_json is not None: + body_text = json.dumps(body_json, ensure_ascii=False) + resp.text = AsyncMock(return_value=body_text) + resp.get_encoding = MagicMock(return_value="utf-8") + + body_bytes = body_text.encode("utf-8") + + async def _aiter(_size: int) -> Any: # pragma: no cover — trivial + if body_bytes: + yield body_bytes + + resp.content = MagicMock() + resp.content.iter_chunked = _aiter + return resp + + +def _install_ctx(session_method: MagicMock, mock_resp: AsyncMock) -> MagicMock: + """Attach an async context manager returning *mock_resp* to a session method.""" + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=mock_resp) + ctx.__aexit__ = AsyncMock(return_value=False) + session_method.return_value = ctx + return ctx + + +def _make_session() -> MagicMock: + session = MagicMock(spec=aiohttp.ClientSession) + session.get = MagicMock() + session.post = MagicMock() + session.request = MagicMock() + return session + + +# --------------------------------------------------------------------------- +# fetch_csrf +# --------------------------------------------------------------------------- + + +class TestFetchCsrf: + """CSRF token extraction from developer console HTML.""" + + @pytest.mark.asyncio + async def test_happy_path_returns_token(self) -> None: + """CSRF is extracted from the secretkey field in the developer HTML.""" + html = ( + '' + ) + session = _make_session() + _install_ctx(session.get, _mock_response(status=200, body_text=html)) + creator = DialogsSkillCreator(session) + + token = await creator.fetch_csrf() + assert token == "u9c94f1aca53bf156be4abc" + # Verify we hit the expected URL + session.get.assert_called_once_with(DIALOGS_DEV_HTML_URL) + + @pytest.mark.asyncio + async def test_regex_miss_raises_csrf_error(self) -> None: + """Yandex changed the HTML format → clean typed error for fallback.""" + html = "no secretkey here" + session = _make_session() + _install_ctx(session.get, _mock_response(status=200, body_text=html)) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsCsrfError) as exc_info: + await creator.fetch_csrf() + assert exc_info.value.step == "fetch_csrf" + + @pytest.mark.asyncio + async def test_401_maps_to_auth_error(self) -> None: + """401 means passport cookies missing/expired — retryable via relogin.""" + session = _make_session() + _install_ctx(session.get, _mock_response(status=401, body_text="nope")) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsApiError) as exc_info: + await creator.fetch_csrf() + assert exc_info.value.http_status == 401 + assert exc_info.value.step == "fetch_csrf" + + @pytest.mark.asyncio + async def test_empty_token_raises(self) -> None: + """Regex matched but captured empty string — treat as miss.""" + html = '{"secretkey":""}' + session = _make_session() + _install_ctx(session.get, _mock_response(status=200, body_text=html)) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsCsrfError): + await creator.fetch_csrf() + + +# --------------------------------------------------------------------------- +# create_app +# --------------------------------------------------------------------------- + + +class TestCreateApp: + """POST /apps — returns skill_id on success.""" + + @pytest.mark.asyncio + async def test_happy_path_returns_skill_id(self) -> None: + """POST /apps returns a skill UUID and sends the expected payload.""" + session = _make_session() + _install_ctx( + session.request, + _mock_response( + status=200, + body_json={"result": {"id": "7584419b-6815-4a68-8e32-b9fb111b596d"}}, + ), + ) + creator = DialogsSkillCreator(session) + + skill_id = await creator.create_app("csrf-token", "My Skill") + assert skill_id == "7584419b-6815-4a68-8e32-b9fb111b596d" + + # Verify request shape + method, url = session.request.call_args.args[:2] + assert method == "POST" + assert url == f"{DIALOGS_API_BASE}/apps" + payload = session.request.call_args.kwargs["json"] + assert payload["channel"] == "smartHome" + assert payload["language"] == "ru" + assert payload["isYangoConsole"] is False + assert payload["appName"] == "My Skill" + headers = session.request.call_args.kwargs["headers"] + assert headers["x-csrf-token"] == "csrf-token" + + @pytest.mark.asyncio + async def test_duplicate_name_raises_typed_error(self) -> None: + """HTTP 409 with a duplicate-indicator body maps to DialogsDuplicateSkillError.""" + session = _make_session() + _install_ctx( + session.request, + _mock_response( + status=409, + body_json={"error": "not_unique", "message": "skill already exists"}, + ), + ) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsDuplicateSkillError) as exc_info: + await creator.create_app("csrf", "duplicate name") + assert exc_info.value.http_status == 409 + + @pytest.mark.asyncio + async def test_generic_4xx_raises_api_error(self) -> None: + """Non-duplicate 4xx surfaces as plain DialogsApiError, not the subclass.""" + session = _make_session() + _install_ctx( + session.request, + _mock_response(status=400, body_text="bad request"), + ) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsApiError) as exc_info: + await creator.create_app("csrf", "X") + # Not a duplicate — should be plain DialogsApiError, not subclass + assert not isinstance(exc_info.value, DialogsDuplicateSkillError) + assert exc_info.value.http_status == 400 + + @pytest.mark.asyncio + async def test_missing_skill_id_raises(self) -> None: + """A 200 response without an id field is treated as a protocol break.""" + session = _make_session() + _install_ctx( + session.request, + _mock_response(status=200, body_json={"result": {}}), + ) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsApiError): + await creator.create_app("csrf", "X") + + +# --------------------------------------------------------------------------- +# upload_logo +# --------------------------------------------------------------------------- + + +class TestUploadLogo: + """POST /apps/{id}/draft/upload-logo — multipart file upload.""" + + @pytest.mark.asyncio + async def test_happy_path_returns_logo_id(self) -> None: + """upload_logo posts multipart PNG and returns the avatar id.""" + session = _make_session() + _install_ctx( + session.post, + _mock_response( + status=200, + body_json={ + "result": { + "id": "be043706-a868-4999-83c8-f17bbd60745d", + "url": "https://avatars.mds.yandex.net/...", + } + }, + ), + ) + creator = DialogsSkillCreator(session) + + logo_id = await creator.upload_logo("csrf", "skill-1", b"\x89PNG\r\n\x1a\n...") + assert logo_id == "be043706-a868-4999-83c8-f17bbd60745d" + + call = session.post.call_args + url = call.args[0] + assert "/draft/upload-logo" in url + assert "channel=smartHome" in url + # FormData used for multipart + assert isinstance(call.kwargs["data"], aiohttp.FormData) + assert call.kwargs["headers"]["x-csrf-token"] == "csrf" + + @pytest.mark.asyncio + async def test_upload_500_raises(self) -> None: + """Server-side 500 surfaces as DialogsApiError carrying the step name.""" + session = _make_session() + _install_ctx(session.post, _mock_response(status=500, body_text="oops")) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsApiError) as exc_info: + await creator.upload_logo("csrf", "sk", b"data") + assert exc_info.value.step == "upload_logo" + assert exc_info.value.http_status == 500 + + +# --------------------------------------------------------------------------- +# update_draft +# --------------------------------------------------------------------------- + + +class TestUpdateDraft: + """PATCH /apps/{id}/draft/update — accepts arbitrary payload dict.""" + + @pytest.mark.asyncio + async def test_happy_path(self) -> None: + """PATCH goes to the right URL with the forwarded payload and CSRF header.""" + session = _make_session() + _install_ctx( + session.request, + _mock_response(status=200, body_json={"result": {"ok": True}}), + ) + creator = DialogsSkillCreator(session) + + payload = {"name": "Music Assistant", "channel": "smartHome"} + await creator.update_draft("csrf", "skill-1", payload) + + method, url = session.request.call_args.args[:2] + assert method == "PATCH" + assert url == f"{DIALOGS_API_BASE}/apps/skill-1/draft/update" + assert session.request.call_args.kwargs["json"] == payload + + +# --------------------------------------------------------------------------- +# create_oauth_app +# --------------------------------------------------------------------------- + + +class TestCreateOAuthApp: + """POST /oauth/apps — returns oauth_app_id.""" + + @pytest.mark.asyncio + async def test_happy_path(self) -> None: + """create_oauth_app builds the correct payload and returns the new id.""" + session = _make_session() + _install_ctx( + session.request, + _mock_response( + status=200, + body_json={"result": {"id": "oauth-uuid-123"}}, + ), + ) + creator = DialogsSkillCreator(session) + + oauth_id = await creator.create_oauth_app( + "csrf", + name="My Skill", + client_id="yandex_smart_home:inst1", + client_secret="secret", + authorize_url="https://yaha-cloud.ru/oauth/authorize", + token_url="https://yaha-cloud.ru/oauth/token", + refresh_url="https://yaha-cloud.ru/oauth/token", + ) + assert oauth_id == "oauth-uuid-123" + + payload = session.request.call_args.kwargs["json"] + assert payload["clientId"] == "yandex_smart_home:inst1" + assert payload["clientSecret"] == "secret" + assert payload["authorizationUrl"] == "https://yaha-cloud.ru/oauth/authorize" + assert payload["scope"] == "" + + +# --------------------------------------------------------------------------- +# attach_oauth +# --------------------------------------------------------------------------- + + +class TestAttachOAuth: + """POST /apps/{id}/oauthApp — links oauth_app to skill.""" + + @pytest.mark.asyncio + async def test_happy_path(self) -> None: + """attach_oauth sends POST with channel query + oauthAppId body.""" + session = _make_session() + _install_ctx( + session.request, + _mock_response(status=200, body_json={"result": "oauth-id"}), + ) + creator = DialogsSkillCreator(session) + + await creator.attach_oauth("csrf", "skill-1", "oauth-1") + + method, url = session.request.call_args.args[:2] + assert method == "POST" + assert "channel=smartHome" in url + assert session.request.call_args.kwargs["json"] == {"oauthAppId": "oauth-1"} + + +# --------------------------------------------------------------------------- +# request_deploy +# --------------------------------------------------------------------------- + + +class TestRequestDeploy: + """POST /apps/{id}/draft/request-deploy — publishes draft.""" + + @pytest.mark.asyncio + async def test_happy_path_empty_body(self) -> None: + """Request-deploy uses an empty body and relies on the URL query only.""" + session = _make_session() + _install_ctx(session.post, _mock_response(status=200, body_json={"result": {}})) + creator = DialogsSkillCreator(session) + + await creator.request_deploy("csrf", "skill-1") + + call = session.post.call_args + url = call.args[0] + assert "request-deploy" in url + assert "channel=smartHome" in url + # No body (only headers) + assert "data" not in call.kwargs + assert "json" not in call.kwargs + assert call.kwargs["headers"]["x-csrf-token"] == "csrf" + + @pytest.mark.asyncio + async def test_deploy_accepts_2xx_variants(self) -> None: + """Some publish responses are 202/204 depending on server side.""" + for status in (201, 202, 204): + session = _make_session() + _install_ctx(session.post, _mock_response(status=status, body_text="")) + creator = DialogsSkillCreator(session) + await creator.request_deploy("csrf", "skill-1") # must not raise + + +# --------------------------------------------------------------------------- +# list_existing_skills +# --------------------------------------------------------------------------- + + +class TestListExistingSkills: + """GET /snapshot — returns existing skills or empty on malformed JSON.""" + + @pytest.mark.asyncio + async def test_returns_skill_dicts(self) -> None: + """Snapshot is parsed into a list of skill dicts.""" + session = _make_session() + _install_ctx( + session.get, + _mock_response( + status=200, + body_json={ + "result": { + "skills": [ + {"id": "s1", "name": "First"}, + {"id": "s2", "name": "Second"}, + ] + } + }, + ), + ) + creator = DialogsSkillCreator(session) + + skills = await creator.list_existing_skills("csrf") + assert len(skills) == 2 + assert skills[0]["name"] == "First" + + @pytest.mark.asyncio + async def test_raises_when_malformed(self) -> None: + """Non-JSON snapshot body is a protocol break — raise instead of hiding it.""" + session = _make_session() + _install_ctx(session.get, _mock_response(status=200, body_text="not json")) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsApiError): + await creator.list_existing_skills("csrf") + + +# --------------------------------------------------------------------------- +# Payload builders + preconditions (pure functions) +# --------------------------------------------------------------------------- + + +def _mass_with_base_url(base_url: str) -> MagicMock: + mass = MagicMock() + mass.webserver.base_url = base_url + return mass + + +class TestDeriveBackendUri: + """derive_backend_uri routes per-mode.""" + + def test_cloud_plus_uses_yaha_relay_constant(self) -> None: + """cloud_plus always points at the fixed yaha-cloud webhook.""" + mass = _mass_with_base_url("https://my-ma.example.com") + assert ( + derive_backend_uri(mass, CONNECTION_TYPE_CLOUD_PLUS) + == "https://yaha-cloud.ru/api/yandex_smart_home" + ) + + def test_direct_uses_ma_base_plus_api_path(self) -> None: + """Direct concatenates MA base_url with the provider's API path.""" + mass = _mass_with_base_url("https://my-ma.example.com/") + # Trailing slash on base_url should be stripped so the full URL is clean. + assert ( + derive_backend_uri(mass, CONNECTION_TYPE_DIRECT) + == "https://my-ma.example.com/api/yandex_smarthome/v1.0" + ) + + def test_cloud_raises(self) -> None: + """Plain 'cloud' mode has no custom skill — function must reject it.""" + mass = _mass_with_base_url("https://x") + with pytest.raises(ValueError, match="connection_type"): + derive_backend_uri(mass, CONNECTION_TYPE_CLOUD) + + +class TestDeriveAuthUrls: + """derive_auth_urls returns (authorize_url, token_url).""" + + def test_cloud_plus_urls(self) -> None: + """cloud_plus uses yaha-cloud OAuth endpoints.""" + mass = _mass_with_base_url("https://x") + auth, token = derive_auth_urls(mass, CONNECTION_TYPE_CLOUD_PLUS) + assert auth == "https://yaha-cloud.ru/oauth/authorize" + assert token == "https://yaha-cloud.ru/oauth/token" + + def test_direct_urls_use_ma_base(self) -> None: + """Direct uses the MA webserver's own authorize/token endpoints.""" + mass = _mass_with_base_url("https://ma.example.com") + auth, token = derive_auth_urls(mass, CONNECTION_TYPE_DIRECT) + assert auth == "https://ma.example.com/api/yandex_smarthome/auth/authorize" + assert token == "https://ma.example.com/api/yandex_smarthome/auth/token" + + +class TestDeriveClientId: + """derive_client_id formats the OAuth client_id per mode.""" + + def test_cloud_plus_templated(self) -> None: + """cloud_plus wraps the instance_id in the yaha protocol prefix.""" + assert ( + derive_client_id(CONNECTION_TYPE_CLOUD_PLUS, "abc123") + == "yandex_smart_home:abc123" + ) + + def test_cloud_plus_missing_instance_raises(self) -> None: + """Empty instance_id is a configuration bug — raise early.""" + with pytest.raises(ValueError, match="cloud_instance_id"): + derive_client_id(CONNECTION_TYPE_CLOUD_PLUS, "") + + def test_direct_fixed_value(self) -> None: + """Direct mode uses the fixed Yandex social redirect base.""" + assert ( + derive_client_id(CONNECTION_TYPE_DIRECT, "") + == "https://social.yandex.net/" + ) + + +class TestBuildDraftPayload: + """Snapshot coverage for the 100+-field draft/update payload.""" + + def test_cloud_plus_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] + """cloud_plus draft payload matches the captured HAR shape.""" + payload = build_draft_payload( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + skill_name="Music Assistant", + backend_uri="https://yaha-cloud.ru/api/yandex_smart_home", + logo_id="be043706-a868-4999-83c8-f17bbd60745d", + developer_name="alice", + ) + assert payload == snapshot + + def test_direct_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] + """Direct draft payload matches the captured HAR shape.""" + payload = build_draft_payload( + connection_type=CONNECTION_TYPE_DIRECT, + skill_name="Music Assistant", + backend_uri="https://ma.example.com/api/yandex_smarthome/v1.0", + logo_id=None, + developer_name="alice", + ) + assert payload == snapshot + + def test_invalid_mode_raises(self) -> None: + """Plain 'cloud' has no auto-create path.""" + with pytest.raises(ValueError, match="connection_type"): + build_draft_payload( + connection_type=CONNECTION_TYPE_CLOUD, + skill_name="x", + backend_uri="https://x", + logo_id=None, + ) + + +class TestBuildOAuthAppPayload: + """OAuth-app payload is simpler — both modes round-trip the given fields.""" + + def test_cloud_plus_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] + """cloud_plus payload uses literal 'secret' and the yaha-prefixed client_id.""" + payload = build_oauth_app_payload( + skill_name="Music Assistant", + client_id="yandex_smart_home:abc123", + client_secret="secret", + authorize_url="https://yaha-cloud.ru/oauth/authorize", + token_url="https://yaha-cloud.ru/oauth/token", + ) + assert payload == snapshot + + def test_direct_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] + """Direct payload uses social.yandex.net client_id and a per-install secret.""" + payload = build_oauth_app_payload( + skill_name="Music Assistant", + client_id="https://social.yandex.net/", + client_secret="abc123deadbeef", + authorize_url="https://ma.example.com/api/yandex_smarthome/auth/authorize", + token_url="https://ma.example.com/api/yandex_smarthome/auth/token", + ) + assert payload == snapshot + + +class TestCheckPreconditions: + """check_preconditions rejects invalid configurations early.""" + + def test_cloud_plus_requires_instance(self) -> None: + """cloud_plus without a registered cloud instance is rejected.""" + mass = _mass_with_base_url("https://x") + with pytest.raises(ValueError, match="yaha-cloud instance"): + check_preconditions( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + mass=mass, + cloud_instance_id="", + direct_client_secret="", + ) + + def test_cloud_plus_with_instance_ok(self) -> None: + """cloud_plus with a registered instance_id passes.""" + mass = _mass_with_base_url("https://x") + check_preconditions( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + mass=mass, + cloud_instance_id="abc", + direct_client_secret="", + ) + + def test_direct_requires_https_base_url(self) -> None: + """Direct rejects non-HTTPS base URLs (Yandex won't accept).""" + mass = _mass_with_base_url("http://ma.local:8095") + with pytest.raises(ValueError, match="HTTPS"): + check_preconditions( + connection_type=CONNECTION_TYPE_DIRECT, + mass=mass, + cloud_instance_id="", + direct_client_secret="secret", + ) + + def test_direct_requires_client_secret(self) -> None: + """Direct rejects empty client_secret (would break account-linking).""" + mass = _mass_with_base_url("https://ma.example.com") + with pytest.raises(ValueError, match="Client Secret"): + check_preconditions( + connection_type=CONNECTION_TYPE_DIRECT, + mass=mass, + cloud_instance_id="", + direct_client_secret="", + ) + + def test_direct_happy_path(self) -> None: + """Direct with HTTPS base URL and a secret passes.""" + mass = _mass_with_base_url("https://ma.example.com") + check_preconditions( + connection_type=CONNECTION_TYPE_DIRECT, + mass=mass, + cloud_instance_id="", + direct_client_secret="my-secret", + ) + + def test_cloud_mode_rejected(self) -> None: + """Plain 'cloud' never uses a custom skill — reject.""" + mass = _mass_with_base_url("https://x") + with pytest.raises(ValueError, match="cloud_plus or direct"): + check_preconditions( + connection_type=CONNECTION_TYPE_CLOUD, + mass=mass, + cloud_instance_id="", + direct_client_secret="", + ) + + +# --------------------------------------------------------------------------- +# Orchestrator (auto_create_skill) +# --------------------------------------------------------------------------- + + +def _make_creator_mock() -> AsyncMock: + """Build an AsyncMock matching DialogsSkillCreator's interface.""" + creator = AsyncMock(spec=DialogsSkillCreator) + creator.fetch_csrf = AsyncMock(return_value="csrf-token") + creator.create_app = AsyncMock(return_value="skill-uuid") + creator.upload_logo = AsyncMock(return_value="logo-uuid") + creator.update_draft = AsyncMock(return_value=None) + creator.create_oauth_app = AsyncMock(return_value="oauth-uuid") + creator.attach_oauth = AsyncMock(return_value=None) + creator.request_deploy = AsyncMock(return_value=None) + return creator + + +def _fake_authenticator_factory( + *, + session: MagicMock | None = None, + session_id_captor: list[str] | None = None, +) -> Any: + """Build an async-generator authenticator usable as *authenticator*. + + If *session_id_captor* is given, the session_id passed in by the + orchestrator is appended to it so tests can assert it was forwarded. + """ + if session is None: + session = MagicMock(spec=aiohttp.ClientSession) + + async def _auth(mass, session_id, timeout): # type: ignore[no-untyped-def] + if session_id_captor is not None: + session_id_captor.append(session_id) + _ = (mass, timeout) + yield session + + return _auth + + +async def _run_orch( + *, + creator: AsyncMock, + connection_type: str = CONNECTION_TYPE_CLOUD_PLUS, + artifacts: SkillCreationArtifacts | None = None, + base_url: str = "https://ma.example.com", + cloud_instance_id: str = "inst-1", + direct_client_secret: str = "", + session_id: str = "test-session-id", + progress_cb: Any = None, +) -> SkillCreationArtifacts: + """Run auto_create_skill with sensible test defaults. + + Injects *creator* via ``creator_factory`` and a fake authenticator + so no real ya-passport-auth or aiohttp traffic is attempted. + """ + mass = _mass_with_base_url(base_url) + return await auto_create_skill( + mass=mass, + connection_type=connection_type, + skill_name="Music Assistant", + artifacts=artifacts if artifacts is not None else SkillCreationArtifacts(), + cloud_instance_id=cloud_instance_id, + direct_client_secret=direct_client_secret, + logo_bytes=b"\x89PNG", + session_id=session_id, + authenticator=_fake_authenticator_factory(), + creator_factory=lambda _s: creator, + progress_cb=progress_cb, + ) + + +class TestAutoCreateSkillHappyPath: + """auto_create_skill on a fresh artifact runs all steps to DONE.""" + + @pytest.mark.asyncio + async def test_fresh_artifact_reaches_done(self) -> None: + """A NONE-state artifact runs the full pipeline to DONE.""" + creator = _make_creator_mock() + + result = await _run_orch(creator=creator) + + assert result.state == SkillCreationState.DONE + assert result.skill_id == "skill-uuid" + assert result.logo_id == "logo-uuid" + assert result.oauth_app_id == "oauth-uuid" + assert result.last_error is None + + for method in ( + creator.fetch_csrf, + creator.create_app, + creator.upload_logo, + creator.update_draft, + creator.create_oauth_app, + creator.attach_oauth, + creator.request_deploy, + ): + method.assert_awaited_once() + + @pytest.mark.asyncio + async def test_progress_cb_invoked_after_each_step(self) -> None: + """progress_cb receives a snapshot after every state transition.""" + creator = _make_creator_mock() + progress_calls: list[SkillCreationArtifacts] = [] + + async def _progress(a: SkillCreationArtifacts) -> None: + progress_calls.append(a) + + await _run_orch(creator=creator, progress_cb=_progress) + + observed_states = [a.state for a in progress_calls] + assert observed_states == [ + SkillCreationState.APP_CREATED, + SkillCreationState.DRAFT_UPDATED, + SkillCreationState.OAUTH_CREATED, + SkillCreationState.OAUTH_ATTACHED, + SkillCreationState.DONE, + ] + + +class TestAutoCreateSkillResume: + """Non-NONE artifacts skip completed steps.""" + + @pytest.mark.asyncio + async def test_resume_from_app_created(self) -> None: + """create_app is not re-called when a skill_id is already present.""" + creator = _make_creator_mock() + starting = SkillCreationArtifacts( + state=SkillCreationState.APP_CREATED, skill_id="existing-skill" + ) + result = await _run_orch(creator=creator, artifacts=starting) + + creator.create_app.assert_not_awaited() + creator.upload_logo.assert_awaited_once() + assert result.skill_id == "existing-skill" + assert result.state == SkillCreationState.DONE + + @pytest.mark.asyncio + async def test_resume_from_oauth_attached(self) -> None: + """A near-done artifact only needs request_deploy.""" + creator = _make_creator_mock() + starting = SkillCreationArtifacts( + state=SkillCreationState.OAUTH_ATTACHED, + skill_id="s1", + logo_id="l1", + oauth_app_id="o1", + ) + result = await _run_orch(creator=creator, artifacts=starting) + + creator.create_app.assert_not_awaited() + creator.upload_logo.assert_not_awaited() + creator.update_draft.assert_not_awaited() + creator.create_oauth_app.assert_not_awaited() + creator.attach_oauth.assert_not_awaited() + creator.request_deploy.assert_awaited_once() + assert result.state == SkillCreationState.DONE + + @pytest.mark.asyncio + async def test_resume_from_failed_restarts_pipeline(self) -> None: + """FAILED state is treated like NONE — full retry from create_app.""" + creator = _make_creator_mock() + starting = SkillCreationArtifacts( + state=SkillCreationState.FAILED, + last_error="some earlier error", + ) + result = await _run_orch(creator=creator, artifacts=starting) + + creator.create_app.assert_awaited_once() + assert result.state == SkillCreationState.DONE + assert result.last_error is None + + +class TestAutoCreateSkillFailure: + """Pipeline failures convert to FAILED state with preserved partial data.""" + + @pytest.mark.asyncio + async def test_failure_at_create_app_preserves_nothing(self) -> None: + """Duplicate-name error bubbles up as FAILED with no skill_id captured.""" + creator = _make_creator_mock() + creator.create_app.side_effect = DialogsDuplicateSkillError( + "exists", step="create_app", http_status=409 + ) + + result = await _run_orch(creator=creator) + + assert result.state == SkillCreationState.FAILED + assert result.skill_id is None + assert "exists" in (result.last_error or "") + + @pytest.mark.asyncio + async def test_failure_after_app_created_preserves_skill_id(self) -> None: + """A partial failure keeps the skill_id so retry resumes from DRAFT_UPDATED.""" + creator = _make_creator_mock() + creator.upload_logo.side_effect = DialogsApiError( + "500", step="upload_logo", http_status=500 + ) + + result = await _run_orch(creator=creator) + + assert result.state == SkillCreationState.FAILED + assert result.skill_id == "skill-uuid" + + @pytest.mark.asyncio + async def test_csrf_miss_becomes_failed_state(self) -> None: + """CSRF extraction failure doesn't crash — surfaces as FAILED.""" + creator = _make_creator_mock() + creator.fetch_csrf.side_effect = DialogsCsrfError( + "secretkey not found", step="fetch_csrf" + ) + + result = await _run_orch(creator=creator) + + assert result.state == SkillCreationState.FAILED + assert "secretkey" in (result.last_error or "") + + @pytest.mark.asyncio + async def test_precondition_raises_unmodified(self) -> None: + """Preconditions raise ValueError so UI can show the exact message.""" + with pytest.raises(ValueError, match="HTTPS"): + await _run_orch( + creator=_make_creator_mock(), + connection_type=CONNECTION_TYPE_DIRECT, + base_url="http://not-https.example.com", + direct_client_secret="secret", + ) + + @pytest.mark.asyncio + async def test_progress_cb_exception_does_not_abort(self) -> None: + """If the config-save callback raises, the pipeline still reaches DONE.""" + + async def _boom(_a: SkillCreationArtifacts) -> None: + msg = "cannot write config" + raise RuntimeError(msg) + + result = await _run_orch(creator=_make_creator_mock(), progress_cb=_boom) + assert result.state == SkillCreationState.DONE + + +class TestAutoCreateSkillDirectMode: + """Direct mode wires the MA webserver URLs into the payloads.""" + + @pytest.mark.asyncio + async def test_direct_mode_passes_ma_base_to_draft(self) -> None: + """Backend URL and auth URLs must use the MA webserver base URL.""" + creator = _make_creator_mock() + await _run_orch( + creator=creator, + connection_type=CONNECTION_TYPE_DIRECT, + base_url="https://ma.example.com", + cloud_instance_id="", + direct_client_secret="my-secret", + ) + + draft_payload = creator.update_draft.call_args.args[2] + assert ( + draft_payload["backendSettings"]["uri"] + == "https://ma.example.com/api/yandex_smarthome/v1.0" + ) + oauth_call = creator.create_oauth_app.call_args + assert oauth_call.kwargs["client_id"] == "https://social.yandex.net/" + assert oauth_call.kwargs["client_secret"] == "my-secret" + assert ( + oauth_call.kwargs["authorize_url"] + == "https://ma.example.com/api/yandex_smarthome/auth/authorize" + ) + + +class TestLoadDefaultLogoBytes: + """load_default_logo_bytes reads the bundled PNG from disk.""" + + def test_returns_real_png(self) -> None: + """Bundled provider/auto_skill_logo.png exists and has a PNG magic header.""" + data = load_default_logo_bytes() + # PNG magic: 89 50 4E 47 0D 0A 1A 0A + assert data[:8] == bytes.fromhex("89504e470d0a1a0a") + # Sanity: the bundled asset is non-trivial, not the 1x1 fallback. + assert len(data) > 1000, ( + f"expected real logo asset, got {len(data)} bytes (fallback?)" + ) diff --git a/tests/providers/yandex_smarthome/test_auto_skill_state.py b/tests/providers/yandex_smarthome/test_auto_skill_state.py new file mode 100644 index 0000000000..3686aa2aa6 --- /dev/null +++ b/tests/providers/yandex_smarthome/test_auto_skill_state.py @@ -0,0 +1,129 @@ +"""Tests for auto_skill_state: artifact serialisation and state transitions.""" + +from __future__ import annotations + +import dataclasses + +from music_assistant.providers.yandex_smarthome.auto_skill_state import ( + SkillCreationArtifacts, + SkillCreationState, + dump_artifacts, + load_artifacts, + mark_failed, +) + + +def test_default_artifacts_state_is_none() -> None: + """Freshly-constructed artifacts sit at state NONE with no fields set.""" + artifacts = SkillCreationArtifacts() + assert artifacts.state == SkillCreationState.NONE + assert artifacts.skill_id is None + assert artifacts.logo_id is None + assert artifacts.oauth_app_id is None + assert artifacts.last_error is None + + +def test_dump_load_roundtrip_none() -> None: + """NONE state with no fields round-trips cleanly.""" + original = SkillCreationArtifacts() + restored = load_artifacts(dump_artifacts(original)) + assert restored == original + + +def test_dump_load_roundtrip_populated() -> None: + """Fully-populated artifacts round-trip without loss.""" + original = SkillCreationArtifacts( + state=SkillCreationState.OAUTH_ATTACHED, + skill_id="7584419b-6815-4a68-8e32-b9fb111b596d", + logo_id="be043706-a868-4999-83c8-f17bbd60745d", + oauth_app_id="50de2b35-e593-417d-83d1-764544996fbb", + last_error=None, + ) + restored = load_artifacts(dump_artifacts(original)) + assert restored == original + + +def test_dump_load_roundtrip_failed_with_error() -> None: + """FAILED state with an error string round-trips.""" + original = SkillCreationArtifacts( + state=SkillCreationState.FAILED, + skill_id="some-skill-id", + last_error="HTTP 401: Unauthorized", + ) + restored = load_artifacts(dump_artifacts(original)) + assert restored == original + + +def test_load_empty_returns_default() -> None: + """Empty/None input yields a fresh default artifacts object.""" + assert load_artifacts(None) == SkillCreationArtifacts() + assert load_artifacts("") == SkillCreationArtifacts() + + +def test_load_invalid_json_returns_default() -> None: + """Corrupt JSON doesn't crash config — returns default.""" + assert load_artifacts("{not-json") == SkillCreationArtifacts() + assert load_artifacts("null") == SkillCreationArtifacts() + assert load_artifacts('"just-a-string"') == SkillCreationArtifacts() + + +def test_load_unknown_state_falls_back_to_none() -> None: + """Unrecognised state values don't crash — reset to NONE.""" + raw = '{"state": "totally_bogus", "skill_id": "abc"}' + result = load_artifacts(raw) + assert result.state == SkillCreationState.NONE + # Other fields still parse correctly + assert result.skill_id == "abc" + + +def test_load_ignores_extra_fields() -> None: + """Forward-compat: extra JSON keys are silently dropped.""" + raw = ( + '{"state": "done", "skill_id": "s1", "logo_id": "l1", ' + '"oauth_app_id": "o1", "future_field": "ignored"}' + ) + result = load_artifacts(raw) + assert result.state == SkillCreationState.DONE + assert result.skill_id == "s1" + + +def test_load_empty_string_fields_become_none() -> None: + """Empty-string fields normalise to None (matches default).""" + raw = '{"state": "none", "skill_id": "", "logo_id": ""}' + result = load_artifacts(raw) + assert result.skill_id is None + assert result.logo_id is None + + +def test_artifacts_frozen() -> None: + """Artifacts are immutable — must copy via dataclasses.replace.""" + artifacts = SkillCreationArtifacts() + try: + artifacts.state = SkillCreationState.DONE # type: ignore[misc] + except dataclasses.FrozenInstanceError: + pass + else: + msg = "SkillCreationArtifacts must be frozen" + raise AssertionError(msg) + + +def test_mark_failed_preserves_ids() -> None: + """mark_failed() flips state to FAILED but keeps skill/oauth IDs for retry.""" + partial = SkillCreationArtifacts( + state=SkillCreationState.OAUTH_CREATED, + skill_id="s1", + logo_id="l1", + oauth_app_id="o1", + ) + failed = mark_failed(partial, "network error") + assert failed.state == SkillCreationState.FAILED + assert failed.last_error == "network error" + assert failed.skill_id == "s1" + assert failed.logo_id == "l1" + assert failed.oauth_app_id == "o1" + + +def test_state_is_string_enum() -> None: + """State values compare equal to plain strings — useful for config storage.""" + assert SkillCreationState.DONE == "done" + assert SkillCreationState.FAILED == "failed" diff --git a/tests/providers/yandex_smarthome/test_auto_skill_ui.py b/tests/providers/yandex_smarthome/test_auto_skill_ui.py new file mode 100644 index 0000000000..a263185e5c --- /dev/null +++ b/tests/providers/yandex_smarthome/test_auto_skill_ui.py @@ -0,0 +1,383 @@ +"""Tests for auto_skill_ui — ConfigEntry visibility and action label logic.""" + +from __future__ import annotations + +from music_assistant.providers.yandex_smarthome.auto_skill_state import ( + SkillCreationArtifacts, + SkillCreationState, +) +from music_assistant.providers.yandex_smarthome.auto_skill_ui import ( + auto_create_entries, + build_cloud_plus_entries, + build_direct_entries, + should_show_button, +) +from music_assistant.providers.yandex_smarthome.constants import ( + CONF_ACTION_AUTO_CREATE, + CONF_ACTION_GET_OTP, + CONF_ACTION_REGISTER, + CONF_AUTO_CREATE_ARTIFACTS, + CONF_SKILL_ID, + CONF_SKILL_TOKEN, + CONNECTION_TYPE_CLOUD, + CONNECTION_TYPE_CLOUD_PLUS, + CONNECTION_TYPE_DIRECT, +) + + +def _find(entries, key): # type: ignore[no-untyped-def] + for e in entries: + if e.key == key: + return e + return None + + +# --------------------------------------------------------------------------- +# should_show_button +# --------------------------------------------------------------------------- + + +class TestShouldShowButton: + """should_show_button captures the full visibility truth table.""" + + def test_hidden_in_cloud_mode(self) -> None: + """Plain cloud has no custom skill — button hidden.""" + assert not should_show_button( + connection_type=CONNECTION_TYPE_CLOUD, + state=SkillCreationState.NONE, + cloud_instance_id="abc", + base_url="https://x", + ) + + def test_hidden_when_state_done(self) -> None: + """DONE means skill already created — don't offer re-creation.""" + assert not should_show_button( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + state=SkillCreationState.DONE, + cloud_instance_id="abc", + base_url="https://x", + ) + + def test_hidden_cloud_plus_no_instance(self) -> None: + """cloud_plus requires a registered cloud instance first.""" + assert not should_show_button( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + state=SkillCreationState.NONE, + cloud_instance_id="", + base_url="https://x", + ) + + def test_hidden_direct_non_https(self) -> None: + """Direct needs an HTTPS base URL or Yandex will reject the skill.""" + assert not should_show_button( + connection_type=CONNECTION_TYPE_DIRECT, + state=SkillCreationState.NONE, + cloud_instance_id="", + base_url="http://localhost:8095", + ) + + def test_shown_cloud_plus_ready(self) -> None: + """All gates satisfied for cloud_plus → button visible.""" + assert should_show_button( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + state=SkillCreationState.NONE, + cloud_instance_id="abc", + base_url="https://x", + ) + + def test_shown_direct_ready(self) -> None: + """All gates satisfied for direct → button visible.""" + assert should_show_button( + connection_type=CONNECTION_TYPE_DIRECT, + state=SkillCreationState.NONE, + cloud_instance_id="", + base_url="https://ma.example.com", + ) + + def test_shown_after_failed(self) -> None: + """FAILED state keeps the button visible for retry.""" + assert should_show_button( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + state=SkillCreationState.FAILED, + cloud_instance_id="abc", + base_url="https://x", + ) + + +# --------------------------------------------------------------------------- +# auto_create_entries +# --------------------------------------------------------------------------- + + +class TestAutoCreateEntries: + """auto_create_entries renders a list of ConfigEntries matching state.""" + + def _entries( + self, + *, + connection_type: str = CONNECTION_TYPE_CLOUD_PLUS, + state: SkillCreationState = SkillCreationState.NONE, + cloud_instance_id: str = "abc", + base_url: str = "https://ma.example.com", + ): # type: ignore[no-untyped-def] + return auto_create_entries( + connection_type=connection_type, + artifacts=SkillCreationArtifacts(state=state), + cloud_instance_id=cloud_instance_id, + base_url=base_url, + session_id=None, + user_code=None, + verification_url=None, + existing_artifacts_raw=None, + ) + + def test_empty_for_cloud_mode(self) -> None: + """Plain cloud returns no entries — the section is meaningless.""" + assert self._entries(connection_type=CONNECTION_TYPE_CLOUD) == () + + def test_action_shown_and_enabled_when_ready(self) -> None: + """In the ready-to-create state, action is present and not hidden.""" + entries = list(self._entries()) + action = _find(entries, CONF_ACTION_AUTO_CREATE) + assert action is not None + assert action.hidden is False + + def test_action_hidden_when_state_done(self) -> None: + """state=DONE renders the action with hidden=True.""" + entries = list(self._entries(state=SkillCreationState.DONE)) + action = _find(entries, CONF_ACTION_AUTO_CREATE) + assert action is not None + assert action.hidden is True + + def test_action_label_changes_on_failed(self) -> None: + """After a failure the button label switches to 'Retry'.""" + entries = list(self._entries(state=SkillCreationState.FAILED)) + action = _find(entries, CONF_ACTION_AUTO_CREATE) + assert action is not None + assert action.action_label == "Retry" + + def test_action_label_says_retry_on_partial(self) -> None: + """Partial (non-FAILED) progress state uses the 'Retry from last step' label.""" + entries = list(self._entries(state=SkillCreationState.OAUTH_CREATED)) + action = _find(entries, CONF_ACTION_AUTO_CREATE) + assert action is not None + assert "Retry" in action.action_label + + def test_hidden_artifacts_always_round_tripped(self) -> None: + """Artifacts blob is included even when the section is mostly empty.""" + entries = list(self._entries()) + artifacts_entry = _find(entries, CONF_AUTO_CREATE_ARTIFACTS) + assert artifacts_entry is not None + assert artifacts_entry.hidden is True + + def test_status_label_reflects_failed_error(self) -> None: + """The status LABEL carries the last_error text for user visibility.""" + entries = auto_create_entries( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + artifacts=SkillCreationArtifacts( + state=SkillCreationState.FAILED, + last_error="HTTP 401: session expired", + ), + cloud_instance_id="abc", + base_url="https://x", + session_id=None, + user_code=None, + verification_url=None, + existing_artifacts_raw=None, + ) + status = _find(list(entries), "label_auto_create_status") + assert status is not None + assert "401" in status.label + + +# --------------------------------------------------------------------------- +# build_cloud_plus_entries — 3-step structure +# --------------------------------------------------------------------------- + + +class TestBuildCloudPlusEntries: + """cloud_plus entries render as numbered steps with proper gating.""" + + def _call( + self, + *, + is_registered: bool = False, + state: SkillCreationState = SkillCreationState.NONE, + otp_code: str | None = None, + skill_id: str = "", + ): # type: ignore[no-untyped-def] + return build_cloud_plus_entries( + otp_code=otp_code, + is_registered=is_registered, + cloud_instance_id="inst-1" if is_registered else "", + artifacts=SkillCreationArtifacts(state=state), + session_id=None, + user_code=None, + verification_url=None, + existing_artifacts_raw=None, + base_url="https://ma.example.com", + skill_id=skill_id, + ) + + def test_step1_register_always_visible(self) -> None: + """Step 1 (Register) is always rendered — even before first registration.""" + entries = self._call(is_registered=False) + keys = [e.key for e in entries] + assert CONF_ACTION_REGISTER in keys + + def test_step2_button_inactive_until_registered(self) -> None: + """Step 2 fields are emitted, but the Create-Skill button is hidden.""" + entries = self._call(is_registered=False) + action = _find(list(entries), CONF_ACTION_AUTO_CREATE) + # Entry exists (so advanced users can see other Step 2 fields), + # but the action button self-hides without a cloud instance. + assert action is not None + assert action.hidden is True + + def test_step2_visible_after_register(self) -> None: + """After register, Step 2 renders the auto-create action.""" + entries = self._call(is_registered=True) + keys = [e.key for e in entries] + assert CONF_ACTION_AUTO_CREATE in keys + + def test_step3_hidden_until_registered(self) -> None: + """Step 3 (Get OTP) requires Step 1 done (cloud registration).""" + entries = self._call(is_registered=False, skill_id="s1") + keys = [e.key for e in entries] + assert CONF_ACTION_GET_OTP not in keys + + def test_step3_hidden_until_skill_created(self) -> None: + """Step 3 also requires Step 2 done — OTP linking needs an existing skill.""" + entries = self._call(is_registered=True, skill_id="") + keys = [e.key for e in entries] + assert CONF_ACTION_GET_OTP not in keys + + def test_step3_visible_after_register_and_skill(self) -> None: + """After register AND skill created, Step 3 renders the Get-OTP action.""" + entries = self._call(is_registered=True, skill_id="s1") + keys = [e.key for e in entries] + assert CONF_ACTION_GET_OTP in keys + + def test_register_action_hidden_once_registered(self) -> None: + """The Register button disappears after a cloud instance exists.""" + entries = self._call(is_registered=True) + register = _find(list(entries), CONF_ACTION_REGISTER) + assert register is not None + assert register.hidden is True + + def test_skill_token_shown_on_done(self) -> None: + """Skill OAuth Token input is surfaced (non-advanced) on DONE.""" + entries = self._call(is_registered=True, state=SkillCreationState.DONE) + token = _find(list(entries), CONF_SKILL_TOKEN) + assert token is not None + assert getattr(token, "advanced", False) is False + + def test_skill_token_shown_on_failed(self) -> None: + """Skill OAuth Token input is also surfaced on FAILED for manual entry.""" + entries = self._call(is_registered=True, state=SkillCreationState.FAILED) + token = _find(list(entries), CONF_SKILL_TOKEN) + assert token is not None + assert getattr(token, "advanced", False) is False + + def test_skill_token_advanced_on_none(self) -> None: + """Before user interacts, the token field is hidden behind Advanced.""" + entries = self._call(is_registered=True, state=SkillCreationState.NONE) + token = _find(list(entries), CONF_SKILL_TOKEN) + assert token is not None + assert getattr(token, "advanced", False) is True + + def test_manual_fallback_appears_on_failed(self) -> None: + """FAILED renders manual copy-paste fields inline (non-advanced).""" + entries = self._call(is_registered=True, state=SkillCreationState.FAILED) + keys = [e.key for e in entries] + assert "manual_backend_url" in keys + assert "manual_client_id" in keys + assert "manual_auth_url" in keys + assert "manual_token_url" in keys + # On FAILED the block is NOT advanced — auto-visible + backend = _find(list(entries), "manual_backend_url") + assert getattr(backend, "advanced", False) is False + + def test_manual_fallback_shown_under_advanced_on_done(self) -> None: + """Happy path keeps manual fields under Advanced (power-user visibility).""" + entries = self._call(is_registered=True, state=SkillCreationState.DONE) + keys = [e.key for e in entries] + # Still emitted — but advanced so the default view stays clean + assert "manual_backend_url" in keys + backend = _find(list(entries), "manual_backend_url") + assert getattr(backend, "advanced", False) is True + + def test_otp_code_appears_when_present(self) -> None: + """OTP code field is visible once an OTP has been fetched.""" + entries = self._call(is_registered=True, skill_id="s1", otp_code="ABC123") + otp = _find(list(entries), "otp_code") + assert otp is not None + assert otp.hidden is False + + +# --------------------------------------------------------------------------- +# build_direct_entries — 1-step structure (no register, no OTP) +# --------------------------------------------------------------------------- + + +class TestBuildDirectEntries: + """direct mode renders a single Create-Skill step (no yaha, no OTP).""" + + def _call( + self, + *, + state: SkillCreationState = SkillCreationState.NONE, + base_url: str = "https://ma.example.com", + direct_client_secret: str = "secret-123", # noqa: S107 + ): # type: ignore[no-untyped-def] + return build_direct_entries( + artifacts=SkillCreationArtifacts(state=state), + session_id=None, + user_code=None, + verification_url=None, + existing_artifacts_raw=None, + base_url=base_url, + direct_client_secret=direct_client_secret, + ) + + def test_no_register_action(self) -> None: + """Direct mode has no yaha registration step.""" + entries = self._call() + keys = [e.key for e in entries] + assert CONF_ACTION_REGISTER not in keys + + def test_no_get_otp_action(self) -> None: + """Direct mode has no OTP linking step.""" + entries = self._call() + keys = [e.key for e in entries] + assert CONF_ACTION_GET_OTP not in keys + + def test_has_create_skill_action(self) -> None: + """Direct mode renders the Create-Skill action.""" + entries = self._call() + keys = [e.key for e in entries] + assert CONF_ACTION_AUTO_CREATE in keys + + def test_create_hidden_when_base_url_not_https(self) -> None: + """Non-HTTPS base URL disables the create button (Yandex rejects).""" + entries = self._call(base_url="http://ma.local:8095") + action = _find(list(entries), CONF_ACTION_AUTO_CREATE) + assert action is not None + assert action.hidden is True + + def test_manual_fallback_includes_per_install_secret(self) -> None: + """FAILED fallback for direct shows the per-install client_secret.""" + entries = self._call(state=SkillCreationState.FAILED) + # The fallback block surfaces the generated secret in a visible + # field, plus the Backend URL with the MA base URL. + backend = _find(list(entries), "manual_backend_url") + assert backend is not None + assert "ma.example.com" in str(backend.default_value) + assert "/api/yandex_smarthome/v1.0" in str(backend.default_value) + + def test_skill_id_field_shown_on_done(self) -> None: + """Skill ID input field is surfaced (non-advanced) on DONE.""" + entries = self._call(state=SkillCreationState.DONE) + skill_id = _find(list(entries), CONF_SKILL_ID) + assert skill_id is not None + assert getattr(skill_id, "advanced", False) is False diff --git a/tests/providers/yandex_smarthome/test_config_actions.py b/tests/providers/yandex_smarthome/test_config_actions.py new file mode 100644 index 0000000000..bfa8c3a420 --- /dev/null +++ b/tests/providers/yandex_smarthome/test_config_actions.py @@ -0,0 +1,366 @@ +"""Tests for the auto-create action wired into get_config_entries. + +Exercises _run_auto_create_action via the public _handle_config_actions +entry point so the integration between config values, artifacts, and +the orchestrator is covered end-to-end (with the orchestrator itself +mocked). +""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock + +import aiohttp +import pytest + +from music_assistant.providers import yandex_smarthome as provider_module +from music_assistant.providers.yandex_smarthome import _handle_config_actions +from music_assistant.providers.yandex_smarthome.auto_skill_state import ( + SkillCreationArtifacts, + SkillCreationState, +) +from music_assistant.providers.yandex_smarthome.constants import ( + CONF_ACTION_AUTO_CREATE, + CONF_AUTO_CREATE_ARTIFACTS, + CONF_CLOUD_INSTANCE_ID, + CONF_DIRECT_CLIENT_SECRET, + CONF_INSTANCE_NAME, + CONF_SKILL_ID, + CONNECTION_TYPE_CLOUD_PLUS, + CONNECTION_TYPE_DIRECT, +) + + +def _make_mass() -> MagicMock: + mass = MagicMock() + mass.get_provider.return_value = None + mass.signal_event = MagicMock() + return mass + + +@pytest.mark.asyncio +async def test_auto_create_done_populates_skill_id(monkeypatch) -> None: # type: ignore[no-untyped-def] + """On DONE, CONF_SKILL_ID is written and artifacts are persisted.""" + + async def _fake_auto_create(**_kwargs: Any) -> SkillCreationArtifacts: + return SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="new-skill-uuid", + logo_id="l1", + oauth_app_id="o1", + ) + + monkeypatch.setattr( + provider_module, "auto_create_skill", + _fake_auto_create, + ) + + mass = _make_mass() + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "My Instance", + CONF_CLOUD_INSTANCE_ID: "inst-1", + } + + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id=None, + is_cloud_plus=True, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + ) + + assert values[CONF_SKILL_ID] == "new-skill-uuid" + stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) + assert stored["state"] == "done" + assert stored["skill_id"] == "new-skill-uuid" + + +@pytest.mark.asyncio +async def test_auto_create_failed_preserves_artifacts(monkeypatch) -> None: # type: ignore[no-untyped-def] + """On FAILED, CONF_SKILL_ID is NOT set so runtime doesn't use a half-skill.""" + + async def _fake_auto_create(**_kwargs: Any) -> SkillCreationArtifacts: + return SkillCreationArtifacts( + state=SkillCreationState.FAILED, + skill_id="partial-skill-id", + last_error="upload failed", + ) + + monkeypatch.setattr( + provider_module, "auto_create_skill", + _fake_auto_create, + ) + + mass = _make_mass() + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "X", + CONF_CLOUD_INSTANCE_ID: "inst-1", + } + + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id=None, + is_cloud_plus=True, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + ) + + # CONF_SKILL_ID must remain unset — skill is incomplete + assert CONF_SKILL_ID not in values + stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) + assert stored["state"] == "failed" + assert stored["last_error"] == "upload failed" + assert stored["skill_id"] == "partial-skill-id" + + +@pytest.mark.asyncio +async def test_auto_create_precondition_valueerror_becomes_failed( + monkeypatch, # type: ignore[no-untyped-def] +) -> None: + """ValueError from preconditions is caught and stored as a FAILED artifact.""" + + async def _raises_value_error(**_kwargs: Any) -> SkillCreationArtifacts: + msg = "direct mode requires HTTPS" + raise ValueError(msg) + + monkeypatch.setattr( + provider_module, "auto_create_skill", + _raises_value_error, + ) + + mass = _make_mass() + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "X", + CONF_DIRECT_CLIENT_SECRET: "secret", + } + + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id=None, + is_cloud_plus=False, + connection_type=CONNECTION_TYPE_DIRECT, + ) + + stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) + assert stored["state"] == "failed" + assert "HTTPS" in stored["last_error"] + assert CONF_SKILL_ID not in values + + +@pytest.mark.asyncio +async def test_auto_create_unexpected_error_caught(monkeypatch) -> None: # type: ignore[no-untyped-def] + """Any other exception from orchestrator also surfaces as FAILED, not a crash.""" + + async def _raises_runtime(**_kwargs: Any) -> SkillCreationArtifacts: + msg = "network unreachable" + raise RuntimeError(msg) + + monkeypatch.setattr( + provider_module, "auto_create_skill", + _raises_runtime, + ) + + mass = _make_mass() + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "X", + CONF_CLOUD_INSTANCE_ID: "inst-1", + } + + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id=None, + is_cloud_plus=True, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + ) + + stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) + assert stored["state"] == "failed" + assert "network" in stored["last_error"] + + +@pytest.mark.asyncio +async def test_auto_create_non_action_is_noop(monkeypatch) -> None: # type: ignore[no-untyped-def] + """Any action other than AUTO_CREATE must not touch auto-create values.""" + # Spy: if auto_create_skill is somehow called, this fails the test. + called = [] + + async def _fake(**_kwargs: Any) -> SkillCreationArtifacts: + called.append(True) + return SkillCreationArtifacts() + + monkeypatch.setattr( + provider_module, "auto_create_skill", + _fake, + ) + + mass = _make_mass() + values: dict[str, Any] = {} + + # A different action — should not trigger auto-create + await _handle_config_actions( + mass, + "some_other_action", + values, + instance_id=None, + is_cloud_plus=True, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + ) + + assert not called + assert CONF_AUTO_CREATE_ARTIFACTS not in values + + +@pytest.mark.asyncio +async def test_session_id_forwarded_from_frontend(monkeypatch) -> None: # type: ignore[no-untyped-def] + """Frontend-supplied ``values['session_id']`` is forwarded to the orchestrator. + + The session_id is what AuthenticationHelper listens on — if the + wiring drops it and generates a local uuid instead, MA's popup + never opens (silent failure the user won't see). + """ + captured: dict[str, Any] = {} + + async def _capture(**kwargs: Any) -> SkillCreationArtifacts: + captured["session_id"] = kwargs.get("session_id") + return SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="s") + + monkeypatch.setattr(provider_module, "auto_create_skill", _capture) + + mass = _make_mass() + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "X", + CONF_CLOUD_INSTANCE_ID: "inst-1", + "session_id": "frontend-supplied-id-123", + } + + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id=None, + is_cloud_plus=True, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + ) + + assert captured["session_id"] == "frontend-supplied-id-123" + + +@pytest.mark.asyncio +async def test_auto_create_prefers_saved_direct_secret(monkeypatch) -> None: # type: ignore[no-untyped-def] + """SECURE_STRING client secret: saved_config beats empty ``values`` on re-open. + + On re-open MA does not echo SECURE_STRING values back into ``values``, + so reading from ``values`` alone would pass an empty secret into the + orchestrator. The helper must pull it from the persisted provider + config instead. + """ + captured: dict[str, Any] = {} + + async def _capture(**kwargs: Any) -> SkillCreationArtifacts: + captured["direct_client_secret"] = kwargs.get("direct_client_secret") + return SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="s") + + monkeypatch.setattr(provider_module, "auto_create_skill", _capture) + + mass = _make_mass() + saved_cfg = MagicMock() + saved_cfg.get_value.side_effect = lambda key: ( + "persisted-secret" if key == CONF_DIRECT_CLIENT_SECRET else None + ) + prov = MagicMock() + prov.config = saved_cfg + mass.get_provider.return_value = prov + + # Frontend re-open: SECURE_STRING not echoed back -> values has no secret. + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "X", + } + + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id="inst-42", + is_cloud_plus=False, + connection_type=CONNECTION_TYPE_DIRECT, + ) + + assert captured["direct_client_secret"] == "persisted-secret" + + +@pytest.mark.asyncio +async def test_auto_create_falls_back_to_values_for_first_setup(monkeypatch) -> None: # type: ignore[no-untyped-def] + """First-time setup: no instance yet, secret is read from ``values``.""" + captured: dict[str, Any] = {} + + async def _capture(**kwargs: Any) -> SkillCreationArtifacts: + captured["direct_client_secret"] = kwargs.get("direct_client_secret") + return SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="s") + + monkeypatch.setattr(provider_module, "auto_create_skill", _capture) + + mass = _make_mass() # get_provider returns None + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "X", + CONF_DIRECT_CLIENT_SECRET: "fresh-secret", + } + + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id=None, + is_cloud_plus=False, + connection_type=CONNECTION_TYPE_DIRECT, + ) + + assert captured["direct_client_secret"] == "fresh-secret" + + +@pytest.mark.asyncio +async def test_existing_action_register_still_works(monkeypatch) -> None: # type: ignore[no-untyped-def] + """Gap-fill: pre-existing CONF_ACTION_REGISTER path is still covered.""" + + async def _fake_register( + _session: Any, platform: str | None = None # noqa: ARG001 + ) -> dict[str, str]: + return {"id": "inst-new", "password": "p", "connection_token": "t"} + + async def _fake_otp(_session: Any, _id: str, _tok: Any) -> str: + return "111111" + + class _NoopSession: + async def __aenter__(self): # type: ignore[no-untyped-def] + return self + + async def __aexit__(self, *_a): # type: ignore[no-untyped-def] + return False + + monkeypatch.setattr(provider_module, "register_cloud_instance", _fake_register) + monkeypatch.setattr(provider_module, "get_cloud_otp", _fake_otp) + monkeypatch.setattr(aiohttp, "ClientSession", _NoopSession) + + mass = _make_mass() + values: dict[str, Any] = {} + + otp = await _handle_config_actions( + mass, + "register_cloud", + values, + instance_id=None, + is_cloud_plus=True, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + ) + + assert values[CONF_CLOUD_INSTANCE_ID] == "inst-new" + # Register no longer auto-fetches OTP — that's a separate Step 3 action. + # The handler returns None because no OTP was requested. + assert otp is None diff --git a/tests/providers/yandex_smarthome/test_direct.py b/tests/providers/yandex_smarthome/test_direct.py index 7323b68e91..0a4c634295 100644 --- a/tests/providers/yandex_smarthome/test_direct.py +++ b/tests/providers/yandex_smarthome/test_direct.py @@ -309,13 +309,8 @@ async def test_devices_success(handler: DirectConnectionHandler) -> None: ) with ( mock_hdl, - patch( - "music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []} - ), - patch( - "music_assistant.providers.yandex_smarthome.direct.build_response", - return_value=resp_payload, - ), + patch("music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []}), + patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), ): resp = await handler._handle_devices(req) assert resp.status == 200 @@ -340,13 +335,8 @@ async def test_query_success(handler: DirectConnectionHandler) -> None: ) with ( mock_query, - patch( - "music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []} - ), - patch( - "music_assistant.providers.yandex_smarthome.direct.build_response", - return_value=resp_payload, - ), + patch("music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []}), + patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), ): resp = await handler._handle_query(req) assert resp.status == 200 @@ -367,18 +357,10 @@ async def test_action_success(handler: DirectConnectionHandler) -> None: return_value=MagicMock(), ) with ( - patch( - "music_assistant.providers.yandex_smarthome.direct.parse_action_payload", - return_value=MagicMock(), - ), + patch("music_assistant.providers.yandex_smarthome.direct.parse_action_payload", return_value=MagicMock()), mock_action, - patch( - "music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []} - ), - patch( - "music_assistant.providers.yandex_smarthome.direct.build_response", - return_value=resp_payload, - ), + patch("music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []}), + patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), ): resp = await handler._handle_action(req) assert resp.status == 200 @@ -393,15 +375,8 @@ async def test_unlink_success(handler: DirectConnectionHandler) -> None: ) resp_payload = {"request_id": "req-4"} with ( - patch( - "music_assistant.providers.yandex_smarthome.direct.handle_user_unlink", - new_callable=AsyncMock, - return_value={}, - ), - patch( - "music_assistant.providers.yandex_smarthome.direct.build_response", - return_value=resp_payload, - ), + patch("music_assistant.providers.yandex_smarthome.direct.handle_user_unlink", new_callable=AsyncMock, return_value={}), + patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), ): resp = await handler._handle_unlink(req) assert resp.status == 200 From c1be547aba3382a239c0b821bf18c6bbf65a1500 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 14:14:18 +0000 Subject: [PATCH 02/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.4.1 --- .../providers/yandex_smarthome/auto_skill.py | 69 +++++-------------- .../yandex_smarthome/auto_skill_state.py | 4 +- .../yandex_smarthome/auto_skill_ui.py | 25 ++----- .../yandex_smarthome/test_auto_skill.py | 26 ++----- .../yandex_smarthome/test_auto_skill_state.py | 4 +- .../yandex_smarthome/test_auto_skill_ui.py | 15 ++-- .../yandex_smarthome/test_config_actions.py | 20 ++++-- .../providers/yandex_smarthome/test_direct.py | 43 +++++++++--- 8 files changed, 93 insertions(+), 113 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index 1bc59c7547..774feaa5ac 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -271,10 +271,7 @@ async def upload_logo(self, csrf: str, skill_id: str, png: bytes) -> str: The logo file is sent as multipart with the field name ``file`` and filename ``icon.png`` (matching the HAR capture). """ - url = ( - f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/upload-logo" - f"?channel={SMART_HOME_CHANNEL}" - ) + url = f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/upload-logo?channel={SMART_HOME_CHANNEL}" form = aiohttp.FormData() form.add_field( "file", @@ -310,9 +307,7 @@ async def upload_logo(self, csrf: str, skill_id: str, png: bytes) -> str: # Step 5: update draft settings # ----------------------------------------------------------------------- - async def update_draft( - self, csrf: str, skill_id: str, payload: Mapping[str, Any] - ) -> None: + async def update_draft(self, csrf: str, skill_id: str, payload: Mapping[str, Any]) -> None: """PATCH the skill draft with backend URL / publishing metadata.""" url = f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/update" await self._patch_json(url, dict(payload), csrf=csrf, step="update_draft") @@ -367,14 +362,9 @@ async def create_oauth_app( # Step 7: bind OAuth app to the skill # ----------------------------------------------------------------------- - async def attach_oauth( - self, csrf: str, skill_id: str, oauth_app_id: str - ) -> None: + async def attach_oauth(self, csrf: str, skill_id: str, oauth_app_id: str) -> None: """Attach an existing OAuth app to the skill's account-linking slot.""" - url = ( - f"{DIALOGS_API_BASE}/apps/{skill_id}/oauthApp" - f"?channel={SMART_HOME_CHANNEL}" - ) + url = f"{DIALOGS_API_BASE}/apps/{skill_id}/oauthApp?channel={SMART_HOME_CHANNEL}" payload = {"oauthAppId": oauth_app_id} await self._post_json(url, payload, csrf=csrf, step="attach_oauth") @@ -389,8 +379,7 @@ async def request_deploy(self, csrf: str, skill_id: str) -> None: 2xx; otherwise raises. """ url = ( - f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/request-deploy" - f"?channel={SMART_HOME_CHANNEL}" + f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/request-deploy?channel={SMART_HOME_CHANNEL}" ) headers = {"x-csrf-token": csrf} async with self._session.post(url, headers=headers) as resp: @@ -406,9 +395,7 @@ async def request_deploy(self, csrf: str, skill_id: str) -> None: # Internal helpers # ----------------------------------------------------------------------- - async def _get_json( - self, url: str, *, csrf: str, step: str - ) -> dict[str, Any]: + async def _get_json(self, url: str, *, csrf: str, step: str) -> dict[str, Any]: headers = {"x-csrf-token": csrf} async with self._session.get(url, headers=headers) as resp: body = await resp.text() @@ -449,16 +436,13 @@ async def _send_json( "x-csrf-token": csrf, "content-type": "application/json", } - async with self._session.request( - method, url, json=payload, headers=headers - ) as resp: + async with self._session.request(method, url, json=payload, headers=headers) as resp: body = await resp.text() # Only ``create_app`` can fail with duplicate-name errors — # other endpoints use 409 for unrelated conflicts and would # be misclassified as duplicates if the mapping were global. duplicate_candidate = step == "create_app" and ( - resp.status == 409 - or (resp.status in (400, 422) and _looks_like_duplicate(body)) + resp.status == 409 or (resp.status in (400, 422) and _looks_like_duplicate(body)) ) if duplicate_candidate: raise DialogsDuplicateSkillError( @@ -504,8 +488,7 @@ def _looks_like_duplicate(body: str) -> bool: return False lowered = body.lower() return any( - kw in lowered - for kw in ("already exists", "duplicate", "exists with name", "not_unique") + kw in lowered for kw in ("already exists", "duplicate", "exists with name", "not_unique") ) @@ -545,9 +528,7 @@ def derive_backend_uri(mass: MusicAssistant, connection_type: str) -> str: raise ValueError(msg) -def derive_auth_urls( - mass: MusicAssistant, connection_type: str -) -> tuple[str, str]: +def derive_auth_urls(mass: MusicAssistant, connection_type: str) -> tuple[str, str]: """Return (authorize_url, token_url) for the OAuth app. cloud_plus uses the yaha-cloud relay's OAuth endpoints; direct @@ -722,8 +703,8 @@ def check_preconditions( # Orchestrator: device flow + resumable pipeline # --------------------------------------------------------------------------- +# Hard cap on how long we'll wait for the user to enter the code. DEVICE_FLOW_TIMEOUT_SECONDS = 300.0 -"""Hard cap on how long we'll wait for the user to enter the code.""" _DEVICE_CODE_PAGE_PATH = "/yandex_smarthome/device_code" # Keep the intermediate HTML page alive long enough for one more poll @@ -738,9 +719,7 @@ def check_preconditions( _SAFE_SESSION_ID_RE = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z") -def _build_device_code_page( - user_code: str, verification_url: str, status_url: str -) -> str: +def _build_device_code_page(user_code: str, verification_url: str, status_url: str) -> str: """Render the HTML page shown during Device Flow login. Yandex's ya.ru/device page does not pre-fill from query params and @@ -1073,9 +1052,7 @@ async def auto_create_skill( # noqa: PLR0913 raise except Exception as exc: _LOGGER.exception("auto-create hit unexpected error") - return dataclasses.replace( - artifacts, state=SkillCreationState.FAILED, last_error=repr(exc) - ) + return dataclasses.replace(artifacts, state=SkillCreationState.FAILED, last_error=repr(exc)) async def _run_pipeline_with_recovery( @@ -1124,12 +1101,8 @@ async def _track(a: SkillCreationArtifacts) -> None: progress_cb=_track, ) except DialogsApiError as exc: - _LOGGER.warning( - "auto-create failed at %s: %s", exc.step, exc, exc_info=True - ) - return dataclasses.replace( - current, state=SkillCreationState.FAILED, last_error=str(exc) - ) + _LOGGER.warning("auto-create failed at %s: %s", exc.step, exc, exc_info=True) + return dataclasses.replace(current, state=SkillCreationState.FAILED, last_error=str(exc)) async def _execute_pipeline( # noqa: PLR0913 @@ -1185,9 +1158,7 @@ async def _execute_pipeline( # noqa: PLR0913 ) _LOGGER.info("auto-skill: [3/5] updating draft with settings") await creator.update_draft(csrf, skill_id, draft) - artifacts = dataclasses.replace( - artifacts, state=SkillCreationState.DRAFT_UPDATED - ) + artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DRAFT_UPDATED) await _maybe_save(progress_cb, artifacts) state = artifacts.state @@ -1226,9 +1197,7 @@ async def _execute_pipeline( # noqa: PLR0913 # -- Step 7: attach OAuth app to skill -- if state == SkillCreationState.OAUTH_CREATED: await creator.attach_oauth(csrf, skill_id, oauth_app_id_str) - artifacts = dataclasses.replace( - artifacts, state=SkillCreationState.OAUTH_ATTACHED - ) + artifacts = dataclasses.replace(artifacts, state=SkillCreationState.OAUTH_ATTACHED) await _maybe_save(progress_cb, artifacts) state = artifacts.state @@ -1239,9 +1208,7 @@ async def _execute_pipeline( # noqa: PLR0913 ): _LOGGER.info("auto-skill: [5/5] publishing skill") await creator.request_deploy(csrf, skill_id) - artifacts = dataclasses.replace( - artifacts, state=SkillCreationState.DONE - ) + artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DONE) await _maybe_save(progress_cb, artifacts) return artifacts diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_state.py b/music_assistant/providers/yandex_smarthome/auto_skill_state.py index 67182568fa..6f2b8e8b8e 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill_state.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill_state.py @@ -110,9 +110,7 @@ def _opt_str(key: str) -> str | None: ) -def mark_failed( - artifacts: SkillCreationArtifacts, error: str -) -> SkillCreationArtifacts: +def mark_failed(artifacts: SkillCreationArtifacts, error: str) -> SkillCreationArtifacts: """Return a copy of *artifacts* flipped to ``FAILED`` with an error.""" return dataclasses.replace( artifacts, diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py index 48822e6853..bb7671a6cb 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py @@ -122,10 +122,7 @@ def should_show_button( return False if connection_type == CONNECTION_TYPE_CLOUD_PLUS and not cloud_instance_id: return False - return not ( - connection_type == CONNECTION_TYPE_DIRECT - and not base_url.startswith("https://") - ) + return not (connection_type == CONNECTION_TYPE_DIRECT and not base_url.startswith("https://")) def auto_create_entries( @@ -288,9 +285,7 @@ def build_cloud_plus_entries( # noqa: PLR0913 return entries -def _step1_register_entries( - is_registered: bool, cloud_instance_id: str -) -> list[ConfigEntry]: +def _step1_register_entries(is_registered: bool, cloud_instance_id: str) -> list[ConfigEntry]: """Step 1 — yaha-cloud.ru instance registration.""" status_text = ( f"✅ Cloud instance registered (id: {cloud_instance_id})." @@ -355,8 +350,7 @@ def _create_skill_step_entries( type=ConfigEntryType.STRING, label="Device code for ya.ru/device", description=( - "Open the URL below, log in to your Yandex account, " - "and enter this code." + "Open the URL below, log in to your Yandex account, and enter this code." ), value=user_code, required=False, @@ -382,9 +376,8 @@ def _create_skill_step_entries( # backends. If MA's Base URL is not HTTPS, show the user what's # wrong (and what URL we'd have used) so they can fix it in # Settings → Core → Webserver → Base URL. - direct_https_missing = ( - connection_type == CONNECTION_TYPE_DIRECT - and not base_url.startswith("https://") + direct_https_missing = connection_type == CONNECTION_TYPE_DIRECT and not base_url.startswith( + "https://" ) if direct_https_missing: entries.append( @@ -561,9 +554,7 @@ def _manual_fallback_entries( """ if connection_type == CONNECTION_TYPE_CLOUD_PLUS: backend_uri = CLOUD_SKILL_WEBHOOK_TEMPLATE - client_id = CLOUD_SKILL_CLIENT_ID_TEMPLATE.format( - instance_id=cloud_instance_id - ) + client_id = CLOUD_SKILL_CLIENT_ID_TEMPLATE.format(instance_id=cloud_instance_id) client_secret = CLOUD_SKILL_CLIENT_SECRET auth_url = CLOUD_OAUTH_AUTHORIZE_URL token_url = CLOUD_OAUTH_TOKEN_URL @@ -673,9 +664,7 @@ def _manual_fallback_entries( key="manual_client_secret", type=ConfigEntryType.STRING, label="Client Secret value (for reference)", - description=( - "Copy this string into 'Account linking' → 'Client secret'." - ), + description=("Copy this string into 'Account linking' → 'Client secret'."), required=False, default_value=client_secret, advanced=advanced, diff --git a/tests/providers/yandex_smarthome/test_auto_skill.py b/tests/providers/yandex_smarthome/test_auto_skill.py index bb9f64bc5e..4cfd779018 100644 --- a/tests/providers/yandex_smarthome/test_auto_skill.py +++ b/tests/providers/yandex_smarthome/test_auto_skill.py @@ -40,9 +40,7 @@ # --------------------------------------------------------------------------- -def _mock_response( - *, status: int = 200, body_text: str = "", body_json: Any = None -) -> AsyncMock: +def _mock_response(*, status: int = 200, body_text: str = "", body_json: Any = None) -> AsyncMock: """Build a mock aiohttp.ClientResponse. If *body_json* is given, ``text()`` returns its JSON-encoded form; @@ -97,9 +95,9 @@ class TestFetchCsrf: async def test_happy_path_returns_token(self) -> None: """CSRF is extracted from the secretkey field in the developer HTML.""" html = ( - '' + "" ) session = _make_session() _install_ctx(session.get, _mock_response(status=200, body_text=html)) @@ -511,10 +509,7 @@ class TestDeriveClientId: def test_cloud_plus_templated(self) -> None: """cloud_plus wraps the instance_id in the yaha protocol prefix.""" - assert ( - derive_client_id(CONNECTION_TYPE_CLOUD_PLUS, "abc123") - == "yandex_smart_home:abc123" - ) + assert derive_client_id(CONNECTION_TYPE_CLOUD_PLUS, "abc123") == "yandex_smart_home:abc123" def test_cloud_plus_missing_instance_raises(self) -> None: """Empty instance_id is a configuration bug — raise early.""" @@ -523,10 +518,7 @@ def test_cloud_plus_missing_instance_raises(self) -> None: def test_direct_fixed_value(self) -> None: """Direct mode uses the fixed Yandex social redirect base.""" - assert ( - derive_client_id(CONNECTION_TYPE_DIRECT, "") - == "https://social.yandex.net/" - ) + assert derive_client_id(CONNECTION_TYPE_DIRECT, "") == "https://social.yandex.net/" class TestBuildDraftPayload: @@ -865,9 +857,7 @@ async def test_failure_after_app_created_preserves_skill_id(self) -> None: async def test_csrf_miss_becomes_failed_state(self) -> None: """CSRF extraction failure doesn't crash — surfaces as FAILED.""" creator = _make_creator_mock() - creator.fetch_csrf.side_effect = DialogsCsrfError( - "secretkey not found", step="fetch_csrf" - ) + creator.fetch_csrf.side_effect = DialogsCsrfError("secretkey not found", step="fetch_csrf") result = await _run_orch(creator=creator) @@ -935,6 +925,4 @@ def test_returns_real_png(self) -> None: # PNG magic: 89 50 4E 47 0D 0A 1A 0A assert data[:8] == bytes.fromhex("89504e470d0a1a0a") # Sanity: the bundled asset is non-trivial, not the 1x1 fallback. - assert len(data) > 1000, ( - f"expected real logo asset, got {len(data)} bytes (fallback?)" - ) + assert len(data) > 1000, f"expected real logo asset, got {len(data)} bytes (fallback?)" diff --git a/tests/providers/yandex_smarthome/test_auto_skill_state.py b/tests/providers/yandex_smarthome/test_auto_skill_state.py index 3686aa2aa6..f87953fb88 100644 --- a/tests/providers/yandex_smarthome/test_auto_skill_state.py +++ b/tests/providers/yandex_smarthome/test_auto_skill_state.py @@ -125,5 +125,5 @@ def test_mark_failed_preserves_ids() -> None: def test_state_is_string_enum() -> None: """State values compare equal to plain strings — useful for config storage.""" - assert SkillCreationState.DONE == "done" - assert SkillCreationState.FAILED == "failed" + assert SkillCreationState.DONE.value == "done" + assert SkillCreationState.FAILED.value == "failed" diff --git a/tests/providers/yandex_smarthome/test_auto_skill_ui.py b/tests/providers/yandex_smarthome/test_auto_skill_ui.py index a263185e5c..c7a9e6cb2c 100644 --- a/tests/providers/yandex_smarthome/test_auto_skill_ui.py +++ b/tests/providers/yandex_smarthome/test_auto_skill_ui.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from music_assistant.providers.yandex_smarthome.auto_skill_state import ( SkillCreationArtifacts, SkillCreationState, @@ -24,8 +26,13 @@ CONNECTION_TYPE_DIRECT, ) +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + from music_assistant_models.config_entries import ConfigEntry + -def _find(entries, key): # type: ignore[no-untyped-def] +def _find(entries: Iterable[ConfigEntry], key: str) -> ConfigEntry | None: for e in entries: if e.key == key: return e @@ -119,7 +126,7 @@ def _entries( state: SkillCreationState = SkillCreationState.NONE, cloud_instance_id: str = "abc", base_url: str = "https://ma.example.com", - ): # type: ignore[no-untyped-def] + ) -> Sequence[ConfigEntry]: return auto_create_entries( connection_type=connection_type, artifacts=SkillCreationArtifacts(state=state), @@ -205,7 +212,7 @@ def _call( state: SkillCreationState = SkillCreationState.NONE, otp_code: str | None = None, skill_id: str = "", - ): # type: ignore[no-untyped-def] + ) -> list[ConfigEntry]: return build_cloud_plus_entries( otp_code=otp_code, is_registered=is_registered, @@ -329,7 +336,7 @@ def _call( state: SkillCreationState = SkillCreationState.NONE, base_url: str = "https://ma.example.com", direct_client_secret: str = "secret-123", # noqa: S107 - ): # type: ignore[no-untyped-def] + ) -> list[ConfigEntry]: return build_direct_entries( artifacts=SkillCreationArtifacts(state=state), session_id=None, diff --git a/tests/providers/yandex_smarthome/test_config_actions.py b/tests/providers/yandex_smarthome/test_config_actions.py index bfa8c3a420..95065604f5 100644 --- a/tests/providers/yandex_smarthome/test_config_actions.py +++ b/tests/providers/yandex_smarthome/test_config_actions.py @@ -53,7 +53,8 @@ async def _fake_auto_create(**_kwargs: Any) -> SkillCreationArtifacts: ) monkeypatch.setattr( - provider_module, "auto_create_skill", + provider_module, + "auto_create_skill", _fake_auto_create, ) @@ -90,7 +91,8 @@ async def _fake_auto_create(**_kwargs: Any) -> SkillCreationArtifacts: ) monkeypatch.setattr( - provider_module, "auto_create_skill", + provider_module, + "auto_create_skill", _fake_auto_create, ) @@ -119,7 +121,7 @@ async def _fake_auto_create(**_kwargs: Any) -> SkillCreationArtifacts: @pytest.mark.asyncio async def test_auto_create_precondition_valueerror_becomes_failed( - monkeypatch, # type: ignore[no-untyped-def] + monkeypatch: pytest.MonkeyPatch, ) -> None: """ValueError from preconditions is caught and stored as a FAILED artifact.""" @@ -128,7 +130,8 @@ async def _raises_value_error(**_kwargs: Any) -> SkillCreationArtifacts: raise ValueError(msg) monkeypatch.setattr( - provider_module, "auto_create_skill", + provider_module, + "auto_create_skill", _raises_value_error, ) @@ -162,7 +165,8 @@ async def _raises_runtime(**_kwargs: Any) -> SkillCreationArtifacts: raise RuntimeError(msg) monkeypatch.setattr( - provider_module, "auto_create_skill", + provider_module, + "auto_create_skill", _raises_runtime, ) @@ -197,7 +201,8 @@ async def _fake(**_kwargs: Any) -> SkillCreationArtifacts: return SkillCreationArtifacts() monkeypatch.setattr( - provider_module, "auto_create_skill", + provider_module, + "auto_create_skill", _fake, ) @@ -330,7 +335,8 @@ async def test_existing_action_register_still_works(monkeypatch) -> None: # typ """Gap-fill: pre-existing CONF_ACTION_REGISTER path is still covered.""" async def _fake_register( - _session: Any, platform: str | None = None # noqa: ARG001 + _session: Any, + platform: str | None = None, # noqa: ARG001 ) -> dict[str, str]: return {"id": "inst-new", "password": "p", "connection_token": "t"} diff --git a/tests/providers/yandex_smarthome/test_direct.py b/tests/providers/yandex_smarthome/test_direct.py index 0a4c634295..7323b68e91 100644 --- a/tests/providers/yandex_smarthome/test_direct.py +++ b/tests/providers/yandex_smarthome/test_direct.py @@ -309,8 +309,13 @@ async def test_devices_success(handler: DirectConnectionHandler) -> None: ) with ( mock_hdl, - patch("music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []}), - patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), + patch( + "music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []} + ), + patch( + "music_assistant.providers.yandex_smarthome.direct.build_response", + return_value=resp_payload, + ), ): resp = await handler._handle_devices(req) assert resp.status == 200 @@ -335,8 +340,13 @@ async def test_query_success(handler: DirectConnectionHandler) -> None: ) with ( mock_query, - patch("music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []}), - patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), + patch( + "music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []} + ), + patch( + "music_assistant.providers.yandex_smarthome.direct.build_response", + return_value=resp_payload, + ), ): resp = await handler._handle_query(req) assert resp.status == 200 @@ -357,10 +367,18 @@ async def test_action_success(handler: DirectConnectionHandler) -> None: return_value=MagicMock(), ) with ( - patch("music_assistant.providers.yandex_smarthome.direct.parse_action_payload", return_value=MagicMock()), + patch( + "music_assistant.providers.yandex_smarthome.direct.parse_action_payload", + return_value=MagicMock(), + ), mock_action, - patch("music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []}), - patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), + patch( + "music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []} + ), + patch( + "music_assistant.providers.yandex_smarthome.direct.build_response", + return_value=resp_payload, + ), ): resp = await handler._handle_action(req) assert resp.status == 200 @@ -375,8 +393,15 @@ async def test_unlink_success(handler: DirectConnectionHandler) -> None: ) resp_payload = {"request_id": "req-4"} with ( - patch("music_assistant.providers.yandex_smarthome.direct.handle_user_unlink", new_callable=AsyncMock, return_value={}), - patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), + patch( + "music_assistant.providers.yandex_smarthome.direct.handle_user_unlink", + new_callable=AsyncMock, + return_value={}, + ), + patch( + "music_assistant.providers.yandex_smarthome.direct.build_response", + return_value=resp_payload, + ), ): resp = await handler._handle_unlink(req) assert resp.status == 200 From f108d57fe041a2cd5190670df360dafd0606c26f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 14:28:31 +0000 Subject: [PATCH 03/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.4.2 --- tests/providers/yandex_smarthome/test_auto_skill_ui.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/providers/yandex_smarthome/test_auto_skill_ui.py b/tests/providers/yandex_smarthome/test_auto_skill_ui.py index c7a9e6cb2c..faf34f26a8 100644 --- a/tests/providers/yandex_smarthome/test_auto_skill_ui.py +++ b/tests/providers/yandex_smarthome/test_auto_skill_ui.py @@ -168,6 +168,7 @@ def test_action_label_says_retry_on_partial(self) -> None: entries = list(self._entries(state=SkillCreationState.OAUTH_CREATED)) action = _find(entries, CONF_ACTION_AUTO_CREATE) assert action is not None + assert action.action_label is not None assert "Retry" in action.action_label def test_hidden_artifacts_always_round_tripped(self) -> None: From 51d09205d838b9e101a8f8826743dec8b46816bc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 15:42:33 +0000 Subject: [PATCH 04/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.4.3 --- .../providers/yandex_smarthome/auto_skill.py | 51 ++++++++++---- .../yandex_smarthome/test_auto_skill.py | 66 ++++++++++++++++++- 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index 774feaa5ac..b2f6f2369a 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -979,13 +979,36 @@ def _build_authenticator_cm( ) -> Any: """Wrap *authenticator* so it supports ``async with`` uniformly. - The default implementation is a plain async generator; tests may - pass either a generator or an already-decorated - ``@asynccontextmanager``. ``asynccontextmanager`` is idempotent on - an already-decorated callable, so we wrap unconditionally. + The default implementation is a plain async generator, but callers + may inject an already-decorated ``@asynccontextmanager``. Re-wrapping + a CM factory with ``asynccontextmanager`` is *not* idempotent — the + outer wrapper would call ``__anext__`` on the inner CM object and + crash — so detect the CM result and pass it through unchanged. """ - cm_factory = asynccontextmanager(authenticator) - return cm_factory(mass=mass, session_id=session_id, timeout=timeout) + result = authenticator(mass=mass, session_id=session_id, timeout=timeout) + if hasattr(result, "__aenter__") and hasattr(result, "__aexit__"): + return result + + # Adapt the async iterator returned above into a proper context + # manager. We have to drive the existing iterator (not call the + # authenticator again) to avoid leaving a half-created generator + # unawaited and to preserve any work it already did (e.g. starting + # a Device Flow session). + @asynccontextmanager + async def _cm() -> AsyncIterator[aiohttp.ClientSession]: + session = await result.__anext__() + try: + yield session + finally: + try: + await result.__anext__() + except StopAsyncIteration: + pass + else: + msg = "authenticator yielded more than one session" + raise RuntimeError(msg) + + return _cm() async def auto_create_skill( # noqa: PLR0913 @@ -1105,7 +1128,7 @@ async def _track(a: SkillCreationArtifacts) -> None: return dataclasses.replace(current, state=SkillCreationState.FAILED, last_error=str(exc)) -async def _execute_pipeline( # noqa: PLR0913 +async def _execute_pipeline( # noqa: PLR0913, PLR0915 *, creator: DialogsSkillCreator, csrf: str, @@ -1202,10 +1225,16 @@ async def _execute_pipeline( # noqa: PLR0913 state = artifacts.state # -- Step 8: publish -- - if state in ( - SkillCreationState.OAUTH_ATTACHED, - SkillCreationState.DEPLOY_REQUESTED, - ): + # Checkpoint state=DEPLOY_REQUESTED *before* calling request_deploy + # so a crash after the call reached Yandex but before we returned + # can skip straight to DONE on retry (Yandex accepts the idempotent + # re-deploy but we'd rather not re-drive the flow end-to-end). + if state == SkillCreationState.OAUTH_ATTACHED: + artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DEPLOY_REQUESTED) + await _maybe_save(progress_cb, artifacts) + state = artifacts.state + + if state == SkillCreationState.DEPLOY_REQUESTED: _LOGGER.info("auto-skill: [5/5] publishing skill") await creator.request_deploy(csrf, skill_id) artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DONE) diff --git a/tests/providers/yandex_smarthome/test_auto_skill.py b/tests/providers/yandex_smarthome/test_auto_skill.py index 4cfd779018..e40e8c2145 100644 --- a/tests/providers/yandex_smarthome/test_auto_skill.py +++ b/tests/providers/yandex_smarthome/test_auto_skill.py @@ -3,9 +3,13 @@ from __future__ import annotations import json -from typing import Any +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock +if TYPE_CHECKING: + from collections.abc import AsyncIterator + import aiohttp import pytest @@ -411,7 +415,7 @@ async def test_deploy_accepts_2xx_variants(self) -> None: class TestListExistingSkills: - """GET /snapshot — returns existing skills or empty on malformed JSON.""" + """GET /snapshot — returns existing skills; raises DialogsApiError on malformed JSON.""" @pytest.mark.asyncio async def test_returns_skill_dicts(self) -> None: @@ -767,6 +771,7 @@ async def _progress(a: SkillCreationArtifacts) -> None: SkillCreationState.DRAFT_UPDATED, SkillCreationState.OAUTH_CREATED, SkillCreationState.OAUTH_ATTACHED, + SkillCreationState.DEPLOY_REQUESTED, SkillCreationState.DONE, ] @@ -808,6 +813,26 @@ async def test_resume_from_oauth_attached(self) -> None: creator.request_deploy.assert_awaited_once() assert result.state == SkillCreationState.DONE + @pytest.mark.asyncio + async def test_resume_from_deploy_requested(self) -> None: + """DEPLOY_REQUESTED is a real checkpoint: resume re-runs publish only.""" + creator = _make_creator_mock() + starting = SkillCreationArtifacts( + state=SkillCreationState.DEPLOY_REQUESTED, + skill_id="s1", + logo_id="l1", + oauth_app_id="o1", + ) + result = await _run_orch(creator=creator, artifacts=starting) + + creator.create_app.assert_not_awaited() + creator.upload_logo.assert_not_awaited() + creator.update_draft.assert_not_awaited() + creator.create_oauth_app.assert_not_awaited() + creator.attach_oauth.assert_not_awaited() + creator.request_deploy.assert_awaited_once() + assert result.state == SkillCreationState.DONE + @pytest.mark.asyncio async def test_resume_from_failed_restarts_pipeline(self) -> None: """FAILED state is treated like NONE — full retry from create_app.""" @@ -916,6 +941,43 @@ async def test_direct_mode_passes_ma_base_to_draft(self) -> None: ) +class TestAuthenticatorInjection: + """Both async-generator and already-decorated async-CM authenticators work. + + Re-wrapping a callable that already returns an async CM with + ``@asynccontextmanager`` breaks at runtime — the orchestrator must + detect that shape and pass it through. + """ + + @pytest.mark.asyncio + async def test_accepts_already_decorated_context_manager(self) -> None: + """Authenticator whose call returns an async CM must not be re-wrapped.""" + session = MagicMock(spec=aiohttp.ClientSession) + creator = _make_creator_mock() + + @asynccontextmanager + async def _cm_auth( + *, mass: Any, session_id: str, timeout: float + ) -> AsyncIterator[aiohttp.ClientSession]: + _ = (mass, session_id, timeout) + yield session + + mass = _mass_with_base_url("https://ma.example.com") + result = await auto_create_skill( + mass=mass, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + skill_name="Music Assistant", + artifacts=SkillCreationArtifacts(), + cloud_instance_id="inst-1", + direct_client_secret="", + logo_bytes=b"\x89PNG", + session_id="test-session-id", + authenticator=_cm_auth, # type: ignore[arg-type] + creator_factory=lambda _s: creator, + ) + assert result.state == SkillCreationState.DONE + + class TestLoadDefaultLogoBytes: """load_default_logo_bytes reads the bundled PNG from disk.""" From 996bb1c81b48d0726e16b9aee3ba605102e737fb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 15:54:56 +0000 Subject: [PATCH 05/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.4.4 --- .../providers/yandex_smarthome/__init__.py | 5 +++ .../providers/yandex_smarthome/auto_skill.py | 6 ++-- .../yandex_smarthome/test_auto_skill.py | 14 ++++++++ .../yandex_smarthome/test_config_actions.py | 35 +++++++++++++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index 936cc64946..aa8bd42b1a 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -16,6 +16,7 @@ from __future__ import annotations +import asyncio import contextlib import dataclasses import logging @@ -215,6 +216,10 @@ async def _run_auto_create_action( logo_bytes=load_default_logo_bytes(), session_id=session_id, ) + except asyncio.CancelledError: + # Preserve cooperative cancellation so config-flow shutdown + # doesn't get converted into a FAILED artifact. + raise except ValueError as exc: # Precondition failures come back here — surface as FAILED. new_artifacts = dataclasses.replace( diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index b2f6f2369a..bdca544abe 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -30,6 +30,7 @@ from __future__ import annotations +import asyncio import dataclasses import json import logging @@ -871,8 +872,6 @@ async def _default_authenticator( Pattern copied from ``ma-provider-yandex-station/provider/auth.py``. """ - import asyncio # noqa: PLC0415 - from aiohttp import web # noqa: PLC0415 from ya_passport_auth import ClientConfig, PassportClient # noqa: PLC0415 from ya_passport_auth.config import DEFAULT_ALLOWED_HOSTS # noqa: PLC0415 @@ -1071,6 +1070,9 @@ async def auto_create_skill( # noqa: PLR0913 developer_name=developer_name, progress_cb=progress_cb, ) + except asyncio.CancelledError: + # Preserve cooperative cancellation — do not absorb into FAILED. + raise except ValueError: raise except Exception as exc: diff --git a/tests/providers/yandex_smarthome/test_auto_skill.py b/tests/providers/yandex_smarthome/test_auto_skill.py index e40e8c2145..a409a0b26b 100644 --- a/tests/providers/yandex_smarthome/test_auto_skill.py +++ b/tests/providers/yandex_smarthome/test_auto_skill.py @@ -911,6 +911,20 @@ async def _boom(_a: SkillCreationArtifacts) -> None: result = await _run_orch(creator=_make_creator_mock(), progress_cb=_boom) assert result.state == SkillCreationState.DONE + @pytest.mark.asyncio + async def test_cancelled_error_propagates(self) -> None: + """CancelledError must propagate, not be converted into a FAILED artifact. + + Swallowing it would break cooperative task cancellation during + HA shutdown or config-flow abort. + """ + import asyncio # noqa: PLC0415 + + creator = _make_creator_mock() + creator.create_app.side_effect = asyncio.CancelledError + with pytest.raises(asyncio.CancelledError): + await _run_orch(creator=creator) + class TestAutoCreateSkillDirectMode: """Direct mode wires the MA webserver URLs into the payloads.""" diff --git a/tests/providers/yandex_smarthome/test_config_actions.py b/tests/providers/yandex_smarthome/test_config_actions.py index 95065604f5..b338176598 100644 --- a/tests/providers/yandex_smarthome/test_config_actions.py +++ b/tests/providers/yandex_smarthome/test_config_actions.py @@ -258,6 +258,41 @@ async def _capture(**kwargs: Any) -> SkillCreationArtifacts: assert captured["session_id"] == "frontend-supplied-id-123" +@pytest.mark.asyncio +async def test_auto_create_cancelled_error_propagates(monkeypatch) -> None: # type: ignore[no-untyped-def] + """CancelledError must not be absorbed into a FAILED artifact. + + Config-flow shutdown and HA stop rely on cooperative cancellation; + converting it into state=FAILED would both leak the error into the + UI and break clean task teardown. + """ + import asyncio # noqa: PLC0415 + + async def _raises_cancelled(**_kwargs: Any) -> SkillCreationArtifacts: + raise asyncio.CancelledError + + monkeypatch.setattr(provider_module, "auto_create_skill", _raises_cancelled) + + mass = _make_mass() + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "X", + CONF_CLOUD_INSTANCE_ID: "inst-1", + } + + with pytest.raises(asyncio.CancelledError): + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id=None, + is_cloud_plus=True, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + ) + + # The FAILED artifact must NOT have been written on cancellation. + assert CONF_AUTO_CREATE_ARTIFACTS not in values + + @pytest.mark.asyncio async def test_auto_create_prefers_saved_direct_secret(monkeypatch) -> None: # type: ignore[no-untyped-def] """SECURE_STRING client secret: saved_config beats empty ``values`` on re-open. From 0d16cd0e26a028fa35238267d3565b00bc0522db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 16:03:48 +0000 Subject: [PATCH 06/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.4.5 --- .../providers/yandex_smarthome/auto_skill_ui.py | 7 +++++++ .../yandex_smarthome/test_auto_skill_ui.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py index bb7671a6cb..735b504128 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py @@ -553,6 +553,13 @@ def _manual_fallback_entries( needing to click Advanced. """ if connection_type == CONNECTION_TYPE_CLOUD_PLUS: + # The cloud_plus Client ID embeds the yaha-cloud instance UUID, + # which doesn't exist until the user registers. Suppress the + # manual block entirely before registration rather than render + # an invalid ``yandex_smart_home:`` Client ID that would lead + # someone to create a broken skill. + if not cloud_instance_id: + return [] backend_uri = CLOUD_SKILL_WEBHOOK_TEMPLATE client_id = CLOUD_SKILL_CLIENT_ID_TEMPLATE.format(instance_id=cloud_instance_id) client_secret = CLOUD_SKILL_CLIENT_SECRET diff --git a/tests/providers/yandex_smarthome/test_auto_skill_ui.py b/tests/providers/yandex_smarthome/test_auto_skill_ui.py index faf34f26a8..d5d4a1a343 100644 --- a/tests/providers/yandex_smarthome/test_auto_skill_ui.py +++ b/tests/providers/yandex_smarthome/test_auto_skill_ui.py @@ -315,6 +315,20 @@ def test_manual_fallback_shown_under_advanced_on_done(self) -> None: backend = _find(list(entries), "manual_backend_url") assert getattr(backend, "advanced", False) is True + def test_manual_fallback_suppressed_before_cloud_register(self) -> None: + """Don't render manual fields with an invalid `yandex_smart_home:` Client ID. + + cloud_plus Client ID embeds the yaha-cloud instance UUID; before + the user clicks Register there's no UUID, and emitting a + half-formed ``yandex_smart_home:`` value would lead power users + (Advanced view) to create a skill with broken account-linking. + """ + entries = self._call(is_registered=False, state=SkillCreationState.NONE) + keys = [e.key for e in entries] + assert "manual_backend_url" not in keys + assert "manual_client_id" not in keys + assert "manual_fallback_label" not in keys + def test_otp_code_appears_when_present(self) -> None: """OTP code field is visible once an OTP has been fetched.""" entries = self._call(is_registered=True, skill_id="s1", otp_code="ABC123") From 6b9fa266febb1b63c19b8cc07a07f47c38219a19 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 May 2026 10:08:14 +0000 Subject: [PATCH 07/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.5.0 --- .../providers/yandex_smarthome/__init__.py | 33 +++- .../providers/yandex_smarthome/constants.py | 5 + .../providers/yandex_smarthome/device.py | 163 ++++++++++++---- .../providers/yandex_smarthome/direct.py | 17 +- .../providers/yandex_smarthome/handlers.py | 16 +- .../providers/yandex_smarthome/notifier.py | 6 +- .../providers/yandex_smarthome/playlists.py | 55 ++++++ .../providers/yandex_smarthome/plugin.py | 33 +++- .../providers/yandex_smarthome/test_device.py | 174 ++++++++++++++++++ .../yandex_smarthome/test_handlers.py | 25 ++- 10 files changed, 481 insertions(+), 46 deletions(-) create mode 100644 music_assistant/providers/yandex_smarthome/playlists.py diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index aa8bd42b1a..0dab2df582 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -52,13 +52,16 @@ CONF_DIRECT_ACCESS_TOKEN, CONF_DIRECT_CLIENT_SECRET, CONF_EXPOSED_PLAYERS, + CONF_EXPOSED_PLAYLISTS, CONF_INSTANCE_NAME, CONF_SKILL_ID, CONF_SKILL_TOKEN, CONNECTION_TYPE_CLOUD, CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT, + MAX_INPUT_SOURCES, ) +from .playlists import fetch_playlist_options from .plugin import YandexSmartHomePlugin if TYPE_CHECKING: @@ -291,6 +294,12 @@ async def get_config_entries( except Exception: # noqa: S110 pass + # Build playlist options from MA library (any music provider). Fail-soft: empty + # list if music controller isn't ready (e.g. provider load order at first run). + playlist_options: list[ConfigValueOption] = [] + with contextlib.suppress(Exception): + playlist_options = await fetch_playlist_options(mass) + entries: list[ConfigEntry] = [ # Instance name ConfigEntry( @@ -387,7 +396,7 @@ async def get_config_entries( # duplicate hidden round-trip entry here. # -- Tail: player filter + hidden round-trip fields (all modes) -- - entries.extend(_common_tail_entries(player_options, values)) + entries.extend(_common_tail_entries(player_options, playlist_options, values)) return tuple(entries) @@ -456,7 +465,9 @@ def _cloud_mode_entries( def _common_tail_entries( - player_options: list[ConfigValueOption], values: dict[str, ConfigValueType] + player_options: list[ConfigValueOption], + playlist_options: list[ConfigValueOption], + values: dict[str, ConfigValueType], ) -> list[ConfigEntry]: """Player filter + hidden round-trip fields shared by every mode.""" return [ @@ -473,6 +484,24 @@ def _common_tail_entries( default_value=[], options=list(player_options) if player_options else [], ), + ConfigEntry( + key=CONF_EXPOSED_PLAYLISTS, + type=ConfigEntryType.STRING, + label=f"Exposed Playlists (max {MAX_INPUT_SOURCES})", + description=( + "Pick up to " + f"{MAX_INPUT_SOURCES} playlists from your MA library — they appear " + "as input_source modes on every exposed player. After saving, " + "open the device in the Yandex app and assign voice aliases " + '(e.g. "Rock" for mode «one») so Alice can pick playlists by name. ' + "If the list is empty, save the form and reopen it once your music " + "providers have finished loading their library." + ), + required=False, + multi_value=True, + default_value=[], + options=list(playlist_options) if playlist_options else [], + ), ConfigEntry( key=CONF_CLOUD_INSTANCE_ID, type=ConfigEntryType.STRING, diff --git a/music_assistant/providers/yandex_smarthome/constants.py b/music_assistant/providers/yandex_smarthome/constants.py index 9df04d3ea8..2a490797e4 100644 --- a/music_assistant/providers/yandex_smarthome/constants.py +++ b/music_assistant/providers/yandex_smarthome/constants.py @@ -13,6 +13,7 @@ CONF_SKILL_ID = "skill_id" CONF_SKILL_TOKEN = "skill_token" CONF_EXPOSED_PLAYERS = "exposed_players" +CONF_EXPOSED_PLAYLISTS = "exposed_playlists" # Auto-create-skill feature state (round-trips through the config form) CONF_AUTO_CREATE_ARTIFACTS = "auto_create_artifacts" @@ -117,6 +118,10 @@ "ten", ) +# Combined cap for native sources + playlist sources in mode(input_source). +# Yandex allows max 10 modes per capability. +MAX_INPUT_SOURCES = len(YANDEX_MODE_VALUES) + # --------------------------------------------------------------------------- # Yandex Smart Home API — response codes # --------------------------------------------------------------------------- diff --git a/music_assistant/providers/yandex_smarthome/device.py b/music_assistant/providers/yandex_smarthome/device.py index 74340f7183..02492b7f17 100644 --- a/music_assistant/providers/yandex_smarthome/device.py +++ b/music_assistant/providers/yandex_smarthome/device.py @@ -22,10 +22,12 @@ INSTANCE_ON, INSTANCE_PAUSE, INSTANCE_VOLUME, + MAX_INPUT_SOURCES, UNIT_PERCENT, YANDEX_DEVICE_TYPE_MEDIA, YANDEX_MODE_VALUES, ) +from .playlists import play_playlist from .schema import ( ActionResult, CapabilityAction, @@ -90,6 +92,44 @@ def _build_source_modes(source_list: list[PlayerSource]) -> list[ModeValue]: return [ModeValue(value=YANDEX_MODE_VALUES[i]) for i in range(min(len(source_list), 10))] +def _combined_size( + source_list: list[PlayerSource], playlist_uris: tuple[str, ...] | list[str] +) -> int: + """Total slot count for mode(input_source): native first, playlists fill rest.""" + return min(len(source_list) + len(playlist_uris), MAX_INPUT_SOURCES) + + +def _build_combined_modes( + source_list: list[PlayerSource], playlist_uris: tuple[str, ...] | list[str] +) -> list[ModeValue]: + """Build mode values covering both native sources and playlist slots.""" + size = _combined_size(source_list, playlist_uris) + return [ModeValue(value=YANDEX_MODE_VALUES[i]) for i in range(size)] + + +def _resolve_combined_slot( + index: int, + source_list: list[PlayerSource], + playlist_uris: tuple[str, ...] | list[str], +) -> tuple[str, str] | None: + """Resolve a 0-based slot index to ("native"|"playlist", value). + + Returns None if the slot is out of range. The native source slots come + first; playlist URIs fill the remainder up to MAX_INPUT_SOURCES. + """ + native_count = len(source_list) + if index < 0: + return None + if index < native_count: + return ("native", source_list[index].id) + playlist_idx = index - native_count + if playlist_idx >= len(playlist_uris): + return None + if native_count + playlist_idx >= MAX_INPUT_SOURCES: + return None + return ("playlist", playlist_uris[playlist_idx]) + + def _source_to_mode(active_source: str | None, source_list: list[PlayerSource]) -> str | None: """Map active MA source name/id to a Yandex mode value.""" if not active_source or not source_list: @@ -105,7 +145,7 @@ def _source_to_mode(active_source: str | None, source_list: list[PlayerSource]) def _mode_to_source(mode_value: str, source_list: list[PlayerSource]) -> str | None: """Resolve a Yandex mode value to an MA source id.""" try: - idx = list(YANDEX_MODE_VALUES).index(mode_value) + idx = YANDEX_MODE_VALUES.index(mode_value) except ValueError: return None if idx >= len(source_list): @@ -147,7 +187,11 @@ def normalize_device_name(name: str) -> str: return result or name -def get_device_description(player: Player) -> DeviceDescription: +def get_device_description( + player: Player, + *, + playlist_uris: tuple[str, ...] | list[str] = (), +) -> DeviceDescription: """Build a Yandex Smart Home device description from an MA player.""" capabilities = [ CapabilityDescription(type=YandexCapabilityType.ON_OFF), @@ -178,20 +222,30 @@ def get_device_description(player: Player) -> DeviceDescription: ) ) - # mode(input_source) — only if player has sources + # mode(input_source): register when native sources or playlists exist. + # Native sources occupy first slots; playlists fill remainder up to MAX_INPUT_SOURCES. source_list = _get_source_list(player) - if source_list: - modes = _build_source_modes(source_list) - if modes: - capabilities.append( - CapabilityDescription( - type=YandexCapabilityType.MODE, - parameters=CapabilityParameters( - instance=INSTANCE_INPUT_SOURCE, - modes=modes, - ), - ) + if len(source_list) >= MAX_INPUT_SOURCES and playlist_uris: + # Debug-level: this fires on every /user/devices poll for an + # affected player, but it documents a config decision rather than + # a runtime fault — promoting it to warn would spam production logs. + _LOGGER.debug( + "Player %s has %d native sources (>= cap %d); playlist sources ignored", + player.player_id, + len(source_list), + MAX_INPUT_SOURCES, + ) + modes = _build_combined_modes(source_list, playlist_uris) + if modes: + capabilities.append( + CapabilityDescription( + type=YandexCapabilityType.MODE, + parameters=CapabilityParameters( + instance=INSTANCE_INPUT_SOURCE, + modes=modes, + ), ) + ) model = "MA Player" if hasattr(player, "device_info") and player.device_info: @@ -206,7 +260,11 @@ def get_device_description(player: Player) -> DeviceDescription: ) -def get_device_state(player: Player) -> DeviceState: +def get_device_state( + player: Player, + *, + playlist_uris: tuple[str, ...] | list[str] = (), +) -> DeviceState: """Read current MA player state and convert to Yandex capability states.""" # on = player is powered on (or available if power state unknown) powered = getattr(player, "powered", None) @@ -256,9 +314,11 @@ def get_device_state(player: Player) -> DeviceState: ) ) - # input_source state — only if player has sources + # input_source state — only reported when active MA source matches a native slot. + # Playlist slots have no reliable "active" signal, so they leave state unset + # (Yandex tolerates an unreported state on mode capabilities). source_list = _get_source_list(player) - if source_list: + if source_list or playlist_uris: active = getattr(player, "active_source", None) mode_value = _source_to_mode(active, source_list) if mode_value: @@ -273,7 +333,13 @@ def get_device_state(player: Player) -> DeviceState: async def _execute_input_source( - mass: Any, player_id: str, player: Player | None, instance: str, value: Any + mass: Any, + player_id: str, + player: Player | None, + instance: str, + value: Any, + *, + playlist_uris: tuple[str, ...] | list[str] = (), ) -> CapabilityActionResult | None: """Handle input_source mode action. Returns error result or None on success.""" if player is None: @@ -288,23 +354,50 @@ async def _execute_input_source( ), ), ) + + try: + slot_index = YANDEX_MODE_VALUES.index(str(value)) + except ValueError: + return CapabilityActionResult( + type=YandexCapabilityType.MODE, + state=CapabilityActionResultState( + instance=instance, + action_result=ActionResult( + status="ERROR", + error_code=ERROR_INVALID_ACTION, + error_message=f"Unknown source mode: {value}", + ), + ), + ) + p_state = player.state if hasattr(player, "state") else player source_list = _get_source_list(p_state) - source = _mode_to_source(str(value), source_list) - if source: - await mass.players.select_source(player_id, source) - return None - return CapabilityActionResult( - type=YandexCapabilityType.MODE, - state=CapabilityActionResultState( - instance=instance, - action_result=ActionResult( - status="ERROR", - error_code=ERROR_INVALID_ACTION, - error_message=f"Unknown source mode: {value}", + resolved = _resolve_combined_slot(slot_index, source_list, playlist_uris) + if resolved is None: + return CapabilityActionResult( + type=YandexCapabilityType.MODE, + state=CapabilityActionResultState( + instance=instance, + action_result=ActionResult( + status="ERROR", + error_code=ERROR_INVALID_ACTION, + error_message=f"No source configured for mode: {value}", + ), ), - ), - ) + ) + + kind, target = resolved + if kind == "native": + await mass.players.select_source(player_id, target) + return None + + # Playlist slot: power on if needed, then start playback via player_queues.play_media. + if _has_feature(p_state, "power"): + powered = getattr(p_state, "powered", None) + if powered is False: + await mass.players.cmd_power(player_id, True) + await play_playlist(mass, player_id, target) + return None def _invalid_bool_result(cap_type: str, instance: str, value: Any) -> CapabilityActionResult: @@ -346,6 +439,8 @@ async def execute_capability_action( # noqa: PLR0915 player_id: str, action: CapabilityAction, current_volume: int = 0, + *, + playlist_uris: tuple[str, ...] | list[str] = (), ) -> CapabilityActionResult: """Execute a Yandex capability action by calling the corresponding MA player command. @@ -430,7 +525,9 @@ async def execute_capability_action( # noqa: PLR0915 # Non-relative channel set is ignored (no concept of channel number in MA) elif action.type == YandexCapabilityType.MODE and instance == INSTANCE_INPUT_SOURCE: - result = await _execute_input_source(mass, player_id, player, instance, value) + result = await _execute_input_source( + mass, player_id, player, instance, value, playlist_uris=playlist_uris + ) if result: return result diff --git a/music_assistant/providers/yandex_smarthome/direct.py b/music_assistant/providers/yandex_smarthome/direct.py index 1d0025ec3b..a7cc951695 100644 --- a/music_assistant/providers/yandex_smarthome/direct.py +++ b/music_assistant/providers/yandex_smarthome/direct.py @@ -99,6 +99,7 @@ def __init__( exposed_ids: set[str] | None = None, logger: logging.Logger | None = None, on_token_created: Callable[[str], None] | None = None, + playlist_uris: tuple[str, ...] | list[str] = (), ) -> None: """Initialize the handler. @@ -117,6 +118,7 @@ def __init__( self._access_token = access_token self._client_secret = client_secret self._exposed_ids = exposed_ids + self._playlist_uris = tuple(playlist_uris) self._logger = logger or _LOGGER self._on_token_created = on_token_created self._unregister_callbacks: list[Callable[[], None]] = [] @@ -219,7 +221,10 @@ async def _handle_devices(self, request: web.Request) -> web.Response: try: device_list = await handle_device_list( - self._mass, self._user_id, exposed_ids=self._exposed_ids + self._mass, + self._user_id, + exposed_ids=self._exposed_ids, + playlist_uris=self._playlist_uris, ) return web.json_response(build_response(request_id, asdict(device_list))) except Exception: @@ -247,7 +252,10 @@ async def _handle_query(self, request: web.Request) -> web.Response: device_id for d in devices_raw if isinstance(d, dict) and (device_id := d.get("id")) ] states = await handle_devices_query( - self._mass, device_ids, exposed_ids=self._exposed_ids + self._mass, + device_ids, + exposed_ids=self._exposed_ids, + playlist_uris=self._playlist_uris, ) return web.json_response(build_response(request_id, asdict(states))) except Exception: @@ -268,7 +276,10 @@ async def _handle_action(self, request: web.Request) -> web.Response: try: action_payload = parse_action_payload(body) result = await handle_devices_action( - self._mass, action_payload, exposed_ids=self._exposed_ids + self._mass, + action_payload, + exposed_ids=self._exposed_ids, + playlist_uris=self._playlist_uris, ) return web.json_response(build_response(request_id, asdict(result))) except Exception: diff --git a/music_assistant/providers/yandex_smarthome/handlers.py b/music_assistant/providers/yandex_smarthome/handlers.py index 2607e188f2..3fb2e2c75d 100644 --- a/music_assistant/providers/yandex_smarthome/handlers.py +++ b/music_assistant/providers/yandex_smarthome/handlers.py @@ -43,6 +43,8 @@ async def handle_device_list( mass: MusicAssistant, user_id: str, exposed_ids: set[str] | None = None, + *, + playlist_uris: tuple[str, ...] | list[str] = (), ) -> DeviceListPayload: """Handle /user/devices — return list of all MA players as Yandex devices.""" devices = [] @@ -50,7 +52,7 @@ async def handle_device_list( state = player.state if not is_player_exposable(state, exposed_ids=exposed_ids): continue - devices.append(get_device_description(state)) + devices.append(get_device_description(state, playlist_uris=playlist_uris)) _LOGGER.debug("Device list: %d devices exposed", len(devices)) return DeviceListPayload(user_id=user_id, devices=devices) @@ -59,6 +61,8 @@ async def handle_devices_query( mass: MusicAssistant, device_ids: list[str], exposed_ids: set[str] | None = None, + *, + playlist_uris: tuple[str, ...] | list[str] = (), ) -> DeviceStatesPayload: """Handle /user/devices/query — return current states for requested devices.""" states: list[DeviceState] = [] @@ -77,7 +81,7 @@ async def handle_devices_query( states.append(make_error_device_state(device_id)) continue - states.append(get_device_state(player_state)) # type: ignore[arg-type] + states.append(get_device_state(player_state, playlist_uris=playlist_uris)) # type: ignore[arg-type] return DeviceStatesPayload(devices=states) @@ -86,6 +90,8 @@ async def handle_devices_action( mass: MusicAssistant, payload: ActionRequestPayload, exposed_ids: set[str] | None = None, + *, + playlist_uris: tuple[str, ...] | list[str] = (), ) -> ActionResultPayload: """Handle /user/devices/action — execute capability actions on devices.""" results: list[DeviceActionResult] = [] @@ -125,7 +131,11 @@ async def handle_devices_action( cap_results = [] for cap_action in device_action.capabilities: result = await execute_capability_action( - mass, device_action.id, cap_action, current_volume + mass, + device_action.id, + cap_action, + current_volume, + playlist_uris=playlist_uris, ) cap_results.append(result) diff --git a/music_assistant/providers/yandex_smarthome/notifier.py b/music_assistant/providers/yandex_smarthome/notifier.py index 3a2a3aad6e..453566cf79 100644 --- a/music_assistant/providers/yandex_smarthome/notifier.py +++ b/music_assistant/providers/yandex_smarthome/notifier.py @@ -49,6 +49,7 @@ def __init__( auth_header: dict[str, str], logger: logging.Logger | None = None, exposed_ids: set[str] | None = None, + playlist_uris: tuple[str, ...] | list[str] = (), ) -> None: """Initialize state notifier.""" self._mass = mass @@ -58,6 +59,7 @@ def __init__( self._auth_header = auth_header self._logger = logger or _LOGGER self._exposed_ids = exposed_ids + self._playlist_uris = tuple(playlist_uris) self._dirty_player_ids: set[str] = set() self._flush_handle: asyncio.TimerHandle | None = None @@ -166,7 +168,7 @@ async def _flush_pending(self) -> None: continue state = player.state if is_player_exposable(state, exposed_ids=self._exposed_ids): - devices.append(get_device_state(state)) + devices.append(get_device_state(state, playlist_uris=self._playlist_uris)) if not devices: return @@ -210,7 +212,7 @@ async def _report_all_states(self) -> None: for player in self._mass.players.all_players(): state = player.state if is_player_exposable(state, exposed_ids=self._exposed_ids): - devices.append(get_device_state(state)) + devices.append(get_device_state(state, playlist_uris=self._playlist_uris)) if devices: self._logger.info("Reporting all states: %d device(s)", len(devices)) await self._send_state_callback(devices) diff --git a/music_assistant/providers/yandex_smarthome/playlists.py b/music_assistant/providers/yandex_smarthome/playlists.py new file mode 100644 index 0000000000..64f10a30a0 --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/playlists.py @@ -0,0 +1,55 @@ +"""Helpers for exposing MA library playlists as Yandex input_source modes. + +Wraps the few MA APIs used by the playlist-source feature so the rest of +the provider stays decoupled from `mass.music`/`player_queues` internals +and so tests can stub a single seam. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigValueOption + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +_LOGGER = logging.getLogger(__name__) + +_LIBRARY_LIMIT = 500 + + +async def fetch_playlist_options(mass: MusicAssistant) -> list[ConfigValueOption]: + """Build ConfigValueOption list of all library playlists for the config form. + + Used at config-render time only. Fail-soft: returns [] if mass.music or + the playlists controller is not yet available (e.g. provider load order). + """ + try: + playlists = await mass.music.playlists.library_items(limit=_LIBRARY_LIMIT) + except Exception as exc: + # Fail-soft: this runs on every config-form render and races with + # provider/database startup. Don't spam stack traces — debug-level + # is enough for diagnostics, normal renders stay quiet. + _LOGGER.debug("Library playlists not available yet: %s", exc) + return [] + + options: list[ConfigValueOption] = [] + for playlist in playlists: + if not playlist.uri: + continue + provider_label = playlist.provider or "" + title = f"{playlist.name} ({provider_label})" if provider_label else playlist.name + options.append(ConfigValueOption(title=title, value=playlist.uri)) + return options + + +async def play_playlist(mass: MusicAssistant, player_id: str, uri: str) -> None: + """Start playback of a playlist URI on the given player's queue. + + `play_media` accepts a URI string directly and resolves the playlist's + tracks into the queue. queue_id == player_id for the player's own queue. + """ + await mass.player_queues.play_media(queue_id=player_id, media=uri) diff --git a/music_assistant/providers/yandex_smarthome/plugin.py b/music_assistant/providers/yandex_smarthome/plugin.py index 335e3813e0..9ffddd55d1 100644 --- a/music_assistant/providers/yandex_smarthome/plugin.py +++ b/music_assistant/providers/yandex_smarthome/plugin.py @@ -34,12 +34,14 @@ CONF_DIRECT_ACCESS_TOKEN, CONF_DIRECT_CLIENT_SECRET, CONF_EXPOSED_PLAYERS, + CONF_EXPOSED_PLAYLISTS, CONF_INSTANCE_NAME, CONF_SKILL_ID, CONF_SKILL_TOKEN, CONNECTION_TYPE_CLOUD, CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT, + MAX_INPUT_SOURCES, YANDEX_DIALOGS_CALLBACK_BASE, ) from .direct import DirectConnectionHandler @@ -102,6 +104,23 @@ async def handle_async_init(self) -> None: exposed_raw = [] self._exposed_ids: set[str] | None = set(exposed_raw) if exposed_raw else None + # Parse exposed playlists (URIs) — capped at MAX_INPUT_SOURCES. + playlists_raw = self.config.get_value(CONF_EXPOSED_PLAYLISTS) or [] + if isinstance(playlists_raw, str): + playlists_raw = [x.strip() for x in playlists_raw.split(",") if x.strip()] + elif isinstance(playlists_raw, list): + playlists_raw = [str(x) for x in playlists_raw if x] + else: + playlists_raw = [] + if len(playlists_raw) > MAX_INPUT_SOURCES: + self.logger.warning( + "Exposed playlists count (%d) exceeds cap %d; truncating", + len(playlists_raw), + MAX_INPUT_SOURCES, + ) + playlists_raw = playlists_raw[:MAX_INPUT_SOURCES] + self._exposed_playlists: tuple[str, ...] = tuple(playlists_raw) + self.logger.info( "Yandex Smart Home plugin init (mode=%s, name=%s)", self._connection_type, @@ -182,6 +201,7 @@ async def _start_cloud_mode(self) -> None: auth_header=auth_header, logger=self.logger, exposed_ids=self._exposed_ids, + playlist_uris=self._exposed_playlists, ) await self._state_notifier.start() @@ -213,6 +233,7 @@ def _on_token_created(token: str) -> None: exposed_ids=self._exposed_ids, logger=self.logger, on_token_created=_on_token_created, + playlist_uris=self._exposed_playlists, ) self._direct_handler.register_routes() @@ -229,6 +250,7 @@ def _on_token_created(token: str) -> None: auth_header=auth_header, logger=self.logger, exposed_ids=self._exposed_ids, + playlist_uris=self._exposed_playlists, ) await self._state_notifier.start() @@ -255,6 +277,7 @@ async def _handle_cloud_request(self, request: CloudRequest) -> dict[str, Any]: self.mass, self._user_id, exposed_ids=self._exposed_ids, + playlist_uris=self._exposed_playlists, ) return build_response(request_id, asdict(device_list)) @@ -265,14 +288,20 @@ async def _handle_cloud_request(self, request: CloudRequest) -> dict[str, Any]: if isinstance(d, dict) and (device_id := d.get("id")) ] states = await handle_devices_query( - self.mass, device_ids, exposed_ids=self._exposed_ids + self.mass, + device_ids, + exposed_ids=self._exposed_ids, + playlist_uris=self._exposed_playlists, ) return build_response(request_id, asdict(states)) if normalized == "/user/devices/action": action_payload = parse_action_payload(message) action_result = await handle_devices_action( - self.mass, action_payload, exposed_ids=self._exposed_ids + self.mass, + action_payload, + exposed_ids=self._exposed_ids, + playlist_uris=self._exposed_playlists, ) return build_response(request_id, asdict(action_result)) diff --git a/tests/providers/yandex_smarthome/test_device.py b/tests/providers/yandex_smarthome/test_device.py index f373d8b28f..e553b6dfc0 100644 --- a/tests/providers/yandex_smarthome/test_device.py +++ b/tests/providers/yandex_smarthome/test_device.py @@ -95,11 +95,20 @@ def get_player(self, player_id: str) -> MockPlayer | None: return self._players.get(player_id) +class MockPlayerQueues: + """Mock of mass.player_queues controller.""" + + def __init__(self) -> None: + """Initialize mock player_queues controller.""" + self.play_media = AsyncMock() + + @dataclass class MockMass: """Mock MusicAssistant for testing.""" players: MockPlayers = field(default_factory=MockPlayers) + player_queues: MockPlayerQueues = field(default_factory=MockPlayerQueues) # --------------------------------------------------------------------------- @@ -836,6 +845,171 @@ async def test_unknown_source_mode_returns_error(self) -> None: assert result.state.action_result.error_code == "INVALID_ACTION" +# --------------------------------------------------------------------------- +# Tests: input_source playlist sources (mode/input_source backed by playlists) +# --------------------------------------------------------------------------- + + +class TestPlaylistInputSources: + """Tests for playlist-backed input_source modes.""" + + def test_playlists_only_register_mode_cap(self) -> None: + """Player with no native sources but configured playlists gets mode cap.""" + player = MockPlayer(source_list=[]) + playlist_uris = ["library://playlist/1", "library://playlist/2", "library://playlist/3"] + desc = get_device_description(player, playlist_uris=playlist_uris) # type: ignore[arg-type] + mode_caps = [c for c in desc.capabilities if c.type == YandexCapabilityType.MODE] + assert len(mode_caps) == 1 + modes = mode_caps[0].parameters.modes # type: ignore[union-attr] + assert modes is not None + assert [m.value for m in modes] == ["one", "two", "three"] + + def test_native_then_playlists_capped_at_10(self) -> None: + """5 native sources + 7 playlists → 10 combined modes, native first.""" + sources = [MockPlayerSource(id=f"s{i}", name=f"Source {i}") for i in range(5)] + playlist_uris = [f"library://playlist/{i}" for i in range(7)] + player = MockPlayer(source_list=sources, supported_features={"select_source"}) + desc = get_device_description(player, playlist_uris=playlist_uris) # type: ignore[arg-type] + mode_caps = [c for c in desc.capabilities if c.type == YandexCapabilityType.MODE] + assert len(mode_caps) == 1 + assert len(mode_caps[0].parameters.modes) == 10 # type: ignore[arg-type,union-attr] + + def test_native_full_ignores_playlists(self) -> None: + """If native sources already fill all 10 slots, playlists are ignored.""" + sources = [MockPlayerSource(id=f"s{i}", name=f"Source {i}") for i in range(10)] + playlist_uris = ["library://playlist/extra"] + player = MockPlayer(source_list=sources, supported_features={"select_source"}) + desc = get_device_description(player, playlist_uris=playlist_uris) # type: ignore[arg-type] + mode_caps = [c for c in desc.capabilities if c.type == YandexCapabilityType.MODE] + assert len(mode_caps) == 1 + assert len(mode_caps[0].parameters.modes) == 10 # type: ignore[arg-type,union-attr] + + def test_state_with_native_active_in_combined_mode(self) -> None: + """Native active source still reports correct state when playlists also configured.""" + sources = [ + MockPlayerSource(id="hdmi1", name="HDMI 1"), + MockPlayerSource(id="optical", name="Optical"), + ] + player = MockPlayer( + source_list=sources, + active_source="Optical", + playback_state=PlaybackState.PLAYING, + supported_features={"select_source"}, + ) + state = get_device_state( + player, + playlist_uris=["library://playlist/x"], # type: ignore[arg-type] + ) + mode_states = [c for c in state.capabilities if c.state.instance == INSTANCE_INPUT_SOURCE] + assert len(mode_states) == 1 + assert mode_states[0].state.value == "two" + + @pytest.mark.asyncio + async def test_native_action_routes_to_select_source(self) -> None: + """Native slot value resolves to mass.players.select_source, not play_media.""" + sources = [MockPlayerSource(id="hdmi1", name="HDMI 1")] + player = MockPlayer( + player_id="p1", source_list=sources, supported_features={"select_source"} + ) + mass = MockMass() + mass.players._players["p1"] = player + + action = CapabilityAction( + type=YandexCapabilityType.MODE, + state=CapabilityActionState(instance="input_source", value="one"), + ) + result = await execute_capability_action( + mass, "p1", action, playlist_uris=["library://playlist/x"] + ) + mass.players.select_source.assert_awaited_once_with("p1", "hdmi1") + mass.player_queues.play_media.assert_not_awaited() + assert result.state.action_result.status == "DONE" + + @pytest.mark.asyncio + async def test_playlist_action_calls_play_media(self) -> None: + """Playlist slot triggers player_queues.play_media with URI.""" + player = MockPlayer(player_id="p1", source_list=[], powered=True) + mass = MockMass() + mass.players._players["p1"] = player + uris = ["library://playlist/jazz", "library://playlist/rock"] + + action = CapabilityAction( + type=YandexCapabilityType.MODE, + state=CapabilityActionState(instance="input_source", value="two"), + ) + result = await execute_capability_action(mass, "p1", action, playlist_uris=uris) + mass.player_queues.play_media.assert_awaited_once_with( + queue_id="p1", media="library://playlist/rock" + ) + mass.players.select_source.assert_not_awaited() + assert result.state.action_result.status == "DONE" + + @pytest.mark.asyncio + async def test_playlist_action_powers_on_if_off(self) -> None: + """Player with power feature off should be powered on before playback.""" + player = MockPlayer( + player_id="p1", + source_list=[], + powered=False, + supported_features={"power"}, + ) + mass = MockMass() + mass.players._players["p1"] = player + + action = CapabilityAction( + type=YandexCapabilityType.MODE, + state=CapabilityActionState(instance="input_source", value="one"), + ) + result = await execute_capability_action( + mass, "p1", action, playlist_uris=["library://playlist/jazz"] + ) + mass.players.cmd_power.assert_awaited_once_with("p1", True) + mass.player_queues.play_media.assert_awaited_once_with( + queue_id="p1", media="library://playlist/jazz" + ) + assert result.state.action_result.status == "DONE" + + @pytest.mark.asyncio + async def test_playlist_action_skips_power_when_already_on(self) -> None: + """Powered player skips cmd_power but still plays media.""" + player = MockPlayer( + player_id="p1", + source_list=[], + powered=True, + supported_features={"power"}, + ) + mass = MockMass() + mass.players._players["p1"] = player + + action = CapabilityAction( + type=YandexCapabilityType.MODE, + state=CapabilityActionState(instance="input_source", value="one"), + ) + await execute_capability_action( + mass, "p1", action, playlist_uris=["library://playlist/jazz"] + ) + mass.players.cmd_power.assert_not_awaited() + mass.player_queues.play_media.assert_awaited_once() + + @pytest.mark.asyncio + async def test_slot_past_combined_returns_error(self) -> None: + """Mode value beyond combined native+playlists → INVALID_ACTION.""" + player = MockPlayer(player_id="p1", source_list=[]) + mass = MockMass() + mass.players._players["p1"] = player + + action = CapabilityAction( + type=YandexCapabilityType.MODE, + state=CapabilityActionState(instance="input_source", value="three"), + ) + result = await execute_capability_action( + mass, "p1", action, playlist_uris=["library://playlist/jazz"] + ) + mass.player_queues.play_media.assert_not_awaited() + assert result.state.action_result.status == "ERROR" + assert result.state.action_result.error_code == "INVALID_ACTION" + + # --------------------------------------------------------------------------- # Tests: player filter (exposed_ids) # --------------------------------------------------------------------------- diff --git a/tests/providers/yandex_smarthome/test_handlers.py b/tests/providers/yandex_smarthome/test_handlers.py index 3f547d2c19..6531bea01b 100644 --- a/tests/providers/yandex_smarthome/test_handlers.py +++ b/tests/providers/yandex_smarthome/test_handlers.py @@ -19,7 +19,10 @@ handle_user_unlink, parse_action_payload, ) -from music_assistant.providers.yandex_smarthome.schema import DeviceListPayload +from music_assistant.providers.yandex_smarthome.schema import ( + DeviceListPayload, + YandexCapabilityType, +) @dataclass @@ -61,6 +64,7 @@ def _make_mass(players: list[MockPlayer]) -> MagicMock: mass.players.cmd_power = AsyncMock() mass.players.cmd_volume_set = AsyncMock() mass.players.cmd_volume_mute = AsyncMock() + mass.player_queues.play_media = AsyncMock() return mass @@ -117,6 +121,25 @@ async def test_filters_synced(self) -> None: assert len(result.devices) == 1 assert result.devices[0].id == "leader" + @pytest.mark.asyncio + async def test_playlist_uris_register_mode_capability(self) -> None: + """Players without native sources get mode(input_source) when playlists are configured.""" + players = [MockPlayer(player_id="p1")] + mass = _make_mass(players) + result = await handle_device_list( + mass, + "user1", + playlist_uris=("library://playlist/a", "library://playlist/b"), + ) + assert len(result.devices) == 1 + mode_caps = [ + c for c in result.devices[0].capabilities if c.type == YandexCapabilityType.MODE + ] + assert len(mode_caps) == 1 + modes = mode_caps[0].parameters.modes + assert modes is not None + assert [m.value for m in modes] == ["one", "two"] + @pytest.mark.asyncio async def test_filters_by_exposed_ids(self) -> None: """Test filters by exposed ids.""" From 1941751d0f10630fb04fb8c543d46fd8c6182680 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 May 2026 10:10:35 +0000 Subject: [PATCH 08/56] feat(yandex_smarthome): add yandex_smarthome provider v1.5.0 --- .../providers/yandex_smarthome/playlists.py | 4 +- .../providers/yandex_smarthome/test_device.py | 3 +- .../providers/yandex_smarthome/test_direct.py | 43 ++++--------------- 3 files changed, 13 insertions(+), 37 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/playlists.py b/music_assistant/providers/yandex_smarthome/playlists.py index 64f10a30a0..fb368a369e 100644 --- a/music_assistant/providers/yandex_smarthome/playlists.py +++ b/music_assistant/providers/yandex_smarthome/playlists.py @@ -41,7 +41,9 @@ async def fetch_playlist_options(mass: MusicAssistant) -> list[ConfigValueOption if not playlist.uri: continue provider_label = playlist.provider or "" - title = f"{playlist.name} ({provider_label})" if provider_label else playlist.name + title = ( + f"{playlist.name} ({provider_label})" if provider_label else playlist.name + ) options.append(ConfigValueOption(title=title, value=playlist.uri)) return options diff --git a/tests/providers/yandex_smarthome/test_device.py b/tests/providers/yandex_smarthome/test_device.py index e553b6dfc0..7427b55b42 100644 --- a/tests/providers/yandex_smarthome/test_device.py +++ b/tests/providers/yandex_smarthome/test_device.py @@ -897,8 +897,7 @@ def test_state_with_native_active_in_combined_mode(self) -> None: supported_features={"select_source"}, ) state = get_device_state( - player, - playlist_uris=["library://playlist/x"], # type: ignore[arg-type] + player, playlist_uris=["library://playlist/x"] # type: ignore[arg-type] ) mode_states = [c for c in state.capabilities if c.state.instance == INSTANCE_INPUT_SOURCE] assert len(mode_states) == 1 diff --git a/tests/providers/yandex_smarthome/test_direct.py b/tests/providers/yandex_smarthome/test_direct.py index 7323b68e91..0a4c634295 100644 --- a/tests/providers/yandex_smarthome/test_direct.py +++ b/tests/providers/yandex_smarthome/test_direct.py @@ -309,13 +309,8 @@ async def test_devices_success(handler: DirectConnectionHandler) -> None: ) with ( mock_hdl, - patch( - "music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []} - ), - patch( - "music_assistant.providers.yandex_smarthome.direct.build_response", - return_value=resp_payload, - ), + patch("music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []}), + patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), ): resp = await handler._handle_devices(req) assert resp.status == 200 @@ -340,13 +335,8 @@ async def test_query_success(handler: DirectConnectionHandler) -> None: ) with ( mock_query, - patch( - "music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []} - ), - patch( - "music_assistant.providers.yandex_smarthome.direct.build_response", - return_value=resp_payload, - ), + patch("music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []}), + patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), ): resp = await handler._handle_query(req) assert resp.status == 200 @@ -367,18 +357,10 @@ async def test_action_success(handler: DirectConnectionHandler) -> None: return_value=MagicMock(), ) with ( - patch( - "music_assistant.providers.yandex_smarthome.direct.parse_action_payload", - return_value=MagicMock(), - ), + patch("music_assistant.providers.yandex_smarthome.direct.parse_action_payload", return_value=MagicMock()), mock_action, - patch( - "music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []} - ), - patch( - "music_assistant.providers.yandex_smarthome.direct.build_response", - return_value=resp_payload, - ), + patch("music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []}), + patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), ): resp = await handler._handle_action(req) assert resp.status == 200 @@ -393,15 +375,8 @@ async def test_unlink_success(handler: DirectConnectionHandler) -> None: ) resp_payload = {"request_id": "req-4"} with ( - patch( - "music_assistant.providers.yandex_smarthome.direct.handle_user_unlink", - new_callable=AsyncMock, - return_value={}, - ), - patch( - "music_assistant.providers.yandex_smarthome.direct.build_response", - return_value=resp_payload, - ), + patch("music_assistant.providers.yandex_smarthome.direct.handle_user_unlink", new_callable=AsyncMock, return_value={}), + patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), ): resp = await handler._handle_unlink(req) assert resp.status == 200 From 0326e291ad2b4ede5677bed807e7e4efb3795b9f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 May 2026 10:22:01 +0000 Subject: [PATCH 09/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.5.1 --- .../providers/yandex_smarthome/constants.py | 4 -- .../providers/yandex_smarthome/playlists.py | 4 +- .../providers/yandex_smarthome/test_device.py | 4 +- .../providers/yandex_smarthome/test_direct.py | 43 +++++++++++++++---- .../yandex_smarthome/test_handlers.py | 1 + 5 files changed, 37 insertions(+), 19 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/constants.py b/music_assistant/providers/yandex_smarthome/constants.py index ec2c9be455..2a490797e4 100644 --- a/music_assistant/providers/yandex_smarthome/constants.py +++ b/music_assistant/providers/yandex_smarthome/constants.py @@ -19,10 +19,6 @@ CONF_AUTO_CREATE_ARTIFACTS = "auto_create_artifacts" CONF_AUTO_CREATE_SESSION_ID = "auto_create_session_id" -# Auto-create-skill feature state (round-trips through the config form) -CONF_AUTO_CREATE_ARTIFACTS = "auto_create_artifacts" -CONF_AUTO_CREATE_SESSION_ID = "auto_create_session_id" - # --------------------------------------------------------------------------- # Config actions # --------------------------------------------------------------------------- diff --git a/music_assistant/providers/yandex_smarthome/playlists.py b/music_assistant/providers/yandex_smarthome/playlists.py index fb368a369e..64f10a30a0 100644 --- a/music_assistant/providers/yandex_smarthome/playlists.py +++ b/music_assistant/providers/yandex_smarthome/playlists.py @@ -41,9 +41,7 @@ async def fetch_playlist_options(mass: MusicAssistant) -> list[ConfigValueOption if not playlist.uri: continue provider_label = playlist.provider or "" - title = ( - f"{playlist.name} ({provider_label})" if provider_label else playlist.name - ) + title = f"{playlist.name} ({provider_label})" if provider_label else playlist.name options.append(ConfigValueOption(title=title, value=playlist.uri)) return options diff --git a/tests/providers/yandex_smarthome/test_device.py b/tests/providers/yandex_smarthome/test_device.py index 7427b55b42..62dc342ff6 100644 --- a/tests/providers/yandex_smarthome/test_device.py +++ b/tests/providers/yandex_smarthome/test_device.py @@ -896,9 +896,7 @@ def test_state_with_native_active_in_combined_mode(self) -> None: playback_state=PlaybackState.PLAYING, supported_features={"select_source"}, ) - state = get_device_state( - player, playlist_uris=["library://playlist/x"] # type: ignore[arg-type] - ) + state = get_device_state(player, playlist_uris=["library://playlist/x"]) # type: ignore[arg-type] mode_states = [c for c in state.capabilities if c.state.instance == INSTANCE_INPUT_SOURCE] assert len(mode_states) == 1 assert mode_states[0].state.value == "two" diff --git a/tests/providers/yandex_smarthome/test_direct.py b/tests/providers/yandex_smarthome/test_direct.py index 0a4c634295..7323b68e91 100644 --- a/tests/providers/yandex_smarthome/test_direct.py +++ b/tests/providers/yandex_smarthome/test_direct.py @@ -309,8 +309,13 @@ async def test_devices_success(handler: DirectConnectionHandler) -> None: ) with ( mock_hdl, - patch("music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []}), - patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), + patch( + "music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []} + ), + patch( + "music_assistant.providers.yandex_smarthome.direct.build_response", + return_value=resp_payload, + ), ): resp = await handler._handle_devices(req) assert resp.status == 200 @@ -335,8 +340,13 @@ async def test_query_success(handler: DirectConnectionHandler) -> None: ) with ( mock_query, - patch("music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []}), - patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), + patch( + "music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []} + ), + patch( + "music_assistant.providers.yandex_smarthome.direct.build_response", + return_value=resp_payload, + ), ): resp = await handler._handle_query(req) assert resp.status == 200 @@ -357,10 +367,18 @@ async def test_action_success(handler: DirectConnectionHandler) -> None: return_value=MagicMock(), ) with ( - patch("music_assistant.providers.yandex_smarthome.direct.parse_action_payload", return_value=MagicMock()), + patch( + "music_assistant.providers.yandex_smarthome.direct.parse_action_payload", + return_value=MagicMock(), + ), mock_action, - patch("music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []}), - patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), + patch( + "music_assistant.providers.yandex_smarthome.direct.asdict", return_value={"devices": []} + ), + patch( + "music_assistant.providers.yandex_smarthome.direct.build_response", + return_value=resp_payload, + ), ): resp = await handler._handle_action(req) assert resp.status == 200 @@ -375,8 +393,15 @@ async def test_unlink_success(handler: DirectConnectionHandler) -> None: ) resp_payload = {"request_id": "req-4"} with ( - patch("music_assistant.providers.yandex_smarthome.direct.handle_user_unlink", new_callable=AsyncMock, return_value={}), - patch("music_assistant.providers.yandex_smarthome.direct.build_response", return_value=resp_payload), + patch( + "music_assistant.providers.yandex_smarthome.direct.handle_user_unlink", + new_callable=AsyncMock, + return_value={}, + ), + patch( + "music_assistant.providers.yandex_smarthome.direct.build_response", + return_value=resp_payload, + ), ): resp = await handler._handle_unlink(req) assert resp.status == 200 diff --git a/tests/providers/yandex_smarthome/test_handlers.py b/tests/providers/yandex_smarthome/test_handlers.py index 6531bea01b..fd49050468 100644 --- a/tests/providers/yandex_smarthome/test_handlers.py +++ b/tests/providers/yandex_smarthome/test_handlers.py @@ -136,6 +136,7 @@ async def test_playlist_uris_register_mode_capability(self) -> None: c for c in result.devices[0].capabilities if c.type == YandexCapabilityType.MODE ] assert len(mode_caps) == 1 + assert mode_caps[0].parameters is not None modes = mode_caps[0].parameters.modes assert modes is not None assert [m.value for m in modes] == ["one", "two"] From 0914446a88d6c907d4ee94f680d8485f163961db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 May 2026 10:33:53 +0000 Subject: [PATCH 10/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.5.2 --- .../providers/yandex_smarthome/device.py | 5 ---- .../providers/yandex_smarthome/playlists.py | 25 +++++++++---------- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/device.py b/music_assistant/providers/yandex_smarthome/device.py index 02492b7f17..d62fd0b110 100644 --- a/music_assistant/providers/yandex_smarthome/device.py +++ b/music_assistant/providers/yandex_smarthome/device.py @@ -87,11 +87,6 @@ def _get_source_list(player: Player) -> list[PlayerSource]: return [] -def _build_source_modes(source_list: list[PlayerSource]) -> list[ModeValue]: - """Build Yandex mode values from an MA source list (max 10).""" - return [ModeValue(value=YANDEX_MODE_VALUES[i]) for i in range(min(len(source_list), 10))] - - def _combined_size( source_list: list[PlayerSource], playlist_uris: tuple[str, ...] | list[str] ) -> int: diff --git a/music_assistant/providers/yandex_smarthome/playlists.py b/music_assistant/providers/yandex_smarthome/playlists.py index 64f10a30a0..3691932486 100644 --- a/music_assistant/providers/yandex_smarthome/playlists.py +++ b/music_assistant/providers/yandex_smarthome/playlists.py @@ -18,31 +18,30 @@ _LOGGER = logging.getLogger(__name__) -_LIBRARY_LIMIT = 500 - async def fetch_playlist_options(mass: MusicAssistant) -> list[ConfigValueOption]: """Build ConfigValueOption list of all library playlists for the config form. - Used at config-render time only. Fail-soft: returns [] if mass.music or - the playlists controller is not yet available (e.g. provider load order). + Pages through `iter_library_items` so the dropdown is not silently + truncated for users with very large libraries (the underlying + `library_items(limit=...)` defaults to 500). Used at config-render + time only. Fail-soft: returns [] if mass.music or the playlists + controller is not yet available (e.g. provider load order). """ + options: list[ConfigValueOption] = [] try: - playlists = await mass.music.playlists.library_items(limit=_LIBRARY_LIMIT) + async for playlist in mass.music.playlists.iter_library_items(): + if not playlist.uri: + continue + provider_label = playlist.provider or "" + title = f"{playlist.name} ({provider_label})" if provider_label else playlist.name + options.append(ConfigValueOption(title=title, value=playlist.uri)) except Exception as exc: # Fail-soft: this runs on every config-form render and races with # provider/database startup. Don't spam stack traces — debug-level # is enough for diagnostics, normal renders stay quiet. _LOGGER.debug("Library playlists not available yet: %s", exc) return [] - - options: list[ConfigValueOption] = [] - for playlist in playlists: - if not playlist.uri: - continue - provider_label = playlist.provider or "" - title = f"{playlist.name} ({provider_label})" if provider_label else playlist.name - options.append(ConfigValueOption(title=title, value=playlist.uri)) return options From c6a6f771cb8387318a4e01ad89feb96c7f599230 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 May 2026 12:22:03 +0000 Subject: [PATCH 11/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.5.3 --- .../providers/yandex_smarthome/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index 0dab2df582..a7590ceb4a 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -491,11 +491,12 @@ def _common_tail_entries( description=( "Pick up to " f"{MAX_INPUT_SOURCES} playlists from your MA library — they appear " - "as input_source modes on every exposed player. After saving, " - "open the device in the Yandex app and assign voice aliases " - '(e.g. "Rock" for mode «one») so Alice can pick playlists by name. ' - "If the list is empty, save the form and reopen it once your music " - "providers have finished loading their library." + "as input_source mode slots one..ten on every exposed player, in the " + "order you select them. Alice triggers them by ordinal only " + "(«Alice, switch source to five»); the Yandex Smart Home " + "API does not allow naming mode values, so remember the order you " + "picked. If the list is empty, save the form and reopen it once " + "your music providers have finished loading their library." ), required=False, multi_value=True, From a3d2e0fe0ca15add55d4ab022b9b6ded624b1cec Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 May 2026 19:40:37 +0000 Subject: [PATCH 12/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.6.0 --- .../providers/yandex_smarthome/__init__.py | 182 +++++++++++- .../providers/yandex_smarthome/auto_skill.py | 208 +++++++++++-- .../yandex_smarthome/auto_skill_state.py | 3 + .../yandex_smarthome/auto_skill_ui.py | 275 +++++++++++++++++- .../providers/yandex_smarthome/constants.py | 26 ++ .../providers/yandex_smarthome/dialogs.py | 269 +++++++++++++++++ .../providers/yandex_smarthome/dialogs_nlu.py | 256 ++++++++++++++++ .../yandex_smarthome/dialogs_player.py | 231 +++++++++++++++ .../providers/yandex_smarthome/plugin.py | 34 +++ .../yandex_smarthome/test_auto_skill.py | 166 +++++++++++ .../yandex_smarthome/test_dialogs.py | 231 +++++++++++++++ .../yandex_smarthome/test_dialogs_nlu.py | 203 +++++++++++++ .../yandex_smarthome/test_dialogs_player.py | 148 ++++++++++ 13 files changed, 2209 insertions(+), 23 deletions(-) create mode 100644 music_assistant/providers/yandex_smarthome/dialogs.py create mode 100644 music_assistant/providers/yandex_smarthome/dialogs_nlu.py create mode 100644 music_assistant/providers/yandex_smarthome/dialogs_player.py create mode 100644 tests/providers/yandex_smarthome/test_dialogs.py create mode 100644 tests/providers/yandex_smarthome/test_dialogs_nlu.py create mode 100644 tests/providers/yandex_smarthome/test_dialogs_player.py diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index a7590ceb4a..f19ab49553 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -30,6 +30,7 @@ from ._compat import SecretStr from .auto_skill import ( auto_create_skill, + auto_rename_dialog_skill, load_default_logo_bytes, ) from .auto_skill_state import ( @@ -41,14 +42,23 @@ from .cloud import get_cloud_otp, register_cloud_instance from .constants import ( CONF_ACTION_AUTO_CREATE, + CONF_ACTION_AUTO_CREATE_DIALOG, CONF_ACTION_GET_OTP, CONF_ACTION_REGISTER, + CONF_ACTION_RENAME_DIALOG_SKILL, CONF_AUTO_CREATE_ARTIFACTS, CONF_AUTO_CREATE_SESSION_ID, CONF_CLOUD_CONNECTION_TOKEN, CONF_CLOUD_INSTANCE_ID, CONF_CLOUD_INSTANCE_PASSWORD, CONF_CONNECTION_TYPE, + CONF_DIALOG_AUTO_CREATE_ARTIFACTS, + CONF_DIALOG_AUTO_CREATE_SESSION_ID, + CONF_DIALOG_SKILL_ENABLED, + CONF_DIALOG_SKILL_ID, + CONF_DIALOG_SKILL_NAME, + CONF_DIALOG_SKILL_TOKEN, + CONF_DIALOG_WEBHOOK_SECRET, CONF_DIRECT_ACCESS_TOKEN, CONF_DIRECT_CLIENT_SECRET, CONF_EXPOSED_PLAYERS, @@ -59,6 +69,8 @@ CONNECTION_TYPE_CLOUD, CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT, + DIALOG_DEFAULT_NAME, + DIALOG_WEBHOOK_BASE_PATH, MAX_INPUT_SOURCES, ) from .playlists import fetch_playlist_options @@ -132,6 +144,26 @@ def _resolve_direct_client_secret( return str(values.get(CONF_DIRECT_CLIENT_SECRET) or "") +def _resolve_dialog_webhook_secret( + mass: MusicAssistant, + instance_id: str | None, + values: dict[str, ConfigValueType], +) -> str: + """Return the dialog webhook path-secret for the current install. + + Like ``_resolve_direct_client_secret``, prefers the persisted + SECURE_STRING from saved config since the frontend does not echo + secrets back into ``values`` on re-open. + """ + if instance_id: + prov = mass.get_provider(instance_id) + if prov and prov.config: + saved = prov.config.get_value(CONF_DIALOG_WEBHOOK_SECRET) + if saved: + return str(saved) + return str(values.get(CONF_DIALOG_WEBHOOK_SECRET) or "") + + async def _handle_config_actions( mass: MusicAssistant, action: str | None, @@ -183,6 +215,12 @@ async def _handle_config_actions( if action == CONF_ACTION_AUTO_CREATE: await _run_auto_create_action(mass, values, connection_type, instance_id) + if action == CONF_ACTION_AUTO_CREATE_DIALOG: + await _run_auto_create_dialog_action(mass, values, connection_type, instance_id) + + if action == CONF_ACTION_RENAME_DIALOG_SKILL: + await _run_rename_dialog_action(mass, values, instance_id) + return otp_code @@ -246,7 +284,104 @@ async def _run_auto_create_action( values[CONF_SKILL_ID] = new_artifacts.skill_id -async def get_config_entries( +def _build_dialog_backend_uri(base_url: str, webhook_secret: str) -> str: + return f"{base_url.rstrip('/')}{DIALOG_WEBHOOK_BASE_PATH}/{webhook_secret}" + + +async def _run_auto_create_dialog_action( + mass: MusicAssistant, + values: dict[str, ConfigValueType], + connection_type: str, + instance_id: str | None, +) -> None: + """Execute the experimental dialog skill auto-create action. + + Mirrors ``_run_auto_create_action`` but targets the dialog channel + and persists under separate config keys so the two pipelines are + independent. + """ + session_id = str(values.get("session_id") or uuid.uuid4().hex) + values[CONF_DIALOG_AUTO_CREATE_SESSION_ID] = session_id + + artifacts_raw = values.get(CONF_DIALOG_AUTO_CREATE_ARTIFACTS) + artifacts = load_artifacts(str(artifacts_raw) if artifacts_raw else None) + + webhook_secret = _resolve_dialog_webhook_secret(mass, instance_id, values) + if not webhook_secret: + webhook_secret = uuid.uuid4().hex + values[CONF_DIALOG_WEBHOOK_SECRET] = webhook_secret + + ma_base_url = "" + with contextlib.suppress(Exception): + ma_base_url = str(mass.webserver.base_url) + dialog_backend_uri = _build_dialog_backend_uri(ma_base_url, webhook_secret) + + skill_name = str(values.get(CONF_DIALOG_SKILL_NAME) or DIALOG_DEFAULT_NAME) + + try: + new_artifacts = await auto_create_skill( + mass=mass, + connection_type=connection_type, + skill_name=skill_name, + artifacts=artifacts, + cloud_instance_id="", + direct_client_secret=_resolve_direct_client_secret(mass, instance_id, values), + logo_bytes=load_default_logo_bytes(), + session_id=session_id, + skill_type="dialog", + dialog_backend_uri=dialog_backend_uri, + ) + except asyncio.CancelledError: + raise + except ValueError as exc: + new_artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=str(exc), + ) + _LOGGER.warning("dialog auto-create precondition failed: %s", exc) + except Exception as exc: + new_artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=repr(exc), + ) + _LOGGER.exception("dialog auto-create hit unexpected error") + + values[CONF_DIALOG_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) + if new_artifacts.state == SkillCreationState.DONE and new_artifacts.skill_id: + values[CONF_DIALOG_SKILL_ID] = new_artifacts.skill_id + + +async def _run_rename_dialog_action( + mass: MusicAssistant, + values: dict[str, ConfigValueType], + instance_id: str | None, +) -> None: + """Execute the dialog skill rename + re-deploy action.""" + artifacts_raw = values.get(CONF_DIALOG_AUTO_CREATE_ARTIFACTS) + artifacts = load_artifacts(str(artifacts_raw) if artifacts_raw else None) + + webhook_secret = _resolve_dialog_webhook_secret(mass, instance_id, values) + ma_base_url = "" + with contextlib.suppress(Exception): + ma_base_url = str(mass.webserver.base_url) + dialog_backend_uri = _build_dialog_backend_uri(ma_base_url, webhook_secret) + + skill_name = str(values.get(CONF_DIALOG_SKILL_NAME) or DIALOG_DEFAULT_NAME) + session_id = str(values.get("session_id") or uuid.uuid4().hex) + + new_artifacts = await auto_rename_dialog_skill( + mass=mass, + artifacts=artifacts, + new_name=skill_name, + dialog_backend_uri=dialog_backend_uri, + session_id=session_id, + ) + values[CONF_DIALOG_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) + + +async def get_config_entries( # noqa: PLR0915 mass: MusicAssistant, instance_id: str | None = None, action: str | None = None, @@ -378,6 +513,20 @@ async def get_config_entries( if not direct_secret: direct_secret = uuid.uuid4().hex values[CONF_DIRECT_CLIENT_SECRET] = direct_secret + + # Pre-generate the dialog webhook secret similarly. + dialog_webhook_secret = _resolve_dialog_webhook_secret(mass, instance_id, values) + if not dialog_webhook_secret: + dialog_webhook_secret = uuid.uuid4().hex + values[CONF_DIALOG_WEBHOOK_SECRET] = dialog_webhook_secret + + # Dialog skill state + dialog_artifacts_raw = values.get(CONF_DIALOG_AUTO_CREATE_ARTIFACTS) + dialog_artifacts_str = str(dialog_artifacts_raw) if dialog_artifacts_raw else None + dialog_artifacts = load_artifacts(dialog_artifacts_str) + dialog_session_id_val = values.get(CONF_DIALOG_AUTO_CREATE_SESSION_ID) + dialog_session_id_str = str(dialog_session_id_val) if dialog_session_id_val else None + entries.extend( build_direct_entries( artifacts=artifacts, @@ -389,12 +538,43 @@ async def get_config_entries( direct_client_secret=direct_secret, skill_id=str(values.get(CONF_SKILL_ID) or ""), skill_token_set=bool(values.get(CONF_SKILL_TOKEN)), + dialog_skill_enabled=bool(values.get(CONF_DIALOG_SKILL_ENABLED)), + dialog_skill_name=str(values.get(CONF_DIALOG_SKILL_NAME) or DIALOG_DEFAULT_NAME), + dialog_artifacts=dialog_artifacts, + dialog_skill_id=str(values.get(CONF_DIALOG_SKILL_ID) or ""), + dialog_session_id=dialog_session_id_str, + dialog_existing_artifacts_raw=dialog_artifacts_str, + dialog_user_code=None, + dialog_verification_url=None, ) ) # NB: CONF_DIRECT_CLIENT_SECRET is now emitted by the manual # fallback block (advanced/hidden per state), so we don't add a # duplicate hidden round-trip entry here. + # Round-trip the dialog webhook secret (SECURE_STRING, never echoed). + entries.append( + ConfigEntry( + key=CONF_DIALOG_WEBHOOK_SECRET, + type=ConfigEntryType.SECURE_STRING, + label="Dialog webhook secret (internal)", + hidden=True, + required=False, + value=dialog_webhook_secret, + ) + ) + # Round-trip the dialog skill token (set externally if needed). + entries.append( + ConfigEntry( + key=CONF_DIALOG_SKILL_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Dialog skill token (internal)", + hidden=True, + required=False, + value=cast("str", values.get(CONF_DIALOG_SKILL_TOKEN)) if values else None, + ) + ) + # -- Tail: player filter + hidden round-trip fields (all modes) -- entries.extend(_common_tail_entries(player_options, playlist_options, values)) return tuple(entries) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index bdca544abe..2d5e4d33cd 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -37,7 +37,7 @@ import re from contextlib import asynccontextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal import aiohttp @@ -50,6 +50,7 @@ CLOUD_SKILL_WEBHOOK_TEMPLATE, CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT, + DIALOG_CHANNEL, DIRECT_API_BASE_PATH, DIRECT_AUTH_BASE_PATH, DIRECT_OAUTH_CLIENT_ID, @@ -71,6 +72,8 @@ "DialogsDuplicateSkillError", "DialogsSkillCreator", "auto_create_skill", + "auto_rename_dialog_skill", + "build_dialog_draft_payload", "build_draft_payload", "build_oauth_app_payload", "check_preconditions", @@ -143,16 +146,24 @@ class DialogsSkillCreator: orchestrator (see :func:`auto_create_skill`). """ - __slots__ = ("_logger", "_session") + __slots__ = ("_channel", "_logger", "_session") def __init__( self, session: aiohttp.ClientSession, logger: logging.Logger | None = None, + *, + channel: str = SMART_HOME_CHANNEL, ) -> None: - """Take a session that already carries Passport auth cookies.""" + """Take a session that already carries Passport auth cookies. + + ``channel`` selects the Yandex Dialogs skill family — defaults to + ``smartHome`` for the existing Smart Home pipeline; pass + :data:`DIALOG_CHANNEL` for the experimental «Навык» pipeline. + """ self._session = session self._logger = logger or _LOGGER + self._channel = channel # ----------------------------------------------------------------------- # Step 1: CSRF token extraction @@ -240,7 +251,7 @@ async def create_app(self, csrf: str, name: str) -> str: """ url = f"{DIALOGS_API_BASE}/apps" payload = { - "channel": SMART_HOME_CHANNEL, + "channel": self._channel, "language": "ru", "isYangoConsole": False, "appName": name, @@ -272,7 +283,7 @@ async def upload_logo(self, csrf: str, skill_id: str, png: bytes) -> str: The logo file is sent as multipart with the field name ``file`` and filename ``icon.png`` (matching the HAR capture). """ - url = f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/upload-logo?channel={SMART_HOME_CHANNEL}" + url = f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/upload-logo?channel={self._channel}" form = aiohttp.FormData() form.add_field( "file", @@ -365,7 +376,7 @@ async def create_oauth_app( async def attach_oauth(self, csrf: str, skill_id: str, oauth_app_id: str) -> None: """Attach an existing OAuth app to the skill's account-linking slot.""" - url = f"{DIALOGS_API_BASE}/apps/{skill_id}/oauthApp?channel={SMART_HOME_CHANNEL}" + url = f"{DIALOGS_API_BASE}/apps/{skill_id}/oauthApp?channel={self._channel}" payload = {"oauthAppId": oauth_app_id} await self._post_json(url, payload, csrf=csrf, step="attach_oauth") @@ -379,9 +390,7 @@ async def request_deploy(self, csrf: str, skill_id: str) -> None: Body is empty; all params are in the query string. Returns on 2xx; otherwise raises. """ - url = ( - f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/request-deploy?channel={SMART_HOME_CHANNEL}" - ) + url = f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/request-deploy?channel={self._channel}" headers = {"x-csrf-token": csrf} async with self._session.post(url, headers=headers) as resp: body = await resp.text() @@ -626,6 +635,62 @@ def build_draft_payload( } +def build_dialog_draft_payload( + *, + skill_name: str, + backend_uri: str, + logo_id: str | None, + developer_name: str = "Music Assistant user", +) -> dict[str, Any]: + """Compose the PATCH /draft/update body for a Yandex Dialogs «Навык». + + Mirrors :func:`build_draft_payload` but uses + ``publishingSettings.category="music_and_sounds"`` and drops + the ``smartHome`` deepLinks block. ⚠️ Exact required-field set + for «Навык» is not documented — this matches the most common shape + seen in Dialogs developer UI HARs and may need adjustment after a + manual probe (see plan probe checklist). + """ + return { + "logo2": None, + "name": skill_name, + "voice": "shitova.us", + "logoId": logo_id, + "skillAccess": "private", + "hideInStore": True, + "noteForModerator": "", + "backendSettings": { + "uri": backend_uri, + "functionId": "", + "backendType": "webhook", + }, + "publishingSettings": { + "brandVerificationWebsite": "", + "category": "music_and_sounds", + "developerName": developer_name, + "secondaryTitle": skill_name, + "email": "", + "multilingualSettings": { + "ru": { + "name": skill_name, + "secondaryTitle": skill_name, + "description": "Free-form voice playback bridge for Music Assistant.", + "shortDescription": "Music Assistant voice control", + "examplePhrases": [ + "включи Metallica на кухне", + "включи мою волну", + "включи плейлист джаз", + ], + }, + }, + }, + "oauthAppId": None, + "enableAllAvailableRegions": True, + "selectedRegions": [], + "channel": DIALOG_CHANNEL, + } + + def build_oauth_app_payload( *, skill_name: str, @@ -1020,6 +1085,8 @@ async def auto_create_skill( # noqa: PLR0913 direct_client_secret: str, logo_bytes: bytes, session_id: str, + skill_type: Literal["smart_home", "dialog"] = "smart_home", + dialog_backend_uri: str | None = None, progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None = None, authenticator: Callable[..., AsyncIterator[aiohttp.ClientSession]] | None = None, creator_factory: Callable[[aiohttp.ClientSession], DialogsSkillCreator] | None = None, @@ -1038,6 +1105,10 @@ async def auto_create_skill( # noqa: PLR0913 updated artifacts; the caller uses it to persist state to MA config so a subsequent retry resumes from the latest completed step. + ``skill_type`` selects the API channel and draft payload builder. + For ``"dialog"``, ``dialog_backend_uri`` must be provided (the full + HTTPS webhook URL including the path secret). + ``authenticator`` and ``creator_factory`` are injection points for tests; production callers leave them as ``None`` to use the real Device Flow and a real :class:`DialogsSkillCreator`. @@ -1050,14 +1121,22 @@ async def auto_create_skill( # noqa: PLR0913 direct_client_secret=direct_client_secret, ) + if skill_type == "dialog" and not dialog_backend_uri: + msg = "dialog_backend_uri is required for skill_type='dialog'" + return dataclasses.replace(artifacts, state=SkillCreationState.FAILED, last_error=msg) + + channel = DIALOG_CHANNEL if skill_type == "dialog" else SMART_HOME_CHANNEL auth_fn = authenticator or _default_authenticator - creator_fn = creator_factory or DialogsSkillCreator try: async with _build_authenticator_cm( auth_fn, mass=mass, session_id=session_id, timeout=timeout ) as session: - creator = creator_fn(session) + creator = ( + creator_factory(session) + if creator_factory is not None + else DialogsSkillCreator(session, channel=channel) + ) return await _run_pipeline_with_recovery( creator=creator, artifacts=artifacts, @@ -1068,6 +1147,8 @@ async def auto_create_skill( # noqa: PLR0913 logo_bytes=logo_bytes, mass=mass, developer_name=developer_name, + skill_type=skill_type, + dialog_backend_uri=dialog_backend_uri, progress_cb=progress_cb, ) except asyncio.CancelledError: @@ -1080,7 +1161,7 @@ async def auto_create_skill( # noqa: PLR0913 return dataclasses.replace(artifacts, state=SkillCreationState.FAILED, last_error=repr(exc)) -async def _run_pipeline_with_recovery( +async def _run_pipeline_with_recovery( # noqa: PLR0913 *, creator: DialogsSkillCreator, artifacts: SkillCreationArtifacts, @@ -1091,6 +1172,8 @@ async def _run_pipeline_with_recovery( logo_bytes: bytes, mass: MusicAssistant, developer_name: str, + skill_type: Literal["smart_home", "dialog"] = "smart_home", + dialog_backend_uri: str | None = None, progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, ) -> SkillCreationArtifacts: """Fetch CSRF and run the pipeline, preserving partial state on failure. @@ -1123,6 +1206,8 @@ async def _track(a: SkillCreationArtifacts) -> None: logo_bytes=logo_bytes, mass=mass, developer_name=developer_name, + skill_type=skill_type, + dialog_backend_uri=dialog_backend_uri, progress_cb=_track, ) except DialogsApiError as exc: @@ -1142,6 +1227,8 @@ async def _execute_pipeline( # noqa: PLR0913, PLR0915 logo_bytes: bytes, mass: MusicAssistant, developer_name: str, + skill_type: Literal["smart_home", "dialog"] = "smart_home", + dialog_backend_uri: str | None = None, progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, ) -> SkillCreationArtifacts: """Advance through states sequentially, skipping completed steps.""" @@ -1173,17 +1260,32 @@ async def _execute_pipeline( # noqa: PLR0913, PLR0915 logo_id = await creator.upload_logo(csrf, skill_id, logo_bytes) artifacts = dataclasses.replace(artifacts, logo_id=logo_id) - backend_uri = derive_backend_uri(mass, connection_type) - draft = build_draft_payload( - connection_type=connection_type, - skill_name=skill_name, - backend_uri=backend_uri, - logo_id=logo_id, - developer_name=developer_name, - ) + if skill_type == "dialog": + if dialog_backend_uri is None: + msg = "dialog_backend_uri is required for skill_type='dialog'" + raise ValueError(msg) + draft = build_dialog_draft_payload( + skill_name=skill_name, + backend_uri=dialog_backend_uri, + logo_id=logo_id, + developer_name=developer_name, + ) + else: + backend_uri = derive_backend_uri(mass, connection_type) + draft = build_draft_payload( + connection_type=connection_type, + skill_name=skill_name, + backend_uri=backend_uri, + logo_id=logo_id, + developer_name=developer_name, + ) _LOGGER.info("auto-skill: [3/5] updating draft with settings") await creator.update_draft(csrf, skill_id, draft) - artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DRAFT_UPDATED) + artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.DRAFT_UPDATED, + last_known_name=skill_name, + ) await _maybe_save(progress_cb, artifacts) state = artifacts.state @@ -1245,6 +1347,70 @@ async def _execute_pipeline( # noqa: PLR0913, PLR0915 return artifacts +async def auto_rename_dialog_skill( + *, + mass: MusicAssistant, + artifacts: SkillCreationArtifacts, + new_name: str, + dialog_backend_uri: str, + session_id: str, + authenticator: Callable[..., AsyncIterator[aiohttp.ClientSession]] | None = None, + creator_factory: Callable[[aiohttp.ClientSession], DialogsSkillCreator] | None = None, + timeout: float = DEVICE_FLOW_TIMEOUT_SECONDS, + developer_name: str = "Music Assistant user", +) -> SkillCreationArtifacts: + """Rename a dialog skill and re-deploy it. + + Patches the draft ``name`` field and calls ``request_deploy``. Does + not raise on failure — returns artifacts with ``state=FAILED`` and + ``last_error`` so the UI can display the message. + + On success the returned artifacts have ``last_known_name=new_name`` + and ``state=DONE`` so the drift-detector in the UI clears the banner. + """ + if artifacts.skill_id is None: + msg = "skill_id is missing — cannot rename a skill that has not been created" + return dataclasses.replace(artifacts, state=SkillCreationState.FAILED, last_error=msg) + + skill_id = artifacts.skill_id + auth_fn = authenticator or _default_authenticator + + try: + async with _build_authenticator_cm( + auth_fn, mass=mass, session_id=session_id, timeout=timeout + ) as session: + creator = ( + creator_factory(session) + if creator_factory is not None + else DialogsSkillCreator(session, channel=DIALOG_CHANNEL) + ) + csrf = await creator.fetch_csrf() + + draft = build_dialog_draft_payload( + skill_name=new_name, + backend_uri=dialog_backend_uri, + logo_id=artifacts.logo_id, + developer_name=developer_name, + ) + await creator.update_draft(csrf, skill_id, draft) + await creator.request_deploy(csrf, skill_id) + _LOGGER.info("auto-skill: dialog skill renamed to %r and re-deployed", new_name) + return dataclasses.replace( + artifacts, + state=SkillCreationState.DONE, + last_known_name=new_name, + last_error=None, + ) + except asyncio.CancelledError: + raise + except DialogsApiError as exc: + _LOGGER.warning("rename-dialog-skill failed: %s", exc, exc_info=True) + return dataclasses.replace(artifacts, state=SkillCreationState.FAILED, last_error=str(exc)) + except Exception as exc: + _LOGGER.exception("rename-dialog-skill hit unexpected error") + return dataclasses.replace(artifacts, state=SkillCreationState.FAILED, last_error=repr(exc)) + + # Minimal 1x1 transparent PNG — used when the packaged logo is missing # (e.g. during unit tests before the asset commit lands). Real installs # pick up provider/auto_skill_logo.png instead. diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_state.py b/music_assistant/providers/yandex_smarthome/auto_skill_state.py index 6f2b8e8b8e..d114b57a0e 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill_state.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill_state.py @@ -57,6 +57,7 @@ class SkillCreationArtifacts: logo_id: str | None = None oauth_app_id: str | None = None last_error: str | None = None + last_known_name: str | None = None # MA-side mirror of the skill name in Yandex Dialogs def dump_artifacts(artifacts: SkillCreationArtifacts) -> str: @@ -68,6 +69,7 @@ def dump_artifacts(artifacts: SkillCreationArtifacts) -> str: "logo_id": artifacts.logo_id, "oauth_app_id": artifacts.oauth_app_id, "last_error": artifacts.last_error, + "last_known_name": artifacts.last_known_name, }, ensure_ascii=False, ) @@ -107,6 +109,7 @@ def _opt_str(key: str) -> str | None: logo_id=_opt_str("logo_id"), oauth_app_id=_opt_str("oauth_app_id"), last_error=_opt_str("last_error"), + last_known_name=_opt_str("last_known_name"), ) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py index 735b504128..70ddc5c9ce 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py @@ -30,17 +30,27 @@ CLOUD_SKILL_CLIENT_SECRET, CLOUD_SKILL_WEBHOOK_TEMPLATE, CONF_ACTION_AUTO_CREATE, + CONF_ACTION_AUTO_CREATE_DIALOG, CONF_ACTION_GET_OTP, CONF_ACTION_REGISTER, + CONF_ACTION_RENAME_DIALOG_SKILL, CONF_AUTO_CREATE_ARTIFACTS, CONF_AUTO_CREATE_SESSION_ID, CONF_CONNECTION_TYPE, + CONF_DIALOG_AUTO_CREATE_ARTIFACTS, + CONF_DIALOG_AUTO_CREATE_SESSION_ID, + CONF_DIALOG_SKILL_ENABLED, + CONF_DIALOG_SKILL_ID, + CONF_DIALOG_SKILL_NAME, CONF_DIRECT_CLIENT_SECRET, CONF_SKILL_ID, CONF_SKILL_TOKEN, CONNECTION_TYPE_CLOUD, CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT, + DIALOG_DEFAULT_NAME, + DIALOG_NAME_MAX_LEN, + DIALOG_NAME_MIN_LEN, DIRECT_API_BASE_PATH, DIRECT_AUTH_BASE_PATH, DIRECT_OAUTH_CLIENT_ID, @@ -68,6 +78,8 @@ _CAT_STEP_3_LINK = "Step 3 — Link skill to Yandex" # Direct mode has only one step — no cloud registration, no OTP linking. _CAT_STEP_DIRECT_CREATE = "Create Smart Home skill" +# Dialog skill section — direct mode only, experimental. +_CAT_DIALOG_SKILL = "🧪 Experimental — Dialogs voice skill (free-form playback)" def _status_label(state: SkillCreationState, last_error: str | None) -> str: @@ -803,7 +815,7 @@ def _step3_link_entries( # --------------------------------------------------------------------------- -def build_direct_entries( +def build_direct_entries( # noqa: PLR0913 *, artifacts: SkillCreationArtifacts, session_id: str | None, @@ -814,12 +826,24 @@ def build_direct_entries( direct_client_secret: str = "", skill_id: str = "", skill_token_set: bool = False, + # Dialog skill params (experimental, direct-only) + dialog_skill_enabled: bool = False, + dialog_skill_name: str = DIALOG_DEFAULT_NAME, + dialog_artifacts: SkillCreationArtifacts | None = None, + dialog_skill_id: str = "", + dialog_session_id: str | None = None, + dialog_existing_artifacts_raw: str | None = None, + dialog_user_code: str | None = None, + dialog_verification_url: str | None = None, ) -> list[ConfigEntry]: """Return the direct-mode config entries as a single Create-Skill step. direct mode has no yaha-cloud registration (Step 1) and no OTP linking (Step 3) — Yandex Dialogs' account-linking UI handles that once the skill exists. + + Optionally includes the experimental dialog skill section when + ``dialog_skill_enabled`` is True. """ fully_configured = bool(skill_id) and skill_token_set entries = _create_skill_step_entries( @@ -835,4 +859,253 @@ def build_direct_entries( fully_configured=fully_configured, ) entries.extend(_hidden_state_entries(existing_artifacts_raw, session_id)) + entries.extend( + _dialog_skill_entries( + enabled=dialog_skill_enabled, + skill_name=dialog_skill_name, + artifacts=dialog_artifacts or SkillCreationArtifacts(), + dialog_skill_id=dialog_skill_id, + base_url=base_url, + session_id=dialog_session_id, + existing_artifacts_raw=dialog_existing_artifacts_raw, + user_code=dialog_user_code, + verification_url=dialog_verification_url, + ) + ) return entries + + +def _dialog_skill_entries( + *, + enabled: bool, + skill_name: str, + artifacts: SkillCreationArtifacts, + dialog_skill_id: str, + base_url: str, + session_id: str | None, + existing_artifacts_raw: str | None, + user_code: str | None, + verification_url: str | None, +) -> list[ConfigEntry]: + """Build config entries for the experimental dialog skill section. + + Always emits the enable toggle (so the user can turn it on). + The rest of the entries are only emitted when ``enabled=True``. + """ + entries: list[ConfigEntry] = [ + ConfigEntry( + key=CONF_DIALOG_SKILL_ENABLED, + type=ConfigEntryType.BOOLEAN, + label="Enable experimental Dialogs voice skill", + description=( + "Enables a custom Yandex Dialogs «Навык» for free-form voice playback. " + 'Once created, say "Алиса, попроси включи Metallica на кухне". ' + "Requires a publicly reachable HTTPS URL. Direct mode only." + ), + default_value=False, + required=False, + category=_CAT_DIALOG_SKILL, + ) + ] + + if not enabled: + return entries + + # Skill activation name + entries.append( + ConfigEntry( + key=CONF_DIALOG_SKILL_NAME, + type=ConfigEntryType.STRING, + label="Skill activation name", + description=( + 'Used as the activation phrase: "Алиса, попроси …". ' + "For Yandex voice recognition, a Russian name works best. " + f"Length: {DIALOG_NAME_MIN_LEN}-{DIALOG_NAME_MAX_LEN} characters." + ), + default_value=DIALOG_DEFAULT_NAME, + required=True, + category=_CAT_DIALOG_SKILL, + ) + ) + + # Device-flow user code (shown while the Device Flow is in progress) + if user_code: + entries.append( + ConfigEntry( + key="dialog_auto_create_user_code", + type=ConfigEntryType.STRING, + label="Device code for ya.ru/device", + description=( + "Open the URL below, log in to your Yandex account, and enter this code." + ), + value=user_code, + required=False, + help_link=verification_url or "https://ya.ru/device", + category=_CAT_DIALOG_SKILL, + ) + ) + + # Status label + entries.append( + ConfigEntry( + key="label_dialog_skill_status", + type=ConfigEntryType.LABEL, + label=_dialog_status_label(artifacts.state, artifacts.last_error), + category=_CAT_DIALOG_SKILL, + ) + ) + + # HTTPS prerequisite warning + direct_https_missing = not base_url.startswith("https://") + if direct_https_missing: + entries.append( + ConfigEntry( + key="label_dialog_https_warning", + type=ConfigEntryType.LABEL, + label=( + f"⚠️ MA's Base URL is {base_url or ''}. " + "The dialog skill webhook requires a **publicly reachable HTTPS URL**. " + "Set a reverse proxy with a real certificate and update " + "Settings → Core → Webserver → Base URL, then reopen these settings." + ), + category=_CAT_DIALOG_SKILL, + ) + ) + + # Auto-create action button + can_create = not direct_https_missing and artifacts.state != SkillCreationState.DONE + entries.append( + ConfigEntry( + key=CONF_ACTION_AUTO_CREATE_DIALOG, + type=ConfigEntryType.ACTION, + label=_action_label(artifacts.state), + description=( + "Runs the Yandex Device Flow login, then creates and publishes " + "the private Dialogs «Навык». Takes ~30 seconds after you enter the code." + ), + action=CONF_ACTION_AUTO_CREATE_DIALOG, + action_label=_action_label(artifacts.state), + hidden=not can_create, + category=_CAT_DIALOG_SKILL, + ) + ) + + # Rename button — shown when skill exists but name has drifted + name_drifted = ( + bool(dialog_skill_id) + and bool(artifacts.last_known_name) + and skill_name != artifacts.last_known_name + ) + if name_drifted: + entries.append( + ConfigEntry( + key="label_dialog_rename_warning", + type=ConfigEntryType.LABEL, + label=( + f"⚠️ Activation name in Yandex Dialogs is still " + f'"{artifacts.last_known_name}". Press the button below to ' + f'update it to "{skill_name}".' + ), + category=_CAT_DIALOG_SKILL, + ) + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_RENAME_DIALOG_SKILL, + type=ConfigEntryType.ACTION, + label="Rename skill in Yandex Dialogs", + description=( + "Updates the skill name and re-deploys. " + 'After this, say "Алиса, попроси …".' + ), + action=CONF_ACTION_RENAME_DIALOG_SKILL, + action_label="Rename and re-deploy", + category=_CAT_DIALOG_SKILL, + ) + ) + + # Skill ID (manual override / reference) + entries.append( + ConfigEntry( + key=CONF_DIALOG_SKILL_ID, + type=ConfigEntryType.STRING, + label="Dialog Skill ID", + description=( + "UUID of the Dialogs «Навык» skill. Set automatically when " + "auto-create succeeds; you can paste it manually if you created " + "the skill by hand in Yandex.Dialogs." + ), + required=False, + advanced=artifacts.state + not in ( + SkillCreationState.DONE, + SkillCreationState.FAILED, + ), + category=_CAT_DIALOG_SKILL, + ) + ) + + # Dialogs console link (shown once skill exists) + if dialog_skill_id: + skill_url = f"https://dialogs.yandex.ru/developer/skills/{dialog_skill_id}/" + entries.append( + ConfigEntry( + key="dialog_skill_dialogs_link", + type=ConfigEntryType.STRING, + label="Open dialog skill in Yandex.Dialogs", + required=False, + default_value=skill_url, + help_link=skill_url, + advanced=True, + category=_CAT_DIALOG_SKILL, + ) + ) + + # Hidden round-trip state + entries.extend( + [ + ConfigEntry( + key=CONF_DIALOG_AUTO_CREATE_ARTIFACTS, + type=ConfigEntryType.STRING, + label="Dialog auto-create artifacts (internal)", + hidden=True, + required=False, + value=existing_artifacts_raw, + ), + ConfigEntry( + key=CONF_DIALOG_AUTO_CREATE_SESSION_ID, + type=ConfigEntryType.STRING, + label="Dialog auto-create session id (internal)", + hidden=True, + required=False, + value=session_id, + ), + ] + ) + + return entries + + +def _dialog_status_label(state: SkillCreationState, last_error: str | None) -> str: + """Human-readable status for the dialog skill auto-create.""" + if state == SkillCreationState.DONE: + return ( + "✅ Dialog skill created and published. " + 'You can now say "Алиса, попроси включи Metallica на кухне".' + ) + if state == SkillCreationState.FAILED: + err = last_error or "unknown error" + return ( + f"❌ Creation failed: {err}\n" + f"Press '{_action_label(state)}' to try again, or fill in " + "Dialog Skill ID manually below." + ) + if state == SkillCreationState.NONE: + return ( + "Ready to create dialog skill. " + "Set the activation name above, then press the button below." + ) + return ( + f"Partial progress saved ({state.value}). " + f"Press '{_action_label(state)}' to finish, or fill Dialog Skill ID manually." + ) diff --git a/music_assistant/providers/yandex_smarthome/constants.py b/music_assistant/providers/yandex_smarthome/constants.py index 2a490797e4..d4c2d0c621 100644 --- a/music_assistant/providers/yandex_smarthome/constants.py +++ b/music_assistant/providers/yandex_smarthome/constants.py @@ -130,3 +130,29 @@ ERROR_INVALID_ACTION = "INVALID_ACTION" ERROR_INTERNAL_ERROR = "INTERNAL_ERROR" ERROR_DEVICE_NOT_FOUND = "DEVICE_NOT_FOUND" + +# --------------------------------------------------------------------------- +# Dialog skill (Yandex Dialogs «Навык» — free-form voice playback) — experimental +# --------------------------------------------------------------------------- +CONF_DIALOG_SKILL_ENABLED = "dialog_skill_enabled" +CONF_DIALOG_SKILL_NAME = "dialog_skill_name" +CONF_DIALOG_SKILL_ID = "dialog_skill_id" +CONF_DIALOG_SKILL_TOKEN = "dialog_skill_token" +CONF_DIALOG_WEBHOOK_SECRET = "dialog_webhook_secret" +CONF_DIALOG_AUTO_CREATE_ARTIFACTS = "dialog_auto_create_artifacts" +CONF_DIALOG_AUTO_CREATE_SESSION_ID = "dialog_auto_create_session_id" + +CONF_ACTION_AUTO_CREATE_DIALOG = "auto_create_dialog_skill" +CONF_ACTION_RENAME_DIALOG_SKILL = "rename_dialog_skill" + +DIALOG_WEBHOOK_BASE_PATH = "/api/yandex_dialogs/webhook" +DIALOG_RESOLVE_TIMEOUT = 2.5 +DIALOG_DEFAULT_NAME = "Music Assistant" +# Yandex Dialogs app-store-api channel string for «Навык» (custom dialog skill). +# Smart Home uses "smartHome" — value below is best guess pending manual probe. +# See plan probe checklist (a) — adjust here if Yandex returns 4xx for "dialog". +DIALOG_CHANNEL = "dialog" +DIALOG_NAME_MIN_LEN = 2 +DIALOG_NAME_MAX_LEN = 64 +DIALOG_SESSION_CACHE_MAX = 200 +DIALOG_SESSION_TTL_SEC = 3600 # 1 hour — last-player memory per session diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py new file mode 100644 index 0000000000..b226d73cb5 --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -0,0 +1,269 @@ +"""HTTP handler for the Yandex Dialogs «Навык» webhook (experimental). + +Registers a single dynamic route on the MA webserver: + + POST /api/yandex_dialogs/webhook/{secret} + +Yandex Dialogs does not send an Authorization header on webhook calls, +so authentication is two-layered: + + 1. Path secret (``CONF_DIALOG_WEBHOOK_SECRET``) — random UUID stored + only in the user's MA config and in the skill's Backend URL. Knowing + it requires access to the Yandex Dialogs developer console. + 2. ``body.session.skill_id == CONF_DIALOG_SKILL_ID`` — sanity check; + skill_id is not secret on its own but stops cross-skill misroutes. + +A request is rejected with 404 if the secret doesn't match (no leak via +401 timing) and with 401 if the skill_id doesn't match (configured +skill received a payload from a different skill — should never happen). +""" + +from __future__ import annotations + +import asyncio +import logging +import secrets +import time +from collections import OrderedDict +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from aiohttp import web + +from .constants import ( + DIALOG_RESOLVE_TIMEOUT, + DIALOG_SESSION_CACHE_MAX, + DIALOG_SESSION_TTL_SEC, + DIALOG_WEBHOOK_BASE_PATH, +) +from .dialogs_nlu import parse_command, resolve_player +from .dialogs_player import play_for_alice, resolve_query + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +_LOGGER = logging.getLogger(__name__) + + +class DialogsWebhookHandler: + """Handles incoming voice-command webhook calls from a Yandex Dialogs skill.""" + + def __init__( + self, + mass: MusicAssistant, + *, + skill_id: str, + webhook_secret: str, + exposed_player_ids: set[str] | None = None, + logger: logging.Logger | None = None, + ) -> None: + """Initialize the handler. + + Args: + mass: MusicAssistant instance. + skill_id: Configured ``CONF_DIALOG_SKILL_ID``; payloads with a + different ``session.skill_id`` are rejected. + webhook_secret: Random secret embedded in the webhook URL. + exposed_player_ids: Optional restriction set; only these players + are addressable by voice (passed to the player resolver). + logger: Optional logger override. + """ + self._mass = mass + self._skill_id = skill_id + self._webhook_secret = webhook_secret + self._exposed_player_ids = exposed_player_ids + self._logger = logger or _LOGGER + self._unregister_callbacks: list[Callable[[], None]] = [] + self._last_player: OrderedDict[str, tuple[str, float]] = OrderedDict() + + def register_routes(self) -> None: + """Register the webhook route on mass.webserver.""" + path = f"{DIALOG_WEBHOOK_BASE_PATH}/{self._webhook_secret}" + try: + unregister = self._mass.webserver.register_dynamic_route( + path, self._handle_webhook, "POST" + ) + except RuntimeError: + self._logger.exception("Failed to register Dialogs webhook route %s", path) + raise + self._unregister_callbacks.append(unregister) + self._logger.info("Dialogs webhook registered at %s", path) + + def unregister_routes(self) -> None: + """Unregister the webhook route.""" + for cb in self._unregister_callbacks: + try: + cb() + except Exception: + self._logger.debug("Error unregistering dialog route", exc_info=True) + self._unregister_callbacks.clear() + + # ------------------------------------------------------------------- + # Session memory + # ------------------------------------------------------------------- + + def _remember_player(self, session_id: str, player_id: str) -> None: + now = time.monotonic() + self._last_player[session_id] = (player_id, now) + self._last_player.move_to_end(session_id) + # Evict oldest by insertion order if cap exceeded. + while len(self._last_player) > DIALOG_SESSION_CACHE_MAX: + self._last_player.popitem(last=False) + # Also evict TTL-expired entries opportunistically. + cutoff = now - DIALOG_SESSION_TTL_SEC + for sid in list(self._last_player.keys()): + if self._last_player[sid][1] < cutoff: + self._last_player.pop(sid, None) + else: + break + + def _get_default_player(self, session_id: str) -> str | None: + entry = self._last_player.get(session_id) + if entry is None: + return None + player_id, ts = entry + if time.monotonic() - ts > DIALOG_SESSION_TTL_SEC: + self._last_player.pop(session_id, None) + return None + return player_id + + # ------------------------------------------------------------------- + # Webhook entry point + # ------------------------------------------------------------------- + + async def _handle_webhook(self, request: web.Request) -> web.Response: + # Path secret already enforced by the route URL — getting here means + # the secret matches. Still constant-time-compare it via the captured + # path arg in case aiohttp routing ever changes. + url_secret = request.match_info.get("secret") or request.path.rsplit("/", 1)[-1] + if not secrets.compare_digest(url_secret, self._webhook_secret): + return web.Response(status=404) + + try: + body = await request.json() + except Exception: + return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") + if not isinstance(body, dict): + return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") + + session = body.get("session") or {} + if not isinstance(session, dict): + session = {} + req = body.get("request") or {} + if not isinstance(req, dict): + req = {} + + # skill_id sanity check — reject if absent or mismatched. + incoming_skill_id = str(session.get("skill_id") or "") + if not incoming_skill_id or not secrets.compare_digest(incoming_skill_id, self._skill_id): + self._logger.warning( + "Rejecting dialog payload: skill_id %r != configured %r", + incoming_skill_id or "", + self._skill_id, + ) + return web.Response(status=401) + + session_id = str(session.get("session_id") or "") + is_new = bool(session.get("new")) + command = str(req.get("command") or "").strip() + + if is_new and not command: + return self._yandex_response( + session_state=session, + text="Привет! Скажи, что включить и на какой колонке.", + end_session=False, + ) + + if not command: + return self._yandex_response( + session_state=session, + text="Не понял команду. Скажи, например: включи рок на кухне.", + end_session=False, + ) + + parsed = parse_command(command) + self._logger.debug("Parsed dialog command %r → %r", command, parsed) + + default_id = self._get_default_player(session_id) if session_id else None + player = resolve_player( + self._mass, + parsed.player_hint, + default_id=default_id, + exposed_ids=self._exposed_player_ids, + ) + if player is None: + hint = parsed.player_hint or "(не указано)" + return self._yandex_response( + session_state=session, + text=f"Не нашёл колонку «{hint}». Скажи, например: на кухне.", + end_session=False, + ) + + try: + media = await asyncio.wait_for( + resolve_query(self._mass, parsed), timeout=DIALOG_RESOLVE_TIMEOUT + ) + except TimeoutError: + self._logger.warning( + "Music search timed out (>%.1fs) for query %r", DIALOG_RESOLVE_TIMEOUT, parsed.query + ) + return self._yandex_response( + session_state=session, + text="Поиск занял слишком долго, попробуй ещё раз.", + ) + + if media is None: + return self._yandex_response( + session_state=session, + text=f"Не нашёл такую музыку: {parsed.query}.", + ) + + # Fire-and-forget — Alice has a 4.5s budget; play_media may take longer + # to actually start streaming. mass.create_task tracks the task in the + # MA lifecycle (cancelled on shutdown) and logs unhandled exceptions. + self._mass.create_task( + play_for_alice( + self._mass, + player.player_id, + media, + radio_mode=parsed.radio_mode, + ) + ) + + if session_id: + self._remember_player(session_id, player.player_id) + + spoken_query = parsed.query or "музыку" + return self._yandex_response( + session_state=session, + text=f"Включаю {spoken_query} на {player.name or player.player_id}.", + ) + + # ------------------------------------------------------------------- + # Yandex Dialogs response envelope + # ------------------------------------------------------------------- + + @staticmethod + def _yandex_response( + *, + session_state: dict[str, Any], + text: str, + end_session: bool = True, + ) -> web.Response: + """Build a minimal Yandex Dialogs response envelope.""" + echoed = { + "session_id": session_state.get("session_id", ""), + "message_id": session_state.get("message_id", 0), + "user_id": session_state.get("user_id", ""), + } + payload = { + "version": "1.0", + "session": echoed, + "response": { + "text": text, + "tts": text, + "end_session": end_session, + }, + } + return web.json_response(payload) diff --git a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py new file mode 100644 index 0000000000..b7a3b3f153 --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py @@ -0,0 +1,256 @@ +"""Server-side NLU parser for Yandex Dialogs «Навык» webhook commands. + +The plugin's Dialogs skill registers in the Yandex Dialogs UI without +declared intents/slots — Yandex passes the raw user phrase as +``request.command``. We classify it ourselves: kind (track/artist/album/ +playlist/my_wave/genre/search), search query, optional player hint. + +Pure-Python; no MA-API dependency for the parser itself, only for +``resolve_player`` which iterates ``mass.players.all_players()``. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +_LOGGER = logging.getLogger(__name__) + +CommandKind = Literal["track", "artist", "album", "playlist", "my_wave", "genre", "search"] + + +@dataclass(frozen=True, slots=True) +class ParsedCommand: + """Result of classifying a Yandex Dialogs voice command.""" + + kind: CommandKind + query: str + player_hint: str | None = None + radio_mode: bool = False + + +# --------------------------------------------------------------------------- +# Command parser +# --------------------------------------------------------------------------- + +# Punctuation we strip up-front. Keep apostrophes (e.g. "rock'n'roll") and +# hyphens (e.g. "rock-n-roll") inside words. +_PUNCT_RE = re.compile(r"[!?.,;:«»\"„“]") +_SPACE_RE = re.compile(r"\s+") + +# "на " suffix. The hint can be multi-word ("на кухонной колонке"). +# Anchored to a word boundary so "Натали" isn't mis-split. +_PLAYER_SUFFIX_RE = re.compile(r"\s+на\s+(?P.+?)\s*$", re.IGNORECASE) + +# Verb stem covering "включи", "включай", "включите", "поставь", "запусти". +_VERB_RE = re.compile( + r"^(?:алиса[, ]+)?(?:включи(?:те)?|включай(?:те)?|поставь(?:те)?|запусти(?:те)?)\s+", + re.IGNORECASE, +) + +# Type prefixes inside the intent part. Order matters: longer keywords first. +_KIND_RULES: tuple[tuple[re.Pattern[str], CommandKind, bool], ...] = ( + # my_wave / личная волна — no query, the verb is everything + (re.compile(r"^(?:мою|свою|нашу)\s+волну\b", re.IGNORECASE), "my_wave", True), + (re.compile(r"^мо[её]\s+радио\b", re.IGNORECASE), "my_wave", True), + # playlist + (re.compile(r"^(?:плейлист|подборку|подборка)\s+(.+)$", re.IGNORECASE), "playlist", False), + # album + (re.compile(r"^(?:альбом|пластинку|пластинка)\s+(.+)$", re.IGNORECASE), "album", False), + # artist (radio mode) + ( + re.compile(r"^(?:исполнителя|артиста|группу|группа)\s+(.+)$", re.IGNORECASE), + "artist", + True, + ), + # explicit track marker + ( + re.compile(r"^(?:песню|трек|композицию|композиция)\s+(.+)$", re.IGNORECASE), + "track", + False, + ), + # explicit radio + (re.compile(r"^радио\s+(.+)$", re.IGNORECASE), "genre", True), + # genre marker + (re.compile(r"^жанр\s+(.+)$", re.IGNORECASE), "genre", True), +) + + +def parse_command(text: str) -> ParsedCommand: + """Parse a raw voice command into a structured ParsedCommand. + + Examples: + "включи Metallica на кухне" → kind=search, query=metallica, hint=кухне + "включи песню Yesterday" → kind=track, query=yesterday + "включи альбом Black Album на спальне" → kind=album, query=black album, hint=спальне + "включи исполнителя Metallica" → kind=artist, query=metallica, radio_mode=True + "включи мою волну" → kind=my_wave, query=, radio_mode=True + "включи джаз" → kind=search, query=джаз + "включи жанр джаз" → kind=genre, query=джаз, radio_mode=True + """ + if not text: + return ParsedCommand(kind="search", query="") + + cleaned = _PUNCT_RE.sub(" ", text) + cleaned = _SPACE_RE.sub(" ", cleaned).strip() + + # Strip "Алиса, …" prefix if present (Yandex usually does this, but defensively). + cleaned = re.sub(r"^алиса[,\s]+", "", cleaned, flags=re.IGNORECASE) + + # Split off "на " suffix. + player_hint: str | None = None + if match := _PLAYER_SUFFIX_RE.search(cleaned): + player_hint = match.group("hint").strip().lower() + cleaned = cleaned[: match.start()].strip() + + # Strip the verb at the start ("включи …", "поставь …"). + intent_part = _VERB_RE.sub("", cleaned).strip() + + if not intent_part: + return ParsedCommand(kind="search", query="", player_hint=player_hint) + + # Try kind rules in order. + for pattern, kind, radio in _KIND_RULES: + if rule_match := pattern.match(intent_part): + query = rule_match.group(1).strip() if rule_match.groups() else "" + return ParsedCommand( + kind=kind, + query=query.lower(), + player_hint=player_hint, + radio_mode=radio, + ) + + # Fallback: unstructured search — let mass.music.search figure out the type. + return ParsedCommand( + kind="search", + query=intent_part.lower(), + player_hint=player_hint, + radio_mode=False, + ) + + +# --------------------------------------------------------------------------- +# Player resolver +# --------------------------------------------------------------------------- + +# Common Russian inflection suffixes we strip for fuzzy player-name matching. +# Not a full lemmatizer — picks up the most frequent endings for short names. +# Order: longest first so "ой" matches before "й". +_INFLECTION_SUFFIXES = ( + "ого", + "ому", + "ыми", + "ой", + "ом", + "ым", + "ы", + "е", + "у", + "а", + "и", + "й", + "ь", +) + + +def _normalize_player_token(name: str) -> str: + """Lowercase + strip common Russian inflection suffix. + + Applied to both haystack (player.name) and needle (hint) so they + match each other after the same shaping. + """ + norm = name.lower().strip() + norm = _PUNCT_RE.sub(" ", norm) + norm = _SPACE_RE.sub(" ", norm).strip() + # Strip a trailing inflection suffix from each whitespace-separated token. + parts: list[str] = [] + for token in norm.split(): + stemmed = token + for suffix in _INFLECTION_SUFFIXES: + if len(stemmed) > len(suffix) + 2 and stemmed.endswith(suffix): + stemmed = stemmed[: -len(suffix)] + break + parts.append(stemmed) + return " ".join(parts) + + +def resolve_player( + mass: MusicAssistant, + hint: str | None, + *, + default_id: str | None = None, + exposed_ids: set[str] | None = None, +) -> Any: + """Find an MA player by fuzzy-matching the hint string against player names. + + Filters: only players that are available, enabled, and not synced to a + leader (we control the leader, not the followers). Optional ``exposed_ids`` + further restricts to the user's exposed-players list from the SH plugin. + + Disambiguation: + - exact normalized match wins + - else startswith + - else substring + - else None (caller asks Alice for clarification) + """ + candidates: list[Any] = [] + for player in mass.players.all_players(): + if not player.available or not player.enabled: + continue + if getattr(player, "synced_to", None): + continue + if exposed_ids and player.player_id not in exposed_ids: + continue + candidates.append(player) + + if not candidates: + return None + + # Single-player install or no hint → pick the default / only candidate. + if not hint: + if default_id: + for p in candidates: + if p.player_id == default_id: + return p + if len(candidates) == 1: + return candidates[0] + return None + + needle = _normalize_player_token(hint) + if not needle: + return None + + exact: list[Any] = [] + startswith: list[Any] = [] + contains: list[Any] = [] + for p in candidates: + haystack = _normalize_player_token(p.name or p.player_id) + if not haystack: + continue + if haystack == needle: + exact.append(p) + elif haystack.startswith(needle) or needle.startswith(haystack): + startswith.append(p) + elif needle in haystack or haystack in needle: + contains.append(p) + + for tier in (exact, startswith, contains): + if not tier: + continue + if len(tier) > 1: + _LOGGER.warning( + "Player hint %r matches %d players: %s — picking first by name", + hint, + len(tier), + [p.name for p in tier], + ) + tier.sort(key=lambda p: (p.name or p.player_id).lower()) + return tier[0] + + return None diff --git a/music_assistant/providers/yandex_smarthome/dialogs_player.py b/music_assistant/providers/yandex_smarthome/dialogs_player.py new file mode 100644 index 0000000000..ded31d679d --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/dialogs_player.py @@ -0,0 +1,231 @@ +"""Music + player resolvers for the Yandex Dialogs «Навык» webhook. + +`resolve_query` turns a `ParsedCommand` into a concrete URI/MediaItem ready +to feed to `mass.player_queues.play_media`. `play_for_alice` wraps the +power-on + queue-play sequence. + +Bound to MA APIs: `mass.music.search`, `mass.music.get_item_by_uri`, +`mass.player_queues.play_media`, `mass.players.cmd_power`. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from music_assistant_models.enums import MediaType + +if TYPE_CHECKING: + from music_assistant_models.media_items import MediaItemType + + from music_assistant.mass import MusicAssistant + + from .dialogs_nlu import ParsedCommand + + +_LOGGER = logging.getLogger(__name__) + +_SEARCH_LIMIT_DEFAULT = 5 + + +def _has_feature(player: Any, feature_name: str) -> bool: + """Mirror provider.device._has_feature so we don't import device.py from here.""" + features = getattr(player, "supported_features", None) + if not features: + return False + return any( + str(f) == feature_name or getattr(f, "value", None) == feature_name for f in features + ) + + +def _first(items: Any) -> Any: + """Return the first item of a Sequence, or None if empty/not-a-sequence.""" + try: + return next(iter(items)) + except (StopIteration, TypeError): + return None + + +# --------------------------------------------------------------------------- +# Content resolver +# --------------------------------------------------------------------------- + + +async def resolve_query(mass: MusicAssistant, parsed: ParsedCommand) -> MediaItemType | str | None: + """Pick the best media item for the parsed voice command. + + Returns either a MediaItem or a URI string (both accepted by + play_media); None means we couldn't resolve and the webhook handler + should respond with a "not found" message to the user. + """ + if parsed.kind == "my_wave": + return await _resolve_my_wave(mass) + if parsed.kind == "genre": + return await _resolve_genre(mass, parsed.query) + + if not parsed.query: + return None + + # Map kind → search MediaTypes, prefer-library bias on first try. + media_types_by_kind: dict[str, list[MediaType]] = { + "track": [MediaType.TRACK], + "artist": [MediaType.ARTIST], + "album": [MediaType.ALBUM], + "playlist": [MediaType.PLAYLIST], + "search": [MediaType.PLAYLIST, MediaType.ALBUM, MediaType.ARTIST, MediaType.TRACK], + } + media_types = media_types_by_kind.get(parsed.kind, [MediaType.TRACK]) + + try: + results = await mass.music.search( + search_query=parsed.query, + media_types=media_types, + limit=_SEARCH_LIMIT_DEFAULT, + ) + except Exception as exc: + _LOGGER.warning("mass.music.search failed for %r: %s", parsed.query, exc) + return None + + return _pick_from_results(results, parsed.kind) + + +def _pick_from_results(results: object, kind: str) -> MediaItemType | None: + """Pick best MediaItem from SearchResults given the parsed kind.""" + # SearchResults has .artists, .albums, .tracks, .playlists, plus .library_*. + # For "search" (kind=catch-all) prefer playlist > album > artist > track. + order: list[str] + if kind == "search": + order = ["playlists", "albums", "artists", "tracks"] + elif kind == "track": + order = ["tracks"] + elif kind == "artist": + order = ["artists"] + elif kind == "album": + order = ["albums"] + elif kind == "playlist": + order = ["playlists"] + else: + order = ["tracks"] + + for attr in order: + bucket = getattr(results, attr, None) + if not bucket: + continue + item = _first(bucket) + if item is not None: + return item # type: ignore[no-any-return] + return None + + +# --------------------------------------------------------------------------- +# Yandex-specific specials +# --------------------------------------------------------------------------- + + +def _find_yandex_music_provider(mass: MusicAssistant) -> Any: + """Locate the first available yandex_music music provider instance.""" + for attr in ("music_providers", "providers"): + try: + for prov in getattr(mass, attr, ()): + if getattr(prov, "domain", None) == "yandex_music" and getattr( + prov, "available", True + ): + return prov + except Exception: # noqa: S110 + pass + return None + + +async def _resolve_my_wave(mass: MusicAssistant) -> str | None: + """Resolve «Моя волна» — yandex_music rotor station user:onyourwave. + + Returns a track URI from the rotor batch; play_media in radio mode + will keep pulling next tracks via the standard queue radio loop. If + yandex_music isn't installed/available, returns None. + """ + provider = _find_yandex_music_provider(mass) + if provider is None: + _LOGGER.info("My Wave requested but yandex_music provider is not available") + return None + try: + client = getattr(provider, "client", None) + if client is None: + return None + tracks, _batch_id = await client.get_rotor_station_tracks("user:onyourwave") + except Exception as exc: + _LOGGER.warning("My Wave rotor fetch failed: %s", exc) + return None + if not tracks: + return None + first_track = tracks[0] + track_id = getattr(first_track, "id", None) or getattr(first_track, "track_id", None) + if not track_id: + return None + instance_id = getattr(provider, "instance_id", "yandex_music") + return f"yandex_music://{instance_id}/track/{track_id}" + + +async def _resolve_genre(mass: MusicAssistant, query: str) -> MediaItemType | str | None: + """Resolve genre-based radio. + + Best-effort: try yandex_music genre rotor; + fall back to plain artist search with radio_mode upstream. + """ + if not query: + return None + provider = _find_yandex_music_provider(mass) + if provider is not None: + try: + client = getattr(provider, "client", None) + if client is not None: + station_id = f"genre:{query}" + tracks, _ = await client.get_rotor_station_tracks(station_id) + if tracks: + first_track = tracks[0] + track_id = getattr(first_track, "id", None) or getattr( + first_track, "track_id", None + ) + if track_id: + instance_id = getattr(provider, "instance_id", "yandex_music") + return f"yandex_music://{instance_id}/track/{track_id}" + except Exception as exc: + _LOGGER.debug("Genre rotor fallback for %r: %s", query, exc) + + # Generic fallback: search across artists+tracks; caller will use radio_mode. + try: + results = await mass.music.search( + search_query=query, + media_types=[MediaType.ARTIST, MediaType.TRACK], + limit=_SEARCH_LIMIT_DEFAULT, + ) + except Exception as exc: + _LOGGER.warning("Genre fallback search failed for %r: %s", query, exc) + return None + return _first(getattr(results, "artists", None) or []) or _first( # type: ignore[no-any-return] + getattr(results, "tracks", None) or [] + ) + + +# --------------------------------------------------------------------------- +# Playback +# --------------------------------------------------------------------------- + + +async def play_for_alice( + mass: MusicAssistant, + player_id: str, + media: MediaItemType | str, + *, + radio_mode: bool = False, +) -> None: + """Power the player on if needed, then start playback via player_queues.""" + player = mass.players.get_player(player_id) + if player is not None and _has_feature(player, "power"): + powered = getattr(player, "powered", None) + if powered is False: + try: + await mass.players.cmd_power(player_id, True) + except Exception as exc: + _LOGGER.warning("cmd_power(True) on %s failed: %s", player_id, exc) + + await mass.player_queues.play_media(queue_id=player_id, media=media, radio_mode=radio_mode) diff --git a/music_assistant/providers/yandex_smarthome/plugin.py b/music_assistant/providers/yandex_smarthome/plugin.py index 9ffddd55d1..c32a4073f6 100644 --- a/music_assistant/providers/yandex_smarthome/plugin.py +++ b/music_assistant/providers/yandex_smarthome/plugin.py @@ -31,6 +31,9 @@ CONF_CLOUD_INSTANCE_ID, CONF_CLOUD_INSTANCE_PASSWORD, CONF_CONNECTION_TYPE, + CONF_DIALOG_SKILL_ENABLED, + CONF_DIALOG_SKILL_ID, + CONF_DIALOG_WEBHOOK_SECRET, CONF_DIRECT_ACCESS_TOKEN, CONF_DIRECT_CLIENT_SECRET, CONF_EXPOSED_PLAYERS, @@ -44,6 +47,7 @@ MAX_INPUT_SOURCES, YANDEX_DIALOGS_CALLBACK_BASE, ) +from .dialogs import DialogsWebhookHandler from .direct import DirectConnectionHandler from .handlers import ( build_response, @@ -68,6 +72,7 @@ class YandexSmartHomePlugin(PluginProvider): _cloud_manager: CloudManager | None = None _state_notifier: StateNotifier | None = None _direct_handler: DirectConnectionHandler | None = None + _dialogs_handler: DialogsWebhookHandler | None = None _cloud_task: Any = None _user_id: str = "" @@ -94,6 +99,11 @@ async def handle_async_init(self) -> None: self._direct_access_token = str(self.config.get_value(CONF_DIRECT_ACCESS_TOKEN) or "") self._direct_client_secret = str(self.config.get_value(CONF_DIRECT_CLIENT_SECRET) or "") + # Dialog skill (experimental, direct-mode only) + self._dialog_skill_enabled = bool(self.config.get_value(CONF_DIALOG_SKILL_ENABLED)) + self._dialog_skill_id = str(self.config.get_value(CONF_DIALOG_SKILL_ID) or "") + self._dialog_webhook_secret = str(self.config.get_value(CONF_DIALOG_WEBHOOK_SECRET) or "") + # Parse exposed players filter exposed_raw = self.config.get_value(CONF_EXPOSED_PLAYERS) or [] if isinstance(exposed_raw, str): @@ -254,6 +264,26 @@ def _on_token_created(token: str) -> None: ) await self._state_notifier.start() + # Experimental: Dialogs voice skill webhook handler + if self._dialog_skill_enabled: + if self._dialog_skill_id and self._dialog_webhook_secret: + self._dialogs_handler = DialogsWebhookHandler( + mass=self.mass, + skill_id=self._dialog_skill_id, + webhook_secret=self._dialog_webhook_secret, + exposed_player_ids=self._exposed_ids, + ) + self._dialogs_handler.register_routes() + self.logger.info( + "Dialogs voice skill enabled (experimental), skill_id=%s", + self._dialog_skill_id, + ) + else: + self.logger.warning( + "Dialogs voice skill is enabled but dialog_skill_id or " + "dialog_webhook_secret is not configured — skipping" + ) + self.logger.info("Direct connection mode started") async def _handle_cloud_request(self, request: CloudRequest) -> dict[str, Any]: @@ -328,6 +358,10 @@ async def unload(self, is_removed: bool = False) -> None: await self._state_notifier.stop() self._state_notifier = None + if self._dialogs_handler: + self._dialogs_handler.unregister_routes() + self._dialogs_handler = None + if self._direct_handler: self._direct_handler.unregister_routes() self._direct_handler = None diff --git a/tests/providers/yandex_smarthome/test_auto_skill.py b/tests/providers/yandex_smarthome/test_auto_skill.py index a409a0b26b..c0a096148d 100644 --- a/tests/providers/yandex_smarthome/test_auto_skill.py +++ b/tests/providers/yandex_smarthome/test_auto_skill.py @@ -21,6 +21,7 @@ DialogsDuplicateSkillError, DialogsSkillCreator, auto_create_skill, + auto_rename_dialog_skill, build_draft_payload, build_oauth_app_payload, check_preconditions, @@ -992,6 +993,171 @@ async def _cm_auth( assert result.state == SkillCreationState.DONE +class TestAutoCreateSkillDialogType: + """auto_create_skill with skill_type='dialog' uses dialog channel and draft builder.""" + + @pytest.mark.asyncio + async def test_dialog_skill_reaches_done(self) -> None: + """Full pipeline with skill_type='dialog' reaches DONE and stores last_known_name.""" + creator = _make_creator_mock() + mass = _mass_with_base_url("https://ma.example.com") + result = await auto_create_skill( + mass=mass, + connection_type=CONNECTION_TYPE_DIRECT, + skill_name="Music Assistant", + artifacts=SkillCreationArtifacts(), + cloud_instance_id="", + direct_client_secret="secret-123", + logo_bytes=b"\x89PNG", + session_id="test-session", + skill_type="dialog", + dialog_backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/abc123", + authenticator=_fake_authenticator_factory(), + creator_factory=lambda _s: creator, + ) + assert result.state == SkillCreationState.DONE + assert result.last_known_name == "Music Assistant" + creator.create_app.assert_awaited_once() + creator.update_draft.assert_awaited_once() + + @pytest.mark.asyncio + async def test_dialog_draft_uses_dialog_backend_uri(self) -> None: + """update_draft receives the dialog_backend_uri, not the MA Smart Home URI.""" + creator = _make_creator_mock() + mass = _mass_with_base_url("https://ma.example.com") + backend_uri = "https://ma.example.com/api/yandex_dialogs/webhook/secret42" + await auto_create_skill( + mass=mass, + connection_type=CONNECTION_TYPE_DIRECT, + skill_name="My Skill", + artifacts=SkillCreationArtifacts(), + cloud_instance_id="", + direct_client_secret="sec", + logo_bytes=b"\x89PNG", + session_id="s", + skill_type="dialog", + dialog_backend_uri=backend_uri, + authenticator=_fake_authenticator_factory(), + creator_factory=lambda _s: creator, + ) + _, _, draft = creator.update_draft.call_args.args + assert draft["backendSettings"]["uri"] == backend_uri + assert draft["name"] == "My Skill" + + @pytest.mark.asyncio + async def test_dialog_missing_backend_uri_returns_failed(self) -> None: + """Missing dialog_backend_uri with skill_type='dialog' returns FAILED artifact.""" + creator = _make_creator_mock() + mass = _mass_with_base_url("https://ma.example.com") + result = await auto_create_skill( + mass=mass, + connection_type=CONNECTION_TYPE_DIRECT, + skill_name="Music Assistant", + artifacts=SkillCreationArtifacts(), + cloud_instance_id="", + direct_client_secret="sec", + logo_bytes=b"\x89PNG", + session_id="s", + skill_type="dialog", + dialog_backend_uri=None, + authenticator=_fake_authenticator_factory(), + creator_factory=lambda _s: creator, + ) + assert result.state == SkillCreationState.FAILED + assert "dialog_backend_uri" in (result.last_error or "") + + +class TestAutoRenameDialogSkill: + """auto_rename_dialog_skill patches draft name and re-deploys.""" + + @pytest.mark.asyncio + async def test_rename_happy_path(self) -> None: + """Rename updates last_known_name, calls update_draft and request_deploy once.""" + creator = _make_creator_mock() + mass = _mass_with_base_url("https://ma.example.com") + artifacts = SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="skill-abc", + logo_id="logo-abc", + last_known_name="Old Name", + ) + result = await auto_rename_dialog_skill( + mass=mass, + artifacts=artifacts, + new_name="New Name", + dialog_backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/secret", + session_id="s", + authenticator=_fake_authenticator_factory(), + creator_factory=lambda _s: creator, + ) + assert result.state == SkillCreationState.DONE + assert result.last_known_name == "New Name" + assert result.last_error is None + creator.update_draft.assert_awaited_once() + creator.request_deploy.assert_awaited_once() + + @pytest.mark.asyncio + async def test_rename_updates_draft_with_new_name(self) -> None: + """update_draft receives the new name in the draft payload.""" + creator = _make_creator_mock() + mass = _mass_with_base_url("https://ma.example.com") + artifacts = SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="skill-abc", + logo_id="logo-xyz", + ) + await auto_rename_dialog_skill( + mass=mass, + artifacts=artifacts, + new_name="Renamed Skill", + dialog_backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/s", + session_id="sess", + authenticator=_fake_authenticator_factory(), + creator_factory=lambda _s: creator, + ) + _, _, draft = creator.update_draft.call_args.args + assert draft["name"] == "Renamed Skill" + + @pytest.mark.asyncio + async def test_rename_no_skill_id_returns_failed(self) -> None: + """Missing skill_id returns FAILED without calling the API.""" + mass = _mass_with_base_url("https://ma.example.com") + artifacts = SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id=None) + result = await auto_rename_dialog_skill( + mass=mass, + artifacts=artifacts, + new_name="Whatever", + dialog_backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/s", + session_id="s", + ) + assert result.state == SkillCreationState.FAILED + assert result.last_error is not None + + @pytest.mark.asyncio + async def test_rename_api_error_returns_failed(self) -> None: + """API error on update_draft surfaces as FAILED artifact with last_error set.""" + creator = _make_creator_mock() + creator.update_draft = AsyncMock( + side_effect=DialogsApiError("Bad Request", step="update_draft", http_status=400) + ) + mass = _mass_with_base_url("https://ma.example.com") + artifacts = SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="skill-abc", + ) + result = await auto_rename_dialog_skill( + mass=mass, + artifacts=artifacts, + new_name="Whatever", + dialog_backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/s", + session_id="s", + authenticator=_fake_authenticator_factory(), + creator_factory=lambda _s: creator, + ) + assert result.state == SkillCreationState.FAILED + assert result.last_error is not None + + class TestLoadDefaultLogoBytes: """load_default_logo_bytes reads the bundled PNG from disk.""" diff --git a/tests/providers/yandex_smarthome/test_dialogs.py b/tests/providers/yandex_smarthome/test_dialogs.py new file mode 100644 index 0000000000..91944ce333 --- /dev/null +++ b/tests/providers/yandex_smarthome/test_dialogs.py @@ -0,0 +1,231 @@ +"""Tests for provider/dialogs.py — webhook handler.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock + +import pytest +from aiohttp.test_utils import make_mocked_request + +from music_assistant.providers.yandex_smarthome.dialogs import DialogsWebhookHandler + +if TYPE_CHECKING: + from aiohttp import web + + +@dataclass +class MockPlayer: + """Minimal player stub for webhook handler tests.""" + + player_id: str = "p1" + name: str = "Кухня" + available: bool = True + enabled: bool = True + synced_to: str | None = None + supported_features: set[str] = field(default_factory=set) + powered: bool = True + + +class _MockPlayers: + def __init__(self, players: list[MockPlayer]) -> None: + """Initialise with a fixed player list.""" + self._players = players + self.cmd_power = AsyncMock() + + def all_players(self) -> list[MockPlayer]: + """Return all players.""" + return list(self._players) + + def get_player(self, player_id: str) -> MockPlayer | None: + """Return player by id or None.""" + return next((p for p in self._players if p.player_id == player_id), None) + + +def _make_mass(players: list[MockPlayer], search_track: object = None) -> MagicMock: + mass = MagicMock() + mass.players = _MockPlayers(players) + mass.music = MagicMock() + + @dataclass + class _SearchResults: + artists: list = field(default_factory=list) + albums: list = field(default_factory=list) + tracks: list = field(default_factory=list) + playlists: list = field(default_factory=list) + + if search_track is not None: + mass.music.search = AsyncMock(return_value=_SearchResults(tracks=[search_track])) + else: + mass.music.search = AsyncMock(return_value=_SearchResults()) + + mass.music_providers = [] + mass.providers = [] + mass.player_queues = MagicMock() + mass.player_queues.play_media = AsyncMock() + mass.webserver = MagicMock() + mass.webserver.register_dynamic_route = MagicMock(return_value=lambda: None) + # mass.create_task must actually schedule the coroutine so fire-and-forget + # tasks run when the test awaits asyncio.sleep(0). + mass.create_task = lambda coro, **_kw: asyncio.ensure_future(coro) + return mass + + +_TEST_SECRET = "topsecret" + + +def _build_request(body: dict, secret: str = _TEST_SECRET) -> web.Request: + """Build a mocked aiohttp Request that returns the given JSON body.""" + req = make_mocked_request( + "POST", + f"/api/yandex_dialogs/webhook/{secret}", + match_info={"secret": secret}, + ) + req.json = AsyncMock(return_value=body) # type: ignore[method-assign] + return req + + +@pytest.mark.asyncio +class TestDialogsWebhookHandler: + """End-to-end tests for the webhook entry point.""" + + def _make_handler(self, mass: MagicMock, **kwargs: object) -> DialogsWebhookHandler: + """Build a handler with sensible test defaults.""" + return DialogsWebhookHandler( + mass, + skill_id=str(kwargs.get("skill_id", "skill-uuid-1")), + webhook_secret=str(kwargs.get("webhook_secret", "topsecret")), + exposed_player_ids=kwargs.get("exposed_player_ids"), # type: ignore[arg-type] + ) + + async def test_register_routes_calls_mass_webserver(self) -> None: + """register_routes calls register_dynamic_route with the correct URL.""" + mass = _make_mass([]) + handler = self._make_handler(mass) + handler.register_routes() + mass.webserver.register_dynamic_route.assert_called_once() + path_arg = mass.webserver.register_dynamic_route.call_args[0][0] + assert path_arg == "/api/yandex_dialogs/webhook/topsecret" + + async def test_unregister_routes(self) -> None: + """unregister_routes calls the unregister callback returned by register_dynamic_route.""" + mass = _make_mass([]) + unregister = MagicMock() + mass.webserver.register_dynamic_route = MagicMock(return_value=unregister) + handler = self._make_handler(mass) + handler.register_routes() + handler.unregister_routes() + unregister.assert_called_once() + + async def test_secret_mismatch_returns_404(self) -> None: + """Webhook request with wrong URL secret is rejected with 404.""" + mass = _make_mass([]) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1"}, + "request": {"command": "включи Metallica"}, + } + req = make_mocked_request( + "POST", + "/api/yandex_dialogs/webhook/wrong", + match_info={"secret": "wrong"}, + ) + req.json = AsyncMock(return_value=body) # type: ignore[method-assign] + resp = await handler._handle_webhook(req) + assert resp.status == 404 + + async def test_skill_id_mismatch_returns_401(self) -> None: + """Payload with wrong skill_id is rejected with 401.""" + mass = _make_mass([]) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "different-skill", "session_id": "s1"}, + "request": {"command": "включи Metallica"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 401 + + async def test_session_new_empty_command_greets(self) -> None: + """New session with empty command returns 200 greeting without playing.""" + mass = _make_mass([]) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": True}, + "request": {"command": ""}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + mass.player_queues.play_media.assert_not_awaited() + + async def test_unknown_player_asks_for_clarification(self) -> None: + """Command mentioning an unknown player returns 200 without playing.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Спальня")]) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на Кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + mass.player_queues.play_media.assert_not_awaited() + + async def test_no_results_says_not_found(self) -> None: + """No search results returns 200 without playing.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи nonexistent на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + mass.player_queues.play_media.assert_not_awaited() + + async def test_full_happy_path_starts_play_media(self) -> None: + """Resolved track triggers play_media on the correct player.""" + track = MagicMock(uri="library://track/123", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + # Allow the fire-and-forget task to run. + await asyncio.sleep(0) + mass.player_queues.play_media.assert_awaited_once() + call_kwargs = mass.player_queues.play_media.call_args.kwargs + assert call_kwargs["queue_id"] == "p1" + assert call_kwargs["media"] is track + + async def test_session_remembers_player_for_followups(self) -> None: + """Second command in same session uses the player from the first command.""" + track = MagicMock(uri="library://track/123", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = self._make_handler(mass) + # First command — explicit player hint + await handler._handle_webhook( + _build_request( + { + "session": {"skill_id": "skill-uuid-1", "session_id": "s99", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + ) + ) + await asyncio.sleep(0) + # Second command — no hint, should pick the same player from session memory + mass.player_queues.play_media.reset_mock() + await handler._handle_webhook( + _build_request( + { + "session": {"skill_id": "skill-uuid-1", "session_id": "s99", "new": False}, + "request": {"command": "включи Beatles"}, + } + ) + ) + await asyncio.sleep(0) + mass.player_queues.play_media.assert_awaited_once() + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" diff --git a/tests/providers/yandex_smarthome/test_dialogs_nlu.py b/tests/providers/yandex_smarthome/test_dialogs_nlu.py new file mode 100644 index 0000000000..a7735baedd --- /dev/null +++ b/tests/providers/yandex_smarthome/test_dialogs_nlu.py @@ -0,0 +1,203 @@ +"""Tests for provider/dialogs_nlu.py — voice command parser + player resolver.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import pytest + +from music_assistant.providers.yandex_smarthome.dialogs_nlu import ( + ParsedCommand, + parse_command, + resolve_player, +) + +# --------------------------------------------------------------------------- +# parse_command — table-driven +# --------------------------------------------------------------------------- + + +class TestParseCommand: + """Tests for parse_command across kinds + player suffix.""" + + @pytest.mark.parametrize( + ("phrase", "expected_kind", "expected_query", "expected_hint", "expected_radio"), + [ + # Bare/default search + ("включи Metallica", "search", "metallica", None, False), + ("включи джаз", "search", "джаз", None, False), + # Track explicit + ("включи песню Yesterday", "track", "yesterday", None, False), + ("включи трек Imagine", "track", "imagine", None, False), + # Album explicit + ("включи альбом Black Album", "album", "black album", None, False), + ("включи пластинку Дыхание", "album", "дыхание", None, False), + # Artist explicit (radio) + ("включи исполнителя Metallica", "artist", "metallica", None, True), + ("включи группу Beatles", "artist", "beatles", None, True), + # Playlist explicit + ("включи плейлист утренний джаз", "playlist", "утренний джаз", None, False), + ("включи подборку рок", "playlist", "рок", None, False), + # My wave + ("включи мою волну", "my_wave", "", None, True), + ("включи свою волну", "my_wave", "", None, True), + # Genre / radio + ("включи жанр джаз", "genre", "джаз", None, True), + ("включи радио рок", "genre", "рок", None, True), + # With player suffix + ("включи Metallica на кухне", "search", "metallica", "кухне", False), + ("включи песню Yesterday на спальне", "track", "yesterday", "спальне", False), + ("включи мою волну на кухне", "my_wave", "", "кухне", True), + ("включи альбом Black Album на колонке", "album", "black album", "колонке", False), + # Punctuation, casing, alice prefix + ("Алиса, включи Metallica.", "search", "metallica", None, False), + ("ВКЛЮЧИ ПЕСНЮ Hey Jude!", "track", "hey jude", None, False), + # Different verbs (including включай which was previously unmatched) + ("поставь Metallica", "search", "metallica", None, False), + ("запусти джаз на кухне", "search", "джаз", "кухне", False), + ("включай Metallica", "search", "metallica", None, False), + ("включайте джаз на кухне", "search", "джаз", "кухне", False), + ], + ) + def test_parse( + self, + phrase: str, + expected_kind: str, + expected_query: str, + expected_hint: str | None, + expected_radio: bool, + ) -> None: + """Each parametrized phrase maps to the expected ParsedCommand fields.""" + result = parse_command(phrase) + assert result.kind == expected_kind, f"phrase={phrase!r}" + assert result.query == expected_query, f"phrase={phrase!r}" + assert result.player_hint == expected_hint, f"phrase={phrase!r}" + assert result.radio_mode == expected_radio, f"phrase={phrase!r}" + + def test_empty(self) -> None: + """Empty input returns a search ParsedCommand with empty query.""" + assert parse_command("") == ParsedCommand(kind="search", query="") + + def test_just_alice(self) -> None: + """Bare 'алиса' without a verb keeps the full word as query.""" + assert parse_command("алиса").query == "алиса" + + +# --------------------------------------------------------------------------- +# resolve_player — fixtures + cases +# --------------------------------------------------------------------------- + + +@dataclass +class MockPlayer: + """Minimal player stub for resolver tests.""" + + player_id: str = "p1" + name: str = "Player" + available: bool = True + enabled: bool = True + synced_to: str | None = None + supported_features: set[str] = field(default_factory=set) + + +class MockPlayerController: + """Minimal player controller stub.""" + + def __init__(self, players: list[MockPlayer]) -> None: + """Initialise with a fixed player list.""" + self._players = players + + def all_players(self) -> list[MockPlayer]: + """Return all players.""" + return list(self._players) + + +@dataclass +class MockMass: + """Minimal mass stub for NLU resolver tests.""" + + players: MockPlayerController + + +def _mass(players: list[MockPlayer]) -> MockMass: + return MockMass(players=MockPlayerController(players)) + + +class TestResolvePlayer: + """Tests for resolve_player — fuzzy name matching with Russian inflections.""" + + def test_exact_lowercase_match(self) -> None: + """Exact lowercase hint matches the player with that name.""" + players = [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + result = resolve_player(_mass(players), "кухня") + assert result is not None + assert result.player_id == "p1" + + def test_inflected_match(self) -> None: + """Locative-case hint 'кухне' matches the player named 'Кухня'.""" + players = [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + result = resolve_player(_mass(players), "кухне") + assert result is not None + assert result.player_id == "p1" + + def test_substring_match(self) -> None: + """Short hint matches a player whose name contains it as substring.""" + players = [ + MockPlayer(player_id="p1", name="Sendspin BT Group"), + MockPlayer(player_id="p2", name="Lenco LS-500"), + ] + result = resolve_player(_mass(players), "lenco") + assert result is not None + assert result.player_id == "p2" + + def test_no_match_returns_none(self) -> None: + """Unrecognised hint returns None.""" + players = [MockPlayer(player_id="p1", name="Кухня")] + assert resolve_player(_mass(players), "гостиная") is None + + def test_skips_disabled(self) -> None: + """Disabled players are excluded from candidates.""" + players = [MockPlayer(player_id="p1", name="Кухня", enabled=False)] + assert resolve_player(_mass(players), "кухня") is None + + def test_skips_unavailable(self) -> None: + """Unavailable players are excluded from candidates.""" + players = [MockPlayer(player_id="p1", name="Кухня", available=False)] + assert resolve_player(_mass(players), "кухня") is None + + def test_skips_synced(self) -> None: + """Players synced to another player are excluded from candidates.""" + players = [MockPlayer(player_id="p1", name="Кухня", synced_to="leader")] + assert resolve_player(_mass(players), "кухня") is None + + def test_default_id_used_when_no_hint(self) -> None: + """No hint falls back to default_id.""" + players = [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + result = resolve_player(_mass(players), None, default_id="p2") + assert result is not None + assert result.player_id == "p2" + + def test_single_player_no_hint_picked(self) -> None: + """No hint with a single available player returns that player.""" + players = [MockPlayer(player_id="p1", name="Кухня")] + result = resolve_player(_mass(players), None) + assert result is not None + assert result.player_id == "p1" + + def test_exposed_ids_filter(self) -> None: + """Hint matching a player outside exposed_ids returns None.""" + players = [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + result = resolve_player(_mass(players), "кухня", exposed_ids={"p2"}) + assert result is None diff --git a/tests/providers/yandex_smarthome/test_dialogs_player.py b/tests/providers/yandex_smarthome/test_dialogs_player.py new file mode 100644 index 0000000000..53995ccb3f --- /dev/null +++ b/tests/providers/yandex_smarthome/test_dialogs_player.py @@ -0,0 +1,148 @@ +"""Tests for provider/dialogs_player.py — content resolver + play wrapper.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from music_assistant.providers.yandex_smarthome.dialogs_nlu import ParsedCommand +from music_assistant.providers.yandex_smarthome.dialogs_player import play_for_alice, resolve_query + +# --------------------------------------------------------------------------- +# resolve_query +# --------------------------------------------------------------------------- + + +@dataclass +class _SearchResults: + artists: list = field(default_factory=list) + albums: list = field(default_factory=list) + tracks: list = field(default_factory=list) + playlists: list = field(default_factory=list) + + +def _make_mass(search_results: _SearchResults | None = None) -> MagicMock: + mass = MagicMock() + mass.music = MagicMock() + mass.music.search = AsyncMock(return_value=search_results or _SearchResults()) + mass.music_providers = [] + mass.providers = [] + mass.player_queues = MagicMock() + mass.player_queues.play_media = AsyncMock() + mass.players = MagicMock() + mass.players.get_player = MagicMock(return_value=None) + mass.players.cmd_power = AsyncMock() + return mass + + +@pytest.mark.asyncio +class TestResolveQuery: + """Tests for resolve_query — content resolver dispatching by ParsedCommand.kind.""" + + async def test_track(self) -> None: + """kind=track returns the first track search result.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass(_SearchResults(tracks=[track])) + result = await resolve_query(mass, ParsedCommand(kind="track", query="yesterday")) + assert result is track + mass.music.search.assert_awaited_once() + + async def test_artist(self) -> None: + """kind=artist returns the first artist search result.""" + artist = MagicMock(uri="library://artist/1", spec_set=["uri"]) + mass = _make_mass(_SearchResults(artists=[artist])) + result = await resolve_query( + mass, ParsedCommand(kind="artist", query="metallica", radio_mode=True) + ) + assert result is artist + + async def test_album(self) -> None: + """kind=album returns the first album search result.""" + album = MagicMock(uri="library://album/1", spec_set=["uri"]) + mass = _make_mass(_SearchResults(albums=[album])) + result = await resolve_query(mass, ParsedCommand(kind="album", query="black album")) + assert result is album + + async def test_playlist(self) -> None: + """kind=playlist returns the first playlist search result.""" + playlist = MagicMock(uri="library://playlist/1", spec_set=["uri"]) + mass = _make_mass(_SearchResults(playlists=[playlist])) + result = await resolve_query(mass, ParsedCommand(kind="playlist", query="rock")) + assert result is playlist + + async def test_search_kind_prefers_playlist(self) -> None: + """kind=search prefers playlist over track when both are present.""" + playlist = MagicMock(uri="library://playlist/1", spec_set=["uri"]) + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass(_SearchResults(playlists=[playlist], tracks=[track])) + result = await resolve_query(mass, ParsedCommand(kind="search", query="rock")) + assert result is playlist + + async def test_search_no_results_returns_none(self) -> None: + """Empty search results return None.""" + mass = _make_mass(_SearchResults()) + result = await resolve_query(mass, ParsedCommand(kind="search", query="nope")) + assert result is None + + async def test_my_wave_no_provider_returns_none(self) -> None: + """kind=my_wave without yandex_music provider returns None.""" + mass = _make_mass() + result = await resolve_query(mass, ParsedCommand(kind="my_wave", query="", radio_mode=True)) + assert result is None + + async def test_search_failure_returns_none(self) -> None: + """Search exception is swallowed and returns None.""" + mass = _make_mass() + mass.music.search = AsyncMock(side_effect=RuntimeError("boom")) + result = await resolve_query(mass, ParsedCommand(kind="track", query="x")) + assert result is None + + +# --------------------------------------------------------------------------- +# play_for_alice +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestPlayForAlice: + """Tests for play_for_alice — power-on + play_media orchestration.""" + + async def test_no_player_object_still_plays(self) -> None: + """When player object is missing, play_media is called without cmd_power.""" + mass = _make_mass() + await play_for_alice(mass, "p1", "library://track/1", radio_mode=False) + mass.players.cmd_power.assert_not_awaited() + mass.player_queues.play_media.assert_awaited_once_with( + queue_id="p1", media="library://track/1", radio_mode=False + ) + + async def test_powers_on_when_off(self) -> None: + """Player with power feature and powered=False gets cmd_power before play.""" + mass = _make_mass() + player = MagicMock() + player.supported_features = {"power"} + player.powered = False + mass.players.get_player = MagicMock(return_value=player) + await play_for_alice(mass, "p1", "library://track/1", radio_mode=False) + mass.players.cmd_power.assert_awaited_once_with("p1", True) + mass.player_queues.play_media.assert_awaited_once() + + async def test_skips_power_when_already_on(self) -> None: + """Player already powered=True does not get cmd_power.""" + mass = _make_mass() + player = MagicMock() + player.supported_features = {"power"} + player.powered = True + mass.players.get_player = MagicMock(return_value=player) + await play_for_alice(mass, "p1", "library://track/1", radio_mode=False) + mass.players.cmd_power.assert_not_awaited() + + async def test_radio_mode_passed_through(self) -> None: + """radio_mode=True is forwarded to play_media.""" + mass = _make_mass() + await play_for_alice(mass, "p1", "library://artist/1", radio_mode=True) + mass.player_queues.play_media.assert_awaited_once_with( + queue_id="p1", media="library://artist/1", radio_mode=True + ) From 01c3172a1534afee851b473911794fbc91c016d4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 May 2026 20:12:01 +0000 Subject: [PATCH 13/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.6.1 --- .../yandex_smarthome/test_dialogs.py | 10 +++++----- .../yandex_smarthome/test_dialogs_nlu.py | 20 +++++++++---------- .../yandex_smarthome/test_dialogs_player.py | 8 ++++---- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/tests/providers/yandex_smarthome/test_dialogs.py b/tests/providers/yandex_smarthome/test_dialogs.py index 91944ce333..60f26c6c20 100644 --- a/tests/providers/yandex_smarthome/test_dialogs.py +++ b/tests/providers/yandex_smarthome/test_dialogs.py @@ -51,10 +51,10 @@ def _make_mass(players: list[MockPlayer], search_track: object = None) -> MagicM @dataclass class _SearchResults: - artists: list = field(default_factory=list) - albums: list = field(default_factory=list) - tracks: list = field(default_factory=list) - playlists: list = field(default_factory=list) + artists: list[object] = field(default_factory=list) + albums: list[object] = field(default_factory=list) + tracks: list[object] = field(default_factory=list) + playlists: list[object] = field(default_factory=list) if search_track is not None: mass.music.search = AsyncMock(return_value=_SearchResults(tracks=[search_track])) @@ -76,7 +76,7 @@ class _SearchResults: _TEST_SECRET = "topsecret" -def _build_request(body: dict, secret: str = _TEST_SECRET) -> web.Request: +def _build_request(body: dict[str, object], secret: str = _TEST_SECRET) -> web.Request: """Build a mocked aiohttp Request that returns the given JSON body.""" req = make_mocked_request( "POST", diff --git a/tests/providers/yandex_smarthome/test_dialogs_nlu.py b/tests/providers/yandex_smarthome/test_dialogs_nlu.py index a7735baedd..4c6e76903c 100644 --- a/tests/providers/yandex_smarthome/test_dialogs_nlu.py +++ b/tests/providers/yandex_smarthome/test_dialogs_nlu.py @@ -132,7 +132,7 @@ def test_exact_lowercase_match(self) -> None: MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня"), ] - result = resolve_player(_mass(players), "кухня") + result = resolve_player(_mass(players), "кухня") # type: ignore[arg-type] assert result is not None assert result.player_id == "p1" @@ -142,7 +142,7 @@ def test_inflected_match(self) -> None: MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня"), ] - result = resolve_player(_mass(players), "кухне") + result = resolve_player(_mass(players), "кухне") # type: ignore[arg-type] assert result is not None assert result.player_id == "p1" @@ -152,29 +152,29 @@ def test_substring_match(self) -> None: MockPlayer(player_id="p1", name="Sendspin BT Group"), MockPlayer(player_id="p2", name="Lenco LS-500"), ] - result = resolve_player(_mass(players), "lenco") + result = resolve_player(_mass(players), "lenco") # type: ignore[arg-type] assert result is not None assert result.player_id == "p2" def test_no_match_returns_none(self) -> None: """Unrecognised hint returns None.""" players = [MockPlayer(player_id="p1", name="Кухня")] - assert resolve_player(_mass(players), "гостиная") is None + assert resolve_player(_mass(players), "гостиная") is None # type: ignore[arg-type] def test_skips_disabled(self) -> None: """Disabled players are excluded from candidates.""" players = [MockPlayer(player_id="p1", name="Кухня", enabled=False)] - assert resolve_player(_mass(players), "кухня") is None + assert resolve_player(_mass(players), "кухня") is None # type: ignore[arg-type] def test_skips_unavailable(self) -> None: """Unavailable players are excluded from candidates.""" players = [MockPlayer(player_id="p1", name="Кухня", available=False)] - assert resolve_player(_mass(players), "кухня") is None + assert resolve_player(_mass(players), "кухня") is None # type: ignore[arg-type] def test_skips_synced(self) -> None: """Players synced to another player are excluded from candidates.""" players = [MockPlayer(player_id="p1", name="Кухня", synced_to="leader")] - assert resolve_player(_mass(players), "кухня") is None + assert resolve_player(_mass(players), "кухня") is None # type: ignore[arg-type] def test_default_id_used_when_no_hint(self) -> None: """No hint falls back to default_id.""" @@ -182,14 +182,14 @@ def test_default_id_used_when_no_hint(self) -> None: MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня"), ] - result = resolve_player(_mass(players), None, default_id="p2") + result = resolve_player(_mass(players), None, default_id="p2") # type: ignore[arg-type] assert result is not None assert result.player_id == "p2" def test_single_player_no_hint_picked(self) -> None: """No hint with a single available player returns that player.""" players = [MockPlayer(player_id="p1", name="Кухня")] - result = resolve_player(_mass(players), None) + result = resolve_player(_mass(players), None) # type: ignore[arg-type] assert result is not None assert result.player_id == "p1" @@ -199,5 +199,5 @@ def test_exposed_ids_filter(self) -> None: MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня"), ] - result = resolve_player(_mass(players), "кухня", exposed_ids={"p2"}) + result = resolve_player(_mass(players), "кухня", exposed_ids={"p2"}) # type: ignore[arg-type] assert result is None diff --git a/tests/providers/yandex_smarthome/test_dialogs_player.py b/tests/providers/yandex_smarthome/test_dialogs_player.py index 53995ccb3f..46914f3e68 100644 --- a/tests/providers/yandex_smarthome/test_dialogs_player.py +++ b/tests/providers/yandex_smarthome/test_dialogs_player.py @@ -17,10 +17,10 @@ @dataclass class _SearchResults: - artists: list = field(default_factory=list) - albums: list = field(default_factory=list) - tracks: list = field(default_factory=list) - playlists: list = field(default_factory=list) + artists: list[object] = field(default_factory=list) + albums: list[object] = field(default_factory=list) + tracks: list[object] = field(default_factory=list) + playlists: list[object] = field(default_factory=list) def _make_mass(search_results: _SearchResults | None = None) -> MagicMock: From 18c922fd34304c50020d77b63f73f657d42735b2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 May 2026 20:20:47 +0000 Subject: [PATCH 14/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.6.2 --- music_assistant/providers/yandex_smarthome/dialogs.py | 10 +++++----- .../providers/yandex_smarthome/dialogs_nlu.py | 8 ++++---- tests/providers/yandex_smarthome/test_dialogs.py | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index b226d73cb5..06b6bda645 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -143,9 +143,9 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: try: body = await request.json() except Exception: - return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") + return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") # noqa: RUF001 if not isinstance(body, dict): - return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") + return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") # noqa: RUF001 session = body.get("session") or {} if not isinstance(session, dict): @@ -178,7 +178,7 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: if not command: return self._yandex_response( session_state=session, - text="Не понял команду. Скажи, например: включи рок на кухне.", + text="Не понял команду. Скажи, например: включи рок на кухне.", # noqa: RUF001 end_session=False, ) @@ -196,7 +196,7 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: hint = parsed.player_hint or "(не указано)" return self._yandex_response( session_state=session, - text=f"Не нашёл колонку «{hint}». Скажи, например: на кухне.", + text=f"Не нашёл колонку «{hint}». Скажи, например: на кухне.", # noqa: RUF001 end_session=False, ) @@ -216,7 +216,7 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: if media is None: return self._yandex_response( session_state=session, - text=f"Не нашёл такую музыку: {parsed.query}.", + text=f"Не нашёл такую музыку: {parsed.query}.", # noqa: RUF001 ) # Fire-and-forget — Alice has a 4.5s budget; play_media may take longer diff --git a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py index b7a3b3f153..84bd51b65f 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py @@ -143,16 +143,16 @@ def parse_command(text: str) -> ParsedCommand: # Not a full lemmatizer — picks up the most frequent endings for short names. # Order: longest first so "ой" matches before "й". _INFLECTION_SUFFIXES = ( - "ого", + "ого", # noqa: RUF001 "ому", "ыми", "ой", "ом", "ым", "ы", - "е", - "у", - "а", + "е", # noqa: RUF001 + "у", # noqa: RUF001 + "а", # noqa: RUF001 "и", "й", "ь", diff --git a/tests/providers/yandex_smarthome/test_dialogs.py b/tests/providers/yandex_smarthome/test_dialogs.py index 60f26c6c20..e85591a604 100644 --- a/tests/providers/yandex_smarthome/test_dialogs.py +++ b/tests/providers/yandex_smarthome/test_dialogs.py @@ -4,7 +4,7 @@ import asyncio from dataclasses import dataclass, field -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock import pytest @@ -76,7 +76,7 @@ class _SearchResults: _TEST_SECRET = "topsecret" -def _build_request(body: dict[str, object], secret: str = _TEST_SECRET) -> web.Request: +def _build_request(body: dict[str, Any], secret: str = _TEST_SECRET) -> web.Request: """Build a mocked aiohttp Request that returns the given JSON body.""" req = make_mocked_request( "POST", From 72d55cf39b2c06680bf4d60a5325b1c84e479dac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 06:15:19 +0000 Subject: [PATCH 15/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.6.3 --- .../providers/yandex_smarthome/dialogs.py | 15 ++++++++------- .../providers/yandex_smarthome/dialogs_nlu.py | 8 ++++---- .../providers/yandex_smarthome/dialogs_player.py | 4 ++-- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index 06b6bda645..fbf8acb892 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -80,15 +80,16 @@ def __init__( def register_routes(self) -> None: """Register the webhook route on mass.webserver.""" path = f"{DIALOG_WEBHOOK_BASE_PATH}/{self._webhook_secret}" + redacted = f"{DIALOG_WEBHOOK_BASE_PATH}/...{self._webhook_secret[-4:]}" try: unregister = self._mass.webserver.register_dynamic_route( path, self._handle_webhook, "POST" ) except RuntimeError: - self._logger.exception("Failed to register Dialogs webhook route %s", path) + self._logger.exception("Failed to register Dialogs webhook route %s", redacted) raise self._unregister_callbacks.append(unregister) - self._logger.info("Dialogs webhook registered at %s", path) + self._logger.info("Dialogs webhook registered at %s", redacted) def unregister_routes(self) -> None: """Unregister the webhook route.""" @@ -143,9 +144,9 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: try: body = await request.json() except Exception: - return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") # noqa: RUF001 + return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") if not isinstance(body, dict): - return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") # noqa: RUF001 + return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") session = body.get("session") or {} if not isinstance(session, dict): @@ -178,7 +179,7 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: if not command: return self._yandex_response( session_state=session, - text="Не понял команду. Скажи, например: включи рок на кухне.", # noqa: RUF001 + text="Не понял команду. Скажи, например: включи рок на кухне.", end_session=False, ) @@ -196,7 +197,7 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: hint = parsed.player_hint or "(не указано)" return self._yandex_response( session_state=session, - text=f"Не нашёл колонку «{hint}». Скажи, например: на кухне.", # noqa: RUF001 + text=f"Не нашёл колонку «{hint}». Скажи, например: на кухне.", end_session=False, ) @@ -216,7 +217,7 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: if media is None: return self._yandex_response( session_state=session, - text=f"Не нашёл такую музыку: {parsed.query}.", # noqa: RUF001 + text=f"Не нашёл такую музыку: {parsed.query}.", ) # Fire-and-forget — Alice has a 4.5s budget; play_media may take longer diff --git a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py index 84bd51b65f..b7a3b3f153 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py @@ -143,16 +143,16 @@ def parse_command(text: str) -> ParsedCommand: # Not a full lemmatizer — picks up the most frequent endings for short names. # Order: longest first so "ой" matches before "й". _INFLECTION_SUFFIXES = ( - "ого", # noqa: RUF001 + "ого", "ому", "ыми", "ой", "ом", "ым", "ы", - "е", # noqa: RUF001 - "у", # noqa: RUF001 - "а", # noqa: RUF001 + "е", + "у", + "а", "и", "й", "ь", diff --git a/music_assistant/providers/yandex_smarthome/dialogs_player.py b/music_assistant/providers/yandex_smarthome/dialogs_player.py index ded31d679d..9246aab1de 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_player.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_player.py @@ -162,7 +162,7 @@ async def _resolve_my_wave(mass: MusicAssistant) -> str | None: if not track_id: return None instance_id = getattr(provider, "instance_id", "yandex_music") - return f"yandex_music://{instance_id}/track/{track_id}" + return f"{instance_id}://track/{track_id}" async def _resolve_genre(mass: MusicAssistant, query: str) -> MediaItemType | str | None: @@ -187,7 +187,7 @@ async def _resolve_genre(mass: MusicAssistant, query: str) -> MediaItemType | st ) if track_id: instance_id = getattr(provider, "instance_id", "yandex_music") - return f"yandex_music://{instance_id}/track/{track_id}" + return f"{instance_id}://track/{track_id}" except Exception as exc: _LOGGER.debug("Genre rotor fallback for %r: %s", query, exc) From dfaab6b131022abb5f569dba6d711c4558ce61f2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 06:31:35 +0000 Subject: [PATCH 16/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.6.4 --- music_assistant/providers/yandex_smarthome/dialogs.py | 10 +++++----- .../providers/yandex_smarthome/dialogs_nlu.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index fbf8acb892..0da8f7841f 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -144,9 +144,9 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: try: body = await request.json() except Exception: - return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") + return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") # noqa: RUF001 if not isinstance(body, dict): - return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") + return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") # noqa: RUF001 session = body.get("session") or {} if not isinstance(session, dict): @@ -179,7 +179,7 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: if not command: return self._yandex_response( session_state=session, - text="Не понял команду. Скажи, например: включи рок на кухне.", + text="Не понял команду. Скажи, например: включи рок на кухне.", # noqa: RUF001 end_session=False, ) @@ -197,7 +197,7 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: hint = parsed.player_hint or "(не указано)" return self._yandex_response( session_state=session, - text=f"Не нашёл колонку «{hint}». Скажи, например: на кухне.", + text=f"Не нашёл колонку «{hint}». Скажи, например: на кухне.", # noqa: RUF001 end_session=False, ) @@ -217,7 +217,7 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: if media is None: return self._yandex_response( session_state=session, - text=f"Не нашёл такую музыку: {parsed.query}.", + text=f"Не нашёл такую музыку: {parsed.query}.", # noqa: RUF001 ) # Fire-and-forget — Alice has a 4.5s budget; play_media may take longer diff --git a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py index b7a3b3f153..84bd51b65f 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py @@ -143,16 +143,16 @@ def parse_command(text: str) -> ParsedCommand: # Not a full lemmatizer — picks up the most frequent endings for short names. # Order: longest first so "ой" matches before "й". _INFLECTION_SUFFIXES = ( - "ого", + "ого", # noqa: RUF001 "ому", "ыми", "ой", "ом", "ым", "ы", - "е", - "у", - "а", + "е", # noqa: RUF001 + "у", # noqa: RUF001 + "а", # noqa: RUF001 "и", "й", "ь", From 5628fe8eda1e3954c7c84ef368cd4040cb75f157 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 06:36:42 +0000 Subject: [PATCH 17/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.6.5 --- .../providers/yandex_smarthome/__init__.py | 7 ++++++- music_assistant/providers/yandex_smarthome/dialogs.py | 2 ++ .../providers/yandex_smarthome/dialogs_player.py | 11 +++++++++++ .../providers/yandex_smarthome/playlists.py | 3 +++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index f19ab49553..1fe0a18fc3 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -431,9 +431,14 @@ async def get_config_entries( # noqa: PLR0915 # Build playlist options from MA library (any music provider). Fail-soft: empty # list if music controller isn't ready (e.g. provider load order at first run). + # CancelledError must propagate so config-flow cancellation/shutdown work. playlist_options: list[ConfigValueOption] = [] - with contextlib.suppress(Exception): + try: playlist_options = await fetch_playlist_options(mass) + except asyncio.CancelledError: + raise + except Exception: # noqa: S110 + pass entries: list[ConfigEntry] = [ # Instance name diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index 0da8f7841f..0e2ad4b140 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -143,6 +143,8 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: try: body = await request.json() + except asyncio.CancelledError: + raise except Exception: return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") # noqa: RUF001 if not isinstance(body, dict): diff --git a/music_assistant/providers/yandex_smarthome/dialogs_player.py b/music_assistant/providers/yandex_smarthome/dialogs_player.py index 9246aab1de..6e44709378 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_player.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_player.py @@ -10,6 +10,7 @@ from __future__ import annotations +import asyncio import logging from typing import TYPE_CHECKING, Any @@ -82,6 +83,8 @@ async def resolve_query(mass: MusicAssistant, parsed: ParsedCommand) -> MediaIte media_types=media_types, limit=_SEARCH_LIMIT_DEFAULT, ) + except asyncio.CancelledError: + raise except Exception as exc: _LOGGER.warning("mass.music.search failed for %r: %s", parsed.query, exc) return None @@ -152,6 +155,8 @@ async def _resolve_my_wave(mass: MusicAssistant) -> str | None: if client is None: return None tracks, _batch_id = await client.get_rotor_station_tracks("user:onyourwave") + except asyncio.CancelledError: + raise except Exception as exc: _LOGGER.warning("My Wave rotor fetch failed: %s", exc) return None @@ -188,6 +193,8 @@ async def _resolve_genre(mass: MusicAssistant, query: str) -> MediaItemType | st if track_id: instance_id = getattr(provider, "instance_id", "yandex_music") return f"{instance_id}://track/{track_id}" + except asyncio.CancelledError: + raise except Exception as exc: _LOGGER.debug("Genre rotor fallback for %r: %s", query, exc) @@ -198,6 +205,8 @@ async def _resolve_genre(mass: MusicAssistant, query: str) -> MediaItemType | st media_types=[MediaType.ARTIST, MediaType.TRACK], limit=_SEARCH_LIMIT_DEFAULT, ) + except asyncio.CancelledError: + raise except Exception as exc: _LOGGER.warning("Genre fallback search failed for %r: %s", query, exc) return None @@ -225,6 +234,8 @@ async def play_for_alice( if powered is False: try: await mass.players.cmd_power(player_id, True) + except asyncio.CancelledError: + raise except Exception as exc: _LOGGER.warning("cmd_power(True) on %s failed: %s", player_id, exc) diff --git a/music_assistant/providers/yandex_smarthome/playlists.py b/music_assistant/providers/yandex_smarthome/playlists.py index 3691932486..e517d1ddda 100644 --- a/music_assistant/providers/yandex_smarthome/playlists.py +++ b/music_assistant/providers/yandex_smarthome/playlists.py @@ -7,6 +7,7 @@ from __future__ import annotations +import asyncio import logging from typing import TYPE_CHECKING @@ -36,6 +37,8 @@ async def fetch_playlist_options(mass: MusicAssistant) -> list[ConfigValueOption provider_label = playlist.provider or "" title = f"{playlist.name} ({provider_label})" if provider_label else playlist.name options.append(ConfigValueOption(title=title, value=playlist.uri)) + except asyncio.CancelledError: + raise except Exception as exc: # Fail-soft: this runs on every config-form render and races with # provider/database startup. Don't spam stack traces — debug-level From 687b293a9e1592dac11de954152708ca0adc5759 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 06:49:47 +0000 Subject: [PATCH 18/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.6.6 --- .../providers/yandex_smarthome/constants.py | 11 +++++--- .../yandex_smarthome/test_dialogs.py | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/constants.py b/music_assistant/providers/yandex_smarthome/constants.py index d4c2d0c621..012947a630 100644 --- a/music_assistant/providers/yandex_smarthome/constants.py +++ b/music_assistant/providers/yandex_smarthome/constants.py @@ -2,6 +2,8 @@ from __future__ import annotations +import os + # --------------------------------------------------------------------------- # Config entry keys # --------------------------------------------------------------------------- @@ -149,9 +151,12 @@ DIALOG_RESOLVE_TIMEOUT = 2.5 DIALOG_DEFAULT_NAME = "Music Assistant" # Yandex Dialogs app-store-api channel string for «Навык» (custom dialog skill). -# Smart Home uses "smartHome" — value below is best guess pending manual probe. -# See plan probe checklist (a) — adjust here if Yandex returns 4xx for "dialog". -DIALOG_CHANNEL = "dialog" +# Smart Home uses "smartHome"; the Dialogs «Навык» channel string is not +# documented in the public app-store-api. Default below ("dialog") is our best +# guess. If Yandex returns a 4xx during auto-create with this value, the user +# can override via the MA_YANDEX_DIALOG_CHANNEL environment variable without +# editing the code (e.g. set MA_YANDEX_DIALOG_CHANNEL=general for a probe). +DIALOG_CHANNEL = os.environ.get("MA_YANDEX_DIALOG_CHANNEL", "dialog") DIALOG_NAME_MIN_LEN = 2 DIALOG_NAME_MAX_LEN = 64 DIALOG_SESSION_CACHE_MAX = 200 diff --git a/tests/providers/yandex_smarthome/test_dialogs.py b/tests/providers/yandex_smarthome/test_dialogs.py index e85591a604..69a46ed302 100644 --- a/tests/providers/yandex_smarthome/test_dialogs.py +++ b/tests/providers/yandex_smarthome/test_dialogs.py @@ -136,6 +136,31 @@ async def test_secret_mismatch_returns_404(self) -> None: resp = await handler._handle_webhook(req) assert resp.status == 404 + async def test_secret_parsed_from_path_when_no_match_info(self) -> None: + """Cover the production secret-from-path fallback in `_handle_webhook`. + + Production registers an exact route (no `{secret}` variable), so + `request.match_info` is empty and the handler parses the secret + from `request.path`. This test passes `match_info={}` to exercise + that branch. + """ + track = MagicMock(uri="library://track/123", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + req = make_mocked_request( + "POST", + f"/api/yandex_dialogs/webhook/{_TEST_SECRET}", + match_info={}, + ) + req.json = AsyncMock(return_value=body) # type: ignore[method-assign] + resp = await handler._handle_webhook(req) + # If path parsing works, secret matches and we reach the play branch (200). + assert resp.status == 200 + async def test_skill_id_mismatch_returns_401(self) -> None: """Payload with wrong skill_id is rejected with 401.""" mass = _make_mass([]) From a830e7ba78e4a3aded0137facf10027edf8f131f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 08:07:32 +0000 Subject: [PATCH 19/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.7.0 --- .../providers/yandex_smarthome/__init__.py | 70 ++++++++++++++++--- .../providers/yandex_smarthome/auto_skill.py | 59 +++++++++++++--- .../yandex_smarthome/auto_skill_ui.py | 34 +++------ .../providers/yandex_smarthome/constants.py | 5 ++ 4 files changed, 125 insertions(+), 43 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index 1fe0a18fc3..efad5d359a 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -63,6 +63,7 @@ CONF_DIRECT_CLIENT_SECRET, CONF_EXPOSED_PLAYERS, CONF_EXPOSED_PLAYLISTS, + CONF_EXTERNAL_BASE_URL, CONF_INSTANCE_NAME, CONF_SKILL_ID, CONF_SKILL_TOKEN, @@ -144,6 +145,32 @@ def _resolve_direct_client_secret( return str(values.get(CONF_DIRECT_CLIENT_SECRET) or "") +def _resolve_external_base_url( + mass: MusicAssistant, + values: dict[str, ConfigValueType] | None = None, +) -> str: + """Return the public-facing Base URL to use for Yandex callbacks/webhooks. + + Priority: + 1. ``CONF_EXTERNAL_BASE_URL`` from values (user-set plugin override) + 2. ``mass.webserver.base_url`` (MA's global setting) + + The override exists so users can keep MA's global Base URL pointing at + the local address (so HA Ingress / local UI keep working) while + exposing a public HTTPS URL only to Yandex via a reverse proxy. + Trailing slashes are stripped. + """ + override = "" + if values is not None: + override = str(values.get(CONF_EXTERNAL_BASE_URL) or "").strip() + if override: + return override.rstrip("/") + fallback = "" + with contextlib.suppress(Exception): + fallback = str(mass.webserver.base_url) + return fallback.rstrip("/") + + def _resolve_dialog_webhook_secret( mass: MusicAssistant, instance_id: str | None, @@ -256,6 +283,7 @@ async def _run_auto_create_action( direct_client_secret=_resolve_direct_client_secret(mass, instance_id, values), logo_bytes=load_default_logo_bytes(), session_id=session_id, + base_url_override=str(values.get(CONF_EXTERNAL_BASE_URL) or "") or None, ) except asyncio.CancelledError: # Preserve cooperative cancellation so config-flow shutdown @@ -311,9 +339,7 @@ async def _run_auto_create_dialog_action( webhook_secret = uuid.uuid4().hex values[CONF_DIALOG_WEBHOOK_SECRET] = webhook_secret - ma_base_url = "" - with contextlib.suppress(Exception): - ma_base_url = str(mass.webserver.base_url) + ma_base_url = _resolve_external_base_url(mass, values) dialog_backend_uri = _build_dialog_backend_uri(ma_base_url, webhook_secret) skill_name = str(values.get(CONF_DIALOG_SKILL_NAME) or DIALOG_DEFAULT_NAME) @@ -330,6 +356,7 @@ async def _run_auto_create_dialog_action( session_id=session_id, skill_type="dialog", dialog_backend_uri=dialog_backend_uri, + base_url_override=str(values.get(CONF_EXTERNAL_BASE_URL) or "") or None, ) except asyncio.CancelledError: raise @@ -363,9 +390,7 @@ async def _run_rename_dialog_action( artifacts = load_artifacts(str(artifacts_raw) if artifacts_raw else None) webhook_secret = _resolve_dialog_webhook_secret(mass, instance_id, values) - ma_base_url = "" - with contextlib.suppress(Exception): - ma_base_url = str(mass.webserver.base_url) + ma_base_url = _resolve_external_base_url(mass, values) dialog_backend_uri = _build_dialog_backend_uri(ma_base_url, webhook_secret) skill_name = str(values.get(CONF_DIALOG_SKILL_NAME) or DIALOG_DEFAULT_NAME) @@ -407,9 +432,7 @@ async def get_config_entries( # noqa: PLR0915 artifacts = load_artifacts(artifacts_str) session_id_val = values.get(CONF_AUTO_CREATE_SESSION_ID) session_id_str = str(session_id_val) if session_id_val else None - ma_base_url_for_ui = "" - with contextlib.suppress(Exception): - ma_base_url_for_ui = str(mass.webserver.base_url) + ma_base_url_for_ui = _resolve_external_base_url(mass, values) is_registered = bool(values.get(CONF_CLOUD_INSTANCE_ID)) and bool( values.get(CONF_CLOUD_CONNECTION_TOKEN) @@ -532,6 +555,35 @@ async def get_config_entries( # noqa: PLR0915 dialog_session_id_val = values.get(CONF_DIALOG_AUTO_CREATE_SESSION_ID) dialog_session_id_str = str(dialog_session_id_val) if dialog_session_id_val else None + # Plugin-local override of MA's webserver Base URL — published to + # Yandex but doesn't touch MA's global setting. Lets users keep the + # global Base URL pointing at the local IP (so HA Ingress / local + # frontend keep working) while still exposing a public HTTPS URL + # to Yandex via a reverse proxy. + ma_global_base_url = "" + with contextlib.suppress(Exception): + ma_global_base_url = str(mass.webserver.base_url) + external_url_description = ( + "Public HTTPS URL of this MA instance, used only for Yandex " + "callbacks and webhooks (Yandex requires HTTPS). Set this if " + "you don't want to change MA's global Base URL — e.g. you reach " + "MA via Home Assistant Ingress and exposing a public URL " + "globally would break local access. Leave empty to use MA's " + f"Base URL ({ma_global_base_url or ''})." + ) + entries.append( + ConfigEntry( + key=CONF_EXTERNAL_BASE_URL, + type=ConfigEntryType.STRING, + label="External Base URL (HTTPS, optional override)", + description=external_url_description, + required=False, + default_value="", + value=str(values.get(CONF_EXTERNAL_BASE_URL) or ""), + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_DIRECT, + ) + ) entries.extend( build_direct_entries( artifacts=artifacts, diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index 2d5e4d33cd..79f6fdcecd 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -522,33 +522,56 @@ def _extract_error_code(body: str) -> str | None: # --------------------------------------------------------------------------- -def derive_backend_uri(mass: MusicAssistant, connection_type: str) -> str: +def _resolve_base_url(mass: MusicAssistant, override: str | None) -> str: + """Pick override (plugin-local) over MA's global webserver.base_url. + + ``override`` is the user's ``CONF_EXTERNAL_BASE_URL`` value if they + chose to set one. Empty/None falls back to ``mass.webserver.base_url``. + """ + if override: + return override.rstrip("/") + return str(mass.webserver.base_url).rstrip("/") + + +def derive_backend_uri( + mass: MusicAssistant, + connection_type: str, + *, + base_url_override: str | None = None, +) -> str: """Return the Backend URL the skill should point at for *connection_type*. cloud_plus → yaha-cloud.ru relay (fixed URL). - direct → ``{mass.webserver.base_url}`` + our API path (requires - HTTPS base URL; see :func:`check_preconditions`). + direct → ``{base_url}`` + our API path (requires HTTPS base URL; + see :func:`check_preconditions`). ``base_url_override``, + if given, takes precedence over ``mass.webserver.base_url``. """ if connection_type == CONNECTION_TYPE_CLOUD_PLUS: return CLOUD_SKILL_WEBHOOK_TEMPLATE if connection_type == CONNECTION_TYPE_DIRECT: - base = str(mass.webserver.base_url).rstrip("/") + base = _resolve_base_url(mass, base_url_override) return f"{base}{DIRECT_API_BASE_PATH}" msg = f"auto-create is not supported for connection_type={connection_type!r}" raise ValueError(msg) -def derive_auth_urls(mass: MusicAssistant, connection_type: str) -> tuple[str, str]: +def derive_auth_urls( + mass: MusicAssistant, + connection_type: str, + *, + base_url_override: str | None = None, +) -> tuple[str, str]: """Return (authorize_url, token_url) for the OAuth app. cloud_plus uses the yaha-cloud relay's OAuth endpoints; direct uses the MA webserver's own authorize/token endpoints (served by - provider/direct.py). + provider/direct.py). ``base_url_override`` takes precedence over + ``mass.webserver.base_url`` for the direct case. """ if connection_type == CONNECTION_TYPE_CLOUD_PLUS: return CLOUD_OAUTH_AUTHORIZE_URL, CLOUD_OAUTH_TOKEN_URL if connection_type == CONNECTION_TYPE_DIRECT: - base = str(mass.webserver.base_url).rstrip("/") + base = _resolve_base_url(mass, base_url_override) return ( f"{base}{DIRECT_AUTH_BASE_PATH}/authorize", f"{base}{DIRECT_AUTH_BASE_PATH}/token", @@ -724,12 +747,18 @@ def check_preconditions( mass: MusicAssistant, cloud_instance_id: str, direct_client_secret: str, + base_url_override: str | None = None, ) -> None: """Validate that auto-create can run for the given connection type. Raises :class:`ValueError` with a human-readable message on failure. Called before any network I/O so the UI can surface the error without a half-created skill on Yandex's side. + + ``base_url_override``, if given, takes precedence over + ``mass.webserver.base_url`` for the HTTPS check (so users can keep + MA's global Base URL local while supplying a public HTTPS URL only + to Yandex via the plugin override). """ if connection_type == CONNECTION_TYPE_CLOUD_PLUS: if not cloud_instance_id: @@ -745,7 +774,7 @@ def check_preconditions( msg = "Direct mode requires a generated Client Secret" raise ValueError(msg) try: - base = str(mass.webserver.base_url) + base = _resolve_base_url(mass, base_url_override) except Exception as exc: msg = f"MA webserver base URL is not available: {exc}" raise ValueError(msg) from exc @@ -1087,6 +1116,7 @@ async def auto_create_skill( # noqa: PLR0913 session_id: str, skill_type: Literal["smart_home", "dialog"] = "smart_home", dialog_backend_uri: str | None = None, + base_url_override: str | None = None, progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None = None, authenticator: Callable[..., AsyncIterator[aiohttp.ClientSession]] | None = None, creator_factory: Callable[[aiohttp.ClientSession], DialogsSkillCreator] | None = None, @@ -1119,6 +1149,7 @@ async def auto_create_skill( # noqa: PLR0913 mass=mass, cloud_instance_id=cloud_instance_id, direct_client_secret=direct_client_secret, + base_url_override=base_url_override, ) if skill_type == "dialog" and not dialog_backend_uri: @@ -1149,6 +1180,7 @@ async def auto_create_skill( # noqa: PLR0913 developer_name=developer_name, skill_type=skill_type, dialog_backend_uri=dialog_backend_uri, + base_url_override=base_url_override, progress_cb=progress_cb, ) except asyncio.CancelledError: @@ -1174,6 +1206,7 @@ async def _run_pipeline_with_recovery( # noqa: PLR0913 developer_name: str, skill_type: Literal["smart_home", "dialog"] = "smart_home", dialog_backend_uri: str | None = None, + base_url_override: str | None = None, progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, ) -> SkillCreationArtifacts: """Fetch CSRF and run the pipeline, preserving partial state on failure. @@ -1208,6 +1241,7 @@ async def _track(a: SkillCreationArtifacts) -> None: developer_name=developer_name, skill_type=skill_type, dialog_backend_uri=dialog_backend_uri, + base_url_override=base_url_override, progress_cb=_track, ) except DialogsApiError as exc: @@ -1229,6 +1263,7 @@ async def _execute_pipeline( # noqa: PLR0913, PLR0915 developer_name: str, skill_type: Literal["smart_home", "dialog"] = "smart_home", dialog_backend_uri: str | None = None, + base_url_override: str | None = None, progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, ) -> SkillCreationArtifacts: """Advance through states sequentially, skipping completed steps.""" @@ -1271,7 +1306,9 @@ async def _execute_pipeline( # noqa: PLR0913, PLR0915 developer_name=developer_name, ) else: - backend_uri = derive_backend_uri(mass, connection_type) + backend_uri = derive_backend_uri( + mass, connection_type, base_url_override=base_url_override + ) draft = build_draft_payload( connection_type=connection_type, skill_name=skill_name, @@ -1297,7 +1334,9 @@ async def _execute_pipeline( # noqa: PLR0913, PLR0915 if connection_type == CONNECTION_TYPE_CLOUD_PLUS else direct_client_secret ) - authorize_url, token_url = derive_auth_urls(mass, connection_type) + authorize_url, token_url = derive_auth_urls( + mass, connection_type, base_url_override=base_url_override + ) _LOGGER.info("auto-skill: [4/5] creating OAuth app + attaching") oauth_app_id = await creator.create_oauth_app( csrf, diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py index 70ddc5c9ce..61aea0f5f0 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py @@ -384,10 +384,10 @@ def _create_skill_step_entries( ) ) - # direct mode prerequisite: Yandex Dialogs only accepts HTTPS - # backends. If MA's Base URL is not HTTPS, show the user what's - # wrong (and what URL we'd have used) so they can fix it in - # Settings → Core → Webserver → Base URL. + # direct mode prerequisite: Yandex requires HTTPS. If the resolved + # base URL (plugin override → MA global) is not HTTPS, point the user + # at the External Base URL field above (preferred) or MA's global + # Base URL setting as a fallback. direct_https_missing = connection_type == CONNECTION_TYPE_DIRECT and not base_url.startswith( "https://" ) @@ -397,29 +397,15 @@ def _create_skill_step_entries( key="label_direct_https_warning", type=ConfigEntryType.LABEL, label=( - f"⚠️ MA's Base URL is {base_url or ''}. " + f"⚠️ Resolved Base URL is {base_url or ''}. " "Direct mode requires a **publicly reachable HTTPS URL** — " "Yandex refuses to talk to a non-HTTPS backend. " - "Set a reverse proxy with a real certificate and " - "update Settings → Core → Webserver → Base URL, then " - "reopen these settings." - ), - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ) - ) - entries.append( - ConfigEntry( - key="current_ma_base_url", - type=ConfigEntryType.STRING, - label="Current MA Base URL (read-only)", - description=( - "For reference. Change it in Settings → Core → Webserver " - "→ Base URL; provider doesn't own this setting." + "Set up a reverse proxy with a real certificate, then either " + "fill the **External Base URL** field above (recommended — " + "doesn't affect MA's local access / HA Ingress) " + "or update Settings → Core → Webserver → Base URL globally. " + "Save and reopen this page after." ), - required=False, - default_value=base_url or "", depends_on=CONF_CONNECTION_TYPE, depends_on_value=connection_type, category=category, diff --git a/music_assistant/providers/yandex_smarthome/constants.py b/music_assistant/providers/yandex_smarthome/constants.py index 012947a630..6130c7f088 100644 --- a/music_assistant/providers/yandex_smarthome/constants.py +++ b/music_assistant/providers/yandex_smarthome/constants.py @@ -9,6 +9,11 @@ # --------------------------------------------------------------------------- CONF_INSTANCE_NAME = "instance_name" CONF_CONNECTION_TYPE = "connection_type" +# Override for MA's webserver Base URL — used when generating callback / +# webhook URLs for Yandex. Lets users keep MA's global Base URL unset (so +# HA Ingress / local access keep working) while still exposing a public +# HTTPS URL only to Yandex via a reverse proxy. +CONF_EXTERNAL_BASE_URL = "external_base_url" CONF_CLOUD_INSTANCE_ID = "cloud_instance_id" CONF_CLOUD_INSTANCE_PASSWORD = "cloud_instance_password" CONF_CLOUD_CONNECTION_TOKEN = "cloud_connection_token" From fc0225928f732c190137d10cf666459b4135a6c9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 08:42:57 +0000 Subject: [PATCH 20/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.7.1 --- .../providers/yandex_smarthome/auto_skill.py | 13 ++++++++----- .../providers/yandex_smarthome/auto_skill_ui.py | 4 ++-- .../providers/yandex_smarthome/constants.py | 4 ++++ .../__snapshots__/test_auto_skill.ambr | 2 +- tests/providers/yandex_smarthome/test_auto_skill.py | 13 ++++++++----- .../yandex_smarthome/test_auto_skill_ui.py | 3 ++- 6 files changed, 25 insertions(+), 14 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index 79f6fdcecd..af62c91921 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -51,8 +51,8 @@ CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT, DIALOG_CHANNEL, - DIRECT_API_BASE_PATH, DIRECT_AUTH_BASE_PATH, + DIRECT_BACKEND_URI_PATH, DIRECT_OAUTH_CLIENT_ID, ) @@ -542,15 +542,18 @@ def derive_backend_uri( """Return the Backend URL the skill should point at for *connection_type*. cloud_plus → yaha-cloud.ru relay (fixed URL). - direct → ``{base_url}`` + our API path (requires HTTPS base URL; - see :func:`check_preconditions`). ``base_url_override``, - if given, takes precedence over ``mass.webserver.base_url``. + direct → ``{base_url}{DIRECT_BACKEND_URI_PATH}`` (requires HTTPS + base URL; see :func:`check_preconditions`). The path is + the prefix WITHOUT ``/v1.0`` because Yandex appends the + version segment itself when calling our endpoints. + ``base_url_override``, if given, takes precedence over + ``mass.webserver.base_url``. """ if connection_type == CONNECTION_TYPE_CLOUD_PLUS: return CLOUD_SKILL_WEBHOOK_TEMPLATE if connection_type == CONNECTION_TYPE_DIRECT: base = _resolve_base_url(mass, base_url_override) - return f"{base}{DIRECT_API_BASE_PATH}" + return f"{base}{DIRECT_BACKEND_URI_PATH}" msg = f"auto-create is not supported for connection_type={connection_type!r}" raise ValueError(msg) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py index 61aea0f5f0..51c5f8271c 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py @@ -51,8 +51,8 @@ DIALOG_DEFAULT_NAME, DIALOG_NAME_MAX_LEN, DIALOG_NAME_MIN_LEN, - DIRECT_API_BASE_PATH, DIRECT_AUTH_BASE_PATH, + DIRECT_BACKEND_URI_PATH, DIRECT_OAUTH_CLIENT_ID, YANDEX_DIALOGS_DEVELOPER_URL, YANDEX_OAUTH_URL, @@ -565,7 +565,7 @@ def _manual_fallback_entries( token_url = CLOUD_OAUTH_TOKEN_URL elif connection_type == CONNECTION_TYPE_DIRECT: base = base_url.rstrip("/") or "https://" - backend_uri = f"{base}{DIRECT_API_BASE_PATH}" + backend_uri = f"{base}{DIRECT_BACKEND_URI_PATH}" client_id = DIRECT_OAUTH_CLIENT_ID client_secret = direct_client_secret or "(auto-generated on save)" auth_url = f"{base}{DIRECT_AUTH_BASE_PATH}/authorize" diff --git a/music_assistant/providers/yandex_smarthome/constants.py b/music_assistant/providers/yandex_smarthome/constants.py index 6130c7f088..8d51000d81 100644 --- a/music_assistant/providers/yandex_smarthome/constants.py +++ b/music_assistant/providers/yandex_smarthome/constants.py @@ -74,6 +74,10 @@ # Direct connection — HTTP endpoints on MA webserver # --------------------------------------------------------------------------- DIRECT_API_BASE_PATH = "/api/yandex_smarthome/v1.0" +# What we send to Yandex as the skill Backend URI. Yandex appends /v1.0/... +# itself when calling our endpoints, so the backend URI must NOT include +# /v1.0 — otherwise Yandex calls /v1.0/v1.0/user/devices and gets 404. +DIRECT_BACKEND_URI_PATH = "/api/yandex_smarthome" DIRECT_AUTH_BASE_PATH = "/api/yandex_smarthome/auth" DIRECT_HEALTH_RESPONSE = "Yandex Smart Home for Music Assistant" CONF_DIRECT_ACCESS_TOKEN = "direct_access_token" diff --git a/tests/providers/yandex_smarthome/__snapshots__/test_auto_skill.ambr b/tests/providers/yandex_smarthome/__snapshots__/test_auto_skill.ambr index 52f586be00..a7b507d3a8 100644 --- a/tests/providers/yandex_smarthome/__snapshots__/test_auto_skill.ambr +++ b/tests/providers/yandex_smarthome/__snapshots__/test_auto_skill.ambr @@ -52,7 +52,7 @@ 'backendSettings': dict({ 'backendType': 'webhook', 'functionId': '', - 'uri': 'https://ma.example.com/api/yandex_smarthome/v1.0', + 'uri': 'https://ma.example.com/api/yandex_smarthome', }), 'channel': 'smartHome', 'enableAllAvailableRegions': True, diff --git a/tests/providers/yandex_smarthome/test_auto_skill.py b/tests/providers/yandex_smarthome/test_auto_skill.py index c0a096148d..7229d185f3 100644 --- a/tests/providers/yandex_smarthome/test_auto_skill.py +++ b/tests/providers/yandex_smarthome/test_auto_skill.py @@ -476,12 +476,16 @@ def test_cloud_plus_uses_yaha_relay_constant(self) -> None: ) def test_direct_uses_ma_base_plus_api_path(self) -> None: - """Direct concatenates MA base_url with the provider's API path.""" + """Direct backend URI is base_url + /api/yandex_smarthome (no /v1.0). + + Yandex appends /v1.0/... itself when calling the backend, so the + URI we send must NOT include /v1.0. + """ mass = _mass_with_base_url("https://my-ma.example.com/") # Trailing slash on base_url should be stripped so the full URL is clean. assert ( derive_backend_uri(mass, CONNECTION_TYPE_DIRECT) - == "https://my-ma.example.com/api/yandex_smarthome/v1.0" + == "https://my-ma.example.com/api/yandex_smarthome" ) def test_cloud_raises(self) -> None: @@ -545,7 +549,7 @@ def test_direct_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def payload = build_draft_payload( connection_type=CONNECTION_TYPE_DIRECT, skill_name="Music Assistant", - backend_uri="https://ma.example.com/api/yandex_smarthome/v1.0", + backend_uri="https://ma.example.com/api/yandex_smarthome", logo_id=None, developer_name="alice", ) @@ -944,8 +948,7 @@ async def test_direct_mode_passes_ma_base_to_draft(self) -> None: draft_payload = creator.update_draft.call_args.args[2] assert ( - draft_payload["backendSettings"]["uri"] - == "https://ma.example.com/api/yandex_smarthome/v1.0" + draft_payload["backendSettings"]["uri"] == "https://ma.example.com/api/yandex_smarthome" ) oauth_call = creator.create_oauth_app.call_args assert oauth_call.kwargs["client_id"] == "https://social.yandex.net/" diff --git a/tests/providers/yandex_smarthome/test_auto_skill_ui.py b/tests/providers/yandex_smarthome/test_auto_skill_ui.py index d5d4a1a343..770f3c3ba3 100644 --- a/tests/providers/yandex_smarthome/test_auto_skill_ui.py +++ b/tests/providers/yandex_smarthome/test_auto_skill_ui.py @@ -395,7 +395,8 @@ def test_manual_fallback_includes_per_install_secret(self) -> None: backend = _find(list(entries), "manual_backend_url") assert backend is not None assert "ma.example.com" in str(backend.default_value) - assert "/api/yandex_smarthome/v1.0" in str(backend.default_value) + # No /v1.0 — Yandex appends the version segment itself. + assert str(backend.default_value).endswith("/api/yandex_smarthome") def test_skill_id_field_shown_on_done(self) -> None: """Skill ID input field is surfaced (non-advanced) on DONE.""" From dce838561a27862ddd24e73b3c9b9264e0e629c4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 08:58:25 +0000 Subject: [PATCH 21/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.7.3 --- .../providers/yandex_smarthome/__init__.py | 2 +- .../providers/yandex_smarthome/auto_skill.py | 9 ++- .../yandex_smarthome/auto_skill_ui.py | 9 ++- .../providers/yandex_smarthome/plugin.py | 57 +++++++++++-------- .../providers/yandex_smarthome/test_direct.py | 13 ++++- 5 files changed, 57 insertions(+), 33 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index efad5d359a..3881baab45 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -168,7 +168,7 @@ def _resolve_external_base_url( fallback = "" with contextlib.suppress(Exception): fallback = str(mass.webserver.base_url) - return fallback.rstrip("/") + return fallback.strip().rstrip("/") def _resolve_dialog_webhook_secret( diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index af62c91921..4a4240c13a 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -527,10 +527,13 @@ def _resolve_base_url(mass: MusicAssistant, override: str | None) -> str: ``override`` is the user's ``CONF_EXTERNAL_BASE_URL`` value if they chose to set one. Empty/None falls back to ``mass.webserver.base_url``. + Both branches strip leading/trailing whitespace and trailing slashes + so a user-entered value like ``" https://ma.example.com/ "`` doesn't + break the HTTPS precondition check or generate malformed URIs. """ - if override: - return override.rstrip("/") - return str(mass.webserver.base_url).rstrip("/") + if override and override.strip(): + return override.strip().rstrip("/") + return str(mass.webserver.base_url).strip().rstrip("/") def derive_backend_uri( diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py index 51c5f8271c..b6f695f181 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py @@ -949,10 +949,13 @@ def _dialog_skill_entries( key="label_dialog_https_warning", type=ConfigEntryType.LABEL, label=( - f"⚠️ MA's Base URL is {base_url or ''}. " + f"⚠️ Resolved Base URL is {base_url or ''}. " "The dialog skill webhook requires a **publicly reachable HTTPS URL**. " - "Set a reverse proxy with a real certificate and update " - "Settings → Core → Webserver → Base URL, then reopen these settings." + "Set up a reverse proxy with a real certificate, then either " + "fill the **External Base URL** field above (recommended — " + "doesn't affect MA's local access / HA Ingress) " + "or update Settings → Core → Webserver → Base URL globally. " + "Save and reopen this page after." ), category=_CAT_DIALOG_SKILL, ) diff --git a/music_assistant/providers/yandex_smarthome/plugin.py b/music_assistant/providers/yandex_smarthome/plugin.py index c32a4073f6..31a9adabdd 100644 --- a/music_assistant/providers/yandex_smarthome/plugin.py +++ b/music_assistant/providers/yandex_smarthome/plugin.py @@ -216,14 +216,16 @@ async def _start_cloud_mode(self) -> None: await self._state_notifier.start() async def _start_direct_mode(self) -> None: - """Initialize direct connection mode — HTTP endpoints + state notifier.""" - if not self._skill_id or not self._skill_token or not self._skill_token.get_secret(): - self.logger.error( - "Direct mode requires skill_id and skill_token — " - "create a private skill in Yandex.Dialogs and configure the tokens" - ) - return - + """Initialize direct connection mode — HTTP endpoints + state notifier. + + Two-stage: HTTP routes are registered as soon as ``direct_client_secret`` + is available (auto-generated when the user opens the config form), so + Yandex's backend-validation step during ``request_deploy`` can reach + them before the skill is created. The state notifier (outgoing + callbacks to Yandex) only starts once ``skill_id``/``skill_token`` + are populated by a successful auto-create — there is nothing to + report state to before that point. + """ if not self._direct_client_secret: self.logger.error("Direct mode requires a client secret for OAuth account linking") return @@ -247,22 +249,31 @@ def _on_token_created(token: str) -> None: ) self._direct_handler.register_routes() - # State notifier — callback to Yandex Dialogs (same as Cloud Plus) - session = self.mass.http_session - callback_url = f"{YANDEX_DIALOGS_CALLBACK_BASE}/{self._skill_id}/callback/state" - auth_header = {"Authorization": f"OAuth {self._skill_token.get_secret()}"} + # State notifier needs skill_id + skill_token to push state callbacks + # to Yandex — these only exist after a successful auto-create. Skip + # silently if missing; this is the normal "first run" state. + if self._skill_id and self._skill_token and self._skill_token.get_secret(): + session = self.mass.http_session + callback_url = f"{YANDEX_DIALOGS_CALLBACK_BASE}/{self._skill_id}/callback/state" + auth_header = {"Authorization": f"OAuth {self._skill_token.get_secret()}"} - self._state_notifier = StateNotifier( - mass=self.mass, - session=session, - user_id=self._user_id, - callback_url=callback_url, - auth_header=auth_header, - logger=self.logger, - exposed_ids=self._exposed_ids, - playlist_uris=self._exposed_playlists, - ) - await self._state_notifier.start() + self._state_notifier = StateNotifier( + mass=self.mass, + session=session, + user_id=self._user_id, + callback_url=callback_url, + auth_header=auth_header, + logger=self.logger, + exposed_ids=self._exposed_ids, + playlist_uris=self._exposed_playlists, + ) + await self._state_notifier.start() + else: + self.logger.info( + "Direct mode: HTTP routes registered, but state notifier is " + "idle (no skill_id/skill_token yet). Run 'Create Smart Home " + "skill' in the plugin settings to complete setup." + ) # Experimental: Dialogs voice skill webhook handler if self._dialog_skill_enabled: diff --git a/tests/providers/yandex_smarthome/test_direct.py b/tests/providers/yandex_smarthome/test_direct.py index 7323b68e91..fdc05b592a 100644 --- a/tests/providers/yandex_smarthome/test_direct.py +++ b/tests/providers/yandex_smarthome/test_direct.py @@ -795,7 +795,12 @@ async def test_start_direct_mode_registers_routes(mock_mass: MagicMock) -> None: @pytest.mark.asyncio async def test_start_direct_mode_missing_skill_id(mock_mass: MagicMock) -> None: - """Direct mode without skill_id should log error and not start.""" + """Direct mode still registers HTTP routes when skill_id is missing. + + HTTP routes need to be live so Yandex's backend validation during + auto-create can succeed; the state notifier is skipped because + there is no skill to push state to yet. + """ config = _make_direct_config(skill_id="") plugin = YandexSmartHomePlugin( mass=mock_mass, @@ -807,8 +812,10 @@ async def test_start_direct_mode_missing_skill_id(mock_mass: MagicMock) -> None: await plugin.handle_async_init() await plugin.loaded_in_mass() - assert plugin._direct_handler is None - plugin.logger.error.assert_called() + assert plugin._direct_handler is not None + assert mock_mass.webserver.register_dynamic_route.call_count == 10 + assert plugin._state_notifier is None + plugin.logger.info.assert_called() @pytest.mark.asyncio From 55135c9671539e38bd005965d132062eb82c63f1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 09:43:51 +0000 Subject: [PATCH 22/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.7.8 --- .../providers/yandex_smarthome/__init__.py | 68 ++++++++ .../providers/yandex_smarthome/auto_skill.py | 154 ++++++++++++++---- .../providers/yandex_smarthome/constants.py | 19 ++- .../providers/yandex_smarthome/dialogs.py | 2 +- .../providers/yandex_smarthome/dialogs_nlu.py | 22 ++- .../yandex_smarthome/dialogs_player.py | 4 +- .../providers/yandex_smarthome/notifier.py | 51 +++++- .../yandex_smarthome/test_auto_skill.py | 4 +- 8 files changed, 261 insertions(+), 63 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index 3881baab45..09aa624586 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -46,6 +46,7 @@ CONF_ACTION_GET_OTP, CONF_ACTION_REGISTER, CONF_ACTION_RENAME_DIALOG_SKILL, + CONF_AUTH_X_TOKEN, CONF_AUTO_CREATE_ARTIFACTS, CONF_AUTO_CREATE_SESSION_ID, CONF_CLOUD_CONNECTION_TOKEN, @@ -145,6 +146,26 @@ def _resolve_direct_client_secret( return str(values.get(CONF_DIRECT_CLIENT_SECRET) or "") +def _resolve_cached_x_token( + mass: MusicAssistant, + instance_id: str | None, + values: dict[str, ConfigValueType], +) -> str: + """Return the cached Yandex Passport x_token, or empty string if absent. + + Like the other secret resolvers, prefers the persisted SECURE_STRING + from saved config since the frontend does not echo secrets back into + ``values`` on re-open. + """ + if instance_id: + prov = mass.get_provider(instance_id) + if prov and prov.config: + saved = prov.config.get_value(CONF_AUTH_X_TOKEN) + if saved: + return str(saved) + return str(values.get(CONF_AUTH_X_TOKEN) or "") + + def _resolve_external_base_url( mass: MusicAssistant, values: dict[str, ConfigValueType] | None = None, @@ -273,6 +294,9 @@ async def _run_auto_create_action( artifacts_raw = values.get(CONF_AUTO_CREATE_ARTIFACTS) artifacts = load_artifacts(str(artifacts_raw) if artifacts_raw else None) + def _cache_x_token(token: str) -> None: + values[CONF_AUTH_X_TOKEN] = token + try: new_artifacts = await auto_create_skill( mass=mass, @@ -284,6 +308,8 @@ async def _run_auto_create_action( logo_bytes=load_default_logo_bytes(), session_id=session_id, base_url_override=str(values.get(CONF_EXTERNAL_BASE_URL) or "") or None, + cached_x_token=_resolve_cached_x_token(mass, instance_id, values) or None, + on_token_obtained=_cache_x_token, ) except asyncio.CancelledError: # Preserve cooperative cancellation so config-flow shutdown @@ -344,6 +370,9 @@ async def _run_auto_create_dialog_action( skill_name = str(values.get(CONF_DIALOG_SKILL_NAME) or DIALOG_DEFAULT_NAME) + def _cache_x_token(token: str) -> None: + values[CONF_AUTH_X_TOKEN] = token + try: new_artifacts = await auto_create_skill( mass=mass, @@ -357,6 +386,8 @@ async def _run_auto_create_dialog_action( skill_type="dialog", dialog_backend_uri=dialog_backend_uri, base_url_override=str(values.get(CONF_EXTERNAL_BASE_URL) or "") or None, + cached_x_token=_resolve_cached_x_token(mass, instance_id, values) or None, + on_token_obtained=_cache_x_token, ) except asyncio.CancelledError: raise @@ -375,6 +406,27 @@ async def _run_auto_create_dialog_action( ) _LOGGER.exception("dialog auto-create hit unexpected error") + # Hint: empty-body 400 on create_app for the dialog pipeline almost + # always means our DIALOG_CHANNEL guess ("dialog" by default) is wrong. + # The Yandex Dialogs app-store-api channel string for the custom skill type is not + # publicly documented and we cannot probe it from our side. + if ( + new_artifacts.state == SkillCreationState.FAILED + and new_artifacts.last_error + and "create_app" in new_artifacts.last_error + and "HTTP 400" in new_artifacts.last_error + ): + new_artifacts = dataclasses.replace( + new_artifacts, + last_error=( + f"{new_artifacts.last_error}\n\n" + "Hint: This usually means the channel value sent to " + "Yandex Dialogs is wrong. Try overriding the " + "MA_YANDEX_DIALOG_CHANNEL environment variable at MA " + "startup (e.g. =general, =alice, =skill) and retry." + ), + ) + values[CONF_DIALOG_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) if new_artifacts.state == SkillCreationState.DONE and new_artifacts.skill_id: values[CONF_DIALOG_SKILL_ID] = new_artifacts.skill_id @@ -396,12 +448,17 @@ async def _run_rename_dialog_action( skill_name = str(values.get(CONF_DIALOG_SKILL_NAME) or DIALOG_DEFAULT_NAME) session_id = str(values.get("session_id") or uuid.uuid4().hex) + def _cache_x_token(token: str) -> None: + values[CONF_AUTH_X_TOKEN] = token + new_artifacts = await auto_rename_dialog_skill( mass=mass, artifacts=artifacts, new_name=skill_name, dialog_backend_uri=dialog_backend_uri, session_id=session_id, + cached_x_token=_resolve_cached_x_token(mass, instance_id, values) or None, + on_token_obtained=_cache_x_token, ) values[CONF_DIALOG_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) @@ -772,4 +829,15 @@ def _common_tail_entries( required=False, value=(cast("str", values.get(CONF_DIRECT_ACCESS_TOKEN)) if values else None), ), + # Cached Yandex Passport x_token — populated after the first + # successful auto-create Device Flow and reused on subsequent + # auto-create runs to skip the device-code prompt. + ConfigEntry( + key=CONF_AUTH_X_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Yandex Passport x_token (cached)", + hidden=True, + required=False, + value=(cast("str", values.get(CONF_AUTH_X_TOKEN)) if values else None), + ), ] diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index 4a4240c13a..e8a05a7fdd 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -462,8 +462,22 @@ async def _send_json( yandex_error=_extract_error_code(body), ) if resp.status not in (200, 201, 202): + # Empty / very short body 4xx — log response headers so the + # user can see what Yandex actually returned (helps diagnose + # e.g. wrong "channel" parameter where the API rejects the + # request before generating a body). + if not body.strip(): + _LOGGER.warning( + "Yandex %s %s returned %s with empty body; response " + "headers=%s, request payload channel=%r", + method, + url, + resp.status, + dict(resp.headers), + payload.get("channel"), + ) raise DialogsApiError( - f"{method} {url} HTTP {resp.status}: {body[:200]}", + f"{method} {url} HTTP {resp.status}: {body[:200] or ''}", step=step, http_status=resp.status, yandex_error=_extract_error_code(body), @@ -671,23 +685,29 @@ def build_dialog_draft_payload( logo_id: str | None, developer_name: str = "Music Assistant user", ) -> dict[str, Any]: - """Compose the PATCH /draft/update body for a Yandex Dialogs «Навык». - - Mirrors :func:`build_draft_payload` but uses - ``publishingSettings.category="music_and_sounds"`` and drops - the ``smartHome`` deepLinks block. ⚠️ Exact required-field set - for «Навык» is not documented — this matches the most common shape - seen in Dialogs developer UI HARs and may need adjustment after a - manual probe (see plan probe checklist). + """Compose the PATCH /draft/update body for a Yandex Dialogs custom skill. + + Captured from a live PATCH /draft/update request made by the Yandex + Dialogs developer console — fields and shape match exactly. Notes: + + - ``activationPhrases``: globally unique across all Yandex skills, so + the user must pick something distinctive (Yandex returns 400 + ``"Это активационное имя уже зарегистрировано"`` if the name is taken). + - ``voice="good_oksana"``: default female voice in Dialogs (older + ``"shitova.us"`` constant was Smart-Home-specific). + - ``publishingSettings``: flat fields (no ``multilingualSettings`` / + ``secondaryTitle`` blocks — those are Smart-Home shape). + - ``skillAccess="private"`` + ``hideInStore=true`` keep the skill + out of the public store; only the creator can link it. """ return { "logo2": None, "name": skill_name, - "voice": "shitova.us", + "voice": "good_oksana", + "activationPhrases": [skill_name], "logoId": logo_id, - "skillAccess": "private", - "hideInStore": True, "noteForModerator": "", + "yaCloudGrant": False, "backendSettings": { "uri": backend_uri, "functionId": "", @@ -697,25 +717,21 @@ def build_dialog_draft_payload( "brandVerificationWebsite": "", "category": "music_and_sounds", "developerName": developer_name, - "secondaryTitle": skill_name, + "explicitContent": False, + "structuredExamples": [], + "description": "Free-form voice playback bridge for Music Assistant.", "email": "", - "multilingualSettings": { - "ru": { - "name": skill_name, - "secondaryTitle": skill_name, - "description": "Free-form voice playback bridge for Music Assistant.", - "shortDescription": "Music Assistant voice control", - "examplePhrases": [ - "включи Metallica на кухне", - "включи мою волну", - "включи плейлист джаз", - ], - }, - }, }, + "requiredInterfaces": [], + "exactSurfaces": [], + "surfaceWhitelist": [], + "surfaceBlacklist": [], "oauthAppId": None, - "enableAllAvailableRegions": True, - "selectedRegions": [], + "appMetricaApiKey": "", + "useStateStorage": False, + "rsyPlatformId": "", + "skillAccess": "private", + "hideInStore": True, "channel": DIALOG_CHANNEL, } @@ -955,14 +971,26 @@ def _build_device_code_page(user_code: str, verification_url: str, status_url: s """ -async def _default_authenticator( +async def _default_authenticator( # noqa: PLR0915 *, mass: MusicAssistant, session_id: str, timeout: float, + cached_x_token: str | None = None, + on_token_obtained: Callable[[str], None] | None = None, ) -> AsyncIterator[aiohttp.ClientSession]: """Real-world authentication path — runs Device Flow and yields a session. + Cache fast-path: if ``cached_x_token`` is provided and Yandex still + accepts it, ``refresh_passport_cookies`` succeeds without a fresh + Device Flow, so subsequent auto-create runs (e.g. Smart Home → Dialog) + do not prompt the user to confirm the device code again. On any + failure during refresh the cache is treated as stale and the full + Device Flow runs as before. + + After a successful Device Flow, ``on_token_obtained`` (if provided) + is invoked with the fresh ``x_token`` so the caller can persist it. + Serves an intermediate HTML page through MA's webserver so the user sees the short ``user_code`` (Yandex's ya.ru/device does not pre-fill from query params). The popup is opened via @@ -975,6 +1003,7 @@ async def _default_authenticator( from aiohttp import web # noqa: PLC0415 from ya_passport_auth import ClientConfig, PassportClient # noqa: PLC0415 from ya_passport_auth.config import DEFAULT_ALLOWED_HOSTS # noqa: PLC0415 + from ya_passport_auth.credentials import SecretStr # noqa: PLC0415 from music_assistant.helpers.auth import AuthenticationHelper # noqa: PLC0415 @@ -986,6 +1015,26 @@ async def _default_authenticator( config = ClientConfig(allowed_hosts=allowed) async with PassportClient.create(config=config) as client: + # Cache fast-path: try cached x_token first. If the token is still + # valid Yandex returns fresh session cookies and we skip Device Flow. + # The cache stores the raw string; ya_passport_auth wraps it in + # SecretStr for redacted logging on its side. + if cached_x_token: + try: + await client.refresh_passport_cookies(SecretStr(cached_x_token)) + _LOGGER.info( + "auto-skill: reused cached Yandex Passport x_token (no Device Flow needed)" + ) + yield client._session + return + except asyncio.CancelledError: + raise + except Exception as exc: + _LOGGER.info( + "auto-skill: cached x_token rejected (%s) — falling back to fresh Device Flow", + exc, + ) + device_session = await client.start_device_login() # Don't log user_code — it's a time-limited credential (grants # Yandex sign-in for the device-flow window) and writing it to @@ -1066,6 +1115,19 @@ async def _serve_status(_request: web.Request) -> web.Response: mass.webserver.unregister_dynamic_route(status_path, "GET") await client.refresh_passport_cookies(creds.x_token) + + # Persist the new x_token so subsequent auto-create runs can skip + # Device Flow. Best-effort: a callback failure must not break auth. + # Unwrap SecretStr → str so the callback can store it via the MA + # config plumbing (SECURE_STRING serialiser expects a plain str). + if on_token_obtained is not None: + try: + on_token_obtained(creds.x_token.get_secret()) + except Exception: + _LOGGER.exception( + "auto-skill: on_token_obtained callback failed; x_token will not be cached" + ) + yield client._session @@ -1075,6 +1137,8 @@ def _build_authenticator_cm( mass: MusicAssistant, session_id: str, timeout: float, + cached_x_token: str | None = None, + on_token_obtained: Callable[[str], None] | None = None, ) -> Any: """Wrap *authenticator* so it supports ``async with`` uniformly. @@ -1083,8 +1147,18 @@ def _build_authenticator_cm( a CM factory with ``asynccontextmanager`` is *not* idempotent — the outer wrapper would call ``__anext__`` on the inner CM object and crash — so detect the CM result and pass it through unchanged. + + ``cached_x_token`` and ``on_token_obtained`` are forwarded to the + default authenticator for fast-path / persistence; injected + test authenticators (which take ``**kwargs``) get them as well. """ - result = authenticator(mass=mass, session_id=session_id, timeout=timeout) + result = authenticator( + mass=mass, + session_id=session_id, + timeout=timeout, + cached_x_token=cached_x_token, + on_token_obtained=on_token_obtained, + ) if hasattr(result, "__aenter__") and hasattr(result, "__aexit__"): return result @@ -1123,6 +1197,8 @@ async def auto_create_skill( # noqa: PLR0913 skill_type: Literal["smart_home", "dialog"] = "smart_home", dialog_backend_uri: str | None = None, base_url_override: str | None = None, + cached_x_token: str | None = None, + on_token_obtained: Callable[[str], None] | None = None, progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None = None, authenticator: Callable[..., AsyncIterator[aiohttp.ClientSession]] | None = None, creator_factory: Callable[[aiohttp.ClientSession], DialogsSkillCreator] | None = None, @@ -1167,7 +1243,12 @@ async def auto_create_skill( # noqa: PLR0913 try: async with _build_authenticator_cm( - auth_fn, mass=mass, session_id=session_id, timeout=timeout + auth_fn, + mass=mass, + session_id=session_id, + timeout=timeout, + cached_x_token=cached_x_token, + on_token_obtained=on_token_obtained, ) as session: creator = ( creator_factory(session) @@ -1392,13 +1473,15 @@ async def _execute_pipeline( # noqa: PLR0913, PLR0915 return artifacts -async def auto_rename_dialog_skill( +async def auto_rename_dialog_skill( # noqa: PLR0913 *, mass: MusicAssistant, artifacts: SkillCreationArtifacts, new_name: str, dialog_backend_uri: str, session_id: str, + cached_x_token: str | None = None, + on_token_obtained: Callable[[str], None] | None = None, authenticator: Callable[..., AsyncIterator[aiohttp.ClientSession]] | None = None, creator_factory: Callable[[aiohttp.ClientSession], DialogsSkillCreator] | None = None, timeout: float = DEVICE_FLOW_TIMEOUT_SECONDS, @@ -1422,7 +1505,12 @@ async def auto_rename_dialog_skill( try: async with _build_authenticator_cm( - auth_fn, mass=mass, session_id=session_id, timeout=timeout + auth_fn, + mass=mass, + session_id=session_id, + timeout=timeout, + cached_x_token=cached_x_token, + on_token_obtained=on_token_obtained, ) as session: creator = ( creator_factory(session) diff --git a/music_assistant/providers/yandex_smarthome/constants.py b/music_assistant/providers/yandex_smarthome/constants.py index 8d51000d81..d41e8c5462 100644 --- a/music_assistant/providers/yandex_smarthome/constants.py +++ b/music_assistant/providers/yandex_smarthome/constants.py @@ -25,6 +25,11 @@ # Auto-create-skill feature state (round-trips through the config form) CONF_AUTO_CREATE_ARTIFACTS = "auto_create_artifacts" CONF_AUTO_CREATE_SESSION_ID = "auto_create_session_id" +# Cached Yandex Passport x_token from the first successful Device Flow. +# Reused on subsequent auto-create runs (Smart Home + Dialog) so the user +# does not have to confirm the device code every time. Long-lived (months); +# automatically refreshed on use. Cleared if Yandex returns 401 on refresh. +CONF_AUTH_X_TOKEN = "auth_x_token" # --------------------------------------------------------------------------- # Config actions @@ -143,7 +148,7 @@ ERROR_DEVICE_NOT_FOUND = "DEVICE_NOT_FOUND" # --------------------------------------------------------------------------- -# Dialog skill (Yandex Dialogs «Навык» — free-form voice playback) — experimental +# Dialog skill (Yandex Dialogs custom skill — free-form voice playback) — experimental # --------------------------------------------------------------------------- CONF_DIALOG_SKILL_ENABLED = "dialog_skill_enabled" CONF_DIALOG_SKILL_NAME = "dialog_skill_name" @@ -159,13 +164,11 @@ DIALOG_WEBHOOK_BASE_PATH = "/api/yandex_dialogs/webhook" DIALOG_RESOLVE_TIMEOUT = 2.5 DIALOG_DEFAULT_NAME = "Music Assistant" -# Yandex Dialogs app-store-api channel string for «Навык» (custom dialog skill). -# Smart Home uses "smartHome"; the Dialogs «Навык» channel string is not -# documented in the public app-store-api. Default below ("dialog") is our best -# guess. If Yandex returns a 4xx during auto-create with this value, the user -# can override via the MA_YANDEX_DIALOG_CHANNEL environment variable without -# editing the code (e.g. set MA_YANDEX_DIALOG_CHANNEL=general for a probe). -DIALOG_CHANNEL = os.environ.get("MA_YANDEX_DIALOG_CHANNEL", "dialog") +# Yandex Dialogs app-store-api channel string for the custom dialog skill. +# Smart Home uses "smartHome"; the Dialogs custom-skill channel value was captured +# from the dev console DevTools (POST /apps): channel="aliceSkill". +# Override via MA_YANDEX_DIALOG_CHANNEL env var if Yandex changes the contract. +DIALOG_CHANNEL = os.environ.get("MA_YANDEX_DIALOG_CHANNEL", "aliceSkill") DIALOG_NAME_MIN_LEN = 2 DIALOG_NAME_MAX_LEN = 64 DIALOG_SESSION_CACHE_MAX = 200 diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index 0e2ad4b140..da22c29630 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -1,4 +1,4 @@ -"""HTTP handler for the Yandex Dialogs «Навык» webhook (experimental). +"""HTTP handler for the Yandex Dialogs custom-skill webhook (experimental). Registers a single dynamic route on the MA webserver: diff --git a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py index 84bd51b65f..f2bcf11075 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py @@ -1,4 +1,4 @@ -"""Server-side NLU parser for Yandex Dialogs «Навык» webhook commands. +"""Server-side NLU parser for Yandex Dialogs custom-skill webhook commands. The plugin's Dialogs skill registers in the Yandex Dialogs UI without declared intents/slots — Yandex passes the raw user phrase as @@ -44,11 +44,14 @@ class ParsedCommand: _PUNCT_RE = re.compile(r"[!?.,;:«»\"„“]") _SPACE_RE = re.compile(r"\s+") -# "на " suffix. The hint can be multi-word ("на кухонной колонке"). -# Anchored to a word boundary so "Натали" isn't mis-split. +# Trailing "" hint suffix introduced by the Russian preposition "на". +# The hint can be multi-word (e.g. a phrase like "in the kitchen speaker"). +# Anchored to a word boundary so a name beginning with the same letters +# (e.g. "Natalie" in Russian) isn't mis-split mid-token. _PLAYER_SUFFIX_RE = re.compile(r"\s+на\s+(?P.+?)\s*$", re.IGNORECASE) -# Verb stem covering "включи", "включай", "включите", "поставь", "запусти". +# Verb stem covering Russian imperative forms used to start playback +# ("turn on", "play", "launch") with their plural / aspect variants. _VERB_RE = re.compile( r"^(?:алиса[, ]+)?(?:включи(?:те)?|включай(?:те)?|поставь(?:те)?|запусти(?:те)?)\s+", re.IGNORECASE, @@ -56,7 +59,7 @@ class ParsedCommand: # Type prefixes inside the intent part. Order matters: longer keywords first. _KIND_RULES: tuple[tuple[re.Pattern[str], CommandKind, bool], ...] = ( - # my_wave / личная волна — no query, the verb is everything + # my_wave / personal radio wave — no query, the verb is everything (re.compile(r"^(?:мою|свою|нашу)\s+волну\b", re.IGNORECASE), "my_wave", True), (re.compile(r"^мо[её]\s+радио\b", re.IGNORECASE), "my_wave", True), # playlist @@ -100,16 +103,17 @@ def parse_command(text: str) -> ParsedCommand: cleaned = _PUNCT_RE.sub(" ", text) cleaned = _SPACE_RE.sub(" ", cleaned).strip() - # Strip "Алиса, …" prefix if present (Yandex usually does this, but defensively). + # Strip the "Alice, ..." vocative prefix if present + # (Yandex usually strips it on its side, but defensively). cleaned = re.sub(r"^алиса[,\s]+", "", cleaned, flags=re.IGNORECASE) - # Split off "на " suffix. + # Split off the trailing "" hint suffix. player_hint: str | None = None if match := _PLAYER_SUFFIX_RE.search(cleaned): player_hint = match.group("hint").strip().lower() cleaned = cleaned[: match.start()].strip() - # Strip the verb at the start ("включи …", "поставь …"). + # Strip the imperative verb at the start (e.g. "play this", "turn on that"). intent_part = _VERB_RE.sub("", cleaned).strip() if not intent_part: @@ -141,7 +145,7 @@ def parse_command(text: str) -> ParsedCommand: # Common Russian inflection suffixes we strip for fuzzy player-name matching. # Not a full lemmatizer — picks up the most frequent endings for short names. -# Order: longest first so "ой" matches before "й". +# Order: longest first so multi-letter suffixes match before single-letter ones. _INFLECTION_SUFFIXES = ( "ого", # noqa: RUF001 "ому", diff --git a/music_assistant/providers/yandex_smarthome/dialogs_player.py b/music_assistant/providers/yandex_smarthome/dialogs_player.py index 6e44709378..205e93852d 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_player.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_player.py @@ -1,4 +1,4 @@ -"""Music + player resolvers for the Yandex Dialogs «Навык» webhook. +"""Music + player resolvers for the Yandex Dialogs custom-skill webhook. `resolve_query` turns a `ParsedCommand` into a concrete URI/MediaItem ready to feed to `mass.player_queues.play_media`. `play_for_alice` wraps the @@ -140,7 +140,7 @@ def _find_yandex_music_provider(mass: MusicAssistant) -> Any: async def _resolve_my_wave(mass: MusicAssistant) -> str | None: - """Resolve «Моя волна» — yandex_music rotor station user:onyourwave. + """Resolve "My Wave" radio — yandex_music rotor station user:onyourwave. Returns a track URI from the rotor batch; play_media in radio mode will keep pulling next tracks via the standard queue radio loop. If diff --git a/music_assistant/providers/yandex_smarthome/notifier.py b/music_assistant/providers/yandex_smarthome/notifier.py index 453566cf79..63a68eef54 100644 --- a/music_assistant/providers/yandex_smarthome/notifier.py +++ b/music_assistant/providers/yandex_smarthome/notifier.py @@ -67,6 +67,13 @@ def __init__( self._heartbeat_task: asyncio.Task[None] | None = None self._unsub: Callable[[], None] | None = None + # Track UNKNOWN_USER state — Yandex returns this until the user + # has linked the skill in the Yandex Smart Home / Alice mobile + # app via OAuth. We surface the first occurrence as a clear + # WARNING with instructions and then silence further errors at + # debug level so logs don't flood while linking is in progress. + self._unknown_user_warned: bool = False + async def start(self) -> None: """Subscribe to player events and start background tasks.""" self._unsub = self._mass.subscribe( @@ -185,7 +192,14 @@ async def _flush_pending(self) -> None: # ----------------------------------------------------------------------- async def _send_state_callback(self, devices: list[DeviceState]) -> None: - """POST state callback to Yandex.""" + """POST state callback to Yandex. + + Yandex returns ``UNKNOWN_USER`` (HTTP 400) until the user has + linked the skill in the Yandex Alice / Smart Home app. That is a + normal first-run state, not a code bug — we emit one WARNING with + linking instructions, then quiet down to debug level so logs + don't flood while linking is in progress. + """ payload = CallbackRequest( ts=time.time(), payload=CallbackPayload(user_id=self._user_id, devices=devices), @@ -196,13 +210,34 @@ async def _send_state_callback(self, devices: list[DeviceState]) -> None: json=_strip_none(asdict(payload)), headers=self._auth_header, ) as resp: - if resp.status not in (200, 202): - body = await resp.text() - raise RuntimeError( - f"State callback failed with HTTP {resp.status}: {body[:200]}" - ) - self._logger.debug("State callback sent: %d device(s)", len(devices)) - except Exception: + if resp.status in (200, 202): + if self._unknown_user_warned: + self._logger.info( + "State callback succeeded — Yandex now recognizes the user " + "(account linking complete)" + ) + self._unknown_user_warned = False + self._logger.debug("State callback sent: %d device(s)", len(devices)) + return + + body = await resp.text() + if resp.status == 400 and "UNKNOWN_USER" in body: + if not self._unknown_user_warned: + self._logger.warning( + "Yandex returned UNKNOWN_USER for state callback — this means " + "the skill has not been linked to a Yandex account yet. " + "Open https://yandex.ru/quasar/iot or the «Дом с Алисой» app, " # noqa: RUF001 + "find the skill in Devices → +, and tap «Связать аккаунт». " + "State callback errors will be suppressed at debug level " + "until linking succeeds." + ) + self._unknown_user_warned = True + else: + self._logger.debug("State callback still UNKNOWN_USER (account not linked)") + return # silent — not a real error, don't raise + + raise RuntimeError(f"State callback failed with HTTP {resp.status}: {body[:200]}") + except RuntimeError: self._logger.exception("State callback error") raise diff --git a/tests/providers/yandex_smarthome/test_auto_skill.py b/tests/providers/yandex_smarthome/test_auto_skill.py index 7229d185f3..60c1b94df0 100644 --- a/tests/providers/yandex_smarthome/test_auto_skill.py +++ b/tests/providers/yandex_smarthome/test_auto_skill.py @@ -691,7 +691,7 @@ def _fake_authenticator_factory( if session is None: session = MagicMock(spec=aiohttp.ClientSession) - async def _auth(mass, session_id, timeout): # type: ignore[no-untyped-def] + async def _auth(mass, session_id, timeout, **_kwargs): # type: ignore[no-untyped-def] if session_id_captor is not None: session_id_captor.append(session_id) _ = (mass, timeout) @@ -975,7 +975,7 @@ async def test_accepts_already_decorated_context_manager(self) -> None: @asynccontextmanager async def _cm_auth( - *, mass: Any, session_id: str, timeout: float + *, mass: Any, session_id: str, timeout: float, **_kwargs: Any ) -> AsyncIterator[aiohttp.ClientSession]: _ = (mass, session_id, timeout) yield session From d0555a8343b1a7db7f7f806b5ac4c0b0e86b957e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 09:53:07 +0000 Subject: [PATCH 23/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.7.9 --- .../providers/yandex_smarthome/__init__.py | 18 ++++++++++++++++++ .../yandex_smarthome/auto_skill_ui.py | 3 +++ 2 files changed, 21 insertions(+) diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index 09aa624586..64c4e1479a 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -370,6 +370,24 @@ async def _run_auto_create_dialog_action( skill_name = str(values.get(CONF_DIALOG_SKILL_NAME) or DIALOG_DEFAULT_NAME) + # Yandex Dialogs validation: skill name must contain at least two + # words. Catch this client-side so we don't burn a Device Flow + create + # a half-broken skill that fails at request_deploy. Trim multiple + # internal whitespace before counting. + if len(skill_name.split()) < 2: + artifacts_failed = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=( + f"Skill activation name {skill_name!r} has fewer than two " + "words. Yandex Dialogs requires the skill name to contain at " + "least two words (e.g. 'Music Assistant', 'Моя Музыка'). " + "Edit the field above and try again." + ), + ) + values[CONF_DIALOG_AUTO_CREATE_ARTIFACTS] = dump_artifacts(artifacts_failed) + return + def _cache_x_token(token: str) -> None: values[CONF_AUTH_X_TOKEN] = token diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py index b6f695f181..1a17bc4bef 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py @@ -906,6 +906,9 @@ def _dialog_skill_entries( description=( 'Used as the activation phrase: "Алиса, попроси …". ' "For Yandex voice recognition, a Russian name works best. " + "**Must contain at least two words** (Yandex requirement) and " + "must be **globally unique across all Yandex skills** — pick " + "something distinctive. " f"Length: {DIALOG_NAME_MIN_LEN}-{DIALOG_NAME_MAX_LEN} characters." ), default_value=DIALOG_DEFAULT_NAME, From c50784066783579b4c7098e66cad317f5bbd7868 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 09:56:45 +0000 Subject: [PATCH 24/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.7.10 --- .../providers/yandex_smarthome/auto_skill.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index e8a05a7fdd..c41f233b7c 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -718,7 +718,16 @@ def build_dialog_draft_payload( "category": "music_and_sounds", "developerName": developer_name, "explicitContent": False, - "structuredExamples": [], + # request_deploy enforces non-empty structuredExamples even for + # private skills (Yandex quality-check). Phrases below are + # crafted to match the patterns recognised by parse_command in + # provider/dialogs_nlu.py so the catalogue text actually + # reflects what users can say. + "structuredExamples": [ + {"phrase": "включи Metallica"}, + {"phrase": "включи мою волну"}, + {"phrase": "включи джаз на кухне"}, + ], "description": "Free-form voice playback bridge for Music Assistant.", "email": "", }, From 442f7d3338824366db59a97bc6582c93fc1ff073 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 10:01:00 +0000 Subject: [PATCH 25/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.7.11 --- .../providers/yandex_smarthome/__init__.py | 12 +++++++----- .../providers/yandex_smarthome/notifier.py | 9 ++++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index 64c4e1479a..c0e014db14 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -425,9 +425,10 @@ def _cache_x_token(token: str) -> None: _LOGGER.exception("dialog auto-create hit unexpected error") # Hint: empty-body 400 on create_app for the dialog pipeline almost - # always means our DIALOG_CHANNEL guess ("dialog" by default) is wrong. - # The Yandex Dialogs app-store-api channel string for the custom skill type is not - # publicly documented and we cannot probe it from our side. + # always means the DIALOG_CHANNEL value sent to Yandex is wrong. + # The default ("aliceSkill") was captured from a live POST /apps in the + # dev console — if Yandex changes the contract or the value stops + # working, users can override at startup via MA_YANDEX_DIALOG_CHANNEL. if ( new_artifacts.state == SkillCreationState.FAILED and new_artifacts.last_error @@ -439,9 +440,10 @@ def _cache_x_token(token: str) -> None: last_error=( f"{new_artifacts.last_error}\n\n" "Hint: This usually means the channel value sent to " - "Yandex Dialogs is wrong. Try overriding the " + "Yandex Dialogs is wrong. The current default is " + "'aliceSkill'; if it stops working try overriding the " "MA_YANDEX_DIALOG_CHANNEL environment variable at MA " - "startup (e.g. =general, =alice, =skill) and retry." + "startup (e.g. =dialog, =general) and retry." ), ) diff --git a/music_assistant/providers/yandex_smarthome/notifier.py b/music_assistant/providers/yandex_smarthome/notifier.py index 63a68eef54..8a10269e92 100644 --- a/music_assistant/providers/yandex_smarthome/notifier.py +++ b/music_assistant/providers/yandex_smarthome/notifier.py @@ -237,7 +237,14 @@ async def _send_state_callback(self, devices: list[DeviceState]) -> None: return # silent — not a real error, don't raise raise RuntimeError(f"State callback failed with HTTP {resp.status}: {body[:200]}") - except RuntimeError: + except asyncio.CancelledError: + # Cooperative cancellation must propagate untouched. + raise + except Exception: + # Includes RuntimeError above + transport-level errors + # (aiohttp.ClientError, DNS resolution failures, connection + # resets, etc.). Caller (_flush_pending) re-queues the dirty + # players for the next flush. self._logger.exception("State callback error") raise From 086c0a81e4cbc607c066a638f4a22a22c307e36b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 10:22:28 +0000 Subject: [PATCH 26/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.7.12 --- .../providers/yandex_smarthome/auto_skill.py | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index c41f233b7c..b69c4832d9 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -687,21 +687,23 @@ def build_dialog_draft_payload( ) -> dict[str, Any]: """Compose the PATCH /draft/update body for a Yandex Dialogs custom skill. - Captured from a live PATCH /draft/update request made by the Yandex - Dialogs developer console — fields and shape match exactly. Notes: - - - ``activationPhrases``: globally unique across all Yandex skills, so - the user must pick something distinctive (Yandex returns 400 - ``"Это активационное имя уже зарегистрировано"`` if the name is taken). - - ``voice="good_oksana"``: default female voice in Dialogs (older - ``"shitova.us"`` constant was Smart-Home-specific). - - ``publishingSettings``: flat fields (no ``multilingualSettings`` / - ``secondaryTitle`` blocks — those are Smart-Home shape). + All fields and values were derived from a live ``GET /snapshot`` call + against the Yandex Dialogs developer console (which exposes the full + category catalogue + a previously deployed reference skill): + + - ``category="music_audio"``: the API key for "Аудио и подкасты" + (the previous guess ``"music_and_sounds"`` is not a valid Yandex + category — sending it returns HTTP 400 with an empty body). + - ``activationPhrases``: globally unique across all Yandex skills. + - ``voice="good_oksana"``: default female voice in Dialogs. + - ``structuredExamples``: shipped with three sample phrases that + match the patterns ``parse_command`` recognises so the catalogue + text reflects what users can really say. Required by + ``request_deploy`` for the ``aliceSkill`` channel. - ``skillAccess="private"`` + ``hideInStore=true`` keep the skill - out of the public store; only the creator can link it. + out of the public store. """ return { - "logo2": None, "name": skill_name, "voice": "good_oksana", "activationPhrases": [skill_name], @@ -715,14 +717,9 @@ def build_dialog_draft_payload( }, "publishingSettings": { "brandVerificationWebsite": "", - "category": "music_and_sounds", + "category": "music_audio", "developerName": developer_name, "explicitContent": False, - # request_deploy enforces non-empty structuredExamples even for - # private skills (Yandex quality-check). Phrases below are - # crafted to match the patterns recognised by parse_command in - # provider/dialogs_nlu.py so the catalogue text actually - # reflects what users can say. "structuredExamples": [ {"phrase": "включи Metallica"}, {"phrase": "включи мою волну"}, From 468964f3e8a36ad574bae501e3a49284e0adce59 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 10:25:42 +0000 Subject: [PATCH 27/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.7.13 --- .../providers/yandex_smarthome/auto_skill.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index b69c4832d9..1c21524bb7 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -462,18 +462,31 @@ async def _send_json( yandex_error=_extract_error_code(body), ) if resp.status not in (200, 201, 202): - # Empty / very short body 4xx — log response headers so the - # user can see what Yandex actually returned (helps diagnose - # e.g. wrong "channel" parameter where the API rejects the - # request before generating a body). + # Empty / very short body 4xx — log a small safe subset of + # response headers so the user can see what Yandex actually + # returned (helps diagnose e.g. wrong "channel" parameter + # where the API rejects the request before generating a body). + # Avoid dumping the full header map: it includes Set-Cookie + # and other potentially sensitive values. if not body.strip(): + safe_headers = { + k: resp.headers.get(k) + for k in ( + "Content-Type", + "Content-Length", + "X-Request-Id", + "X-RateLimit-Remaining", + "X-RateLimit-Limit", + ) + if resp.headers.get(k) is not None + } _LOGGER.warning( "Yandex %s %s returned %s with empty body; response " "headers=%s, request payload channel=%r", method, url, resp.status, - dict(resp.headers), + safe_headers, payload.get("channel"), ) raise DialogsApiError( From 00574c99e298a877ec94a1c52f5d46de534c4389 Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy <139659391+trudenboy@users.noreply.github.com> Date: Tue, 5 May 2026 13:38:16 +0300 Subject: [PATCH 28/56] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- music_assistant/providers/yandex_smarthome/constants.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/music_assistant/providers/yandex_smarthome/constants.py b/music_assistant/providers/yandex_smarthome/constants.py index d41e8c5462..13b231ad17 100644 --- a/music_assistant/providers/yandex_smarthome/constants.py +++ b/music_assistant/providers/yandex_smarthome/constants.py @@ -28,7 +28,8 @@ # Cached Yandex Passport x_token from the first successful Device Flow. # Reused on subsequent auto-create runs (Smart Home + Dialog) so the user # does not have to confirm the device code every time. Long-lived (months); -# automatically refreshed on use. Cleared if Yandex returns 401 on refresh. +# automatically refreshed on use. If refresh fails (including 401), auth +# falls back to Device Flow; the cached value is not cleared here. CONF_AUTH_X_TOKEN = "auth_x_token" # --------------------------------------------------------------------------- From e08ca79760cf994c54707bd5b5af8d05a34c3cfd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 10:42:23 +0000 Subject: [PATCH 29/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.7.14 --- .../providers/yandex_smarthome/auto_skill.py | 117 +++++++++++++----- .../providers/yandex_smarthome/constants.py | 3 +- .../yandex_smarthome/test_auto_skill.py | 9 +- 3 files changed, 96 insertions(+), 33 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index 1c21524bb7..e56d84b577 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -74,6 +74,7 @@ "auto_create_skill", "auto_rename_dialog_skill", "build_dialog_draft_payload", + "build_dialog_publishing_settings", "build_draft_payload", "build_oauth_app_payload", "check_preconditions", @@ -696,25 +697,23 @@ def build_dialog_draft_payload( skill_name: str, backend_uri: str, logo_id: str | None, - developer_name: str = "Music Assistant user", + developer_name: str = "Music Assistant user", # noqa: ARG001 — kept for API parity ) -> dict[str, Any]: """Compose the PATCH /draft/update body for a Yandex Dialogs custom skill. - All fields and values were derived from a live ``GET /snapshot`` call - against the Yandex Dialogs developer console (which exposes the full - category catalogue + a previously deployed reference skill): - - - ``category="music_audio"``: the API key for "Аудио и подкасты" - (the previous guess ``"music_and_sounds"`` is not a valid Yandex - category — sending it returns HTTP 400 with an empty body). - - ``activationPhrases``: globally unique across all Yandex skills. - - ``voice="good_oksana"``: default female voice in Dialogs. - - ``structuredExamples``: shipped with three sample phrases that - match the patterns ``parse_command`` recognises so the catalogue - text reflects what users can really say. Required by - ``request_deploy`` for the ``aliceSkill`` channel. - - ``skillAccess="private"`` + ``hideInStore=true`` keep the skill - out of the public store. + Minimal payload that survives Yandex's silent 400-with-empty-body + rejection. The dev console captures showed Yandex pre-fills several + publishingSettings fields server-side (e.g. ``email`` from the user's + Passport account); sending an explicit empty value for any of them + appears to be invalid in some way the API doesn't bother to explain. + Strategy: ship only the fields we genuinely need to set (name, + activation, voice, backend URL, access flags) and let Yandex keep its + own defaults for everything else. + + The publishingSettings block (category, description, examples, etc.) + is filled by a separate pre-deploy PATCH right before + ``request_deploy`` so the user does not have to fill the form by + hand. See :func:`build_dialog_publishing_settings`. """ return { "name": skill_name, @@ -728,6 +727,33 @@ def build_dialog_draft_payload( "functionId": "", "backendType": "webhook", }, + "requiredInterfaces": [], + "exactSurfaces": [], + "surfaceWhitelist": [], + "surfaceBlacklist": [], + "oauthAppId": None, + "appMetricaApiKey": "", + "useStateStorage": False, + "rsyPlatformId": "", + "skillAccess": "private", + "hideInStore": True, + "channel": DIALOG_CHANNEL, + } + + +def build_dialog_publishing_settings( + *, + developer_name: str = "Music Assistant user", + user_email: str = "", +) -> dict[str, Any]: + """Compose only the publishingSettings block for a separate PATCH pass. + + Sent right before ``request_deploy`` so the deploy validation finds + everything filled. If ``user_email`` is empty the field is omitted + entirely (Yandex pre-fills from the Passport account; sending an + explicit empty string is what triggers the silent 400 we saw). + """ + settings: dict[str, Any] = { "publishingSettings": { "brandVerificationWebsite": "", "category": "music_audio", @@ -739,20 +765,11 @@ def build_dialog_draft_payload( {"phrase": "включи джаз на кухне"}, ], "description": "Free-form voice playback bridge for Music Assistant.", - "email": "", }, - "requiredInterfaces": [], - "exactSurfaces": [], - "surfaceWhitelist": [], - "surfaceBlacklist": [], - "oauthAppId": None, - "appMetricaApiKey": "", - "useStateStorage": False, - "rsyPlatformId": "", - "skillAccess": "private", - "hideInStore": True, - "channel": DIALOG_CHANNEL, } + if user_email: + settings["publishingSettings"]["email"] = user_email + return settings def build_oauth_app_payload( @@ -1484,8 +1501,50 @@ async def _execute_pipeline( # noqa: PLR0913, PLR0915 state = artifacts.state if state == SkillCreationState.DEPLOY_REQUESTED: + if skill_type == "dialog": + # Right before deploy, push a separate PATCH with the + # publishingSettings block (category, description, examples). + # Sending these in the initial draft PATCH triggers Yandex's + # silent "HTTP 400 with empty body" rejection — we have not + # been able to identify the exact field that fails validation. + # Splitting into two PATCH passes lets the first one succeed + # (initial fields) and tries the publishingSettings second; + # if it still fails, the skill is at least created and the + # user can finish setup in the Yandex Dialogs dev console. + settings_payload = build_dialog_publishing_settings( + developer_name=developer_name, + ) + try: + await creator.update_draft(csrf, skill_id, settings_payload) + except DialogsApiError as exc: + _LOGGER.warning( + "auto-skill: pre-deploy publishingSettings PATCH failed " + "(%s) — skill draft is created and reachable in the " + "Yandex Dialogs dev console; finish required fields " + "(description, category, example phrases) and click " + "'Submit for moderation' there to publish.", + exc, + ) + artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DONE) + await _maybe_save(progress_cb, artifacts) + return artifacts _LOGGER.info("auto-skill: [5/5] publishing skill") - await creator.request_deploy(csrf, skill_id) + try: + await creator.request_deploy(csrf, skill_id) + except DialogsApiError as exc: + if skill_type == "dialog": + # Same rationale: even if deploy fails, the skill exists + # and the user can publish from the dev console. + _LOGGER.warning( + "auto-skill: dialog request_deploy failed (%s) — skill " + "is created; complete the form fields in the Yandex " + "Dialogs dev console and submit for moderation there.", + exc, + ) + artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DONE) + await _maybe_save(progress_cb, artifacts) + return artifacts + raise artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DONE) await _maybe_save(progress_cb, artifacts) diff --git a/music_assistant/providers/yandex_smarthome/constants.py b/music_assistant/providers/yandex_smarthome/constants.py index 13b231ad17..d41e8c5462 100644 --- a/music_assistant/providers/yandex_smarthome/constants.py +++ b/music_assistant/providers/yandex_smarthome/constants.py @@ -28,8 +28,7 @@ # Cached Yandex Passport x_token from the first successful Device Flow. # Reused on subsequent auto-create runs (Smart Home + Dialog) so the user # does not have to confirm the device code every time. Long-lived (months); -# automatically refreshed on use. If refresh fails (including 401), auth -# falls back to Device Flow; the cached value is not cleared here. +# automatically refreshed on use. Cleared if Yandex returns 401 on refresh. CONF_AUTH_X_TOKEN = "auth_x_token" # --------------------------------------------------------------------------- diff --git a/tests/providers/yandex_smarthome/test_auto_skill.py b/tests/providers/yandex_smarthome/test_auto_skill.py index 60c1b94df0..9428393603 100644 --- a/tests/providers/yandex_smarthome/test_auto_skill.py +++ b/tests/providers/yandex_smarthome/test_auto_skill.py @@ -1021,7 +1021,9 @@ async def test_dialog_skill_reaches_done(self) -> None: assert result.state == SkillCreationState.DONE assert result.last_known_name == "Music Assistant" creator.create_app.assert_awaited_once() - creator.update_draft.assert_awaited_once() + # Two update_draft calls now: initial minimal draft + pre-deploy + # publishingSettings PATCH split. + assert creator.update_draft.await_count == 2 @pytest.mark.asyncio async def test_dialog_draft_uses_dialog_backend_uri(self) -> None: @@ -1043,7 +1045,10 @@ async def test_dialog_draft_uses_dialog_backend_uri(self) -> None: authenticator=_fake_authenticator_factory(), creator_factory=lambda _s: creator, ) - _, _, draft = creator.update_draft.call_args.args + # First update_draft call carries the initial minimal draft including + # backendSettings; the second call (pre-deploy) only contains + # publishingSettings, so we inspect call_args_list[0] specifically. + _, _, draft = creator.update_draft.call_args_list[0].args assert draft["backendSettings"]["uri"] == backend_uri assert draft["name"] == "My Skill" From fd8b62f6a3ae19c1f1c42110a7972430723d478f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 10:49:51 +0000 Subject: [PATCH 30/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.7.15 --- music_assistant/providers/yandex_smarthome/device.py | 6 ++++++ music_assistant/providers/yandex_smarthome/dialogs.py | 11 +++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/device.py b/music_assistant/providers/yandex_smarthome/device.py index d62fd0b110..9748f971d1 100644 --- a/music_assistant/providers/yandex_smarthome/device.py +++ b/music_assistant/providers/yandex_smarthome/device.py @@ -6,6 +6,7 @@ from __future__ import annotations +import asyncio import logging import re from typing import TYPE_CHECKING, Any @@ -551,6 +552,11 @@ async def execute_capability_action( # noqa: PLR0915 ), ), ) + except asyncio.CancelledError: + # Cooperative cancellation must propagate untouched — without this + # the broad `except Exception` below would convert a shutdown / + # config-flow abort into an INTERNAL_ERROR action result. + raise except Exception: _LOGGER.exception("Error executing action %s/%s on %s", action.type, instance, player_id) return CapabilityActionResult( diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index da22c29630..719a86dd42 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -1,8 +1,15 @@ """HTTP handler for the Yandex Dialogs custom-skill webhook (experimental). -Registers a single dynamic route on the MA webserver: +Registers a single exact route on the MA webserver — the secret is +**baked into the path string** at registration time, not a route +template variable: - POST /api/yandex_dialogs/webhook/{secret} + POST /api/yandex_dialogs/webhook/ + +Therefore ``request.match_info`` is empty in production; the handler +parses the secret from ``request.path`` (last segment) for the +constant-time compare. Tests that pass an explicit ``match_info`` cover +the alternative branch. Yandex Dialogs does not send an Authorization header on webhook calls, so authentication is two-layered: From bcfcb11ce9934dde51a14c4b3c0dd11e5a1aefb4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 11:18:41 +0000 Subject: [PATCH 31/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.7.16 --- .../providers/yandex_smarthome/auto_skill.py | 137 ++++++------------ .../yandex_smarthome/test_auto_skill.py | 9 +- 2 files changed, 50 insertions(+), 96 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index e56d84b577..57b8602d38 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -74,7 +74,6 @@ "auto_create_skill", "auto_rename_dialog_skill", "build_dialog_draft_payload", - "build_dialog_publishing_settings", "build_draft_payload", "build_oauth_app_payload", "check_preconditions", @@ -697,25 +696,26 @@ def build_dialog_draft_payload( skill_name: str, backend_uri: str, logo_id: str | None, - developer_name: str = "Music Assistant user", # noqa: ARG001 — kept for API parity + developer_name: str = "Music Assistant user", ) -> dict[str, Any]: """Compose the PATCH /draft/update body for a Yandex Dialogs custom skill. - Minimal payload that survives Yandex's silent 400-with-empty-body - rejection. The dev console captures showed Yandex pre-fills several - publishingSettings fields server-side (e.g. ``email`` from the user's - Passport account); sending an explicit empty value for any of them - appears to be invalid in some way the API doesn't bother to explain. - Strategy: ship only the fields we genuinely need to set (name, - activation, voice, backend URL, access flags) and let Yandex keep its - own defaults for everything else. - - The publishingSettings block (category, description, examples, etc.) - is filled by a separate pre-deploy PATCH right before - ``request_deploy`` so the user does not have to fill the form by - hand. See :func:`build_dialog_publishing_settings`. + All fields and shapes were captured from a live PATCH issued by the + dev console after the user filled the form successfully. Notable + discoveries: + + - ``structuredExamples`` shape: each entry is + ``{"marker": , "activationPhrase": , + "request": , "is_valid": true}`` — NOT ``{"phrase": "..."}`` + as we previously guessed (that wrong shape was the cause of all + the silent HTTP 400 + empty-body rejections from Yandex). + - ``description``: required non-empty. + - ``category``: ``"music_audio"`` (API key for "Аудио и подкасты"). + - ``email``: empty string is OK; Yandex pre-fills it from the user's + Passport account on its side anyway. """ return { + "logo2": None, "name": skill_name, "voice": "good_oksana", "activationPhrases": [skill_name], @@ -727,6 +727,38 @@ def build_dialog_draft_payload( "functionId": "", "backendType": "webhook", }, + "publishingSettings": { + "brandVerificationWebsite": "", + "category": "music_audio", + "developerName": developer_name, + "explicitContent": False, + "structuredExamples": [ + { + "marker": "попроси", + "activationPhrase": skill_name, + "request": "включи Metallica", + "is_valid": True, + }, + { + "marker": "попроси", + "activationPhrase": skill_name, + "request": "включи мою волну", + "is_valid": True, + }, + { + "marker": "попроси", + "activationPhrase": skill_name, + "request": "включи джаз на кухне", + "is_valid": True, + }, + ], + "description": ( + f"{skill_name}: голосовое управление Music Assistant — " + "поиск и воспроизведение треков, альбомов, плейлистов, " + "радио и Моей волны на любой колонке в системе." + ), + "email": "", + }, "requiredInterfaces": [], "exactSurfaces": [], "surfaceWhitelist": [], @@ -741,37 +773,6 @@ def build_dialog_draft_payload( } -def build_dialog_publishing_settings( - *, - developer_name: str = "Music Assistant user", - user_email: str = "", -) -> dict[str, Any]: - """Compose only the publishingSettings block for a separate PATCH pass. - - Sent right before ``request_deploy`` so the deploy validation finds - everything filled. If ``user_email`` is empty the field is omitted - entirely (Yandex pre-fills from the Passport account; sending an - explicit empty string is what triggers the silent 400 we saw). - """ - settings: dict[str, Any] = { - "publishingSettings": { - "brandVerificationWebsite": "", - "category": "music_audio", - "developerName": developer_name, - "explicitContent": False, - "structuredExamples": [ - {"phrase": "включи Metallica"}, - {"phrase": "включи мою волну"}, - {"phrase": "включи джаз на кухне"}, - ], - "description": "Free-form voice playback bridge for Music Assistant.", - }, - } - if user_email: - settings["publishingSettings"]["email"] = user_email - return settings - - def build_oauth_app_payload( *, skill_name: str, @@ -1501,50 +1502,8 @@ async def _execute_pipeline( # noqa: PLR0913, PLR0915 state = artifacts.state if state == SkillCreationState.DEPLOY_REQUESTED: - if skill_type == "dialog": - # Right before deploy, push a separate PATCH with the - # publishingSettings block (category, description, examples). - # Sending these in the initial draft PATCH triggers Yandex's - # silent "HTTP 400 with empty body" rejection — we have not - # been able to identify the exact field that fails validation. - # Splitting into two PATCH passes lets the first one succeed - # (initial fields) and tries the publishingSettings second; - # if it still fails, the skill is at least created and the - # user can finish setup in the Yandex Dialogs dev console. - settings_payload = build_dialog_publishing_settings( - developer_name=developer_name, - ) - try: - await creator.update_draft(csrf, skill_id, settings_payload) - except DialogsApiError as exc: - _LOGGER.warning( - "auto-skill: pre-deploy publishingSettings PATCH failed " - "(%s) — skill draft is created and reachable in the " - "Yandex Dialogs dev console; finish required fields " - "(description, category, example phrases) and click " - "'Submit for moderation' there to publish.", - exc, - ) - artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DONE) - await _maybe_save(progress_cb, artifacts) - return artifacts _LOGGER.info("auto-skill: [5/5] publishing skill") - try: - await creator.request_deploy(csrf, skill_id) - except DialogsApiError as exc: - if skill_type == "dialog": - # Same rationale: even if deploy fails, the skill exists - # and the user can publish from the dev console. - _LOGGER.warning( - "auto-skill: dialog request_deploy failed (%s) — skill " - "is created; complete the form fields in the Yandex " - "Dialogs dev console and submit for moderation there.", - exc, - ) - artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DONE) - await _maybe_save(progress_cb, artifacts) - return artifacts - raise + await creator.request_deploy(csrf, skill_id) artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DONE) await _maybe_save(progress_cb, artifacts) diff --git a/tests/providers/yandex_smarthome/test_auto_skill.py b/tests/providers/yandex_smarthome/test_auto_skill.py index 9428393603..60c1b94df0 100644 --- a/tests/providers/yandex_smarthome/test_auto_skill.py +++ b/tests/providers/yandex_smarthome/test_auto_skill.py @@ -1021,9 +1021,7 @@ async def test_dialog_skill_reaches_done(self) -> None: assert result.state == SkillCreationState.DONE assert result.last_known_name == "Music Assistant" creator.create_app.assert_awaited_once() - # Two update_draft calls now: initial minimal draft + pre-deploy - # publishingSettings PATCH split. - assert creator.update_draft.await_count == 2 + creator.update_draft.assert_awaited_once() @pytest.mark.asyncio async def test_dialog_draft_uses_dialog_backend_uri(self) -> None: @@ -1045,10 +1043,7 @@ async def test_dialog_draft_uses_dialog_backend_uri(self) -> None: authenticator=_fake_authenticator_factory(), creator_factory=lambda _s: creator, ) - # First update_draft call carries the initial minimal draft including - # backendSettings; the second call (pre-deploy) only contains - # publishingSettings, so we inspect call_args_list[0] specifically. - _, _, draft = creator.update_draft.call_args_list[0].args + _, _, draft = creator.update_draft.call_args.args assert draft["backendSettings"]["uri"] == backend_uri assert draft["name"] == "My Skill" From 4e017c84ad735b65d64086a631157399109b515c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 11:47:51 +0000 Subject: [PATCH 32/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.7.20 --- .../providers/yandex_smarthome/auto_skill.py | 44 ++++++++++++++ .../yandex_smarthome/auto_skill_ui.py | 21 +++++++ .../providers/yandex_smarthome/dialogs_nlu.py | 59 +++++++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index 57b8602d38..9b2f03e343 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -401,6 +401,35 @@ async def request_deploy(self, csrf: str, skill_id: str) -> None: http_status=resp.status, ) + async def get_operations(self, csrf: str, skill_id: str) -> list[dict[str, Any]]: + """Fetch the recent operations log for a skill. + + Returns entries like ``{"type": "deployCompleted", "itemId": "", "createdAt": "..."}``. + Used to poll for ``deployCompleted`` after ``request_deploy``. + """ + url = f"{DIALOGS_API_BASE}/apps/{skill_id}/operations" + headers = {"x-csrf-token": csrf} + async with self._session.get(url, headers=headers) as resp: + body = await resp.text() + if resp.status != 200: + raise DialogsApiError( + f"get_operations HTTP {resp.status}: {body[:200]}", + step="get_operations", + http_status=resp.status, + ) + data = _try_json(body) + if isinstance(data, dict): + result = data.get("result", data) + if isinstance(result, list): + return [op for op in result if isinstance(op, dict)] + if isinstance(result, dict): + ops = result.get("operations") or result.get("items") + if isinstance(ops, list): + return [op for op in ops if isinstance(op, dict)] + if isinstance(data, list): + return [op for op in data if isinstance(op, dict)] + return [] + # ----------------------------------------------------------------------- # Internal helpers # ----------------------------------------------------------------------- @@ -1504,6 +1533,21 @@ async def _execute_pipeline( # noqa: PLR0913, PLR0915 if state == SkillCreationState.DEPLOY_REQUESTED: _LOGGER.info("auto-skill: [5/5] publishing skill") await creator.request_deploy(csrf, skill_id) + # Yandex's deploy is async — for smart_home it usually completes + # in a few seconds, but for aliceSkill ("Навык") it can take + # 5-15 minutes under typical moderation queue conditions. We + # don't block the config-flow waiting; the request was accepted, + # Yandex will finish on its side. The UI surfaces a direct link + # to the skill's dev-console page so the user can check the + # on-air indicator at their convenience. + _LOGGER.info( + "auto-skill: deploy requested for skill %s — Yandex processes " + "this asynchronously (a few seconds for smart_home, several " + "minutes for dialog skills). Watch on-air status at " + "https://dialogs.yandex.ru/developer/skills/%s", + skill_id, + skill_id, + ) artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DONE) await _maybe_save(progress_cb, artifacts) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py index 1a17bc4bef..5f4744d068 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py @@ -944,6 +944,27 @@ def _dialog_skill_entries( ) ) + # Once a skill exists, surface a direct link to its dev-console page so + # the user can verify the publish status / tweak the form fields manually. + if artifacts.skill_id: + skill_url = f"https://dialogs.yandex.ru/developer/skills/{artifacts.skill_id}" + entries.append( + ConfigEntry( + key="label_dialog_skill_link", + type=ConfigEntryType.LABEL, + label=( + f"🔗 Skill in Yandex Dialogs dev console: {skill_url}\n\n" + "Status indicator at the top of that page shows whether " + "the skill is *on air* yet. Yandex deploys aliceSkills " + "asynchronously — for private skills this typically " + "takes a few minutes (sometimes 5-10 under load) after " + "auto-create completes. The skill is unusable on Alice " + "until the dev console shows «На воздухе»." # noqa: RUF001 + ), + category=_CAT_DIALOG_SKILL, + ) + ) + # HTTPS prerequisite warning direct_https_missing = not base_url.startswith("https://") if direct_https_missing: diff --git a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py index f2bcf11075..dcf392ce51 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py @@ -163,6 +163,24 @@ def parse_command(text: str) -> ParsedCommand: ) +# Generic Russian words for "speaker / player" — fall through to the +# default/only-exposed player if the user said one of these instead of +# a specific player name. Stored as already-normalised stems so we can +# compare against the same normalisation we run on `hint`. +_GENERIC_PLAYER_STEMS = frozenset( + { + "колонк", # колонка / на колонке / колонку + "плеер", # плеер / на плеере / плеера + "пле", # short for "плеер" after stripping the trailing -ер suffix # noqa: RUF003 + "проигрыватель", # full word survives stem (no matching suffix) + "проигрывател", # stripped «-ь» + "динамик", # динамик / на динамике + "акустик", # акустика / на акустике + "устройств", # устройство / на устройстве + } +) + + def _normalize_player_token(name: str) -> str: """Lowercase + strip common Russian inflection suffix. @@ -233,8 +251,10 @@ def resolve_player( exact: list[Any] = [] startswith: list[Any] = [] contains: list[Any] = [] + haystacks: list[tuple[str, str]] = [] # (raw, normalised) for debug for p in candidates: haystack = _normalize_player_token(p.name or p.player_id) + haystacks.append((p.name or p.player_id, haystack)) if not haystack: continue if haystack == needle: @@ -244,6 +264,17 @@ def resolve_player( elif needle in haystack or haystack in needle: contains.append(p) + _LOGGER.debug( + "resolve_player: hint=%r → needle=%r; candidates=%s; " + "matches: exact=%d startswith=%d contains=%d", + hint, + needle, + haystacks, + len(exact), + len(startswith), + len(contains), + ) + for tier in (exact, startswith, contains): if not tier: continue @@ -257,4 +288,32 @@ def resolve_player( tier.sort(key=lambda p: (p.name or p.player_id).lower()) return tier[0] + # Generic-word fallback: user said something like "на колонке" / + # "на проигрывателе" / "на динамике" — these mean "any speaker", not + # a specific player. If only one player is exposed (or a default_id + # is configured) we can resolve unambiguously; otherwise still None. + if any(stem in needle for stem in _GENERIC_PLAYER_STEMS): + if default_id: + for p in candidates: + if p.player_id == default_id: + _LOGGER.info( + "Generic player hint %r → resolved to default player %r", + hint, + p.name, + ) + return p + if len(candidates) == 1: + _LOGGER.info( + "Generic player hint %r → resolved to the only exposed player %r", + hint, + candidates[0].name, + ) + return candidates[0] + _LOGGER.warning( + "Generic player hint %r matches no specific player and there are " + "%d exposed players — caller will ask for clarification", + hint, + len(candidates), + ) + return None From a098b550d4a10068ddc5f74749a09e3d28f0d232 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 12:03:20 +0000 Subject: [PATCH 33/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.7.21 --- .../providers/yandex_smarthome/dialogs_nlu.py | 25 +++++++++++++++---- .../yandex_smarthome/dialogs_player.py | 9 +++++-- .../yandex_smarthome/test_dialogs_nlu.py | 24 ++++++++++-------- .../yandex_smarthome/test_dialogs_player.py | 17 +++++++++---- 4 files changed, 53 insertions(+), 22 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py index dcf392ce51..90905fd75b 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py @@ -50,10 +50,21 @@ class ParsedCommand: # (e.g. "Natalie" in Russian) isn't mis-split mid-token. _PLAYER_SUFFIX_RE = re.compile(r"\s+на\s+(?P.+?)\s*$", re.IGNORECASE) -# Verb stem covering Russian imperative forms used to start playback -# ("turn on", "play", "launch") with their plural / aspect variants. +# Verb regex covering Russian imperative + infinitive forms used to +# start playback ("turn on", "play", "launch"). Yandex's voice-to-text +# sometimes returns the infinitive ("включить") even if the user spoke +# the imperative ("включи"); we accept both. Also covers the plural +# imperatives (-те), informal aspect variants (включай/сыграй), +# and the listening verbs (послушай/послушать). _VERB_RE = re.compile( - r"^(?:алиса[, ]+)?(?:включи(?:те)?|включай(?:те)?|поставь(?:те)?|запусти(?:те)?)\s+", + r"^(?:алиса[, ]+)?(?:" + r"включи(?:те)?|включай(?:те)?|включить|" + r"поставь(?:те)?|поставить|" + r"запусти(?:те)?|запустить|" + r"сыграй(?:те)?|сыграть|" + r"играй(?:те)?|" + r"послушай(?:те)?|послушать" + r")\s+", re.IGNORECASE, ) @@ -130,12 +141,16 @@ def parse_command(text: str) -> ParsedCommand: radio_mode=radio, ) - # Fallback: unstructured search — let mass.music.search figure out the type. + # Fallback: unstructured search — let mass.music.search figure out + # the type. Force radio_mode=True so when the result is an artist or + # a single track, MA starts a radio based on it instead of playing + # one item and stopping (matches the typical user expectation + # "включи " → "play music"). return ParsedCommand( kind="search", query=intent_part.lower(), player_hint=player_hint, - radio_mode=False, + radio_mode=True, ) diff --git a/music_assistant/providers/yandex_smarthome/dialogs_player.py b/music_assistant/providers/yandex_smarthome/dialogs_player.py index 205e93852d..87e3addfaa 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_player.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_player.py @@ -95,10 +95,15 @@ async def resolve_query(mass: MusicAssistant, parsed: ParsedCommand) -> MediaIte def _pick_from_results(results: object, kind: str) -> MediaItemType | None: """Pick best MediaItem from SearchResults given the parsed kind.""" # SearchResults has .artists, .albums, .tracks, .playlists, plus .library_*. - # For "search" (kind=catch-all) prefer playlist > album > artist > track. + # For "search" (no explicit marker), users almost always say a band / + # song / album name without qualifier ("включи Iron Maiden", + # "включи Yesterday"). Best UX is to resolve to the ARTIST first + # (radio_mode=True will be set on top → starts artist radio), then + # ALBUM, then TRACK; PLAYLIST is least likely to be what the user + # wants when they didn't say "плейлист" or "подборку". order: list[str] if kind == "search": - order = ["playlists", "albums", "artists", "tracks"] + order = ["artists", "albums", "tracks", "playlists"] elif kind == "track": order = ["tracks"] elif kind == "artist": diff --git a/tests/providers/yandex_smarthome/test_dialogs_nlu.py b/tests/providers/yandex_smarthome/test_dialogs_nlu.py index 4c6e76903c..a062057646 100644 --- a/tests/providers/yandex_smarthome/test_dialogs_nlu.py +++ b/tests/providers/yandex_smarthome/test_dialogs_nlu.py @@ -23,9 +23,10 @@ class TestParseCommand: @pytest.mark.parametrize( ("phrase", "expected_kind", "expected_query", "expected_hint", "expected_radio"), [ - # Bare/default search - ("включи Metallica", "search", "metallica", None, False), - ("включи джаз", "search", "джаз", None, False), + # Bare/default search — radio_mode=True so artists and tracks + # start a radio rather than playing one item and stopping. + ("включи Metallica", "search", "metallica", None, True), + ("включи джаз", "search", "джаз", None, True), # Track explicit ("включи песню Yesterday", "track", "yesterday", None, False), ("включи трек Imagine", "track", "imagine", None, False), @@ -45,18 +46,21 @@ class TestParseCommand: ("включи жанр джаз", "genre", "джаз", None, True), ("включи радио рок", "genre", "рок", None, True), # With player suffix - ("включи Metallica на кухне", "search", "metallica", "кухне", False), + ("включи Metallica на кухне", "search", "metallica", "кухне", True), ("включи песню Yesterday на спальне", "track", "yesterday", "спальне", False), ("включи мою волну на кухне", "my_wave", "", "кухне", True), ("включи альбом Black Album на колонке", "album", "black album", "колонке", False), # Punctuation, casing, alice prefix - ("Алиса, включи Metallica.", "search", "metallica", None, False), + ("Алиса, включи Metallica.", "search", "metallica", None, True), ("ВКЛЮЧИ ПЕСНЮ Hey Jude!", "track", "hey jude", None, False), - # Different verbs (including включай which was previously unmatched) - ("поставь Metallica", "search", "metallica", None, False), - ("запусти джаз на кухне", "search", "джаз", "кухне", False), - ("включай Metallica", "search", "metallica", None, False), - ("включайте джаз на кухне", "search", "джаз", "кухне", False), + # Different verbs (incl. infinitives Yandex sometimes returns) + ("поставь Metallica", "search", "metallica", None, True), + ("запусти джаз на кухне", "search", "джаз", "кухне", True), + ("включай Metallica", "search", "metallica", None, True), + ("включайте джаз на кухне", "search", "джаз", "кухне", True), + ("включить Iron Maiden", "search", "iron maiden", None, True), + ("сыграй Metallica на кухне", "search", "metallica", "кухне", True), + ("послушать джаз", "search", "джаз", None, True), ], ) def test_parse( diff --git a/tests/providers/yandex_smarthome/test_dialogs_player.py b/tests/providers/yandex_smarthome/test_dialogs_player.py index 46914f3e68..cf06054ad3 100644 --- a/tests/providers/yandex_smarthome/test_dialogs_player.py +++ b/tests/providers/yandex_smarthome/test_dialogs_player.py @@ -72,13 +72,20 @@ async def test_playlist(self) -> None: result = await resolve_query(mass, ParsedCommand(kind="playlist", query="rock")) assert result is playlist - async def test_search_kind_prefers_playlist(self) -> None: - """kind=search prefers playlist over track when both are present.""" + async def test_search_kind_prefers_artist(self) -> None: + """kind=search prefers artist over playlist/track for unqualified queries. + + Users typically say a band/artist name without a "плейлист" / + "альбом" qualifier ("включи Iron Maiden"). Picking the artist + (with radio_mode=True downstream) matches the intent better than + starting an unrelated playlist that happens to contain the query. + """ + artist = MagicMock(uri="library://artist/1", spec_set=["uri"]) playlist = MagicMock(uri="library://playlist/1", spec_set=["uri"]) track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass(_SearchResults(playlists=[playlist], tracks=[track])) - result = await resolve_query(mass, ParsedCommand(kind="search", query="rock")) - assert result is playlist + mass = _make_mass(_SearchResults(artists=[artist], playlists=[playlist], tracks=[track])) + result = await resolve_query(mass, ParsedCommand(kind="search", query="iron maiden")) + assert result is artist async def test_search_no_results_returns_none(self) -> None: """Empty search results return None.""" From 304eebd7425b7c56493eb21729af2e53376b039d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 14:39:43 +0000 Subject: [PATCH 34/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.7.21 --- .../providers/yandex_smarthome/__init__.py | 2 +- .../providers/yandex_smarthome/auto_skill.py | 2 +- .../providers/yandex_smarthome/constants.py | 2 - .../providers/yandex_smarthome/device.py | 2 +- .../providers/yandex_smarthome/dialogs.py | 635 +++++++++++++++-- .../yandex_smarthome/dialogs_control.py | 283 ++++++++ .../providers/yandex_smarthome/dialogs_nlu.py | 151 +++-- .../yandex_smarthome/dialogs_player.py | 34 +- .../yandex_smarthome/test_dialogs.py | 641 +++++++++++++++++- .../yandex_smarthome/test_dialogs_control.py | 308 +++++++++ .../yandex_smarthome/test_dialogs_nlu.py | 60 ++ .../yandex_smarthome/test_dialogs_player.py | 35 + 12 files changed, 2013 insertions(+), 142 deletions(-) create mode 100644 music_assistant/providers/yandex_smarthome/dialogs_control.py create mode 100644 tests/providers/yandex_smarthome/test_dialogs_control.py diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index c0e014db14..59421dea27 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -483,7 +483,7 @@ def _cache_x_token(token: str) -> None: values[CONF_DIALOG_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) -async def get_config_entries( # noqa: PLR0915 +async def get_config_entries( mass: MusicAssistant, instance_id: str | None = None, action: str | None = None, diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index 9b2f03e343..5600557505 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -1402,7 +1402,7 @@ async def _track(a: SkillCreationArtifacts) -> None: return dataclasses.replace(current, state=SkillCreationState.FAILED, last_error=str(exc)) -async def _execute_pipeline( # noqa: PLR0913, PLR0915 +async def _execute_pipeline( # noqa: PLR0913 *, creator: DialogsSkillCreator, csrf: str, diff --git a/music_assistant/providers/yandex_smarthome/constants.py b/music_assistant/providers/yandex_smarthome/constants.py index d41e8c5462..dba0eeaa12 100644 --- a/music_assistant/providers/yandex_smarthome/constants.py +++ b/music_assistant/providers/yandex_smarthome/constants.py @@ -171,5 +171,3 @@ DIALOG_CHANNEL = os.environ.get("MA_YANDEX_DIALOG_CHANNEL", "aliceSkill") DIALOG_NAME_MIN_LEN = 2 DIALOG_NAME_MAX_LEN = 64 -DIALOG_SESSION_CACHE_MAX = 200 -DIALOG_SESSION_TTL_SEC = 3600 # 1 hour — last-player memory per session diff --git a/music_assistant/providers/yandex_smarthome/device.py b/music_assistant/providers/yandex_smarthome/device.py index 9748f971d1..fbd9dd9c6c 100644 --- a/music_assistant/providers/yandex_smarthome/device.py +++ b/music_assistant/providers/yandex_smarthome/device.py @@ -430,7 +430,7 @@ def _invalid_numeric_result(cap_type: str, instance: str, value: Any) -> Capabil ) -async def execute_capability_action( # noqa: PLR0915 +async def execute_capability_action( mass: Any, player_id: str, action: CapabilityAction, diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index 719a86dd42..aad2c309e3 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -23,15 +23,20 @@ A request is rejected with 404 if the secret doesn't match (no leak via 401 timing) and with 401 if the skill_id doesn't match (configured skill received a payload from a different skill — should never happen). + +Session memory: the handler does not keep any in-process LRU. The +"last player used" default is round-tripped through Yandex's three +state buckets (priority: ``state.session`` → ``state.application`` → +``state.user``), which survives plugin reloads and even MA restarts +for the application/user tiers. """ from __future__ import annotations import asyncio import logging +import re import secrets -import time -from collections import OrderedDict from collections.abc import Callable from typing import TYPE_CHECKING, Any @@ -39,11 +44,22 @@ from .constants import ( DIALOG_RESOLVE_TIMEOUT, - DIALOG_SESSION_CACHE_MAX, - DIALOG_SESSION_TTL_SEC, DIALOG_WEBHOOK_BASE_PATH, ) -from .dialogs_nlu import parse_command, resolve_player +from .dialogs_control import ( + control_confirmation, + execute_control, + format_list_players, + parse_control, +) +from .dialogs_nlu import ( + _VERB_RE, + ParsedCommand, + list_exposed_players, + parse_command, + resolve_player, + resolve_player_candidates, +) from .dialogs_player import play_for_alice, resolve_query if TYPE_CHECKING: @@ -53,6 +69,64 @@ _LOGGER = logging.getLogger(__name__) +# Static stress-mark dictionary for common response words (P0.2). +# Keys are case-insensitive whole-word matches; the marker is `+` placed +# directly before the stressed vowel — Yandex Alice TTS supports this +# inline syntax. Keep small and high-confidence; band/track names are +# left as-is (those need a separate phoneme dict — P2.3). +_TTS_STRESS_MARKS: dict[str, str] = { + "включаю": "включ+аю", + "ставлю": "ст+авлю", + "пауза": "п+ауза", + "продолжаю": "продолж+аю", + "следующая": "сл+едующая", + "предыдущая": "пред+ыдущая", + "громче": "гр+омче", + "тише": "т+ише", + "громкость": "гр+омкость", + "колонке": "кол+онке", + "колонку": "кол+онку", +} + +_TTS_WORD_RE = re.compile(r"[А-Яа-яЁё]+") + + +def _tts_for(text: str) -> str: + """Add `+` stress markers to known words for cleaner Alice TTS. + + Pure substitution — unknown words pass through unchanged. The map is + intentionally small (high-confidence Russian response words only); + expand via PRs as patterns emerge. + """ + if not text: + return text + + def _sub(match: re.Match[str]) -> str: + word = match.group(0) + replacement = _TTS_STRESS_MARKS.get(word.lower()) + if replacement is None: + return word + if word[:1].isupper(): + return replacement[:1].upper() + replacement[1:] + return replacement + + return _TTS_WORD_RE.sub(_sub, text) + + +def _safe_dict(value: Any) -> dict[str, Any]: + """Return value if it's a dict, else an empty dict (defensive parsing).""" + return value if isinstance(value, dict) else {} + + +def _without_pending(state: dict[str, Any]) -> dict[str, Any]: + """Return a copy of `state` with `pending_command` and `awaiting_query` removed. + + Used after the disambiguation/elicitation flow successfully completes + so the next turn doesn't accidentally re-enter the saved branch. + """ + return {k: v for k, v in state.items() if k not in {"pending_command", "awaiting_query"}} + + class DialogsWebhookHandler: """Handles incoming voice-command webhook calls from a Yandex Dialogs skill.""" @@ -82,7 +156,6 @@ def __init__( self._exposed_player_ids = exposed_player_ids self._logger = logger or _LOGGER self._unregister_callbacks: list[Callable[[], None]] = [] - self._last_player: OrderedDict[str, tuple[str, float]] = OrderedDict() def register_routes(self) -> None: """Register the webhook route on mass.webserver.""" @@ -107,35 +180,6 @@ def unregister_routes(self) -> None: self._logger.debug("Error unregistering dialog route", exc_info=True) self._unregister_callbacks.clear() - # ------------------------------------------------------------------- - # Session memory - # ------------------------------------------------------------------- - - def _remember_player(self, session_id: str, player_id: str) -> None: - now = time.monotonic() - self._last_player[session_id] = (player_id, now) - self._last_player.move_to_end(session_id) - # Evict oldest by insertion order if cap exceeded. - while len(self._last_player) > DIALOG_SESSION_CACHE_MAX: - self._last_player.popitem(last=False) - # Also evict TTL-expired entries opportunistically. - cutoff = now - DIALOG_SESSION_TTL_SEC - for sid in list(self._last_player.keys()): - if self._last_player[sid][1] < cutoff: - self._last_player.pop(sid, None) - else: - break - - def _get_default_player(self, session_id: str) -> str | None: - entry = self._last_player.get(session_id) - if entry is None: - return None - player_id, ts = entry - if time.monotonic() - ts > DIALOG_SESSION_TTL_SEC: - self._last_player.pop(session_id, None) - return None - return player_id - # ------------------------------------------------------------------- # Webhook entry point # ------------------------------------------------------------------- @@ -153,9 +197,15 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: except asyncio.CancelledError: raise except Exception: - return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") # noqa: RUF001 + return self._yandex_response( + incoming_session={}, + text="Что-то пошло не так с запросом.", + ) if not isinstance(body, dict): - return self._yandex_response(session_state={}, text="Что-то пошло не так с запросом.") # noqa: RUF001 + return self._yandex_response( + incoming_session={}, + text="Что-то пошло не так с запросом.", + ) session = body.get("session") or {} if not isinstance(session, dict): @@ -174,59 +224,340 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: ) return web.Response(status=401) - session_id = str(session.get("session_id") or "") + # State buckets (P0.1 — replace in-memory LRU with Yandex state). + state = body.get("state") or {} + if not isinstance(state, dict): + state = {} + session_state_in = _safe_dict(state.get("session")) + app_state_in = _safe_dict(state.get("application")) + user_state_in = _safe_dict(state.get("user")) + + default_id_raw = ( + session_state_in.get("last_player_id") + or app_state_in.get("last_player_id") + or user_state_in.get("preferred_player_id") + ) + default_id = str(default_id_raw) if default_id_raw else None + is_new = bool(session.get("new")) command = str(req.get("command") or "").strip() + # Single summary log per incoming request — surfaces the wire-shape + # bits we route on. Sensitive fields (skill_id, webhook_secret, + # raw payload IDs) are excluded; user/session IDs are opaque + # opaque tokens and DEBUG is opt-in, so they're included as-is. + self._logger.debug( + "Webhook recv: cmd=%r req_type=%s is_new=%s pending=%s " + "awaiting=%s default_player=%s session_id=%s", + command, + req.get("type", "SimpleUtterance"), + is_new, + bool(session_state_in.get("pending_command")), + bool(session_state_in.get("awaiting_query")), + default_id, + session.get("session_id", ""), + ) + if is_new and not command: + text = "Привет! Скажи, что включить и на какой колонке." return self._yandex_response( - session_state=session, - text="Привет! Скажи, что включить и на какой колонке.", + incoming_session=session, + text=text, + tts=_tts_for(text), end_session=False, + session_state=session_state_in, ) if not command: + text = "Не понял команду. Скажи, например: включи рок на кухне." return self._yandex_response( - session_state=session, - text="Не понял команду. Скажи, например: включи рок на кухне.", # noqa: RUF001 + incoming_session=session, + text=text, + tts=_tts_for(text), end_session=False, + session_state=session_state_in, + ) + + # P0.6 — try control commands (pause/next/volume/...) FIRST, on + # the raw command. Doing this before the awaiting-query synthesis + # lets the user pivot from a slot-elicit prompt straight into a + # control intent ("Включи." → "Что включить?" → "пауза на кухне") + # without the prefix-prepend turning it into "включи пауза…". + # If control matches, drop any pending/awaiting state — the user + # is no longer in either of those flows. + if control := parse_control(command): + self._logger.debug("Parsed dialog control %r → %r", command, control) + return self._handle_control( + session=session, + control=control, + default_id=default_id, + session_state_in=_without_pending(session_state_in), + app_state_in=app_state_in, + ) + + # P0.4 — awaiting-query re-entry. If the previous turn asked "Что + # включить?" and the new utterance isn't a control phrase, treat + # it as the missing query slot. Prepend a synthetic "включи " so + # the existing kind classifier runs ("песню X", "альбом Y", + # "мою волну", etc.). Skip the synthetic prefix if the user + # already said one of the verbs. + if session_state_in.get("awaiting_query") and not _VERB_RE.match(command): + command = f"включи {command}" + self._logger.debug("Awaiting-query branch: synthesised cmd=%r", command) + + # P0.3 — pending-command re-entry. If a previous turn asked the + # user to disambiguate which player to use, the new utterance (or + # button press) carries the answer; replay the saved play intent. + pending = session_state_in.get("pending_command") + if isinstance(pending, dict): + self._logger.debug( + "Pending-command branch: kind=%s query=%r radio=%s; cmd=%r payload=%s", + pending.get("kind"), + pending.get("query"), + pending.get("radio_mode"), + command, + bool(_safe_dict(req.get("payload")).get("player_id")), + ) + replay_response = await self._try_resume_pending( + session=session, + req=req, + command=command, + pending=pending, + session_state_in=session_state_in, + app_state_in=app_state_in, + ) + if replay_response is not None: + return replay_response + self._logger.debug( + "Pending-command branch: could not resume — falling through to parse_command" ) parsed = parse_command(command) self._logger.debug("Parsed dialog command %r → %r", command, parsed) + return await self._dispatch_play( + session=session, + parsed=parsed, + default_id=default_id, + session_state_in=session_state_in, + app_state_in=app_state_in, + ) - default_id = self._get_default_player(session_id) if session_id else None - player = resolve_player( + # ------------------------------------------------------------------- + # Play dispatch (slot-elicit + resolve + disambiguate + play) + # ------------------------------------------------------------------- + + async def _dispatch_play( + self, + *, + session: dict[str, Any], + parsed: ParsedCommand, + default_id: str | None, + session_state_in: dict[str, Any], + app_state_in: dict[str, Any], + ) -> web.Response: + """Slot-elicit / resolve player / disambiguate / play (or fail).""" + # P0.4 — slot elicitation: bare verb with no actionable content. + if parsed.kind == "search" and not parsed.query and not parsed.player_hint: + self._logger.debug("Slot-elicit branch: empty query, asking 'Что включить?'") + text = "Что включить? Можно сказать имя артиста, песни или плейлиста." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state={ + **_without_pending(session_state_in), + "awaiting_query": True, + }, + ) + + candidates = resolve_player_candidates( self._mass, parsed.player_hint, default_id=default_id, exposed_ids=self._exposed_player_ids, ) - if player is None: + if not candidates: + # Special case: no hint, no default, multiple exposed players. + # `resolve_player_candidates` returns [] with no hint when it + # can't pick deterministically — for the user that's ambiguity, + # not "not found". Surface all exposed players for + # disambiguation instead of the misleading "не нашёл колонку + # «(не указано)»". + if parsed.player_hint is None and default_id is None: + all_exposed = list_exposed_players(self._mass, exposed_ids=self._exposed_player_ids) + if len(all_exposed) >= 2: + self._logger.debug( + "Play branch: no hint + no default + %d exposed → " + "disambiguation across all exposed players", + len(all_exposed), + ) + return self._build_disambiguation_response( + session=session, + parsed=parsed, + candidates=all_exposed, + session_state_in=session_state_in, + ) hint = parsed.player_hint or "(не указано)" + self._logger.info( + "Play branch: no player resolved for hint=%r (default_id=%s); " + "responding 'не нашёл колонку'", + parsed.player_hint, + default_id, + ) + text = f"Не нашёл колонку «{hint}». Скажи, например: на кухне." return self._yandex_response( - session_state=session, - text=f"Не нашёл колонку «{hint}». Скажи, например: на кухне.", # noqa: RUF001 + incoming_session=session, + text=text, + tts=_tts_for(text), end_session=False, + session_state=session_state_in, + ) + if len(candidates) > 1: + self._logger.debug( + "Play branch: ambiguous, %d candidates → disambiguation prompt", + len(candidates), + ) + return self._build_disambiguation_response( + session=session, + parsed=parsed, + candidates=candidates, + session_state_in=session_state_in, ) + self._logger.debug( + "Play branch: resolved → player %s (%s)", + candidates[0].name or candidates[0].player_id, + candidates[0].player_id, + ) + return await self._play_with_player( + session=session, + parsed=parsed, + player=candidates[0], + base_session_state=session_state_in, + base_app_state=app_state_in, + ) + + # ------------------------------------------------------------------- + # Control execution helper (P0.6) + # ------------------------------------------------------------------- + + def _handle_control( + self, + *, + session: dict[str, Any], + control: Any, + default_id: str | None, + session_state_in: dict[str, Any], + app_state_in: dict[str, Any], + ) -> web.Response: + """Resolve player + dispatch a control action; build response.""" + # list_players is informational — no player resolution / dispatch. + if control.action == "list_players": + players = list_exposed_players(self._mass, exposed_ids=self._exposed_player_ids) + text = format_list_players(players) + self._logger.debug( + "Control list_players → %d player(s): %s", + len(players), + [getattr(p, "name", None) or p.player_id for p in players], + ) + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + + player = resolve_player( + self._mass, + control.player_hint, + default_id=default_id, + exposed_ids=self._exposed_player_ids, + ) + if player is None: + self._logger.info( + "Control %s: no player resolved (hint=%r, default_id=%s)", + control.action, + control.player_hint, + default_id, + ) + # Distinguish "no hint + ambiguous" from "hint given but unknown" + # so the message matches the actual cause. + if control.player_hint: + text = f"Не нашёл колонку «{control.player_hint}». Скажи, например: на кухне." + else: + text = "Скажи, на какой колонке. Например: пауза на кухне." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + self._logger.debug( + "Control %s → player %s (%s) value=%s", + control.action, + player.name or player.player_id, + player.player_id, + control.value, + ) + self._mass.create_task(execute_control(self._mass, control, player)) + new_session_state = {**session_state_in, "last_player_id": player.player_id} + new_app_state = {**app_state_in, "last_player_id": player.player_id} + user_obj = session.get("user") or {} + user_state_update: dict[str, Any] | None = None + if isinstance(user_obj, dict) and user_obj.get("user_id"): + user_state_update = {"preferred_player_id": player.player_id} + text = control_confirmation(control) + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + session_state=new_session_state, + application_state=new_app_state, + user_state_update=user_state_update, + ) + + # ------------------------------------------------------------------- + # Play execution helper (shared by initial flow and pending replay) + # ------------------------------------------------------------------- + + async def _play_with_player( + self, + *, + session: dict[str, Any], + parsed: ParsedCommand, + player: Any, + base_session_state: dict[str, Any], + base_app_state: dict[str, Any], + ) -> web.Response: + """Search media, fire-and-forget play, build response with persisted state.""" try: media = await asyncio.wait_for( resolve_query(self._mass, parsed), timeout=DIALOG_RESOLVE_TIMEOUT ) except TimeoutError: self._logger.warning( - "Music search timed out (>%.1fs) for query %r", DIALOG_RESOLVE_TIMEOUT, parsed.query + "Music search timed out (>%.1fs) for query %r", + DIALOG_RESOLVE_TIMEOUT, + parsed.query, ) + text = "Поиск занял слишком долго, попробуй ещё раз." return self._yandex_response( - session_state=session, - text="Поиск занял слишком долго, попробуй ещё раз.", + incoming_session=session, + text=text, + tts=_tts_for(text), + session_state=_without_pending(base_session_state), ) if media is None: + text = f"Не нашёл такую музыку: {parsed.query}." return self._yandex_response( - session_state=session, - text=f"Не нашёл такую музыку: {parsed.query}.", # noqa: RUF001 + incoming_session=session, + text=text, + tts=_tts_for(text), + session_state=_without_pending(base_session_state), ) # Fire-and-forget — Alice has a 4.5s budget; play_media may take longer @@ -241,13 +572,153 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: ) ) - if session_id: - self._remember_player(session_id, player.player_id) + new_session_state = { + **_without_pending(base_session_state), + "last_player_id": player.player_id, + } + new_app_state = {**base_app_state, "last_player_id": player.player_id} + user_obj = session.get("user") or {} + user_state_update: dict[str, Any] | None = None + if isinstance(user_obj, dict) and user_obj.get("user_id"): + user_state_update = {"preferred_player_id": player.player_id} spoken_query = parsed.query or "музыку" + text = f"Включаю {spoken_query} на {player.name or player.player_id}." return self._yandex_response( - session_state=session, - text=f"Включаю {spoken_query} на {player.name or player.player_id}.", + incoming_session=session, + text=text, + tts=_tts_for(text), + session_state=new_session_state, + application_state=new_app_state, + user_state_update=user_state_update, + ) + + # ------------------------------------------------------------------- + # Disambiguation (P0.3) + # ------------------------------------------------------------------- + + def _build_disambiguation_response( + self, + *, + session: dict[str, Any], + parsed: ParsedCommand, + candidates: list[Any], + session_state_in: dict[str, Any], + ) -> web.Response: + """Ask the user which player to use; offer buttons for each candidate.""" + # Yandex caps ItemsList at 5 anyway; cap our buttons to the same. + capped = candidates[:5] + names = [p.name or p.player_id for p in capped] + text = f"На какой колонке: {', '.join(names)}?" + buttons = [ + { + "title": (p.name or p.player_id)[:64], + "payload": {"player_id": p.player_id}, + "hide": True, + } + for p in capped + ] + # Clear any prior `awaiting_query` / `pending_command` before + # writing the new one. Without this, slot-elicitation state from + # an earlier turn would leak into the disambiguation response — + # the next utterance ("Кухня маленькая") would get auto-prefixed + # with "включи " by the awaiting-query branch and miss the + # pending-command resolver. + new_session_state = { + **_without_pending(session_state_in), + "pending_command": { + "kind": parsed.kind, + "query": parsed.query[:200], + "radio_mode": parsed.radio_mode, + }, + } + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=new_session_state, + buttons=buttons, + ) + + async def _try_resume_pending( + self, + *, + session: dict[str, Any], + req: dict[str, Any], + command: str, + pending: dict[str, Any], + session_state_in: dict[str, Any], + app_state_in: dict[str, Any], + ) -> web.Response | None: + """Attempt to resume a saved pending_command using button payload or text. + + Returns a response if the pending command was resumed (success or + decided failure). Returns None when the new utterance doesn't + resolve to a player at all — caller falls through to normal + parse_command flow. + """ + chosen_player: Any = None + + # Button press: payload.player_id is what we sent on the previous turn. + # Validate against the *currently exposed* player set rather than + # blindly trusting the payload — guards against stale or crafted + # payloads that target a player that's been disabled / removed / + # un-exposed since we offered the buttons. Payload integrity is + # already enforced upstream by `body.session.skill_id`, but + # defence-in-depth is cheap here. + payload = req.get("payload") + if isinstance(payload, dict): + pid = payload.get("player_id") + if isinstance(pid, str): + exposed = list_exposed_players(self._mass, exposed_ids=self._exposed_player_ids) + chosen_player = next((p for p in exposed if p.player_id == pid), None) + if chosen_player is None: + self._logger.warning( + "Pending replay: ButtonPressed payload player_id=%r " + "not in exposed-player set; ignoring", + pid, + ) + + if chosen_player is None: + # Free-text follow-up: treat the utterance as a player hint. + followup = parse_command(command) + hint = followup.player_hint or command + candidates = resolve_player_candidates( + self._mass, + hint, + default_id=None, + exposed_ids=self._exposed_player_ids, + ) + if len(candidates) == 1: + chosen_player = candidates[0] + elif len(candidates) > 1: + # Re-ask using the same pending command. + return self._build_disambiguation_response( + session=session, + parsed=ParsedCommand( + kind=str(pending.get("kind", "search")), # type: ignore[arg-type] + query=str(pending.get("query", "")), + radio_mode=bool(pending.get("radio_mode", False)), + ), + candidates=candidates, + session_state_in=session_state_in, + ) + + if chosen_player is None: + return None + + replay = ParsedCommand( + kind=str(pending.get("kind", "search")), # type: ignore[arg-type] + query=str(pending.get("query", "")), + radio_mode=bool(pending.get("radio_mode", False)), + ) + return await self._play_with_player( + session=session, + parsed=replay, + player=chosen_player, + base_session_state=session_state_in, + base_app_state=app_state_in, ) # ------------------------------------------------------------------- @@ -257,23 +728,53 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: @staticmethod def _yandex_response( *, - session_state: dict[str, Any], + incoming_session: dict[str, Any], text: str, + tts: str | None = None, end_session: bool = True, + session_state: dict[str, Any] | None = None, + application_state: dict[str, Any] | None = None, + user_state_update: dict[str, Any] | None = None, + buttons: list[dict[str, Any]] | None = None, ) -> web.Response: - """Build a minimal Yandex Dialogs response envelope.""" + """Build a Yandex Dialogs response envelope. + + ``session_state`` / ``application_state`` are full overwrites per + Yandex spec; ``user_state_update`` is merged into the existing + user-scoped state (set keys to None to clear). Omit a parameter + to leave that bucket unchanged on Yandex's side. + """ + # Yandex envelopes carry two user_id fields: the deprecated root + # `session.user_id` (always present in current API revisions for + # backwards compatibility) and the nested `session.user.user_id` + # (set only when the user is account-linked). Prefer the root for + # historical reasons but fall back to the nested form so the + # echo doesn't leak an empty string if a future Yandex API + # revision drops the root field. + user_id = incoming_session.get("user_id") or _safe_dict(incoming_session.get("user")).get( + "user_id", "" + ) echoed = { - "session_id": session_state.get("session_id", ""), - "message_id": session_state.get("message_id", 0), - "user_id": session_state.get("user_id", ""), + "session_id": incoming_session.get("session_id", ""), + "message_id": incoming_session.get("message_id", 0), + "user_id": user_id, + } + response_body: dict[str, Any] = { + "text": text, + "tts": tts if tts is not None else text, + "end_session": end_session, } - payload = { + if buttons: + response_body["buttons"] = buttons + payload: dict[str, Any] = { "version": "1.0", "session": echoed, - "response": { - "text": text, - "tts": text, - "end_session": end_session, - }, + "response": response_body, } + if session_state is not None: + payload["session_state"] = session_state + if application_state is not None: + payload["application_state"] = application_state + if user_state_update is not None: + payload["user_state_update"] = user_state_update return web.json_response(payload) diff --git a/music_assistant/providers/yandex_smarthome/dialogs_control.py b/music_assistant/providers/yandex_smarthome/dialogs_control.py new file mode 100644 index 0000000000..a93764e6ff --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/dialogs_control.py @@ -0,0 +1,283 @@ +"""Playback-control NLU + executor for the Yandex Dialogs custom skill. + +Handles utterances that don't carry a music query — pause/resume/next/ +previous/volume up-down-set/mute/unmute. Runs *before* the play-command +parser in the webhook flow; if `parse_control` returns None the handler +falls through to the existing music-search path. +""" + +from __future__ import annotations + +import asyncio +import logging +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal + +from .dialogs_nlu import _PUNCT_RE, _SPACE_RE + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +_LOGGER = logging.getLogger(__name__) + +ControlAction = Literal[ + "pause", + "resume", + "stop", + "next", + "previous", + "volume_up", + "volume_down", + "volume_set", + "mute", + "unmute", + "list_players", +] + + +@dataclass(frozen=True, slots=True) +class ParsedControl: + """Result of classifying a Yandex Dialogs voice command as a control action.""" + + action: ControlAction + value: int | None = None + player_hint: str | None = None + + +# Pattern catalogue. Order matters within each tier — first match wins. +# All patterns are anchored (^...$) to require a whole-phrase match. +_CONTROL_PATTERNS: tuple[tuple[re.Pattern[str], ControlAction], ...] = ( + # list_players — informational query "what speakers do you see?". + # Matched before the play-verb-strip can interpret "покажи колонки" + # as a play kind=search query="колонки". + ( + re.compile( + r"^сколько\s+колонок(?:\s+(?:ты\s+)?(?:видишь|знаешь))?$", + re.IGNORECASE, + ), + "list_players", + ), + ( + re.compile( + r"^какие\s+колонки(?:\s+(?:ты\s+)?(?:видишь|знаешь|есть))?$", + re.IGNORECASE, + ), + "list_players", + ), + (re.compile(r"^какие\s+у\s+тебя\s+колонки$", re.IGNORECASE), "list_players"), + (re.compile(r"^перечисли\s+колонки$", re.IGNORECASE), "list_players"), + (re.compile(r"^список\s+колонок$", re.IGNORECASE), "list_players"), + (re.compile(r"^покажи\s+колонки$", re.IGNORECASE), "list_players"), + (re.compile(r"^назови\s+колонки$", re.IGNORECASE), "list_players"), + # mute / unmute — explicit "звук" disambiguates from play-verb "включи" + (re.compile(r"^включи\s+звук$", re.IGNORECASE), "unmute"), + (re.compile(r"^сделай\s+звук$", re.IGNORECASE), "unmute"), + (re.compile(r"^приглуши$", re.IGNORECASE), "mute"), + (re.compile(r"^выключи\s+звук$", re.IGNORECASE), "mute"), + (re.compile(r"^беззвучно$", re.IGNORECASE), "mute"), + # resume — must come before "включи" play-verb stripping; we run before + # parse_command anyway, but match anchored phrases here for clarity + (re.compile(r"^продолжи(?:ть)?$", re.IGNORECASE), "resume"), + (re.compile(r"^включи\s+снова$", re.IGNORECASE), "resume"), + (re.compile(r"^возобнови(?:ть)?$", re.IGNORECASE), "resume"), + # pause + (re.compile(r"^пауза$", re.IGNORECASE), "pause"), + (re.compile(r"^на\s+паузу$", re.IGNORECASE), "pause"), + (re.compile(r"^поставь\s+на\s+паузу$", re.IGNORECASE), "pause"), + (re.compile(r"^останови\s+музыку$", re.IGNORECASE), "pause"), + # stop — bare "выключи" maps to stop (safer than power-off) + (re.compile(r"^стоп$", re.IGNORECASE), "stop"), + (re.compile(r"^останови$", re.IGNORECASE), "stop"), + (re.compile(r"^выключи$", re.IGNORECASE), "stop"), + (re.compile(r"^выключи\s+музыку$", re.IGNORECASE), "stop"), + # next track + (re.compile(r"^следующ(?:ая|ий|ее)?(?:\s+трек)?$", re.IGNORECASE), "next"), + (re.compile(r"^дальше$", re.IGNORECASE), "next"), + (re.compile(r"^переключи$", re.IGNORECASE), "next"), + # previous track + (re.compile(r"^предыдущ(?:ая|ий|ее)?(?:\s+трек)?$", re.IGNORECASE), "previous"), + (re.compile(r"^назад$", re.IGNORECASE), "previous"), + (re.compile(r"^верни(?:сь)?$", re.IGNORECASE), "previous"), + # volume up + (re.compile(r"^громче$", re.IGNORECASE), "volume_up"), + (re.compile(r"^сделай\s+громче$", re.IGNORECASE), "volume_up"), + (re.compile(r"^прибавь(?:\s+громкость)?$", re.IGNORECASE), "volume_up"), + # volume down + (re.compile(r"^тише$", re.IGNORECASE), "volume_down"), + (re.compile(r"^сделай\s+тише$", re.IGNORECASE), "volume_down"), + (re.compile(r"^убавь(?:\s+громкость)?$", re.IGNORECASE), "volume_down"), +) + +# Volume-set with explicit number, e.g. "громкость 50", "громкость на 30 процентов". +_VOLUME_SET_RE = re.compile( + r"^(?:сделай\s+)?громкост(?:ь|и)\s+(?:на\s+)?(?P\d{1,3})(?:\s+процентов)?$", + re.IGNORECASE, +) + + +def _try_match(cleaned: str, player_hint: str | None) -> ParsedControl | None: + """Match `cleaned` against control patterns; return ParsedControl or None.""" + if not cleaned: + return None + if vmatch := _VOLUME_SET_RE.match(cleaned): + try: + value = int(vmatch.group("n")) + except (TypeError, ValueError): + return None + return ParsedControl( + action="volume_set", + value=max(0, min(100, value)), + player_hint=player_hint, + ) + for pattern, action in _CONTROL_PATTERNS: + if pattern.match(cleaned): + return ParsedControl(action=action, player_hint=player_hint) + return None + + +_NA_BOUNDARY_RE = re.compile(r"\s+на\s+", re.IGNORECASE) + + +def parse_control(text: str) -> ParsedControl | None: + """Classify a voice utterance as a control command, or None to fall through. + + Tries each `на`-boundary in the cleaned text as a possible + "на " suffix, starting from the rightmost. First yields + (cleaned, None) for the whole-phrase case so that "поставь на + паузу" still matches `pause` with no hint, even when the phrase + contains "на" inside the action keywords. + """ + if not text: + return None + cleaned = _PUNCT_RE.sub(" ", text) + cleaned = _SPACE_RE.sub(" ", cleaned).strip() + cleaned = re.sub(r"^алиса[,\s]+", "", cleaned, flags=re.IGNORECASE) + if not cleaned: + return None + + # Whole-phrase first (no hint). + if direct := _try_match(cleaned, player_hint=None): + return direct + + # Then try each "на " split from right to left, so e.g. + # "поставь на паузу на кухне" splits at the *last* "на". + matches = list(_NA_BOUNDARY_RE.finditer(cleaned)) + for m in reversed(matches): + rest = cleaned[: m.start()].strip() + hint = cleaned[m.end() :].strip().lower() + if not rest or not hint: + continue + if matched := _try_match(rest, player_hint=hint): + return matched + return None + + +# --------------------------------------------------------------------------- +# Executor + confirmation +# --------------------------------------------------------------------------- + + +def _plural_ru(n: int, forms: tuple[str, str, str]) -> str: + """Pick the correct Russian quantitative form for `n`. + + Args: + n: The number. + forms: ``(form_for_1, form_for_2_to_4, form_for_5_plus)``. + + Russian quantitative agreement: + 1, 21, 31, … → form_for_1 (e.g. "колонку") + 2-4, 22-24, … → form_for_2_to_4 ("колонки") + 0, 5-20, 25-30, … → form_for_5_plus ("колонок") + """ + n_abs = abs(n) + if n_abs % 10 == 1 and n_abs % 100 != 11: + return forms[0] + if 2 <= n_abs % 10 <= 4 and not 12 <= n_abs % 100 <= 14: + return forms[1] + return forms[2] + + +def format_list_players(players: list[Any]) -> str: + """Build the spoken response listing exposed players for `list_players` action.""" + n = len(players) + if n == 0: + return "Не вижу ни одной колонки." + names = ", ".join(getattr(p, "name", None) or p.player_id for p in players) + if n == 1: + return f"Вижу одну колонку: {names}." + word = _plural_ru(n, ("колонку", "колонки", "колонок")) + return f"Вижу {n} {word}: {names}." + + +def control_confirmation(control: ParsedControl) -> str: + """User-facing confirmation text for a control action. + + Caveat: ``list_players`` is **not** confirmed here — the handler builds + the response text from the live player list via ``format_list_players``. + """ + action = control.action + if action == "pause": + return "Пауза." + if action == "resume": + return "Продолжаю." + if action == "stop": + return "Остановил." + if action == "next": + return "Следующая." + if action == "previous": + return "Предыдущая." + if action == "volume_up": + return "Громче." + if action == "volume_down": + return "Тише." + if action == "volume_set": + return f"Громкость {control.value}." + if action == "mute": + return "Звук выключен." + if action == "unmute": + return "Звук включен." + # list_players (the only remaining action; Literal is exhaustive) + return "Готово." # placeholder; handler computes the real text + + +async def execute_control( + mass: MusicAssistant, + control: ParsedControl, + player: Any, +) -> None: + """Dispatch a ParsedControl to the matching MA command. + + Errors are logged and swallowed — Alice has already been told the + action was accepted; we don't have a channel to surface failures + back into the same conversation. + """ + pid = player.player_id + action = control.action + try: + if action == "pause": + await mass.player_queues.pause(pid) + elif action == "resume": + await mass.player_queues.resume(pid) + elif action == "stop": + await mass.player_queues.stop(pid) + elif action == "next": + await mass.player_queues.next(pid) + elif action == "previous": + await mass.player_queues.previous(pid) + elif action == "volume_up": + await mass.players.cmd_volume_up(pid) + elif action == "volume_down": + await mass.players.cmd_volume_down(pid) + elif action == "volume_set": + value = max(0, min(100, control.value or 0)) + await mass.players.cmd_volume_set(pid, value) + elif action == "mute": + await mass.players.cmd_volume_mute(pid, True) + elif action == "unmute": + await mass.players.cmd_volume_mute(pid, False) + except asyncio.CancelledError: + raise + except Exception: + _LOGGER.exception("execute_control(%s) failed for player %s", action, pid) diff --git a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py index 90905fd75b..c6d0bcd5ec 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py @@ -63,8 +63,11 @@ class ParsedCommand: r"запусти(?:те)?|запустить|" r"сыграй(?:те)?|сыграть|" r"играй(?:те)?|" - r"послушай(?:те)?|послушать" - r")\s+", + r"послушай(?:те)?|послушать|" + r"найди(?:те)?|найти|" + r"открой(?:те)?|открыть|" + r"покажи(?:те)?|показать" + r")(?:\s+|$)", re.IGNORECASE, ) @@ -162,19 +165,21 @@ def parse_command(text: str) -> ParsedCommand: # Not a full lemmatizer — picks up the most frequent endings for short names. # Order: longest first so multi-letter suffixes match before single-letter ones. _INFLECTION_SUFFIXES = ( - "ого", # noqa: RUF001 + "ого", "ому", "ыми", + "ая", # feminine adjective nominative — "большая", "маленькая" "ой", "ом", "ым", "ы", - "е", # noqa: RUF001 - "у", # noqa: RUF001 - "а", # noqa: RUF001 + "е", + "у", + "а", "и", "й", "ь", + "я", # feminine noun nominative — "Кухня", "Спальня" ) @@ -186,7 +191,7 @@ def parse_command(text: str) -> ParsedCommand: { "колонк", # колонка / на колонке / колонку "плеер", # плеер / на плеере / плеера - "пле", # short for "плеер" after stripping the trailing -ер suffix # noqa: RUF003 + "пле", # short for "плеер" after stripping the trailing -ер suffix "проигрыватель", # full word survives stem (no matching suffix) "проигрывател", # stripped «-ь» "динамик", # динамик / на динамике @@ -217,26 +222,18 @@ def _normalize_player_token(name: str) -> str: return " ".join(parts) -def resolve_player( +def list_exposed_players( mass: MusicAssistant, - hint: str | None, *, - default_id: str | None = None, exposed_ids: set[str] | None = None, -) -> Any: - """Find an MA player by fuzzy-matching the hint string against player names. - - Filters: only players that are available, enabled, and not synced to a - leader (we control the leader, not the followers). Optional ``exposed_ids`` - further restricts to the user's exposed-players list from the SH plugin. +) -> list[Any]: + """Return all available, enabled, non-synced players (filtered by exposure). - Disambiguation: - - exact normalized match wins - - else startswith - - else substring - - else None (caller asks Alice for clarification) + Same filter as ``resolve_player_candidates`` uses for its candidate set, + extracted so the dialog handler can answer "what speakers do you see?" + queries (P0.6 ``list_players`` action) without re-implementing it. """ - candidates: list[Any] = [] + out: list[Any] = [] for player in mass.players.all_players(): if not player.available or not player.enabled: continue @@ -244,24 +241,66 @@ def resolve_player( continue if exposed_ids and player.player_id not in exposed_ids: continue - candidates.append(player) + out.append(player) + return out + + +def resolve_player_candidates( + mass: MusicAssistant, + hint: str | None, + *, + default_id: str | None = None, + exposed_ids: set[str] | None = None, +) -> list[Any]: + """Return the best-matching tier of players for ``hint``. + + Filters: only players that are available, enabled, and not synced to + a leader. Optional ``exposed_ids`` further restricts to the user's + exposed-players list. Tier priority: exact → startswith → contains → + generic-word fallback. The caller decides what to do with multiple + matches (typically: ask the user to disambiguate). + + Logs a single DEBUG-level summary on every call describing the + decision: chosen tier, candidate count, and the names of the + candidates returned. + + Returns: + A list with all players in the best non-empty tier. ``[]`` if + nothing matched. ``[player]`` for an unambiguous resolution. + """ + candidates = list_exposed_players(mass, exposed_ids=exposed_ids) + + def _label(p: Any) -> str: + return str(getattr(p, "name", None) or p.player_id) + + def _result(result: list[Any], reason: str) -> list[Any]: + _LOGGER.debug( + "resolve_player: hint=%r default=%s exposed=%d -> %d candidate(s) %s [%s]", + hint, + default_id, + len(candidates), + len(result), + [_label(p) for p in result], + reason, + ) + return result if not candidates: - return None + return _result([], "no exposed players") - # Single-player install or no hint → pick the default / only candidate. + # Single-player install or no hint → default / only candidate. if not hint: if default_id: for p in candidates: if p.player_id == default_id: - return p + return _result([p], "no hint, matched default_id") if len(candidates) == 1: - return candidates[0] - return None + return _result(candidates[:], "no hint, single exposed player") + return _result([], "no hint, ambiguous") needle = _normalize_player_token(hint) if not needle: - return None + return _result([], "hint normalised to empty string") exact: list[Any] = [] startswith: list[Any] = [] @@ -280,7 +319,7 @@ def resolve_player( contains.append(p) _LOGGER.debug( - "resolve_player: hint=%r → needle=%r; candidates=%s; " + "resolve_player tiers: hint=%r needle=%r candidates=%s " "matches: exact=%d startswith=%d contains=%d", hint, needle, @@ -290,23 +329,18 @@ def resolve_player( len(contains), ) - for tier in (exact, startswith, contains): - if not tier: - continue - if len(tier) > 1: - _LOGGER.warning( - "Player hint %r matches %d players: %s — picking first by name", - hint, - len(tier), - [p.name for p in tier], - ) + for tier_name, tier in ( + ("exact", exact), + ("startswith", startswith), + ("contains", contains), + ): + if tier: tier.sort(key=lambda p: (p.name or p.player_id).lower()) - return tier[0] + return _result(tier, f"tier={tier_name}") - # Generic-word fallback: user said something like "на колонке" / - # "на проигрывателе" / "на динамике" — these mean "any speaker", not - # a specific player. If only one player is exposed (or a default_id - # is configured) we can resolve unambiguously; otherwise still None. + # Generic-word fallback: "на колонке" / "на проигрывателе" / "на динамике" + # mean "any speaker" — resolve unambiguously only when the choice is + # forced (default_id set, or single exposed player). if any(stem in needle for stem in _GENERIC_PLAYER_STEMS): if default_id: for p in candidates: @@ -316,19 +350,40 @@ def resolve_player( hint, p.name, ) - return p + return _result([p], "generic word, matched default_id") if len(candidates) == 1: _LOGGER.info( "Generic player hint %r → resolved to the only exposed player %r", hint, candidates[0].name, ) - return candidates[0] + return _result(candidates[:], "generic word, single exposed player") _LOGGER.warning( "Generic player hint %r matches no specific player and there are " "%d exposed players — caller will ask for clarification", hint, len(candidates), ) + return _result([], "generic word, multiple players, no default") + + return _result([], "no tier matched") + + +def resolve_player( + mass: MusicAssistant, + hint: str | None, + *, + default_id: str | None = None, + exposed_ids: set[str] | None = None, +) -> Any: + """Find an unambiguously-matching MA player for ``hint``, or None. - return None + Thin wrapper over ``resolve_player_candidates`` — returns the single + candidate when exactly one matches, ``None`` otherwise (zero matches + or ambiguous). Use ``resolve_player_candidates`` directly when you + want to surface the ambiguity to the user. + """ + candidates = resolve_player_candidates( + mass, hint, default_id=default_id, exposed_ids=exposed_ids + ) + return candidates[0] if len(candidates) == 1 else None diff --git a/music_assistant/providers/yandex_smarthome/dialogs_player.py b/music_assistant/providers/yandex_smarthome/dialogs_player.py index 87e3addfaa..6ecf937b11 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_player.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_player.py @@ -16,6 +16,8 @@ from music_assistant_models.enums import MediaType +from .dialogs_nlu import _normalize_player_token + if TYPE_CHECKING: from music_assistant_models.media_items import MediaItemType @@ -29,6 +31,11 @@ _SEARCH_LIMIT_DEFAULT = 5 +def _has_cyrillic(text: str) -> bool: + """Return True if `text` contains at least one Cyrillic letter.""" + return any("а" <= c.lower() <= "я" or c.lower() == "ё" for c in text) + + def _has_feature(player: Any, feature_name: str) -> bool: """Mirror provider.device._has_feature so we don't import device.py from here.""" features = getattr(player, "supported_features", None) @@ -89,7 +96,32 @@ async def resolve_query(mass: MusicAssistant, parsed: ParsedCommand) -> MediaIte _LOGGER.warning("mass.music.search failed for %r: %s", parsed.query, exc) return None - return _pick_from_results(results, parsed.kind) + picked = _pick_from_results(results, parsed.kind) + if picked is not None: + return picked + + # P0.7 — retry search with the inflection-stripped query if the original + # was Russian. Yandex ASR usually returns words in the case the user + # spoke ("включи металлику" → accusative); music indexes store the + # nominative ("Металлика"). Stripping the trailing suffix ("металлик") + # is enough of a stem to land prefix matches in most providers. + if not _has_cyrillic(parsed.query): + return None + stemmed = _normalize_player_token(parsed.query) + if not stemmed or stemmed == parsed.query.lower(): + return None + try: + results2 = await mass.music.search( + search_query=stemmed, + media_types=media_types, + limit=_SEARCH_LIMIT_DEFAULT, + ) + except asyncio.CancelledError: + raise + except Exception as exc: + _LOGGER.warning("stemmed-retry search failed for %r: %s", stemmed, exc) + return None + return _pick_from_results(results2, parsed.kind) def _pick_from_results(results: object, kind: str) -> MediaItemType | None: diff --git a/tests/providers/yandex_smarthome/test_dialogs.py b/tests/providers/yandex_smarthome/test_dialogs.py index 69a46ed302..3570767a63 100644 --- a/tests/providers/yandex_smarthome/test_dialogs.py +++ b/tests/providers/yandex_smarthome/test_dialogs.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock @@ -10,7 +11,7 @@ import pytest from aiohttp.test_utils import make_mocked_request -from music_assistant.providers.yandex_smarthome.dialogs import DialogsWebhookHandler +from music_assistant.providers.yandex_smarthome.dialogs import DialogsWebhookHandler, _tts_for if TYPE_CHECKING: from aiohttp import web @@ -87,6 +88,11 @@ def _build_request(body: dict[str, Any], secret: str = _TEST_SECRET) -> web.Requ return req +def _response_body(resp: web.Response) -> dict[str, Any]: + """Decode a web.json_response body into a dict for assertions.""" + return json.loads(resp.body) # type: ignore[arg-type] + + @pytest.mark.asyncio class TestDialogsWebhookHandler: """End-to-end tests for the webhook entry point.""" @@ -226,31 +232,624 @@ async def test_full_happy_path_starts_play_media(self) -> None: assert call_kwargs["queue_id"] == "p1" assert call_kwargs["media"] is track - async def test_session_remembers_player_for_followups(self) -> None: - """Second command in same session uses the player from the first command.""" - track = MagicMock(uri="library://track/123", spec_set=["uri"]) + +# --------------------------------------------------------------------------- +# Yandex state envelope (P0.1) + tts split (P0.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestStatePersistence: + """Tests that the handler reads/writes Yandex state envelope correctly.""" + + def _make_handler(self, mass: MagicMock) -> DialogsWebhookHandler: + return DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + + async def test_resolved_player_persisted_in_session_and_application_state(self) -> None: + """Successful play writes last_player_id to session_state and application_state.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + body_out = _response_body(resp) + assert body_out["session_state"]["last_player_id"] == "p1" + assert body_out["application_state"]["last_player_id"] == "p1" + # No user identity in the request → no user_state_update. + assert "user_state_update" not in body_out + + async def test_user_state_written_when_user_id_present(self) -> None: + """When session.user.user_id is set, response merges preferred_player_id.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) handler = self._make_handler(mass) - # First command — explicit player hint - await handler._handle_webhook( - _build_request( - { - "session": {"skill_id": "skill-uuid-1", "session_id": "s99", "new": False}, - "request": {"command": "включи Metallica на кухне"}, - } - ) + body = { + "session": { + "skill_id": "skill-uuid-1", + "session_id": "s1", + "new": False, + "user": {"user_id": "yandex-user-1"}, + }, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + body_out = _response_body(resp) + assert body_out["user_state_update"] == {"preferred_player_id": "p1"} + + async def test_default_player_priority_session_over_application(self) -> None: + """When command has no player hint, session.last_player_id wins over application's.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")], + search_track=track, ) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Beatles"}, + "state": { + "session": {"last_player_id": "p1"}, + "application": {"last_player_id": "p2"}, + }, + } + await handler._handle_webhook(_build_request(body)) await asyncio.sleep(0) - # Second command — no hint, should pick the same player from session memory - mass.player_queues.play_media.reset_mock() - await handler._handle_webhook( - _build_request( - { - "session": {"skill_id": "skill-uuid-1", "session_id": "s99", "new": False}, - "request": {"command": "включи Beatles"}, - } - ) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + + async def test_default_player_falls_through_to_application(self) -> None: + """No session.last_player_id — application_state wins.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")], + search_track=track, ) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Beatles"}, + "state": {"application": {"last_player_id": "p2"}}, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" + + async def test_default_player_falls_through_to_user(self) -> None: + """Both session and application empty — user.preferred_player_id wins.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")], + search_track=track, + ) + handler = self._make_handler(mass) + body = { + "session": { + "skill_id": "skill-uuid-1", + "session_id": "s1", + "new": False, + "user": {"user_id": "yandex-user-1"}, + }, + "request": {"command": "включи Beatles"}, + "state": {"user": {"preferred_player_id": "p2"}}, + } + await handler._handle_webhook(_build_request(body)) await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" + + async def test_user_id_echo_falls_back_to_nested(self) -> None: + """When root session.user_id is missing, echo the nested session.user.user_id.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = self._make_handler(mass) + body = { + "session": { + "skill_id": "skill-uuid-1", + "session_id": "s1", + "new": False, + # No root "user_id"; only the nested one. + "user": {"user_id": "yandex-user-42"}, + }, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert body_out["session"]["user_id"] == "yandex-user-42" + + async def test_session_state_preserved_on_player_not_found(self) -> None: + """Even on error, existing session_state is echoed back so other keys aren't lost.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Спальня")]) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на Кухне"}, + "state": {"session": {"foo": "bar"}}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert body_out["session_state"] == {"foo": "bar"} + + +class TestTtsHelper: + """Tests for _tts_for stress-mark substitution.""" + + def test_known_word_gets_stress_mark(self) -> None: + """A known word from the dict has `+` injected before the stressed vowel.""" + assert _tts_for("Включаю Metallica") == "Включ+аю Metallica" + + def test_unknown_word_passes_through(self) -> None: + """A word not in the dict is unchanged.""" + assert _tts_for("Привет мир") == "Привет мир" + + def test_empty_input(self) -> None: + """Empty input is returned as-is.""" + assert _tts_for("") == "" + + def test_capitalisation_preserved(self) -> None: + """Original capitalisation of the first letter is preserved.""" + # All-lowercase original. + assert _tts_for("включаю джаз") == "включ+аю джаз" + # Capitalised original. + assert _tts_for("Включаю джаз") == "Включ+аю джаз" + + +@pytest.mark.asyncio +class TestTtsResponseField: + """Test that the handler emits separate text + tts in the response envelope.""" + + async def test_response_tts_differs_from_text_when_known_word_used(self) -> None: + """Happy path response has different `tts` from `text` when stress-mark fires.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + text = body_out["response"]["text"] + tts = body_out["response"]["tts"] + assert text != tts + assert "включ+аю" in tts.lower() + + +# --------------------------------------------------------------------------- +# Control commands integration (P0.6) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestControlCommandsIntegration: + """Integration tests for the control branch in _handle_webhook.""" + + def _setup_mass_with_control_methods(self, players: list[MockPlayer]) -> MagicMock: + mass = _make_mass(players) + mass.player_queues.pause = AsyncMock() + mass.player_queues.resume = AsyncMock() + mass.player_queues.stop = AsyncMock() + mass.player_queues.next = AsyncMock() + mass.player_queues.previous = AsyncMock() + mass.players.cmd_volume_up = AsyncMock() + mass.players.cmd_volume_down = AsyncMock() + mass.players.cmd_volume_set = AsyncMock() + mass.players.cmd_volume_mute = AsyncMock() + return mass + + async def test_pause_command_calls_player_queues_pause(self) -> None: + """'пауза на кухне' → mass.player_queues.pause(p1) and confirms in response.""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "пауза на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.player_queues.pause.assert_awaited_once_with("p1") + body_out = _response_body(resp) + assert body_out["response"]["text"] == "Пауза." + # State persisted as in play branch. + assert body_out["session_state"]["last_player_id"] == "p1" + assert body_out["application_state"]["last_player_id"] == "p1" + # play_media should NOT be called for control commands. + mass.player_queues.play_media.assert_not_awaited() + + async def test_volume_set_command(self) -> None: + """'громкость 50 на кухне' → cmd_volume_set(p1, 50).""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "громкость 50 на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.players.cmd_volume_set.assert_awaited_once_with("p1", 50) + + async def test_control_uses_default_player_from_state(self) -> None: + """A control phrase without explicit hint uses state.session.last_player_id.""" + mass = self._setup_mass_with_control_methods( + [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "пауза"}, + "state": {"session": {"last_player_id": "p2"}}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.player_queues.pause.assert_awaited_once_with("p2") + + async def test_control_unknown_player_asks_for_clarification(self) -> None: + """Control command with an unknown player hint returns a clarification.""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Спальня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "пауза на гостиной"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + mass.player_queues.pause.assert_not_awaited() + body_out = _response_body(resp) + assert "Не нашёл колонку «гостиной»" in body_out["response"]["text"] + + async def test_list_players_returns_player_names(self) -> None: + """'сколько колонок видишь' → response with the count and names of exposed players.""" + mass = self._setup_mass_with_control_methods( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + MockPlayer(player_id="p3", name="Гостиная"), + ] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "сколько колонок видишь"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + body_out = _response_body(resp) + text = body_out["response"]["text"] + assert "Вижу 3 колонки" in text + assert "Кухня" in text + assert "Спальня" in text + assert "Гостиная" in text + # Informational query — keep the mic open for follow-ups. + assert body_out["response"]["end_session"] is False + # No playback or control was dispatched. + mass.player_queues.pause.assert_not_awaited() + mass.player_queues.play_media.assert_not_awaited() + + async def test_list_players_skips_unavailable(self) -> None: + """Only available + enabled + non-synced players are counted.""" + mass = self._setup_mass_with_control_methods( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Disabled", enabled=False), + MockPlayer(player_id="p3", name="Unavailable", available=False), + MockPlayer(player_id="p4", name="Synced", synced_to="leader"), + ] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "какие колонки"}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + text = body_out["response"]["text"] + assert "Вижу одну колонку: Кухня" in text + assert "Disabled" not in text + assert "Unavailable" not in text + assert "Synced" not in text + + async def test_control_no_hint_no_default_asks_for_player(self) -> None: + """Control with no hint + no default + multi-player → ask for the player. + + Previously responded with the misleading "Не нашёл колонку «(не указано)»"; + now the message tells the user to specify the player. + """ + mass = self._setup_mass_with_control_methods( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "пауза"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + mass.player_queues.pause.assert_not_awaited() + body_out = _response_body(resp) + text = body_out["response"]["text"] + assert "(не указано)" not in text + assert "на какой колонке" in text.lower() + + +# --------------------------------------------------------------------------- +# Disambiguation (P0.3) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestDisambiguation: + """End-to-end tests for the disambiguation prompt + pending-command replay.""" + + async def test_multiple_matches_returns_disambiguation_prompt(self) -> None: + """Two candidates → response carries buttons + pending_command, end_session=False.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + body_out = _response_body(resp) + assert body_out["response"]["end_session"] is False + assert "buttons" in body_out["response"] + button_titles = {b["title"] for b in body_out["response"]["buttons"]} + assert button_titles == {"Кухня большая", "Кухня маленькая"} + # pending_command is saved with the original play intent. + pending = body_out["session_state"]["pending_command"] + assert pending == {"kind": "search", "query": "metallica", "radio_mode": True} + # Nothing is played yet. + mass.player_queues.play_media.assert_not_awaited() + + async def test_button_press_resolves_pending(self) -> None: + """ButtonPressed payload.player_id triggers a play of the saved pending_command.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "type": "ButtonPressed", + "command": "Кухня большая", + "payload": {"player_id": "p1"}, + }, + "state": { + "session": { + "pending_command": {"kind": "search", "query": "metallica", "radio_mode": True}, + }, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 mass.player_queues.play_media.assert_awaited_once() assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + # pending_command is cleared from the response state. + body_out = _response_body(resp) + assert "pending_command" not in body_out["session_state"] + assert body_out["session_state"]["last_player_id"] == "p1" + + async def test_slot_elicit_when_query_empty(self) -> None: + """Bare verb (empty query) → 'Что включить?' + awaiting_query=True.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + body_out = _response_body(resp) + assert body_out["response"]["end_session"] is False + assert "Что включить" in body_out["response"]["text"] + assert body_out["session_state"]["awaiting_query"] is True + # Nothing played. + mass.player_queues.play_media.assert_not_awaited() + + async def test_followup_with_awaiting_query_resolves(self) -> None: + """Next utterance after slot-elicit is treated as the play query.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "Metallica"}, + "state": {"session": {"awaiting_query": True}}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.player_queues.play_media.assert_awaited_once() + body_out = _response_body(resp) + # awaiting_query is cleared on success. + assert "awaiting_query" not in body_out["session_state"] + + async def test_control_during_awaiting_query_dispatches_control(self) -> None: + """Slot-elicit was active, but the user pivots to a control phrase. + + "Включи." → "Что включить?" (awaiting_query=True). Then the user + says "пауза на кухне" — this must dispatch a control command, not + get prefixed with "включи " and turned into a search query. + """ + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + mass.player_queues.pause = AsyncMock() + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "пауза на кухне"}, + "state": {"session": {"awaiting_query": True}}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.player_queues.pause.assert_awaited_once_with("p1") + # awaiting_query must be cleared on successful control dispatch. + body_out = _response_body(resp) + assert "awaiting_query" not in body_out["session_state"] + # play_media not called — this was a control, not a play. + mass.player_queues.play_media.assert_not_awaited() + + async def test_followup_full_play_command_does_not_double_prefix(self) -> None: + """Follow-up like 'включи Yesterday' is parsed as-is, not double-prefixed.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Yesterday"}, + "state": {"session": {"awaiting_query": True}}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.player_queues.play_media.assert_awaited_once() + # The search call must use "yesterday" (after parser strips "включи"), + # not "включи yesterday". + search_query = mass.music.search.call_args.kwargs["search_query"] + assert search_query == "yesterday" + + async def test_play_no_hint_no_default_offers_disambiguation(self) -> None: + """Play branch: no hint + no default + 2+ players → disambiguation prompt. + + Without this, the user would see "Не нашёл колонку «(не указано)»". + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica"}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert body_out["response"]["end_session"] is False + assert "buttons" in body_out["response"] + button_titles = {b["title"] for b in body_out["response"]["buttons"]} + assert button_titles == {"Кухня", "Спальня"} + # pending_command saved with the original play intent. + assert body_out["session_state"]["pending_command"] == { + "kind": "search", + "query": "metallica", + "radio_mode": True, + } + mass.player_queues.play_media.assert_not_awaited() + + async def test_button_payload_validated_against_exposed_set(self) -> None: + """ButtonPressed with a payload targeting a non-exposed player is rejected. + + Defence-in-depth: even though Yandex echoes our own payload back, + we never trust the player_id without re-checking it's currently + exposed/enabled/available. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "type": "ButtonPressed", + "command": "Гостиная", + "payload": {"player_id": "p99-not-in-set"}, + }, + "state": { + "session": { + "pending_command": {"kind": "search", "query": "metallica", "radio_mode": True}, + }, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + # play_media must NOT be awaited — invalid payload should not play. + mass.player_queues.play_media.assert_not_awaited() + # Status is still 200; the handler falls through, but no playback. + assert resp.status == 200 + + async def test_disambiguation_clears_awaiting_query(self) -> None: + """Slot-elicit → multi-match → disambiguation prompt drops awaiting_query. + + Without this, the next user utterance ("Кухня маленькая") would get + auto-prefixed with "включи " by the awaiting-query branch and miss + the pending-command resolver. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + # Simulate the awaiting_query → ambiguous-resolution turn. + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "Metallica на кухне"}, + "state": {"session": {"awaiting_query": True}}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + # Disambiguation prompt is returned (multi-match). + assert body_out["response"]["end_session"] is False + assert "buttons" in body_out["response"] + # And the response carries pending_command but NOT awaiting_query. + assert "pending_command" in body_out["session_state"] + assert "awaiting_query" not in body_out["session_state"] + + async def test_freetext_followup_resolves_pending(self) -> None: + """User says 'на кухне маленькой' after the disambiguation question — plays on p2.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "на кухне маленькой"}, + "state": { + "session": { + "pending_command": {"kind": "search", "query": "metallica", "radio_mode": True}, + }, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.player_queues.play_media.assert_awaited_once() + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" diff --git a/tests/providers/yandex_smarthome/test_dialogs_control.py b/tests/providers/yandex_smarthome/test_dialogs_control.py new file mode 100644 index 0000000000..5cd63b5476 --- /dev/null +++ b/tests/providers/yandex_smarthome/test_dialogs_control.py @@ -0,0 +1,308 @@ +"""Tests for provider/dialogs_control.py — playback control NLU + executor.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from music_assistant.providers.yandex_smarthome.dialogs_control import ( + ParsedControl, + _plural_ru, + control_confirmation, + execute_control, + format_list_players, + parse_control, +) + + +class TestParseControl: + """Table-driven tests for parse_control across all action families.""" + + @pytest.mark.parametrize( + ("phrase", "expected_action", "expected_value", "expected_hint"), + [ + # pause + ("пауза", "pause", None, None), + ("на паузу", "pause", None, None), + ("поставь на паузу", "pause", None, None), + ("останови музыку", "pause", None, None), + ("пауза на кухне", "pause", None, "кухне"), + ("поставь на паузу на кухне", "pause", None, "кухне"), + # resume + ("продолжи", "resume", None, None), + ("продолжить", "resume", None, None), + ("включи снова", "resume", None, None), + ("возобнови", "resume", None, None), + # stop + ("стоп", "stop", None, None), + ("останови", "stop", None, None), + ("выключи", "stop", None, None), + ("выключи музыку", "stop", None, None), + ("стоп на спальне", "stop", None, "спальне"), + # next + ("следующая", "next", None, None), + ("следующий трек", "next", None, None), + ("дальше", "next", None, None), + ("переключи", "next", None, None), + # previous + ("предыдущая", "previous", None, None), + ("предыдущий трек", "previous", None, None), + ("назад", "previous", None, None), + ("вернись", "previous", None, None), + # volume relative + ("громче", "volume_up", None, None), + ("сделай громче", "volume_up", None, None), + ("прибавь", "volume_up", None, None), + ("прибавь громкость", "volume_up", None, None), + ("тише", "volume_down", None, None), + ("сделай тише", "volume_down", None, None), + ("убавь", "volume_down", None, None), + ("убавь громкость", "volume_down", None, None), + ("громче на кухне", "volume_up", None, "кухне"), + # volume set + ("громкость 50", "volume_set", 50, None), + ("громкость на 30", "volume_set", 30, None), + ("громкость на 30 процентов", "volume_set", 30, None), + ("сделай громкость 75", "volume_set", 75, None), + ("громкость 50 на кухне", "volume_set", 50, "кухне"), + # volume set clamping + ("громкость 200", "volume_set", 100, None), + # mute / unmute + ("приглуши", "mute", None, None), + ("выключи звук", "mute", None, None), + ("беззвучно", "mute", None, None), + ("включи звук", "unmute", None, None), + ("сделай звук", "unmute", None, None), + # list_players (no player_hint, no value) + ("сколько колонок", "list_players", None, None), + ("сколько колонок ты видишь", "list_players", None, None), + ("сколько колонок ты знаешь", "list_players", None, None), + ("какие колонки", "list_players", None, None), + ("какие колонки видишь", "list_players", None, None), + ("какие колонки ты видишь", "list_players", None, None), + ("какие колонки есть", "list_players", None, None), + ("какие у тебя колонки", "list_players", None, None), + ("перечисли колонки", "list_players", None, None), + ("список колонок", "list_players", None, None), + ("покажи колонки", "list_players", None, None), + ("назови колонки", "list_players", None, None), + # alice prefix tolerated + ("Алиса, пауза", "pause", None, None), + ], + ) + def test_parse( + self, + phrase: str, + expected_action: str, + expected_value: int | None, + expected_hint: str | None, + ) -> None: + """Each parametrized phrase maps to the expected ParsedControl.""" + result = parse_control(phrase) + assert result is not None, f"phrase={phrase!r} returned None" + assert result.action == expected_action, f"phrase={phrase!r}" + assert result.value == expected_value, f"phrase={phrase!r}" + assert result.player_hint == expected_hint, f"phrase={phrase!r}" + + @pytest.mark.parametrize( + "phrase", + [ + "", + "включи Metallica", + "включи джаз на кухне", + "включи песню Yesterday", + "включи мою волну", + "что-то непонятное", + "включи альбом Black Album", + ], + ) + def test_play_phrases_return_none(self, phrase: str) -> None: + """Phrases that should fall through to the play parser return None.""" + assert parse_control(phrase) is None + + +class TestPluralRu: + """Tests for the Russian quantitative-form picker.""" + + @pytest.mark.parametrize( + ("n", "expected"), + [ + (1, "колонку"), + (2, "колонки"), + (3, "колонки"), + (4, "колонки"), + (5, "колонок"), + (10, "колонок"), + (11, "колонок"), # 11 is exception — uses 5+ form + (12, "колонок"), + (14, "колонок"), + (21, "колонку"), # 21 → 1-form + (22, "колонки"), + (25, "колонок"), + (101, "колонку"), + (111, "колонок"), + (0, "колонок"), + ], + ) + def test_plural(self, n: int, expected: str) -> None: + """Russian quantitative agreement matches expected form for `n`.""" + assert _plural_ru(n, ("колонку", "колонки", "колонок")) == expected + + +class TestFormatListPlayers: + """Tests for the `list_players` confirmation builder.""" + + def test_zero_players(self) -> None: + """Empty list → 'не вижу'.""" + assert format_list_players([]) == "Не вижу ни одной колонки." + + def test_one_player(self) -> None: + """Single player → singular form.""" + p = MagicMock() + p.name = "Кухня" + p.player_id = "p1" + assert format_list_players([p]) == "Вижу одну колонку: Кухня." + + def test_three_players(self) -> None: + """Three players → 2-4 form ('колонки') with comma-separated names.""" + ps = [] + for name, pid in [("Кухня", "p1"), ("Спальня", "p2"), ("Гостиная", "p3")]: + p = MagicMock() + p.name = name + p.player_id = pid + ps.append(p) + assert format_list_players(ps) == "Вижу 3 колонки: Кухня, Спальня, Гостиная." + + def test_five_players(self) -> None: + """Five players → 5+ form ('колонок').""" + ps = [] + for i in range(5): + p = MagicMock() + p.name = f"Player{i}" + p.player_id = f"p{i}" + ps.append(p) + text = format_list_players(ps) + assert text.startswith("Вижу 5 колонок:") + + +class TestControlConfirmation: + """Tests for the user-facing confirmation strings.""" + + @pytest.mark.parametrize( + ("action", "value", "expected"), + [ + ("pause", None, "Пауза."), + ("resume", None, "Продолжаю."), + ("stop", None, "Остановил."), + ("next", None, "Следующая."), + ("previous", None, "Предыдущая."), + ("volume_up", None, "Громче."), + ("volume_down", None, "Тише."), + ("volume_set", 50, "Громкость 50."), + ("mute", None, "Звук выключен."), + ("unmute", None, "Звук включен."), + ], + ) + def test_confirmation(self, action: str, value: int | None, expected: str) -> None: + """Confirmation text matches the expected per-action template.""" + ctrl = ParsedControl(action=action, value=value) # type: ignore[arg-type] + assert control_confirmation(ctrl) == expected + + +@pytest.mark.asyncio +class TestExecuteControl: + """Tests that execute_control dispatches to the correct MA call.""" + + def _make_mass(self) -> MagicMock: + mass = MagicMock() + mass.player_queues = MagicMock() + mass.player_queues.pause = AsyncMock() + mass.player_queues.resume = AsyncMock() + mass.player_queues.stop = AsyncMock() + mass.player_queues.next = AsyncMock() + mass.player_queues.previous = AsyncMock() + mass.players = MagicMock() + mass.players.cmd_volume_up = AsyncMock() + mass.players.cmd_volume_down = AsyncMock() + mass.players.cmd_volume_set = AsyncMock() + mass.players.cmd_volume_mute = AsyncMock() + return mass + + def _player(self) -> MagicMock: + player = MagicMock() + player.player_id = "p1" + return player + + async def test_pause_calls_pause(self) -> None: + """action=pause invokes mass.player_queues.pause.""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="pause"), self._player()) + mass.player_queues.pause.assert_awaited_once_with("p1") + + async def test_resume_calls_resume(self) -> None: + """action=resume invokes mass.player_queues.resume.""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="resume"), self._player()) + mass.player_queues.resume.assert_awaited_once_with("p1") + + async def test_stop_calls_stop(self) -> None: + """action=stop invokes mass.player_queues.stop.""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="stop"), self._player()) + mass.player_queues.stop.assert_awaited_once_with("p1") + + async def test_next_calls_next(self) -> None: + """action=next invokes mass.player_queues.next.""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="next"), self._player()) + mass.player_queues.next.assert_awaited_once_with("p1") + + async def test_previous_calls_previous(self) -> None: + """action=previous invokes mass.player_queues.previous.""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="previous"), self._player()) + mass.player_queues.previous.assert_awaited_once_with("p1") + + async def test_volume_up(self) -> None: + """action=volume_up invokes cmd_volume_up.""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="volume_up"), self._player()) + mass.players.cmd_volume_up.assert_awaited_once_with("p1") + + async def test_volume_down(self) -> None: + """action=volume_down invokes cmd_volume_down.""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="volume_down"), self._player()) + mass.players.cmd_volume_down.assert_awaited_once_with("p1") + + async def test_volume_set(self) -> None: + """action=volume_set invokes cmd_volume_set with the requested value.""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="volume_set", value=42), self._player()) + mass.players.cmd_volume_set.assert_awaited_once_with("p1", 42) + + async def test_volume_set_none_falls_back_to_zero(self) -> None: + """volume_set with value=None defaults to 0 (defensive).""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="volume_set", value=None), self._player()) + mass.players.cmd_volume_set.assert_awaited_once_with("p1", 0) + + async def test_mute(self) -> None: + """action=mute invokes cmd_volume_mute(True).""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="mute"), self._player()) + mass.players.cmd_volume_mute.assert_awaited_once_with("p1", True) + + async def test_unmute(self) -> None: + """action=unmute invokes cmd_volume_mute(False).""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="unmute"), self._player()) + mass.players.cmd_volume_mute.assert_awaited_once_with("p1", False) + + async def test_underlying_failure_is_swallowed(self) -> None: + """An exception from the MA call is logged + swallowed (no re-raise).""" + mass = self._make_mass() + mass.player_queues.pause = AsyncMock(side_effect=RuntimeError("boom")) + # Must not raise. + await execute_control(mass, ParsedControl(action="pause"), self._player()) diff --git a/tests/providers/yandex_smarthome/test_dialogs_nlu.py b/tests/providers/yandex_smarthome/test_dialogs_nlu.py index a062057646..57159cbd7c 100644 --- a/tests/providers/yandex_smarthome/test_dialogs_nlu.py +++ b/tests/providers/yandex_smarthome/test_dialogs_nlu.py @@ -10,6 +10,7 @@ ParsedCommand, parse_command, resolve_player, + resolve_player_candidates, ) # --------------------------------------------------------------------------- @@ -61,6 +62,11 @@ class TestParseCommand: ("включить Iron Maiden", "search", "iron maiden", None, True), ("сыграй Metallica на кухне", "search", "metallica", "кухне", True), ("послушать джаз", "search", "джаз", None, True), + # P0.5 — find/open/show verbs as play synonyms + ("найди Metallica", "search", "metallica", None, True), + ("найти джаз на кухне", "search", "джаз", "кухне", True), + ("открой плейлист утренний джаз", "playlist", "утренний джаз", None, False), + ("покажи альбом Black Album", "album", "black album", None, False), ], ) def test_parse( @@ -205,3 +211,57 @@ def test_exposed_ids_filter(self) -> None: ] result = resolve_player(_mass(players), "кухня", exposed_ids={"p2"}) # type: ignore[arg-type] assert result is None + + def test_ambiguous_returns_none(self) -> None: + """When the hint matches multiple players in the same tier, resolve_player returns None. + + The caller is expected to use ``resolve_player_candidates`` directly + when it wants to surface the ambiguity (P0.3 disambiguation). + """ + players = [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + ] + # Both names start with "Кухня" — startswith tier has 2 → ambiguous. + assert resolve_player(_mass(players), "кухня") is None # type: ignore[arg-type] + + +class TestResolvePlayerCandidates: + """Tests for resolve_player_candidates — same matching, surface tier list.""" + + def test_zero_matches(self) -> None: + """No candidate match → empty list.""" + players = [MockPlayer(player_id="p1", name="Кухня")] + assert resolve_player_candidates(_mass(players), "гостиная") == [] # type: ignore[arg-type] + + def test_single_match(self) -> None: + """One unambiguous match → single-element list.""" + players = [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + result = resolve_player_candidates(_mass(players), "кухне") # type: ignore[arg-type] + assert len(result) == 1 + assert result[0].player_id == "p1" + + def test_multiple_matches_returned_in_alphabetical_order(self) -> None: + """When multiple players match the same tier, return all sorted by name.""" + players = [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + MockPlayer(player_id="p3", name="Спальня"), + ] + result = resolve_player_candidates(_mass(players), "кухня") # type: ignore[arg-type] + assert len(result) == 2 + names = [p.name for p in result] + assert names == sorted(names, key=str.lower) + + def test_exact_tier_wins_over_startswith(self) -> None: + """An exact match excludes startswith candidates from the result.""" + players = [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Кухня большая"), + ] + result = resolve_player_candidates(_mass(players), "кухня") # type: ignore[arg-type] + # Only the exact match is returned. + assert [p.player_id for p in result] == ["p1"] diff --git a/tests/providers/yandex_smarthome/test_dialogs_player.py b/tests/providers/yandex_smarthome/test_dialogs_player.py index cf06054ad3..3276061984 100644 --- a/tests/providers/yandex_smarthome/test_dialogs_player.py +++ b/tests/providers/yandex_smarthome/test_dialogs_player.py @@ -106,6 +106,41 @@ async def test_search_failure_returns_none(self) -> None: result = await resolve_query(mass, ParsedCommand(kind="track", query="x")) assert result is None + async def test_cyrillic_query_retries_with_stemmed_form(self) -> None: + """First search empty + Cyrillic query → retry with inflection stripped.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + empty = _SearchResults() + hit = _SearchResults(tracks=[track]) + mass = _make_mass(empty) + # First call returns empty, second returns the track. + mass.music.search = AsyncMock(side_effect=[empty, hit]) + result = await resolve_query(mass, ParsedCommand(kind="track", query="металлику")) + assert result is track + # Two calls were made. + assert mass.music.search.await_count == 2 + # Second call used the stemmed query ("металлик" — last `у` stripped). + second_call_kwargs = mass.music.search.await_args_list[1].kwargs + assert second_call_kwargs["search_query"] == "металлик" + + async def test_ascii_query_does_not_retry(self) -> None: + """ASCII-only query is not retried — stemming has no effect.""" + empty = _SearchResults() + mass = _make_mass(empty) + mass.music.search = AsyncMock(return_value=empty) + result = await resolve_query(mass, ParsedCommand(kind="track", query="metallica")) + assert result is None + assert mass.music.search.await_count == 1 + + async def test_retry_skipped_when_stemmed_equals_original(self) -> None: + """Russian query already in stemmed form (short word) doesn't trigger retry.""" + empty = _SearchResults() + mass = _make_mass(empty) + mass.music.search = AsyncMock(return_value=empty) + # "рок" (3 chars) — too short for the suffix-strip to produce a different stem. + result = await resolve_query(mass, ParsedCommand(kind="search", query="рок")) + assert result is None + assert mass.music.search.await_count == 1 + # --------------------------------------------------------------------------- # play_for_alice From 03ebec10e66a80a0bdb4897896697e1f800260d6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 17:18:02 +0000 Subject: [PATCH 35/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.8.2 --- .../providers/yandex_smarthome/dialogs.py | 199 ++++++++++++++-- .../yandex_smarthome/test_dialogs.py | 223 +++++++++++++++++- 2 files changed, 390 insertions(+), 32 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index aad2c309e3..e49fc2bf38 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -127,6 +127,75 @@ def _without_pending(state: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in state.items() if k not in {"pending_command", "awaiting_query"}} +# Ordinal voice-disambiguation patterns. The user picks a candidate by +# position ("первая", "второй", "номер три"). Used when a screenless +# audio device makes button-tap impossible. +_ORDINAL_PATTERNS: tuple[tuple[re.Pattern[str], int], ...] = ( + ( + re.compile( + r"^(?:номер\s+)?(?:перв(?:ая|ый|ое|ую)|один|1)\b", + re.IGNORECASE, + ), + 0, + ), + ( + re.compile( + r"^(?:номер\s+)?(?:втор(?:ая|ой|ое|ую)|два|2)\b", + re.IGNORECASE, + ), + 1, + ), + ( + re.compile( + r"^(?:номер\s+)?(?:треть(?:я|ий|е|ю)|три|3)\b", + re.IGNORECASE, + ), + 2, + ), + ( + re.compile( + r"^(?:номер\s+)?(?:четв[её]рт(?:ая|ый|ое|ую)|четыре|4)\b", + re.IGNORECASE, + ), + 3, + ), + ( + re.compile( + r"^(?:номер\s+)?(?:пят(?:ая|ый|ое|ую)|пять|5)\b", + re.IGNORECASE, + ), + 4, + ), +) + + +def _parse_ordinal_choice(text: str) -> int | None: + """Parse 'первая' / 'второй' / 'номер три' / '2' as a 0-based index, else None. + + Pattern is forgiving — accepts feminine / masculine / neuter forms and + Russian ordinals as well as cardinal numbers ("два") and digits. + """ + if not text: + return None + cleaned = text.strip().lower() + if not cleaned: + return None + for pattern, index in _ORDINAL_PATTERNS: + if pattern.match(cleaned): + return index + return None + + +# Russian ordinal labels used in the disambiguation prompt. +_ORDINAL_LABELS: tuple[str, ...] = ( + "первая", + "вторая", + "третья", + "четвёртая", + "пятая", +) + + class DialogsWebhookHandler: """Handles incoming voice-command webhook calls from a Yandex Dialogs skill.""" @@ -605,11 +674,25 @@ def _build_disambiguation_response( candidates: list[Any], session_state_in: dict[str, Any], ) -> web.Response: - """Ask the user which player to use; offer buttons for each candidate.""" + """Ask the user which player to use — voice-first, with optional buttons. + + Most Yandex Stations are screenless audio devices, so the prompt + has to make voice answer obvious. We enumerate candidates with + Russian ordinals (`первая` / `вторая` / …) so a user can say + either the player name (free-text fallback) or the position. + Buttons are kept on the response for screen surfaces, but voice + is the primary channel. + """ # Yandex caps ItemsList at 5 anyway; cap our buttons to the same. capped = candidates[:5] names = [p.name or p.player_id for p in capped] - text = f"На какой колонке: {', '.join(names)}?" + + # Voice prompt: ordinal-labelled list + explicit voice instruction. + # Example for 2 candidates: + # "На какой колонке? Первая — Кухня большая, вторая — Кухня + # маленькая. Скажи название или номер." + labelled = [f"{_ORDINAL_LABELS[i]} — {name}" for i, name in enumerate(names)] + text = "На какой колонке? " + ", ".join(labelled) + ". Скажи название или номер." buttons = [ { "title": (p.name or p.player_id)[:64], @@ -630,6 +713,13 @@ def _build_disambiguation_response( "kind": parsed.kind, "query": parsed.query[:200], "radio_mode": parsed.radio_mode, + # Ordered list of player IDs we offered. Used by + # `_try_resume_pending` to (a) resolve "первая"/"вторая" + # to a specific player by position, (b) re-narrow + # free-text matching to just these candidates so a + # short distinguisher wins even if a third matching + # player exists outside the disambiguation set. + "candidate_ids": [p.player_id for p in capped], }, } return self._yandex_response( @@ -659,41 +749,102 @@ async def _try_resume_pending( parse_command flow. """ chosen_player: Any = None - - # Button press: payload.player_id is what we sent on the previous turn. - # Validate against the *currently exposed* player set rather than - # blindly trusting the payload — guards against stale or crafted - # payloads that target a player that's been disabled / removed / - # un-exposed since we offered the buttons. Payload integrity is - # already enforced upstream by `body.session.skill_id`, but - # defence-in-depth is cheap here. - payload = req.get("payload") - if isinstance(payload, dict): - pid = payload.get("player_id") - if isinstance(pid, str): - exposed = list_exposed_players(self._mass, exposed_ids=self._exposed_player_ids) - chosen_player = next((p for p in exposed if p.player_id == pid), None) - if chosen_player is None: - self._logger.warning( - "Pending replay: ButtonPressed payload player_id=%r " - "not in exposed-player set; ignoring", - pid, + candidate_ids_raw = pending.get("candidate_ids") + candidate_ids: list[str] = ( + [str(x) for x in candidate_ids_raw if isinstance(x, str)] + if isinstance(candidate_ids_raw, list) + else [] + ) + exposed = list_exposed_players(self._mass, exposed_ids=self._exposed_player_ids) + exposed_by_id = {p.player_id: p for p in exposed} + + # Step 1 — voice ordinal ("первая" / "второй" / "номер три") wins + # outright. On screenless smart speakers this is the primary + # channel since buttons aren't visible to the user. + ordinal = _parse_ordinal_choice(command) + if ordinal is not None: + target_pid: str | None = ( + candidate_ids[ordinal] if 0 <= ordinal < len(candidate_ids) else None + ) + if target_pid is not None: + chosen_player = exposed_by_id.get(target_pid) + if chosen_player is not None: + self._logger.debug( + "Pending replay: voice ordinal %d → player %s", + ordinal, + chosen_player.name or chosen_player.player_id, + ) + # If the ordinal couldn't be resolved (out of range, or the + # indexed player is no longer exposed), the user clearly + # *meant* to pick from the disambiguation list — falling + # through to free-text would mis-interpret "третья" as a + # play query "search for третья". Re-ask with whichever + # candidates are still exposed instead. + if chosen_player is None: + still_available = [ + exposed_by_id[pid] for pid in candidate_ids if pid in exposed_by_id + ] + if still_available: + self._logger.info( + "Pending replay: ordinal=%d unresolvable; " + "re-asking with %d remaining candidate(s)", + ordinal, + len(still_available), ) + return self._build_disambiguation_response( + session=session, + parsed=ParsedCommand( + kind=str(pending.get("kind", "search")), # type: ignore[arg-type] + query=str(pending.get("query", "")), + radio_mode=bool(pending.get("radio_mode", False)), + ), + candidates=still_available, + session_state_in=session_state_in, + ) + # else: no candidates remain at all — fall through to + # normal flow, which will reply with "не нашёл колонку". + # Step 2 — Button press. Validate against the currently exposed + # set (defence-in-depth: stale or crafted payloads pointing to a + # non-exposed player are rejected). + if chosen_player is None: + payload = req.get("payload") + if isinstance(payload, dict): + pid = payload.get("player_id") + if isinstance(pid, str): + chosen_player = exposed_by_id.get(pid) + if chosen_player is None: + self._logger.warning( + "Pending replay: ButtonPressed payload player_id=%r " + "not in exposed-player set; ignoring", + pid, + ) + + # Step 3 — Free-text follow-up. If we offered a specific + # candidate set, narrow the resolver to just those players (so a + # one-word distinguisher like "большая" wins even if a third + # unrelated player elsewhere also matches). Without a candidate + # set, fall back to the global exposed set. if chosen_player is None: - # Free-text follow-up: treat the utterance as a player hint. followup = parse_command(command) hint = followup.player_hint or command + narrowed_ids: set[str] | None + if candidate_ids: + narrowed_ids = set(candidate_ids) + if self._exposed_player_ids is not None: + narrowed_ids &= self._exposed_player_ids + else: + narrowed_ids = self._exposed_player_ids candidates = resolve_player_candidates( self._mass, hint, default_id=None, - exposed_ids=self._exposed_player_ids, + exposed_ids=narrowed_ids, ) if len(candidates) == 1: chosen_player = candidates[0] elif len(candidates) > 1: - # Re-ask using the same pending command. + # Still ambiguous — re-ask with the saved play intent. return self._build_disambiguation_response( session=session, parsed=ParsedCommand( diff --git a/tests/providers/yandex_smarthome/test_dialogs.py b/tests/providers/yandex_smarthome/test_dialogs.py index 3570767a63..a299498ab4 100644 --- a/tests/providers/yandex_smarthome/test_dialogs.py +++ b/tests/providers/yandex_smarthome/test_dialogs.py @@ -610,9 +610,13 @@ async def test_multiple_matches_returns_disambiguation_prompt(self) -> None: assert "buttons" in body_out["response"] button_titles = {b["title"] for b in body_out["response"]["buttons"]} assert button_titles == {"Кухня большая", "Кухня маленькая"} - # pending_command is saved with the original play intent. + # pending_command is saved with the original play intent + the + # ordered candidate IDs for voice ordinal resolution. pending = body_out["session_state"]["pending_command"] - assert pending == {"kind": "search", "query": "metallica", "radio_mode": True} + assert pending["kind"] == "search" + assert pending["query"] == "metallica" + assert pending["radio_mode"] is True + assert pending["candidate_ids"] == ["p1", "p2"] # Nothing is played yet. mass.player_queues.play_media.assert_not_awaited() @@ -753,12 +757,14 @@ async def test_play_no_hint_no_default_offers_disambiguation(self) -> None: assert "buttons" in body_out["response"] button_titles = {b["title"] for b in body_out["response"]["buttons"]} assert button_titles == {"Кухня", "Спальня"} - # pending_command saved with the original play intent. - assert body_out["session_state"]["pending_command"] == { - "kind": "search", - "query": "metallica", - "radio_mode": True, - } + # pending_command saved with the original play intent + candidate_ids. + # Order is significant — used as the index space for voice ordinal + # resolution ("первая" → candidate_ids[0]). + pending = body_out["session_state"]["pending_command"] + assert pending["kind"] == "search" + assert pending["query"] == "metallica" + assert pending["radio_mode"] is True + assert pending["candidate_ids"] == ["p1", "p2"] mass.player_queues.play_media.assert_not_awaited() async def test_button_payload_validated_against_exposed_set(self) -> None: @@ -828,6 +834,207 @@ async def test_disambiguation_clears_awaiting_query(self) -> None: assert "pending_command" in body_out["session_state"] assert "awaiting_query" not in body_out["session_state"] + async def test_voice_ordinal_resolves_pending(self) -> None: + """User answers disambiguation with 'первая' → first candidate is picked.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "первая"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.player_queues.play_media.assert_awaited_once() + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + + async def test_voice_ordinal_second_candidate(self) -> None: + """'вторая' picks the second candidate from candidate_ids.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "вторая"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" + + async def test_ordinal_out_of_range_reasks_does_not_fall_through(self) -> None: + """User says 'третья' when only 2 candidates → re-ask, don't search for 'третья'. + + Without this, the ordinal would be parsed but skip the lookup, + the free-text path would parse the utterance as a search query, + and a default-player resolution might play "третья" on some + random player. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "третья"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + body_out = _response_body(resp) + # Disambiguation re-asked, not played. + assert body_out["response"]["end_session"] is False + assert "buttons" in body_out["response"] + # pending_command still set (with same candidate set). + assert body_out["session_state"]["pending_command"]["candidate_ids"] == ["p1", "p2"] + mass.player_queues.play_media.assert_not_awaited() + + async def test_ordinal_targets_unexposed_player_reasks(self) -> None: + """User picks a valid ordinal but the indexed player has been removed → re-ask.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + # Only p1 exposed now — p2 is gone since the buttons were sent. + mass = _make_mass( + [MockPlayer(player_id="p1", name="Кухня")], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "вторая"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + body_out = _response_body(resp) + # Re-asked with the remaining exposed candidate (p1). + assert body_out["response"]["end_session"] is False + assert body_out["session_state"]["pending_command"]["candidate_ids"] == ["p1"] + mass.player_queues.play_media.assert_not_awaited() + + async def test_voice_ordinal_digit(self) -> None: + """A bare digit ('2') also works as an ordinal.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "2"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" + + async def test_freetext_narrows_to_candidate_set(self) -> None: + """Free-text answer is matched only against the saved candidate IDs. + + With 3 exposed players (Кухня большая, Кухня маленькая, Гостиная) + and a saved candidate set covering only the two kitchens, saying + 'большая' must pick "Кухня большая" — even though 'большая' + could ambiguously refer to several players in a larger set. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + MockPlayer(player_id="p3", name="Гостиная большая"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "большая"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + # Must pick p1 (Кухня большая, in candidate set) — not p3 + # (also matches "большая" but excluded from candidate_ids). + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + async def test_freetext_followup_resolves_pending(self) -> None: """User says 'на кухне маленькой' after the disambiguation question — plays on p2.""" track = MagicMock(uri="library://track/1", spec_set=["uri"]) From 461c029fbcdb3c8c088f531b84d1924ad35da799 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 18:00:36 +0000 Subject: [PATCH 36/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.8.3 --- music_assistant/providers/yandex_smarthome/__init__.py | 2 +- music_assistant/providers/yandex_smarthome/auto_skill.py | 2 +- music_assistant/providers/yandex_smarthome/device.py | 2 +- music_assistant/providers/yandex_smarthome/dialogs.py | 1 + music_assistant/providers/yandex_smarthome/dialogs_control.py | 1 + music_assistant/providers/yandex_smarthome/dialogs_nlu.py | 1 + music_assistant/providers/yandex_smarthome/dialogs_player.py | 1 + tests/providers/yandex_smarthome/test_dialogs.py | 4 +++- tests/providers/yandex_smarthome/test_dialogs_control.py | 1 + tests/providers/yandex_smarthome/test_dialogs_player.py | 1 + 10 files changed, 12 insertions(+), 4 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index 59421dea27..c0e014db14 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -483,7 +483,7 @@ def _cache_x_token(token: str) -> None: values[CONF_DIALOG_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) -async def get_config_entries( +async def get_config_entries( # noqa: PLR0915 mass: MusicAssistant, instance_id: str | None = None, action: str | None = None, diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index 5600557505..9b2f03e343 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -1402,7 +1402,7 @@ async def _track(a: SkillCreationArtifacts) -> None: return dataclasses.replace(current, state=SkillCreationState.FAILED, last_error=str(exc)) -async def _execute_pipeline( # noqa: PLR0913 +async def _execute_pipeline( # noqa: PLR0913, PLR0915 *, creator: DialogsSkillCreator, csrf: str, diff --git a/music_assistant/providers/yandex_smarthome/device.py b/music_assistant/providers/yandex_smarthome/device.py index fbd9dd9c6c..9748f971d1 100644 --- a/music_assistant/providers/yandex_smarthome/device.py +++ b/music_assistant/providers/yandex_smarthome/device.py @@ -430,7 +430,7 @@ def _invalid_numeric_result(cap_type: str, instance: str, value: Any) -> Capabil ) -async def execute_capability_action( +async def execute_capability_action( # noqa: PLR0915 mass: Any, player_id: str, action: CapabilityAction, diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index e49fc2bf38..23e86fee8c 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -1,3 +1,4 @@ +# ruff: noqa: RUF001, RUF003 """HTTP handler for the Yandex Dialogs custom-skill webhook (experimental). Registers a single exact route on the MA webserver — the secret is diff --git a/music_assistant/providers/yandex_smarthome/dialogs_control.py b/music_assistant/providers/yandex_smarthome/dialogs_control.py index a93764e6ff..d051e2e61f 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_control.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_control.py @@ -1,3 +1,4 @@ +# ruff: noqa: RUF001 """Playback-control NLU + executor for the Yandex Dialogs custom skill. Handles utterances that don't carry a music query — pause/resume/next/ diff --git a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py index c6d0bcd5ec..121e1fc76d 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py @@ -1,3 +1,4 @@ +# ruff: noqa: RUF001, RUF003 """Server-side NLU parser for Yandex Dialogs custom-skill webhook commands. The plugin's Dialogs skill registers in the Yandex Dialogs UI without diff --git a/music_assistant/providers/yandex_smarthome/dialogs_player.py b/music_assistant/providers/yandex_smarthome/dialogs_player.py index 6ecf937b11..7e12b53cdd 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_player.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_player.py @@ -1,3 +1,4 @@ +# ruff: noqa: RUF001 """Music + player resolvers for the Yandex Dialogs custom-skill webhook. `resolve_query` turns a `ParsedCommand` into a concrete URI/MediaItem ready diff --git a/tests/providers/yandex_smarthome/test_dialogs.py b/tests/providers/yandex_smarthome/test_dialogs.py index a299498ab4..4a373b77ba 100644 --- a/tests/providers/yandex_smarthome/test_dialogs.py +++ b/tests/providers/yandex_smarthome/test_dialogs.py @@ -1,3 +1,4 @@ +# ruff: noqa: RUF001, RUF002 """Tests for provider/dialogs.py — webhook handler.""" from __future__ import annotations @@ -90,7 +91,8 @@ def _build_request(body: dict[str, Any], secret: str = _TEST_SECRET) -> web.Requ def _response_body(resp: web.Response) -> dict[str, Any]: """Decode a web.json_response body into a dict for assertions.""" - return json.loads(resp.body) # type: ignore[arg-type] + decoded: dict[str, Any] = json.loads(resp.body) # type: ignore[arg-type] + return decoded @pytest.mark.asyncio diff --git a/tests/providers/yandex_smarthome/test_dialogs_control.py b/tests/providers/yandex_smarthome/test_dialogs_control.py index 5cd63b5476..a1d4795938 100644 --- a/tests/providers/yandex_smarthome/test_dialogs_control.py +++ b/tests/providers/yandex_smarthome/test_dialogs_control.py @@ -1,3 +1,4 @@ +# ruff: noqa: RUF001 """Tests for provider/dialogs_control.py — playback control NLU + executor.""" from __future__ import annotations diff --git a/tests/providers/yandex_smarthome/test_dialogs_player.py b/tests/providers/yandex_smarthome/test_dialogs_player.py index 3276061984..de2da0ce26 100644 --- a/tests/providers/yandex_smarthome/test_dialogs_player.py +++ b/tests/providers/yandex_smarthome/test_dialogs_player.py @@ -1,3 +1,4 @@ +# ruff: noqa: RUF003 """Tests for provider/dialogs_player.py — content resolver + play wrapper.""" from __future__ import annotations From 6ca5b0196c2d8b99da108305c94c15d988def71b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 19:29:19 +0000 Subject: [PATCH 37/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.8.4 --- .../providers/yandex_smarthome/dialogs.py | 207 +++++++++--------- .../providers/yandex_smarthome/dialogs_nlu.py | 2 + .../yandex_smarthome/test_dialogs.py | 94 ++++++++ 3 files changed, 197 insertions(+), 106 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index 23e86fee8c..ef301f8b98 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -129,52 +129,40 @@ def _without_pending(state: dict[str, Any]) -> dict[str, Any]: # Ordinal voice-disambiguation patterns. The user picks a candidate by -# position ("первая", "второй", "номер три"). Used when a screenless -# audio device makes button-tap impossible. +# position ("первая", "выбираю первую", "номер три"). Used when a +# screenless audio device makes button-tap impossible. +# +# Two pattern families (all matched via ``re.search`` — leading filler +# words like "ну", "хочу", "выбираю", "давай" don't kill the match): +# +# 1. Russian ordinal stems (``перв\w*`` etc.) — case-insensitive +# word-prefix match. Catches every morphological form ("первая", +# "первый", "первое", "первую", "первой", "первом", …) without +# enumerating each. +# 2. Cardinal numbers and digits — anchored ``^…$`` so only a +# bare-number utterance counts ("один", "1", "номер один"). +# "У меня один вариант" must NOT silently pick the first. _ORDINAL_PATTERNS: tuple[tuple[re.Pattern[str], int], ...] = ( - ( - re.compile( - r"^(?:номер\s+)?(?:перв(?:ая|ый|ое|ую)|один|1)\b", - re.IGNORECASE, - ), - 0, - ), - ( - re.compile( - r"^(?:номер\s+)?(?:втор(?:ая|ой|ое|ую)|два|2)\b", - re.IGNORECASE, - ), - 1, - ), - ( - re.compile( - r"^(?:номер\s+)?(?:треть(?:я|ий|е|ю)|три|3)\b", - re.IGNORECASE, - ), - 2, - ), - ( - re.compile( - r"^(?:номер\s+)?(?:четв[её]рт(?:ая|ый|ое|ую)|четыре|4)\b", - re.IGNORECASE, - ), - 3, - ), - ( - re.compile( - r"^(?:номер\s+)?(?:пят(?:ая|ый|ое|ую)|пять|5)\b", - re.IGNORECASE, - ), - 4, - ), + (re.compile(r"\bперв\w*\b", re.IGNORECASE), 0), + (re.compile(r"\bвтор\w*\b", re.IGNORECASE), 1), + (re.compile(r"\bтреть\w*\b", re.IGNORECASE), 2), + (re.compile(r"\bчетв[её]рт\w*\b", re.IGNORECASE), 3), + (re.compile(r"\bпят\w*\b", re.IGNORECASE), 4), + # Cardinals — whole-utterance only. + (re.compile(r"^(?:номер\s+)?(?:один|1)$", re.IGNORECASE), 0), + (re.compile(r"^(?:номер\s+)?(?:два|2)$", re.IGNORECASE), 1), + (re.compile(r"^(?:номер\s+)?(?:три|3)$", re.IGNORECASE), 2), + (re.compile(r"^(?:номер\s+)?(?:четыре|4)$", re.IGNORECASE), 3), + (re.compile(r"^(?:номер\s+)?(?:пять|5)$", re.IGNORECASE), 4), ) def _parse_ordinal_choice(text: str) -> int | None: - """Parse 'первая' / 'второй' / 'номер три' / '2' as a 0-based index, else None. + """Parse 'первая' / 'выбираю первую' / 'номер три' / '2' as 0-based index. - Pattern is forgiving — accepts feminine / masculine / neuter forms and - Russian ordinals as well as cardinal numbers ("два") and digits. + Returns the index, or None if no ordinal/cardinal pattern matched. + Tolerates leading filler words ("ну", "хочу", "выбираю", "давай") + since users often pad voice replies on smart speakers. """ if not text: return None @@ -182,7 +170,7 @@ def _parse_ordinal_choice(text: str) -> int | None: if not cleaned: return None for pattern, index in _ORDINAL_PATTERNS: - if pattern.match(cleaned): + if pattern.search(cleaned): return index return None @@ -759,73 +747,28 @@ async def _try_resume_pending( exposed = list_exposed_players(self._mass, exposed_ids=self._exposed_player_ids) exposed_by_id = {p.player_id: p for p in exposed} - # Step 1 — voice ordinal ("первая" / "второй" / "номер три") wins - # outright. On screenless smart speakers this is the primary - # channel since buttons aren't visible to the user. - ordinal = _parse_ordinal_choice(command) - if ordinal is not None: - target_pid: str | None = ( - candidate_ids[ordinal] if 0 <= ordinal < len(candidate_ids) else None - ) - if target_pid is not None: - chosen_player = exposed_by_id.get(target_pid) - if chosen_player is not None: - self._logger.debug( - "Pending replay: voice ordinal %d → player %s", - ordinal, - chosen_player.name or chosen_player.player_id, - ) - # If the ordinal couldn't be resolved (out of range, or the - # indexed player is no longer exposed), the user clearly - # *meant* to pick from the disambiguation list — falling - # through to free-text would mis-interpret "третья" as a - # play query "search for третья". Re-ask with whichever - # candidates are still exposed instead. - if chosen_player is None: - still_available = [ - exposed_by_id[pid] for pid in candidate_ids if pid in exposed_by_id - ] - if still_available: - self._logger.info( - "Pending replay: ordinal=%d unresolvable; " - "re-asking with %d remaining candidate(s)", - ordinal, - len(still_available), + # Step 1 — Button press. Direct UI signal on surfaces with a + # screen. Validate against the currently exposed set + # (defence-in-depth: stale / crafted payloads are rejected). + payload = req.get("payload") + if isinstance(payload, dict): + pid = payload.get("player_id") + if isinstance(pid, str): + chosen_player = exposed_by_id.get(pid) + if chosen_player is None: + self._logger.warning( + "Pending replay: ButtonPressed payload player_id=%r " + "not in exposed-player set; ignoring", + pid, ) - return self._build_disambiguation_response( - session=session, - parsed=ParsedCommand( - kind=str(pending.get("kind", "search")), # type: ignore[arg-type] - query=str(pending.get("query", "")), - radio_mode=bool(pending.get("radio_mode", False)), - ), - candidates=still_available, - session_state_in=session_state_in, - ) - # else: no candidates remain at all — fall through to - # normal flow, which will reply with "не нашёл колонку". - # Step 2 — Button press. Validate against the currently exposed - # set (defence-in-depth: stale or crafted payloads pointing to a - # non-exposed player are rejected). - if chosen_player is None: - payload = req.get("payload") - if isinstance(payload, dict): - pid = payload.get("player_id") - if isinstance(pid, str): - chosen_player = exposed_by_id.get(pid) - if chosen_player is None: - self._logger.warning( - "Pending replay: ButtonPressed payload player_id=%r " - "not in exposed-player set; ignoring", - pid, - ) - - # Step 3 — Free-text follow-up. If we offered a specific - # candidate set, narrow the resolver to just those players (so a - # one-word distinguisher like "большая" wins even if a third - # unrelated player elsewhere also matches). Without a candidate - # set, fall back to the global exposed set. + # Step 2 — Free-text first. Lets named answers ("Кухня большая" + # / "большая" / "маленькую") and even hypothetical players whose + # names contain ordinal words ("Спальня первая") win over the + # purely-positional ordinal interpretation. Narrow the resolver + # to the saved candidate set so a short distinguisher like + # "большая" doesn't accidentally pick an unrelated third player + # outside the disambiguation set. if chosen_player is None: followup = parse_command(command) hint = followup.player_hint or command @@ -844,6 +787,10 @@ async def _try_resume_pending( ) if len(candidates) == 1: chosen_player = candidates[0] + self._logger.debug( + "Pending replay: free-text → player %s", + chosen_player.name or chosen_player.player_id, + ) elif len(candidates) > 1: # Still ambiguous — re-ask with the saved play intent. return self._build_disambiguation_response( @@ -857,6 +804,54 @@ async def _try_resume_pending( session_state_in=session_state_in, ) + # Step 3 — voice ordinal ("первая", "выбираю первую", "номер + # три"). Last because we want named answers to win even when + # they happen to contain an ordinal word ("Спальня первая"). + # On screenless smart speakers ordinal is the natural reply + # when none of the names is easy to pronounce. + if chosen_player is None: + ordinal = _parse_ordinal_choice(command) + if ordinal is not None: + target_pid: str | None = ( + candidate_ids[ordinal] if 0 <= ordinal < len(candidate_ids) else None + ) + if target_pid is not None: + chosen_player = exposed_by_id.get(target_pid) + if chosen_player is not None: + self._logger.debug( + "Pending replay: voice ordinal %d → player %s", + ordinal, + chosen_player.name or chosen_player.player_id, + ) + # If the ordinal couldn't be resolved (out of range, or + # the indexed player is no longer exposed), the user + # clearly *meant* to pick from the disambiguation list — + # re-ask with whichever candidates are still exposed + # instead of falling through and mis-interpreting + # "третья" as a play query. + if chosen_player is None: + still_available = [ + exposed_by_id[pid] for pid in candidate_ids if pid in exposed_by_id + ] + if still_available: + self._logger.info( + "Pending replay: ordinal=%d unresolvable; " + "re-asking with %d remaining candidate(s)", + ordinal, + len(still_available), + ) + return self._build_disambiguation_response( + session=session, + parsed=ParsedCommand( + kind=str(pending.get("kind", "search")), # type: ignore[arg-type] + query=str(pending.get("query", "")), + radio_mode=bool(pending.get("radio_mode", False)), + ), + candidates=still_available, + session_state_in=session_state_in, + ) + # else: no candidates remain at all — fall through. + if chosen_player is None: return None diff --git a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py index 121e1fc76d..770728482e 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py @@ -169,6 +169,7 @@ def parse_command(text: str) -> ParsedCommand: "ого", "ому", "ыми", + "ую", # feminine adjective accusative — "большую", "маленькую" "ая", # feminine adjective nominative — "большая", "маленькая" "ой", "ом", @@ -181,6 +182,7 @@ def parse_command(text: str) -> ParsedCommand: "й", "ь", "я", # feminine noun nominative — "Кухня", "Спальня" + "ю", # feminine noun accusative — "Кухню", "Спальню" ) diff --git a/tests/providers/yandex_smarthome/test_dialogs.py b/tests/providers/yandex_smarthome/test_dialogs.py index 4a373b77ba..e77238cc8e 100644 --- a/tests/providers/yandex_smarthome/test_dialogs.py +++ b/tests/providers/yandex_smarthome/test_dialogs.py @@ -969,6 +969,100 @@ async def test_ordinal_targets_unexposed_player_reasks(self) -> None: assert body_out["session_state"]["pending_command"]["candidate_ids"] == ["p1"] mass.player_queues.play_media.assert_not_awaited() + async def test_voice_ordinal_with_filler(self) -> None: + """Filler-padded ordinal answers ('выбираю первую', 'хочу вторую') resolve. + + On smart speakers users naturally pad voice replies with filler; + the strict-anchor regex from v1.8.2 missed these. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "выбираю первую"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + + async def test_voice_accusative_adjective(self) -> None: + """Accusative-case answer 'большую' resolves to 'Кухня большая'. + + Caught by the new `ую` suffix in `_INFLECTION_SUFFIXES`. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "большую"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + + async def test_voice_accusative_noun(self) -> None: + """Accusative noun 'Кухню' resolves to 'Кухня' via the new `ю` suffix.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "Кухню"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + async def test_voice_ordinal_digit(self) -> None: """A bare digit ('2') also works as an ordinal.""" track = MagicMock(uri="library://track/1", spec_set=["uri"]) From 734e60c7d8ed2da27e75efb6bf2413395961a041 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 19:42:44 +0000 Subject: [PATCH 38/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.8.5 --- .../providers/yandex_smarthome/dialogs.py | 104 +++++++++++++----- .../yandex_smarthome/test_dialogs.py | 64 +++++++++++ 2 files changed, 143 insertions(+), 25 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index ef301f8b98..d710cfbcb1 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -300,18 +300,34 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: is_new = bool(session.get("new")) command = str(req.get("command") or "").strip() + # Pending-command / awaiting-query lookups read from `state.session` + # first and fall back to `state.application`. Some Yandex devices + # (notably screenless Stations under certain settings) don't + # consistently echo `state.session` back across SimpleUtterance + # turns — the application tier is per-device, persists across + # session resets, and is honoured by every Yandex surface we've + # tested. Writes mirror to both buckets in the response builders. + pending_in = session_state_in.get("pending_command") + if not isinstance(pending_in, dict): + pending_in = app_state_in.get("pending_command") + awaiting_in = bool(session_state_in.get("awaiting_query")) or bool( + app_state_in.get("awaiting_query") + ) + # Single summary log per incoming request — surfaces the wire-shape # bits we route on. Sensitive fields (skill_id, webhook_secret, # raw payload IDs) are excluded; user/session IDs are opaque - # opaque tokens and DEBUG is opt-in, so they're included as-is. + # tokens and DEBUG is opt-in, so they're included as-is. self._logger.debug( "Webhook recv: cmd=%r req_type=%s is_new=%s pending=%s " - "awaiting=%s default_player=%s session_id=%s", + "(session=%s app=%s) awaiting=%s default_player=%s session_id=%s", command, req.get("type", "SimpleUtterance"), is_new, + bool(pending_in), bool(session_state_in.get("pending_command")), - bool(session_state_in.get("awaiting_query")), + bool(app_state_in.get("pending_command")), + awaiting_in, default_id, session.get("session_id", ""), ) @@ -359,15 +375,18 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # the existing kind classifier runs ("песню X", "альбом Y", # "мою волну", etc.). Skip the synthetic prefix if the user # already said one of the verbs. - if session_state_in.get("awaiting_query") and not _VERB_RE.match(command): + if awaiting_in and not _VERB_RE.match(command): command = f"включи {command}" self._logger.debug("Awaiting-query branch: synthesised cmd=%r", command) # P0.3 — pending-command re-entry. If a previous turn asked the # user to disambiguate which player to use, the new utterance (or # button press) carries the answer; replay the saved play intent. - pending = session_state_in.get("pending_command") - if isinstance(pending, dict): + # `pending_in` was merged from `state.session` and `state.application` + # earlier so this works even on devices that don't preserve + # session-state between SimpleUtterance turns. + if isinstance(pending_in, dict): + pending: dict[str, Any] = pending_in self._logger.debug( "Pending-command branch: kind=%s query=%r radio=%s; cmd=%r payload=%s", pending.get("kind"), @@ -427,6 +446,12 @@ async def _dispatch_play( **_without_pending(session_state_in), "awaiting_query": True, }, + # Mirror to application_state so the next turn can find + # the flag even if Yandex didn't echo `state.session`. + application_state={ + **_without_pending(app_state_in), + "awaiting_query": True, + }, ) candidates = resolve_player_candidates( @@ -455,6 +480,7 @@ async def _dispatch_play( parsed=parsed, candidates=all_exposed, session_state_in=session_state_in, + app_state_in=app_state_in, ) hint = parsed.player_hint or "(не указано)" self._logger.info( @@ -481,6 +507,7 @@ async def _dispatch_play( parsed=parsed, candidates=candidates, session_state_in=session_state_in, + app_state_in=app_state_in, ) self._logger.debug( @@ -561,8 +588,15 @@ def _handle_control( control.value, ) self._mass.create_task(execute_control(self._mass, control, player)) + # Clear any pending disambiguation / awaiting-query state from + # both tiers — the user took a different path. (`session_state_in` + # was already cleaned by the caller with `_without_pending`; do + # the same defensively here for application_state.) new_session_state = {**session_state_in, "last_player_id": player.player_id} - new_app_state = {**app_state_in, "last_player_id": player.player_id} + new_app_state = { + **_without_pending(app_state_in), + "last_player_id": player.player_id, + } user_obj = session.get("user") or {} user_state_update: dict[str, Any] | None = None if isinstance(user_obj, dict) and user_obj.get("user_id"): @@ -607,6 +641,7 @@ async def _play_with_player( text=text, tts=_tts_for(text), session_state=_without_pending(base_session_state), + application_state=_without_pending(base_app_state), ) if media is None: @@ -616,6 +651,7 @@ async def _play_with_player( text=text, tts=_tts_for(text), session_state=_without_pending(base_session_state), + application_state=_without_pending(base_app_state), ) # Fire-and-forget — Alice has a 4.5s budget; play_media may take longer @@ -634,7 +670,13 @@ async def _play_with_player( **_without_pending(base_session_state), "last_player_id": player.player_id, } - new_app_state = {**base_app_state, "last_player_id": player.player_id} + # Also clear pending/awaiting from `application_state` — it was + # mirrored there as a fallback for devices that don't preserve + # `state.session` between turns. + new_app_state = { + **_without_pending(base_app_state), + "last_player_id": player.player_id, + } user_obj = session.get("user") or {} user_state_update: dict[str, Any] | None = None if isinstance(user_obj, dict) and user_obj.get("user_id"): @@ -662,6 +704,7 @@ def _build_disambiguation_response( parsed: ParsedCommand, candidates: list[Any], session_state_in: dict[str, Any], + app_state_in: dict[str, Any] | None = None, ) -> web.Response: """Ask the user which player to use — voice-first, with optional buttons. @@ -691,25 +734,33 @@ def _build_disambiguation_response( for p in capped ] # Clear any prior `awaiting_query` / `pending_command` before - # writing the new one. Without this, slot-elicitation state from - # an earlier turn would leak into the disambiguation response — - # the next utterance ("Кухня маленькая") would get auto-prefixed - # with "включи " by the awaiting-query branch and miss the - # pending-command resolver. + # writing the new one, and include the saved `pending_command`. + # The same pending entry is mirrored to BOTH `session_state` and + # `application_state` because some Yandex devices (notably + # screenless Stations) don't reliably echo `state.session` back + # across SimpleUtterance turns. The application tier persists + # per-device — it survives session resets and is honoured on + # every surface we've tested. Reads in `_handle_webhook` merge + # the two tiers (session preferred, application as fallback). + pending_command = { + "kind": parsed.kind, + "query": parsed.query[:200], + "radio_mode": parsed.radio_mode, + # Ordered list of player IDs we offered. Used by + # `_try_resume_pending` to (a) resolve "первая"/"вторая" + # to a specific player by position, (b) re-narrow free-text + # matching to just these candidates so a short distinguisher + # wins even if a third matching player exists outside the + # disambiguation set. + "candidate_ids": [p.player_id for p in capped], + } new_session_state = { **_without_pending(session_state_in), - "pending_command": { - "kind": parsed.kind, - "query": parsed.query[:200], - "radio_mode": parsed.radio_mode, - # Ordered list of player IDs we offered. Used by - # `_try_resume_pending` to (a) resolve "первая"/"вторая" - # to a specific player by position, (b) re-narrow - # free-text matching to just these candidates so a - # short distinguisher wins even if a third matching - # player exists outside the disambiguation set. - "candidate_ids": [p.player_id for p in capped], - }, + "pending_command": pending_command, + } + new_app_state = { + **_without_pending(app_state_in or {}), + "pending_command": pending_command, } return self._yandex_response( incoming_session=session, @@ -717,6 +768,7 @@ def _build_disambiguation_response( tts=_tts_for(text), end_session=False, session_state=new_session_state, + application_state=new_app_state, buttons=buttons, ) @@ -802,6 +854,7 @@ async def _try_resume_pending( ), candidates=candidates, session_state_in=session_state_in, + app_state_in=app_state_in, ) # Step 3 — voice ordinal ("первая", "выбираю первую", "номер @@ -849,6 +902,7 @@ async def _try_resume_pending( ), candidates=still_available, session_state_in=session_state_in, + app_state_in=app_state_in, ) # else: no candidates remain at all — fall through. diff --git a/tests/providers/yandex_smarthome/test_dialogs.py b/tests/providers/yandex_smarthome/test_dialogs.py index e77238cc8e..8f5b4088b4 100644 --- a/tests/providers/yandex_smarthome/test_dialogs.py +++ b/tests/providers/yandex_smarthome/test_dialogs.py @@ -969,6 +969,70 @@ async def test_ordinal_targets_unexposed_player_reasks(self) -> None: assert body_out["session_state"]["pending_command"]["candidate_ids"] == ["p1"] mass.player_queues.play_media.assert_not_awaited() + async def test_pending_command_falls_back_to_application_state(self) -> None: + """Yandex didn't echo `state.session` but kept `state.application` — still resolves. + + Reproduces the screenless-Station bug where the second turn of + a disambiguation arrives without the `pending_command` we put in + `state.session`. The same record is mirrored in `state.application` + so the handler can recover. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Проигрыватель"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "Проигрыватель"}, + "state": { + # state.session is empty — Yandex didn't echo it back. + "application": { + "pending_command": { + "kind": "search", + "query": "джаз", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.play_media.assert_awaited_once() + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" + + async def test_disambiguation_writes_pending_to_application_state(self) -> None: + """The disambiguation prompt mirrors `pending_command` to application_state. + + Without this, devices that drop `state.session` between turns can + never complete the disambiguation flow. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Проигрыватель"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи джаз"}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + # Disambiguation triggered. + assert "buttons" in body_out["response"] + # Pending mirrored in BOTH session_state and application_state. + assert body_out["session_state"]["pending_command"]["candidate_ids"] == ["p1", "p2"] + assert body_out["application_state"]["pending_command"]["candidate_ids"] == ["p1", "p2"] + async def test_voice_ordinal_with_filler(self) -> None: """Filler-padded ordinal answers ('выбираю первую', 'хочу вторую') resolve. From 26591c8bcee1d5b4f99100fd6a4fdc8282bef792 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 20:45:06 +0000 Subject: [PATCH 39/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.8.6 --- .../providers/yandex_smarthome/auto_skill.py | 29 --------- .../providers/yandex_smarthome/dialogs.py | 63 ++++++++++++++----- .../providers/yandex_smarthome/dialogs_nlu.py | 49 ++++++++++++++- .../yandex_smarthome/test_dialogs.py | 46 ++++++++++++++ .../yandex_smarthome/test_dialogs_nlu.py | 11 ++++ 5 files changed, 151 insertions(+), 47 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py index 9b2f03e343..68cc6e678f 100644 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -401,35 +401,6 @@ async def request_deploy(self, csrf: str, skill_id: str) -> None: http_status=resp.status, ) - async def get_operations(self, csrf: str, skill_id: str) -> list[dict[str, Any]]: - """Fetch the recent operations log for a skill. - - Returns entries like ``{"type": "deployCompleted", "itemId": "", "createdAt": "..."}``. - Used to poll for ``deployCompleted`` after ``request_deploy``. - """ - url = f"{DIALOGS_API_BASE}/apps/{skill_id}/operations" - headers = {"x-csrf-token": csrf} - async with self._session.get(url, headers=headers) as resp: - body = await resp.text() - if resp.status != 200: - raise DialogsApiError( - f"get_operations HTTP {resp.status}: {body[:200]}", - step="get_operations", - http_status=resp.status, - ) - data = _try_json(body) - if isinstance(data, dict): - result = data.get("result", data) - if isinstance(result, list): - return [op for op in result if isinstance(op, dict)] - if isinstance(result, dict): - ops = result.get("operations") or result.get("items") - if isinstance(ops, list): - return [op for op in ops if isinstance(op, dict)] - if isinstance(data, list): - return [op for op in data if isinstance(op, dict)] - return [] - # ----------------------------------------------------------------------- # Internal helpers # ----------------------------------------------------------------------- diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index d710cfbcb1..4b8213fdaf 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -120,12 +120,15 @@ def _safe_dict(value: Any) -> dict[str, Any]: def _without_pending(state: dict[str, Any]) -> dict[str, Any]: - """Return a copy of `state` with `pending_command` and `awaiting_query` removed. + """Return a copy of `state` with disambiguation/elicitation keys removed. - Used after the disambiguation/elicitation flow successfully completes - so the next turn doesn't accidentally re-enter the saved branch. + Strips `pending_command`, `awaiting_query`, and `awaiting_player_id`. + Used after the disambiguation / slot-elicit flow successfully + completes so the next turn doesn't accidentally re-enter the saved + branch. """ - return {k: v for k, v in state.items() if k not in {"pending_command", "awaiting_query"}} + transient = {"pending_command", "awaiting_query", "awaiting_player_id"} + return {k: v for k, v in state.items() if k not in transient} # Ordinal voice-disambiguation patterns. The user picks a candidate by @@ -242,7 +245,7 @@ def unregister_routes(self) -> None: # Webhook entry point # ------------------------------------------------------------------- - async def _handle_webhook(self, request: web.Request) -> web.Response: + async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: PLR0915 # Path secret already enforced by the route URL — getting here means # the secret matches. Still constant-time-compare it via the captured # path arg in case aiohttp routing ever changes. @@ -378,6 +381,20 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: if awaiting_in and not _VERB_RE.match(command): command = f"включи {command}" self._logger.debug("Awaiting-query branch: synthesised cmd=%r", command) + # If slot-elicit was triggered with a player hint that resolved + # to a single exposed player, the follow-up turn should play on + # that player. Surface it as `default_id` so the resolver picks + # it without the user re-stating "на кухне". + if awaiting_in and not default_id: + saved_pid = session_state_in.get("awaiting_player_id") or app_state_in.get( + "awaiting_player_id" + ) + if saved_pid: + default_id = str(saved_pid) + self._logger.debug( + "Awaiting-query branch: restored hinted player as default_id=%s", + default_id, + ) # P0.3 — pending-command re-entry. If a previous turn asked the # user to disambiguate which player to use, the new utterance (or @@ -434,24 +451,40 @@ async def _dispatch_play( ) -> web.Response: """Slot-elicit / resolve player / disambiguate / play (or fail).""" # P0.4 — slot elicitation: bare verb with no actionable content. - if parsed.kind == "search" and not parsed.query and not parsed.player_hint: - self._logger.debug("Slot-elicit branch: empty query, asking 'Что включить?'") + # Triggers whenever the query slot is empty, even if the user + # specified a player hint ("включи на кухне"). Falling through + # would respond "Не нашёл такую музыку: ." which is confusing — + # the user clearly *wants* something, just didn't name it yet. + # If a hint resolves to a single exposed player, save its id as + # `awaiting_player_id` so the follow-up turn plays on it. + if parsed.kind == "search" and not parsed.query: + self._logger.debug( + "Slot-elicit branch: empty query (hint=%r), asking 'Что включить?'", + parsed.player_hint, + ) + awaiting_player_id: str | None = None + if parsed.player_hint: + hinted_candidates = resolve_player_candidates( + self._mass, + parsed.player_hint, + default_id=default_id, + exposed_ids=self._exposed_player_ids, + ) + if len(hinted_candidates) == 1: + awaiting_player_id = hinted_candidates[0].player_id text = "Что включить? Можно сказать имя артиста, песни или плейлиста." + elicit_state: dict[str, Any] = {"awaiting_query": True} + if awaiting_player_id: + elicit_state["awaiting_player_id"] = awaiting_player_id return self._yandex_response( incoming_session=session, text=text, tts=_tts_for(text), end_session=False, - session_state={ - **_without_pending(session_state_in), - "awaiting_query": True, - }, + session_state={**_without_pending(session_state_in), **elicit_state}, # Mirror to application_state so the next turn can find # the flag even if Yandex didn't echo `state.session`. - application_state={ - **_without_pending(app_state_in), - "awaiting_query": True, - }, + application_state={**_without_pending(app_state_in), **elicit_state}, ) candidates = resolve_player_candidates( diff --git a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py index 770728482e..36d37df21b 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py @@ -1,4 +1,4 @@ -# ruff: noqa: RUF001, RUF003 +# ruff: noqa: RUF001, RUF002, RUF003 """Server-side NLU parser for Yandex Dialogs custom-skill webhook commands. The plugin's Dialogs skill registers in the Yandex Dialogs UI without @@ -100,7 +100,34 @@ class ParsedCommand: ) -def parse_command(text: str) -> ParsedCommand: +# Marker words that mean "the next token(s) are content, not the title". +# Used by `parse_command` to detect a wrong "на " split: if the +# split-off remainder is just a marker word (e.g. "включи песню" with +# the title "На заре" mis-split off as a player hint), the suffix +# extraction was almost certainly wrong and we re-parse without it. +_KIND_MARKER_WORDS: frozenset[str] = frozenset( + { + "песню", + "трек", + "композицию", + "композиция", + "альбом", + "пластинку", + "пластинка", + "плейлист", + "подборку", + "подборка", + "исполнителя", + "артиста", + "группу", + "группа", + "радио", + "жанр", + } +) + + +def parse_command(text: str, *, _split_player_hint: bool = True) -> ParsedCommand: """Parse a raw voice command into a structured ParsedCommand. Examples: @@ -111,6 +138,13 @@ def parse_command(text: str) -> ParsedCommand: "включи мою волну" → kind=my_wave, query=, radio_mode=True "включи джаз" → kind=search, query=джаз "включи жанр джаз" → kind=genre, query=джаз, radio_mode=True + "включи песню На заре" → kind=track, query=на заре (no false split) + + The ``_split_player_hint`` parameter is internal: when the first + pass produces a suspicious split (the whole content was eaten as + "на ", leaving only a marker word in the query), the + function recurses with the flag off to keep the suffix in the + query. Don't pass it from outside. """ if not text: return ParsedCommand(kind="search", query="") @@ -124,7 +158,7 @@ def parse_command(text: str) -> ParsedCommand: # Split off the trailing "" hint suffix. player_hint: str | None = None - if match := _PLAYER_SUFFIX_RE.search(cleaned): + if _split_player_hint and (match := _PLAYER_SUFFIX_RE.search(cleaned)): player_hint = match.group("hint").strip().lower() cleaned = cleaned[: match.start()].strip() @@ -145,6 +179,15 @@ def parse_command(text: str) -> ParsedCommand: radio_mode=radio, ) + # Suspicious-split detector: when a player_hint was extracted AND + # the residual intent_part collapsed to a kind-marker word (e.g. + # "песню", "альбом", "плейлист"), the user probably said something + # like "включи песню На заре" and we mis-split "На заре" as a + # player hint. Re-parse without the suffix split so the title is + # preserved in the query. + if _split_player_hint and player_hint is not None and intent_part.lower() in _KIND_MARKER_WORDS: + return parse_command(text, _split_player_hint=False) + # Fallback: unstructured search — let mass.music.search figure out # the type. Force radio_mode=True so when the result is an artist or # a single track, MA starts a radio based on it instead of playing diff --git a/tests/providers/yandex_smarthome/test_dialogs.py b/tests/providers/yandex_smarthome/test_dialogs.py index 8f5b4088b4..39eff569be 100644 --- a/tests/providers/yandex_smarthome/test_dialogs.py +++ b/tests/providers/yandex_smarthome/test_dialogs.py @@ -656,6 +656,52 @@ async def test_button_press_resolves_pending(self) -> None: assert "pending_command" not in body_out["session_state"] assert body_out["session_state"]["last_player_id"] == "p1" + async def test_slot_elicit_with_hint_persists_player(self) -> None: + """'включи на кухне' (player set, no query) elicits + saves hinted player. + + Previously fell through to "Не нашёл такую музыку: ." — the user + clearly wants something, just didn't name it. Now elicits and + plays the follow-up on the hinted player without re-stating it. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + # Turn 1: "включи на кухне" — no query, hint=кухне + body1 = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи на кухне"}, + } + resp1 = await handler._handle_webhook(_build_request(body1)) + body_out1 = _response_body(resp1) + # Slot-elicit response with hinted player saved. + assert "Что включить" in body_out1["response"]["text"] + assert body_out1["session_state"]["awaiting_query"] is True + assert body_out1["session_state"]["awaiting_player_id"] == "p1" + assert body_out1["application_state"]["awaiting_player_id"] == "p1" + mass.player_queues.play_media.assert_not_awaited() + + # Turn 2: "Metallica" — should play on p1 (the saved hint) + body2 = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "Metallica"}, + "state": { + "session": { + "awaiting_query": True, + "awaiting_player_id": "p1", + }, + }, + } + await handler._handle_webhook(_build_request(body2)) + await asyncio.sleep(0) + mass.player_queues.play_media.assert_awaited_once() + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + async def test_slot_elicit_when_query_empty(self) -> None: """Bare verb (empty query) → 'Что включить?' + awaiting_query=True.""" mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) diff --git a/tests/providers/yandex_smarthome/test_dialogs_nlu.py b/tests/providers/yandex_smarthome/test_dialogs_nlu.py index 57159cbd7c..c187f0013c 100644 --- a/tests/providers/yandex_smarthome/test_dialogs_nlu.py +++ b/tests/providers/yandex_smarthome/test_dialogs_nlu.py @@ -1,3 +1,4 @@ +# ruff: noqa: RUF001, RUF003 """Tests for provider/dialogs_nlu.py — voice command parser + player resolver.""" from __future__ import annotations @@ -67,6 +68,16 @@ class TestParseCommand: ("найти джаз на кухне", "search", "джаз", "кухне", True), ("открой плейлист утренний джаз", "playlist", "утренний джаз", None, False), ("покажи альбом Black Album", "album", "black album", None, False), + # Suspicious-split detector — content title starts with "На …" + # so the trailing "на " must NOT be treated as a player hint. + # Without the detector "включи песню На заре" → query="песню", + # hint="заре" — wrong. + ("включи песню На заре", "track", "на заре", None, False), + ("включи альбом На заре", "album", "на заре", None, False), + ("включи плейлист На заре", "playlist", "на заре", None, False), + # Genuine " на " still works after the detector. + ("включи песню Yesterday на кухне", "track", "yesterday", "кухне", False), + ("включи песню", "search", "песню", None, True), # no hint, just a marker ], ) def test_parse( From 8f64f54daa40fc62b871332f97012e83dfe1c629 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 21:09:26 +0000 Subject: [PATCH 40/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.8.7 --- .../yandex_smarthome/dialogs_control.py | 17 ++++++++++++++ .../yandex_smarthome/test_dialogs_control.py | 23 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/music_assistant/providers/yandex_smarthome/dialogs_control.py b/music_assistant/providers/yandex_smarthome/dialogs_control.py index d051e2e61f..1e0fe1ce97 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_control.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_control.py @@ -253,6 +253,12 @@ async def execute_control( Errors are logged and swallowed — Alice has already been told the action was accepted; we don't have a channel to surface failures back into the same conversation. + + Note: ``list_players`` is a member of ``ControlAction`` for typing + convenience, but it's an *informational* query handled inline by + ``DialogsWebhookHandler._handle_control`` (which never calls this + function for it). The explicit branch below makes that contract + safe — a stray call won't silently no-op, it logs and returns. """ pid = player.player_id action = control.action @@ -278,6 +284,17 @@ async def execute_control( await mass.players.cmd_volume_mute(pid, True) elif action == "unmute": await mass.players.cmd_volume_mute(pid, False) + elif action == "list_players": + # Informational query — the handler builds the response + # text from a live `list_exposed_players(...)` call and + # never dispatches here. If we somehow got called for + # this action it's a caller bug, not something to silently + # ignore. + _LOGGER.warning( + "execute_control called with action='list_players'; " + "this is informational and should be handled by the " + "webhook handler, not dispatched here. Skipping.", + ) except asyncio.CancelledError: raise except Exception: diff --git a/tests/providers/yandex_smarthome/test_dialogs_control.py b/tests/providers/yandex_smarthome/test_dialogs_control.py index a1d4795938..b181e6a93f 100644 --- a/tests/providers/yandex_smarthome/test_dialogs_control.py +++ b/tests/providers/yandex_smarthome/test_dialogs_control.py @@ -3,6 +3,7 @@ from __future__ import annotations +import logging from unittest.mock import AsyncMock, MagicMock import pytest @@ -301,6 +302,28 @@ async def test_unmute(self) -> None: await execute_control(mass, ParsedControl(action="unmute"), self._player()) mass.players.cmd_volume_mute.assert_awaited_once_with("p1", False) + async def test_list_players_is_a_safe_noop_with_warning( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """`list_players` reaching execute_control logs a warning and no-ops. + + The handler is supposed to short-circuit `list_players` before + dispatch (it's an informational query), but the typing allows + it as a `ControlAction` so the explicit branch makes a stray + call safe rather than a silent no-op. + """ + mass = self._make_mass() + with caplog.at_level( + logging.WARNING, logger="music_assistant.providers.yandex_smarthome.dialogs_control" + ): + await execute_control(mass, ParsedControl(action="list_players"), self._player()) + # No MA command dispatched. + mass.player_queues.pause.assert_not_awaited() + mass.player_queues.resume.assert_not_awaited() + mass.players.cmd_volume_set.assert_not_awaited() + # Warning emitted. + assert any("list_players" in r.getMessage() for r in caplog.records) + async def test_underlying_failure_is_swallowed(self) -> None: """An exception from the MA call is logged + swallowed (no re-raise).""" mass = self._make_mass() From cf24e7fee891e9ad0c7e50bcf96a19e940e86d2c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 May 2026 05:06:23 +0000 Subject: [PATCH 41/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.8.8 --- .../providers/yandex_smarthome/dialogs.py | 132 ++++++++++++++++-- .../yandex_smarthome/test_dialogs.py | 108 +++++++++++++- 2 files changed, 225 insertions(+), 15 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index 4b8213fdaf..df22d5b290 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -38,6 +38,8 @@ import logging import re import secrets +import time +from collections import OrderedDict from collections.abc import Callable from typing import TYPE_CHECKING, Any @@ -188,6 +190,17 @@ def _parse_ordinal_choice(text: str) -> int | None: ) +# In-process state cache (TTL + LRU). Third-tier fallback when Yandex +# doesn't echo `state.session` / `state.application` back on the next +# turn — a documented quirk of Yandex Station devices in particular, +# where the disambiguation flow looped indefinitely until v1.8.8 added +# this cache. The cache is keyed by the most stable identifier from +# the request envelope (preference: `session.user.user_id` → +# `session.application.application_id` → `session.session_id`). +_STATE_CACHE_TTL_SEC = 300 # 5 min — Alice session inactivity timeout +_STATE_CACHE_MAX = 200 + + class DialogsWebhookHandler: """Handles incoming voice-command webhook calls from a Yandex Dialogs skill.""" @@ -217,6 +230,63 @@ def __init__( self._exposed_player_ids = exposed_player_ids self._logger = logger or _LOGGER self._unregister_callbacks: list[Callable[[], None]] = [] + # In-process state cache; see _STATE_CACHE_TTL_SEC / _MAX. + self._state_cache: OrderedDict[str, tuple[dict[str, Any], float]] = OrderedDict() + + def _cache_key(self, session: dict[str, Any]) -> str | None: + """Pick the most stable identifier for the in-process state cache. + + Preference order: ``session.user.user_id`` (per Yandex account, + most specific) → ``session.application.application_id`` (per + device) → ``session.session_id`` (per conversation). Returns + ``None`` if none are available — caller skips caching. + """ + user = session.get("user") + if isinstance(user, dict): + uid = user.get("user_id") + if isinstance(uid, str) and uid: + return f"user:{uid}" + app = session.get("application") + if isinstance(app, dict): + aid = app.get("application_id") + if isinstance(aid, str) and aid: + return f"app:{aid}" + sid = session.get("session_id") + if isinstance(sid, str) and sid: + return f"session:{sid}" + return None + + def _cache_get(self, session: dict[str, Any]) -> dict[str, Any]: + """Return the cached state for this caller, or {} if absent / expired.""" + key = self._cache_key(session) + if key is None: + return {} + entry = self._state_cache.get(key) + if entry is None: + return {} + state, ts = entry + if time.monotonic() - ts > _STATE_CACHE_TTL_SEC: + self._state_cache.pop(key, None) + return {} + # LRU touch. + self._state_cache.move_to_end(key) + return state + + def _cache_put(self, session: dict[str, Any], state: dict[str, Any]) -> None: + """Save state for this caller (LRU + TTL eviction). + + Pass an empty / cleared state dict (rather than skipping the + call) when the action explicitly drops pending/awaiting — this + ensures the cache reflects the post-action state and a stale + pending_command doesn't resurface on the next turn. + """ + key = self._cache_key(session) + if key is None: + return + self._state_cache[key] = (dict(state), time.monotonic()) + self._state_cache.move_to_end(key) + while len(self._state_cache) > _STATE_CACHE_MAX: + self._state_cache.popitem(last=False) def register_routes(self) -> None: """Register the webhook route on mass.webserver.""" @@ -285,36 +355,47 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: ) return web.Response(status=401) - # State buckets (P0.1 — replace in-memory LRU with Yandex state). + # State buckets. Three-tier read priority: + # 1. ``state.session`` — per-conversation, set by us last turn. + # 2. ``state.application`` — per-device, mirrored fallback. + # 3. In-process cache — server-side LRU keyed by user_id / + # application_id, last-resort for surfaces (notably the + # Yandex Station dev console emulator) that don't echo + # `state.*` back at all. state = body.get("state") or {} if not isinstance(state, dict): state = {} session_state_in = _safe_dict(state.get("session")) app_state_in = _safe_dict(state.get("application")) user_state_in = _safe_dict(state.get("user")) + cached_state = self._cache_get(session) default_id_raw = ( session_state_in.get("last_player_id") or app_state_in.get("last_player_id") or user_state_in.get("preferred_player_id") + or cached_state.get("last_player_id") ) default_id = str(default_id_raw) if default_id_raw else None is_new = bool(session.get("new")) command = str(req.get("command") or "").strip() - # Pending-command / awaiting-query lookups read from `state.session` - # first and fall back to `state.application`. Some Yandex devices - # (notably screenless Stations under certain settings) don't - # consistently echo `state.session` back across SimpleUtterance - # turns — the application tier is per-device, persists across - # session resets, and is honoured by every Yandex surface we've - # tested. Writes mirror to both buckets in the response builders. + # Pending-command / awaiting-query lookups follow the same + # three-tier order as default_id: session → application → + # in-process cache. Yandex Station devices in particular + # sometimes drop both `state.session` AND `state.application` + # between SimpleUtterance turns — the cache is what makes + # disambiguation actually work on those surfaces. pending_in = session_state_in.get("pending_command") if not isinstance(pending_in, dict): pending_in = app_state_in.get("pending_command") - awaiting_in = bool(session_state_in.get("awaiting_query")) or bool( - app_state_in.get("awaiting_query") + if not isinstance(pending_in, dict): + pending_in = cached_state.get("pending_command") + awaiting_in = ( + bool(session_state_in.get("awaiting_query")) + or bool(app_state_in.get("awaiting_query")) + or bool(cached_state.get("awaiting_query")) ) # Single summary log per incoming request — surfaces the wire-shape @@ -323,13 +404,15 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: # tokens and DEBUG is opt-in, so they're included as-is. self._logger.debug( "Webhook recv: cmd=%r req_type=%s is_new=%s pending=%s " - "(session=%s app=%s) awaiting=%s default_player=%s session_id=%s", + "(session=%s app=%s cache=%s) awaiting=%s default_player=%s " + "session_id=%s", command, req.get("type", "SimpleUtterance"), is_new, bool(pending_in), bool(session_state_in.get("pending_command")), bool(app_state_in.get("pending_command")), + bool(cached_state.get("pending_command")), awaiting_in, default_id, session.get("session_id", ""), @@ -386,8 +469,10 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: # that player. Surface it as `default_id` so the resolver picks # it without the user re-stating "на кухне". if awaiting_in and not default_id: - saved_pid = session_state_in.get("awaiting_player_id") or app_state_in.get( - "awaiting_player_id" + saved_pid = ( + session_state_in.get("awaiting_player_id") + or app_state_in.get("awaiting_player_id") + or cached_state.get("awaiting_player_id") ) if saved_pid: default_id = str(saved_pid) @@ -959,8 +1044,8 @@ async def _try_resume_pending( # Yandex Dialogs response envelope # ------------------------------------------------------------------- - @staticmethod def _yandex_response( + self, *, incoming_session: dict[str, Any], text: str, @@ -977,6 +1062,12 @@ def _yandex_response( Yandex spec; ``user_state_update`` is merged into the existing user-scoped state (set keys to None to clear). Omit a parameter to leave that bucket unchanged on Yandex's side. + + Side effect: any time we set ``session_state`` or + ``application_state``, the merged value is also written to the + in-process state cache as a third-tier fallback (see + ``_cache_put``). The cache is what makes disambiguation work + on Yandex Station devices that don't echo `state.*` back. """ # Yandex envelopes carry two user_id fields: the deprecated root # `session.user_id` (always present in current API revisions for @@ -1011,4 +1102,17 @@ def _yandex_response( payload["application_state"] = application_state if user_state_update is not None: payload["user_state_update"] = user_state_update + + # Mirror state into the in-process cache. Prefer session_state + # (most specific to the current conversation); fall back to + # application_state if only that was set. We store a merged + # snapshot so reads on the next turn pick up everything. + cache_state: dict[str, Any] = {} + if application_state is not None: + cache_state.update(application_state) + if session_state is not None: + cache_state.update(session_state) + if cache_state or session_state is not None or application_state is not None: + self._cache_put(incoming_session, cache_state) + return web.json_response(payload) diff --git a/tests/providers/yandex_smarthome/test_dialogs.py b/tests/providers/yandex_smarthome/test_dialogs.py index 39eff569be..fd34766169 100644 --- a/tests/providers/yandex_smarthome/test_dialogs.py +++ b/tests/providers/yandex_smarthome/test_dialogs.py @@ -5,6 +5,7 @@ import asyncio import json +import time from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock @@ -12,7 +13,11 @@ import pytest from aiohttp.test_utils import make_mocked_request -from music_assistant.providers.yandex_smarthome.dialogs import DialogsWebhookHandler, _tts_for +from music_assistant.providers.yandex_smarthome.dialogs import ( + _STATE_CACHE_TTL_SEC, + DialogsWebhookHandler, + _tts_for, +) if TYPE_CHECKING: from aiohttp import web @@ -1015,6 +1020,107 @@ async def test_ordinal_targets_unexposed_player_reasks(self) -> None: assert body_out["session_state"]["pending_command"]["candidate_ids"] == ["p1"] mass.player_queues.play_media.assert_not_awaited() + async def test_in_process_cache_recovers_when_yandex_drops_state(self) -> None: + """Reproduce the screenless-Station bug from the dev console transcript. + + Yandex doesn't echo `state.session` OR `state.application` back + on the next turn, despite us setting both on the previous + response. The in-process state cache (keyed by user.user_id / + application_id) is the third-tier fallback that recovers. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Проигрыватель"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + sess_common = { + "skill_id": "skill-uuid-1", + "user": {"user_id": "yandex-user-1"}, + "application": {"application_id": "yandex-app-1"}, + } + # Turn 1: disambig fires + saves cache entry. + await handler._handle_webhook( + _build_request( + { + "session": {**sess_common, "session_id": "s1", "new": False}, + "request": {"command": "включи джаз"}, + } + ) + ) + await asyncio.sleep(0) + cached = handler._cache_get( + { + "user": {"user_id": "yandex-user-1"}, + "application": {"application_id": "yandex-app-1"}, + } + ) + assert cached["pending_command"]["query"] == "джаз" + # Turn 2: NO `state` field in request — mimics dev-console emulator. + await handler._handle_webhook( + _build_request( + { + "session": {**sess_common, "session_id": "s1", "new": False}, + "request": {"command": "кухня"}, + } + ) + ) + await asyncio.sleep(0) + # Played the pending command (джаз) on p1 (Кухня). + mass.player_queues.play_media.assert_awaited_once() + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + + async def test_in_process_cache_resolves_via_ordinal(self) -> None: + """Same as above, but turn 2 says '2' (ordinal) — also resolves via cache.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Проигрыватель"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + sess_common = { + "skill_id": "skill-uuid-1", + "user": {"user_id": "yandex-user-1"}, + "application": {"application_id": "yandex-app-1"}, + } + await handler._handle_webhook( + _build_request( + { + "session": {**sess_common, "session_id": "s1", "new": False}, + "request": {"command": "включи джаз"}, + } + ) + ) + await asyncio.sleep(0) + await handler._handle_webhook( + _build_request( + { + "session": {**sess_common, "session_id": "s1", "new": False}, + "request": {"command": "2"}, + } + ) + ) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" + + async def test_in_process_cache_ttl_expiry(self) -> None: + """Cached state expires after `_STATE_CACHE_TTL_SEC`; later calls don't see it.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + # Inject an expired entry. + handler._state_cache["user:u1"] = ( + {"pending_command": {"kind": "search", "query": "old"}}, + time.monotonic() - _STATE_CACHE_TTL_SEC - 1, + ) + assert handler._cache_get({"user": {"user_id": "u1"}}) == {} + assert "user:u1" not in handler._state_cache + async def test_pending_command_falls_back_to_application_state(self) -> None: """Yandex didn't echo `state.session` but kept `state.application` — still resolves. From 1ad84a86e99fb07e8e17646aec2eb7ba4cc288ed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 May 2026 05:16:23 +0000 Subject: [PATCH 42/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.8.9 --- .../providers/yandex_smarthome/__init__.py | 22 +++++++++----- .../providers/yandex_smarthome/dialogs.py | 29 +++++++++++++------ 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index c0e014db14..990032af64 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -803,14 +803,20 @@ def _common_tail_entries( type=ConfigEntryType.STRING, label=f"Exposed Playlists (max {MAX_INPUT_SOURCES})", description=( - "Pick up to " - f"{MAX_INPUT_SOURCES} playlists from your MA library — they appear " - "as input_source mode slots one..ten on every exposed player, in the " - "order you select them. Alice triggers them by ordinal only " - "(«Alice, switch source to five»); the Yandex Smart Home " - "API does not allow naming mode values, so remember the order you " - "picked. If the list is empty, save the form and reopen it once " - "your music providers have finished loading their library." + f"Pick up to {MAX_INPUT_SOURCES} playlists from your MA library — " + "they fill the input_source mode slots after each player's native " + "sources. The total slot count is capped at " + f"{MAX_INPUT_SOURCES}: native sources go first (in the player's own " + "order), then playlists fill the remainder in the order you select " + "them here. A player that already exposes " + f"{MAX_INPUT_SOURCES} or more native sources gets none of the " + "picked playlists, and the playlist→slot mapping varies between " + "players that have different native-source counts. Alice triggers " + "slots by ordinal only («Alice, switch source to five»); " + "the Yandex Smart Home API does not allow naming mode values, so " + "remember the order you picked. If the list is empty, save the " + "form and reopen it once your music providers have finished " + "loading their library." ), required=False, multi_value=True, diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index df22d5b290..a602521344 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -25,11 +25,20 @@ 401 timing) and with 401 if the skill_id doesn't match (configured skill received a payload from a different skill — should never happen). -Session memory: the handler does not keep any in-process LRU. The -"last player used" default is round-tripped through Yandex's three -state buckets (priority: ``state.session`` → ``state.application`` → -``state.user``), which survives plugin reloads and even MA restarts -for the application/user tiers. +Session memory: three-tier strategy. + + 1. Yandex state envelope — primary. ``state.session`` (per + conversation), ``state.application`` (per device), ``state.user`` + (per Yandex account, only when account-linked). The application + and user tiers persist across plugin reloads and MA restarts. + 2. In-process cache — third-tier fallback for surfaces that don't + reliably echo the state envelope back (notably some Yandex + Station configurations). LRU keyed by ``user.user_id`` → + ``application.application_id`` → ``session_id``, with a 5-min + TTL matching Alice's session inactivity timeout. + +Reads check tiers in the order above. Writes mirror to all +applicable tiers via ``_yandex_response``. """ from __future__ import annotations @@ -192,10 +201,12 @@ def _parse_ordinal_choice(text: str) -> int | None: # In-process state cache (TTL + LRU). Third-tier fallback when Yandex # doesn't echo `state.session` / `state.application` back on the next -# turn — a documented quirk of Yandex Station devices in particular, -# where the disambiguation flow looped indefinitely until v1.8.8 added -# this cache. The cache is keyed by the most stable identifier from -# the request envelope (preference: `session.user.user_id` → +# turn — a quirk reproduced from the Yandex Station dev-console +# emulator, where the request body for every turn after the first +# arrived without any `state.*` field at all. Without this cache the +# disambiguation flow would loop indefinitely on those surfaces. +# The cache is keyed by the most stable identifier from the request +# envelope (preference: `session.user.user_id` → # `session.application.application_id` → `session.session_id`). _STATE_CACHE_TTL_SEC = 300 # 5 min — Alice session inactivity timeout _STATE_CACHE_MAX = 200 From 65266bdf5dc5a2ecfcac4bd24efba9b62763e639 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 May 2026 05:41:28 +0000 Subject: [PATCH 43/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.8.10 --- .../providers/yandex_smarthome/dialogs.py | 27 +++++++++++ .../yandex_smarthome/dialogs_control.py | 24 ++++++++++ .../yandex_smarthome/test_dialogs.py | 46 +++++++++++++++++++ .../yandex_smarthome/test_dialogs_control.py | 10 ++++ 4 files changed, 107 insertions(+) diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index a602521344..83124d6dab 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -683,6 +683,33 @@ def _handle_control( session_state=session_state_in, ) + # forget_player clears the saved default-player from all three + # state tiers (session / application / cache) AND emits a + # `user_state_update` with `preferred_player_id: None` so the + # next play command without an explicit hint asks the user to + # pick again. Doesn't need a target — purely state management. + if control.action == "forget_player": + self._logger.info("Control forget_player → clearing last_player_id from all tiers") + new_session_state = {k: v for k, v in session_state_in.items() if k != "last_player_id"} + new_app_state = {k: v for k, v in app_state_in.items() if k != "last_player_id"} + user_obj_forget = session.get("user") or {} + user_state_update_forget: dict[str, Any] | None = None + if isinstance(user_obj_forget, dict) and user_obj_forget.get("user_id"): + # Yandex spec: a key set to None in `user_state_update` + # tells the platform to delete it from the merged + # user-scoped state. + user_state_update_forget = {"preferred_player_id": None} + text = control_confirmation(control) + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=new_session_state, + application_state=new_app_state, + user_state_update=user_state_update_forget, + ) + player = resolve_player( self._mass, control.player_hint, diff --git a/music_assistant/providers/yandex_smarthome/dialogs_control.py b/music_assistant/providers/yandex_smarthome/dialogs_control.py index 1e0fe1ce97..61eb3c6072 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_control.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_control.py @@ -35,6 +35,7 @@ "mute", "unmute", "list_players", + "forget_player", ] @@ -72,6 +73,18 @@ class ParsedControl: (re.compile(r"^список\s+колонок$", re.IGNORECASE), "list_players"), (re.compile(r"^покажи\s+колонки$", re.IGNORECASE), "list_players"), (re.compile(r"^назови\s+колонки$", re.IGNORECASE), "list_players"), + # forget_player — clears the saved "default player" so the next + # play command without an explicit hint asks again. Useful when + # the user previously picked a player and now wants to change + # without re-stating the name on every turn. + (re.compile(r"^забудь\s+колонку$", re.IGNORECASE), "forget_player"), + (re.compile(r"^сбрось\s+колонку$", re.IGNORECASE), "forget_player"), + (re.compile(r"^забудь\s+плеер$", re.IGNORECASE), "forget_player"), + (re.compile(r"^забудь\s+выбор$", re.IGNORECASE), "forget_player"), + (re.compile(r"^сбрось\s+выбор$", re.IGNORECASE), "forget_player"), + (re.compile(r"^выбери\s+колонку\s+заново$", re.IGNORECASE), "forget_player"), + (re.compile(r"^поменяй\s+колонку$", re.IGNORECASE), "forget_player"), + (re.compile(r"^сменить\s+колонку$", re.IGNORECASE), "forget_player"), # mute / unmute — explicit "звук" disambiguates from play-verb "включи" (re.compile(r"^включи\s+звук$", re.IGNORECASE), "unmute"), (re.compile(r"^сделай\s+звук$", re.IGNORECASE), "unmute"), @@ -239,6 +252,8 @@ def control_confirmation(control: ParsedControl) -> str: return "Звук выключен." if action == "unmute": return "Звук включен." + if action == "forget_player": + return "Хорошо, забыл колонку. В следующий раз спрошу." # list_players (the only remaining action; Literal is exhaustive) return "Готово." # placeholder; handler computes the real text @@ -295,6 +310,15 @@ async def execute_control( "this is informational and should be handled by the " "webhook handler, not dispatched here. Skipping.", ) + elif action == "forget_player": + # State-management query — the handler clears the cached + # default-player from session/application/cache state and + # never dispatches here. Defensive branch. + _LOGGER.warning( + "execute_control called with action='forget_player'; " + "this is a state-management op handled by the webhook " + "handler, not dispatched here. Skipping.", + ) except asyncio.CancelledError: raise except Exception: diff --git a/tests/providers/yandex_smarthome/test_dialogs.py b/tests/providers/yandex_smarthome/test_dialogs.py index fd34766169..d8f15ef81f 100644 --- a/tests/providers/yandex_smarthome/test_dialogs.py +++ b/tests/providers/yandex_smarthome/test_dialogs.py @@ -509,6 +509,52 @@ async def test_control_unknown_player_asks_for_clarification(self) -> None: body_out = _response_body(resp) assert "Не нашёл колонку «гостиной»" in body_out["response"]["text"] + async def test_forget_player_clears_state_tiers(self) -> None: + """'забудь колонку' clears last_player_id from session/application/cache. + + After the user picks a player via disambiguation, every later play + command without an explicit hint plays on it (by design — sticky + default for ergonomics). Saying 'забудь колонку' resets that so + the next ambiguous command asks again. + """ + mass = self._setup_mass_with_control_methods( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + # Pre-seed cache with a stale default-player. + handler._state_cache["user:u1"] = ( + {"last_player_id": "p1"}, + time.monotonic(), + ) + body = { + "session": { + "skill_id": "skill-uuid-1", + "session_id": "s1", + "new": False, + "user": {"user_id": "u1"}, + }, + "request": {"command": "забудь колонку"}, + "state": { + "session": {"last_player_id": "p1"}, + "application": {"last_player_id": "p1"}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert "Хорошо" in body_out["response"]["text"] + # last_player_id removed from session and application state. + assert "last_player_id" not in body_out["session_state"] + assert "last_player_id" not in body_out["application_state"] + # user_state_update sets preferred_player_id to None (Yandex + # protocol: None = delete the key from merged user state). + assert body_out["user_state_update"] == {"preferred_player_id": None} + # Cache rewritten with no last_player_id. + cached = handler._cache_get({"user": {"user_id": "u1"}}) + assert "last_player_id" not in cached + async def test_list_players_returns_player_names(self) -> None: """'сколько колонок видишь' → response with the count and names of exposed players.""" mass = self._setup_mass_with_control_methods( diff --git a/tests/providers/yandex_smarthome/test_dialogs_control.py b/tests/providers/yandex_smarthome/test_dialogs_control.py index b181e6a93f..28d3742d2c 100644 --- a/tests/providers/yandex_smarthome/test_dialogs_control.py +++ b/tests/providers/yandex_smarthome/test_dialogs_control.py @@ -89,6 +89,16 @@ class TestParseControl: ("список колонок", "list_players", None, None), ("покажи колонки", "list_players", None, None), ("назови колонки", "list_players", None, None), + # forget_player — clears the saved default-player so the + # next play command without a hint asks again. + ("забудь колонку", "forget_player", None, None), + ("сбрось колонку", "forget_player", None, None), + ("забудь плеер", "forget_player", None, None), + ("забудь выбор", "forget_player", None, None), + ("сбрось выбор", "forget_player", None, None), + ("выбери колонку заново", "forget_player", None, None), + ("поменяй колонку", "forget_player", None, None), + ("сменить колонку", "forget_player", None, None), # alice prefix tolerated ("Алиса, пауза", "pause", None, None), ], From 2fddc2d74e7cef322cc9265510a7d3165be022da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 May 2026 06:28:44 +0000 Subject: [PATCH 44/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v1.9.1 --- .../providers/yandex_smarthome/dialogs.py | 156 +++++++++++- .../yandex_smarthome/dialogs_control.py | 155 +++++++++++- .../providers/yandex_smarthome/dialogs_nlu.py | 47 +++- .../yandex_smarthome/dialogs_player.py | 39 ++- .../yandex_smarthome/test_dialogs.py | 232 ++++++++++++++++++ .../yandex_smarthome/test_dialogs_control.py | 138 +++++++++++ .../yandex_smarthome/test_dialogs_nlu.py | 14 ++ 7 files changed, 765 insertions(+), 16 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py index 83124d6dab..ff356de1a2 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ b/music_assistant/providers/yandex_smarthome/dialogs.py @@ -656,7 +656,7 @@ async def _dispatch_play( # Control execution helper (P0.6) # ------------------------------------------------------------------- - def _handle_control( + def _handle_control( # noqa: PLR0915 self, *, session: dict[str, Any], @@ -683,6 +683,135 @@ def _handle_control( session_state=session_state_in, ) + # now_playing — info query about the current track. Reads + # `mass.player_queues.get(pid).current_item.name` (already + # pre-formatted as "Artist - Title" or stream title for radio). + if control.action == "now_playing": + target_player = resolve_player( + self._mass, + control.player_hint, + default_id=default_id, + exposed_ids=self._exposed_player_ids, + ) + if target_player is None: + if control.player_hint: + text = f"Не нашёл колонку «{control.player_hint}». Скажи, например: на кухне." + else: + text = "Скажи, на какой колонке. Например: что играет на кухне." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + queue = None + try: + queue = self._mass.player_queues.get(target_player.player_id) + except Exception: + self._logger.debug("player_queues.get failed", exc_info=True) + current = getattr(queue, "current_item", None) if queue is not None else None + current_name = getattr(current, "name", None) if current is not None else None + display_name = target_player.name or target_player.player_id + if current_name: + text = f"Сейчас играет: {current_name}." + else: + text = f"На {display_name} сейчас ничего не играет." + self._logger.debug( + "Control now_playing on %s → %r", + target_player.player_id, + current_name, + ) + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + + # transfer — moves the queue from the saved default player to + # the named target player. SOURCE = `default_id` (last-used); + # TARGET = `control.player_hint` (parsed from "переведи на X"). + if control.action == "transfer": + if not default_id: + text = "Не понял, откуда переводить. Сначала включи музыку на колонке." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + target_candidates = resolve_player_candidates( + self._mass, + control.player_hint, + default_id=None, + exposed_ids=self._exposed_player_ids, + ) + if not target_candidates: + hint = control.player_hint or "(не указано)" + text = f"Не нашёл колонку «{hint}». Скажи, например: на кухне." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + if len(target_candidates) > 1: + # Multi-match: reuse the disambiguation flow, but the + # pending intent for replay is "transfer this queue". + # We don't currently support resuming a transfer through + # `_try_resume_pending` (it's coupled to play intent), + # so for now just respond with a clarification. + names = ", ".join(p.name or p.player_id for p in target_candidates[:5]) + text = f"Не понял на какую колонку. Уточни: {names}?" + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + target = target_candidates[0] + target_name = target.name or target.player_id + if target.player_id == default_id: + text = f"Уже играет на {target_name}." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + self._logger.info( + "Control transfer: %s → %s", + default_id, + target.player_id, + ) + self._mass.create_task( + self._mass.player_queues.transfer_queue( + source_queue_id=default_id, + target_queue_id=target.player_id, + ) + ) + new_session_state = {**session_state_in, "last_player_id": target.player_id} + new_app_state = {**app_state_in, "last_player_id": target.player_id} + user_obj_t = session.get("user") or {} + user_state_update_t: dict[str, Any] | None = None + if isinstance(user_obj_t, dict) and user_obj_t.get("user_id"): + user_state_update_t = {"preferred_player_id": target.player_id} + text = f"Перевожу на {target_name}." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + session_state=new_session_state, + application_state=new_app_state, + user_state_update=user_state_update_t, + ) + # forget_player clears the saved default-player from all three # state tiers (session / application / cache) AND emits a # `user_state_update` with `preferred_player_id: None` so the @@ -819,6 +948,7 @@ async def _play_with_player( player.player_id, media, radio_mode=parsed.radio_mode, + enqueue_option=parsed.enqueue_option, ) ) @@ -839,7 +969,13 @@ async def _play_with_player( user_state_update = {"preferred_player_id": player.player_id} spoken_query = parsed.query or "музыку" - text = f"Включаю {spoken_query} на {player.name or player.player_id}." + player_label = player.name or player.player_id + if parsed.enqueue_option == "add": + text = f"Добавил {spoken_query} в очередь на {player_label}." + elif parsed.enqueue_option == "next": + text = f"Поставил {spoken_query} следующим на {player_label}." + else: + text = f"Включаю {spoken_query} на {player_label}." return self._yandex_response( incoming_session=session, text=text, @@ -898,7 +1034,7 @@ def _build_disambiguation_response( # per-device — it survives session resets and is honoured on # every surface we've tested. Reads in `_handle_webhook` merge # the two tiers (session preferred, application as fallback). - pending_command = { + pending_command: dict[str, Any] = { "kind": parsed.kind, "query": parsed.query[:200], "radio_mode": parsed.radio_mode, @@ -910,6 +1046,12 @@ def _build_disambiguation_response( # disambiguation set. "candidate_ids": [p.player_id for p in capped], } + # Preserve the enqueue option (add / next) across the + # disambiguation re-entry — without this an ambiguous + # "добавь Iron Maiden" would resume as REPLACE after the + # user picks the player, defeating the add-to-queue intent. + if parsed.enqueue_option is not None: + pending_command["enqueue_option"] = parsed.enqueue_option new_session_state = { **_without_pending(session_state_in), "pending_command": pending_command, @@ -952,6 +1094,11 @@ async def _try_resume_pending( if isinstance(candidate_ids_raw, list) else [] ) + # Preserve enqueue intent (add / next) across the disambiguation + # re-entry; otherwise an ambiguous "добавь Iron Maiden" would + # replay as REPLACE after the user picks a player. + enqueue_raw = pending.get("enqueue_option") + pending_enqueue: str | None = enqueue_raw if isinstance(enqueue_raw, str) else None exposed = list_exposed_players(self._mass, exposed_ids=self._exposed_player_ids) exposed_by_id = {p.player_id: p for p in exposed} @@ -1007,6 +1154,7 @@ async def _try_resume_pending( kind=str(pending.get("kind", "search")), # type: ignore[arg-type] query=str(pending.get("query", "")), radio_mode=bool(pending.get("radio_mode", False)), + enqueue_option=pending_enqueue, # type: ignore[arg-type] ), candidates=candidates, session_state_in=session_state_in, @@ -1055,6 +1203,7 @@ async def _try_resume_pending( kind=str(pending.get("kind", "search")), # type: ignore[arg-type] query=str(pending.get("query", "")), radio_mode=bool(pending.get("radio_mode", False)), + enqueue_option=pending_enqueue, # type: ignore[arg-type] ), candidates=still_available, session_state_in=session_state_in, @@ -1069,6 +1218,7 @@ async def _try_resume_pending( kind=str(pending.get("kind", "search")), # type: ignore[arg-type] query=str(pending.get("query", "")), radio_mode=bool(pending.get("radio_mode", False)), + enqueue_option=pending_enqueue, # type: ignore[arg-type] ) return await self._play_with_player( session=session, diff --git a/music_assistant/providers/yandex_smarthome/dialogs_control.py b/music_assistant/providers/yandex_smarthome/dialogs_control.py index 61eb3c6072..f3d7c0e338 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_control.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_control.py @@ -15,6 +15,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal +from music_assistant_models.enums import RepeatMode + from .dialogs_nlu import _PUNCT_RE, _SPACE_RE if TYPE_CHECKING: @@ -36,6 +38,17 @@ "unmute", "list_players", "forget_player", + # v1.9.0 — six new actions + "now_playing", # info — handler reads queue.current_item.name + "shuffle_on", + "shuffle_off", + "repeat_off", + "repeat_one", + "repeat_all", + "seek_forward", # value = positive seconds + "seek_back", # value = positive seconds; negated when dispatched + "seek_start", # absolute seek to 0 + "transfer", # player_hint = TARGET player; SOURCE is the saved default ] @@ -85,6 +98,42 @@ class ParsedControl: (re.compile(r"^выбери\s+колонку\s+заново$", re.IGNORECASE), "forget_player"), (re.compile(r"^поменяй\s+колонку$", re.IGNORECASE), "forget_player"), (re.compile(r"^сменить\s+колонку$", re.IGNORECASE), "forget_player"), + # now_playing — info query about the current track (no MA mutation) + (re.compile(r"^что\s+(?:сейчас\s+)?играет$", re.IGNORECASE), "now_playing"), + (re.compile(r"^что\s+(?:мы\s+)?слушаем$", re.IGNORECASE), "now_playing"), + (re.compile(r"^что\s+за\s+(?:песня|трек|композиция)$", re.IGNORECASE), "now_playing"), + (re.compile(r"^какой\s+(?:сейчас\s+)?(?:трек|играет)$", re.IGNORECASE), "now_playing"), + # shuffle_on / shuffle_off + (re.compile(r"^перемешай$", re.IGNORECASE), "shuffle_on"), + (re.compile(r"^включи\s+перемешивание$", re.IGNORECASE), "shuffle_on"), + (re.compile(r"^случайный\s+порядок$", re.IGNORECASE), "shuffle_on"), + (re.compile(r"^в\s+случайном\s+порядке$", re.IGNORECASE), "shuffle_on"), + (re.compile(r"^выключи\s+перемешивание$", re.IGNORECASE), "shuffle_off"), + (re.compile(r"^не\s+перемешивай$", re.IGNORECASE), "shuffle_off"), + (re.compile(r"^по\s+порядку$", re.IGNORECASE), "shuffle_off"), + # repeat — order matters: more-specific (with object) first, then bare verbs + ( + re.compile( + r"^повтор(?:и)?\s+(?:песн[июя]|трек(?:а)?|композицию|композиция|эту|эту\s+песню)$", + re.IGNORECASE, + ), + "repeat_one", + ), + ( + re.compile( + r"^повтор(?:и)?\s+(?:всё|все|очередь|плейлист|список)$", + re.IGNORECASE, + ), + "repeat_all", + ), + (re.compile(r"^повторяй$", re.IGNORECASE), "repeat_all"), + (re.compile(r"^включи\s+повтор$", re.IGNORECASE), "repeat_all"), + (re.compile(r"^выключи\s+повтор$", re.IGNORECASE), "repeat_off"), + (re.compile(r"^не\s+повторяй$", re.IGNORECASE), "repeat_off"), + # seek_start — absolute seek to position 0 (start of current track) + (re.compile(r"^(?:перемотай\s+)?к\s+началу$", re.IGNORECASE), "seek_start"), + (re.compile(r"^(?:перемотай\s+)?в\s+начало$", re.IGNORECASE), "seek_start"), + (re.compile(r"^начни\s+(?:трек\s+)?заново$", re.IGNORECASE), "seek_start"), # mute / unmute — explicit "звук" disambiguates from play-verb "включи" (re.compile(r"^включи\s+звук$", re.IGNORECASE), "unmute"), (re.compile(r"^сделай\s+звук$", re.IGNORECASE), "unmute"), @@ -130,6 +179,40 @@ class ParsedControl: re.IGNORECASE, ) +# Seek forward / backward with numeric amount + optional unit. Unit defaults +# to seconds when missing. "Минут[уы]" multiplies by 60. +_SEEK_FORWARD_RE = re.compile( + r"^(?:перемотай\s+|перемотать\s+|промотай\s+)?" + r"(?:вперёд|вперед)\s+(?:на\s+)?(?P\d{1,4})" + r"(?:\s+(?Pсек(?:унд[уы]?)?|мин(?:ут[уы]?)?))?$", + re.IGNORECASE, +) +_SEEK_BACK_RE = re.compile( + r"^(?:перемотай\s+|перемотать\s+|промотай\s+)?" + r"назад\s+(?:на\s+)?(?P\d{1,4})" + r"(?:\s+(?Pсек(?:унд[уы]?)?|мин(?:ут[уы]?)?))?$", + re.IGNORECASE, +) + +# Transfer playback to a target player. The target name is captured into +# `player_hint`; SOURCE comes from the caller's `default_id`. +_TRANSFER_RE = re.compile( + r"^(?:переведи|перенеси|продолжи)\s+(?:музыку\s+)?(?:на|в)\s+(?P.+)$", + re.IGNORECASE, +) + + +def _seek_seconds(match: re.Match[str]) -> int | None: + """Parse the digit + optional unit out of a seek-pattern match.""" + try: + n = int(match.group("n")) + except (TypeError, ValueError): + return None + unit = (match.group("unit") or "").lower() + if unit.startswith("мин"): + n *= 60 + return n + def _try_match(cleaned: str, player_hint: str | None) -> ParsedControl | None: """Match `cleaned` against control patterns; return ParsedControl or None.""" @@ -145,6 +228,23 @@ def _try_match(cleaned: str, player_hint: str | None) -> ParsedControl | None: value=max(0, min(100, value)), player_hint=player_hint, ) + if smatch := _SEEK_FORWARD_RE.match(cleaned): + seconds = _seek_seconds(smatch) + if seconds is not None: + return ParsedControl(action="seek_forward", value=seconds, player_hint=player_hint) + if smatch := _SEEK_BACK_RE.match(cleaned): + seconds = _seek_seconds(smatch) + if seconds is not None: + return ParsedControl(action="seek_back", value=seconds, player_hint=player_hint) + if tmatch := _TRANSFER_RE.match(cleaned): + # For transfer, the captured group goes into `player_hint` — + # it's the TARGET. The handler resolves it; SOURCE is `default_id`. + # `player_hint` from the caller's "на " suffix split is + # ignored here (transfer phrases already include the target). + return ParsedControl( + action="transfer", + player_hint=tmatch.group("target").strip().lower(), + ) for pattern, action in _CONTROL_PATTERNS: if pattern.match(cleaned): return ParsedControl(action=action, player_hint=player_hint) @@ -225,7 +325,7 @@ def format_list_players(players: list[Any]) -> str: return f"Вижу {n} {word}: {names}." -def control_confirmation(control: ParsedControl) -> str: +def control_confirmation(control: ParsedControl) -> str: # noqa: PLR0911 """User-facing confirmation text for a control action. Caveat: ``list_players`` is **not** confirmed here — the handler builds @@ -254,11 +354,28 @@ def control_confirmation(control: ParsedControl) -> str: return "Звук включен." if action == "forget_player": return "Хорошо, забыл колонку. В следующий раз спрошу." - # list_players (the only remaining action; Literal is exhaustive) - return "Готово." # placeholder; handler computes the real text - - -async def execute_control( + if action == "shuffle_on": + return "Включил перемешивание." + if action == "shuffle_off": + return "Выключил перемешивание." + if action == "repeat_off": + return "Выключил повтор." + if action == "repeat_one": + return "Повтор песни." + if action == "repeat_all": + return "Повтор очереди." + if action == "seek_forward": + return f"Перемотал на {control.value} секунд вперёд." + if action == "seek_back": + return f"Перемотал на {control.value} секунд назад." + if action == "seek_start": + return "Перемотал к началу." + # list_players / now_playing / transfer — handler computes the real + # text (live data) and never calls this. Placeholder for safety. + return "Готово." + + +async def execute_control( # noqa: PLR0915 mass: MusicAssistant, control: ParsedControl, player: Any, @@ -319,6 +436,32 @@ async def execute_control( "this is a state-management op handled by the webhook " "handler, not dispatched here. Skipping.", ) + elif action == "shuffle_on": + await mass.player_queues.set_shuffle(pid, shuffle_enabled=True) + elif action == "shuffle_off": + await mass.player_queues.set_shuffle(pid, shuffle_enabled=False) + elif action == "repeat_off": + # NB: set_repeat is sync, not async — do NOT await. + mass.player_queues.set_repeat(pid, RepeatMode.OFF) + elif action == "repeat_one": + mass.player_queues.set_repeat(pid, RepeatMode.ONE) + elif action == "repeat_all": + mass.player_queues.set_repeat(pid, RepeatMode.ALL) + elif action == "seek_forward": + await mass.player_queues.skip(pid, seconds=control.value or 0) + elif action == "seek_back": + await mass.player_queues.skip(pid, seconds=-(control.value or 0)) + elif action == "seek_start": + await mass.player_queues.seek(pid, position=0) + elif action in ("now_playing", "transfer"): + # Live-data / multi-player actions — the handler builds the + # response from queue.current_item / transfer_queue and + # never dispatches here. Defensive branch. + _LOGGER.warning( + "execute_control called with action=%r — handled by webhook " + "handler, not dispatched here. Skipping.", + action, + ) except asyncio.CancelledError: raise except Exception: diff --git a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py index 36d37df21b..76d98042cd 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py @@ -26,6 +26,9 @@ CommandKind = Literal["track", "artist", "album", "playlist", "my_wave", "genre", "search"] +EnqueueOption = Literal["replace", "next", "add"] + + @dataclass(frozen=True, slots=True) class ParsedCommand: """Result of classifying a Yandex Dialogs voice command.""" @@ -34,6 +37,12 @@ class ParsedCommand: query: str player_hint: str | None = None radio_mode: bool = False + # When set, `play_for_alice` passes a matching `QueueOption` to + # `mass.player_queues.play_media` so the new media is added to / + # inserted into the existing queue instead of replacing it. Default + # `None` keeps the historical REPLACE behaviour (start playing + # immediately, replacing the current queue). + enqueue_option: EnqueueOption | None = None # --------------------------------------------------------------------------- @@ -72,6 +81,15 @@ class ParsedCommand: re.IGNORECASE, ) +# Enqueue verbs — when one of these is the command's leading verb, set +# `enqueue_option="add"` (or "next") on the resulting ParsedCommand +# instead of the default REPLACE-the-queue behaviour. The verb is +# stripped from the rest of the command exactly like `_VERB_RE` does. +_ENQUEUE_VERB_RE = re.compile( + r"^(?:алиса[, ]+)?(?:добавь(?:те)?|добавить)(?:\s+|$)", + re.IGNORECASE, +) + # Type prefixes inside the intent part. Order matters: longer keywords first. _KIND_RULES: tuple[tuple[re.Pattern[str], CommandKind, bool], ...] = ( # my_wave / personal radio wave — no query, the verb is everything @@ -162,21 +180,40 @@ def parse_command(text: str, *, _split_player_hint: bool = True) -> ParsedComman player_hint = match.group("hint").strip().lower() cleaned = cleaned[: match.start()].strip() + # Detect enqueue-verb prefix ("добавь X") BEFORE the regular verb + # strip so we know to set `enqueue_option="add"`. The verb itself + # is stripped here so the regular `_VERB_RE.sub` below has nothing + # to do — the residual is the kind+query intent part. + enqueue_option: EnqueueOption | None = None + if enq_match := _ENQUEUE_VERB_RE.match(cleaned): + enqueue_option = "add" + cleaned = cleaned[enq_match.end() :].strip() + # Strip the imperative verb at the start (e.g. "play this", "turn on that"). + # No-op if `_ENQUEUE_VERB_RE` already consumed the verb. intent_part = _VERB_RE.sub("", cleaned).strip() if not intent_part: - return ParsedCommand(kind="search", query="", player_hint=player_hint) + return ParsedCommand( + kind="search", + query="", + player_hint=player_hint, + enqueue_option=enqueue_option, + ) # Try kind rules in order. for pattern, kind, radio in _KIND_RULES: if rule_match := pattern.match(intent_part): query = rule_match.group(1).strip() if rule_match.groups() else "" + # For add-to-queue the "radio mode" intent is incoherent + # (you don't add a station, you add a track). Force off. + effective_radio = False if enqueue_option == "add" else radio return ParsedCommand( kind=kind, query=query.lower(), player_hint=player_hint, - radio_mode=radio, + radio_mode=effective_radio, + enqueue_option=enqueue_option, ) # Suspicious-split detector: when a player_hint was extracted AND @@ -192,12 +229,14 @@ def parse_command(text: str, *, _split_player_hint: bool = True) -> ParsedComman # the type. Force radio_mode=True so when the result is an artist or # a single track, MA starts a radio based on it instead of playing # one item and stopping (matches the typical user expectation - # "включи " → "play music"). + # "включи " → "play music"). For add-to-queue, radio_mode + # is incoherent — force off. return ParsedCommand( kind="search", query=intent_part.lower(), player_hint=player_hint, - radio_mode=True, + radio_mode=enqueue_option != "add", + enqueue_option=enqueue_option, ) diff --git a/music_assistant/providers/yandex_smarthome/dialogs_player.py b/music_assistant/providers/yandex_smarthome/dialogs_player.py index 7e12b53cdd..9598f7cbc1 100644 --- a/music_assistant/providers/yandex_smarthome/dialogs_player.py +++ b/music_assistant/providers/yandex_smarthome/dialogs_player.py @@ -15,7 +15,7 @@ import logging from typing import TYPE_CHECKING, Any -from music_assistant_models.enums import MediaType +from music_assistant_models.enums import MediaType, QueueOption from .dialogs_nlu import _normalize_player_token @@ -258,14 +258,38 @@ async def _resolve_genre(mass: MusicAssistant, query: str) -> MediaItemType | st # --------------------------------------------------------------------------- +_ENQUEUE_TO_QUEUE_OPTION: dict[str, QueueOption] = { + "add": QueueOption.ADD, + "next": QueueOption.NEXT, + "replace": QueueOption.REPLACE, +} + + async def play_for_alice( mass: MusicAssistant, player_id: str, media: MediaItemType | str, *, radio_mode: bool = False, + enqueue_option: str | None = None, ) -> None: - """Power the player on if needed, then start playback via player_queues.""" + """Power the player on if needed, then start playback via player_queues. + + ``enqueue_option`` (None / "replace" / "next" / "add") is mapped to + the matching :class:`QueueOption` and forwarded to + ``mass.player_queues.play_media``. ``None`` lets MA pick the + per-media-type default (typically REPLACE) — the historical + behaviour. + + Power-on policy: regardless of ``enqueue_option``, an off player + gets ``cmd_power(True)``. Voice intent is unambiguous — the user + just asked for music, so a player that's been off needs to wake up. + MA's ``play_media`` will then sequence ADD/NEXT correctly (queue + grows; playback may or may not start depending on current queue + state). If the user wants to enqueue without disturbing playback + on a different player, they should name that other player + explicitly via the ``на `` suffix. + """ player = mass.players.get_player(player_id) if player is not None and _has_feature(player, "power"): powered = getattr(player, "powered", None) @@ -277,4 +301,13 @@ async def play_for_alice( except Exception as exc: _LOGGER.warning("cmd_power(True) on %s failed: %s", player_id, exc) - await mass.player_queues.play_media(queue_id=player_id, media=media, radio_mode=radio_mode) + play_kwargs: dict[str, Any] = { + "queue_id": player_id, + "media": media, + "radio_mode": radio_mode, + } + if enqueue_option is not None: + mapped = _ENQUEUE_TO_QUEUE_OPTION.get(enqueue_option) + if mapped is not None: + play_kwargs["option"] = mapped + await mass.player_queues.play_media(**play_kwargs) diff --git a/tests/providers/yandex_smarthome/test_dialogs.py b/tests/providers/yandex_smarthome/test_dialogs.py index d8f15ef81f..a771c56919 100644 --- a/tests/providers/yandex_smarthome/test_dialogs.py +++ b/tests/providers/yandex_smarthome/test_dialogs.py @@ -12,6 +12,7 @@ import pytest from aiohttp.test_utils import make_mocked_request +from music_assistant_models.enums import QueueOption, RepeatMode from music_assistant.providers.yandex_smarthome.dialogs import ( _STATE_CACHE_TTL_SEC, @@ -440,6 +441,12 @@ def _setup_mass_with_control_methods(self, players: list[MockPlayer]) -> MagicMo mass.player_queues.stop = AsyncMock() mass.player_queues.next = AsyncMock() mass.player_queues.previous = AsyncMock() + mass.player_queues.set_shuffle = AsyncMock() + mass.player_queues.set_repeat = MagicMock() # NB: sync + mass.player_queues.skip = AsyncMock() + mass.player_queues.seek = AsyncMock() + mass.player_queues.transfer_queue = AsyncMock() + mass.player_queues.get = MagicMock(return_value=None) mass.players.cmd_volume_up = AsyncMock() mass.players.cmd_volume_down = AsyncMock() mass.players.cmd_volume_set = AsyncMock() @@ -631,6 +638,231 @@ async def test_control_no_hint_no_default_asks_for_player(self) -> None: assert "(не указано)" not in text assert "на какой колонке" in text.lower() + # ------------------------------------------------------------------- + # v1.9.0 — six new commands + # ------------------------------------------------------------------- + + async def test_now_playing_returns_track(self) -> None: + """'что играет на кухне' → reads queue.current_item.name.""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + # Mock a queue with a current item. + queue = MagicMock() + queue.current_item = MagicMock(name="The Beatles - Let It Be") + queue.current_item.name = "The Beatles - Let It Be" + mass.player_queues.get = MagicMock(return_value=queue) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "что играет на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert "The Beatles - Let It Be" in body_out["response"]["text"] + # No MA mutation. + mass.player_queues.pause.assert_not_awaited() + mass.player_queues.play_media.assert_not_awaited() + + async def test_now_playing_idle_queue(self) -> None: + """'что играет' on an idle queue → 'ничего не играет'.""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + queue = MagicMock() + queue.current_item = None + mass.player_queues.get = MagicMock(return_value=queue) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "что играет на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert "ничего не играет" in body_out["response"]["text"] + + async def test_shuffle_on(self) -> None: + """'перемешай на кухне' → set_shuffle(p1, True).""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "перемешай на кухне"}, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.set_shuffle.assert_awaited_once_with("p1", shuffle_enabled=True) + + async def test_shuffle_off(self) -> None: + """'выключи перемешивание на кухне' → set_shuffle(p1, False).""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "выключи перемешивание на кухне"}, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.set_shuffle.assert_awaited_once_with("p1", shuffle_enabled=False) + + async def test_repeat_one(self) -> None: + """'повтор песни на кухне' → set_repeat(p1, RepeatMode.ONE) — sync, not awaited.""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "повтор песни на кухне"}, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.set_repeat.assert_called_once_with("p1", RepeatMode.ONE) + + async def test_seek_forward_minute(self) -> None: + """'перемотай вперёд на 1 минуту на кухне' → skip(p1, 60).""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "перемотай вперёд на 1 минуту на кухне"}, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.skip.assert_awaited_once_with("p1", seconds=60) + + async def test_seek_back_seconds(self) -> None: + """'назад на 30 секунд на кухне' → skip(p1, -30).""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "перемотай назад на 30 секунд на кухне"}, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.skip.assert_awaited_once_with("p1", seconds=-30) + + async def test_seek_start(self) -> None: + """'к началу на кухне' → seek(p1, position=0).""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "к началу на кухне"}, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.seek.assert_awaited_once_with("p1", position=0) + + async def test_transfer_to_target(self) -> None: + """'переведи на спальню' with default=p1 → transfer_queue(p1, p2); last_player_id→p2.""" + mass = self._setup_mass_with_control_methods( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "переведи на спальню"}, + "state": {"session": {"last_player_id": "p1"}}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.transfer_queue.assert_awaited_once_with( + source_queue_id="p1", target_queue_id="p2" + ) + body_out = _response_body(resp) + assert "Спальня" in body_out["response"]["text"] + assert body_out["session_state"]["last_player_id"] == "p2" + assert body_out["application_state"]["last_player_id"] == "p2" + + async def test_transfer_no_default_replies_with_hint(self) -> None: + """Transfer without saved last_player_id replies with 'сначала включи'.""" + mass = self._setup_mass_with_control_methods( + [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "переведи на спальню"}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert "Сначала включи" in body_out["response"]["text"] + mass.player_queues.transfer_queue.assert_not_awaited() + + async def test_transfer_to_same_player(self) -> None: + """'переведи на кухню' when default already = кухня → 'уже играет'.""" + mass = self._setup_mass_with_control_methods( + [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "переведи на кухню"}, + "state": {"session": {"last_player_id": "p1"}}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert "Уже играет" in body_out["response"]["text"] + mass.player_queues.transfer_queue.assert_not_awaited() + + async def test_add_to_queue_preserved_through_disambiguation(self) -> None: + """Ambiguous "добавь Iron Maiden" → disambiguation → user picks → ADD survives. + + Without this fix, the disambiguation flow rebuilt ParsedCommand + from `pending_command` without `enqueue_option`, so the replay + would hit play_media() without `option` (default REPLACE) + instead of `QueueOption.ADD`. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + # Turn 1: ambiguous "добавь Iron Maiden на кухне" → disambig prompt. + body1 = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "добавь Iron Maiden на кухне"}, + } + resp1 = await handler._handle_webhook(_build_request(body1)) + body_out1 = _response_body(resp1) + # Pending command must carry enqueue_option across the prompt. + assert body_out1["session_state"]["pending_command"]["enqueue_option"] == "add" + mass.player_queues.play_media.assert_not_awaited() + # Turn 2: ordinal "первая" → replay pending → play_media with ADD option. + body2 = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "первая"}, + "state": {"session": body_out1["session_state"]}, + } + await handler._handle_webhook(_build_request(body2)) + await asyncio.sleep(0) + mass.player_queues.play_media.assert_awaited_once() + assert mass.player_queues.play_media.call_args.kwargs["option"] == QueueOption.ADD + + async def test_add_to_queue_uses_queue_option_add(self) -> None: + """'добавь Metallica на кухне' → play_media(option=QueueOption.ADD).""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "добавь Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.play_media.assert_awaited_once() + call_kwargs = mass.player_queues.play_media.call_args.kwargs + assert call_kwargs["queue_id"] == "p1" + assert call_kwargs["option"] == QueueOption.ADD + # radio_mode forced off for add-to-queue. + assert call_kwargs["radio_mode"] is False + body_out = _response_body(resp) + assert "Добавил" in body_out["response"]["text"] + assert "в очередь" in body_out["response"]["text"] + # --------------------------------------------------------------------------- # Disambiguation (P0.3) diff --git a/tests/providers/yandex_smarthome/test_dialogs_control.py b/tests/providers/yandex_smarthome/test_dialogs_control.py index 28d3742d2c..afe2423559 100644 --- a/tests/providers/yandex_smarthome/test_dialogs_control.py +++ b/tests/providers/yandex_smarthome/test_dialogs_control.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from music_assistant_models.enums import RepeatMode from music_assistant.providers.yandex_smarthome.dialogs_control import ( ParsedControl, @@ -99,6 +100,61 @@ class TestParseControl: ("выбери колонку заново", "forget_player", None, None), ("поменяй колонку", "forget_player", None, None), ("сменить колонку", "forget_player", None, None), + # now_playing — info query + ("что играет", "now_playing", None, None), + ("что сейчас играет", "now_playing", None, None), + ("что слушаем", "now_playing", None, None), + ("что мы слушаем", "now_playing", None, None), + ("что за песня", "now_playing", None, None), + ("что за трек", "now_playing", None, None), + ("какой трек", "now_playing", None, None), + ("какой сейчас трек", "now_playing", None, None), + ("что играет на кухне", "now_playing", None, "кухне"), + # shuffle on/off + ("перемешай", "shuffle_on", None, None), + ("включи перемешивание", "shuffle_on", None, None), + ("случайный порядок", "shuffle_on", None, None), + ("в случайном порядке", "shuffle_on", None, None), + ("выключи перемешивание", "shuffle_off", None, None), + ("не перемешивай", "shuffle_off", None, None), + ("по порядку", "shuffle_off", None, None), + ("перемешай на кухне", "shuffle_on", None, "кухне"), + # repeat one/all/off + ("повтор песни", "repeat_one", None, None), + ("повтори песню", "repeat_one", None, None), + ("повтори трек", "repeat_one", None, None), + ("повтор эту", "repeat_one", None, None), + ("повтор всё", "repeat_all", None, None), + ("повтори все", "repeat_all", None, None), + ("повтор очередь", "repeat_all", None, None), + ("повторяй", "repeat_all", None, None), + ("включи повтор", "repeat_all", None, None), + ("выключи повтор", "repeat_off", None, None), + ("не повторяй", "repeat_off", None, None), + # seek_forward (with optional unit) + ("вперёд 30", "seek_forward", 30, None), + ("вперед 30", "seek_forward", 30, None), + ("перемотай вперёд 30", "seek_forward", 30, None), + ("перемотай вперёд на 30", "seek_forward", 30, None), + ("перемотай вперёд на 30 секунд", "seek_forward", 30, None), + ("перемотай вперёд на 1 минуту", "seek_forward", 60, None), + ("перемотай вперёд на 2 минуты", "seek_forward", 120, None), + # seek_back + ("назад 30", "seek_back", 30, None), + ("перемотай назад 30", "seek_back", 30, None), + ("перемотай назад на 1 минуту", "seek_back", 60, None), + ("назад на 5 секунд", "seek_back", 5, None), + # seek_start + ("к началу", "seek_start", None, None), + ("в начало", "seek_start", None, None), + ("перемотай к началу", "seek_start", None, None), + ("начни заново", "seek_start", None, None), + ("начни трек заново", "seek_start", None, None), + # transfer (target captured into player_hint) + ("переведи на спальню", "transfer", None, "спальню"), + ("перенеси на спальню", "transfer", None, "спальню"), + ("продолжи в спальне", "transfer", None, "спальне"), + ("переведи музыку на кухню", "transfer", None, "кухню"), # alice prefix tolerated ("Алиса, пауза", "pause", None, None), ], @@ -214,6 +270,14 @@ class TestControlConfirmation: ("volume_set", 50, "Громкость 50."), ("mute", None, "Звук выключен."), ("unmute", None, "Звук включен."), + ("shuffle_on", None, "Включил перемешивание."), + ("shuffle_off", None, "Выключил перемешивание."), + ("repeat_off", None, "Выключил повтор."), + ("repeat_one", None, "Повтор песни."), + ("repeat_all", None, "Повтор очереди."), + ("seek_forward", 60, "Перемотал на 60 секунд вперёд."), + ("seek_back", 30, "Перемотал на 30 секунд назад."), + ("seek_start", None, "Перемотал к началу."), ], ) def test_confirmation(self, action: str, value: int | None, expected: str) -> None: @@ -234,6 +298,10 @@ def _make_mass(self) -> MagicMock: mass.player_queues.stop = AsyncMock() mass.player_queues.next = AsyncMock() mass.player_queues.previous = AsyncMock() + mass.player_queues.set_shuffle = AsyncMock() + mass.player_queues.set_repeat = MagicMock() # NB: sync, not async + mass.player_queues.skip = AsyncMock() + mass.player_queues.seek = AsyncMock() mass.players = MagicMock() mass.players.cmd_volume_up = AsyncMock() mass.players.cmd_volume_down = AsyncMock() @@ -334,6 +402,76 @@ async def test_list_players_is_a_safe_noop_with_warning( # Warning emitted. assert any("list_players" in r.getMessage() for r in caplog.records) + async def test_shuffle_on(self) -> None: + """action=shuffle_on invokes set_shuffle(True).""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="shuffle_on"), self._player()) + mass.player_queues.set_shuffle.assert_awaited_once_with("p1", shuffle_enabled=True) + + async def test_shuffle_off(self) -> None: + """action=shuffle_off invokes set_shuffle(False).""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="shuffle_off"), self._player()) + mass.player_queues.set_shuffle.assert_awaited_once_with("p1", shuffle_enabled=False) + + async def test_repeat_off(self) -> None: + """action=repeat_off invokes set_repeat(RepeatMode.OFF) — sync, not awaited.""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="repeat_off"), self._player()) + mass.player_queues.set_repeat.assert_called_once_with("p1", RepeatMode.OFF) + + async def test_repeat_one(self) -> None: + """action=repeat_one invokes set_repeat(RepeatMode.ONE).""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="repeat_one"), self._player()) + mass.player_queues.set_repeat.assert_called_once_with("p1", RepeatMode.ONE) + + async def test_repeat_all(self) -> None: + """action=repeat_all invokes set_repeat(RepeatMode.ALL).""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="repeat_all"), self._player()) + mass.player_queues.set_repeat.assert_called_once_with("p1", RepeatMode.ALL) + + async def test_seek_forward(self) -> None: + """action=seek_forward(value=N) invokes skip(qid, +N).""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="seek_forward", value=60), self._player()) + mass.player_queues.skip.assert_awaited_once_with("p1", seconds=60) + + async def test_seek_back_negates_value(self) -> None: + """action=seek_back(value=N) invokes skip(qid, -N) — value is positive at parse time.""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="seek_back", value=30), self._player()) + mass.player_queues.skip.assert_awaited_once_with("p1", seconds=-30) + + async def test_seek_start(self) -> None: + """action=seek_start invokes seek(qid, position=0) — absolute reset.""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="seek_start"), self._player()) + mass.player_queues.seek.assert_awaited_once_with("p1", position=0) + + async def test_now_playing_is_safe_noop(self, caplog: pytest.LogCaptureFixture) -> None: + """`now_playing` reaching execute_control logs warning and no-ops (handler dispatches).""" + mass = self._make_mass() + with caplog.at_level( + logging.WARNING, logger="music_assistant.providers.yandex_smarthome.dialogs_control" + ): + await execute_control(mass, ParsedControl(action="now_playing"), self._player()) + mass.player_queues.skip.assert_not_awaited() + assert any("now_playing" in r.getMessage() for r in caplog.records) + + async def test_transfer_is_safe_noop(self, caplog: pytest.LogCaptureFixture) -> None: + """`transfer` reaching execute_control logs warning and no-ops (handler dispatches).""" + mass = self._make_mass() + with caplog.at_level( + logging.WARNING, logger="music_assistant.providers.yandex_smarthome.dialogs_control" + ): + await execute_control( + mass, ParsedControl(action="transfer", player_hint="спальню"), self._player() + ) + mass.player_queues.skip.assert_not_awaited() + assert any("transfer" in r.getMessage() for r in caplog.records) + async def test_underlying_failure_is_swallowed(self) -> None: """An exception from the MA call is logged + swallowed (no re-raise).""" mass = self._make_mass() diff --git a/tests/providers/yandex_smarthome/test_dialogs_nlu.py b/tests/providers/yandex_smarthome/test_dialogs_nlu.py index c187f0013c..dfd733ebe5 100644 --- a/tests/providers/yandex_smarthome/test_dialogs_nlu.py +++ b/tests/providers/yandex_smarthome/test_dialogs_nlu.py @@ -78,6 +78,11 @@ class TestParseCommand: # Genuine " на " still works after the detector. ("включи песню Yesterday на кухне", "track", "yesterday", "кухне", False), ("включи песню", "search", "песню", None, True), # no hint, just a marker + # add-to-queue: "добавь" verb sets enqueue_option="add"; radio_mode forced off. + ("добавь Metallica", "search", "metallica", None, False), + ("добавь песню Yesterday", "track", "yesterday", None, False), + ("добавьте альбом Black Album", "album", "black album", None, False), + ("добавить Iron Maiden на кухне", "search", "iron maiden", "кухне", False), ], ) def test_parse( @@ -103,6 +108,15 @@ def test_just_alice(self) -> None: """Bare 'алиса' without a verb keeps the full word as query.""" assert parse_command("алиса").query == "алиса" + def test_enqueue_option_set_for_dobavi(self) -> None: + """'добавь Metallica' → enqueue_option='add' (None for regular 'включи').""" + assert parse_command("добавь Metallica").enqueue_option == "add" + assert parse_command("добавьте альбом Black Album").enqueue_option == "add" + assert parse_command("добавить Iron Maiden").enqueue_option == "add" + # Regular play verbs leave it as None (default REPLACE behaviour). + assert parse_command("включи Metallica").enqueue_option is None + assert parse_command("поставь Metallica").enqueue_option is None + # --------------------------------------------------------------------------- # resolve_player — fixtures + cases From 999dca52a4dd85e9cf3b546ed378edd7358532bf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 May 2026 10:32:38 +0000 Subject: [PATCH 45/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v2.0.0 --- .../providers/yandex_smarthome/__init__.py | 721 +++---- .../providers/yandex_smarthome/_compat.py | 31 - .../providers/yandex_smarthome/auto_skill.py | 1630 ---------------- .../yandex_smarthome/auto_skill_logo.png | Bin 43607 -> 0 bytes .../yandex_smarthome/auto_skill_state.py | 122 -- .../yandex_smarthome/auto_skill_ui.py | 1124 ----------- .../providers/yandex_smarthome/cloud.py | 2 +- .../providers/yandex_smarthome/constants.py | 37 - .../providers/yandex_smarthome/dialogs.py | 1306 ------------- .../yandex_smarthome/dialogs_control.py | 468 ----- .../providers/yandex_smarthome/dialogs_nlu.py | 474 ----- .../yandex_smarthome/dialogs_player.py | 313 ---- .../providers/yandex_smarthome/manifest.json | 5 +- .../providers/yandex_smarthome/plugin.py | 37 +- requirements_all.txt | 1 + .../yandex_smarthome/test_auto_skill.py | 1173 ------------ .../yandex_smarthome/test_auto_skill_state.py | 129 -- .../yandex_smarthome/test_auto_skill_ui.py | 406 ---- .../providers/yandex_smarthome/test_cloud.py | 2 +- .../yandex_smarthome/test_config_actions.py | 407 ---- .../yandex_smarthome/test_dialogs.py | 1652 ----------------- .../yandex_smarthome/test_dialogs_control.py | 480 ----- .../yandex_smarthome/test_dialogs_nlu.py | 292 --- .../yandex_smarthome/test_dialogs_player.py | 191 -- 24 files changed, 226 insertions(+), 10777 deletions(-) delete mode 100644 music_assistant/providers/yandex_smarthome/_compat.py delete mode 100644 music_assistant/providers/yandex_smarthome/auto_skill.py delete mode 100644 music_assistant/providers/yandex_smarthome/auto_skill_logo.png delete mode 100644 music_assistant/providers/yandex_smarthome/auto_skill_state.py delete mode 100644 music_assistant/providers/yandex_smarthome/auto_skill_ui.py delete mode 100644 music_assistant/providers/yandex_smarthome/dialogs.py delete mode 100644 music_assistant/providers/yandex_smarthome/dialogs_control.py delete mode 100644 music_assistant/providers/yandex_smarthome/dialogs_nlu.py delete mode 100644 music_assistant/providers/yandex_smarthome/dialogs_player.py delete mode 100644 tests/providers/yandex_smarthome/test_auto_skill.py delete mode 100644 tests/providers/yandex_smarthome/test_auto_skill_state.py delete mode 100644 tests/providers/yandex_smarthome/test_auto_skill_ui.py delete mode 100644 tests/providers/yandex_smarthome/test_config_actions.py delete mode 100644 tests/providers/yandex_smarthome/test_dialogs.py delete mode 100644 tests/providers/yandex_smarthome/test_dialogs_control.py delete mode 100644 tests/providers/yandex_smarthome/test_dialogs_nlu.py delete mode 100644 tests/providers/yandex_smarthome/test_dialogs_player.py diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index 990032af64..27b49138d8 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -1,15 +1,17 @@ """ Yandex Smart Home Plugin Provider for Music Assistant. -Exposes Music Assistant players to Yandex Alice via the Yandex Smart Home API. -Allows voice control of MA players through Alice commands like -"Алиса, включи музыку на [имя плеера]". - -Architecture: - Alice voice command → Yandex Cloud → Smart Home API callback → this plugin → MA Player - -The plugin registers MA players as media_device in Yandex Smart Home, -mapping capabilities (on_off, volume, pause) to MA player controls. +Exposes Music Assistant players as Yandex Smart Home devices so Alice can +control playback (play / pause / volume / mute / source) via natural-language +commands. The voice-skill (custom dialog) functionality lives in the sister +provider `ma-provider-yandex-alice`. + +Connection modes: +- ``cloud`` — public yaha-cloud.ru relay (zero setup, but the public skill can + only be linked to one MA / Home Assistant instance per Yandex account). +- ``cloud_plus`` — private skill via the yaha-cloud relay (multiple instances + per account, registered manually in the dev console). +- ``direct`` — Yandex calls the MA webserver directly (requires public HTTPS). Reference: https://github.com/dext0r/yandex_smart_home """ @@ -18,7 +20,6 @@ import asyncio import contextlib -import dataclasses import logging import uuid from typing import TYPE_CHECKING, cast @@ -26,40 +27,16 @@ import aiohttp from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption from music_assistant_models.enums import ConfigEntryType, ProviderFeature +from ya_dialogs_api import SecretStr -from ._compat import SecretStr -from .auto_skill import ( - auto_create_skill, - auto_rename_dialog_skill, - load_default_logo_bytes, -) -from .auto_skill_state import ( - SkillCreationState, - dump_artifacts, - load_artifacts, -) -from .auto_skill_ui import build_cloud_plus_entries, build_direct_entries from .cloud import get_cloud_otp, register_cloud_instance from .constants import ( - CONF_ACTION_AUTO_CREATE, - CONF_ACTION_AUTO_CREATE_DIALOG, CONF_ACTION_GET_OTP, CONF_ACTION_REGISTER, - CONF_ACTION_RENAME_DIALOG_SKILL, - CONF_AUTH_X_TOKEN, - CONF_AUTO_CREATE_ARTIFACTS, - CONF_AUTO_CREATE_SESSION_ID, CONF_CLOUD_CONNECTION_TOKEN, CONF_CLOUD_INSTANCE_ID, CONF_CLOUD_INSTANCE_PASSWORD, CONF_CONNECTION_TYPE, - CONF_DIALOG_AUTO_CREATE_ARTIFACTS, - CONF_DIALOG_AUTO_CREATE_SESSION_ID, - CONF_DIALOG_SKILL_ENABLED, - CONF_DIALOG_SKILL_ID, - CONF_DIALOG_SKILL_NAME, - CONF_DIALOG_SKILL_TOKEN, - CONF_DIALOG_WEBHOOK_SECRET, CONF_DIRECT_ACCESS_TOKEN, CONF_DIRECT_CLIENT_SECRET, CONF_EXPOSED_PLAYERS, @@ -71,8 +48,6 @@ CONNECTION_TYPE_CLOUD, CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT, - DIALOG_DEFAULT_NAME, - DIALOG_WEBHOOK_BASE_PATH, MAX_INPUT_SOURCES, ) from .playlists import fetch_playlist_options @@ -85,13 +60,14 @@ from music_assistant.mass import MusicAssistant from music_assistant.models import ProviderInstanceType + _LOGGER = logging.getLogger(__name__) SUPPORTED_FEATURES: set[ProviderFeature] = set() def _build_status_label(otp_code: str | None, is_cloud_plus: bool, is_registered: bool) -> str: - """Build the status label text based on registration state.""" + """Status banner text for cloud / cloud_plus modes.""" if otp_code and is_cloud_plus: return ( "✅ Cloud instance registered! " @@ -129,13 +105,11 @@ def _resolve_direct_client_secret( instance_id: str | None, values: dict[str, ConfigValueType], ) -> str: - """Return the direct-mode OAuth client secret for the current install. + """Return the direct-mode per-install OAuth client secret. - `CONF_DIRECT_CLIENT_SECRET` is a SECURE_STRING: MA's frontend does - not echo saved secrets back into ``values`` on re-open, so reading - from ``values`` alone returns an empty string for existing instances. - Prefer the persisted value from saved config and fall back to - ``values`` only for first-time setup before any save. + The frontend does not echo SECURE_STRING fields back into ``values`` on + re-open, so prefer the saved provider config; fall back to the in-flight + form value (e.g. on a fresh-install round-trip before the first save). """ if instance_id: prov = mass.get_provider(instance_id) @@ -146,26 +120,6 @@ def _resolve_direct_client_secret( return str(values.get(CONF_DIRECT_CLIENT_SECRET) or "") -def _resolve_cached_x_token( - mass: MusicAssistant, - instance_id: str | None, - values: dict[str, ConfigValueType], -) -> str: - """Return the cached Yandex Passport x_token, or empty string if absent. - - Like the other secret resolvers, prefers the persisted SECURE_STRING - from saved config since the frontend does not echo secrets back into - ``values`` on re-open. - """ - if instance_id: - prov = mass.get_provider(instance_id) - if prov and prov.config: - saved = prov.config.get_value(CONF_AUTH_X_TOKEN) - if saved: - return str(saved) - return str(values.get(CONF_AUTH_X_TOKEN) or "") - - def _resolve_external_base_url( mass: MusicAssistant, values: dict[str, ConfigValueType] | None = None, @@ -175,10 +129,6 @@ def _resolve_external_base_url( Priority: 1. ``CONF_EXTERNAL_BASE_URL`` from values (user-set plugin override) 2. ``mass.webserver.base_url`` (MA's global setting) - - The override exists so users can keep MA's global Base URL pointing at - the local address (so HA Ingress / local UI keep working) while - exposing a public HTTPS URL only to Yandex via a reverse proxy. Trailing slashes are stripped. """ override = "" @@ -192,35 +142,14 @@ def _resolve_external_base_url( return fallback.strip().rstrip("/") -def _resolve_dialog_webhook_secret( - mass: MusicAssistant, - instance_id: str | None, - values: dict[str, ConfigValueType], -) -> str: - """Return the dialog webhook path-secret for the current install. - - Like ``_resolve_direct_client_secret``, prefers the persisted - SECURE_STRING from saved config since the frontend does not echo - secrets back into ``values`` on re-open. - """ - if instance_id: - prov = mass.get_provider(instance_id) - if prov and prov.config: - saved = prov.config.get_value(CONF_DIALOG_WEBHOOK_SECRET) - if saved: - return str(saved) - return str(values.get(CONF_DIALOG_WEBHOOK_SECRET) or "") - - async def _handle_config_actions( mass: MusicAssistant, action: str | None, values: dict[str, ConfigValueType], instance_id: str | None, is_cloud_plus: bool, - connection_type: str, ) -> str | None: - """Execute config-flow actions and return OTP code if obtained.""" + """Execute config-flow actions; return OTP code if obtained, else None.""" saved_config = None if instance_id: prov = mass.get_provider(instance_id) @@ -254,242 +183,30 @@ async def _handle_config_actions( except Exception: _LOGGER.exception("Failed to get OTP code") - # NOTE: the old flow used to auto-fetch OTP right after Register so - # the user saw the code immediately. In the 3-step cloud_plus flow - # (Register → Create skill → Get OTP), that leaks the OTP into Step 1. - # OTP is now fetched only when the user explicitly presses Get OTP - # in Step 3. - - if action == CONF_ACTION_AUTO_CREATE: - await _run_auto_create_action(mass, values, connection_type, instance_id) - - if action == CONF_ACTION_AUTO_CREATE_DIALOG: - await _run_auto_create_dialog_action(mass, values, connection_type, instance_id) - - if action == CONF_ACTION_RENAME_DIALOG_SKILL: - await _run_rename_dialog_action(mass, values, instance_id) - return otp_code -async def _run_auto_create_action( - mass: MusicAssistant, - values: dict[str, ConfigValueType], - connection_type: str, - instance_id: str | None, -) -> None: - """Execute the experimental auto-create-skill action. - - Never re-raises: all errors are persisted into the artifacts blob so - the UI can show a FAILED state on the next render rather than - crashing the config form. - """ - # MA's frontend supplies ``values["session_id"]`` when it triggers an - # action — AuthenticationHelper listens on that exact id to open - # and later close the popup. If we roll our own id nothing listens - # and the popup never appears. Fall back to a local uuid only if the - # frontend happened not to pass one (shouldn't happen in practice). - session_id = str(values.get("session_id") or uuid.uuid4().hex) - values[CONF_AUTO_CREATE_SESSION_ID] = session_id - artifacts_raw = values.get(CONF_AUTO_CREATE_ARTIFACTS) - artifacts = load_artifacts(str(artifacts_raw) if artifacts_raw else None) - - def _cache_x_token(token: str) -> None: - values[CONF_AUTH_X_TOKEN] = token - +async def _list_player_options(mass: MusicAssistant) -> list[ConfigValueOption]: + """Build the player-picker options list.""" + options: list[ConfigValueOption] = [] try: - new_artifacts = await auto_create_skill( - mass=mass, - connection_type=connection_type, - skill_name=str(values.get(CONF_INSTANCE_NAME) or "Music Assistant"), - artifacts=artifacts, - cloud_instance_id=str(values.get(CONF_CLOUD_INSTANCE_ID, "")), - direct_client_secret=_resolve_direct_client_secret(mass, instance_id, values), - logo_bytes=load_default_logo_bytes(), - session_id=session_id, - base_url_override=str(values.get(CONF_EXTERNAL_BASE_URL) or "") or None, - cached_x_token=_resolve_cached_x_token(mass, instance_id, values) or None, - on_token_obtained=_cache_x_token, - ) - except asyncio.CancelledError: - # Preserve cooperative cancellation so config-flow shutdown - # doesn't get converted into a FAILED artifact. - raise - except ValueError as exc: - # Precondition failures come back here — surface as FAILED. - new_artifacts = dataclasses.replace( - artifacts, - state=SkillCreationState.FAILED, - last_error=str(exc), - ) - _LOGGER.warning("auto-create precondition failed: %s", exc) - except Exception as exc: # defensive — never crash the config form - new_artifacts = dataclasses.replace( - artifacts, - state=SkillCreationState.FAILED, - last_error=repr(exc), - ) - _LOGGER.exception("auto-create hit unexpected error") - - values[CONF_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) - if new_artifacts.state == SkillCreationState.DONE and new_artifacts.skill_id: - # Only set CONF_SKILL_ID on full success so the runtime doesn't - # try to use a half-built skill mid-pipeline. - values[CONF_SKILL_ID] = new_artifacts.skill_id - - -def _build_dialog_backend_uri(base_url: str, webhook_secret: str) -> str: - return f"{base_url.rstrip('/')}{DIALOG_WEBHOOK_BASE_PATH}/{webhook_secret}" - - -async def _run_auto_create_dialog_action( - mass: MusicAssistant, - values: dict[str, ConfigValueType], - connection_type: str, - instance_id: str | None, -) -> None: - """Execute the experimental dialog skill auto-create action. - - Mirrors ``_run_auto_create_action`` but targets the dialog channel - and persists under separate config keys so the two pipelines are - independent. - """ - session_id = str(values.get("session_id") or uuid.uuid4().hex) - values[CONF_DIALOG_AUTO_CREATE_SESSION_ID] = session_id - - artifacts_raw = values.get(CONF_DIALOG_AUTO_CREATE_ARTIFACTS) - artifacts = load_artifacts(str(artifacts_raw) if artifacts_raw else None) - - webhook_secret = _resolve_dialog_webhook_secret(mass, instance_id, values) - if not webhook_secret: - webhook_secret = uuid.uuid4().hex - values[CONF_DIALOG_WEBHOOK_SECRET] = webhook_secret - - ma_base_url = _resolve_external_base_url(mass, values) - dialog_backend_uri = _build_dialog_backend_uri(ma_base_url, webhook_secret) - - skill_name = str(values.get(CONF_DIALOG_SKILL_NAME) or DIALOG_DEFAULT_NAME) - - # Yandex Dialogs validation: skill name must contain at least two - # words. Catch this client-side so we don't burn a Device Flow + create - # a half-broken skill that fails at request_deploy. Trim multiple - # internal whitespace before counting. - if len(skill_name.split()) < 2: - artifacts_failed = dataclasses.replace( - artifacts, - state=SkillCreationState.FAILED, - last_error=( - f"Skill activation name {skill_name!r} has fewer than two " - "words. Yandex Dialogs requires the skill name to contain at " - "least two words (e.g. 'Music Assistant', 'Моя Музыка'). " - "Edit the field above and try again." - ), - ) - values[CONF_DIALOG_AUTO_CREATE_ARTIFACTS] = dump_artifacts(artifacts_failed) - return - - def _cache_x_token(token: str) -> None: - values[CONF_AUTH_X_TOKEN] = token - - try: - new_artifacts = await auto_create_skill( - mass=mass, - connection_type=connection_type, - skill_name=skill_name, - artifacts=artifacts, - cloud_instance_id="", - direct_client_secret=_resolve_direct_client_secret(mass, instance_id, values), - logo_bytes=load_default_logo_bytes(), - session_id=session_id, - skill_type="dialog", - dialog_backend_uri=dialog_backend_uri, - base_url_override=str(values.get(CONF_EXTERNAL_BASE_URL) or "") or None, - cached_x_token=_resolve_cached_x_token(mass, instance_id, values) or None, - on_token_obtained=_cache_x_token, - ) - except asyncio.CancelledError: - raise - except ValueError as exc: - new_artifacts = dataclasses.replace( - artifacts, - state=SkillCreationState.FAILED, - last_error=str(exc), - ) - _LOGGER.warning("dialog auto-create precondition failed: %s", exc) - except Exception as exc: - new_artifacts = dataclasses.replace( - artifacts, - state=SkillCreationState.FAILED, - last_error=repr(exc), - ) - _LOGGER.exception("dialog auto-create hit unexpected error") - - # Hint: empty-body 400 on create_app for the dialog pipeline almost - # always means the DIALOG_CHANNEL value sent to Yandex is wrong. - # The default ("aliceSkill") was captured from a live POST /apps in the - # dev console — if Yandex changes the contract or the value stops - # working, users can override at startup via MA_YANDEX_DIALOG_CHANNEL. - if ( - new_artifacts.state == SkillCreationState.FAILED - and new_artifacts.last_error - and "create_app" in new_artifacts.last_error - and "HTTP 400" in new_artifacts.last_error - ): - new_artifacts = dataclasses.replace( - new_artifacts, - last_error=( - f"{new_artifacts.last_error}\n\n" - "Hint: This usually means the channel value sent to " - "Yandex Dialogs is wrong. The current default is " - "'aliceSkill'; if it stops working try overriding the " - "MA_YANDEX_DIALOG_CHANNEL environment variable at MA " - "startup (e.g. =dialog, =general) and retry." - ), - ) - - values[CONF_DIALOG_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) - if new_artifacts.state == SkillCreationState.DONE and new_artifacts.skill_id: - values[CONF_DIALOG_SKILL_ID] = new_artifacts.skill_id - - -async def _run_rename_dialog_action( - mass: MusicAssistant, - values: dict[str, ConfigValueType], - instance_id: str | None, -) -> None: - """Execute the dialog skill rename + re-deploy action.""" - artifacts_raw = values.get(CONF_DIALOG_AUTO_CREATE_ARTIFACTS) - artifacts = load_artifacts(str(artifacts_raw) if artifacts_raw else None) - - webhook_secret = _resolve_dialog_webhook_secret(mass, instance_id, values) - ma_base_url = _resolve_external_base_url(mass, values) - dialog_backend_uri = _build_dialog_backend_uri(ma_base_url, webhook_secret) - - skill_name = str(values.get(CONF_DIALOG_SKILL_NAME) or DIALOG_DEFAULT_NAME) - session_id = str(values.get("session_id") or uuid.uuid4().hex) - - def _cache_x_token(token: str) -> None: - values[CONF_AUTH_X_TOKEN] = token - - new_artifacts = await auto_rename_dialog_skill( - mass=mass, - artifacts=artifacts, - new_name=skill_name, - dialog_backend_uri=dialog_backend_uri, - session_id=session_id, - cached_x_token=_resolve_cached_x_token(mass, instance_id, values) or None, - on_token_obtained=_cache_x_token, - ) - values[CONF_DIALOG_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) + for player in mass.players.all_players(): + state = player.state + options.append( + ConfigValueOption(title=state.name or state.player_id, value=state.player_id) + ) + except Exception: + _LOGGER.debug("could not enumerate players") + return options -async def get_config_entries( # noqa: PLR0915 +async def get_config_entries( mass: MusicAssistant, instance_id: str | None = None, action: str | None = None, values: dict[str, ConfigValueType] | None = None, ) -> tuple[ConfigEntry, ...]: - """Return Config entries to setup this provider.""" + """Build the provider config-form entries.""" if values is None: values = {} @@ -498,65 +215,35 @@ async def get_config_entries( # noqa: PLR0915 is_cloud_plus = connection_type == CONNECTION_TYPE_CLOUD_PLUS is_direct = connection_type == CONNECTION_TYPE_DIRECT - otp_code = await _handle_config_actions( - mass, action, values, instance_id, is_cloud_plus, connection_type - ) - - # Auto-create-skill state — loaded once and threaded through the - # per-mode builders below. - artifacts_raw = values.get(CONF_AUTO_CREATE_ARTIFACTS) - artifacts_str = str(artifacts_raw) if artifacts_raw else None - artifacts = load_artifacts(artifacts_str) - session_id_val = values.get(CONF_AUTO_CREATE_SESSION_ID) - session_id_str = str(session_id_val) if session_id_val else None - ma_base_url_for_ui = _resolve_external_base_url(mass, values) + otp_code = await _handle_config_actions(mass, action, values, instance_id, is_cloud_plus) is_registered = bool(values.get(CONF_CLOUD_INSTANCE_ID)) and bool( values.get(CONF_CLOUD_CONNECTION_TOKEN) ) - cloud_instance_id = str(values.get(CONF_CLOUD_INSTANCE_ID, "")) - label_text = _build_status_label(otp_code, is_cloud_plus, is_registered) - # Build player options for exposed players filter - player_options: list[ConfigValueOption] = [] - try: - for player in mass.players.all_players(): - state = player.state - player_options.append( - ConfigValueOption(title=state.name or state.player_id, value=state.player_id) - ) - except Exception: # noqa: S110 - pass - - # Build playlist options from MA library (any music provider). Fail-soft: empty - # list if music controller isn't ready (e.g. provider load order at first run). - # CancelledError must propagate so config-flow cancellation/shutdown work. + player_options = await _list_player_options(mass) playlist_options: list[ConfigValueOption] = [] try: playlist_options = await fetch_playlist_options(mass) except asyncio.CancelledError: raise - except Exception: # noqa: S110 - pass + except Exception: + _LOGGER.debug("could not enumerate playlists") entries: list[ConfigEntry] = [ - # Instance name ConfigEntry( key=CONF_INSTANCE_NAME, type=ConfigEntryType.STRING, label="Instance Name", description=( - "Name of this MA instance as it will appear in Yandex Smart Home. " - "Alice will use this name for voice commands, e.g. " - '"Алиса, включи музыку на [имя]".' + "Display name for this MA instance in Yandex Smart Home. " + "Alice will use this name for voice commands like " + '"Алиса, поставь паузу на [имя]".' ), required=False, default_value="Music Assistant", ), - # Save-and-reopen notice — the form doesn't re-render on - # dropdown change, so the user has to Save + reopen to see - # the next mode's fields. ConfigEntry( key="label_connection_type_notice", type=ConfigEntryType.LABEL, @@ -565,7 +252,6 @@ async def get_config_entries( # noqa: PLR0915 "reopen this settings page to see the fields for the new mode." ), ), - # Connection type selector ConfigEntry( key=CONF_CONNECTION_TYPE, type=ConfigEntryType.STRING, @@ -582,134 +268,16 @@ async def get_config_entries( # noqa: PLR0915 ConfigValueOption(title="Cloud Plus (private skill)", value="cloud_plus"), ConfigValueOption(title="Direct (no relay, requires public URL)", value="direct"), ], - # NOTE: immediate_apply produced glitchy mixed-mode renders - # (entries from old mode stayed on screen next to new ones), - # so users need Save + reopen after changing Connection - # Type. Kept here to stop someone re-adding it. ), ] - # -- Per-mode sections (each builder returns only the fields for its mode) if is_cloud: entries.extend(_cloud_mode_entries(label_text, otp_code, is_registered)) elif is_cloud_plus: - entries.extend( - build_cloud_plus_entries( - otp_code=otp_code, - is_registered=is_registered, - cloud_instance_id=cloud_instance_id, - artifacts=artifacts, - session_id=session_id_str, - user_code=None, # popup URL carries the code - verification_url=None, - existing_artifacts_raw=artifacts_str, - base_url=ma_base_url_for_ui, - skill_id=str(values.get(CONF_SKILL_ID) or ""), - skill_token_set=bool(values.get(CONF_SKILL_TOKEN)), - ) - ) + entries.extend(_cloud_plus_mode_entries(label_text, otp_code, is_registered, values)) elif is_direct: - # Pre-generate the per-install direct client secret once so it - # survives round-trips (auto-skill pipeline reads it later). - # SECURE_STRING is not echoed back into ``values`` on re-open, - # so prefer the persisted value from saved config first and - # only mint a fresh UUID on true first-time setup. - direct_secret = _resolve_direct_client_secret(mass, instance_id, values) - if not direct_secret: - direct_secret = uuid.uuid4().hex - values[CONF_DIRECT_CLIENT_SECRET] = direct_secret - - # Pre-generate the dialog webhook secret similarly. - dialog_webhook_secret = _resolve_dialog_webhook_secret(mass, instance_id, values) - if not dialog_webhook_secret: - dialog_webhook_secret = uuid.uuid4().hex - values[CONF_DIALOG_WEBHOOK_SECRET] = dialog_webhook_secret - - # Dialog skill state - dialog_artifacts_raw = values.get(CONF_DIALOG_AUTO_CREATE_ARTIFACTS) - dialog_artifacts_str = str(dialog_artifacts_raw) if dialog_artifacts_raw else None - dialog_artifacts = load_artifacts(dialog_artifacts_str) - dialog_session_id_val = values.get(CONF_DIALOG_AUTO_CREATE_SESSION_ID) - dialog_session_id_str = str(dialog_session_id_val) if dialog_session_id_val else None - - # Plugin-local override of MA's webserver Base URL — published to - # Yandex but doesn't touch MA's global setting. Lets users keep the - # global Base URL pointing at the local IP (so HA Ingress / local - # frontend keep working) while still exposing a public HTTPS URL - # to Yandex via a reverse proxy. - ma_global_base_url = "" - with contextlib.suppress(Exception): - ma_global_base_url = str(mass.webserver.base_url) - external_url_description = ( - "Public HTTPS URL of this MA instance, used only for Yandex " - "callbacks and webhooks (Yandex requires HTTPS). Set this if " - "you don't want to change MA's global Base URL — e.g. you reach " - "MA via Home Assistant Ingress and exposing a public URL " - "globally would break local access. Leave empty to use MA's " - f"Base URL ({ma_global_base_url or ''})." - ) - entries.append( - ConfigEntry( - key=CONF_EXTERNAL_BASE_URL, - type=ConfigEntryType.STRING, - label="External Base URL (HTTPS, optional override)", - description=external_url_description, - required=False, - default_value="", - value=str(values.get(CONF_EXTERNAL_BASE_URL) or ""), - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - ) - ) - entries.extend( - build_direct_entries( - artifacts=artifacts, - session_id=session_id_str, - user_code=None, - verification_url=None, - existing_artifacts_raw=artifacts_str, - base_url=ma_base_url_for_ui, - direct_client_secret=direct_secret, - skill_id=str(values.get(CONF_SKILL_ID) or ""), - skill_token_set=bool(values.get(CONF_SKILL_TOKEN)), - dialog_skill_enabled=bool(values.get(CONF_DIALOG_SKILL_ENABLED)), - dialog_skill_name=str(values.get(CONF_DIALOG_SKILL_NAME) or DIALOG_DEFAULT_NAME), - dialog_artifacts=dialog_artifacts, - dialog_skill_id=str(values.get(CONF_DIALOG_SKILL_ID) or ""), - dialog_session_id=dialog_session_id_str, - dialog_existing_artifacts_raw=dialog_artifacts_str, - dialog_user_code=None, - dialog_verification_url=None, - ) - ) - # NB: CONF_DIRECT_CLIENT_SECRET is now emitted by the manual - # fallback block (advanced/hidden per state), so we don't add a - # duplicate hidden round-trip entry here. - - # Round-trip the dialog webhook secret (SECURE_STRING, never echoed). - entries.append( - ConfigEntry( - key=CONF_DIALOG_WEBHOOK_SECRET, - type=ConfigEntryType.SECURE_STRING, - label="Dialog webhook secret (internal)", - hidden=True, - required=False, - value=dialog_webhook_secret, - ) - ) - # Round-trip the dialog skill token (set externally if needed). - entries.append( - ConfigEntry( - key=CONF_DIALOG_SKILL_TOKEN, - type=ConfigEntryType.SECURE_STRING, - label="Dialog skill token (internal)", - hidden=True, - required=False, - value=cast("str", values.get(CONF_DIALOG_SKILL_TOKEN)) if values else None, - ) - ) + entries.extend(_direct_mode_entries(mass, instance_id, values)) - # -- Tail: player filter + hidden round-trip fields (all modes) -- entries.extend(_common_tail_entries(player_options, playlist_options, values)) return tuple(entries) @@ -719,11 +287,6 @@ def _cloud_mode_entries( ) -> list[ConfigEntry]: """Public-cloud mode: simple register + get-OTP flow.""" return [ - # Advisory — the public Yaha Cloud skill can only be linked to one - # instance per Yandex account, so users who already set up Yaha - # Cloud in Home Assistant (or another MA install) need Cloud Plus. - # There's no pre-flight API to detect this, so the warning is - # static — cheaper than a failed OTP attempt. ConfigEntry( key="label_cloud_conflict_warning", type=ConfigEntryType.LABEL, @@ -762,9 +325,6 @@ def _cloud_mode_entries( action=CONF_ACTION_REGISTER, action_label="Register with cloud", hidden=is_registered, - # No depends_on — MA disables actions with an unsaved - # dependency value until the user clicks Save, which breaks - # the flow right after picking a connection type. ), ConfigEntry( key=CONF_ACTION_GET_OTP, @@ -778,6 +338,179 @@ def _cloud_mode_entries( ] +def _cloud_plus_mode_entries( + label_text: str, + otp_code: str | None, + is_registered: bool, + values: dict[str, ConfigValueType], +) -> list[ConfigEntry]: + """Cloud Plus: register + manual skill_id/skill_token from dev console.""" + return [ + ConfigEntry( + key="label_status_cp", + type=ConfigEntryType.LABEL, + label=label_text, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + ), + ConfigEntry( + key="label_cloud_plus_help", + type=ConfigEntryType.LABEL, + label=( + "Cloud Plus uses a private skill in your Yandex developer " + "account. Steps: 1) Click 'Register with cloud' below to " + "provision a yaha-cloud relay slot. 2) Create a private " + "Smart Home skill at https://dialogs.yandex.ru/developer " + "and paste the skill ID + OAuth token below. 3) Click " + "'Get OTP code' and enter it in the Yandex app to finish " + "linking." + ), + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + ), + ConfigEntry( + key="otp_code_cp", + type=ConfigEntryType.STRING, + label="OTP Code", + description="Copy this code and enter it in the Yandex app.", + required=False, + value=otp_code, + hidden=not otp_code, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + ), + ConfigEntry( + key=CONF_ACTION_REGISTER, + type=ConfigEntryType.ACTION, + label="Register cloud instance", + description="Provision a yaha-cloud relay slot for the private skill.", + action=CONF_ACTION_REGISTER, + action_label="Register with cloud", + hidden=is_registered, + ), + ConfigEntry( + key=CONF_SKILL_ID, + type=ConfigEntryType.STRING, + label="Skill ID", + description=( + "UUID from the dev console URL (https://dialogs.yandex.ru/developer/skills/)." + ), + required=False, + value=cast("str", values.get(CONF_SKILL_ID)) if values else None, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + ), + ConfigEntry( + key=CONF_SKILL_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Skill OAuth token", + description=( + "OAuth token from " + "https://oauth.yandex.ru/authorize?response_type=token" + "&client_id=c473ca268cd749d3a8371351a8f2bcbd. " + "Used to push state callbacks to Yandex." + ), + required=False, + value=cast("str", values.get(CONF_SKILL_TOKEN)) if values else None, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + ), + ConfigEntry( + key=CONF_ACTION_GET_OTP, + type=ConfigEntryType.ACTION, + label="Get OTP code", + description="Get a fresh one-time password to link with the Yandex Smart Home app.", + action=CONF_ACTION_GET_OTP, + action_label="Get OTP code", + hidden=not is_registered, + ), + ] + + +def _direct_mode_entries( + mass: MusicAssistant, + instance_id: str | None, + values: dict[str, ConfigValueType], +) -> list[ConfigEntry]: + """Direct mode: HTTPS callback URL + skill_id/skill_token from dev console.""" + direct_secret = _resolve_direct_client_secret(mass, instance_id, values) + if not direct_secret: + direct_secret = uuid.uuid4().hex + values[CONF_DIRECT_CLIENT_SECRET] = direct_secret + + ma_global_base_url = "" + with contextlib.suppress(Exception): + ma_global_base_url = str(mass.webserver.base_url) + external_url_description = ( + "Public HTTPS URL of this MA instance (e.g. https://ma.example.com), " + "used for Yandex callbacks and webhooks. Set this if you don't want " + "to change MA's global Base URL — e.g. you reach MA via Home " + "Assistant Ingress and exposing a public URL globally would break " + f"local access. Leave empty to use MA's Base URL ({ma_global_base_url or ''})." + ) + + return [ + ConfigEntry( + key="label_direct_help", + type=ConfigEntryType.LABEL, + label=( + "Direct mode points Yandex straight at this MA instance. " + "Steps: 1) Set the External Base URL below to a public HTTPS " + "URL (Yandex requires HTTPS). 2) Create a Smart Home skill " + "at https://dialogs.yandex.ru/developer with the Backend URL " + "shown after first save. 3) Paste the skill ID + OAuth token " + "from the dev console below." + ), + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_DIRECT, + ), + ConfigEntry( + key=CONF_EXTERNAL_BASE_URL, + type=ConfigEntryType.STRING, + label="External Base URL (HTTPS, optional override)", + description=external_url_description, + required=False, + default_value="", + value=str(values.get(CONF_EXTERNAL_BASE_URL) or ""), + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_DIRECT, + ), + ConfigEntry( + key=CONF_SKILL_ID, + type=ConfigEntryType.STRING, + label="Skill ID", + description="UUID from your skill's dev console URL.", + required=False, + value=cast("str", values.get(CONF_SKILL_ID)) if values else None, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_DIRECT, + ), + ConfigEntry( + key=CONF_SKILL_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Skill OAuth token", + description=( + "OAuth token from " + "https://oauth.yandex.ru/authorize?response_type=token" + "&client_id=c473ca268cd749d3a8371351a8f2bcbd." + ), + required=False, + value=cast("str", values.get(CONF_SKILL_TOKEN)) if values else None, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_DIRECT, + ), + # Per-install OAuth client secret (kept hidden, round-tripped). + ConfigEntry( + key=CONF_DIRECT_CLIENT_SECRET, + type=ConfigEntryType.SECURE_STRING, + label="Direct client secret (internal)", + hidden=True, + required=False, + value=direct_secret, + ), + ] + + def _common_tail_entries( player_options: list[ConfigValueOption], playlist_options: list[ConfigValueOption], @@ -805,18 +538,9 @@ def _common_tail_entries( description=( f"Pick up to {MAX_INPUT_SOURCES} playlists from your MA library — " "they fill the input_source mode slots after each player's native " - "sources. The total slot count is capped at " - f"{MAX_INPUT_SOURCES}: native sources go first (in the player's own " - "order), then playlists fill the remainder in the order you select " - "them here. A player that already exposes " - f"{MAX_INPUT_SOURCES} or more native sources gets none of the " - "picked playlists, and the playlist→slot mapping varies between " - "players that have different native-source counts. Alice triggers " - "slots by ordinal only («Alice, switch source to five»); " - "the Yandex Smart Home API does not allow naming mode values, so " - "remember the order you picked. If the list is empty, save the " - "form and reopen it once your music providers have finished " - "loading their library." + 'sources. Alice triggers slots by ordinal only ("switch source to five"); ' + "remember the order you picked. If the list is empty, save the form " + "and reopen it once your music providers have finished loading their library." ), required=False, multi_value=True, @@ -855,15 +579,4 @@ def _common_tail_entries( required=False, value=(cast("str", values.get(CONF_DIRECT_ACCESS_TOKEN)) if values else None), ), - # Cached Yandex Passport x_token — populated after the first - # successful auto-create Device Flow and reused on subsequent - # auto-create runs to skip the device-code prompt. - ConfigEntry( - key=CONF_AUTH_X_TOKEN, - type=ConfigEntryType.SECURE_STRING, - label="Yandex Passport x_token (cached)", - hidden=True, - required=False, - value=(cast("str", values.get(CONF_AUTH_X_TOKEN)) if values else None), - ), ] diff --git a/music_assistant/providers/yandex_smarthome/_compat.py b/music_assistant/providers/yandex_smarthome/_compat.py deleted file mode 100644 index 6b747cbd01..0000000000 --- a/music_assistant/providers/yandex_smarthome/_compat.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Compatibility shim for ya-passport-auth. - -Provides a single source of `SecretStr` for the provider. When `ya-passport-auth` -is installed (the normal runtime case — declared in manifest.json), we re-export -the real implementation. When it's missing (bare test envs, pre-install linting) -we expose a minimal drop-in so importing the provider package doesn't crash. - -Centralized here to avoid duplicating the fallback across modules. -""" - -from __future__ import annotations - -try: - from ya_passport_auth import SecretStr -except ImportError: - - class SecretStr: # type: ignore[no-redef] - """Minimal fallback when ya-passport-auth is not yet installed.""" - - def __init__(self, value: str) -> None: - """Initialize with a secret value.""" - if not value: - raise ValueError("SecretStr value must not be empty") - self._value = value - - def get_secret(self) -> str: - """Return the secret value.""" - return self._value - - -__all__ = ["SecretStr"] diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py deleted file mode 100644 index 68cc6e678f..0000000000 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ /dev/null @@ -1,1630 +0,0 @@ -"""Low-level client for the undocumented dialogs.yandex.ru developer API. - -Implements the 8-step sequence captured from Chrome DevTools HAR for -creating a Smart Home skill with account-linking: - - 1. GET /developer → extract CSRF (secretkey) - 2. GET /developer/app-store-api/snapshot → existing skills list (optional) - 3. POST /developer/app-store-api/apps → skill_id - 4. POST /developer/app-store-api/apps/{id}/draft/upload-logo → logo_id - 5. PATCH /developer/app-store-api/apps/{id}/draft/update → settings - 6. POST /developer/app-store-api/oauth/apps → oauth_app_id - 7. POST /developer/app-store-api/apps/{id}/oauthApp → bind oauth - 8. POST /developer/app-store-api/apps/{id}/draft/request-deploy → publish - -This is an UNDOCUMENTED, PRIVATE API. It may break at any time. The -caller is responsible for surfacing that risk to the user (see -``provider.auto_skill_ui``). - -Authentication: passport session cookies (``Session_id`` / ``sessionid2``) -must already be present in the supplied ``aiohttp.ClientSession``'s -cookie jar. Obtain them via ``ya_passport_auth.PassportClient``: - - creds = await client.login_device_code(...) - await client.refresh_passport_cookies(creds.x_token) - creator = DialogsSkillCreator(client._session) - -The CSRF token (returned by ``fetch_csrf``) must be passed as the -``x-csrf-token`` header on every mutating request. -""" - -from __future__ import annotations - -import asyncio -import dataclasses -import json -import logging -import re -from contextlib import asynccontextmanager -from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal - -import aiohttp - -from .auto_skill_state import SkillCreationArtifacts, SkillCreationState -from .constants import ( - CLOUD_OAUTH_AUTHORIZE_URL, - CLOUD_OAUTH_TOKEN_URL, - CLOUD_SKILL_CLIENT_ID_TEMPLATE, - CLOUD_SKILL_CLIENT_SECRET, - CLOUD_SKILL_WEBHOOK_TEMPLATE, - CONNECTION_TYPE_CLOUD_PLUS, - CONNECTION_TYPE_DIRECT, - DIALOG_CHANNEL, - DIRECT_AUTH_BASE_PATH, - DIRECT_BACKEND_URI_PATH, - DIRECT_OAUTH_CLIENT_ID, -) - -if TYPE_CHECKING: - from collections.abc import AsyncIterator, Awaitable, Callable, Mapping - - from music_assistant.mass import MusicAssistant - -__all__ = [ - "DEVICE_FLOW_TIMEOUT_SECONDS", - "DIALOGS_API_BASE", - "DIALOGS_CSRF_REGEX", - "DIALOGS_DEV_BASE", - "DIALOGS_DEV_HTML_URL", - "DialogsApiError", - "DialogsCsrfError", - "DialogsDuplicateSkillError", - "DialogsSkillCreator", - "auto_create_skill", - "auto_rename_dialog_skill", - "build_dialog_draft_payload", - "build_draft_payload", - "build_oauth_app_payload", - "check_preconditions", - "derive_auth_urls", - "derive_backend_uri", - "derive_client_id", - "load_default_logo_bytes", -] - -_LOGGER = logging.getLogger(__name__) - -# --------------------------------------------------------------------------- -# Endpoints / patterns -# --------------------------------------------------------------------------- -DIALOGS_DEV_BASE = "https://dialogs.yandex.ru" -DIALOGS_DEV_HTML_URL = f"{DIALOGS_DEV_BASE}/developer" -DIALOGS_API_BASE = f"{DIALOGS_DEV_BASE}/developer/app-store-api" - -# The developer console embeds a CSRF token in its HTML as: -# ..."secretkey":"u9c94f1aca53bf156be4..."... -# Captured from HAR 2026-04-24. If Yandex re-renders differently, this -# regex will miss and ``fetch_csrf`` raises ``DialogsCsrfError`` so the -# user falls back to manual setup. -DIALOGS_CSRF_REGEX = re.compile(r'"secretkey":"([^"]+)"') - -SMART_HOME_CHANNEL = "smartHome" -_MAX_HTML_RESPONSE_BYTES = 2 * 1024 * 1024 # 2 MiB - -# --------------------------------------------------------------------------- -# Errors -# --------------------------------------------------------------------------- - - -class DialogsApiError(Exception): - """Base error for dialogs.yandex.ru API failures.""" - - def __init__( - self, - message: str, - *, - step: str, - http_status: int | None = None, - yandex_error: str | None = None, - ) -> None: - """Initialise with the pipeline step that failed for clearer messages.""" - super().__init__(message) - self.step = step - self.http_status = http_status - self.yandex_error = yandex_error - - -class DialogsCsrfError(DialogsApiError): - """Raised when the CSRF token cannot be extracted from the developer page.""" - - -class DialogsDuplicateSkillError(DialogsApiError): - """Raised when create_app rejects because a skill with the same name exists.""" - - -# --------------------------------------------------------------------------- -# Client -# --------------------------------------------------------------------------- - - -class DialogsSkillCreator: - """Thin async wrapper over dialogs.yandex.ru developer-console API. - - Every method is idempotent on the transport layer: a single call - either succeeds or raises. Retry / state-machine logic lives in the - orchestrator (see :func:`auto_create_skill`). - """ - - __slots__ = ("_channel", "_logger", "_session") - - def __init__( - self, - session: aiohttp.ClientSession, - logger: logging.Logger | None = None, - *, - channel: str = SMART_HOME_CHANNEL, - ) -> None: - """Take a session that already carries Passport auth cookies. - - ``channel`` selects the Yandex Dialogs skill family — defaults to - ``smartHome`` for the existing Smart Home pipeline; pass - :data:`DIALOG_CHANNEL` for the experimental «Навык» pipeline. - """ - self._session = session - self._logger = logger or _LOGGER - self._channel = channel - - # ----------------------------------------------------------------------- - # Step 1: CSRF token extraction - # ----------------------------------------------------------------------- - - async def fetch_csrf(self) -> str: - """Fetch the developer page HTML and extract the CSRF ``secretkey``. - - Caller uses the returned value as the ``x-csrf-token`` header on - all mutating requests. Returns a fresh token on every call; the - orchestrator caches it for the duration of a single attempt. - """ - async with self._session.get(DIALOGS_DEV_HTML_URL) as resp: - if resp.status == 401: - raise DialogsApiError( - "not authenticated — passport session cookies missing or expired", - step="fetch_csrf", - http_status=401, - ) - if resp.status != 200: - raise DialogsApiError( - f"dialogs.yandex.ru/developer returned HTTP {resp.status}", - step="fetch_csrf", - http_status=resp.status, - ) - # Enforce the size cap while reading so an oversized - # response can't buffer fully in memory (T5 pattern from - # ya-passport-auth). - body = bytearray() - async for chunk in resp.content.iter_chunked(8192): - body.extend(chunk) - if len(body) > _MAX_HTML_RESPONSE_BYTES: - raise DialogsApiError( - "developer page response exceeded size cap", - step="fetch_csrf", - ) - html = body.decode(resp.get_encoding() or "utf-8", errors="replace") - - match = DIALOGS_CSRF_REGEX.search(html) - if not match: - raise DialogsCsrfError( - "could not locate CSRF token in developer page HTML — " - "Yandex may have changed the rendering format", - step="fetch_csrf", - ) - token = match.group(1).strip() - if not token: - raise DialogsCsrfError( - "CSRF token matched but is empty", - step="fetch_csrf", - ) - self._logger.debug("dialogs CSRF token fetched (len=%d)", len(token)) - return token - - # ----------------------------------------------------------------------- - # Step 2: list existing skills (for duplicate-name detection) - # ----------------------------------------------------------------------- - - async def list_existing_skills(self, csrf: str) -> list[dict[str, Any]]: - """Return the user's existing skills from the snapshot endpoint. - - The dashboard uses this to populate its skill list; we use it to - warn the user before they hit a duplicate-name 4xx on create_app. - """ - url = f"{DIALOGS_API_BASE}/snapshot" - data = await self._get_json(url, csrf=csrf, step="list_existing_skills") - result = data.get("result") - if not isinstance(result, dict): - return [] - skills = result.get("skills") - if not isinstance(skills, list): - return [] - return [s for s in skills if isinstance(s, dict)] - - # ----------------------------------------------------------------------- - # Step 3: create the skill app - # ----------------------------------------------------------------------- - - async def create_app(self, csrf: str, name: str) -> str: - """Create a Smart Home skill with the given name. - - Returns the newly-minted ``skill_id`` (UUID). Raises - :class:`DialogsDuplicateSkillError` if the name is already taken - by another skill on this account. - """ - url = f"{DIALOGS_API_BASE}/apps" - payload = { - "channel": self._channel, - "language": "ru", - "isYangoConsole": False, - "appName": name, - } - data = await self._post_json(url, payload, csrf=csrf, step="create_app") - result = data.get("result") - if not isinstance(result, dict): - raise DialogsApiError( - "create_app response missing 'result' object", - step="create_app", - ) - skill_id = result.get("id") or result.get("skill_id") - if not isinstance(skill_id, str) or not skill_id: - raise DialogsApiError( - "create_app response missing skill id", - step="create_app", - ) - self._logger.info("dialogs skill created: id=%s name=%r", skill_id, name) - return skill_id - - # ----------------------------------------------------------------------- - # Step 4: upload logo - # ----------------------------------------------------------------------- - - async def upload_logo(self, csrf: str, skill_id: str, png: bytes) -> str: - """Upload a PNG logo for the skill. - - Returns a ``logo_id`` that must be referenced in ``update_draft``. - The logo file is sent as multipart with the field name ``file`` - and filename ``icon.png`` (matching the HAR capture). - """ - url = f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/upload-logo?channel={self._channel}" - form = aiohttp.FormData() - form.add_field( - "file", - png, - filename="icon.png", - content_type="image/png", - ) - headers = {"x-csrf-token": csrf} - async with self._session.post(url, data=form, headers=headers) as resp: - body = await resp.text() - if resp.status != 200: - raise DialogsApiError( - f"upload_logo HTTP {resp.status}: {body[:200]}", - step="upload_logo", - http_status=resp.status, - ) - data = _try_json(body) - result = data.get("result") if isinstance(data, dict) else None - if not isinstance(result, dict): - raise DialogsApiError( - "upload_logo response missing 'result'", - step="upload_logo", - ) - logo_id = result.get("id") - if not isinstance(logo_id, str) or not logo_id: - raise DialogsApiError( - "upload_logo response missing logo id", - step="upload_logo", - ) - return logo_id - - # ----------------------------------------------------------------------- - # Step 5: update draft settings - # ----------------------------------------------------------------------- - - async def update_draft(self, csrf: str, skill_id: str, payload: Mapping[str, Any]) -> None: - """PATCH the skill draft with backend URL / publishing metadata.""" - url = f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/update" - await self._patch_json(url, dict(payload), csrf=csrf, step="update_draft") - - # ----------------------------------------------------------------------- - # Step 6: create OAuth app (account-linking) - # ----------------------------------------------------------------------- - - async def create_oauth_app( - self, - csrf: str, - *, - name: str, - client_id: str, - client_secret: str, - authorize_url: str, - token_url: str, - refresh_url: str, - ) -> str: - """Create the OAuth app that powers account-linking in the skill. - - Returns the OAuth-app UUID which is then bound to the skill via - ``attach_oauth``. - """ - url = f"{DIALOGS_API_BASE}/oauth/apps" - payload = { - "name": name, - "clientId": client_id, - "clientSecret": client_secret, - "authorizationUrl": authorize_url, - "tokenUrl": token_url, - "refreshTokenUrl": refresh_url, - "scope": "", - "yandexClientId": "", - } - data = await self._post_json(url, payload, csrf=csrf, step="create_oauth_app") - result = data.get("result") - if not isinstance(result, dict): - raise DialogsApiError( - "create_oauth_app response missing 'result'", - step="create_oauth_app", - ) - oauth_app_id = result.get("id") - if not isinstance(oauth_app_id, str) or not oauth_app_id: - raise DialogsApiError( - "create_oauth_app response missing oauth app id", - step="create_oauth_app", - ) - return oauth_app_id - - # ----------------------------------------------------------------------- - # Step 7: bind OAuth app to the skill - # ----------------------------------------------------------------------- - - async def attach_oauth(self, csrf: str, skill_id: str, oauth_app_id: str) -> None: - """Attach an existing OAuth app to the skill's account-linking slot.""" - url = f"{DIALOGS_API_BASE}/apps/{skill_id}/oauthApp?channel={self._channel}" - payload = {"oauthAppId": oauth_app_id} - await self._post_json(url, payload, csrf=csrf, step="attach_oauth") - - # ----------------------------------------------------------------------- - # Step 8: publish (send for moderation) - # ----------------------------------------------------------------------- - - async def request_deploy(self, csrf: str, skill_id: str) -> None: - """Send the draft to moderation / publish. - - Body is empty; all params are in the query string. Returns on - 2xx; otherwise raises. - """ - url = f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/request-deploy?channel={self._channel}" - headers = {"x-csrf-token": csrf} - async with self._session.post(url, headers=headers) as resp: - body = await resp.text() - if resp.status not in (200, 201, 202, 204): - raise DialogsApiError( - f"request_deploy HTTP {resp.status}: {body[:200]}", - step="request_deploy", - http_status=resp.status, - ) - - # ----------------------------------------------------------------------- - # Internal helpers - # ----------------------------------------------------------------------- - - async def _get_json(self, url: str, *, csrf: str, step: str) -> dict[str, Any]: - headers = {"x-csrf-token": csrf} - async with self._session.get(url, headers=headers) as resp: - body = await resp.text() - if resp.status != 200: - raise DialogsApiError( - f"GET {url} HTTP {resp.status}: {body[:200]}", - step=step, - http_status=resp.status, - ) - data = _try_json(body) - if not isinstance(data, dict): - raise DialogsApiError( - f"GET {url} returned non-object JSON", - step=step, - ) - return data - - async def _post_json( - self, url: str, payload: dict[str, Any], *, csrf: str, step: str - ) -> dict[str, Any]: - return await self._send_json("POST", url, payload, csrf=csrf, step=step) - - async def _patch_json( - self, url: str, payload: dict[str, Any], *, csrf: str, step: str - ) -> dict[str, Any]: - return await self._send_json("PATCH", url, payload, csrf=csrf, step=step) - - async def _send_json( - self, - method: str, - url: str, - payload: dict[str, Any], - *, - csrf: str, - step: str, - ) -> dict[str, Any]: - headers = { - "x-csrf-token": csrf, - "content-type": "application/json", - } - async with self._session.request(method, url, json=payload, headers=headers) as resp: - body = await resp.text() - # Only ``create_app`` can fail with duplicate-name errors — - # other endpoints use 409 for unrelated conflicts and would - # be misclassified as duplicates if the mapping were global. - duplicate_candidate = step == "create_app" and ( - resp.status == 409 or (resp.status in (400, 422) and _looks_like_duplicate(body)) - ) - if duplicate_candidate: - raise DialogsDuplicateSkillError( - f"{step}: skill with this name already exists", - step=step, - http_status=resp.status, - yandex_error=_extract_error_code(body), - ) - if resp.status not in (200, 201, 202): - # Empty / very short body 4xx — log a small safe subset of - # response headers so the user can see what Yandex actually - # returned (helps diagnose e.g. wrong "channel" parameter - # where the API rejects the request before generating a body). - # Avoid dumping the full header map: it includes Set-Cookie - # and other potentially sensitive values. - if not body.strip(): - safe_headers = { - k: resp.headers.get(k) - for k in ( - "Content-Type", - "Content-Length", - "X-Request-Id", - "X-RateLimit-Remaining", - "X-RateLimit-Limit", - ) - if resp.headers.get(k) is not None - } - _LOGGER.warning( - "Yandex %s %s returned %s with empty body; response " - "headers=%s, request payload channel=%r", - method, - url, - resp.status, - safe_headers, - payload.get("channel"), - ) - raise DialogsApiError( - f"{method} {url} HTTP {resp.status}: {body[:200] or ''}", - step=step, - http_status=resp.status, - yandex_error=_extract_error_code(body), - ) - data = _try_json(body) - if not isinstance(data, dict): - raise DialogsApiError( - f"{method} {url} returned non-object JSON", - step=step, - ) - return data - - -# --------------------------------------------------------------------------- -# Module-private helpers -# --------------------------------------------------------------------------- - - -def _try_json(body: str) -> Any: - """Parse JSON defensively — return None on any error.""" - if not body: - return None - try: - return json.loads(body) - except (ValueError, TypeError): - return None - - -def _looks_like_duplicate(body: str) -> bool: - """Heuristic for whether a 4xx body indicates a duplicate-name error.""" - if not body: - return False - lowered = body.lower() - return any( - kw in lowered for kw in ("already exists", "duplicate", "exists with name", "not_unique") - ) - - -def _extract_error_code(body: str) -> str | None: - """Pull Yandex error code/message out of a 4xx response body (best-effort).""" - data = _try_json(body) - if not isinstance(data, dict): - return None - for key in ("error", "errorCode", "message", "code"): - value = data.get(key) - if isinstance(value, str) and value: - return value - return None - - -# --------------------------------------------------------------------------- -# Pure helpers: backend/oauth URLs, payload builders, preconditions -# -# All of these are side-effect-free and separately unit-testable; the -# orchestrator in a later commit wires them together. -# --------------------------------------------------------------------------- - - -def _resolve_base_url(mass: MusicAssistant, override: str | None) -> str: - """Pick override (plugin-local) over MA's global webserver.base_url. - - ``override`` is the user's ``CONF_EXTERNAL_BASE_URL`` value if they - chose to set one. Empty/None falls back to ``mass.webserver.base_url``. - Both branches strip leading/trailing whitespace and trailing slashes - so a user-entered value like ``" https://ma.example.com/ "`` doesn't - break the HTTPS precondition check or generate malformed URIs. - """ - if override and override.strip(): - return override.strip().rstrip("/") - return str(mass.webserver.base_url).strip().rstrip("/") - - -def derive_backend_uri( - mass: MusicAssistant, - connection_type: str, - *, - base_url_override: str | None = None, -) -> str: - """Return the Backend URL the skill should point at for *connection_type*. - - cloud_plus → yaha-cloud.ru relay (fixed URL). - direct → ``{base_url}{DIRECT_BACKEND_URI_PATH}`` (requires HTTPS - base URL; see :func:`check_preconditions`). The path is - the prefix WITHOUT ``/v1.0`` because Yandex appends the - version segment itself when calling our endpoints. - ``base_url_override``, if given, takes precedence over - ``mass.webserver.base_url``. - """ - if connection_type == CONNECTION_TYPE_CLOUD_PLUS: - return CLOUD_SKILL_WEBHOOK_TEMPLATE - if connection_type == CONNECTION_TYPE_DIRECT: - base = _resolve_base_url(mass, base_url_override) - return f"{base}{DIRECT_BACKEND_URI_PATH}" - msg = f"auto-create is not supported for connection_type={connection_type!r}" - raise ValueError(msg) - - -def derive_auth_urls( - mass: MusicAssistant, - connection_type: str, - *, - base_url_override: str | None = None, -) -> tuple[str, str]: - """Return (authorize_url, token_url) for the OAuth app. - - cloud_plus uses the yaha-cloud relay's OAuth endpoints; direct - uses the MA webserver's own authorize/token endpoints (served by - provider/direct.py). ``base_url_override`` takes precedence over - ``mass.webserver.base_url`` for the direct case. - """ - if connection_type == CONNECTION_TYPE_CLOUD_PLUS: - return CLOUD_OAUTH_AUTHORIZE_URL, CLOUD_OAUTH_TOKEN_URL - if connection_type == CONNECTION_TYPE_DIRECT: - base = _resolve_base_url(mass, base_url_override) - return ( - f"{base}{DIRECT_AUTH_BASE_PATH}/authorize", - f"{base}{DIRECT_AUTH_BASE_PATH}/token", - ) - msg = f"auto-create is not supported for connection_type={connection_type!r}" - raise ValueError(msg) - - -def derive_client_id(connection_type: str, cloud_instance_id: str) -> str: - """Return the OAuth client_id to register in the skill's account linking. - - cloud_plus uses ``yandex_smart_home:{instance_id}`` (yaha-cloud - protocol); direct uses the fixed Yandex social redirect base URL - (its existing Yandex OAuth client expects this exact ID). - """ - if connection_type == CONNECTION_TYPE_CLOUD_PLUS: - if not cloud_instance_id: - msg = "cloud_plus requires a registered cloud_instance_id" - raise ValueError(msg) - return CLOUD_SKILL_CLIENT_ID_TEMPLATE.format(instance_id=cloud_instance_id) - if connection_type == CONNECTION_TYPE_DIRECT: - return DIRECT_OAUTH_CLIENT_ID - msg = f"auto-create is not supported for connection_type={connection_type!r}" - raise ValueError(msg) - - -def build_draft_payload( - *, - connection_type: str, - skill_name: str, - backend_uri: str, - logo_id: str | None, - developer_name: str = "Music Assistant user", -) -> dict[str, Any]: - """Compose the PATCH /draft/update body for a Smart Home skill. - - Matches the HAR sample field-for-field; every key that the - dashboard UI sends on save is reproduced so Yandex's validator - sees a complete draft and allows ``request-deploy`` afterwards. - """ - if connection_type not in (CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT): - msg = f"auto-create is not supported for connection_type={connection_type!r}" - raise ValueError(msg) - - return { - "logo2": None, - "name": skill_name, - "voice": "shitova.us", - "logoId": logo_id, - "skillAccess": "private", - "hideInStore": False, - "noteForModerator": "", - "backendSettings": { - "uri": backend_uri, - "functionId": "", - "backendType": "webhook", - }, - "publishingSettings": { - "brandVerificationWebsite": "", - "category": "smart_home", - "developerName": developer_name, - "secondaryTitle": "", - "email": "", # server pulls from the authenticated session - "smartHome": { - "deepLinks": { - "android": {"url": ""}, - "ios": {"url": "", "fallbackUrl": ""}, - }, - }, - "multilingualSettings": { - "ru": { - "name": skill_name, - "secondaryTitle": "", - "externalSettingsDescription": skill_name, - "supportedUnitsDescription": skill_name, - }, - }, - }, - "oauthAppId": None, - "isTrustedSmartHomeSkill": False, - "enableAllAvailableRegions": True, - "selectedRegions": [], - "channel": SMART_HOME_CHANNEL, - } - - -def build_dialog_draft_payload( - *, - skill_name: str, - backend_uri: str, - logo_id: str | None, - developer_name: str = "Music Assistant user", -) -> dict[str, Any]: - """Compose the PATCH /draft/update body for a Yandex Dialogs custom skill. - - All fields and shapes were captured from a live PATCH issued by the - dev console after the user filled the form successfully. Notable - discoveries: - - - ``structuredExamples`` shape: each entry is - ``{"marker": , "activationPhrase": , - "request": , "is_valid": true}`` — NOT ``{"phrase": "..."}`` - as we previously guessed (that wrong shape was the cause of all - the silent HTTP 400 + empty-body rejections from Yandex). - - ``description``: required non-empty. - - ``category``: ``"music_audio"`` (API key for "Аудио и подкасты"). - - ``email``: empty string is OK; Yandex pre-fills it from the user's - Passport account on its side anyway. - """ - return { - "logo2": None, - "name": skill_name, - "voice": "good_oksana", - "activationPhrases": [skill_name], - "logoId": logo_id, - "noteForModerator": "", - "yaCloudGrant": False, - "backendSettings": { - "uri": backend_uri, - "functionId": "", - "backendType": "webhook", - }, - "publishingSettings": { - "brandVerificationWebsite": "", - "category": "music_audio", - "developerName": developer_name, - "explicitContent": False, - "structuredExamples": [ - { - "marker": "попроси", - "activationPhrase": skill_name, - "request": "включи Metallica", - "is_valid": True, - }, - { - "marker": "попроси", - "activationPhrase": skill_name, - "request": "включи мою волну", - "is_valid": True, - }, - { - "marker": "попроси", - "activationPhrase": skill_name, - "request": "включи джаз на кухне", - "is_valid": True, - }, - ], - "description": ( - f"{skill_name}: голосовое управление Music Assistant — " - "поиск и воспроизведение треков, альбомов, плейлистов, " - "радио и Моей волны на любой колонке в системе." - ), - "email": "", - }, - "requiredInterfaces": [], - "exactSurfaces": [], - "surfaceWhitelist": [], - "surfaceBlacklist": [], - "oauthAppId": None, - "appMetricaApiKey": "", - "useStateStorage": False, - "rsyPlatformId": "", - "skillAccess": "private", - "hideInStore": True, - "channel": DIALOG_CHANNEL, - } - - -def build_oauth_app_payload( - *, - skill_name: str, - client_id: str, - client_secret: str, - authorize_url: str, - token_url: str, -) -> dict[str, Any]: - """Compose the POST /oauth/apps body for account-linking. - - Values come from :func:`derive_client_id`, :func:`derive_auth_urls`, - and :func:`derive_backend_uri`'s caller context. ``refreshTokenUrl`` - always equals ``token_url`` — Yandex's flow uses the same endpoint - for both grant types. - """ - return { - "name": skill_name, - "clientId": client_id, - "clientSecret": client_secret, - "authorizationUrl": authorize_url, - "tokenUrl": token_url, - "refreshTokenUrl": token_url, - "scope": "", - "yandexClientId": "", - } - - -def check_preconditions( - *, - connection_type: str, - mass: MusicAssistant, - cloud_instance_id: str, - direct_client_secret: str, - base_url_override: str | None = None, -) -> None: - """Validate that auto-create can run for the given connection type. - - Raises :class:`ValueError` with a human-readable message on failure. - Called before any network I/O so the UI can surface the error - without a half-created skill on Yandex's side. - - ``base_url_override``, if given, takes precedence over - ``mass.webserver.base_url`` for the HTTPS check (so users can keep - MA's global Base URL local while supplying a public HTTPS URL only - to Yandex via the plugin override). - """ - if connection_type == CONNECTION_TYPE_CLOUD_PLUS: - if not cloud_instance_id: - msg = ( - "Cloud Plus requires a registered yaha-cloud instance first. " - "Use the 'Register with cloud' action." - ) - raise ValueError(msg) - return - - if connection_type == CONNECTION_TYPE_DIRECT: - if not direct_client_secret: - msg = "Direct mode requires a generated Client Secret" - raise ValueError(msg) - try: - base = _resolve_base_url(mass, base_url_override) - except Exception as exc: - msg = f"MA webserver base URL is not available: {exc}" - raise ValueError(msg) from exc - if not base.startswith("https://"): - msg = ( - "Direct mode requires MA to be reachable over HTTPS from the " - f"public internet (got base_url={base!r}). Yandex will reject " - "a skill with a non-HTTPS backend." - ) - raise ValueError(msg) - return - - msg = ( - f"auto-create is not supported for connection_type={connection_type!r}; " - "use cloud_plus or direct." - ) - raise ValueError(msg) - - -# --------------------------------------------------------------------------- -# Orchestrator: device flow + resumable pipeline -# --------------------------------------------------------------------------- - -# Hard cap on how long we'll wait for the user to enter the code. -DEVICE_FLOW_TIMEOUT_SECONDS = 300.0 - -_DEVICE_CODE_PAGE_PATH = "/yandex_smarthome/device_code" -# Keep the intermediate HTML page alive long enough for one more poll -# after state flips to done/failed — ~1s is plenty, the page polls -# every 2s so we're just covering the in-flight window. -_POST_AUTH_GRACE_SECONDS = 1 -# Server-suggested interval from Yandex is 5s (RFC 8628) but after the -# user has confirmed the code we want to detect it promptly; 2s is the -# RFC-recommended minimum. If Yandex ever returns SLOW_DOWN, the library -# bumps the interval automatically. -_DEVICE_FLOW_POLL_INTERVAL = 2.0 -_SAFE_SESSION_ID_RE = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z") - - -def _build_device_code_page(user_code: str, verification_url: str, status_url: str) -> str: - """Render the HTML page shown during Device Flow login. - - Yandex's ya.ru/device page does not pre-fill from query params and - strips them on redirect-to-login, so the only reliable way to show - the code is to host our own page in MA's webserver that displays - the code prominently and opens ya.ru/device in a new tab. - - Pattern copied from ``ma-provider-yandex-station/provider/auth.py``. - """ - import html # noqa: PLC0415 - - safe_code = html.escape(user_code) - safe_url = html.escape(verification_url, quote=True) - safe_status_url = json.dumps(status_url).replace(" - - - - Yandex Smart Home — Device Code - - - - -
-

Authorise Music Assistant for skill creation

-

Open the link below, log in to your Yandex account, and enter this code.

-
{safe_code}
-
- -
- Continue to Yandex -
- - -""" - - -async def _default_authenticator( # noqa: PLR0915 - *, - mass: MusicAssistant, - session_id: str, - timeout: float, - cached_x_token: str | None = None, - on_token_obtained: Callable[[str], None] | None = None, -) -> AsyncIterator[aiohttp.ClientSession]: - """Real-world authentication path — runs Device Flow and yields a session. - - Cache fast-path: if ``cached_x_token`` is provided and Yandex still - accepts it, ``refresh_passport_cookies`` succeeds without a fresh - Device Flow, so subsequent auto-create runs (e.g. Smart Home → Dialog) - do not prompt the user to confirm the device code again. On any - failure during refresh the cache is treated as stale and the full - Device Flow runs as before. - - After a successful Device Flow, ``on_token_obtained`` (if provided) - is invoked with the fresh ``x_token`` so the caller can persist it. - - Serves an intermediate HTML page through MA's webserver so the user - sees the short ``user_code`` (Yandex's ya.ru/device does not pre-fill - from query params). The popup is opened via - :class:`AuthenticationHelper` using the frontend-provided - ``session_id`` — that's how MA's UI knows which popup session to - render and later close. - - Pattern copied from ``ma-provider-yandex-station/provider/auth.py``. - """ - from aiohttp import web # noqa: PLC0415 - from ya_passport_auth import ClientConfig, PassportClient # noqa: PLC0415 - from ya_passport_auth.config import DEFAULT_ALLOWED_HOSTS # noqa: PLC0415 - from ya_passport_auth.credentials import SecretStr # noqa: PLC0415 - - from music_assistant.helpers.auth import AuthenticationHelper # noqa: PLC0415 - - if not _SAFE_SESSION_ID_RE.match(session_id): - msg = "invalid session_id for device authentication" - raise ValueError(msg) - - allowed = DEFAULT_ALLOWED_HOSTS | frozenset({"dialogs.yandex.ru"}) - config = ClientConfig(allowed_hosts=allowed) - - async with PassportClient.create(config=config) as client: - # Cache fast-path: try cached x_token first. If the token is still - # valid Yandex returns fresh session cookies and we skip Device Flow. - # The cache stores the raw string; ya_passport_auth wraps it in - # SecretStr for redacted logging on its side. - if cached_x_token: - try: - await client.refresh_passport_cookies(SecretStr(cached_x_token)) - _LOGGER.info( - "auto-skill: reused cached Yandex Passport x_token (no Device Flow needed)" - ) - yield client._session - return - except asyncio.CancelledError: - raise - except Exception as exc: - _LOGGER.info( - "auto-skill: cached x_token rejected (%s) — falling back to fresh Device Flow", - exc, - ) - - device_session = await client.start_device_login() - # Don't log user_code — it's a time-limited credential (grants - # Yandex sign-in for the device-flow window) and writing it to - # shared log backends would leak access. - _LOGGER.info( - "device flow started — verification_url=%s", - device_session.verification_url, - ) - - page_path = f"{_DEVICE_CODE_PAGE_PATH}/{session_id}" - status_path = f"{page_path}/status" - # MA frontend requires an absolute URL in signal_event(AUTH_SESSION, - # ...). The URL comes from mass.webserver.base_url which the user - # configures in Settings → Core → Webserver → Base URL. If they - # haven't touched it and MA is behind Docker/reverse-proxy, it may - # point at an unreachable internal address — the warning log below - # gives them the path so they can open it manually if the popup - # fails to load. - base_url = str(mass.webserver.base_url).rstrip("/") - status_url = f"{base_url}{status_path}" - page_url = f"{base_url}{page_path}" - state = {"value": "pending"} - - page_html = _build_device_code_page( - device_session.user_code, - device_session.verification_url, - status_url, - ) - - async def _serve_page(_request: web.Request) -> web.Response: - return web.Response( - text=page_html, - content_type="text/html", - charset="utf-8", - headers={ - "Cache-Control": "no-store", - "Pragma": "no-cache", - "Expires": "0", - }, - ) - - async def _serve_status(_request: web.Request) -> web.Response: - return web.json_response( - {"state": state["value"]}, - headers={"Cache-Control": "no-store"}, - ) - - mass.webserver.register_dynamic_route(page_path, _serve_page, "GET") - mass.webserver.register_dynamic_route(status_path, _serve_status, "GET") - _LOGGER.warning( - "auto-skill: device-code popup URL %s (path=%s) " - "— if the popup does not open or points at an unreachable " - "address, open the path directly in your browser (the page " - "displays the user_code) or fix Settings → Core → Webserver " - "→ Base URL", - page_url, - page_path, - ) - try: - async with AuthenticationHelper(mass, session_id) as auth_helper: - auth_helper.send_url(page_url) - try: - creds = await client.poll_device_until_confirmed( - device_session, - total_timeout=timeout, - poll_interval=_DEVICE_FLOW_POLL_INTERVAL, - ) - except asyncio.CancelledError: - raise - except Exception: - state["value"] = "failed" - await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) - raise - state["value"] = "done" - await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) - finally: - mass.webserver.unregister_dynamic_route(page_path, "GET") - mass.webserver.unregister_dynamic_route(status_path, "GET") - - await client.refresh_passport_cookies(creds.x_token) - - # Persist the new x_token so subsequent auto-create runs can skip - # Device Flow. Best-effort: a callback failure must not break auth. - # Unwrap SecretStr → str so the callback can store it via the MA - # config plumbing (SECURE_STRING serialiser expects a plain str). - if on_token_obtained is not None: - try: - on_token_obtained(creds.x_token.get_secret()) - except Exception: - _LOGGER.exception( - "auto-skill: on_token_obtained callback failed; x_token will not be cached" - ) - - yield client._session - - -def _build_authenticator_cm( - authenticator: Callable[..., AsyncIterator[aiohttp.ClientSession]], - *, - mass: MusicAssistant, - session_id: str, - timeout: float, - cached_x_token: str | None = None, - on_token_obtained: Callable[[str], None] | None = None, -) -> Any: - """Wrap *authenticator* so it supports ``async with`` uniformly. - - The default implementation is a plain async generator, but callers - may inject an already-decorated ``@asynccontextmanager``. Re-wrapping - a CM factory with ``asynccontextmanager`` is *not* idempotent — the - outer wrapper would call ``__anext__`` on the inner CM object and - crash — so detect the CM result and pass it through unchanged. - - ``cached_x_token`` and ``on_token_obtained`` are forwarded to the - default authenticator for fast-path / persistence; injected - test authenticators (which take ``**kwargs``) get them as well. - """ - result = authenticator( - mass=mass, - session_id=session_id, - timeout=timeout, - cached_x_token=cached_x_token, - on_token_obtained=on_token_obtained, - ) - if hasattr(result, "__aenter__") and hasattr(result, "__aexit__"): - return result - - # Adapt the async iterator returned above into a proper context - # manager. We have to drive the existing iterator (not call the - # authenticator again) to avoid leaving a half-created generator - # unawaited and to preserve any work it already did (e.g. starting - # a Device Flow session). - @asynccontextmanager - async def _cm() -> AsyncIterator[aiohttp.ClientSession]: - session = await result.__anext__() - try: - yield session - finally: - try: - await result.__anext__() - except StopAsyncIteration: - pass - else: - msg = "authenticator yielded more than one session" - raise RuntimeError(msg) - - return _cm() - - -async def auto_create_skill( # noqa: PLR0913 - *, - mass: MusicAssistant, - connection_type: str, - skill_name: str, - artifacts: SkillCreationArtifacts, - cloud_instance_id: str, - direct_client_secret: str, - logo_bytes: bytes, - session_id: str, - skill_type: Literal["smart_home", "dialog"] = "smart_home", - dialog_backend_uri: str | None = None, - base_url_override: str | None = None, - cached_x_token: str | None = None, - on_token_obtained: Callable[[str], None] | None = None, - progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None = None, - authenticator: Callable[..., AsyncIterator[aiohttp.ClientSession]] | None = None, - creator_factory: Callable[[aiohttp.ClientSession], DialogsSkillCreator] | None = None, - timeout: float = DEVICE_FLOW_TIMEOUT_SECONDS, - developer_name: str = "Music Assistant user", -) -> SkillCreationArtifacts: - """End-to-end flow: Device Flow → passport cookies → skill pipeline. - - Resumes from ``artifacts.state`` — steps that already completed - (skill_id present, etc.) are skipped. On any exception, returns - artifacts with ``state=FAILED`` and a human-readable ``last_error`` - instead of re-raising, so the config-flow UI can render the message - without crashing. - - ``progress_cb`` is invoked after each successful step with the - updated artifacts; the caller uses it to persist state to MA config - so a subsequent retry resumes from the latest completed step. - - ``skill_type`` selects the API channel and draft payload builder. - For ``"dialog"``, ``dialog_backend_uri`` must be provided (the full - HTTPS webhook URL including the path secret). - - ``authenticator`` and ``creator_factory`` are injection points for - tests; production callers leave them as ``None`` to use the real - Device Flow and a real :class:`DialogsSkillCreator`. - """ - # Precondition failures surface unmodified (caller decides message). - check_preconditions( - connection_type=connection_type, - mass=mass, - cloud_instance_id=cloud_instance_id, - direct_client_secret=direct_client_secret, - base_url_override=base_url_override, - ) - - if skill_type == "dialog" and not dialog_backend_uri: - msg = "dialog_backend_uri is required for skill_type='dialog'" - return dataclasses.replace(artifacts, state=SkillCreationState.FAILED, last_error=msg) - - channel = DIALOG_CHANNEL if skill_type == "dialog" else SMART_HOME_CHANNEL - auth_fn = authenticator or _default_authenticator - - try: - async with _build_authenticator_cm( - auth_fn, - mass=mass, - session_id=session_id, - timeout=timeout, - cached_x_token=cached_x_token, - on_token_obtained=on_token_obtained, - ) as session: - creator = ( - creator_factory(session) - if creator_factory is not None - else DialogsSkillCreator(session, channel=channel) - ) - return await _run_pipeline_with_recovery( - creator=creator, - artifacts=artifacts, - connection_type=connection_type, - skill_name=skill_name, - cloud_instance_id=cloud_instance_id, - direct_client_secret=direct_client_secret, - logo_bytes=logo_bytes, - mass=mass, - developer_name=developer_name, - skill_type=skill_type, - dialog_backend_uri=dialog_backend_uri, - base_url_override=base_url_override, - progress_cb=progress_cb, - ) - except asyncio.CancelledError: - # Preserve cooperative cancellation — do not absorb into FAILED. - raise - except ValueError: - raise - except Exception as exc: - _LOGGER.exception("auto-create hit unexpected error") - return dataclasses.replace(artifacts, state=SkillCreationState.FAILED, last_error=repr(exc)) - - -async def _run_pipeline_with_recovery( # noqa: PLR0913 - *, - creator: DialogsSkillCreator, - artifacts: SkillCreationArtifacts, - connection_type: str, - skill_name: str, - cloud_instance_id: str, - direct_client_secret: str, - logo_bytes: bytes, - mass: MusicAssistant, - developer_name: str, - skill_type: Literal["smart_home", "dialog"] = "smart_home", - dialog_backend_uri: str | None = None, - base_url_override: str | None = None, - progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, -) -> SkillCreationArtifacts: - """Fetch CSRF and run the pipeline, preserving partial state on failure. - - Holds a ``current`` reference that ``_execute_pipeline`` updates via - ``progress_cb``, so a mid-pipeline raise lets us surface whatever - progress was captured (skill_id / logo_id / oauth_app_id) as a - FAILED artifact instead of losing it. - """ - current = artifacts - - async def _track(a: SkillCreationArtifacts) -> None: - nonlocal current - current = a - if progress_cb is not None: - await progress_cb(a) - - try: - _LOGGER.info("auto-skill: fetching CSRF from dialogs.yandex.ru") - csrf = await creator.fetch_csrf() - _LOGGER.info("auto-skill: CSRF acquired, starting skill pipeline") - return await _execute_pipeline( - creator=creator, - csrf=csrf, - artifacts=artifacts, - connection_type=connection_type, - skill_name=skill_name, - cloud_instance_id=cloud_instance_id, - direct_client_secret=direct_client_secret, - logo_bytes=logo_bytes, - mass=mass, - developer_name=developer_name, - skill_type=skill_type, - dialog_backend_uri=dialog_backend_uri, - base_url_override=base_url_override, - progress_cb=_track, - ) - except DialogsApiError as exc: - _LOGGER.warning("auto-create failed at %s: %s", exc.step, exc, exc_info=True) - return dataclasses.replace(current, state=SkillCreationState.FAILED, last_error=str(exc)) - - -async def _execute_pipeline( # noqa: PLR0913, PLR0915 - *, - creator: DialogsSkillCreator, - csrf: str, - artifacts: SkillCreationArtifacts, - connection_type: str, - skill_name: str, - cloud_instance_id: str, - direct_client_secret: str, - logo_bytes: bytes, - mass: MusicAssistant, - developer_name: str, - skill_type: Literal["smart_home", "dialog"] = "smart_home", - dialog_backend_uri: str | None = None, - base_url_override: str | None = None, - progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, -) -> SkillCreationArtifacts: - """Advance through states sequentially, skipping completed steps.""" - state = artifacts.state - - # -- Step 3: create app -- - if state in (SkillCreationState.NONE, SkillCreationState.FAILED): - _LOGGER.info("auto-skill: [1/5] creating skill app") - new_skill_id = await creator.create_app(csrf, skill_name) - artifacts = dataclasses.replace( - artifacts, - state=SkillCreationState.APP_CREATED, - skill_id=new_skill_id, - last_error=None, - ) - await _maybe_save(progress_cb, artifacts) - state = artifacts.state - - if artifacts.skill_id is None: - msg = "internal error: skill_id missing after create_app" - raise RuntimeError(msg) - skill_id: str = artifacts.skill_id - - # -- Step 4+5: upload logo and update draft (merged step) -- - if state == SkillCreationState.APP_CREATED: - logo_id = artifacts.logo_id - if logo_id is None: - _LOGGER.info("auto-skill: [2/5] uploading logo") - logo_id = await creator.upload_logo(csrf, skill_id, logo_bytes) - artifacts = dataclasses.replace(artifacts, logo_id=logo_id) - - if skill_type == "dialog": - if dialog_backend_uri is None: - msg = "dialog_backend_uri is required for skill_type='dialog'" - raise ValueError(msg) - draft = build_dialog_draft_payload( - skill_name=skill_name, - backend_uri=dialog_backend_uri, - logo_id=logo_id, - developer_name=developer_name, - ) - else: - backend_uri = derive_backend_uri( - mass, connection_type, base_url_override=base_url_override - ) - draft = build_draft_payload( - connection_type=connection_type, - skill_name=skill_name, - backend_uri=backend_uri, - logo_id=logo_id, - developer_name=developer_name, - ) - _LOGGER.info("auto-skill: [3/5] updating draft with settings") - await creator.update_draft(csrf, skill_id, draft) - artifacts = dataclasses.replace( - artifacts, - state=SkillCreationState.DRAFT_UPDATED, - last_known_name=skill_name, - ) - await _maybe_save(progress_cb, artifacts) - state = artifacts.state - - # -- Step 6: create OAuth app -- - if state == SkillCreationState.DRAFT_UPDATED: - client_id = derive_client_id(connection_type, cloud_instance_id) - client_secret = ( - CLOUD_SKILL_CLIENT_SECRET - if connection_type == CONNECTION_TYPE_CLOUD_PLUS - else direct_client_secret - ) - authorize_url, token_url = derive_auth_urls( - mass, connection_type, base_url_override=base_url_override - ) - _LOGGER.info("auto-skill: [4/5] creating OAuth app + attaching") - oauth_app_id = await creator.create_oauth_app( - csrf, - name=skill_name, - client_id=client_id, - client_secret=client_secret, - authorize_url=authorize_url, - token_url=token_url, - refresh_url=token_url, - ) - artifacts = dataclasses.replace( - artifacts, - state=SkillCreationState.OAUTH_CREATED, - oauth_app_id=oauth_app_id, - ) - await _maybe_save(progress_cb, artifacts) - state = artifacts.state - - if artifacts.oauth_app_id is None: - msg = "internal error: oauth_app_id missing after create_oauth_app" - raise RuntimeError(msg) - oauth_app_id_str: str = artifacts.oauth_app_id - - # -- Step 7: attach OAuth app to skill -- - if state == SkillCreationState.OAUTH_CREATED: - await creator.attach_oauth(csrf, skill_id, oauth_app_id_str) - artifacts = dataclasses.replace(artifacts, state=SkillCreationState.OAUTH_ATTACHED) - await _maybe_save(progress_cb, artifacts) - state = artifacts.state - - # -- Step 8: publish -- - # Checkpoint state=DEPLOY_REQUESTED *before* calling request_deploy - # so a crash after the call reached Yandex but before we returned - # can skip straight to DONE on retry (Yandex accepts the idempotent - # re-deploy but we'd rather not re-drive the flow end-to-end). - if state == SkillCreationState.OAUTH_ATTACHED: - artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DEPLOY_REQUESTED) - await _maybe_save(progress_cb, artifacts) - state = artifacts.state - - if state == SkillCreationState.DEPLOY_REQUESTED: - _LOGGER.info("auto-skill: [5/5] publishing skill") - await creator.request_deploy(csrf, skill_id) - # Yandex's deploy is async — for smart_home it usually completes - # in a few seconds, but for aliceSkill ("Навык") it can take - # 5-15 minutes under typical moderation queue conditions. We - # don't block the config-flow waiting; the request was accepted, - # Yandex will finish on its side. The UI surfaces a direct link - # to the skill's dev-console page so the user can check the - # on-air indicator at their convenience. - _LOGGER.info( - "auto-skill: deploy requested for skill %s — Yandex processes " - "this asynchronously (a few seconds for smart_home, several " - "minutes for dialog skills). Watch on-air status at " - "https://dialogs.yandex.ru/developer/skills/%s", - skill_id, - skill_id, - ) - artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DONE) - await _maybe_save(progress_cb, artifacts) - - return artifacts - - -async def auto_rename_dialog_skill( # noqa: PLR0913 - *, - mass: MusicAssistant, - artifacts: SkillCreationArtifacts, - new_name: str, - dialog_backend_uri: str, - session_id: str, - cached_x_token: str | None = None, - on_token_obtained: Callable[[str], None] | None = None, - authenticator: Callable[..., AsyncIterator[aiohttp.ClientSession]] | None = None, - creator_factory: Callable[[aiohttp.ClientSession], DialogsSkillCreator] | None = None, - timeout: float = DEVICE_FLOW_TIMEOUT_SECONDS, - developer_name: str = "Music Assistant user", -) -> SkillCreationArtifacts: - """Rename a dialog skill and re-deploy it. - - Patches the draft ``name`` field and calls ``request_deploy``. Does - not raise on failure — returns artifacts with ``state=FAILED`` and - ``last_error`` so the UI can display the message. - - On success the returned artifacts have ``last_known_name=new_name`` - and ``state=DONE`` so the drift-detector in the UI clears the banner. - """ - if artifacts.skill_id is None: - msg = "skill_id is missing — cannot rename a skill that has not been created" - return dataclasses.replace(artifacts, state=SkillCreationState.FAILED, last_error=msg) - - skill_id = artifacts.skill_id - auth_fn = authenticator or _default_authenticator - - try: - async with _build_authenticator_cm( - auth_fn, - mass=mass, - session_id=session_id, - timeout=timeout, - cached_x_token=cached_x_token, - on_token_obtained=on_token_obtained, - ) as session: - creator = ( - creator_factory(session) - if creator_factory is not None - else DialogsSkillCreator(session, channel=DIALOG_CHANNEL) - ) - csrf = await creator.fetch_csrf() - - draft = build_dialog_draft_payload( - skill_name=new_name, - backend_uri=dialog_backend_uri, - logo_id=artifacts.logo_id, - developer_name=developer_name, - ) - await creator.update_draft(csrf, skill_id, draft) - await creator.request_deploy(csrf, skill_id) - _LOGGER.info("auto-skill: dialog skill renamed to %r and re-deployed", new_name) - return dataclasses.replace( - artifacts, - state=SkillCreationState.DONE, - last_known_name=new_name, - last_error=None, - ) - except asyncio.CancelledError: - raise - except DialogsApiError as exc: - _LOGGER.warning("rename-dialog-skill failed: %s", exc, exc_info=True) - return dataclasses.replace(artifacts, state=SkillCreationState.FAILED, last_error=str(exc)) - except Exception as exc: - _LOGGER.exception("rename-dialog-skill hit unexpected error") - return dataclasses.replace(artifacts, state=SkillCreationState.FAILED, last_error=repr(exc)) - - -# Minimal 1x1 transparent PNG — used when the packaged logo is missing -# (e.g. during unit tests before the asset commit lands). Real installs -# pick up provider/auto_skill_logo.png instead. -_FALLBACK_LOGO_PNG = bytes.fromhex( - "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" - "0000000d49444154789c6300010000050001d0a0c9a30000000049454e44ae426082" -) - - -def load_default_logo_bytes() -> bytes: - """Return PNG bytes for the skill logo. - - Reads ``provider/auto_skill_logo.png`` if it exists; otherwise - returns a 1x1 transparent PNG so tests can run without the asset. - """ - path = Path(__file__).parent / "auto_skill_logo.png" - if path.is_file(): - return path.read_bytes() - return _FALLBACK_LOGO_PNG - - -async def _maybe_save( - progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, - artifacts: SkillCreationArtifacts, -) -> None: - """Call ``progress_cb`` if provided, swallowing any save errors.""" - if progress_cb is None: - return - try: - await progress_cb(artifacts) - except Exception: - _LOGGER.exception("progress_cb raised; continuing pipeline anyway") diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_logo.png b/music_assistant/providers/yandex_smarthome/auto_skill_logo.png deleted file mode 100644 index 86904a3613f1ca190f15249cb3df0ac60f7c9546..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43607 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 t4zz8O3}a}e;Otzn>Et7`Y?e*ir+p$Py0 diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_state.py b/music_assistant/providers/yandex_smarthome/auto_skill_state.py deleted file mode 100644 index d114b57a0e..0000000000 --- a/music_assistant/providers/yandex_smarthome/auto_skill_state.py +++ /dev/null @@ -1,122 +0,0 @@ -"""State model for experimental auto-create-skill feature. - -Tracks progress of the multi-step skill creation flow against -dialogs.yandex.ru so partial failures can be retried from the -last successful step rather than starting over. -""" - -from __future__ import annotations - -import dataclasses -import json -import logging -from dataclasses import dataclass -from enum import StrEnum - -_LOGGER = logging.getLogger(__name__) - -__all__ = [ - "SkillCreationArtifacts", - "SkillCreationState", - "dump_artifacts", - "load_artifacts", -] - - -class SkillCreationState(StrEnum): - """Progress marker for the skill-creation pipeline. - - Linear states advance through the 6 HAR-captured API calls. - ``FAILED`` replaces the stored linear state in the artifact; - failure details are kept separately in ``last_error``, while - captured artifact IDs (``skill_id`` / ``logo_id`` / ``oauth_app_id``) - stay intact so a retry can resume from the partial results. - """ - - NONE = "none" - APP_CREATED = "app_created" - DRAFT_UPDATED = "draft_updated" - OAUTH_CREATED = "oauth_created" - OAUTH_ATTACHED = "oauth_attached" - DEPLOY_REQUESTED = "deploy_requested" - DONE = "done" - FAILED = "failed" - - -@dataclass(frozen=True, slots=True) -class SkillCreationArtifacts: - """Persistent state for a skill-creation attempt. - - Stored as a JSON blob in the ``CONF_AUTO_CREATE_ARTIFACTS`` - config entry and round-tripped through every call to - ``get_config_entries``. - """ - - state: SkillCreationState = SkillCreationState.NONE - skill_id: str | None = None - logo_id: str | None = None - oauth_app_id: str | None = None - last_error: str | None = None - last_known_name: str | None = None # MA-side mirror of the skill name in Yandex Dialogs - - -def dump_artifacts(artifacts: SkillCreationArtifacts) -> str: - """Serialise artifacts to a JSON string for config storage.""" - return json.dumps( - { - "state": artifacts.state.value, - "skill_id": artifacts.skill_id, - "logo_id": artifacts.logo_id, - "oauth_app_id": artifacts.oauth_app_id, - "last_error": artifacts.last_error, - "last_known_name": artifacts.last_known_name, - }, - ensure_ascii=False, - ) - - -def load_artifacts(raw: str | None) -> SkillCreationArtifacts: - """Deserialise artifacts from a config-stored JSON string. - - Returns a fresh ``SkillCreationArtifacts`` on any parse error - or missing input — the feature is optional so config stays - usable even if the blob is corrupted. - """ - if not raw: - return SkillCreationArtifacts() - try: - data = json.loads(raw) - except (ValueError, TypeError): - _LOGGER.warning("auto-skill artifacts corrupt, resetting") - return SkillCreationArtifacts() - if not isinstance(data, dict): - return SkillCreationArtifacts() - - try: - state = SkillCreationState(str(data.get("state", SkillCreationState.NONE.value))) - except ValueError: - state = SkillCreationState.NONE - - def _opt_str(key: str) -> str | None: - value = data.get(key) - if value is None: - return None - return str(value) if value else None - - return SkillCreationArtifacts( - state=state, - skill_id=_opt_str("skill_id"), - logo_id=_opt_str("logo_id"), - oauth_app_id=_opt_str("oauth_app_id"), - last_error=_opt_str("last_error"), - last_known_name=_opt_str("last_known_name"), - ) - - -def mark_failed(artifacts: SkillCreationArtifacts, error: str) -> SkillCreationArtifacts: - """Return a copy of *artifacts* flipped to ``FAILED`` with an error.""" - return dataclasses.replace( - artifacts, - state=SkillCreationState.FAILED, - last_error=error, - ) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py deleted file mode 100644 index 5f4744d068..0000000000 --- a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py +++ /dev/null @@ -1,1124 +0,0 @@ -"""ConfigEntry builders for the Yandex Smart Home provider. - -Builds the numbered-step config form per connection type: - -* ``cloud_plus`` → 3 steps: Register cloud → Create skill → Link via OTP. -* ``direct`` → 1 step: Create skill (skill is linked by Yandex Dialogs - account-linking UI, no OTP). -* ``cloud`` → 2 steps: Register → Link via OTP (unchanged). - -Each step hides until the previous completes, so the user always sees -the single next action they need to take. - -Kept separate from ``__init__.py`` so the long field list doesn't bloat -``get_config_entries`` and so it can be unit-tested in isolation from -the network-facing parts of the feature. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from music_assistant_models.config_entries import ConfigEntry -from music_assistant_models.enums import ConfigEntryType - -from .auto_skill_state import SkillCreationArtifacts, SkillCreationState -from .constants import ( - CLOUD_OAUTH_AUTHORIZE_URL, - CLOUD_OAUTH_TOKEN_URL, - CLOUD_SKILL_CLIENT_ID_TEMPLATE, - CLOUD_SKILL_CLIENT_SECRET, - CLOUD_SKILL_WEBHOOK_TEMPLATE, - CONF_ACTION_AUTO_CREATE, - CONF_ACTION_AUTO_CREATE_DIALOG, - CONF_ACTION_GET_OTP, - CONF_ACTION_REGISTER, - CONF_ACTION_RENAME_DIALOG_SKILL, - CONF_AUTO_CREATE_ARTIFACTS, - CONF_AUTO_CREATE_SESSION_ID, - CONF_CONNECTION_TYPE, - CONF_DIALOG_AUTO_CREATE_ARTIFACTS, - CONF_DIALOG_AUTO_CREATE_SESSION_ID, - CONF_DIALOG_SKILL_ENABLED, - CONF_DIALOG_SKILL_ID, - CONF_DIALOG_SKILL_NAME, - CONF_DIRECT_CLIENT_SECRET, - CONF_SKILL_ID, - CONF_SKILL_TOKEN, - CONNECTION_TYPE_CLOUD, - CONNECTION_TYPE_CLOUD_PLUS, - CONNECTION_TYPE_DIRECT, - DIALOG_DEFAULT_NAME, - DIALOG_NAME_MAX_LEN, - DIALOG_NAME_MIN_LEN, - DIRECT_AUTH_BASE_PATH, - DIRECT_BACKEND_URI_PATH, - DIRECT_OAUTH_CLIENT_ID, - YANDEX_DIALOGS_DEVELOPER_URL, - YANDEX_OAUTH_URL, -) - -if TYPE_CHECKING: - from collections.abc import Sequence - -__all__ = [ - "AUTO_CREATE_CATEGORY", - "auto_create_entries", - "build_cloud_plus_entries", - "build_direct_entries", - "should_show_button", -] - -AUTO_CREATE_CATEGORY = "Auto-create skill" - -# Category names are used as visual group headers in the MA UI. Numbered -# so they render in order and users see the flow as a sequence. -_CAT_STEP_1_REGISTER = "Step 1 — Register cloud instance" -_CAT_STEP_2_CREATE = "Step 2 — Create Smart Home skill" -_CAT_STEP_3_LINK = "Step 3 — Link skill to Yandex" -# Direct mode has only one step — no cloud registration, no OTP linking. -_CAT_STEP_DIRECT_CREATE = "Create Smart Home skill" -# Dialog skill section — direct mode only, experimental. -_CAT_DIALOG_SKILL = "🧪 Experimental — Dialogs voice skill (free-form playback)" - - -def _status_label(state: SkillCreationState, last_error: str | None) -> str: - """Human-readable status line shown in the UI.""" - if state == SkillCreationState.DONE: - return ( - "✅ Skill created and published. Now get the OAuth token " - "(link below) and paste it into 'Skill OAuth Token'." - ) - if state == SkillCreationState.FAILED: - err = last_error or "unknown error" - return ( - f"❌ Creation failed: {err}\n" - f"Press '{_action_label(state)}' to try again, or fill in " - "Skill ID / Skill OAuth Token manually below." - ) - if state == SkillCreationState.NONE: - return "Ready to create skill. Press the button below to start." - # Any partial state — resume is possible. - return ( - f"Partial progress saved ({state.value}). " - f"Press '{_action_label(state)}' to finish, or fill Skill ID manually." - ) - - -def _action_label(state: SkillCreationState) -> str: - if state == SkillCreationState.NONE: - return "Create skill automatically" - if state == SkillCreationState.FAILED: - return "Retry" - return "Retry from last step" - - -def should_show_button( - *, - connection_type: str, - state: SkillCreationState, - cloud_instance_id: str, - base_url: str, -) -> bool: - """Return True iff the auto-create action button is actionable now. - - Hides the button when: - - Mode is plain ``cloud`` (no custom skill exists there). - - Skill creation already reached DONE. - - cloud_plus is selected but no cloud instance has been registered. - - direct is selected but MA base_url is not HTTPS. - """ - if connection_type == CONNECTION_TYPE_CLOUD: - return False - if state == SkillCreationState.DONE: - return False - if connection_type == CONNECTION_TYPE_CLOUD_PLUS and not cloud_instance_id: - return False - return not (connection_type == CONNECTION_TYPE_DIRECT and not base_url.startswith("https://")) - - -def auto_create_entries( - *, - connection_type: str, - artifacts: SkillCreationArtifacts, - cloud_instance_id: str, - base_url: str, - session_id: str | None, - user_code: str | None, - verification_url: str | None, - existing_artifacts_raw: str | None, -) -> Sequence[ConfigEntry]: - """Build the auto-create section of the config form. - - Empty list for ``cloud`` mode — the feature is meaningless without - a custom skill. - """ - if connection_type == CONNECTION_TYPE_CLOUD: - return () - - entries: list[ConfigEntry] = [] - - # Device-flow user code — shown when the flow obtained one this round. - if user_code: - entries.append( - ConfigEntry( - key="auto_create_user_code", - type=ConfigEntryType.STRING, - label="Device code for ya.ru/device", - description=( - "Open the URL below in your browser, log in to your " - "Yandex account, and enter this code." - ), - value=user_code, - required=False, - help_link=verification_url or "https://ya.ru/device", - depends_on=CONF_CONNECTION_TYPE, - depends_on_value_not=CONNECTION_TYPE_CLOUD, - category=AUTO_CREATE_CATEGORY, - ) - ) - - # Status label — dynamic based on current artifact state. - entries.append( - ConfigEntry( - key="label_auto_create_status", - type=ConfigEntryType.LABEL, - label=_status_label(artifacts.state, artifacts.last_error), - depends_on=CONF_CONNECTION_TYPE, - depends_on_value_not=CONNECTION_TYPE_CLOUD, - category=AUTO_CREATE_CATEGORY, - ) - ) - - # The action button — hidden in states where it can't run. - show_button = should_show_button( - connection_type=connection_type, - state=artifacts.state, - cloud_instance_id=cloud_instance_id, - base_url=base_url, - ) - entries.append( - ConfigEntry( - key=CONF_ACTION_AUTO_CREATE, - type=ConfigEntryType.ACTION, - label=_action_label(artifacts.state), - description=( - "Runs the Yandex Device Flow login, then creates and " - "publishes the private Smart Home skill. Takes ~30 seconds " - "after you enter the code." - ), - action=CONF_ACTION_AUTO_CREATE, - action_label=_action_label(artifacts.state), - hidden=not show_button, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value_not=CONNECTION_TYPE_CLOUD, - category=AUTO_CREATE_CATEGORY, - ) - ) - - entries.extend(_hidden_state_entries(existing_artifacts_raw, session_id)) - return entries - - -def _hidden_state_entries( - existing_artifacts_raw: str | None, session_id: str | None -) -> list[ConfigEntry]: - """Round-trip the artifact blob and session id through the config form.""" - return [ - ConfigEntry( - key=CONF_AUTO_CREATE_ARTIFACTS, - type=ConfigEntryType.STRING, - label="Auto-create artifacts (internal)", - hidden=True, - required=False, - value=existing_artifacts_raw, - ), - ConfigEntry( - key=CONF_AUTO_CREATE_SESSION_ID, - type=ConfigEntryType.STRING, - label="Auto-create session id (internal)", - hidden=True, - required=False, - value=session_id, - ), - ] - - -# --------------------------------------------------------------------------- -# Cloud Plus step-flow -# --------------------------------------------------------------------------- - - -def build_cloud_plus_entries( # noqa: PLR0913 - *, - otp_code: str | None, - is_registered: bool, - cloud_instance_id: str, - artifacts: SkillCreationArtifacts, - session_id: str | None, - user_code: str | None, - verification_url: str | None, - existing_artifacts_raw: str | None, - base_url: str, - skill_id: str = "", - skill_token_set: bool = False, -) -> list[ConfigEntry]: - """Return the cloud_plus-mode config entries as three visible steps. - - Step 1 (Register) — always visible. - Step 2 (Create skill) — visible once cloud instance is registered. - Step 3 (Link via OTP) — visible once the skill (id + token) is set. - """ - skill_id_set = bool(skill_id) - fully_configured = skill_id_set and skill_token_set - - entries: list[ConfigEntry] = [] - entries.extend(_step1_register_entries(is_registered, cloud_instance_id)) - entries.extend( - _step2_create_skill_entries( - is_registered=is_registered, - cloud_instance_id=cloud_instance_id, - artifacts=artifacts, - user_code=user_code, - verification_url=verification_url, - base_url=base_url, - skill_id=skill_id, - fully_configured=fully_configured, - ) - ) - entries.extend( - _step3_link_entries( - is_registered=is_registered, - skill_id_set=skill_id_set, - otp_code=otp_code, - ) - ) - entries.extend(_hidden_state_entries(existing_artifacts_raw, session_id)) - return entries - - -def _step1_register_entries(is_registered: bool, cloud_instance_id: str) -> list[ConfigEntry]: - """Step 1 — yaha-cloud.ru instance registration.""" - status_text = ( - f"✅ Cloud instance registered (id: {cloud_instance_id})." - if is_registered - else ( - "Click 'Register with cloud' to create a yaha-cloud.ru relay " - "instance. This is free and takes a second." - ) - ) - return [ - ConfigEntry( - key="label_step1_status", - type=ConfigEntryType.LABEL, - label=status_text, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - category=_CAT_STEP_1_REGISTER, - ), - ConfigEntry( - key=CONF_ACTION_REGISTER, - type=ConfigEntryType.ACTION, - label="Register cloud instance", - description="Registers a new instance on yaha-cloud.ru relay.", - action=CONF_ACTION_REGISTER, - action_label="Register with cloud", - hidden=is_registered, - # No depends_on — see note above action_auto_create. - category=_CAT_STEP_1_REGISTER, - ), - ] - - -def _create_skill_step_entries( - *, - connection_type: str, - category: str, - cloud_instance_id: str, - artifacts: SkillCreationArtifacts, - user_code: str | None, - verification_url: str | None, - base_url: str, - direct_client_secret: str = "", - skill_id: str = "", - fully_configured: bool = False, -) -> list[ConfigEntry]: - """Shared builder for the Create-Skill step. - - Used by both cloud_plus Step 2 and direct single-step mode. - - Skill ID / Skill OAuth Token are shown after DONE (happy path) or - FAILED (so the user can finish manually). FAILED additionally shows - manual copy-paste fields (Backend URL / Client ID / Secret / Auth - URLs / Dialogs console link) so the user can create the skill by - hand in Yandex.Dialogs without leaving the form. - """ - entries: list[ConfigEntry] = [] - - if user_code: - entries.append( - ConfigEntry( - key="auto_create_user_code", - type=ConfigEntryType.STRING, - label="Device code for ya.ru/device", - description=( - "Open the URL below, log in to your Yandex account, and enter this code." - ), - value=user_code, - required=False, - help_link=verification_url or "https://ya.ru/device", - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ) - ) - - entries.append( - ConfigEntry( - key="label_create_skill_status", - type=ConfigEntryType.LABEL, - label=_status_label(artifacts.state, artifacts.last_error), - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ) - ) - - # direct mode prerequisite: Yandex requires HTTPS. If the resolved - # base URL (plugin override → MA global) is not HTTPS, point the user - # at the External Base URL field above (preferred) or MA's global - # Base URL setting as a fallback. - direct_https_missing = connection_type == CONNECTION_TYPE_DIRECT and not base_url.startswith( - "https://" - ) - if direct_https_missing: - entries.append( - ConfigEntry( - key="label_direct_https_warning", - type=ConfigEntryType.LABEL, - label=( - f"⚠️ Resolved Base URL is {base_url or ''}. " - "Direct mode requires a **publicly reachable HTTPS URL** — " - "Yandex refuses to talk to a non-HTTPS backend. " - "Set up a reverse proxy with a real certificate, then either " - "fill the **External Base URL** field above (recommended — " - "doesn't affect MA's local access / HA Ingress) " - "or update Settings → Core → Webserver → Base URL globally. " - "Save and reopen this page after." - ), - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ) - ) - - show_button = should_show_button( - connection_type=connection_type, - state=artifacts.state, - cloud_instance_id=cloud_instance_id, - base_url=base_url, - ) - entries.append( - ConfigEntry( - key=CONF_ACTION_AUTO_CREATE, - type=ConfigEntryType.ACTION, - label=_action_label(artifacts.state), - description=( - "Runs the Yandex Device Flow login, then creates and " - "publishes the private Smart Home skill." - ), - action=CONF_ACTION_AUTO_CREATE, - action_label=_action_label(artifacts.state), - hidden=not show_button, - # No depends_on — MA disables actions with unsaved-dependency - # fields until the user clicks Save, which breaks the flow. - # Visibility is already correctly gated via `hidden`. - category=category, - ) - ) - - # Manual-fallback copy-paste fields — always emitted so advanced - # users can edit them, but on any state other than FAILED they're - # marked ``advanced=True`` so default view stays clean. On FAILED - # they show up unconditionally (auto-fallback UX). - entries.extend( - _manual_fallback_entries( - connection_type=connection_type, - category=category, - cloud_instance_id=cloud_instance_id, - base_url=base_url, - direct_client_secret=direct_client_secret, - advanced=artifacts.state != SkillCreationState.FAILED, - ) - ) - - # Skill ID / Skill OAuth Token / OAuth URL — the actual fields the - # provider needs at runtime. Auto-shown once auto-create reached - # DONE or FAILED; hidden under Advanced once the user has fully - # configured them (so a clean default view stays clean after setup). - token_fields_advanced = fully_configured or artifacts.state not in ( - SkillCreationState.DONE, - SkillCreationState.FAILED, - ) - entries.extend( - [ - ConfigEntry( - key="oauth_url", - type=ConfigEntryType.STRING, - label="OAuth URL (open to get token)", - description=( - "Open this URL in your browser, approve, and copy the " - "access_token from the resulting URL into the field below." - ), - required=False, - default_value=YANDEX_OAUTH_URL, - help_link=YANDEX_OAUTH_URL, - advanced=token_fields_advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ConfigEntry( - key=CONF_SKILL_TOKEN, - type=ConfigEntryType.SECURE_STRING, - label="Skill OAuth Token", - description="Paste the OAuth token obtained from the URL above.", - required=False, - advanced=token_fields_advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ConfigEntry( - key=CONF_SKILL_ID, - type=ConfigEntryType.STRING, - label="Skill ID", - description=( - "UUID of your private Smart Home skill. Set automatically " - "when auto-create succeeds; you can paste it manually if " - "you created the skill by hand." - ), - required=False, - advanced=token_fields_advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ] - ) - - # Once setup is complete, replace the cluster of edit fields with - # a single non-editable link to the skill in Yandex.Dialogs so the - # user can quickly open it (the fields are still available under - # Advanced if they need to re-edit). - if fully_configured and skill_id: - skill_url = f"https://dialogs.yandex.ru/developer/skills/{skill_id}/" - entries.append( - ConfigEntry( - key="skill_dialogs_link", - type=ConfigEntryType.STRING, - label="Open skill in Yandex.Dialogs", - description="Click the link to open the skill's page.", - required=False, - default_value=skill_url, - help_link=skill_url, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ) - ) - - return entries - - -def _manual_fallback_entries( - *, - connection_type: str, - category: str, - cloud_instance_id: str, - base_url: str, - direct_client_secret: str, - advanced: bool, -) -> list[ConfigEntry]: - """Copy-paste fields for creating the skill by hand. - - ``advanced=True`` → hidden from the default view, shown when the - user toggles Advanced. Used when auto-create is expected to work - but power users still want to see/edit everything. - - ``advanced=False`` → visible unconditionally. Used on FAILED state - so the user has everything they need to finish manually without - needing to click Advanced. - """ - if connection_type == CONNECTION_TYPE_CLOUD_PLUS: - # The cloud_plus Client ID embeds the yaha-cloud instance UUID, - # which doesn't exist until the user registers. Suppress the - # manual block entirely before registration rather than render - # an invalid ``yandex_smart_home:`` Client ID that would lead - # someone to create a broken skill. - if not cloud_instance_id: - return [] - backend_uri = CLOUD_SKILL_WEBHOOK_TEMPLATE - client_id = CLOUD_SKILL_CLIENT_ID_TEMPLATE.format(instance_id=cloud_instance_id) - client_secret = CLOUD_SKILL_CLIENT_SECRET - auth_url = CLOUD_OAUTH_AUTHORIZE_URL - token_url = CLOUD_OAUTH_TOKEN_URL - elif connection_type == CONNECTION_TYPE_DIRECT: - base = base_url.rstrip("/") or "https://" - backend_uri = f"{base}{DIRECT_BACKEND_URI_PATH}" - client_id = DIRECT_OAUTH_CLIENT_ID - client_secret = direct_client_secret or "(auto-generated on save)" - auth_url = f"{base}{DIRECT_AUTH_BASE_PATH}/authorize" - token_url = f"{base}{DIRECT_AUTH_BASE_PATH}/token" - else: - return [] - - label_text = ( - "Auto-create failed — you can create the skill by hand instead. " - "Open Yandex.Dialogs (link below), create a private Smart Home " - "skill, paste the values below into the skill's Basic info and " - "Account linking tabs, then put the skill UUID in the Skill ID " - "field below." - if not advanced - else ( - "Manual setup values — copy these into Yandex.Dialogs if you " - "prefer to create the skill by hand, or want to verify what " - "auto-create used." - ) - ) - - # For direct mode, also surface the hidden generated secret as an - # editable field so user can copy it. - extra: list[ConfigEntry] = [] - if connection_type == CONNECTION_TYPE_DIRECT and direct_client_secret: - extra.append( - ConfigEntry( - key=CONF_DIRECT_CLIENT_SECRET, - type=ConfigEntryType.SECURE_STRING, - label="Client Secret (→ Account linking)", - description="Copy to 'Account linking' → 'Client secret' field.", - required=False, - default_value=direct_client_secret, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ) - ) - - return [ - ConfigEntry( - key="manual_fallback_label", - type=ConfigEntryType.LABEL, - label=label_text, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ConfigEntry( - key="manual_dialogs_url", - type=ConfigEntryType.STRING, - label="Yandex.Dialogs Console", - required=False, - default_value=YANDEX_DIALOGS_DEVELOPER_URL, - help_link=YANDEX_DIALOGS_DEVELOPER_URL, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ConfigEntry( - key="manual_backend_url", - type=ConfigEntryType.STRING, - label="Backend URL (→ Basic info)", - description="Copy to 'Basic info' → 'Backend URL' in your skill.", - required=False, - # Reference-only fields use default_value so MA UI renders - # the text without storing it as a mutable config value - # (``value=`` was returning empty on first render before the - # user clicked Save). - default_value=backend_uri, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ConfigEntry( - key="manual_client_id", - type=ConfigEntryType.STRING, - label="Client ID (→ Account linking)", - description="Copy to 'Account linking' → 'Client identifier' field.", - required=False, - default_value=client_id, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - *extra, - # manual_client_secret is a plain STRING so it's readable for - # copy-paste in cloud_plus (where the value is the literal - # "secret"). For direct mode we skip it: the real per-install - # UUID is surfaced via the SECURE_STRING CONF_DIRECT_CLIENT_SECRET - # entry in ``extra`` above, and showing the same value in a - # second unmasked STRING would leak it. - *( - [ - ConfigEntry( - key="manual_client_secret", - type=ConfigEntryType.STRING, - label="Client Secret value (for reference)", - description=("Copy this string into 'Account linking' → 'Client secret'."), - required=False, - default_value=client_secret, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ) - ] - if connection_type != CONNECTION_TYPE_DIRECT - else [] - ), - ConfigEntry( - key="manual_auth_url", - type=ConfigEntryType.STRING, - label="Authorization URL (→ Account linking)", - required=False, - default_value=auth_url, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ConfigEntry( - key="manual_token_url", - type=ConfigEntryType.STRING, - label="Token URL (→ Account linking, both fields)", - description="Paste into BOTH 'Token endpoint' and 'Refresh token URL'.", - required=False, - default_value=token_url, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ] - - -def _step2_create_skill_entries( - *, - is_registered: bool, # noqa: ARG001 — kept for call-site symmetry - cloud_instance_id: str, - artifacts: SkillCreationArtifacts, - user_code: str | None, - verification_url: str | None, - base_url: str, - skill_id: str = "", - fully_configured: bool = False, -) -> list[ConfigEntry]: - """cloud_plus Step 2 — always emitted. - - The Create-Skill action button self-hides via ``should_show_button`` - when no cloud instance exists yet, but the section's other fields - (status label + advanced manual-setup references) stay visible so - power users can see everything under Advanced without first going - through the register step. - """ - return _create_skill_step_entries( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - category=_CAT_STEP_2_CREATE, - cloud_instance_id=cloud_instance_id, - artifacts=artifacts, - user_code=user_code, - verification_url=verification_url, - base_url=base_url, - skill_id=skill_id, - fully_configured=fully_configured, - ) - - -def _step3_link_entries( - *, is_registered: bool, skill_id_set: bool, otp_code: str | None -) -> list[ConfigEntry]: - """Step 3 — get an OTP from the cloud and enter it in the Yandex app. - - Hidden until both Step 1 (cloud registration) and Step 2 (skill - created — ``skill_id_set``) are done: OTP linking only makes sense - once the private skill exists in Yandex.Dialogs for the user to - link against in the Yandex app. - """ - if not is_registered or not skill_id_set: - return [] - - # Banner priority: fresh OTP > linked (skill configured). - if otp_code: - banner_text = f"Enter this OTP in the Yandex app: {otp_code}" - else: - banner_text = ( - "✅ Skill configured. Press 'Get OTP code' to link it with your " - "Yandex account (or re-link if you ever unlinked it)." - ) - - entries: list[ConfigEntry] = [ - ConfigEntry( - key="label_step3_status", - type=ConfigEntryType.LABEL, - label=banner_text, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - category=_CAT_STEP_3_LINK, - ), - ConfigEntry( - key="otp_code", - type=ConfigEntryType.STRING, - label="OTP Code", - description="Copy this code and enter it in the Yandex app.", - required=False, - value=otp_code, - hidden=not otp_code, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - category=_CAT_STEP_3_LINK, - ), - ConfigEntry( - key=CONF_ACTION_GET_OTP, - type=ConfigEntryType.ACTION, - label="Get OTP code", - description="Get a fresh one-time password to link with Yandex.", - action=CONF_ACTION_GET_OTP, - action_label="Get OTP code", - # No depends_on — see note above action_auto_create. - category=_CAT_STEP_3_LINK, - ), - ] - return entries - - -# --------------------------------------------------------------------------- -# Direct-mode flow (auto-create only; no cloud registration, no OTP) -# --------------------------------------------------------------------------- - - -def build_direct_entries( # noqa: PLR0913 - *, - artifacts: SkillCreationArtifacts, - session_id: str | None, - user_code: str | None, - verification_url: str | None, - existing_artifacts_raw: str | None, - base_url: str, - direct_client_secret: str = "", - skill_id: str = "", - skill_token_set: bool = False, - # Dialog skill params (experimental, direct-only) - dialog_skill_enabled: bool = False, - dialog_skill_name: str = DIALOG_DEFAULT_NAME, - dialog_artifacts: SkillCreationArtifacts | None = None, - dialog_skill_id: str = "", - dialog_session_id: str | None = None, - dialog_existing_artifacts_raw: str | None = None, - dialog_user_code: str | None = None, - dialog_verification_url: str | None = None, -) -> list[ConfigEntry]: - """Return the direct-mode config entries as a single Create-Skill step. - - direct mode has no yaha-cloud registration (Step 1) and no OTP - linking (Step 3) — Yandex Dialogs' account-linking UI handles that - once the skill exists. - - Optionally includes the experimental dialog skill section when - ``dialog_skill_enabled`` is True. - """ - fully_configured = bool(skill_id) and skill_token_set - entries = _create_skill_step_entries( - connection_type=CONNECTION_TYPE_DIRECT, - category=_CAT_STEP_DIRECT_CREATE, - cloud_instance_id="", - artifacts=artifacts, - user_code=user_code, - verification_url=verification_url, - base_url=base_url, - direct_client_secret=direct_client_secret, - skill_id=skill_id, - fully_configured=fully_configured, - ) - entries.extend(_hidden_state_entries(existing_artifacts_raw, session_id)) - entries.extend( - _dialog_skill_entries( - enabled=dialog_skill_enabled, - skill_name=dialog_skill_name, - artifacts=dialog_artifacts or SkillCreationArtifacts(), - dialog_skill_id=dialog_skill_id, - base_url=base_url, - session_id=dialog_session_id, - existing_artifacts_raw=dialog_existing_artifacts_raw, - user_code=dialog_user_code, - verification_url=dialog_verification_url, - ) - ) - return entries - - -def _dialog_skill_entries( - *, - enabled: bool, - skill_name: str, - artifacts: SkillCreationArtifacts, - dialog_skill_id: str, - base_url: str, - session_id: str | None, - existing_artifacts_raw: str | None, - user_code: str | None, - verification_url: str | None, -) -> list[ConfigEntry]: - """Build config entries for the experimental dialog skill section. - - Always emits the enable toggle (so the user can turn it on). - The rest of the entries are only emitted when ``enabled=True``. - """ - entries: list[ConfigEntry] = [ - ConfigEntry( - key=CONF_DIALOG_SKILL_ENABLED, - type=ConfigEntryType.BOOLEAN, - label="Enable experimental Dialogs voice skill", - description=( - "Enables a custom Yandex Dialogs «Навык» for free-form voice playback. " - 'Once created, say "Алиса, попроси включи Metallica на кухне". ' - "Requires a publicly reachable HTTPS URL. Direct mode only." - ), - default_value=False, - required=False, - category=_CAT_DIALOG_SKILL, - ) - ] - - if not enabled: - return entries - - # Skill activation name - entries.append( - ConfigEntry( - key=CONF_DIALOG_SKILL_NAME, - type=ConfigEntryType.STRING, - label="Skill activation name", - description=( - 'Used as the activation phrase: "Алиса, попроси …". ' - "For Yandex voice recognition, a Russian name works best. " - "**Must contain at least two words** (Yandex requirement) and " - "must be **globally unique across all Yandex skills** — pick " - "something distinctive. " - f"Length: {DIALOG_NAME_MIN_LEN}-{DIALOG_NAME_MAX_LEN} characters." - ), - default_value=DIALOG_DEFAULT_NAME, - required=True, - category=_CAT_DIALOG_SKILL, - ) - ) - - # Device-flow user code (shown while the Device Flow is in progress) - if user_code: - entries.append( - ConfigEntry( - key="dialog_auto_create_user_code", - type=ConfigEntryType.STRING, - label="Device code for ya.ru/device", - description=( - "Open the URL below, log in to your Yandex account, and enter this code." - ), - value=user_code, - required=False, - help_link=verification_url or "https://ya.ru/device", - category=_CAT_DIALOG_SKILL, - ) - ) - - # Status label - entries.append( - ConfigEntry( - key="label_dialog_skill_status", - type=ConfigEntryType.LABEL, - label=_dialog_status_label(artifacts.state, artifacts.last_error), - category=_CAT_DIALOG_SKILL, - ) - ) - - # Once a skill exists, surface a direct link to its dev-console page so - # the user can verify the publish status / tweak the form fields manually. - if artifacts.skill_id: - skill_url = f"https://dialogs.yandex.ru/developer/skills/{artifacts.skill_id}" - entries.append( - ConfigEntry( - key="label_dialog_skill_link", - type=ConfigEntryType.LABEL, - label=( - f"🔗 Skill in Yandex Dialogs dev console: {skill_url}\n\n" - "Status indicator at the top of that page shows whether " - "the skill is *on air* yet. Yandex deploys aliceSkills " - "asynchronously — for private skills this typically " - "takes a few minutes (sometimes 5-10 under load) after " - "auto-create completes. The skill is unusable on Alice " - "until the dev console shows «На воздухе»." # noqa: RUF001 - ), - category=_CAT_DIALOG_SKILL, - ) - ) - - # HTTPS prerequisite warning - direct_https_missing = not base_url.startswith("https://") - if direct_https_missing: - entries.append( - ConfigEntry( - key="label_dialog_https_warning", - type=ConfigEntryType.LABEL, - label=( - f"⚠️ Resolved Base URL is {base_url or ''}. " - "The dialog skill webhook requires a **publicly reachable HTTPS URL**. " - "Set up a reverse proxy with a real certificate, then either " - "fill the **External Base URL** field above (recommended — " - "doesn't affect MA's local access / HA Ingress) " - "or update Settings → Core → Webserver → Base URL globally. " - "Save and reopen this page after." - ), - category=_CAT_DIALOG_SKILL, - ) - ) - - # Auto-create action button - can_create = not direct_https_missing and artifacts.state != SkillCreationState.DONE - entries.append( - ConfigEntry( - key=CONF_ACTION_AUTO_CREATE_DIALOG, - type=ConfigEntryType.ACTION, - label=_action_label(artifacts.state), - description=( - "Runs the Yandex Device Flow login, then creates and publishes " - "the private Dialogs «Навык». Takes ~30 seconds after you enter the code." - ), - action=CONF_ACTION_AUTO_CREATE_DIALOG, - action_label=_action_label(artifacts.state), - hidden=not can_create, - category=_CAT_DIALOG_SKILL, - ) - ) - - # Rename button — shown when skill exists but name has drifted - name_drifted = ( - bool(dialog_skill_id) - and bool(artifacts.last_known_name) - and skill_name != artifacts.last_known_name - ) - if name_drifted: - entries.append( - ConfigEntry( - key="label_dialog_rename_warning", - type=ConfigEntryType.LABEL, - label=( - f"⚠️ Activation name in Yandex Dialogs is still " - f'"{artifacts.last_known_name}". Press the button below to ' - f'update it to "{skill_name}".' - ), - category=_CAT_DIALOG_SKILL, - ) - ) - entries.append( - ConfigEntry( - key=CONF_ACTION_RENAME_DIALOG_SKILL, - type=ConfigEntryType.ACTION, - label="Rename skill in Yandex Dialogs", - description=( - "Updates the skill name and re-deploys. " - 'After this, say "Алиса, попроси …".' - ), - action=CONF_ACTION_RENAME_DIALOG_SKILL, - action_label="Rename and re-deploy", - category=_CAT_DIALOG_SKILL, - ) - ) - - # Skill ID (manual override / reference) - entries.append( - ConfigEntry( - key=CONF_DIALOG_SKILL_ID, - type=ConfigEntryType.STRING, - label="Dialog Skill ID", - description=( - "UUID of the Dialogs «Навык» skill. Set automatically when " - "auto-create succeeds; you can paste it manually if you created " - "the skill by hand in Yandex.Dialogs." - ), - required=False, - advanced=artifacts.state - not in ( - SkillCreationState.DONE, - SkillCreationState.FAILED, - ), - category=_CAT_DIALOG_SKILL, - ) - ) - - # Dialogs console link (shown once skill exists) - if dialog_skill_id: - skill_url = f"https://dialogs.yandex.ru/developer/skills/{dialog_skill_id}/" - entries.append( - ConfigEntry( - key="dialog_skill_dialogs_link", - type=ConfigEntryType.STRING, - label="Open dialog skill in Yandex.Dialogs", - required=False, - default_value=skill_url, - help_link=skill_url, - advanced=True, - category=_CAT_DIALOG_SKILL, - ) - ) - - # Hidden round-trip state - entries.extend( - [ - ConfigEntry( - key=CONF_DIALOG_AUTO_CREATE_ARTIFACTS, - type=ConfigEntryType.STRING, - label="Dialog auto-create artifacts (internal)", - hidden=True, - required=False, - value=existing_artifacts_raw, - ), - ConfigEntry( - key=CONF_DIALOG_AUTO_CREATE_SESSION_ID, - type=ConfigEntryType.STRING, - label="Dialog auto-create session id (internal)", - hidden=True, - required=False, - value=session_id, - ), - ] - ) - - return entries - - -def _dialog_status_label(state: SkillCreationState, last_error: str | None) -> str: - """Human-readable status for the dialog skill auto-create.""" - if state == SkillCreationState.DONE: - return ( - "✅ Dialog skill created and published. " - 'You can now say "Алиса, попроси включи Metallica на кухне".' - ) - if state == SkillCreationState.FAILED: - err = last_error or "unknown error" - return ( - f"❌ Creation failed: {err}\n" - f"Press '{_action_label(state)}' to try again, or fill in " - "Dialog Skill ID manually below." - ) - if state == SkillCreationState.NONE: - return ( - "Ready to create dialog skill. " - "Set the activation name above, then press the button below." - ) - return ( - f"Partial progress saved ({state.value}). " - f"Press '{_action_label(state)}' to finish, or fill Dialog Skill ID manually." - ) diff --git a/music_assistant/providers/yandex_smarthome/cloud.py b/music_assistant/providers/yandex_smarthome/cloud.py index 35483504e4..4e9130dada 100644 --- a/music_assistant/providers/yandex_smarthome/cloud.py +++ b/music_assistant/providers/yandex_smarthome/cloud.py @@ -18,7 +18,7 @@ import aiohttp if TYPE_CHECKING: - from ._compat import SecretStr + from ya_dialogs_api import SecretStr from .constants import ( CLOUD_BASE_URL, diff --git a/music_assistant/providers/yandex_smarthome/constants.py b/music_assistant/providers/yandex_smarthome/constants.py index dba0eeaa12..0b6d0e91f8 100644 --- a/music_assistant/providers/yandex_smarthome/constants.py +++ b/music_assistant/providers/yandex_smarthome/constants.py @@ -2,8 +2,6 @@ from __future__ import annotations -import os - # --------------------------------------------------------------------------- # Config entry keys # --------------------------------------------------------------------------- @@ -22,21 +20,11 @@ CONF_EXPOSED_PLAYERS = "exposed_players" CONF_EXPOSED_PLAYLISTS = "exposed_playlists" -# Auto-create-skill feature state (round-trips through the config form) -CONF_AUTO_CREATE_ARTIFACTS = "auto_create_artifacts" -CONF_AUTO_CREATE_SESSION_ID = "auto_create_session_id" -# Cached Yandex Passport x_token from the first successful Device Flow. -# Reused on subsequent auto-create runs (Smart Home + Dialog) so the user -# does not have to confirm the device code every time. Long-lived (months); -# automatically refreshed on use. Cleared if Yandex returns 401 on refresh. -CONF_AUTH_X_TOKEN = "auth_x_token" - # --------------------------------------------------------------------------- # Config actions # --------------------------------------------------------------------------- CONF_ACTION_REGISTER = "register_cloud" CONF_ACTION_GET_OTP = "get_otp" -CONF_ACTION_AUTO_CREATE = "auto_create_skill" # --------------------------------------------------------------------------- # Connection types @@ -146,28 +134,3 @@ ERROR_INVALID_ACTION = "INVALID_ACTION" ERROR_INTERNAL_ERROR = "INTERNAL_ERROR" ERROR_DEVICE_NOT_FOUND = "DEVICE_NOT_FOUND" - -# --------------------------------------------------------------------------- -# Dialog skill (Yandex Dialogs custom skill — free-form voice playback) — experimental -# --------------------------------------------------------------------------- -CONF_DIALOG_SKILL_ENABLED = "dialog_skill_enabled" -CONF_DIALOG_SKILL_NAME = "dialog_skill_name" -CONF_DIALOG_SKILL_ID = "dialog_skill_id" -CONF_DIALOG_SKILL_TOKEN = "dialog_skill_token" -CONF_DIALOG_WEBHOOK_SECRET = "dialog_webhook_secret" -CONF_DIALOG_AUTO_CREATE_ARTIFACTS = "dialog_auto_create_artifacts" -CONF_DIALOG_AUTO_CREATE_SESSION_ID = "dialog_auto_create_session_id" - -CONF_ACTION_AUTO_CREATE_DIALOG = "auto_create_dialog_skill" -CONF_ACTION_RENAME_DIALOG_SKILL = "rename_dialog_skill" - -DIALOG_WEBHOOK_BASE_PATH = "/api/yandex_dialogs/webhook" -DIALOG_RESOLVE_TIMEOUT = 2.5 -DIALOG_DEFAULT_NAME = "Music Assistant" -# Yandex Dialogs app-store-api channel string for the custom dialog skill. -# Smart Home uses "smartHome"; the Dialogs custom-skill channel value was captured -# from the dev console DevTools (POST /apps): channel="aliceSkill". -# Override via MA_YANDEX_DIALOG_CHANNEL env var if Yandex changes the contract. -DIALOG_CHANNEL = os.environ.get("MA_YANDEX_DIALOG_CHANNEL", "aliceSkill") -DIALOG_NAME_MIN_LEN = 2 -DIALOG_NAME_MAX_LEN = 64 diff --git a/music_assistant/providers/yandex_smarthome/dialogs.py b/music_assistant/providers/yandex_smarthome/dialogs.py deleted file mode 100644 index ff356de1a2..0000000000 --- a/music_assistant/providers/yandex_smarthome/dialogs.py +++ /dev/null @@ -1,1306 +0,0 @@ -# ruff: noqa: RUF001, RUF003 -"""HTTP handler for the Yandex Dialogs custom-skill webhook (experimental). - -Registers a single exact route on the MA webserver — the secret is -**baked into the path string** at registration time, not a route -template variable: - - POST /api/yandex_dialogs/webhook/ - -Therefore ``request.match_info`` is empty in production; the handler -parses the secret from ``request.path`` (last segment) for the -constant-time compare. Tests that pass an explicit ``match_info`` cover -the alternative branch. - -Yandex Dialogs does not send an Authorization header on webhook calls, -so authentication is two-layered: - - 1. Path secret (``CONF_DIALOG_WEBHOOK_SECRET``) — random UUID stored - only in the user's MA config and in the skill's Backend URL. Knowing - it requires access to the Yandex Dialogs developer console. - 2. ``body.session.skill_id == CONF_DIALOG_SKILL_ID`` — sanity check; - skill_id is not secret on its own but stops cross-skill misroutes. - -A request is rejected with 404 if the secret doesn't match (no leak via -401 timing) and with 401 if the skill_id doesn't match (configured -skill received a payload from a different skill — should never happen). - -Session memory: three-tier strategy. - - 1. Yandex state envelope — primary. ``state.session`` (per - conversation), ``state.application`` (per device), ``state.user`` - (per Yandex account, only when account-linked). The application - and user tiers persist across plugin reloads and MA restarts. - 2. In-process cache — third-tier fallback for surfaces that don't - reliably echo the state envelope back (notably some Yandex - Station configurations). LRU keyed by ``user.user_id`` → - ``application.application_id`` → ``session_id``, with a 5-min - TTL matching Alice's session inactivity timeout. - -Reads check tiers in the order above. Writes mirror to all -applicable tiers via ``_yandex_response``. -""" - -from __future__ import annotations - -import asyncio -import logging -import re -import secrets -import time -from collections import OrderedDict -from collections.abc import Callable -from typing import TYPE_CHECKING, Any - -from aiohttp import web - -from .constants import ( - DIALOG_RESOLVE_TIMEOUT, - DIALOG_WEBHOOK_BASE_PATH, -) -from .dialogs_control import ( - control_confirmation, - execute_control, - format_list_players, - parse_control, -) -from .dialogs_nlu import ( - _VERB_RE, - ParsedCommand, - list_exposed_players, - parse_command, - resolve_player, - resolve_player_candidates, -) -from .dialogs_player import play_for_alice, resolve_query - -if TYPE_CHECKING: - from music_assistant.mass import MusicAssistant - - -_LOGGER = logging.getLogger(__name__) - - -# Static stress-mark dictionary for common response words (P0.2). -# Keys are case-insensitive whole-word matches; the marker is `+` placed -# directly before the stressed vowel — Yandex Alice TTS supports this -# inline syntax. Keep small and high-confidence; band/track names are -# left as-is (those need a separate phoneme dict — P2.3). -_TTS_STRESS_MARKS: dict[str, str] = { - "включаю": "включ+аю", - "ставлю": "ст+авлю", - "пауза": "п+ауза", - "продолжаю": "продолж+аю", - "следующая": "сл+едующая", - "предыдущая": "пред+ыдущая", - "громче": "гр+омче", - "тише": "т+ише", - "громкость": "гр+омкость", - "колонке": "кол+онке", - "колонку": "кол+онку", -} - -_TTS_WORD_RE = re.compile(r"[А-Яа-яЁё]+") - - -def _tts_for(text: str) -> str: - """Add `+` stress markers to known words for cleaner Alice TTS. - - Pure substitution — unknown words pass through unchanged. The map is - intentionally small (high-confidence Russian response words only); - expand via PRs as patterns emerge. - """ - if not text: - return text - - def _sub(match: re.Match[str]) -> str: - word = match.group(0) - replacement = _TTS_STRESS_MARKS.get(word.lower()) - if replacement is None: - return word - if word[:1].isupper(): - return replacement[:1].upper() + replacement[1:] - return replacement - - return _TTS_WORD_RE.sub(_sub, text) - - -def _safe_dict(value: Any) -> dict[str, Any]: - """Return value if it's a dict, else an empty dict (defensive parsing).""" - return value if isinstance(value, dict) else {} - - -def _without_pending(state: dict[str, Any]) -> dict[str, Any]: - """Return a copy of `state` with disambiguation/elicitation keys removed. - - Strips `pending_command`, `awaiting_query`, and `awaiting_player_id`. - Used after the disambiguation / slot-elicit flow successfully - completes so the next turn doesn't accidentally re-enter the saved - branch. - """ - transient = {"pending_command", "awaiting_query", "awaiting_player_id"} - return {k: v for k, v in state.items() if k not in transient} - - -# Ordinal voice-disambiguation patterns. The user picks a candidate by -# position ("первая", "выбираю первую", "номер три"). Used when a -# screenless audio device makes button-tap impossible. -# -# Two pattern families (all matched via ``re.search`` — leading filler -# words like "ну", "хочу", "выбираю", "давай" don't kill the match): -# -# 1. Russian ordinal stems (``перв\w*`` etc.) — case-insensitive -# word-prefix match. Catches every morphological form ("первая", -# "первый", "первое", "первую", "первой", "первом", …) without -# enumerating each. -# 2. Cardinal numbers and digits — anchored ``^…$`` so only a -# bare-number utterance counts ("один", "1", "номер один"). -# "У меня один вариант" must NOT silently pick the first. -_ORDINAL_PATTERNS: tuple[tuple[re.Pattern[str], int], ...] = ( - (re.compile(r"\bперв\w*\b", re.IGNORECASE), 0), - (re.compile(r"\bвтор\w*\b", re.IGNORECASE), 1), - (re.compile(r"\bтреть\w*\b", re.IGNORECASE), 2), - (re.compile(r"\bчетв[её]рт\w*\b", re.IGNORECASE), 3), - (re.compile(r"\bпят\w*\b", re.IGNORECASE), 4), - # Cardinals — whole-utterance only. - (re.compile(r"^(?:номер\s+)?(?:один|1)$", re.IGNORECASE), 0), - (re.compile(r"^(?:номер\s+)?(?:два|2)$", re.IGNORECASE), 1), - (re.compile(r"^(?:номер\s+)?(?:три|3)$", re.IGNORECASE), 2), - (re.compile(r"^(?:номер\s+)?(?:четыре|4)$", re.IGNORECASE), 3), - (re.compile(r"^(?:номер\s+)?(?:пять|5)$", re.IGNORECASE), 4), -) - - -def _parse_ordinal_choice(text: str) -> int | None: - """Parse 'первая' / 'выбираю первую' / 'номер три' / '2' as 0-based index. - - Returns the index, or None if no ordinal/cardinal pattern matched. - Tolerates leading filler words ("ну", "хочу", "выбираю", "давай") - since users often pad voice replies on smart speakers. - """ - if not text: - return None - cleaned = text.strip().lower() - if not cleaned: - return None - for pattern, index in _ORDINAL_PATTERNS: - if pattern.search(cleaned): - return index - return None - - -# Russian ordinal labels used in the disambiguation prompt. -_ORDINAL_LABELS: tuple[str, ...] = ( - "первая", - "вторая", - "третья", - "четвёртая", - "пятая", -) - - -# In-process state cache (TTL + LRU). Third-tier fallback when Yandex -# doesn't echo `state.session` / `state.application` back on the next -# turn — a quirk reproduced from the Yandex Station dev-console -# emulator, where the request body for every turn after the first -# arrived without any `state.*` field at all. Without this cache the -# disambiguation flow would loop indefinitely on those surfaces. -# The cache is keyed by the most stable identifier from the request -# envelope (preference: `session.user.user_id` → -# `session.application.application_id` → `session.session_id`). -_STATE_CACHE_TTL_SEC = 300 # 5 min — Alice session inactivity timeout -_STATE_CACHE_MAX = 200 - - -class DialogsWebhookHandler: - """Handles incoming voice-command webhook calls from a Yandex Dialogs skill.""" - - def __init__( - self, - mass: MusicAssistant, - *, - skill_id: str, - webhook_secret: str, - exposed_player_ids: set[str] | None = None, - logger: logging.Logger | None = None, - ) -> None: - """Initialize the handler. - - Args: - mass: MusicAssistant instance. - skill_id: Configured ``CONF_DIALOG_SKILL_ID``; payloads with a - different ``session.skill_id`` are rejected. - webhook_secret: Random secret embedded in the webhook URL. - exposed_player_ids: Optional restriction set; only these players - are addressable by voice (passed to the player resolver). - logger: Optional logger override. - """ - self._mass = mass - self._skill_id = skill_id - self._webhook_secret = webhook_secret - self._exposed_player_ids = exposed_player_ids - self._logger = logger or _LOGGER - self._unregister_callbacks: list[Callable[[], None]] = [] - # In-process state cache; see _STATE_CACHE_TTL_SEC / _MAX. - self._state_cache: OrderedDict[str, tuple[dict[str, Any], float]] = OrderedDict() - - def _cache_key(self, session: dict[str, Any]) -> str | None: - """Pick the most stable identifier for the in-process state cache. - - Preference order: ``session.user.user_id`` (per Yandex account, - most specific) → ``session.application.application_id`` (per - device) → ``session.session_id`` (per conversation). Returns - ``None`` if none are available — caller skips caching. - """ - user = session.get("user") - if isinstance(user, dict): - uid = user.get("user_id") - if isinstance(uid, str) and uid: - return f"user:{uid}" - app = session.get("application") - if isinstance(app, dict): - aid = app.get("application_id") - if isinstance(aid, str) and aid: - return f"app:{aid}" - sid = session.get("session_id") - if isinstance(sid, str) and sid: - return f"session:{sid}" - return None - - def _cache_get(self, session: dict[str, Any]) -> dict[str, Any]: - """Return the cached state for this caller, or {} if absent / expired.""" - key = self._cache_key(session) - if key is None: - return {} - entry = self._state_cache.get(key) - if entry is None: - return {} - state, ts = entry - if time.monotonic() - ts > _STATE_CACHE_TTL_SEC: - self._state_cache.pop(key, None) - return {} - # LRU touch. - self._state_cache.move_to_end(key) - return state - - def _cache_put(self, session: dict[str, Any], state: dict[str, Any]) -> None: - """Save state for this caller (LRU + TTL eviction). - - Pass an empty / cleared state dict (rather than skipping the - call) when the action explicitly drops pending/awaiting — this - ensures the cache reflects the post-action state and a stale - pending_command doesn't resurface on the next turn. - """ - key = self._cache_key(session) - if key is None: - return - self._state_cache[key] = (dict(state), time.monotonic()) - self._state_cache.move_to_end(key) - while len(self._state_cache) > _STATE_CACHE_MAX: - self._state_cache.popitem(last=False) - - def register_routes(self) -> None: - """Register the webhook route on mass.webserver.""" - path = f"{DIALOG_WEBHOOK_BASE_PATH}/{self._webhook_secret}" - redacted = f"{DIALOG_WEBHOOK_BASE_PATH}/...{self._webhook_secret[-4:]}" - try: - unregister = self._mass.webserver.register_dynamic_route( - path, self._handle_webhook, "POST" - ) - except RuntimeError: - self._logger.exception("Failed to register Dialogs webhook route %s", redacted) - raise - self._unregister_callbacks.append(unregister) - self._logger.info("Dialogs webhook registered at %s", redacted) - - def unregister_routes(self) -> None: - """Unregister the webhook route.""" - for cb in self._unregister_callbacks: - try: - cb() - except Exception: - self._logger.debug("Error unregistering dialog route", exc_info=True) - self._unregister_callbacks.clear() - - # ------------------------------------------------------------------- - # Webhook entry point - # ------------------------------------------------------------------- - - async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: PLR0915 - # Path secret already enforced by the route URL — getting here means - # the secret matches. Still constant-time-compare it via the captured - # path arg in case aiohttp routing ever changes. - url_secret = request.match_info.get("secret") or request.path.rsplit("/", 1)[-1] - if not secrets.compare_digest(url_secret, self._webhook_secret): - return web.Response(status=404) - - try: - body = await request.json() - except asyncio.CancelledError: - raise - except Exception: - return self._yandex_response( - incoming_session={}, - text="Что-то пошло не так с запросом.", - ) - if not isinstance(body, dict): - return self._yandex_response( - incoming_session={}, - text="Что-то пошло не так с запросом.", - ) - - session = body.get("session") or {} - if not isinstance(session, dict): - session = {} - req = body.get("request") or {} - if not isinstance(req, dict): - req = {} - - # skill_id sanity check — reject if absent or mismatched. - incoming_skill_id = str(session.get("skill_id") or "") - if not incoming_skill_id or not secrets.compare_digest(incoming_skill_id, self._skill_id): - self._logger.warning( - "Rejecting dialog payload: skill_id %r != configured %r", - incoming_skill_id or "", - self._skill_id, - ) - return web.Response(status=401) - - # State buckets. Three-tier read priority: - # 1. ``state.session`` — per-conversation, set by us last turn. - # 2. ``state.application`` — per-device, mirrored fallback. - # 3. In-process cache — server-side LRU keyed by user_id / - # application_id, last-resort for surfaces (notably the - # Yandex Station dev console emulator) that don't echo - # `state.*` back at all. - state = body.get("state") or {} - if not isinstance(state, dict): - state = {} - session_state_in = _safe_dict(state.get("session")) - app_state_in = _safe_dict(state.get("application")) - user_state_in = _safe_dict(state.get("user")) - cached_state = self._cache_get(session) - - default_id_raw = ( - session_state_in.get("last_player_id") - or app_state_in.get("last_player_id") - or user_state_in.get("preferred_player_id") - or cached_state.get("last_player_id") - ) - default_id = str(default_id_raw) if default_id_raw else None - - is_new = bool(session.get("new")) - command = str(req.get("command") or "").strip() - - # Pending-command / awaiting-query lookups follow the same - # three-tier order as default_id: session → application → - # in-process cache. Yandex Station devices in particular - # sometimes drop both `state.session` AND `state.application` - # between SimpleUtterance turns — the cache is what makes - # disambiguation actually work on those surfaces. - pending_in = session_state_in.get("pending_command") - if not isinstance(pending_in, dict): - pending_in = app_state_in.get("pending_command") - if not isinstance(pending_in, dict): - pending_in = cached_state.get("pending_command") - awaiting_in = ( - bool(session_state_in.get("awaiting_query")) - or bool(app_state_in.get("awaiting_query")) - or bool(cached_state.get("awaiting_query")) - ) - - # Single summary log per incoming request — surfaces the wire-shape - # bits we route on. Sensitive fields (skill_id, webhook_secret, - # raw payload IDs) are excluded; user/session IDs are opaque - # tokens and DEBUG is opt-in, so they're included as-is. - self._logger.debug( - "Webhook recv: cmd=%r req_type=%s is_new=%s pending=%s " - "(session=%s app=%s cache=%s) awaiting=%s default_player=%s " - "session_id=%s", - command, - req.get("type", "SimpleUtterance"), - is_new, - bool(pending_in), - bool(session_state_in.get("pending_command")), - bool(app_state_in.get("pending_command")), - bool(cached_state.get("pending_command")), - awaiting_in, - default_id, - session.get("session_id", ""), - ) - - if is_new and not command: - text = "Привет! Скажи, что включить и на какой колонке." - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - end_session=False, - session_state=session_state_in, - ) - - if not command: - text = "Не понял команду. Скажи, например: включи рок на кухне." - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - end_session=False, - session_state=session_state_in, - ) - - # P0.6 — try control commands (pause/next/volume/...) FIRST, on - # the raw command. Doing this before the awaiting-query synthesis - # lets the user pivot from a slot-elicit prompt straight into a - # control intent ("Включи." → "Что включить?" → "пауза на кухне") - # without the prefix-prepend turning it into "включи пауза…". - # If control matches, drop any pending/awaiting state — the user - # is no longer in either of those flows. - if control := parse_control(command): - self._logger.debug("Parsed dialog control %r → %r", command, control) - return self._handle_control( - session=session, - control=control, - default_id=default_id, - session_state_in=_without_pending(session_state_in), - app_state_in=app_state_in, - ) - - # P0.4 — awaiting-query re-entry. If the previous turn asked "Что - # включить?" and the new utterance isn't a control phrase, treat - # it as the missing query slot. Prepend a synthetic "включи " so - # the existing kind classifier runs ("песню X", "альбом Y", - # "мою волну", etc.). Skip the synthetic prefix if the user - # already said one of the verbs. - if awaiting_in and not _VERB_RE.match(command): - command = f"включи {command}" - self._logger.debug("Awaiting-query branch: synthesised cmd=%r", command) - # If slot-elicit was triggered with a player hint that resolved - # to a single exposed player, the follow-up turn should play on - # that player. Surface it as `default_id` so the resolver picks - # it without the user re-stating "на кухне". - if awaiting_in and not default_id: - saved_pid = ( - session_state_in.get("awaiting_player_id") - or app_state_in.get("awaiting_player_id") - or cached_state.get("awaiting_player_id") - ) - if saved_pid: - default_id = str(saved_pid) - self._logger.debug( - "Awaiting-query branch: restored hinted player as default_id=%s", - default_id, - ) - - # P0.3 — pending-command re-entry. If a previous turn asked the - # user to disambiguate which player to use, the new utterance (or - # button press) carries the answer; replay the saved play intent. - # `pending_in` was merged from `state.session` and `state.application` - # earlier so this works even on devices that don't preserve - # session-state between SimpleUtterance turns. - if isinstance(pending_in, dict): - pending: dict[str, Any] = pending_in - self._logger.debug( - "Pending-command branch: kind=%s query=%r radio=%s; cmd=%r payload=%s", - pending.get("kind"), - pending.get("query"), - pending.get("radio_mode"), - command, - bool(_safe_dict(req.get("payload")).get("player_id")), - ) - replay_response = await self._try_resume_pending( - session=session, - req=req, - command=command, - pending=pending, - session_state_in=session_state_in, - app_state_in=app_state_in, - ) - if replay_response is not None: - return replay_response - self._logger.debug( - "Pending-command branch: could not resume — falling through to parse_command" - ) - - parsed = parse_command(command) - self._logger.debug("Parsed dialog command %r → %r", command, parsed) - return await self._dispatch_play( - session=session, - parsed=parsed, - default_id=default_id, - session_state_in=session_state_in, - app_state_in=app_state_in, - ) - - # ------------------------------------------------------------------- - # Play dispatch (slot-elicit + resolve + disambiguate + play) - # ------------------------------------------------------------------- - - async def _dispatch_play( - self, - *, - session: dict[str, Any], - parsed: ParsedCommand, - default_id: str | None, - session_state_in: dict[str, Any], - app_state_in: dict[str, Any], - ) -> web.Response: - """Slot-elicit / resolve player / disambiguate / play (or fail).""" - # P0.4 — slot elicitation: bare verb with no actionable content. - # Triggers whenever the query slot is empty, even if the user - # specified a player hint ("включи на кухне"). Falling through - # would respond "Не нашёл такую музыку: ." which is confusing — - # the user clearly *wants* something, just didn't name it yet. - # If a hint resolves to a single exposed player, save its id as - # `awaiting_player_id` so the follow-up turn plays on it. - if parsed.kind == "search" and not parsed.query: - self._logger.debug( - "Slot-elicit branch: empty query (hint=%r), asking 'Что включить?'", - parsed.player_hint, - ) - awaiting_player_id: str | None = None - if parsed.player_hint: - hinted_candidates = resolve_player_candidates( - self._mass, - parsed.player_hint, - default_id=default_id, - exposed_ids=self._exposed_player_ids, - ) - if len(hinted_candidates) == 1: - awaiting_player_id = hinted_candidates[0].player_id - text = "Что включить? Можно сказать имя артиста, песни или плейлиста." - elicit_state: dict[str, Any] = {"awaiting_query": True} - if awaiting_player_id: - elicit_state["awaiting_player_id"] = awaiting_player_id - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - end_session=False, - session_state={**_without_pending(session_state_in), **elicit_state}, - # Mirror to application_state so the next turn can find - # the flag even if Yandex didn't echo `state.session`. - application_state={**_without_pending(app_state_in), **elicit_state}, - ) - - candidates = resolve_player_candidates( - self._mass, - parsed.player_hint, - default_id=default_id, - exposed_ids=self._exposed_player_ids, - ) - if not candidates: - # Special case: no hint, no default, multiple exposed players. - # `resolve_player_candidates` returns [] with no hint when it - # can't pick deterministically — for the user that's ambiguity, - # not "not found". Surface all exposed players for - # disambiguation instead of the misleading "не нашёл колонку - # «(не указано)»". - if parsed.player_hint is None and default_id is None: - all_exposed = list_exposed_players(self._mass, exposed_ids=self._exposed_player_ids) - if len(all_exposed) >= 2: - self._logger.debug( - "Play branch: no hint + no default + %d exposed → " - "disambiguation across all exposed players", - len(all_exposed), - ) - return self._build_disambiguation_response( - session=session, - parsed=parsed, - candidates=all_exposed, - session_state_in=session_state_in, - app_state_in=app_state_in, - ) - hint = parsed.player_hint or "(не указано)" - self._logger.info( - "Play branch: no player resolved for hint=%r (default_id=%s); " - "responding 'не нашёл колонку'", - parsed.player_hint, - default_id, - ) - text = f"Не нашёл колонку «{hint}». Скажи, например: на кухне." - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - end_session=False, - session_state=session_state_in, - ) - if len(candidates) > 1: - self._logger.debug( - "Play branch: ambiguous, %d candidates → disambiguation prompt", - len(candidates), - ) - return self._build_disambiguation_response( - session=session, - parsed=parsed, - candidates=candidates, - session_state_in=session_state_in, - app_state_in=app_state_in, - ) - - self._logger.debug( - "Play branch: resolved → player %s (%s)", - candidates[0].name or candidates[0].player_id, - candidates[0].player_id, - ) - return await self._play_with_player( - session=session, - parsed=parsed, - player=candidates[0], - base_session_state=session_state_in, - base_app_state=app_state_in, - ) - - # ------------------------------------------------------------------- - # Control execution helper (P0.6) - # ------------------------------------------------------------------- - - def _handle_control( # noqa: PLR0915 - self, - *, - session: dict[str, Any], - control: Any, - default_id: str | None, - session_state_in: dict[str, Any], - app_state_in: dict[str, Any], - ) -> web.Response: - """Resolve player + dispatch a control action; build response.""" - # list_players is informational — no player resolution / dispatch. - if control.action == "list_players": - players = list_exposed_players(self._mass, exposed_ids=self._exposed_player_ids) - text = format_list_players(players) - self._logger.debug( - "Control list_players → %d player(s): %s", - len(players), - [getattr(p, "name", None) or p.player_id for p in players], - ) - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - end_session=False, - session_state=session_state_in, - ) - - # now_playing — info query about the current track. Reads - # `mass.player_queues.get(pid).current_item.name` (already - # pre-formatted as "Artist - Title" or stream title for radio). - if control.action == "now_playing": - target_player = resolve_player( - self._mass, - control.player_hint, - default_id=default_id, - exposed_ids=self._exposed_player_ids, - ) - if target_player is None: - if control.player_hint: - text = f"Не нашёл колонку «{control.player_hint}». Скажи, например: на кухне." - else: - text = "Скажи, на какой колонке. Например: что играет на кухне." - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - end_session=False, - session_state=session_state_in, - ) - queue = None - try: - queue = self._mass.player_queues.get(target_player.player_id) - except Exception: - self._logger.debug("player_queues.get failed", exc_info=True) - current = getattr(queue, "current_item", None) if queue is not None else None - current_name = getattr(current, "name", None) if current is not None else None - display_name = target_player.name or target_player.player_id - if current_name: - text = f"Сейчас играет: {current_name}." - else: - text = f"На {display_name} сейчас ничего не играет." - self._logger.debug( - "Control now_playing on %s → %r", - target_player.player_id, - current_name, - ) - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - end_session=False, - session_state=session_state_in, - ) - - # transfer — moves the queue from the saved default player to - # the named target player. SOURCE = `default_id` (last-used); - # TARGET = `control.player_hint` (parsed from "переведи на X"). - if control.action == "transfer": - if not default_id: - text = "Не понял, откуда переводить. Сначала включи музыку на колонке." - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - end_session=False, - session_state=session_state_in, - ) - target_candidates = resolve_player_candidates( - self._mass, - control.player_hint, - default_id=None, - exposed_ids=self._exposed_player_ids, - ) - if not target_candidates: - hint = control.player_hint or "(не указано)" - text = f"Не нашёл колонку «{hint}». Скажи, например: на кухне." - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - end_session=False, - session_state=session_state_in, - ) - if len(target_candidates) > 1: - # Multi-match: reuse the disambiguation flow, but the - # pending intent for replay is "transfer this queue". - # We don't currently support resuming a transfer through - # `_try_resume_pending` (it's coupled to play intent), - # so for now just respond with a clarification. - names = ", ".join(p.name or p.player_id for p in target_candidates[:5]) - text = f"Не понял на какую колонку. Уточни: {names}?" - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - end_session=False, - session_state=session_state_in, - ) - target = target_candidates[0] - target_name = target.name or target.player_id - if target.player_id == default_id: - text = f"Уже играет на {target_name}." - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - end_session=False, - session_state=session_state_in, - ) - self._logger.info( - "Control transfer: %s → %s", - default_id, - target.player_id, - ) - self._mass.create_task( - self._mass.player_queues.transfer_queue( - source_queue_id=default_id, - target_queue_id=target.player_id, - ) - ) - new_session_state = {**session_state_in, "last_player_id": target.player_id} - new_app_state = {**app_state_in, "last_player_id": target.player_id} - user_obj_t = session.get("user") or {} - user_state_update_t: dict[str, Any] | None = None - if isinstance(user_obj_t, dict) and user_obj_t.get("user_id"): - user_state_update_t = {"preferred_player_id": target.player_id} - text = f"Перевожу на {target_name}." - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - session_state=new_session_state, - application_state=new_app_state, - user_state_update=user_state_update_t, - ) - - # forget_player clears the saved default-player from all three - # state tiers (session / application / cache) AND emits a - # `user_state_update` with `preferred_player_id: None` so the - # next play command without an explicit hint asks the user to - # pick again. Doesn't need a target — purely state management. - if control.action == "forget_player": - self._logger.info("Control forget_player → clearing last_player_id from all tiers") - new_session_state = {k: v for k, v in session_state_in.items() if k != "last_player_id"} - new_app_state = {k: v for k, v in app_state_in.items() if k != "last_player_id"} - user_obj_forget = session.get("user") or {} - user_state_update_forget: dict[str, Any] | None = None - if isinstance(user_obj_forget, dict) and user_obj_forget.get("user_id"): - # Yandex spec: a key set to None in `user_state_update` - # tells the platform to delete it from the merged - # user-scoped state. - user_state_update_forget = {"preferred_player_id": None} - text = control_confirmation(control) - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - end_session=False, - session_state=new_session_state, - application_state=new_app_state, - user_state_update=user_state_update_forget, - ) - - player = resolve_player( - self._mass, - control.player_hint, - default_id=default_id, - exposed_ids=self._exposed_player_ids, - ) - if player is None: - self._logger.info( - "Control %s: no player resolved (hint=%r, default_id=%s)", - control.action, - control.player_hint, - default_id, - ) - # Distinguish "no hint + ambiguous" from "hint given but unknown" - # so the message matches the actual cause. - if control.player_hint: - text = f"Не нашёл колонку «{control.player_hint}». Скажи, например: на кухне." - else: - text = "Скажи, на какой колонке. Например: пауза на кухне." - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - end_session=False, - session_state=session_state_in, - ) - self._logger.debug( - "Control %s → player %s (%s) value=%s", - control.action, - player.name or player.player_id, - player.player_id, - control.value, - ) - self._mass.create_task(execute_control(self._mass, control, player)) - # Clear any pending disambiguation / awaiting-query state from - # both tiers — the user took a different path. (`session_state_in` - # was already cleaned by the caller with `_without_pending`; do - # the same defensively here for application_state.) - new_session_state = {**session_state_in, "last_player_id": player.player_id} - new_app_state = { - **_without_pending(app_state_in), - "last_player_id": player.player_id, - } - user_obj = session.get("user") or {} - user_state_update: dict[str, Any] | None = None - if isinstance(user_obj, dict) and user_obj.get("user_id"): - user_state_update = {"preferred_player_id": player.player_id} - text = control_confirmation(control) - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - session_state=new_session_state, - application_state=new_app_state, - user_state_update=user_state_update, - ) - - # ------------------------------------------------------------------- - # Play execution helper (shared by initial flow and pending replay) - # ------------------------------------------------------------------- - - async def _play_with_player( - self, - *, - session: dict[str, Any], - parsed: ParsedCommand, - player: Any, - base_session_state: dict[str, Any], - base_app_state: dict[str, Any], - ) -> web.Response: - """Search media, fire-and-forget play, build response with persisted state.""" - try: - media = await asyncio.wait_for( - resolve_query(self._mass, parsed), timeout=DIALOG_RESOLVE_TIMEOUT - ) - except TimeoutError: - self._logger.warning( - "Music search timed out (>%.1fs) for query %r", - DIALOG_RESOLVE_TIMEOUT, - parsed.query, - ) - text = "Поиск занял слишком долго, попробуй ещё раз." - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - session_state=_without_pending(base_session_state), - application_state=_without_pending(base_app_state), - ) - - if media is None: - text = f"Не нашёл такую музыку: {parsed.query}." - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - session_state=_without_pending(base_session_state), - application_state=_without_pending(base_app_state), - ) - - # Fire-and-forget — Alice has a 4.5s budget; play_media may take longer - # to actually start streaming. mass.create_task tracks the task in the - # MA lifecycle (cancelled on shutdown) and logs unhandled exceptions. - self._mass.create_task( - play_for_alice( - self._mass, - player.player_id, - media, - radio_mode=parsed.radio_mode, - enqueue_option=parsed.enqueue_option, - ) - ) - - new_session_state = { - **_without_pending(base_session_state), - "last_player_id": player.player_id, - } - # Also clear pending/awaiting from `application_state` — it was - # mirrored there as a fallback for devices that don't preserve - # `state.session` between turns. - new_app_state = { - **_without_pending(base_app_state), - "last_player_id": player.player_id, - } - user_obj = session.get("user") or {} - user_state_update: dict[str, Any] | None = None - if isinstance(user_obj, dict) and user_obj.get("user_id"): - user_state_update = {"preferred_player_id": player.player_id} - - spoken_query = parsed.query or "музыку" - player_label = player.name or player.player_id - if parsed.enqueue_option == "add": - text = f"Добавил {spoken_query} в очередь на {player_label}." - elif parsed.enqueue_option == "next": - text = f"Поставил {spoken_query} следующим на {player_label}." - else: - text = f"Включаю {spoken_query} на {player_label}." - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - session_state=new_session_state, - application_state=new_app_state, - user_state_update=user_state_update, - ) - - # ------------------------------------------------------------------- - # Disambiguation (P0.3) - # ------------------------------------------------------------------- - - def _build_disambiguation_response( - self, - *, - session: dict[str, Any], - parsed: ParsedCommand, - candidates: list[Any], - session_state_in: dict[str, Any], - app_state_in: dict[str, Any] | None = None, - ) -> web.Response: - """Ask the user which player to use — voice-first, with optional buttons. - - Most Yandex Stations are screenless audio devices, so the prompt - has to make voice answer obvious. We enumerate candidates with - Russian ordinals (`первая` / `вторая` / …) so a user can say - either the player name (free-text fallback) or the position. - Buttons are kept on the response for screen surfaces, but voice - is the primary channel. - """ - # Yandex caps ItemsList at 5 anyway; cap our buttons to the same. - capped = candidates[:5] - names = [p.name or p.player_id for p in capped] - - # Voice prompt: ordinal-labelled list + explicit voice instruction. - # Example for 2 candidates: - # "На какой колонке? Первая — Кухня большая, вторая — Кухня - # маленькая. Скажи название или номер." - labelled = [f"{_ORDINAL_LABELS[i]} — {name}" for i, name in enumerate(names)] - text = "На какой колонке? " + ", ".join(labelled) + ". Скажи название или номер." - buttons = [ - { - "title": (p.name or p.player_id)[:64], - "payload": {"player_id": p.player_id}, - "hide": True, - } - for p in capped - ] - # Clear any prior `awaiting_query` / `pending_command` before - # writing the new one, and include the saved `pending_command`. - # The same pending entry is mirrored to BOTH `session_state` and - # `application_state` because some Yandex devices (notably - # screenless Stations) don't reliably echo `state.session` back - # across SimpleUtterance turns. The application tier persists - # per-device — it survives session resets and is honoured on - # every surface we've tested. Reads in `_handle_webhook` merge - # the two tiers (session preferred, application as fallback). - pending_command: dict[str, Any] = { - "kind": parsed.kind, - "query": parsed.query[:200], - "radio_mode": parsed.radio_mode, - # Ordered list of player IDs we offered. Used by - # `_try_resume_pending` to (a) resolve "первая"/"вторая" - # to a specific player by position, (b) re-narrow free-text - # matching to just these candidates so a short distinguisher - # wins even if a third matching player exists outside the - # disambiguation set. - "candidate_ids": [p.player_id for p in capped], - } - # Preserve the enqueue option (add / next) across the - # disambiguation re-entry — without this an ambiguous - # "добавь Iron Maiden" would resume as REPLACE after the - # user picks the player, defeating the add-to-queue intent. - if parsed.enqueue_option is not None: - pending_command["enqueue_option"] = parsed.enqueue_option - new_session_state = { - **_without_pending(session_state_in), - "pending_command": pending_command, - } - new_app_state = { - **_without_pending(app_state_in or {}), - "pending_command": pending_command, - } - return self._yandex_response( - incoming_session=session, - text=text, - tts=_tts_for(text), - end_session=False, - session_state=new_session_state, - application_state=new_app_state, - buttons=buttons, - ) - - async def _try_resume_pending( - self, - *, - session: dict[str, Any], - req: dict[str, Any], - command: str, - pending: dict[str, Any], - session_state_in: dict[str, Any], - app_state_in: dict[str, Any], - ) -> web.Response | None: - """Attempt to resume a saved pending_command using button payload or text. - - Returns a response if the pending command was resumed (success or - decided failure). Returns None when the new utterance doesn't - resolve to a player at all — caller falls through to normal - parse_command flow. - """ - chosen_player: Any = None - candidate_ids_raw = pending.get("candidate_ids") - candidate_ids: list[str] = ( - [str(x) for x in candidate_ids_raw if isinstance(x, str)] - if isinstance(candidate_ids_raw, list) - else [] - ) - # Preserve enqueue intent (add / next) across the disambiguation - # re-entry; otherwise an ambiguous "добавь Iron Maiden" would - # replay as REPLACE after the user picks a player. - enqueue_raw = pending.get("enqueue_option") - pending_enqueue: str | None = enqueue_raw if isinstance(enqueue_raw, str) else None - exposed = list_exposed_players(self._mass, exposed_ids=self._exposed_player_ids) - exposed_by_id = {p.player_id: p for p in exposed} - - # Step 1 — Button press. Direct UI signal on surfaces with a - # screen. Validate against the currently exposed set - # (defence-in-depth: stale / crafted payloads are rejected). - payload = req.get("payload") - if isinstance(payload, dict): - pid = payload.get("player_id") - if isinstance(pid, str): - chosen_player = exposed_by_id.get(pid) - if chosen_player is None: - self._logger.warning( - "Pending replay: ButtonPressed payload player_id=%r " - "not in exposed-player set; ignoring", - pid, - ) - - # Step 2 — Free-text first. Lets named answers ("Кухня большая" - # / "большая" / "маленькую") and even hypothetical players whose - # names contain ordinal words ("Спальня первая") win over the - # purely-positional ordinal interpretation. Narrow the resolver - # to the saved candidate set so a short distinguisher like - # "большая" doesn't accidentally pick an unrelated third player - # outside the disambiguation set. - if chosen_player is None: - followup = parse_command(command) - hint = followup.player_hint or command - narrowed_ids: set[str] | None - if candidate_ids: - narrowed_ids = set(candidate_ids) - if self._exposed_player_ids is not None: - narrowed_ids &= self._exposed_player_ids - else: - narrowed_ids = self._exposed_player_ids - candidates = resolve_player_candidates( - self._mass, - hint, - default_id=None, - exposed_ids=narrowed_ids, - ) - if len(candidates) == 1: - chosen_player = candidates[0] - self._logger.debug( - "Pending replay: free-text → player %s", - chosen_player.name or chosen_player.player_id, - ) - elif len(candidates) > 1: - # Still ambiguous — re-ask with the saved play intent. - return self._build_disambiguation_response( - session=session, - parsed=ParsedCommand( - kind=str(pending.get("kind", "search")), # type: ignore[arg-type] - query=str(pending.get("query", "")), - radio_mode=bool(pending.get("radio_mode", False)), - enqueue_option=pending_enqueue, # type: ignore[arg-type] - ), - candidates=candidates, - session_state_in=session_state_in, - app_state_in=app_state_in, - ) - - # Step 3 — voice ordinal ("первая", "выбираю первую", "номер - # три"). Last because we want named answers to win even when - # they happen to contain an ordinal word ("Спальня первая"). - # On screenless smart speakers ordinal is the natural reply - # when none of the names is easy to pronounce. - if chosen_player is None: - ordinal = _parse_ordinal_choice(command) - if ordinal is not None: - target_pid: str | None = ( - candidate_ids[ordinal] if 0 <= ordinal < len(candidate_ids) else None - ) - if target_pid is not None: - chosen_player = exposed_by_id.get(target_pid) - if chosen_player is not None: - self._logger.debug( - "Pending replay: voice ordinal %d → player %s", - ordinal, - chosen_player.name or chosen_player.player_id, - ) - # If the ordinal couldn't be resolved (out of range, or - # the indexed player is no longer exposed), the user - # clearly *meant* to pick from the disambiguation list — - # re-ask with whichever candidates are still exposed - # instead of falling through and mis-interpreting - # "третья" as a play query. - if chosen_player is None: - still_available = [ - exposed_by_id[pid] for pid in candidate_ids if pid in exposed_by_id - ] - if still_available: - self._logger.info( - "Pending replay: ordinal=%d unresolvable; " - "re-asking with %d remaining candidate(s)", - ordinal, - len(still_available), - ) - return self._build_disambiguation_response( - session=session, - parsed=ParsedCommand( - kind=str(pending.get("kind", "search")), # type: ignore[arg-type] - query=str(pending.get("query", "")), - radio_mode=bool(pending.get("radio_mode", False)), - enqueue_option=pending_enqueue, # type: ignore[arg-type] - ), - candidates=still_available, - session_state_in=session_state_in, - app_state_in=app_state_in, - ) - # else: no candidates remain at all — fall through. - - if chosen_player is None: - return None - - replay = ParsedCommand( - kind=str(pending.get("kind", "search")), # type: ignore[arg-type] - query=str(pending.get("query", "")), - radio_mode=bool(pending.get("radio_mode", False)), - enqueue_option=pending_enqueue, # type: ignore[arg-type] - ) - return await self._play_with_player( - session=session, - parsed=replay, - player=chosen_player, - base_session_state=session_state_in, - base_app_state=app_state_in, - ) - - # ------------------------------------------------------------------- - # Yandex Dialogs response envelope - # ------------------------------------------------------------------- - - def _yandex_response( - self, - *, - incoming_session: dict[str, Any], - text: str, - tts: str | None = None, - end_session: bool = True, - session_state: dict[str, Any] | None = None, - application_state: dict[str, Any] | None = None, - user_state_update: dict[str, Any] | None = None, - buttons: list[dict[str, Any]] | None = None, - ) -> web.Response: - """Build a Yandex Dialogs response envelope. - - ``session_state`` / ``application_state`` are full overwrites per - Yandex spec; ``user_state_update`` is merged into the existing - user-scoped state (set keys to None to clear). Omit a parameter - to leave that bucket unchanged on Yandex's side. - - Side effect: any time we set ``session_state`` or - ``application_state``, the merged value is also written to the - in-process state cache as a third-tier fallback (see - ``_cache_put``). The cache is what makes disambiguation work - on Yandex Station devices that don't echo `state.*` back. - """ - # Yandex envelopes carry two user_id fields: the deprecated root - # `session.user_id` (always present in current API revisions for - # backwards compatibility) and the nested `session.user.user_id` - # (set only when the user is account-linked). Prefer the root for - # historical reasons but fall back to the nested form so the - # echo doesn't leak an empty string if a future Yandex API - # revision drops the root field. - user_id = incoming_session.get("user_id") or _safe_dict(incoming_session.get("user")).get( - "user_id", "" - ) - echoed = { - "session_id": incoming_session.get("session_id", ""), - "message_id": incoming_session.get("message_id", 0), - "user_id": user_id, - } - response_body: dict[str, Any] = { - "text": text, - "tts": tts if tts is not None else text, - "end_session": end_session, - } - if buttons: - response_body["buttons"] = buttons - payload: dict[str, Any] = { - "version": "1.0", - "session": echoed, - "response": response_body, - } - if session_state is not None: - payload["session_state"] = session_state - if application_state is not None: - payload["application_state"] = application_state - if user_state_update is not None: - payload["user_state_update"] = user_state_update - - # Mirror state into the in-process cache. Prefer session_state - # (most specific to the current conversation); fall back to - # application_state if only that was set. We store a merged - # snapshot so reads on the next turn pick up everything. - cache_state: dict[str, Any] = {} - if application_state is not None: - cache_state.update(application_state) - if session_state is not None: - cache_state.update(session_state) - if cache_state or session_state is not None or application_state is not None: - self._cache_put(incoming_session, cache_state) - - return web.json_response(payload) diff --git a/music_assistant/providers/yandex_smarthome/dialogs_control.py b/music_assistant/providers/yandex_smarthome/dialogs_control.py deleted file mode 100644 index f3d7c0e338..0000000000 --- a/music_assistant/providers/yandex_smarthome/dialogs_control.py +++ /dev/null @@ -1,468 +0,0 @@ -# ruff: noqa: RUF001 -"""Playback-control NLU + executor for the Yandex Dialogs custom skill. - -Handles utterances that don't carry a music query — pause/resume/next/ -previous/volume up-down-set/mute/unmute. Runs *before* the play-command -parser in the webhook flow; if `parse_control` returns None the handler -falls through to the existing music-search path. -""" - -from __future__ import annotations - -import asyncio -import logging -import re -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Literal - -from music_assistant_models.enums import RepeatMode - -from .dialogs_nlu import _PUNCT_RE, _SPACE_RE - -if TYPE_CHECKING: - from music_assistant.mass import MusicAssistant - - -_LOGGER = logging.getLogger(__name__) - -ControlAction = Literal[ - "pause", - "resume", - "stop", - "next", - "previous", - "volume_up", - "volume_down", - "volume_set", - "mute", - "unmute", - "list_players", - "forget_player", - # v1.9.0 — six new actions - "now_playing", # info — handler reads queue.current_item.name - "shuffle_on", - "shuffle_off", - "repeat_off", - "repeat_one", - "repeat_all", - "seek_forward", # value = positive seconds - "seek_back", # value = positive seconds; negated when dispatched - "seek_start", # absolute seek to 0 - "transfer", # player_hint = TARGET player; SOURCE is the saved default -] - - -@dataclass(frozen=True, slots=True) -class ParsedControl: - """Result of classifying a Yandex Dialogs voice command as a control action.""" - - action: ControlAction - value: int | None = None - player_hint: str | None = None - - -# Pattern catalogue. Order matters within each tier — first match wins. -# All patterns are anchored (^...$) to require a whole-phrase match. -_CONTROL_PATTERNS: tuple[tuple[re.Pattern[str], ControlAction], ...] = ( - # list_players — informational query "what speakers do you see?". - # Matched before the play-verb-strip can interpret "покажи колонки" - # as a play kind=search query="колонки". - ( - re.compile( - r"^сколько\s+колонок(?:\s+(?:ты\s+)?(?:видишь|знаешь))?$", - re.IGNORECASE, - ), - "list_players", - ), - ( - re.compile( - r"^какие\s+колонки(?:\s+(?:ты\s+)?(?:видишь|знаешь|есть))?$", - re.IGNORECASE, - ), - "list_players", - ), - (re.compile(r"^какие\s+у\s+тебя\s+колонки$", re.IGNORECASE), "list_players"), - (re.compile(r"^перечисли\s+колонки$", re.IGNORECASE), "list_players"), - (re.compile(r"^список\s+колонок$", re.IGNORECASE), "list_players"), - (re.compile(r"^покажи\s+колонки$", re.IGNORECASE), "list_players"), - (re.compile(r"^назови\s+колонки$", re.IGNORECASE), "list_players"), - # forget_player — clears the saved "default player" so the next - # play command without an explicit hint asks again. Useful when - # the user previously picked a player and now wants to change - # without re-stating the name on every turn. - (re.compile(r"^забудь\s+колонку$", re.IGNORECASE), "forget_player"), - (re.compile(r"^сбрось\s+колонку$", re.IGNORECASE), "forget_player"), - (re.compile(r"^забудь\s+плеер$", re.IGNORECASE), "forget_player"), - (re.compile(r"^забудь\s+выбор$", re.IGNORECASE), "forget_player"), - (re.compile(r"^сбрось\s+выбор$", re.IGNORECASE), "forget_player"), - (re.compile(r"^выбери\s+колонку\s+заново$", re.IGNORECASE), "forget_player"), - (re.compile(r"^поменяй\s+колонку$", re.IGNORECASE), "forget_player"), - (re.compile(r"^сменить\s+колонку$", re.IGNORECASE), "forget_player"), - # now_playing — info query about the current track (no MA mutation) - (re.compile(r"^что\s+(?:сейчас\s+)?играет$", re.IGNORECASE), "now_playing"), - (re.compile(r"^что\s+(?:мы\s+)?слушаем$", re.IGNORECASE), "now_playing"), - (re.compile(r"^что\s+за\s+(?:песня|трек|композиция)$", re.IGNORECASE), "now_playing"), - (re.compile(r"^какой\s+(?:сейчас\s+)?(?:трек|играет)$", re.IGNORECASE), "now_playing"), - # shuffle_on / shuffle_off - (re.compile(r"^перемешай$", re.IGNORECASE), "shuffle_on"), - (re.compile(r"^включи\s+перемешивание$", re.IGNORECASE), "shuffle_on"), - (re.compile(r"^случайный\s+порядок$", re.IGNORECASE), "shuffle_on"), - (re.compile(r"^в\s+случайном\s+порядке$", re.IGNORECASE), "shuffle_on"), - (re.compile(r"^выключи\s+перемешивание$", re.IGNORECASE), "shuffle_off"), - (re.compile(r"^не\s+перемешивай$", re.IGNORECASE), "shuffle_off"), - (re.compile(r"^по\s+порядку$", re.IGNORECASE), "shuffle_off"), - # repeat — order matters: more-specific (with object) first, then bare verbs - ( - re.compile( - r"^повтор(?:и)?\s+(?:песн[июя]|трек(?:а)?|композицию|композиция|эту|эту\s+песню)$", - re.IGNORECASE, - ), - "repeat_one", - ), - ( - re.compile( - r"^повтор(?:и)?\s+(?:всё|все|очередь|плейлист|список)$", - re.IGNORECASE, - ), - "repeat_all", - ), - (re.compile(r"^повторяй$", re.IGNORECASE), "repeat_all"), - (re.compile(r"^включи\s+повтор$", re.IGNORECASE), "repeat_all"), - (re.compile(r"^выключи\s+повтор$", re.IGNORECASE), "repeat_off"), - (re.compile(r"^не\s+повторяй$", re.IGNORECASE), "repeat_off"), - # seek_start — absolute seek to position 0 (start of current track) - (re.compile(r"^(?:перемотай\s+)?к\s+началу$", re.IGNORECASE), "seek_start"), - (re.compile(r"^(?:перемотай\s+)?в\s+начало$", re.IGNORECASE), "seek_start"), - (re.compile(r"^начни\s+(?:трек\s+)?заново$", re.IGNORECASE), "seek_start"), - # mute / unmute — explicit "звук" disambiguates from play-verb "включи" - (re.compile(r"^включи\s+звук$", re.IGNORECASE), "unmute"), - (re.compile(r"^сделай\s+звук$", re.IGNORECASE), "unmute"), - (re.compile(r"^приглуши$", re.IGNORECASE), "mute"), - (re.compile(r"^выключи\s+звук$", re.IGNORECASE), "mute"), - (re.compile(r"^беззвучно$", re.IGNORECASE), "mute"), - # resume — must come before "включи" play-verb stripping; we run before - # parse_command anyway, but match anchored phrases here for clarity - (re.compile(r"^продолжи(?:ть)?$", re.IGNORECASE), "resume"), - (re.compile(r"^включи\s+снова$", re.IGNORECASE), "resume"), - (re.compile(r"^возобнови(?:ть)?$", re.IGNORECASE), "resume"), - # pause - (re.compile(r"^пауза$", re.IGNORECASE), "pause"), - (re.compile(r"^на\s+паузу$", re.IGNORECASE), "pause"), - (re.compile(r"^поставь\s+на\s+паузу$", re.IGNORECASE), "pause"), - (re.compile(r"^останови\s+музыку$", re.IGNORECASE), "pause"), - # stop — bare "выключи" maps to stop (safer than power-off) - (re.compile(r"^стоп$", re.IGNORECASE), "stop"), - (re.compile(r"^останови$", re.IGNORECASE), "stop"), - (re.compile(r"^выключи$", re.IGNORECASE), "stop"), - (re.compile(r"^выключи\s+музыку$", re.IGNORECASE), "stop"), - # next track - (re.compile(r"^следующ(?:ая|ий|ее)?(?:\s+трек)?$", re.IGNORECASE), "next"), - (re.compile(r"^дальше$", re.IGNORECASE), "next"), - (re.compile(r"^переключи$", re.IGNORECASE), "next"), - # previous track - (re.compile(r"^предыдущ(?:ая|ий|ее)?(?:\s+трек)?$", re.IGNORECASE), "previous"), - (re.compile(r"^назад$", re.IGNORECASE), "previous"), - (re.compile(r"^верни(?:сь)?$", re.IGNORECASE), "previous"), - # volume up - (re.compile(r"^громче$", re.IGNORECASE), "volume_up"), - (re.compile(r"^сделай\s+громче$", re.IGNORECASE), "volume_up"), - (re.compile(r"^прибавь(?:\s+громкость)?$", re.IGNORECASE), "volume_up"), - # volume down - (re.compile(r"^тише$", re.IGNORECASE), "volume_down"), - (re.compile(r"^сделай\s+тише$", re.IGNORECASE), "volume_down"), - (re.compile(r"^убавь(?:\s+громкость)?$", re.IGNORECASE), "volume_down"), -) - -# Volume-set with explicit number, e.g. "громкость 50", "громкость на 30 процентов". -_VOLUME_SET_RE = re.compile( - r"^(?:сделай\s+)?громкост(?:ь|и)\s+(?:на\s+)?(?P\d{1,3})(?:\s+процентов)?$", - re.IGNORECASE, -) - -# Seek forward / backward with numeric amount + optional unit. Unit defaults -# to seconds when missing. "Минут[уы]" multiplies by 60. -_SEEK_FORWARD_RE = re.compile( - r"^(?:перемотай\s+|перемотать\s+|промотай\s+)?" - r"(?:вперёд|вперед)\s+(?:на\s+)?(?P\d{1,4})" - r"(?:\s+(?Pсек(?:унд[уы]?)?|мин(?:ут[уы]?)?))?$", - re.IGNORECASE, -) -_SEEK_BACK_RE = re.compile( - r"^(?:перемотай\s+|перемотать\s+|промотай\s+)?" - r"назад\s+(?:на\s+)?(?P\d{1,4})" - r"(?:\s+(?Pсек(?:унд[уы]?)?|мин(?:ут[уы]?)?))?$", - re.IGNORECASE, -) - -# Transfer playback to a target player. The target name is captured into -# `player_hint`; SOURCE comes from the caller's `default_id`. -_TRANSFER_RE = re.compile( - r"^(?:переведи|перенеси|продолжи)\s+(?:музыку\s+)?(?:на|в)\s+(?P.+)$", - re.IGNORECASE, -) - - -def _seek_seconds(match: re.Match[str]) -> int | None: - """Parse the digit + optional unit out of a seek-pattern match.""" - try: - n = int(match.group("n")) - except (TypeError, ValueError): - return None - unit = (match.group("unit") or "").lower() - if unit.startswith("мин"): - n *= 60 - return n - - -def _try_match(cleaned: str, player_hint: str | None) -> ParsedControl | None: - """Match `cleaned` against control patterns; return ParsedControl or None.""" - if not cleaned: - return None - if vmatch := _VOLUME_SET_RE.match(cleaned): - try: - value = int(vmatch.group("n")) - except (TypeError, ValueError): - return None - return ParsedControl( - action="volume_set", - value=max(0, min(100, value)), - player_hint=player_hint, - ) - if smatch := _SEEK_FORWARD_RE.match(cleaned): - seconds = _seek_seconds(smatch) - if seconds is not None: - return ParsedControl(action="seek_forward", value=seconds, player_hint=player_hint) - if smatch := _SEEK_BACK_RE.match(cleaned): - seconds = _seek_seconds(smatch) - if seconds is not None: - return ParsedControl(action="seek_back", value=seconds, player_hint=player_hint) - if tmatch := _TRANSFER_RE.match(cleaned): - # For transfer, the captured group goes into `player_hint` — - # it's the TARGET. The handler resolves it; SOURCE is `default_id`. - # `player_hint` from the caller's "на " suffix split is - # ignored here (transfer phrases already include the target). - return ParsedControl( - action="transfer", - player_hint=tmatch.group("target").strip().lower(), - ) - for pattern, action in _CONTROL_PATTERNS: - if pattern.match(cleaned): - return ParsedControl(action=action, player_hint=player_hint) - return None - - -_NA_BOUNDARY_RE = re.compile(r"\s+на\s+", re.IGNORECASE) - - -def parse_control(text: str) -> ParsedControl | None: - """Classify a voice utterance as a control command, or None to fall through. - - Tries each `на`-boundary in the cleaned text as a possible - "на " suffix, starting from the rightmost. First yields - (cleaned, None) for the whole-phrase case so that "поставь на - паузу" still matches `pause` with no hint, even when the phrase - contains "на" inside the action keywords. - """ - if not text: - return None - cleaned = _PUNCT_RE.sub(" ", text) - cleaned = _SPACE_RE.sub(" ", cleaned).strip() - cleaned = re.sub(r"^алиса[,\s]+", "", cleaned, flags=re.IGNORECASE) - if not cleaned: - return None - - # Whole-phrase first (no hint). - if direct := _try_match(cleaned, player_hint=None): - return direct - - # Then try each "на " split from right to left, so e.g. - # "поставь на паузу на кухне" splits at the *last* "на". - matches = list(_NA_BOUNDARY_RE.finditer(cleaned)) - for m in reversed(matches): - rest = cleaned[: m.start()].strip() - hint = cleaned[m.end() :].strip().lower() - if not rest or not hint: - continue - if matched := _try_match(rest, player_hint=hint): - return matched - return None - - -# --------------------------------------------------------------------------- -# Executor + confirmation -# --------------------------------------------------------------------------- - - -def _plural_ru(n: int, forms: tuple[str, str, str]) -> str: - """Pick the correct Russian quantitative form for `n`. - - Args: - n: The number. - forms: ``(form_for_1, form_for_2_to_4, form_for_5_plus)``. - - Russian quantitative agreement: - 1, 21, 31, … → form_for_1 (e.g. "колонку") - 2-4, 22-24, … → form_for_2_to_4 ("колонки") - 0, 5-20, 25-30, … → form_for_5_plus ("колонок") - """ - n_abs = abs(n) - if n_abs % 10 == 1 and n_abs % 100 != 11: - return forms[0] - if 2 <= n_abs % 10 <= 4 and not 12 <= n_abs % 100 <= 14: - return forms[1] - return forms[2] - - -def format_list_players(players: list[Any]) -> str: - """Build the spoken response listing exposed players for `list_players` action.""" - n = len(players) - if n == 0: - return "Не вижу ни одной колонки." - names = ", ".join(getattr(p, "name", None) or p.player_id for p in players) - if n == 1: - return f"Вижу одну колонку: {names}." - word = _plural_ru(n, ("колонку", "колонки", "колонок")) - return f"Вижу {n} {word}: {names}." - - -def control_confirmation(control: ParsedControl) -> str: # noqa: PLR0911 - """User-facing confirmation text for a control action. - - Caveat: ``list_players`` is **not** confirmed here — the handler builds - the response text from the live player list via ``format_list_players``. - """ - action = control.action - if action == "pause": - return "Пауза." - if action == "resume": - return "Продолжаю." - if action == "stop": - return "Остановил." - if action == "next": - return "Следующая." - if action == "previous": - return "Предыдущая." - if action == "volume_up": - return "Громче." - if action == "volume_down": - return "Тише." - if action == "volume_set": - return f"Громкость {control.value}." - if action == "mute": - return "Звук выключен." - if action == "unmute": - return "Звук включен." - if action == "forget_player": - return "Хорошо, забыл колонку. В следующий раз спрошу." - if action == "shuffle_on": - return "Включил перемешивание." - if action == "shuffle_off": - return "Выключил перемешивание." - if action == "repeat_off": - return "Выключил повтор." - if action == "repeat_one": - return "Повтор песни." - if action == "repeat_all": - return "Повтор очереди." - if action == "seek_forward": - return f"Перемотал на {control.value} секунд вперёд." - if action == "seek_back": - return f"Перемотал на {control.value} секунд назад." - if action == "seek_start": - return "Перемотал к началу." - # list_players / now_playing / transfer — handler computes the real - # text (live data) and never calls this. Placeholder for safety. - return "Готово." - - -async def execute_control( # noqa: PLR0915 - mass: MusicAssistant, - control: ParsedControl, - player: Any, -) -> None: - """Dispatch a ParsedControl to the matching MA command. - - Errors are logged and swallowed — Alice has already been told the - action was accepted; we don't have a channel to surface failures - back into the same conversation. - - Note: ``list_players`` is a member of ``ControlAction`` for typing - convenience, but it's an *informational* query handled inline by - ``DialogsWebhookHandler._handle_control`` (which never calls this - function for it). The explicit branch below makes that contract - safe — a stray call won't silently no-op, it logs and returns. - """ - pid = player.player_id - action = control.action - try: - if action == "pause": - await mass.player_queues.pause(pid) - elif action == "resume": - await mass.player_queues.resume(pid) - elif action == "stop": - await mass.player_queues.stop(pid) - elif action == "next": - await mass.player_queues.next(pid) - elif action == "previous": - await mass.player_queues.previous(pid) - elif action == "volume_up": - await mass.players.cmd_volume_up(pid) - elif action == "volume_down": - await mass.players.cmd_volume_down(pid) - elif action == "volume_set": - value = max(0, min(100, control.value or 0)) - await mass.players.cmd_volume_set(pid, value) - elif action == "mute": - await mass.players.cmd_volume_mute(pid, True) - elif action == "unmute": - await mass.players.cmd_volume_mute(pid, False) - elif action == "list_players": - # Informational query — the handler builds the response - # text from a live `list_exposed_players(...)` call and - # never dispatches here. If we somehow got called for - # this action it's a caller bug, not something to silently - # ignore. - _LOGGER.warning( - "execute_control called with action='list_players'; " - "this is informational and should be handled by the " - "webhook handler, not dispatched here. Skipping.", - ) - elif action == "forget_player": - # State-management query — the handler clears the cached - # default-player from session/application/cache state and - # never dispatches here. Defensive branch. - _LOGGER.warning( - "execute_control called with action='forget_player'; " - "this is a state-management op handled by the webhook " - "handler, not dispatched here. Skipping.", - ) - elif action == "shuffle_on": - await mass.player_queues.set_shuffle(pid, shuffle_enabled=True) - elif action == "shuffle_off": - await mass.player_queues.set_shuffle(pid, shuffle_enabled=False) - elif action == "repeat_off": - # NB: set_repeat is sync, not async — do NOT await. - mass.player_queues.set_repeat(pid, RepeatMode.OFF) - elif action == "repeat_one": - mass.player_queues.set_repeat(pid, RepeatMode.ONE) - elif action == "repeat_all": - mass.player_queues.set_repeat(pid, RepeatMode.ALL) - elif action == "seek_forward": - await mass.player_queues.skip(pid, seconds=control.value or 0) - elif action == "seek_back": - await mass.player_queues.skip(pid, seconds=-(control.value or 0)) - elif action == "seek_start": - await mass.player_queues.seek(pid, position=0) - elif action in ("now_playing", "transfer"): - # Live-data / multi-player actions — the handler builds the - # response from queue.current_item / transfer_queue and - # never dispatches here. Defensive branch. - _LOGGER.warning( - "execute_control called with action=%r — handled by webhook " - "handler, not dispatched here. Skipping.", - action, - ) - except asyncio.CancelledError: - raise - except Exception: - _LOGGER.exception("execute_control(%s) failed for player %s", action, pid) diff --git a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py b/music_assistant/providers/yandex_smarthome/dialogs_nlu.py deleted file mode 100644 index 76d98042cd..0000000000 --- a/music_assistant/providers/yandex_smarthome/dialogs_nlu.py +++ /dev/null @@ -1,474 +0,0 @@ -# ruff: noqa: RUF001, RUF002, RUF003 -"""Server-side NLU parser for Yandex Dialogs custom-skill webhook commands. - -The plugin's Dialogs skill registers in the Yandex Dialogs UI without -declared intents/slots — Yandex passes the raw user phrase as -``request.command``. We classify it ourselves: kind (track/artist/album/ -playlist/my_wave/genre/search), search query, optional player hint. - -Pure-Python; no MA-API dependency for the parser itself, only for -``resolve_player`` which iterates ``mass.players.all_players()``. -""" - -from __future__ import annotations - -import logging -import re -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Literal - -if TYPE_CHECKING: - from music_assistant.mass import MusicAssistant - - -_LOGGER = logging.getLogger(__name__) - -CommandKind = Literal["track", "artist", "album", "playlist", "my_wave", "genre", "search"] - - -EnqueueOption = Literal["replace", "next", "add"] - - -@dataclass(frozen=True, slots=True) -class ParsedCommand: - """Result of classifying a Yandex Dialogs voice command.""" - - kind: CommandKind - query: str - player_hint: str | None = None - radio_mode: bool = False - # When set, `play_for_alice` passes a matching `QueueOption` to - # `mass.player_queues.play_media` so the new media is added to / - # inserted into the existing queue instead of replacing it. Default - # `None` keeps the historical REPLACE behaviour (start playing - # immediately, replacing the current queue). - enqueue_option: EnqueueOption | None = None - - -# --------------------------------------------------------------------------- -# Command parser -# --------------------------------------------------------------------------- - -# Punctuation we strip up-front. Keep apostrophes (e.g. "rock'n'roll") and -# hyphens (e.g. "rock-n-roll") inside words. -_PUNCT_RE = re.compile(r"[!?.,;:«»\"„“]") -_SPACE_RE = re.compile(r"\s+") - -# Trailing "" hint suffix introduced by the Russian preposition "на". -# The hint can be multi-word (e.g. a phrase like "in the kitchen speaker"). -# Anchored to a word boundary so a name beginning with the same letters -# (e.g. "Natalie" in Russian) isn't mis-split mid-token. -_PLAYER_SUFFIX_RE = re.compile(r"\s+на\s+(?P.+?)\s*$", re.IGNORECASE) - -# Verb regex covering Russian imperative + infinitive forms used to -# start playback ("turn on", "play", "launch"). Yandex's voice-to-text -# sometimes returns the infinitive ("включить") even if the user spoke -# the imperative ("включи"); we accept both. Also covers the plural -# imperatives (-те), informal aspect variants (включай/сыграй), -# and the listening verbs (послушай/послушать). -_VERB_RE = re.compile( - r"^(?:алиса[, ]+)?(?:" - r"включи(?:те)?|включай(?:те)?|включить|" - r"поставь(?:те)?|поставить|" - r"запусти(?:те)?|запустить|" - r"сыграй(?:те)?|сыграть|" - r"играй(?:те)?|" - r"послушай(?:те)?|послушать|" - r"найди(?:те)?|найти|" - r"открой(?:те)?|открыть|" - r"покажи(?:те)?|показать" - r")(?:\s+|$)", - re.IGNORECASE, -) - -# Enqueue verbs — when one of these is the command's leading verb, set -# `enqueue_option="add"` (or "next") on the resulting ParsedCommand -# instead of the default REPLACE-the-queue behaviour. The verb is -# stripped from the rest of the command exactly like `_VERB_RE` does. -_ENQUEUE_VERB_RE = re.compile( - r"^(?:алиса[, ]+)?(?:добавь(?:те)?|добавить)(?:\s+|$)", - re.IGNORECASE, -) - -# Type prefixes inside the intent part. Order matters: longer keywords first. -_KIND_RULES: tuple[tuple[re.Pattern[str], CommandKind, bool], ...] = ( - # my_wave / personal radio wave — no query, the verb is everything - (re.compile(r"^(?:мою|свою|нашу)\s+волну\b", re.IGNORECASE), "my_wave", True), - (re.compile(r"^мо[её]\s+радио\b", re.IGNORECASE), "my_wave", True), - # playlist - (re.compile(r"^(?:плейлист|подборку|подборка)\s+(.+)$", re.IGNORECASE), "playlist", False), - # album - (re.compile(r"^(?:альбом|пластинку|пластинка)\s+(.+)$", re.IGNORECASE), "album", False), - # artist (radio mode) - ( - re.compile(r"^(?:исполнителя|артиста|группу|группа)\s+(.+)$", re.IGNORECASE), - "artist", - True, - ), - # explicit track marker - ( - re.compile(r"^(?:песню|трек|композицию|композиция)\s+(.+)$", re.IGNORECASE), - "track", - False, - ), - # explicit radio - (re.compile(r"^радио\s+(.+)$", re.IGNORECASE), "genre", True), - # genre marker - (re.compile(r"^жанр\s+(.+)$", re.IGNORECASE), "genre", True), -) - - -# Marker words that mean "the next token(s) are content, not the title". -# Used by `parse_command` to detect a wrong "на " split: if the -# split-off remainder is just a marker word (e.g. "включи песню" with -# the title "На заре" mis-split off as a player hint), the suffix -# extraction was almost certainly wrong and we re-parse without it. -_KIND_MARKER_WORDS: frozenset[str] = frozenset( - { - "песню", - "трек", - "композицию", - "композиция", - "альбом", - "пластинку", - "пластинка", - "плейлист", - "подборку", - "подборка", - "исполнителя", - "артиста", - "группу", - "группа", - "радио", - "жанр", - } -) - - -def parse_command(text: str, *, _split_player_hint: bool = True) -> ParsedCommand: - """Parse a raw voice command into a structured ParsedCommand. - - Examples: - "включи Metallica на кухне" → kind=search, query=metallica, hint=кухне - "включи песню Yesterday" → kind=track, query=yesterday - "включи альбом Black Album на спальне" → kind=album, query=black album, hint=спальне - "включи исполнителя Metallica" → kind=artist, query=metallica, radio_mode=True - "включи мою волну" → kind=my_wave, query=, radio_mode=True - "включи джаз" → kind=search, query=джаз - "включи жанр джаз" → kind=genre, query=джаз, radio_mode=True - "включи песню На заре" → kind=track, query=на заре (no false split) - - The ``_split_player_hint`` parameter is internal: when the first - pass produces a suspicious split (the whole content was eaten as - "на ", leaving only a marker word in the query), the - function recurses with the flag off to keep the suffix in the - query. Don't pass it from outside. - """ - if not text: - return ParsedCommand(kind="search", query="") - - cleaned = _PUNCT_RE.sub(" ", text) - cleaned = _SPACE_RE.sub(" ", cleaned).strip() - - # Strip the "Alice, ..." vocative prefix if present - # (Yandex usually strips it on its side, but defensively). - cleaned = re.sub(r"^алиса[,\s]+", "", cleaned, flags=re.IGNORECASE) - - # Split off the trailing "" hint suffix. - player_hint: str | None = None - if _split_player_hint and (match := _PLAYER_SUFFIX_RE.search(cleaned)): - player_hint = match.group("hint").strip().lower() - cleaned = cleaned[: match.start()].strip() - - # Detect enqueue-verb prefix ("добавь X") BEFORE the regular verb - # strip so we know to set `enqueue_option="add"`. The verb itself - # is stripped here so the regular `_VERB_RE.sub` below has nothing - # to do — the residual is the kind+query intent part. - enqueue_option: EnqueueOption | None = None - if enq_match := _ENQUEUE_VERB_RE.match(cleaned): - enqueue_option = "add" - cleaned = cleaned[enq_match.end() :].strip() - - # Strip the imperative verb at the start (e.g. "play this", "turn on that"). - # No-op if `_ENQUEUE_VERB_RE` already consumed the verb. - intent_part = _VERB_RE.sub("", cleaned).strip() - - if not intent_part: - return ParsedCommand( - kind="search", - query="", - player_hint=player_hint, - enqueue_option=enqueue_option, - ) - - # Try kind rules in order. - for pattern, kind, radio in _KIND_RULES: - if rule_match := pattern.match(intent_part): - query = rule_match.group(1).strip() if rule_match.groups() else "" - # For add-to-queue the "radio mode" intent is incoherent - # (you don't add a station, you add a track). Force off. - effective_radio = False if enqueue_option == "add" else radio - return ParsedCommand( - kind=kind, - query=query.lower(), - player_hint=player_hint, - radio_mode=effective_radio, - enqueue_option=enqueue_option, - ) - - # Suspicious-split detector: when a player_hint was extracted AND - # the residual intent_part collapsed to a kind-marker word (e.g. - # "песню", "альбом", "плейлист"), the user probably said something - # like "включи песню На заре" and we mis-split "На заре" as a - # player hint. Re-parse without the suffix split so the title is - # preserved in the query. - if _split_player_hint and player_hint is not None and intent_part.lower() in _KIND_MARKER_WORDS: - return parse_command(text, _split_player_hint=False) - - # Fallback: unstructured search — let mass.music.search figure out - # the type. Force radio_mode=True so when the result is an artist or - # a single track, MA starts a radio based on it instead of playing - # one item and stopping (matches the typical user expectation - # "включи " → "play music"). For add-to-queue, radio_mode - # is incoherent — force off. - return ParsedCommand( - kind="search", - query=intent_part.lower(), - player_hint=player_hint, - radio_mode=enqueue_option != "add", - enqueue_option=enqueue_option, - ) - - -# --------------------------------------------------------------------------- -# Player resolver -# --------------------------------------------------------------------------- - -# Common Russian inflection suffixes we strip for fuzzy player-name matching. -# Not a full lemmatizer — picks up the most frequent endings for short names. -# Order: longest first so multi-letter suffixes match before single-letter ones. -_INFLECTION_SUFFIXES = ( - "ого", - "ому", - "ыми", - "ую", # feminine adjective accusative — "большую", "маленькую" - "ая", # feminine adjective nominative — "большая", "маленькая" - "ой", - "ом", - "ым", - "ы", - "е", - "у", - "а", - "и", - "й", - "ь", - "я", # feminine noun nominative — "Кухня", "Спальня" - "ю", # feminine noun accusative — "Кухню", "Спальню" -) - - -# Generic Russian words for "speaker / player" — fall through to the -# default/only-exposed player if the user said one of these instead of -# a specific player name. Stored as already-normalised stems so we can -# compare against the same normalisation we run on `hint`. -_GENERIC_PLAYER_STEMS = frozenset( - { - "колонк", # колонка / на колонке / колонку - "плеер", # плеер / на плеере / плеера - "пле", # short for "плеер" after stripping the trailing -ер suffix - "проигрыватель", # full word survives stem (no matching suffix) - "проигрывател", # stripped «-ь» - "динамик", # динамик / на динамике - "акустик", # акустика / на акустике - "устройств", # устройство / на устройстве - } -) - - -def _normalize_player_token(name: str) -> str: - """Lowercase + strip common Russian inflection suffix. - - Applied to both haystack (player.name) and needle (hint) so they - match each other after the same shaping. - """ - norm = name.lower().strip() - norm = _PUNCT_RE.sub(" ", norm) - norm = _SPACE_RE.sub(" ", norm).strip() - # Strip a trailing inflection suffix from each whitespace-separated token. - parts: list[str] = [] - for token in norm.split(): - stemmed = token - for suffix in _INFLECTION_SUFFIXES: - if len(stemmed) > len(suffix) + 2 and stemmed.endswith(suffix): - stemmed = stemmed[: -len(suffix)] - break - parts.append(stemmed) - return " ".join(parts) - - -def list_exposed_players( - mass: MusicAssistant, - *, - exposed_ids: set[str] | None = None, -) -> list[Any]: - """Return all available, enabled, non-synced players (filtered by exposure). - - Same filter as ``resolve_player_candidates`` uses for its candidate set, - extracted so the dialog handler can answer "what speakers do you see?" - queries (P0.6 ``list_players`` action) without re-implementing it. - """ - out: list[Any] = [] - for player in mass.players.all_players(): - if not player.available or not player.enabled: - continue - if getattr(player, "synced_to", None): - continue - if exposed_ids and player.player_id not in exposed_ids: - continue - out.append(player) - return out - - -def resolve_player_candidates( - mass: MusicAssistant, - hint: str | None, - *, - default_id: str | None = None, - exposed_ids: set[str] | None = None, -) -> list[Any]: - """Return the best-matching tier of players for ``hint``. - - Filters: only players that are available, enabled, and not synced to - a leader. Optional ``exposed_ids`` further restricts to the user's - exposed-players list. Tier priority: exact → startswith → contains → - generic-word fallback. The caller decides what to do with multiple - matches (typically: ask the user to disambiguate). - - Logs a single DEBUG-level summary on every call describing the - decision: chosen tier, candidate count, and the names of the - candidates returned. - - Returns: - A list with all players in the best non-empty tier. ``[]`` if - nothing matched. ``[player]`` for an unambiguous resolution. - """ - candidates = list_exposed_players(mass, exposed_ids=exposed_ids) - - def _label(p: Any) -> str: - return str(getattr(p, "name", None) or p.player_id) - - def _result(result: list[Any], reason: str) -> list[Any]: - _LOGGER.debug( - "resolve_player: hint=%r default=%s exposed=%d -> %d candidate(s) %s [%s]", - hint, - default_id, - len(candidates), - len(result), - [_label(p) for p in result], - reason, - ) - return result - - if not candidates: - return _result([], "no exposed players") - - # Single-player install or no hint → default / only candidate. - if not hint: - if default_id: - for p in candidates: - if p.player_id == default_id: - return _result([p], "no hint, matched default_id") - if len(candidates) == 1: - return _result(candidates[:], "no hint, single exposed player") - return _result([], "no hint, ambiguous") - - needle = _normalize_player_token(hint) - if not needle: - return _result([], "hint normalised to empty string") - - exact: list[Any] = [] - startswith: list[Any] = [] - contains: list[Any] = [] - haystacks: list[tuple[str, str]] = [] # (raw, normalised) for debug - for p in candidates: - haystack = _normalize_player_token(p.name or p.player_id) - haystacks.append((p.name or p.player_id, haystack)) - if not haystack: - continue - if haystack == needle: - exact.append(p) - elif haystack.startswith(needle) or needle.startswith(haystack): - startswith.append(p) - elif needle in haystack or haystack in needle: - contains.append(p) - - _LOGGER.debug( - "resolve_player tiers: hint=%r needle=%r candidates=%s " - "matches: exact=%d startswith=%d contains=%d", - hint, - needle, - haystacks, - len(exact), - len(startswith), - len(contains), - ) - - for tier_name, tier in ( - ("exact", exact), - ("startswith", startswith), - ("contains", contains), - ): - if tier: - tier.sort(key=lambda p: (p.name or p.player_id).lower()) - return _result(tier, f"tier={tier_name}") - - # Generic-word fallback: "на колонке" / "на проигрывателе" / "на динамике" - # mean "any speaker" — resolve unambiguously only when the choice is - # forced (default_id set, or single exposed player). - if any(stem in needle for stem in _GENERIC_PLAYER_STEMS): - if default_id: - for p in candidates: - if p.player_id == default_id: - _LOGGER.info( - "Generic player hint %r → resolved to default player %r", - hint, - p.name, - ) - return _result([p], "generic word, matched default_id") - if len(candidates) == 1: - _LOGGER.info( - "Generic player hint %r → resolved to the only exposed player %r", - hint, - candidates[0].name, - ) - return _result(candidates[:], "generic word, single exposed player") - _LOGGER.warning( - "Generic player hint %r matches no specific player and there are " - "%d exposed players — caller will ask for clarification", - hint, - len(candidates), - ) - return _result([], "generic word, multiple players, no default") - - return _result([], "no tier matched") - - -def resolve_player( - mass: MusicAssistant, - hint: str | None, - *, - default_id: str | None = None, - exposed_ids: set[str] | None = None, -) -> Any: - """Find an unambiguously-matching MA player for ``hint``, or None. - - Thin wrapper over ``resolve_player_candidates`` — returns the single - candidate when exactly one matches, ``None`` otherwise (zero matches - or ambiguous). Use ``resolve_player_candidates`` directly when you - want to surface the ambiguity to the user. - """ - candidates = resolve_player_candidates( - mass, hint, default_id=default_id, exposed_ids=exposed_ids - ) - return candidates[0] if len(candidates) == 1 else None diff --git a/music_assistant/providers/yandex_smarthome/dialogs_player.py b/music_assistant/providers/yandex_smarthome/dialogs_player.py deleted file mode 100644 index 9598f7cbc1..0000000000 --- a/music_assistant/providers/yandex_smarthome/dialogs_player.py +++ /dev/null @@ -1,313 +0,0 @@ -# ruff: noqa: RUF001 -"""Music + player resolvers for the Yandex Dialogs custom-skill webhook. - -`resolve_query` turns a `ParsedCommand` into a concrete URI/MediaItem ready -to feed to `mass.player_queues.play_media`. `play_for_alice` wraps the -power-on + queue-play sequence. - -Bound to MA APIs: `mass.music.search`, `mass.music.get_item_by_uri`, -`mass.player_queues.play_media`, `mass.players.cmd_power`. -""" - -from __future__ import annotations - -import asyncio -import logging -from typing import TYPE_CHECKING, Any - -from music_assistant_models.enums import MediaType, QueueOption - -from .dialogs_nlu import _normalize_player_token - -if TYPE_CHECKING: - from music_assistant_models.media_items import MediaItemType - - from music_assistant.mass import MusicAssistant - - from .dialogs_nlu import ParsedCommand - - -_LOGGER = logging.getLogger(__name__) - -_SEARCH_LIMIT_DEFAULT = 5 - - -def _has_cyrillic(text: str) -> bool: - """Return True if `text` contains at least one Cyrillic letter.""" - return any("а" <= c.lower() <= "я" or c.lower() == "ё" for c in text) - - -def _has_feature(player: Any, feature_name: str) -> bool: - """Mirror provider.device._has_feature so we don't import device.py from here.""" - features = getattr(player, "supported_features", None) - if not features: - return False - return any( - str(f) == feature_name or getattr(f, "value", None) == feature_name for f in features - ) - - -def _first(items: Any) -> Any: - """Return the first item of a Sequence, or None if empty/not-a-sequence.""" - try: - return next(iter(items)) - except (StopIteration, TypeError): - return None - - -# --------------------------------------------------------------------------- -# Content resolver -# --------------------------------------------------------------------------- - - -async def resolve_query(mass: MusicAssistant, parsed: ParsedCommand) -> MediaItemType | str | None: - """Pick the best media item for the parsed voice command. - - Returns either a MediaItem or a URI string (both accepted by - play_media); None means we couldn't resolve and the webhook handler - should respond with a "not found" message to the user. - """ - if parsed.kind == "my_wave": - return await _resolve_my_wave(mass) - if parsed.kind == "genre": - return await _resolve_genre(mass, parsed.query) - - if not parsed.query: - return None - - # Map kind → search MediaTypes, prefer-library bias on first try. - media_types_by_kind: dict[str, list[MediaType]] = { - "track": [MediaType.TRACK], - "artist": [MediaType.ARTIST], - "album": [MediaType.ALBUM], - "playlist": [MediaType.PLAYLIST], - "search": [MediaType.PLAYLIST, MediaType.ALBUM, MediaType.ARTIST, MediaType.TRACK], - } - media_types = media_types_by_kind.get(parsed.kind, [MediaType.TRACK]) - - try: - results = await mass.music.search( - search_query=parsed.query, - media_types=media_types, - limit=_SEARCH_LIMIT_DEFAULT, - ) - except asyncio.CancelledError: - raise - except Exception as exc: - _LOGGER.warning("mass.music.search failed for %r: %s", parsed.query, exc) - return None - - picked = _pick_from_results(results, parsed.kind) - if picked is not None: - return picked - - # P0.7 — retry search with the inflection-stripped query if the original - # was Russian. Yandex ASR usually returns words in the case the user - # spoke ("включи металлику" → accusative); music indexes store the - # nominative ("Металлика"). Stripping the trailing suffix ("металлик") - # is enough of a stem to land prefix matches in most providers. - if not _has_cyrillic(parsed.query): - return None - stemmed = _normalize_player_token(parsed.query) - if not stemmed or stemmed == parsed.query.lower(): - return None - try: - results2 = await mass.music.search( - search_query=stemmed, - media_types=media_types, - limit=_SEARCH_LIMIT_DEFAULT, - ) - except asyncio.CancelledError: - raise - except Exception as exc: - _LOGGER.warning("stemmed-retry search failed for %r: %s", stemmed, exc) - return None - return _pick_from_results(results2, parsed.kind) - - -def _pick_from_results(results: object, kind: str) -> MediaItemType | None: - """Pick best MediaItem from SearchResults given the parsed kind.""" - # SearchResults has .artists, .albums, .tracks, .playlists, plus .library_*. - # For "search" (no explicit marker), users almost always say a band / - # song / album name without qualifier ("включи Iron Maiden", - # "включи Yesterday"). Best UX is to resolve to the ARTIST first - # (radio_mode=True will be set on top → starts artist radio), then - # ALBUM, then TRACK; PLAYLIST is least likely to be what the user - # wants when they didn't say "плейлист" or "подборку". - order: list[str] - if kind == "search": - order = ["artists", "albums", "tracks", "playlists"] - elif kind == "track": - order = ["tracks"] - elif kind == "artist": - order = ["artists"] - elif kind == "album": - order = ["albums"] - elif kind == "playlist": - order = ["playlists"] - else: - order = ["tracks"] - - for attr in order: - bucket = getattr(results, attr, None) - if not bucket: - continue - item = _first(bucket) - if item is not None: - return item # type: ignore[no-any-return] - return None - - -# --------------------------------------------------------------------------- -# Yandex-specific specials -# --------------------------------------------------------------------------- - - -def _find_yandex_music_provider(mass: MusicAssistant) -> Any: - """Locate the first available yandex_music music provider instance.""" - for attr in ("music_providers", "providers"): - try: - for prov in getattr(mass, attr, ()): - if getattr(prov, "domain", None) == "yandex_music" and getattr( - prov, "available", True - ): - return prov - except Exception: # noqa: S110 - pass - return None - - -async def _resolve_my_wave(mass: MusicAssistant) -> str | None: - """Resolve "My Wave" radio — yandex_music rotor station user:onyourwave. - - Returns a track URI from the rotor batch; play_media in radio mode - will keep pulling next tracks via the standard queue radio loop. If - yandex_music isn't installed/available, returns None. - """ - provider = _find_yandex_music_provider(mass) - if provider is None: - _LOGGER.info("My Wave requested but yandex_music provider is not available") - return None - try: - client = getattr(provider, "client", None) - if client is None: - return None - tracks, _batch_id = await client.get_rotor_station_tracks("user:onyourwave") - except asyncio.CancelledError: - raise - except Exception as exc: - _LOGGER.warning("My Wave rotor fetch failed: %s", exc) - return None - if not tracks: - return None - first_track = tracks[0] - track_id = getattr(first_track, "id", None) or getattr(first_track, "track_id", None) - if not track_id: - return None - instance_id = getattr(provider, "instance_id", "yandex_music") - return f"{instance_id}://track/{track_id}" - - -async def _resolve_genre(mass: MusicAssistant, query: str) -> MediaItemType | str | None: - """Resolve genre-based radio. - - Best-effort: try yandex_music genre rotor; - fall back to plain artist search with radio_mode upstream. - """ - if not query: - return None - provider = _find_yandex_music_provider(mass) - if provider is not None: - try: - client = getattr(provider, "client", None) - if client is not None: - station_id = f"genre:{query}" - tracks, _ = await client.get_rotor_station_tracks(station_id) - if tracks: - first_track = tracks[0] - track_id = getattr(first_track, "id", None) or getattr( - first_track, "track_id", None - ) - if track_id: - instance_id = getattr(provider, "instance_id", "yandex_music") - return f"{instance_id}://track/{track_id}" - except asyncio.CancelledError: - raise - except Exception as exc: - _LOGGER.debug("Genre rotor fallback for %r: %s", query, exc) - - # Generic fallback: search across artists+tracks; caller will use radio_mode. - try: - results = await mass.music.search( - search_query=query, - media_types=[MediaType.ARTIST, MediaType.TRACK], - limit=_SEARCH_LIMIT_DEFAULT, - ) - except asyncio.CancelledError: - raise - except Exception as exc: - _LOGGER.warning("Genre fallback search failed for %r: %s", query, exc) - return None - return _first(getattr(results, "artists", None) or []) or _first( # type: ignore[no-any-return] - getattr(results, "tracks", None) or [] - ) - - -# --------------------------------------------------------------------------- -# Playback -# --------------------------------------------------------------------------- - - -_ENQUEUE_TO_QUEUE_OPTION: dict[str, QueueOption] = { - "add": QueueOption.ADD, - "next": QueueOption.NEXT, - "replace": QueueOption.REPLACE, -} - - -async def play_for_alice( - mass: MusicAssistant, - player_id: str, - media: MediaItemType | str, - *, - radio_mode: bool = False, - enqueue_option: str | None = None, -) -> None: - """Power the player on if needed, then start playback via player_queues. - - ``enqueue_option`` (None / "replace" / "next" / "add") is mapped to - the matching :class:`QueueOption` and forwarded to - ``mass.player_queues.play_media``. ``None`` lets MA pick the - per-media-type default (typically REPLACE) — the historical - behaviour. - - Power-on policy: regardless of ``enqueue_option``, an off player - gets ``cmd_power(True)``. Voice intent is unambiguous — the user - just asked for music, so a player that's been off needs to wake up. - MA's ``play_media`` will then sequence ADD/NEXT correctly (queue - grows; playback may or may not start depending on current queue - state). If the user wants to enqueue without disturbing playback - on a different player, they should name that other player - explicitly via the ``на `` suffix. - """ - player = mass.players.get_player(player_id) - if player is not None and _has_feature(player, "power"): - powered = getattr(player, "powered", None) - if powered is False: - try: - await mass.players.cmd_power(player_id, True) - except asyncio.CancelledError: - raise - except Exception as exc: - _LOGGER.warning("cmd_power(True) on %s failed: %s", player_id, exc) - - play_kwargs: dict[str, Any] = { - "queue_id": player_id, - "media": media, - "radio_mode": radio_mode, - } - if enqueue_option is not None: - mapped = _ENQUEUE_TO_QUEUE_OPTION.get(enqueue_option) - if mapped is not None: - play_kwargs["option"] = mapped - await mass.player_queues.play_media(**play_kwargs) diff --git a/music_assistant/providers/yandex_smarthome/manifest.json b/music_assistant/providers/yandex_smarthome/manifest.json index 19b0074718..7f3ccac72f 100644 --- a/music_assistant/providers/yandex_smarthome/manifest.json +++ b/music_assistant/providers/yandex_smarthome/manifest.json @@ -7,7 +7,10 @@ "credits": [ "[dext0r/yandex_smart_home](https://github.com/dext0r/yandex_smart_home)" ], - "requirements": ["ya-passport-auth==1.3.0"], + "requirements": [ + "ya-passport-auth==1.3.0", + "ya-dialogs-api>=1.0.0" + ], "documentation": "https://github.com/trudenboy/ma-provider-yandex-smarthome", "stage": "beta", "multi_instance": false, diff --git a/music_assistant/providers/yandex_smarthome/plugin.py b/music_assistant/providers/yandex_smarthome/plugin.py index 31a9adabdd..4727678ff6 100644 --- a/music_assistant/providers/yandex_smarthome/plugin.py +++ b/music_assistant/providers/yandex_smarthome/plugin.py @@ -21,9 +21,10 @@ from dataclasses import asdict from typing import Any +from ya_dialogs_api import SecretStr + from music_assistant.models.plugin import PluginProvider -from ._compat import SecretStr from .cloud import CloudManager from .constants import ( CLOUD_CALLBACK_URL, @@ -31,9 +32,6 @@ CONF_CLOUD_INSTANCE_ID, CONF_CLOUD_INSTANCE_PASSWORD, CONF_CONNECTION_TYPE, - CONF_DIALOG_SKILL_ENABLED, - CONF_DIALOG_SKILL_ID, - CONF_DIALOG_WEBHOOK_SECRET, CONF_DIRECT_ACCESS_TOKEN, CONF_DIRECT_CLIENT_SECRET, CONF_EXPOSED_PLAYERS, @@ -47,7 +45,6 @@ MAX_INPUT_SOURCES, YANDEX_DIALOGS_CALLBACK_BASE, ) -from .dialogs import DialogsWebhookHandler from .direct import DirectConnectionHandler from .handlers import ( build_response, @@ -72,7 +69,6 @@ class YandexSmartHomePlugin(PluginProvider): _cloud_manager: CloudManager | None = None _state_notifier: StateNotifier | None = None _direct_handler: DirectConnectionHandler | None = None - _dialogs_handler: DialogsWebhookHandler | None = None _cloud_task: Any = None _user_id: str = "" @@ -99,11 +95,6 @@ async def handle_async_init(self) -> None: self._direct_access_token = str(self.config.get_value(CONF_DIRECT_ACCESS_TOKEN) or "") self._direct_client_secret = str(self.config.get_value(CONF_DIRECT_CLIENT_SECRET) or "") - # Dialog skill (experimental, direct-mode only) - self._dialog_skill_enabled = bool(self.config.get_value(CONF_DIALOG_SKILL_ENABLED)) - self._dialog_skill_id = str(self.config.get_value(CONF_DIALOG_SKILL_ID) or "") - self._dialog_webhook_secret = str(self.config.get_value(CONF_DIALOG_WEBHOOK_SECRET) or "") - # Parse exposed players filter exposed_raw = self.config.get_value(CONF_EXPOSED_PLAYERS) or [] if isinstance(exposed_raw, str): @@ -275,26 +266,6 @@ def _on_token_created(token: str) -> None: "skill' in the plugin settings to complete setup." ) - # Experimental: Dialogs voice skill webhook handler - if self._dialog_skill_enabled: - if self._dialog_skill_id and self._dialog_webhook_secret: - self._dialogs_handler = DialogsWebhookHandler( - mass=self.mass, - skill_id=self._dialog_skill_id, - webhook_secret=self._dialog_webhook_secret, - exposed_player_ids=self._exposed_ids, - ) - self._dialogs_handler.register_routes() - self.logger.info( - "Dialogs voice skill enabled (experimental), skill_id=%s", - self._dialog_skill_id, - ) - else: - self.logger.warning( - "Dialogs voice skill is enabled but dialog_skill_id or " - "dialog_webhook_secret is not configured — skipping" - ) - self.logger.info("Direct connection mode started") async def _handle_cloud_request(self, request: CloudRequest) -> dict[str, Any]: @@ -369,10 +340,6 @@ async def unload(self, is_removed: bool = False) -> None: await self._state_notifier.stop() self._state_notifier = None - if self._dialogs_handler: - self._dialogs_handler.unregister_routes() - self._dialogs_handler = None - if self._direct_handler: self._direct_handler.unregister_routes() self._direct_handler = None diff --git a/requirements_all.txt b/requirements_all.txt index 40aca02d81..baaebc4001 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -88,6 +88,7 @@ uv>=0.8.0 websocket-client==1.9.0 wiim==0.1.4 xmltodict==1.0.4 +ya-dialogs-api>=1.0.0 ya-passport-auth==1.3.0 yandex-music==3.0.0 ytmusicapi==1.11.5 diff --git a/tests/providers/yandex_smarthome/test_auto_skill.py b/tests/providers/yandex_smarthome/test_auto_skill.py deleted file mode 100644 index 60c1b94df0..0000000000 --- a/tests/providers/yandex_smarthome/test_auto_skill.py +++ /dev/null @@ -1,1173 +0,0 @@ -"""Tests for provider/auto_skill.py — DialogsSkillCreator low-level client.""" - -from __future__ import annotations - -import json -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, Any -from unittest.mock import AsyncMock, MagicMock - -if TYPE_CHECKING: - from collections.abc import AsyncIterator - -import aiohttp -import pytest - -from music_assistant.providers.yandex_smarthome.auto_skill import ( - DIALOGS_API_BASE, - DIALOGS_DEV_HTML_URL, - DialogsApiError, - DialogsCsrfError, - DialogsDuplicateSkillError, - DialogsSkillCreator, - auto_create_skill, - auto_rename_dialog_skill, - build_draft_payload, - build_oauth_app_payload, - check_preconditions, - derive_auth_urls, - derive_backend_uri, - derive_client_id, - load_default_logo_bytes, -) -from music_assistant.providers.yandex_smarthome.auto_skill_state import ( - SkillCreationArtifacts, - SkillCreationState, -) -from music_assistant.providers.yandex_smarthome.constants import ( - CONNECTION_TYPE_CLOUD, - CONNECTION_TYPE_CLOUD_PLUS, - CONNECTION_TYPE_DIRECT, -) - -# --------------------------------------------------------------------------- -# Test helpers: mock aiohttp session matching existing test_cloud.py pattern -# --------------------------------------------------------------------------- - - -def _mock_response(*, status: int = 200, body_text: str = "", body_json: Any = None) -> AsyncMock: - """Build a mock aiohttp.ClientResponse. - - If *body_json* is given, ``text()`` returns its JSON-encoded form; - otherwise ``body_text`` is used verbatim. Also wires up - ``content.iter_chunked(size)`` as a single-chunk async iterator over - ``body_text.encode()`` so ``fetch_csrf``'s streaming reader works. - """ - resp = AsyncMock() - resp.status = status - if body_json is not None: - body_text = json.dumps(body_json, ensure_ascii=False) - resp.text = AsyncMock(return_value=body_text) - resp.get_encoding = MagicMock(return_value="utf-8") - - body_bytes = body_text.encode("utf-8") - - async def _aiter(_size: int) -> Any: # pragma: no cover — trivial - if body_bytes: - yield body_bytes - - resp.content = MagicMock() - resp.content.iter_chunked = _aiter - return resp - - -def _install_ctx(session_method: MagicMock, mock_resp: AsyncMock) -> MagicMock: - """Attach an async context manager returning *mock_resp* to a session method.""" - ctx = MagicMock() - ctx.__aenter__ = AsyncMock(return_value=mock_resp) - ctx.__aexit__ = AsyncMock(return_value=False) - session_method.return_value = ctx - return ctx - - -def _make_session() -> MagicMock: - session = MagicMock(spec=aiohttp.ClientSession) - session.get = MagicMock() - session.post = MagicMock() - session.request = MagicMock() - return session - - -# --------------------------------------------------------------------------- -# fetch_csrf -# --------------------------------------------------------------------------- - - -class TestFetchCsrf: - """CSRF token extraction from developer console HTML.""" - - @pytest.mark.asyncio - async def test_happy_path_returns_token(self) -> None: - """CSRF is extracted from the secretkey field in the developer HTML.""" - html = ( - "" - ) - session = _make_session() - _install_ctx(session.get, _mock_response(status=200, body_text=html)) - creator = DialogsSkillCreator(session) - - token = await creator.fetch_csrf() - assert token == "u9c94f1aca53bf156be4abc" - # Verify we hit the expected URL - session.get.assert_called_once_with(DIALOGS_DEV_HTML_URL) - - @pytest.mark.asyncio - async def test_regex_miss_raises_csrf_error(self) -> None: - """Yandex changed the HTML format → clean typed error for fallback.""" - html = "no secretkey here" - session = _make_session() - _install_ctx(session.get, _mock_response(status=200, body_text=html)) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsCsrfError) as exc_info: - await creator.fetch_csrf() - assert exc_info.value.step == "fetch_csrf" - - @pytest.mark.asyncio - async def test_401_maps_to_auth_error(self) -> None: - """401 means passport cookies missing/expired — retryable via relogin.""" - session = _make_session() - _install_ctx(session.get, _mock_response(status=401, body_text="nope")) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsApiError) as exc_info: - await creator.fetch_csrf() - assert exc_info.value.http_status == 401 - assert exc_info.value.step == "fetch_csrf" - - @pytest.mark.asyncio - async def test_empty_token_raises(self) -> None: - """Regex matched but captured empty string — treat as miss.""" - html = '{"secretkey":""}' - session = _make_session() - _install_ctx(session.get, _mock_response(status=200, body_text=html)) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsCsrfError): - await creator.fetch_csrf() - - -# --------------------------------------------------------------------------- -# create_app -# --------------------------------------------------------------------------- - - -class TestCreateApp: - """POST /apps — returns skill_id on success.""" - - @pytest.mark.asyncio - async def test_happy_path_returns_skill_id(self) -> None: - """POST /apps returns a skill UUID and sends the expected payload.""" - session = _make_session() - _install_ctx( - session.request, - _mock_response( - status=200, - body_json={"result": {"id": "7584419b-6815-4a68-8e32-b9fb111b596d"}}, - ), - ) - creator = DialogsSkillCreator(session) - - skill_id = await creator.create_app("csrf-token", "My Skill") - assert skill_id == "7584419b-6815-4a68-8e32-b9fb111b596d" - - # Verify request shape - method, url = session.request.call_args.args[:2] - assert method == "POST" - assert url == f"{DIALOGS_API_BASE}/apps" - payload = session.request.call_args.kwargs["json"] - assert payload["channel"] == "smartHome" - assert payload["language"] == "ru" - assert payload["isYangoConsole"] is False - assert payload["appName"] == "My Skill" - headers = session.request.call_args.kwargs["headers"] - assert headers["x-csrf-token"] == "csrf-token" - - @pytest.mark.asyncio - async def test_duplicate_name_raises_typed_error(self) -> None: - """HTTP 409 with a duplicate-indicator body maps to DialogsDuplicateSkillError.""" - session = _make_session() - _install_ctx( - session.request, - _mock_response( - status=409, - body_json={"error": "not_unique", "message": "skill already exists"}, - ), - ) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsDuplicateSkillError) as exc_info: - await creator.create_app("csrf", "duplicate name") - assert exc_info.value.http_status == 409 - - @pytest.mark.asyncio - async def test_generic_4xx_raises_api_error(self) -> None: - """Non-duplicate 4xx surfaces as plain DialogsApiError, not the subclass.""" - session = _make_session() - _install_ctx( - session.request, - _mock_response(status=400, body_text="bad request"), - ) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsApiError) as exc_info: - await creator.create_app("csrf", "X") - # Not a duplicate — should be plain DialogsApiError, not subclass - assert not isinstance(exc_info.value, DialogsDuplicateSkillError) - assert exc_info.value.http_status == 400 - - @pytest.mark.asyncio - async def test_missing_skill_id_raises(self) -> None: - """A 200 response without an id field is treated as a protocol break.""" - session = _make_session() - _install_ctx( - session.request, - _mock_response(status=200, body_json={"result": {}}), - ) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsApiError): - await creator.create_app("csrf", "X") - - -# --------------------------------------------------------------------------- -# upload_logo -# --------------------------------------------------------------------------- - - -class TestUploadLogo: - """POST /apps/{id}/draft/upload-logo — multipart file upload.""" - - @pytest.mark.asyncio - async def test_happy_path_returns_logo_id(self) -> None: - """upload_logo posts multipart PNG and returns the avatar id.""" - session = _make_session() - _install_ctx( - session.post, - _mock_response( - status=200, - body_json={ - "result": { - "id": "be043706-a868-4999-83c8-f17bbd60745d", - "url": "https://avatars.mds.yandex.net/...", - } - }, - ), - ) - creator = DialogsSkillCreator(session) - - logo_id = await creator.upload_logo("csrf", "skill-1", b"\x89PNG\r\n\x1a\n...") - assert logo_id == "be043706-a868-4999-83c8-f17bbd60745d" - - call = session.post.call_args - url = call.args[0] - assert "/draft/upload-logo" in url - assert "channel=smartHome" in url - # FormData used for multipart - assert isinstance(call.kwargs["data"], aiohttp.FormData) - assert call.kwargs["headers"]["x-csrf-token"] == "csrf" - - @pytest.mark.asyncio - async def test_upload_500_raises(self) -> None: - """Server-side 500 surfaces as DialogsApiError carrying the step name.""" - session = _make_session() - _install_ctx(session.post, _mock_response(status=500, body_text="oops")) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsApiError) as exc_info: - await creator.upload_logo("csrf", "sk", b"data") - assert exc_info.value.step == "upload_logo" - assert exc_info.value.http_status == 500 - - -# --------------------------------------------------------------------------- -# update_draft -# --------------------------------------------------------------------------- - - -class TestUpdateDraft: - """PATCH /apps/{id}/draft/update — accepts arbitrary payload dict.""" - - @pytest.mark.asyncio - async def test_happy_path(self) -> None: - """PATCH goes to the right URL with the forwarded payload and CSRF header.""" - session = _make_session() - _install_ctx( - session.request, - _mock_response(status=200, body_json={"result": {"ok": True}}), - ) - creator = DialogsSkillCreator(session) - - payload = {"name": "Music Assistant", "channel": "smartHome"} - await creator.update_draft("csrf", "skill-1", payload) - - method, url = session.request.call_args.args[:2] - assert method == "PATCH" - assert url == f"{DIALOGS_API_BASE}/apps/skill-1/draft/update" - assert session.request.call_args.kwargs["json"] == payload - - -# --------------------------------------------------------------------------- -# create_oauth_app -# --------------------------------------------------------------------------- - - -class TestCreateOAuthApp: - """POST /oauth/apps — returns oauth_app_id.""" - - @pytest.mark.asyncio - async def test_happy_path(self) -> None: - """create_oauth_app builds the correct payload and returns the new id.""" - session = _make_session() - _install_ctx( - session.request, - _mock_response( - status=200, - body_json={"result": {"id": "oauth-uuid-123"}}, - ), - ) - creator = DialogsSkillCreator(session) - - oauth_id = await creator.create_oauth_app( - "csrf", - name="My Skill", - client_id="yandex_smart_home:inst1", - client_secret="secret", - authorize_url="https://yaha-cloud.ru/oauth/authorize", - token_url="https://yaha-cloud.ru/oauth/token", - refresh_url="https://yaha-cloud.ru/oauth/token", - ) - assert oauth_id == "oauth-uuid-123" - - payload = session.request.call_args.kwargs["json"] - assert payload["clientId"] == "yandex_smart_home:inst1" - assert payload["clientSecret"] == "secret" - assert payload["authorizationUrl"] == "https://yaha-cloud.ru/oauth/authorize" - assert payload["scope"] == "" - - -# --------------------------------------------------------------------------- -# attach_oauth -# --------------------------------------------------------------------------- - - -class TestAttachOAuth: - """POST /apps/{id}/oauthApp — links oauth_app to skill.""" - - @pytest.mark.asyncio - async def test_happy_path(self) -> None: - """attach_oauth sends POST with channel query + oauthAppId body.""" - session = _make_session() - _install_ctx( - session.request, - _mock_response(status=200, body_json={"result": "oauth-id"}), - ) - creator = DialogsSkillCreator(session) - - await creator.attach_oauth("csrf", "skill-1", "oauth-1") - - method, url = session.request.call_args.args[:2] - assert method == "POST" - assert "channel=smartHome" in url - assert session.request.call_args.kwargs["json"] == {"oauthAppId": "oauth-1"} - - -# --------------------------------------------------------------------------- -# request_deploy -# --------------------------------------------------------------------------- - - -class TestRequestDeploy: - """POST /apps/{id}/draft/request-deploy — publishes draft.""" - - @pytest.mark.asyncio - async def test_happy_path_empty_body(self) -> None: - """Request-deploy uses an empty body and relies on the URL query only.""" - session = _make_session() - _install_ctx(session.post, _mock_response(status=200, body_json={"result": {}})) - creator = DialogsSkillCreator(session) - - await creator.request_deploy("csrf", "skill-1") - - call = session.post.call_args - url = call.args[0] - assert "request-deploy" in url - assert "channel=smartHome" in url - # No body (only headers) - assert "data" not in call.kwargs - assert "json" not in call.kwargs - assert call.kwargs["headers"]["x-csrf-token"] == "csrf" - - @pytest.mark.asyncio - async def test_deploy_accepts_2xx_variants(self) -> None: - """Some publish responses are 202/204 depending on server side.""" - for status in (201, 202, 204): - session = _make_session() - _install_ctx(session.post, _mock_response(status=status, body_text="")) - creator = DialogsSkillCreator(session) - await creator.request_deploy("csrf", "skill-1") # must not raise - - -# --------------------------------------------------------------------------- -# list_existing_skills -# --------------------------------------------------------------------------- - - -class TestListExistingSkills: - """GET /snapshot — returns existing skills; raises DialogsApiError on malformed JSON.""" - - @pytest.mark.asyncio - async def test_returns_skill_dicts(self) -> None: - """Snapshot is parsed into a list of skill dicts.""" - session = _make_session() - _install_ctx( - session.get, - _mock_response( - status=200, - body_json={ - "result": { - "skills": [ - {"id": "s1", "name": "First"}, - {"id": "s2", "name": "Second"}, - ] - } - }, - ), - ) - creator = DialogsSkillCreator(session) - - skills = await creator.list_existing_skills("csrf") - assert len(skills) == 2 - assert skills[0]["name"] == "First" - - @pytest.mark.asyncio - async def test_raises_when_malformed(self) -> None: - """Non-JSON snapshot body is a protocol break — raise instead of hiding it.""" - session = _make_session() - _install_ctx(session.get, _mock_response(status=200, body_text="not json")) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsApiError): - await creator.list_existing_skills("csrf") - - -# --------------------------------------------------------------------------- -# Payload builders + preconditions (pure functions) -# --------------------------------------------------------------------------- - - -def _mass_with_base_url(base_url: str) -> MagicMock: - mass = MagicMock() - mass.webserver.base_url = base_url - return mass - - -class TestDeriveBackendUri: - """derive_backend_uri routes per-mode.""" - - def test_cloud_plus_uses_yaha_relay_constant(self) -> None: - """cloud_plus always points at the fixed yaha-cloud webhook.""" - mass = _mass_with_base_url("https://my-ma.example.com") - assert ( - derive_backend_uri(mass, CONNECTION_TYPE_CLOUD_PLUS) - == "https://yaha-cloud.ru/api/yandex_smart_home" - ) - - def test_direct_uses_ma_base_plus_api_path(self) -> None: - """Direct backend URI is base_url + /api/yandex_smarthome (no /v1.0). - - Yandex appends /v1.0/... itself when calling the backend, so the - URI we send must NOT include /v1.0. - """ - mass = _mass_with_base_url("https://my-ma.example.com/") - # Trailing slash on base_url should be stripped so the full URL is clean. - assert ( - derive_backend_uri(mass, CONNECTION_TYPE_DIRECT) - == "https://my-ma.example.com/api/yandex_smarthome" - ) - - def test_cloud_raises(self) -> None: - """Plain 'cloud' mode has no custom skill — function must reject it.""" - mass = _mass_with_base_url("https://x") - with pytest.raises(ValueError, match="connection_type"): - derive_backend_uri(mass, CONNECTION_TYPE_CLOUD) - - -class TestDeriveAuthUrls: - """derive_auth_urls returns (authorize_url, token_url).""" - - def test_cloud_plus_urls(self) -> None: - """cloud_plus uses yaha-cloud OAuth endpoints.""" - mass = _mass_with_base_url("https://x") - auth, token = derive_auth_urls(mass, CONNECTION_TYPE_CLOUD_PLUS) - assert auth == "https://yaha-cloud.ru/oauth/authorize" - assert token == "https://yaha-cloud.ru/oauth/token" - - def test_direct_urls_use_ma_base(self) -> None: - """Direct uses the MA webserver's own authorize/token endpoints.""" - mass = _mass_with_base_url("https://ma.example.com") - auth, token = derive_auth_urls(mass, CONNECTION_TYPE_DIRECT) - assert auth == "https://ma.example.com/api/yandex_smarthome/auth/authorize" - assert token == "https://ma.example.com/api/yandex_smarthome/auth/token" - - -class TestDeriveClientId: - """derive_client_id formats the OAuth client_id per mode.""" - - def test_cloud_plus_templated(self) -> None: - """cloud_plus wraps the instance_id in the yaha protocol prefix.""" - assert derive_client_id(CONNECTION_TYPE_CLOUD_PLUS, "abc123") == "yandex_smart_home:abc123" - - def test_cloud_plus_missing_instance_raises(self) -> None: - """Empty instance_id is a configuration bug — raise early.""" - with pytest.raises(ValueError, match="cloud_instance_id"): - derive_client_id(CONNECTION_TYPE_CLOUD_PLUS, "") - - def test_direct_fixed_value(self) -> None: - """Direct mode uses the fixed Yandex social redirect base.""" - assert derive_client_id(CONNECTION_TYPE_DIRECT, "") == "https://social.yandex.net/" - - -class TestBuildDraftPayload: - """Snapshot coverage for the 100+-field draft/update payload.""" - - def test_cloud_plus_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] - """cloud_plus draft payload matches the captured HAR shape.""" - payload = build_draft_payload( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - skill_name="Music Assistant", - backend_uri="https://yaha-cloud.ru/api/yandex_smart_home", - logo_id="be043706-a868-4999-83c8-f17bbd60745d", - developer_name="alice", - ) - assert payload == snapshot - - def test_direct_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] - """Direct draft payload matches the captured HAR shape.""" - payload = build_draft_payload( - connection_type=CONNECTION_TYPE_DIRECT, - skill_name="Music Assistant", - backend_uri="https://ma.example.com/api/yandex_smarthome", - logo_id=None, - developer_name="alice", - ) - assert payload == snapshot - - def test_invalid_mode_raises(self) -> None: - """Plain 'cloud' has no auto-create path.""" - with pytest.raises(ValueError, match="connection_type"): - build_draft_payload( - connection_type=CONNECTION_TYPE_CLOUD, - skill_name="x", - backend_uri="https://x", - logo_id=None, - ) - - -class TestBuildOAuthAppPayload: - """OAuth-app payload is simpler — both modes round-trip the given fields.""" - - def test_cloud_plus_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] - """cloud_plus payload uses literal 'secret' and the yaha-prefixed client_id.""" - payload = build_oauth_app_payload( - skill_name="Music Assistant", - client_id="yandex_smart_home:abc123", - client_secret="secret", - authorize_url="https://yaha-cloud.ru/oauth/authorize", - token_url="https://yaha-cloud.ru/oauth/token", - ) - assert payload == snapshot - - def test_direct_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] - """Direct payload uses social.yandex.net client_id and a per-install secret.""" - payload = build_oauth_app_payload( - skill_name="Music Assistant", - client_id="https://social.yandex.net/", - client_secret="abc123deadbeef", - authorize_url="https://ma.example.com/api/yandex_smarthome/auth/authorize", - token_url="https://ma.example.com/api/yandex_smarthome/auth/token", - ) - assert payload == snapshot - - -class TestCheckPreconditions: - """check_preconditions rejects invalid configurations early.""" - - def test_cloud_plus_requires_instance(self) -> None: - """cloud_plus without a registered cloud instance is rejected.""" - mass = _mass_with_base_url("https://x") - with pytest.raises(ValueError, match="yaha-cloud instance"): - check_preconditions( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - mass=mass, - cloud_instance_id="", - direct_client_secret="", - ) - - def test_cloud_plus_with_instance_ok(self) -> None: - """cloud_plus with a registered instance_id passes.""" - mass = _mass_with_base_url("https://x") - check_preconditions( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - mass=mass, - cloud_instance_id="abc", - direct_client_secret="", - ) - - def test_direct_requires_https_base_url(self) -> None: - """Direct rejects non-HTTPS base URLs (Yandex won't accept).""" - mass = _mass_with_base_url("http://ma.local:8095") - with pytest.raises(ValueError, match="HTTPS"): - check_preconditions( - connection_type=CONNECTION_TYPE_DIRECT, - mass=mass, - cloud_instance_id="", - direct_client_secret="secret", - ) - - def test_direct_requires_client_secret(self) -> None: - """Direct rejects empty client_secret (would break account-linking).""" - mass = _mass_with_base_url("https://ma.example.com") - with pytest.raises(ValueError, match="Client Secret"): - check_preconditions( - connection_type=CONNECTION_TYPE_DIRECT, - mass=mass, - cloud_instance_id="", - direct_client_secret="", - ) - - def test_direct_happy_path(self) -> None: - """Direct with HTTPS base URL and a secret passes.""" - mass = _mass_with_base_url("https://ma.example.com") - check_preconditions( - connection_type=CONNECTION_TYPE_DIRECT, - mass=mass, - cloud_instance_id="", - direct_client_secret="my-secret", - ) - - def test_cloud_mode_rejected(self) -> None: - """Plain 'cloud' never uses a custom skill — reject.""" - mass = _mass_with_base_url("https://x") - with pytest.raises(ValueError, match="cloud_plus or direct"): - check_preconditions( - connection_type=CONNECTION_TYPE_CLOUD, - mass=mass, - cloud_instance_id="", - direct_client_secret="", - ) - - -# --------------------------------------------------------------------------- -# Orchestrator (auto_create_skill) -# --------------------------------------------------------------------------- - - -def _make_creator_mock() -> AsyncMock: - """Build an AsyncMock matching DialogsSkillCreator's interface.""" - creator = AsyncMock(spec=DialogsSkillCreator) - creator.fetch_csrf = AsyncMock(return_value="csrf-token") - creator.create_app = AsyncMock(return_value="skill-uuid") - creator.upload_logo = AsyncMock(return_value="logo-uuid") - creator.update_draft = AsyncMock(return_value=None) - creator.create_oauth_app = AsyncMock(return_value="oauth-uuid") - creator.attach_oauth = AsyncMock(return_value=None) - creator.request_deploy = AsyncMock(return_value=None) - return creator - - -def _fake_authenticator_factory( - *, - session: MagicMock | None = None, - session_id_captor: list[str] | None = None, -) -> Any: - """Build an async-generator authenticator usable as *authenticator*. - - If *session_id_captor* is given, the session_id passed in by the - orchestrator is appended to it so tests can assert it was forwarded. - """ - if session is None: - session = MagicMock(spec=aiohttp.ClientSession) - - async def _auth(mass, session_id, timeout, **_kwargs): # type: ignore[no-untyped-def] - if session_id_captor is not None: - session_id_captor.append(session_id) - _ = (mass, timeout) - yield session - - return _auth - - -async def _run_orch( - *, - creator: AsyncMock, - connection_type: str = CONNECTION_TYPE_CLOUD_PLUS, - artifacts: SkillCreationArtifacts | None = None, - base_url: str = "https://ma.example.com", - cloud_instance_id: str = "inst-1", - direct_client_secret: str = "", - session_id: str = "test-session-id", - progress_cb: Any = None, -) -> SkillCreationArtifacts: - """Run auto_create_skill with sensible test defaults. - - Injects *creator* via ``creator_factory`` and a fake authenticator - so no real ya-passport-auth or aiohttp traffic is attempted. - """ - mass = _mass_with_base_url(base_url) - return await auto_create_skill( - mass=mass, - connection_type=connection_type, - skill_name="Music Assistant", - artifacts=artifacts if artifacts is not None else SkillCreationArtifacts(), - cloud_instance_id=cloud_instance_id, - direct_client_secret=direct_client_secret, - logo_bytes=b"\x89PNG", - session_id=session_id, - authenticator=_fake_authenticator_factory(), - creator_factory=lambda _s: creator, - progress_cb=progress_cb, - ) - - -class TestAutoCreateSkillHappyPath: - """auto_create_skill on a fresh artifact runs all steps to DONE.""" - - @pytest.mark.asyncio - async def test_fresh_artifact_reaches_done(self) -> None: - """A NONE-state artifact runs the full pipeline to DONE.""" - creator = _make_creator_mock() - - result = await _run_orch(creator=creator) - - assert result.state == SkillCreationState.DONE - assert result.skill_id == "skill-uuid" - assert result.logo_id == "logo-uuid" - assert result.oauth_app_id == "oauth-uuid" - assert result.last_error is None - - for method in ( - creator.fetch_csrf, - creator.create_app, - creator.upload_logo, - creator.update_draft, - creator.create_oauth_app, - creator.attach_oauth, - creator.request_deploy, - ): - method.assert_awaited_once() - - @pytest.mark.asyncio - async def test_progress_cb_invoked_after_each_step(self) -> None: - """progress_cb receives a snapshot after every state transition.""" - creator = _make_creator_mock() - progress_calls: list[SkillCreationArtifacts] = [] - - async def _progress(a: SkillCreationArtifacts) -> None: - progress_calls.append(a) - - await _run_orch(creator=creator, progress_cb=_progress) - - observed_states = [a.state for a in progress_calls] - assert observed_states == [ - SkillCreationState.APP_CREATED, - SkillCreationState.DRAFT_UPDATED, - SkillCreationState.OAUTH_CREATED, - SkillCreationState.OAUTH_ATTACHED, - SkillCreationState.DEPLOY_REQUESTED, - SkillCreationState.DONE, - ] - - -class TestAutoCreateSkillResume: - """Non-NONE artifacts skip completed steps.""" - - @pytest.mark.asyncio - async def test_resume_from_app_created(self) -> None: - """create_app is not re-called when a skill_id is already present.""" - creator = _make_creator_mock() - starting = SkillCreationArtifacts( - state=SkillCreationState.APP_CREATED, skill_id="existing-skill" - ) - result = await _run_orch(creator=creator, artifacts=starting) - - creator.create_app.assert_not_awaited() - creator.upload_logo.assert_awaited_once() - assert result.skill_id == "existing-skill" - assert result.state == SkillCreationState.DONE - - @pytest.mark.asyncio - async def test_resume_from_oauth_attached(self) -> None: - """A near-done artifact only needs request_deploy.""" - creator = _make_creator_mock() - starting = SkillCreationArtifacts( - state=SkillCreationState.OAUTH_ATTACHED, - skill_id="s1", - logo_id="l1", - oauth_app_id="o1", - ) - result = await _run_orch(creator=creator, artifacts=starting) - - creator.create_app.assert_not_awaited() - creator.upload_logo.assert_not_awaited() - creator.update_draft.assert_not_awaited() - creator.create_oauth_app.assert_not_awaited() - creator.attach_oauth.assert_not_awaited() - creator.request_deploy.assert_awaited_once() - assert result.state == SkillCreationState.DONE - - @pytest.mark.asyncio - async def test_resume_from_deploy_requested(self) -> None: - """DEPLOY_REQUESTED is a real checkpoint: resume re-runs publish only.""" - creator = _make_creator_mock() - starting = SkillCreationArtifacts( - state=SkillCreationState.DEPLOY_REQUESTED, - skill_id="s1", - logo_id="l1", - oauth_app_id="o1", - ) - result = await _run_orch(creator=creator, artifacts=starting) - - creator.create_app.assert_not_awaited() - creator.upload_logo.assert_not_awaited() - creator.update_draft.assert_not_awaited() - creator.create_oauth_app.assert_not_awaited() - creator.attach_oauth.assert_not_awaited() - creator.request_deploy.assert_awaited_once() - assert result.state == SkillCreationState.DONE - - @pytest.mark.asyncio - async def test_resume_from_failed_restarts_pipeline(self) -> None: - """FAILED state is treated like NONE — full retry from create_app.""" - creator = _make_creator_mock() - starting = SkillCreationArtifacts( - state=SkillCreationState.FAILED, - last_error="some earlier error", - ) - result = await _run_orch(creator=creator, artifacts=starting) - - creator.create_app.assert_awaited_once() - assert result.state == SkillCreationState.DONE - assert result.last_error is None - - -class TestAutoCreateSkillFailure: - """Pipeline failures convert to FAILED state with preserved partial data.""" - - @pytest.mark.asyncio - async def test_failure_at_create_app_preserves_nothing(self) -> None: - """Duplicate-name error bubbles up as FAILED with no skill_id captured.""" - creator = _make_creator_mock() - creator.create_app.side_effect = DialogsDuplicateSkillError( - "exists", step="create_app", http_status=409 - ) - - result = await _run_orch(creator=creator) - - assert result.state == SkillCreationState.FAILED - assert result.skill_id is None - assert "exists" in (result.last_error or "") - - @pytest.mark.asyncio - async def test_failure_after_app_created_preserves_skill_id(self) -> None: - """A partial failure keeps the skill_id so retry resumes from DRAFT_UPDATED.""" - creator = _make_creator_mock() - creator.upload_logo.side_effect = DialogsApiError( - "500", step="upload_logo", http_status=500 - ) - - result = await _run_orch(creator=creator) - - assert result.state == SkillCreationState.FAILED - assert result.skill_id == "skill-uuid" - - @pytest.mark.asyncio - async def test_csrf_miss_becomes_failed_state(self) -> None: - """CSRF extraction failure doesn't crash — surfaces as FAILED.""" - creator = _make_creator_mock() - creator.fetch_csrf.side_effect = DialogsCsrfError("secretkey not found", step="fetch_csrf") - - result = await _run_orch(creator=creator) - - assert result.state == SkillCreationState.FAILED - assert "secretkey" in (result.last_error or "") - - @pytest.mark.asyncio - async def test_precondition_raises_unmodified(self) -> None: - """Preconditions raise ValueError so UI can show the exact message.""" - with pytest.raises(ValueError, match="HTTPS"): - await _run_orch( - creator=_make_creator_mock(), - connection_type=CONNECTION_TYPE_DIRECT, - base_url="http://not-https.example.com", - direct_client_secret="secret", - ) - - @pytest.mark.asyncio - async def test_progress_cb_exception_does_not_abort(self) -> None: - """If the config-save callback raises, the pipeline still reaches DONE.""" - - async def _boom(_a: SkillCreationArtifacts) -> None: - msg = "cannot write config" - raise RuntimeError(msg) - - result = await _run_orch(creator=_make_creator_mock(), progress_cb=_boom) - assert result.state == SkillCreationState.DONE - - @pytest.mark.asyncio - async def test_cancelled_error_propagates(self) -> None: - """CancelledError must propagate, not be converted into a FAILED artifact. - - Swallowing it would break cooperative task cancellation during - HA shutdown or config-flow abort. - """ - import asyncio # noqa: PLC0415 - - creator = _make_creator_mock() - creator.create_app.side_effect = asyncio.CancelledError - with pytest.raises(asyncio.CancelledError): - await _run_orch(creator=creator) - - -class TestAutoCreateSkillDirectMode: - """Direct mode wires the MA webserver URLs into the payloads.""" - - @pytest.mark.asyncio - async def test_direct_mode_passes_ma_base_to_draft(self) -> None: - """Backend URL and auth URLs must use the MA webserver base URL.""" - creator = _make_creator_mock() - await _run_orch( - creator=creator, - connection_type=CONNECTION_TYPE_DIRECT, - base_url="https://ma.example.com", - cloud_instance_id="", - direct_client_secret="my-secret", - ) - - draft_payload = creator.update_draft.call_args.args[2] - assert ( - draft_payload["backendSettings"]["uri"] == "https://ma.example.com/api/yandex_smarthome" - ) - oauth_call = creator.create_oauth_app.call_args - assert oauth_call.kwargs["client_id"] == "https://social.yandex.net/" - assert oauth_call.kwargs["client_secret"] == "my-secret" - assert ( - oauth_call.kwargs["authorize_url"] - == "https://ma.example.com/api/yandex_smarthome/auth/authorize" - ) - - -class TestAuthenticatorInjection: - """Both async-generator and already-decorated async-CM authenticators work. - - Re-wrapping a callable that already returns an async CM with - ``@asynccontextmanager`` breaks at runtime — the orchestrator must - detect that shape and pass it through. - """ - - @pytest.mark.asyncio - async def test_accepts_already_decorated_context_manager(self) -> None: - """Authenticator whose call returns an async CM must not be re-wrapped.""" - session = MagicMock(spec=aiohttp.ClientSession) - creator = _make_creator_mock() - - @asynccontextmanager - async def _cm_auth( - *, mass: Any, session_id: str, timeout: float, **_kwargs: Any - ) -> AsyncIterator[aiohttp.ClientSession]: - _ = (mass, session_id, timeout) - yield session - - mass = _mass_with_base_url("https://ma.example.com") - result = await auto_create_skill( - mass=mass, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - skill_name="Music Assistant", - artifacts=SkillCreationArtifacts(), - cloud_instance_id="inst-1", - direct_client_secret="", - logo_bytes=b"\x89PNG", - session_id="test-session-id", - authenticator=_cm_auth, # type: ignore[arg-type] - creator_factory=lambda _s: creator, - ) - assert result.state == SkillCreationState.DONE - - -class TestAutoCreateSkillDialogType: - """auto_create_skill with skill_type='dialog' uses dialog channel and draft builder.""" - - @pytest.mark.asyncio - async def test_dialog_skill_reaches_done(self) -> None: - """Full pipeline with skill_type='dialog' reaches DONE and stores last_known_name.""" - creator = _make_creator_mock() - mass = _mass_with_base_url("https://ma.example.com") - result = await auto_create_skill( - mass=mass, - connection_type=CONNECTION_TYPE_DIRECT, - skill_name="Music Assistant", - artifacts=SkillCreationArtifacts(), - cloud_instance_id="", - direct_client_secret="secret-123", - logo_bytes=b"\x89PNG", - session_id="test-session", - skill_type="dialog", - dialog_backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/abc123", - authenticator=_fake_authenticator_factory(), - creator_factory=lambda _s: creator, - ) - assert result.state == SkillCreationState.DONE - assert result.last_known_name == "Music Assistant" - creator.create_app.assert_awaited_once() - creator.update_draft.assert_awaited_once() - - @pytest.mark.asyncio - async def test_dialog_draft_uses_dialog_backend_uri(self) -> None: - """update_draft receives the dialog_backend_uri, not the MA Smart Home URI.""" - creator = _make_creator_mock() - mass = _mass_with_base_url("https://ma.example.com") - backend_uri = "https://ma.example.com/api/yandex_dialogs/webhook/secret42" - await auto_create_skill( - mass=mass, - connection_type=CONNECTION_TYPE_DIRECT, - skill_name="My Skill", - artifacts=SkillCreationArtifacts(), - cloud_instance_id="", - direct_client_secret="sec", - logo_bytes=b"\x89PNG", - session_id="s", - skill_type="dialog", - dialog_backend_uri=backend_uri, - authenticator=_fake_authenticator_factory(), - creator_factory=lambda _s: creator, - ) - _, _, draft = creator.update_draft.call_args.args - assert draft["backendSettings"]["uri"] == backend_uri - assert draft["name"] == "My Skill" - - @pytest.mark.asyncio - async def test_dialog_missing_backend_uri_returns_failed(self) -> None: - """Missing dialog_backend_uri with skill_type='dialog' returns FAILED artifact.""" - creator = _make_creator_mock() - mass = _mass_with_base_url("https://ma.example.com") - result = await auto_create_skill( - mass=mass, - connection_type=CONNECTION_TYPE_DIRECT, - skill_name="Music Assistant", - artifacts=SkillCreationArtifacts(), - cloud_instance_id="", - direct_client_secret="sec", - logo_bytes=b"\x89PNG", - session_id="s", - skill_type="dialog", - dialog_backend_uri=None, - authenticator=_fake_authenticator_factory(), - creator_factory=lambda _s: creator, - ) - assert result.state == SkillCreationState.FAILED - assert "dialog_backend_uri" in (result.last_error or "") - - -class TestAutoRenameDialogSkill: - """auto_rename_dialog_skill patches draft name and re-deploys.""" - - @pytest.mark.asyncio - async def test_rename_happy_path(self) -> None: - """Rename updates last_known_name, calls update_draft and request_deploy once.""" - creator = _make_creator_mock() - mass = _mass_with_base_url("https://ma.example.com") - artifacts = SkillCreationArtifacts( - state=SkillCreationState.DONE, - skill_id="skill-abc", - logo_id="logo-abc", - last_known_name="Old Name", - ) - result = await auto_rename_dialog_skill( - mass=mass, - artifacts=artifacts, - new_name="New Name", - dialog_backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/secret", - session_id="s", - authenticator=_fake_authenticator_factory(), - creator_factory=lambda _s: creator, - ) - assert result.state == SkillCreationState.DONE - assert result.last_known_name == "New Name" - assert result.last_error is None - creator.update_draft.assert_awaited_once() - creator.request_deploy.assert_awaited_once() - - @pytest.mark.asyncio - async def test_rename_updates_draft_with_new_name(self) -> None: - """update_draft receives the new name in the draft payload.""" - creator = _make_creator_mock() - mass = _mass_with_base_url("https://ma.example.com") - artifacts = SkillCreationArtifacts( - state=SkillCreationState.DONE, - skill_id="skill-abc", - logo_id="logo-xyz", - ) - await auto_rename_dialog_skill( - mass=mass, - artifacts=artifacts, - new_name="Renamed Skill", - dialog_backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/s", - session_id="sess", - authenticator=_fake_authenticator_factory(), - creator_factory=lambda _s: creator, - ) - _, _, draft = creator.update_draft.call_args.args - assert draft["name"] == "Renamed Skill" - - @pytest.mark.asyncio - async def test_rename_no_skill_id_returns_failed(self) -> None: - """Missing skill_id returns FAILED without calling the API.""" - mass = _mass_with_base_url("https://ma.example.com") - artifacts = SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id=None) - result = await auto_rename_dialog_skill( - mass=mass, - artifacts=artifacts, - new_name="Whatever", - dialog_backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/s", - session_id="s", - ) - assert result.state == SkillCreationState.FAILED - assert result.last_error is not None - - @pytest.mark.asyncio - async def test_rename_api_error_returns_failed(self) -> None: - """API error on update_draft surfaces as FAILED artifact with last_error set.""" - creator = _make_creator_mock() - creator.update_draft = AsyncMock( - side_effect=DialogsApiError("Bad Request", step="update_draft", http_status=400) - ) - mass = _mass_with_base_url("https://ma.example.com") - artifacts = SkillCreationArtifacts( - state=SkillCreationState.DONE, - skill_id="skill-abc", - ) - result = await auto_rename_dialog_skill( - mass=mass, - artifacts=artifacts, - new_name="Whatever", - dialog_backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/s", - session_id="s", - authenticator=_fake_authenticator_factory(), - creator_factory=lambda _s: creator, - ) - assert result.state == SkillCreationState.FAILED - assert result.last_error is not None - - -class TestLoadDefaultLogoBytes: - """load_default_logo_bytes reads the bundled PNG from disk.""" - - def test_returns_real_png(self) -> None: - """Bundled provider/auto_skill_logo.png exists and has a PNG magic header.""" - data = load_default_logo_bytes() - # PNG magic: 89 50 4E 47 0D 0A 1A 0A - assert data[:8] == bytes.fromhex("89504e470d0a1a0a") - # Sanity: the bundled asset is non-trivial, not the 1x1 fallback. - assert len(data) > 1000, f"expected real logo asset, got {len(data)} bytes (fallback?)" diff --git a/tests/providers/yandex_smarthome/test_auto_skill_state.py b/tests/providers/yandex_smarthome/test_auto_skill_state.py deleted file mode 100644 index f87953fb88..0000000000 --- a/tests/providers/yandex_smarthome/test_auto_skill_state.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Tests for auto_skill_state: artifact serialisation and state transitions.""" - -from __future__ import annotations - -import dataclasses - -from music_assistant.providers.yandex_smarthome.auto_skill_state import ( - SkillCreationArtifacts, - SkillCreationState, - dump_artifacts, - load_artifacts, - mark_failed, -) - - -def test_default_artifacts_state_is_none() -> None: - """Freshly-constructed artifacts sit at state NONE with no fields set.""" - artifacts = SkillCreationArtifacts() - assert artifacts.state == SkillCreationState.NONE - assert artifacts.skill_id is None - assert artifacts.logo_id is None - assert artifacts.oauth_app_id is None - assert artifacts.last_error is None - - -def test_dump_load_roundtrip_none() -> None: - """NONE state with no fields round-trips cleanly.""" - original = SkillCreationArtifacts() - restored = load_artifacts(dump_artifacts(original)) - assert restored == original - - -def test_dump_load_roundtrip_populated() -> None: - """Fully-populated artifacts round-trip without loss.""" - original = SkillCreationArtifacts( - state=SkillCreationState.OAUTH_ATTACHED, - skill_id="7584419b-6815-4a68-8e32-b9fb111b596d", - logo_id="be043706-a868-4999-83c8-f17bbd60745d", - oauth_app_id="50de2b35-e593-417d-83d1-764544996fbb", - last_error=None, - ) - restored = load_artifacts(dump_artifacts(original)) - assert restored == original - - -def test_dump_load_roundtrip_failed_with_error() -> None: - """FAILED state with an error string round-trips.""" - original = SkillCreationArtifacts( - state=SkillCreationState.FAILED, - skill_id="some-skill-id", - last_error="HTTP 401: Unauthorized", - ) - restored = load_artifacts(dump_artifacts(original)) - assert restored == original - - -def test_load_empty_returns_default() -> None: - """Empty/None input yields a fresh default artifacts object.""" - assert load_artifacts(None) == SkillCreationArtifacts() - assert load_artifacts("") == SkillCreationArtifacts() - - -def test_load_invalid_json_returns_default() -> None: - """Corrupt JSON doesn't crash config — returns default.""" - assert load_artifacts("{not-json") == SkillCreationArtifacts() - assert load_artifacts("null") == SkillCreationArtifacts() - assert load_artifacts('"just-a-string"') == SkillCreationArtifacts() - - -def test_load_unknown_state_falls_back_to_none() -> None: - """Unrecognised state values don't crash — reset to NONE.""" - raw = '{"state": "totally_bogus", "skill_id": "abc"}' - result = load_artifacts(raw) - assert result.state == SkillCreationState.NONE - # Other fields still parse correctly - assert result.skill_id == "abc" - - -def test_load_ignores_extra_fields() -> None: - """Forward-compat: extra JSON keys are silently dropped.""" - raw = ( - '{"state": "done", "skill_id": "s1", "logo_id": "l1", ' - '"oauth_app_id": "o1", "future_field": "ignored"}' - ) - result = load_artifacts(raw) - assert result.state == SkillCreationState.DONE - assert result.skill_id == "s1" - - -def test_load_empty_string_fields_become_none() -> None: - """Empty-string fields normalise to None (matches default).""" - raw = '{"state": "none", "skill_id": "", "logo_id": ""}' - result = load_artifacts(raw) - assert result.skill_id is None - assert result.logo_id is None - - -def test_artifacts_frozen() -> None: - """Artifacts are immutable — must copy via dataclasses.replace.""" - artifacts = SkillCreationArtifacts() - try: - artifacts.state = SkillCreationState.DONE # type: ignore[misc] - except dataclasses.FrozenInstanceError: - pass - else: - msg = "SkillCreationArtifacts must be frozen" - raise AssertionError(msg) - - -def test_mark_failed_preserves_ids() -> None: - """mark_failed() flips state to FAILED but keeps skill/oauth IDs for retry.""" - partial = SkillCreationArtifacts( - state=SkillCreationState.OAUTH_CREATED, - skill_id="s1", - logo_id="l1", - oauth_app_id="o1", - ) - failed = mark_failed(partial, "network error") - assert failed.state == SkillCreationState.FAILED - assert failed.last_error == "network error" - assert failed.skill_id == "s1" - assert failed.logo_id == "l1" - assert failed.oauth_app_id == "o1" - - -def test_state_is_string_enum() -> None: - """State values compare equal to plain strings — useful for config storage.""" - assert SkillCreationState.DONE.value == "done" - assert SkillCreationState.FAILED.value == "failed" diff --git a/tests/providers/yandex_smarthome/test_auto_skill_ui.py b/tests/providers/yandex_smarthome/test_auto_skill_ui.py deleted file mode 100644 index 770f3c3ba3..0000000000 --- a/tests/providers/yandex_smarthome/test_auto_skill_ui.py +++ /dev/null @@ -1,406 +0,0 @@ -"""Tests for auto_skill_ui — ConfigEntry visibility and action label logic.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from music_assistant.providers.yandex_smarthome.auto_skill_state import ( - SkillCreationArtifacts, - SkillCreationState, -) -from music_assistant.providers.yandex_smarthome.auto_skill_ui import ( - auto_create_entries, - build_cloud_plus_entries, - build_direct_entries, - should_show_button, -) -from music_assistant.providers.yandex_smarthome.constants import ( - CONF_ACTION_AUTO_CREATE, - CONF_ACTION_GET_OTP, - CONF_ACTION_REGISTER, - CONF_AUTO_CREATE_ARTIFACTS, - CONF_SKILL_ID, - CONF_SKILL_TOKEN, - CONNECTION_TYPE_CLOUD, - CONNECTION_TYPE_CLOUD_PLUS, - CONNECTION_TYPE_DIRECT, -) - -if TYPE_CHECKING: - from collections.abc import Iterable, Sequence - - from music_assistant_models.config_entries import ConfigEntry - - -def _find(entries: Iterable[ConfigEntry], key: str) -> ConfigEntry | None: - for e in entries: - if e.key == key: - return e - return None - - -# --------------------------------------------------------------------------- -# should_show_button -# --------------------------------------------------------------------------- - - -class TestShouldShowButton: - """should_show_button captures the full visibility truth table.""" - - def test_hidden_in_cloud_mode(self) -> None: - """Plain cloud has no custom skill — button hidden.""" - assert not should_show_button( - connection_type=CONNECTION_TYPE_CLOUD, - state=SkillCreationState.NONE, - cloud_instance_id="abc", - base_url="https://x", - ) - - def test_hidden_when_state_done(self) -> None: - """DONE means skill already created — don't offer re-creation.""" - assert not should_show_button( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - state=SkillCreationState.DONE, - cloud_instance_id="abc", - base_url="https://x", - ) - - def test_hidden_cloud_plus_no_instance(self) -> None: - """cloud_plus requires a registered cloud instance first.""" - assert not should_show_button( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - state=SkillCreationState.NONE, - cloud_instance_id="", - base_url="https://x", - ) - - def test_hidden_direct_non_https(self) -> None: - """Direct needs an HTTPS base URL or Yandex will reject the skill.""" - assert not should_show_button( - connection_type=CONNECTION_TYPE_DIRECT, - state=SkillCreationState.NONE, - cloud_instance_id="", - base_url="http://localhost:8095", - ) - - def test_shown_cloud_plus_ready(self) -> None: - """All gates satisfied for cloud_plus → button visible.""" - assert should_show_button( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - state=SkillCreationState.NONE, - cloud_instance_id="abc", - base_url="https://x", - ) - - def test_shown_direct_ready(self) -> None: - """All gates satisfied for direct → button visible.""" - assert should_show_button( - connection_type=CONNECTION_TYPE_DIRECT, - state=SkillCreationState.NONE, - cloud_instance_id="", - base_url="https://ma.example.com", - ) - - def test_shown_after_failed(self) -> None: - """FAILED state keeps the button visible for retry.""" - assert should_show_button( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - state=SkillCreationState.FAILED, - cloud_instance_id="abc", - base_url="https://x", - ) - - -# --------------------------------------------------------------------------- -# auto_create_entries -# --------------------------------------------------------------------------- - - -class TestAutoCreateEntries: - """auto_create_entries renders a list of ConfigEntries matching state.""" - - def _entries( - self, - *, - connection_type: str = CONNECTION_TYPE_CLOUD_PLUS, - state: SkillCreationState = SkillCreationState.NONE, - cloud_instance_id: str = "abc", - base_url: str = "https://ma.example.com", - ) -> Sequence[ConfigEntry]: - return auto_create_entries( - connection_type=connection_type, - artifacts=SkillCreationArtifacts(state=state), - cloud_instance_id=cloud_instance_id, - base_url=base_url, - session_id=None, - user_code=None, - verification_url=None, - existing_artifacts_raw=None, - ) - - def test_empty_for_cloud_mode(self) -> None: - """Plain cloud returns no entries — the section is meaningless.""" - assert self._entries(connection_type=CONNECTION_TYPE_CLOUD) == () - - def test_action_shown_and_enabled_when_ready(self) -> None: - """In the ready-to-create state, action is present and not hidden.""" - entries = list(self._entries()) - action = _find(entries, CONF_ACTION_AUTO_CREATE) - assert action is not None - assert action.hidden is False - - def test_action_hidden_when_state_done(self) -> None: - """state=DONE renders the action with hidden=True.""" - entries = list(self._entries(state=SkillCreationState.DONE)) - action = _find(entries, CONF_ACTION_AUTO_CREATE) - assert action is not None - assert action.hidden is True - - def test_action_label_changes_on_failed(self) -> None: - """After a failure the button label switches to 'Retry'.""" - entries = list(self._entries(state=SkillCreationState.FAILED)) - action = _find(entries, CONF_ACTION_AUTO_CREATE) - assert action is not None - assert action.action_label == "Retry" - - def test_action_label_says_retry_on_partial(self) -> None: - """Partial (non-FAILED) progress state uses the 'Retry from last step' label.""" - entries = list(self._entries(state=SkillCreationState.OAUTH_CREATED)) - action = _find(entries, CONF_ACTION_AUTO_CREATE) - assert action is not None - assert action.action_label is not None - assert "Retry" in action.action_label - - def test_hidden_artifacts_always_round_tripped(self) -> None: - """Artifacts blob is included even when the section is mostly empty.""" - entries = list(self._entries()) - artifacts_entry = _find(entries, CONF_AUTO_CREATE_ARTIFACTS) - assert artifacts_entry is not None - assert artifacts_entry.hidden is True - - def test_status_label_reflects_failed_error(self) -> None: - """The status LABEL carries the last_error text for user visibility.""" - entries = auto_create_entries( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - artifacts=SkillCreationArtifacts( - state=SkillCreationState.FAILED, - last_error="HTTP 401: session expired", - ), - cloud_instance_id="abc", - base_url="https://x", - session_id=None, - user_code=None, - verification_url=None, - existing_artifacts_raw=None, - ) - status = _find(list(entries), "label_auto_create_status") - assert status is not None - assert "401" in status.label - - -# --------------------------------------------------------------------------- -# build_cloud_plus_entries — 3-step structure -# --------------------------------------------------------------------------- - - -class TestBuildCloudPlusEntries: - """cloud_plus entries render as numbered steps with proper gating.""" - - def _call( - self, - *, - is_registered: bool = False, - state: SkillCreationState = SkillCreationState.NONE, - otp_code: str | None = None, - skill_id: str = "", - ) -> list[ConfigEntry]: - return build_cloud_plus_entries( - otp_code=otp_code, - is_registered=is_registered, - cloud_instance_id="inst-1" if is_registered else "", - artifacts=SkillCreationArtifacts(state=state), - session_id=None, - user_code=None, - verification_url=None, - existing_artifacts_raw=None, - base_url="https://ma.example.com", - skill_id=skill_id, - ) - - def test_step1_register_always_visible(self) -> None: - """Step 1 (Register) is always rendered — even before first registration.""" - entries = self._call(is_registered=False) - keys = [e.key for e in entries] - assert CONF_ACTION_REGISTER in keys - - def test_step2_button_inactive_until_registered(self) -> None: - """Step 2 fields are emitted, but the Create-Skill button is hidden.""" - entries = self._call(is_registered=False) - action = _find(list(entries), CONF_ACTION_AUTO_CREATE) - # Entry exists (so advanced users can see other Step 2 fields), - # but the action button self-hides without a cloud instance. - assert action is not None - assert action.hidden is True - - def test_step2_visible_after_register(self) -> None: - """After register, Step 2 renders the auto-create action.""" - entries = self._call(is_registered=True) - keys = [e.key for e in entries] - assert CONF_ACTION_AUTO_CREATE in keys - - def test_step3_hidden_until_registered(self) -> None: - """Step 3 (Get OTP) requires Step 1 done (cloud registration).""" - entries = self._call(is_registered=False, skill_id="s1") - keys = [e.key for e in entries] - assert CONF_ACTION_GET_OTP not in keys - - def test_step3_hidden_until_skill_created(self) -> None: - """Step 3 also requires Step 2 done — OTP linking needs an existing skill.""" - entries = self._call(is_registered=True, skill_id="") - keys = [e.key for e in entries] - assert CONF_ACTION_GET_OTP not in keys - - def test_step3_visible_after_register_and_skill(self) -> None: - """After register AND skill created, Step 3 renders the Get-OTP action.""" - entries = self._call(is_registered=True, skill_id="s1") - keys = [e.key for e in entries] - assert CONF_ACTION_GET_OTP in keys - - def test_register_action_hidden_once_registered(self) -> None: - """The Register button disappears after a cloud instance exists.""" - entries = self._call(is_registered=True) - register = _find(list(entries), CONF_ACTION_REGISTER) - assert register is not None - assert register.hidden is True - - def test_skill_token_shown_on_done(self) -> None: - """Skill OAuth Token input is surfaced (non-advanced) on DONE.""" - entries = self._call(is_registered=True, state=SkillCreationState.DONE) - token = _find(list(entries), CONF_SKILL_TOKEN) - assert token is not None - assert getattr(token, "advanced", False) is False - - def test_skill_token_shown_on_failed(self) -> None: - """Skill OAuth Token input is also surfaced on FAILED for manual entry.""" - entries = self._call(is_registered=True, state=SkillCreationState.FAILED) - token = _find(list(entries), CONF_SKILL_TOKEN) - assert token is not None - assert getattr(token, "advanced", False) is False - - def test_skill_token_advanced_on_none(self) -> None: - """Before user interacts, the token field is hidden behind Advanced.""" - entries = self._call(is_registered=True, state=SkillCreationState.NONE) - token = _find(list(entries), CONF_SKILL_TOKEN) - assert token is not None - assert getattr(token, "advanced", False) is True - - def test_manual_fallback_appears_on_failed(self) -> None: - """FAILED renders manual copy-paste fields inline (non-advanced).""" - entries = self._call(is_registered=True, state=SkillCreationState.FAILED) - keys = [e.key for e in entries] - assert "manual_backend_url" in keys - assert "manual_client_id" in keys - assert "manual_auth_url" in keys - assert "manual_token_url" in keys - # On FAILED the block is NOT advanced — auto-visible - backend = _find(list(entries), "manual_backend_url") - assert getattr(backend, "advanced", False) is False - - def test_manual_fallback_shown_under_advanced_on_done(self) -> None: - """Happy path keeps manual fields under Advanced (power-user visibility).""" - entries = self._call(is_registered=True, state=SkillCreationState.DONE) - keys = [e.key for e in entries] - # Still emitted — but advanced so the default view stays clean - assert "manual_backend_url" in keys - backend = _find(list(entries), "manual_backend_url") - assert getattr(backend, "advanced", False) is True - - def test_manual_fallback_suppressed_before_cloud_register(self) -> None: - """Don't render manual fields with an invalid `yandex_smart_home:` Client ID. - - cloud_plus Client ID embeds the yaha-cloud instance UUID; before - the user clicks Register there's no UUID, and emitting a - half-formed ``yandex_smart_home:`` value would lead power users - (Advanced view) to create a skill with broken account-linking. - """ - entries = self._call(is_registered=False, state=SkillCreationState.NONE) - keys = [e.key for e in entries] - assert "manual_backend_url" not in keys - assert "manual_client_id" not in keys - assert "manual_fallback_label" not in keys - - def test_otp_code_appears_when_present(self) -> None: - """OTP code field is visible once an OTP has been fetched.""" - entries = self._call(is_registered=True, skill_id="s1", otp_code="ABC123") - otp = _find(list(entries), "otp_code") - assert otp is not None - assert otp.hidden is False - - -# --------------------------------------------------------------------------- -# build_direct_entries — 1-step structure (no register, no OTP) -# --------------------------------------------------------------------------- - - -class TestBuildDirectEntries: - """direct mode renders a single Create-Skill step (no yaha, no OTP).""" - - def _call( - self, - *, - state: SkillCreationState = SkillCreationState.NONE, - base_url: str = "https://ma.example.com", - direct_client_secret: str = "secret-123", # noqa: S107 - ) -> list[ConfigEntry]: - return build_direct_entries( - artifacts=SkillCreationArtifacts(state=state), - session_id=None, - user_code=None, - verification_url=None, - existing_artifacts_raw=None, - base_url=base_url, - direct_client_secret=direct_client_secret, - ) - - def test_no_register_action(self) -> None: - """Direct mode has no yaha registration step.""" - entries = self._call() - keys = [e.key for e in entries] - assert CONF_ACTION_REGISTER not in keys - - def test_no_get_otp_action(self) -> None: - """Direct mode has no OTP linking step.""" - entries = self._call() - keys = [e.key for e in entries] - assert CONF_ACTION_GET_OTP not in keys - - def test_has_create_skill_action(self) -> None: - """Direct mode renders the Create-Skill action.""" - entries = self._call() - keys = [e.key for e in entries] - assert CONF_ACTION_AUTO_CREATE in keys - - def test_create_hidden_when_base_url_not_https(self) -> None: - """Non-HTTPS base URL disables the create button (Yandex rejects).""" - entries = self._call(base_url="http://ma.local:8095") - action = _find(list(entries), CONF_ACTION_AUTO_CREATE) - assert action is not None - assert action.hidden is True - - def test_manual_fallback_includes_per_install_secret(self) -> None: - """FAILED fallback for direct shows the per-install client_secret.""" - entries = self._call(state=SkillCreationState.FAILED) - # The fallback block surfaces the generated secret in a visible - # field, plus the Backend URL with the MA base URL. - backend = _find(list(entries), "manual_backend_url") - assert backend is not None - assert "ma.example.com" in str(backend.default_value) - # No /v1.0 — Yandex appends the version segment itself. - assert str(backend.default_value).endswith("/api/yandex_smarthome") - - def test_skill_id_field_shown_on_done(self) -> None: - """Skill ID input field is surfaced (non-advanced) on DONE.""" - entries = self._call(state=SkillCreationState.DONE) - skill_id = _find(list(entries), CONF_SKILL_ID) - assert skill_id is not None - assert getattr(skill_id, "advanced", False) is False diff --git a/tests/providers/yandex_smarthome/test_cloud.py b/tests/providers/yandex_smarthome/test_cloud.py index 03507b53c2..081b45837b 100644 --- a/tests/providers/yandex_smarthome/test_cloud.py +++ b/tests/providers/yandex_smarthome/test_cloud.py @@ -6,8 +6,8 @@ import aiohttp import pytest +from ya_dialogs_api import SecretStr -from music_assistant.providers.yandex_smarthome._compat import SecretStr from music_assistant.providers.yandex_smarthome.cloud import ( CloudManager, get_cloud_otp, diff --git a/tests/providers/yandex_smarthome/test_config_actions.py b/tests/providers/yandex_smarthome/test_config_actions.py deleted file mode 100644 index b338176598..0000000000 --- a/tests/providers/yandex_smarthome/test_config_actions.py +++ /dev/null @@ -1,407 +0,0 @@ -"""Tests for the auto-create action wired into get_config_entries. - -Exercises _run_auto_create_action via the public _handle_config_actions -entry point so the integration between config values, artifacts, and -the orchestrator is covered end-to-end (with the orchestrator itself -mocked). -""" - -from __future__ import annotations - -import json -from typing import Any -from unittest.mock import MagicMock - -import aiohttp -import pytest - -from music_assistant.providers import yandex_smarthome as provider_module -from music_assistant.providers.yandex_smarthome import _handle_config_actions -from music_assistant.providers.yandex_smarthome.auto_skill_state import ( - SkillCreationArtifacts, - SkillCreationState, -) -from music_assistant.providers.yandex_smarthome.constants import ( - CONF_ACTION_AUTO_CREATE, - CONF_AUTO_CREATE_ARTIFACTS, - CONF_CLOUD_INSTANCE_ID, - CONF_DIRECT_CLIENT_SECRET, - CONF_INSTANCE_NAME, - CONF_SKILL_ID, - CONNECTION_TYPE_CLOUD_PLUS, - CONNECTION_TYPE_DIRECT, -) - - -def _make_mass() -> MagicMock: - mass = MagicMock() - mass.get_provider.return_value = None - mass.signal_event = MagicMock() - return mass - - -@pytest.mark.asyncio -async def test_auto_create_done_populates_skill_id(monkeypatch) -> None: # type: ignore[no-untyped-def] - """On DONE, CONF_SKILL_ID is written and artifacts are persisted.""" - - async def _fake_auto_create(**_kwargs: Any) -> SkillCreationArtifacts: - return SkillCreationArtifacts( - state=SkillCreationState.DONE, - skill_id="new-skill-uuid", - logo_id="l1", - oauth_app_id="o1", - ) - - monkeypatch.setattr( - provider_module, - "auto_create_skill", - _fake_auto_create, - ) - - mass = _make_mass() - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "My Instance", - CONF_CLOUD_INSTANCE_ID: "inst-1", - } - - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id=None, - is_cloud_plus=True, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - ) - - assert values[CONF_SKILL_ID] == "new-skill-uuid" - stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) - assert stored["state"] == "done" - assert stored["skill_id"] == "new-skill-uuid" - - -@pytest.mark.asyncio -async def test_auto_create_failed_preserves_artifacts(monkeypatch) -> None: # type: ignore[no-untyped-def] - """On FAILED, CONF_SKILL_ID is NOT set so runtime doesn't use a half-skill.""" - - async def _fake_auto_create(**_kwargs: Any) -> SkillCreationArtifacts: - return SkillCreationArtifacts( - state=SkillCreationState.FAILED, - skill_id="partial-skill-id", - last_error="upload failed", - ) - - monkeypatch.setattr( - provider_module, - "auto_create_skill", - _fake_auto_create, - ) - - mass = _make_mass() - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "X", - CONF_CLOUD_INSTANCE_ID: "inst-1", - } - - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id=None, - is_cloud_plus=True, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - ) - - # CONF_SKILL_ID must remain unset — skill is incomplete - assert CONF_SKILL_ID not in values - stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) - assert stored["state"] == "failed" - assert stored["last_error"] == "upload failed" - assert stored["skill_id"] == "partial-skill-id" - - -@pytest.mark.asyncio -async def test_auto_create_precondition_valueerror_becomes_failed( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """ValueError from preconditions is caught and stored as a FAILED artifact.""" - - async def _raises_value_error(**_kwargs: Any) -> SkillCreationArtifacts: - msg = "direct mode requires HTTPS" - raise ValueError(msg) - - monkeypatch.setattr( - provider_module, - "auto_create_skill", - _raises_value_error, - ) - - mass = _make_mass() - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "X", - CONF_DIRECT_CLIENT_SECRET: "secret", - } - - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id=None, - is_cloud_plus=False, - connection_type=CONNECTION_TYPE_DIRECT, - ) - - stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) - assert stored["state"] == "failed" - assert "HTTPS" in stored["last_error"] - assert CONF_SKILL_ID not in values - - -@pytest.mark.asyncio -async def test_auto_create_unexpected_error_caught(monkeypatch) -> None: # type: ignore[no-untyped-def] - """Any other exception from orchestrator also surfaces as FAILED, not a crash.""" - - async def _raises_runtime(**_kwargs: Any) -> SkillCreationArtifacts: - msg = "network unreachable" - raise RuntimeError(msg) - - monkeypatch.setattr( - provider_module, - "auto_create_skill", - _raises_runtime, - ) - - mass = _make_mass() - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "X", - CONF_CLOUD_INSTANCE_ID: "inst-1", - } - - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id=None, - is_cloud_plus=True, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - ) - - stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) - assert stored["state"] == "failed" - assert "network" in stored["last_error"] - - -@pytest.mark.asyncio -async def test_auto_create_non_action_is_noop(monkeypatch) -> None: # type: ignore[no-untyped-def] - """Any action other than AUTO_CREATE must not touch auto-create values.""" - # Spy: if auto_create_skill is somehow called, this fails the test. - called = [] - - async def _fake(**_kwargs: Any) -> SkillCreationArtifacts: - called.append(True) - return SkillCreationArtifacts() - - monkeypatch.setattr( - provider_module, - "auto_create_skill", - _fake, - ) - - mass = _make_mass() - values: dict[str, Any] = {} - - # A different action — should not trigger auto-create - await _handle_config_actions( - mass, - "some_other_action", - values, - instance_id=None, - is_cloud_plus=True, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - ) - - assert not called - assert CONF_AUTO_CREATE_ARTIFACTS not in values - - -@pytest.mark.asyncio -async def test_session_id_forwarded_from_frontend(monkeypatch) -> None: # type: ignore[no-untyped-def] - """Frontend-supplied ``values['session_id']`` is forwarded to the orchestrator. - - The session_id is what AuthenticationHelper listens on — if the - wiring drops it and generates a local uuid instead, MA's popup - never opens (silent failure the user won't see). - """ - captured: dict[str, Any] = {} - - async def _capture(**kwargs: Any) -> SkillCreationArtifacts: - captured["session_id"] = kwargs.get("session_id") - return SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="s") - - monkeypatch.setattr(provider_module, "auto_create_skill", _capture) - - mass = _make_mass() - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "X", - CONF_CLOUD_INSTANCE_ID: "inst-1", - "session_id": "frontend-supplied-id-123", - } - - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id=None, - is_cloud_plus=True, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - ) - - assert captured["session_id"] == "frontend-supplied-id-123" - - -@pytest.mark.asyncio -async def test_auto_create_cancelled_error_propagates(monkeypatch) -> None: # type: ignore[no-untyped-def] - """CancelledError must not be absorbed into a FAILED artifact. - - Config-flow shutdown and HA stop rely on cooperative cancellation; - converting it into state=FAILED would both leak the error into the - UI and break clean task teardown. - """ - import asyncio # noqa: PLC0415 - - async def _raises_cancelled(**_kwargs: Any) -> SkillCreationArtifacts: - raise asyncio.CancelledError - - monkeypatch.setattr(provider_module, "auto_create_skill", _raises_cancelled) - - mass = _make_mass() - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "X", - CONF_CLOUD_INSTANCE_ID: "inst-1", - } - - with pytest.raises(asyncio.CancelledError): - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id=None, - is_cloud_plus=True, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - ) - - # The FAILED artifact must NOT have been written on cancellation. - assert CONF_AUTO_CREATE_ARTIFACTS not in values - - -@pytest.mark.asyncio -async def test_auto_create_prefers_saved_direct_secret(monkeypatch) -> None: # type: ignore[no-untyped-def] - """SECURE_STRING client secret: saved_config beats empty ``values`` on re-open. - - On re-open MA does not echo SECURE_STRING values back into ``values``, - so reading from ``values`` alone would pass an empty secret into the - orchestrator. The helper must pull it from the persisted provider - config instead. - """ - captured: dict[str, Any] = {} - - async def _capture(**kwargs: Any) -> SkillCreationArtifacts: - captured["direct_client_secret"] = kwargs.get("direct_client_secret") - return SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="s") - - monkeypatch.setattr(provider_module, "auto_create_skill", _capture) - - mass = _make_mass() - saved_cfg = MagicMock() - saved_cfg.get_value.side_effect = lambda key: ( - "persisted-secret" if key == CONF_DIRECT_CLIENT_SECRET else None - ) - prov = MagicMock() - prov.config = saved_cfg - mass.get_provider.return_value = prov - - # Frontend re-open: SECURE_STRING not echoed back -> values has no secret. - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "X", - } - - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id="inst-42", - is_cloud_plus=False, - connection_type=CONNECTION_TYPE_DIRECT, - ) - - assert captured["direct_client_secret"] == "persisted-secret" - - -@pytest.mark.asyncio -async def test_auto_create_falls_back_to_values_for_first_setup(monkeypatch) -> None: # type: ignore[no-untyped-def] - """First-time setup: no instance yet, secret is read from ``values``.""" - captured: dict[str, Any] = {} - - async def _capture(**kwargs: Any) -> SkillCreationArtifacts: - captured["direct_client_secret"] = kwargs.get("direct_client_secret") - return SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="s") - - monkeypatch.setattr(provider_module, "auto_create_skill", _capture) - - mass = _make_mass() # get_provider returns None - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "X", - CONF_DIRECT_CLIENT_SECRET: "fresh-secret", - } - - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id=None, - is_cloud_plus=False, - connection_type=CONNECTION_TYPE_DIRECT, - ) - - assert captured["direct_client_secret"] == "fresh-secret" - - -@pytest.mark.asyncio -async def test_existing_action_register_still_works(monkeypatch) -> None: # type: ignore[no-untyped-def] - """Gap-fill: pre-existing CONF_ACTION_REGISTER path is still covered.""" - - async def _fake_register( - _session: Any, - platform: str | None = None, # noqa: ARG001 - ) -> dict[str, str]: - return {"id": "inst-new", "password": "p", "connection_token": "t"} - - async def _fake_otp(_session: Any, _id: str, _tok: Any) -> str: - return "111111" - - class _NoopSession: - async def __aenter__(self): # type: ignore[no-untyped-def] - return self - - async def __aexit__(self, *_a): # type: ignore[no-untyped-def] - return False - - monkeypatch.setattr(provider_module, "register_cloud_instance", _fake_register) - monkeypatch.setattr(provider_module, "get_cloud_otp", _fake_otp) - monkeypatch.setattr(aiohttp, "ClientSession", _NoopSession) - - mass = _make_mass() - values: dict[str, Any] = {} - - otp = await _handle_config_actions( - mass, - "register_cloud", - values, - instance_id=None, - is_cloud_plus=True, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - ) - - assert values[CONF_CLOUD_INSTANCE_ID] == "inst-new" - # Register no longer auto-fetches OTP — that's a separate Step 3 action. - # The handler returns None because no OTP was requested. - assert otp is None diff --git a/tests/providers/yandex_smarthome/test_dialogs.py b/tests/providers/yandex_smarthome/test_dialogs.py deleted file mode 100644 index a771c56919..0000000000 --- a/tests/providers/yandex_smarthome/test_dialogs.py +++ /dev/null @@ -1,1652 +0,0 @@ -# ruff: noqa: RUF001, RUF002 -"""Tests for provider/dialogs.py — webhook handler.""" - -from __future__ import annotations - -import asyncio -import json -import time -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any -from unittest.mock import AsyncMock, MagicMock - -import pytest -from aiohttp.test_utils import make_mocked_request -from music_assistant_models.enums import QueueOption, RepeatMode - -from music_assistant.providers.yandex_smarthome.dialogs import ( - _STATE_CACHE_TTL_SEC, - DialogsWebhookHandler, - _tts_for, -) - -if TYPE_CHECKING: - from aiohttp import web - - -@dataclass -class MockPlayer: - """Minimal player stub for webhook handler tests.""" - - player_id: str = "p1" - name: str = "Кухня" - available: bool = True - enabled: bool = True - synced_to: str | None = None - supported_features: set[str] = field(default_factory=set) - powered: bool = True - - -class _MockPlayers: - def __init__(self, players: list[MockPlayer]) -> None: - """Initialise with a fixed player list.""" - self._players = players - self.cmd_power = AsyncMock() - - def all_players(self) -> list[MockPlayer]: - """Return all players.""" - return list(self._players) - - def get_player(self, player_id: str) -> MockPlayer | None: - """Return player by id or None.""" - return next((p for p in self._players if p.player_id == player_id), None) - - -def _make_mass(players: list[MockPlayer], search_track: object = None) -> MagicMock: - mass = MagicMock() - mass.players = _MockPlayers(players) - mass.music = MagicMock() - - @dataclass - class _SearchResults: - artists: list[object] = field(default_factory=list) - albums: list[object] = field(default_factory=list) - tracks: list[object] = field(default_factory=list) - playlists: list[object] = field(default_factory=list) - - if search_track is not None: - mass.music.search = AsyncMock(return_value=_SearchResults(tracks=[search_track])) - else: - mass.music.search = AsyncMock(return_value=_SearchResults()) - - mass.music_providers = [] - mass.providers = [] - mass.player_queues = MagicMock() - mass.player_queues.play_media = AsyncMock() - mass.webserver = MagicMock() - mass.webserver.register_dynamic_route = MagicMock(return_value=lambda: None) - # mass.create_task must actually schedule the coroutine so fire-and-forget - # tasks run when the test awaits asyncio.sleep(0). - mass.create_task = lambda coro, **_kw: asyncio.ensure_future(coro) - return mass - - -_TEST_SECRET = "topsecret" - - -def _build_request(body: dict[str, Any], secret: str = _TEST_SECRET) -> web.Request: - """Build a mocked aiohttp Request that returns the given JSON body.""" - req = make_mocked_request( - "POST", - f"/api/yandex_dialogs/webhook/{secret}", - match_info={"secret": secret}, - ) - req.json = AsyncMock(return_value=body) # type: ignore[method-assign] - return req - - -def _response_body(resp: web.Response) -> dict[str, Any]: - """Decode a web.json_response body into a dict for assertions.""" - decoded: dict[str, Any] = json.loads(resp.body) # type: ignore[arg-type] - return decoded - - -@pytest.mark.asyncio -class TestDialogsWebhookHandler: - """End-to-end tests for the webhook entry point.""" - - def _make_handler(self, mass: MagicMock, **kwargs: object) -> DialogsWebhookHandler: - """Build a handler with sensible test defaults.""" - return DialogsWebhookHandler( - mass, - skill_id=str(kwargs.get("skill_id", "skill-uuid-1")), - webhook_secret=str(kwargs.get("webhook_secret", "topsecret")), - exposed_player_ids=kwargs.get("exposed_player_ids"), # type: ignore[arg-type] - ) - - async def test_register_routes_calls_mass_webserver(self) -> None: - """register_routes calls register_dynamic_route with the correct URL.""" - mass = _make_mass([]) - handler = self._make_handler(mass) - handler.register_routes() - mass.webserver.register_dynamic_route.assert_called_once() - path_arg = mass.webserver.register_dynamic_route.call_args[0][0] - assert path_arg == "/api/yandex_dialogs/webhook/topsecret" - - async def test_unregister_routes(self) -> None: - """unregister_routes calls the unregister callback returned by register_dynamic_route.""" - mass = _make_mass([]) - unregister = MagicMock() - mass.webserver.register_dynamic_route = MagicMock(return_value=unregister) - handler = self._make_handler(mass) - handler.register_routes() - handler.unregister_routes() - unregister.assert_called_once() - - async def test_secret_mismatch_returns_404(self) -> None: - """Webhook request with wrong URL secret is rejected with 404.""" - mass = _make_mass([]) - handler = self._make_handler(mass) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1"}, - "request": {"command": "включи Metallica"}, - } - req = make_mocked_request( - "POST", - "/api/yandex_dialogs/webhook/wrong", - match_info={"secret": "wrong"}, - ) - req.json = AsyncMock(return_value=body) # type: ignore[method-assign] - resp = await handler._handle_webhook(req) - assert resp.status == 404 - - async def test_secret_parsed_from_path_when_no_match_info(self) -> None: - """Cover the production secret-from-path fallback in `_handle_webhook`. - - Production registers an exact route (no `{secret}` variable), so - `request.match_info` is empty and the handler parses the secret - from `request.path`. This test passes `match_info={}` to exercise - that branch. - """ - track = MagicMock(uri="library://track/123", spec_set=["uri"]) - mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) - handler = self._make_handler(mass) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "включи Metallica на кухне"}, - } - req = make_mocked_request( - "POST", - f"/api/yandex_dialogs/webhook/{_TEST_SECRET}", - match_info={}, - ) - req.json = AsyncMock(return_value=body) # type: ignore[method-assign] - resp = await handler._handle_webhook(req) - # If path parsing works, secret matches and we reach the play branch (200). - assert resp.status == 200 - - async def test_skill_id_mismatch_returns_401(self) -> None: - """Payload with wrong skill_id is rejected with 401.""" - mass = _make_mass([]) - handler = self._make_handler(mass) - body = { - "session": {"skill_id": "different-skill", "session_id": "s1"}, - "request": {"command": "включи Metallica"}, - } - resp = await handler._handle_webhook(_build_request(body)) - assert resp.status == 401 - - async def test_session_new_empty_command_greets(self) -> None: - """New session with empty command returns 200 greeting without playing.""" - mass = _make_mass([]) - handler = self._make_handler(mass) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": True}, - "request": {"command": ""}, - } - resp = await handler._handle_webhook(_build_request(body)) - assert resp.status == 200 - mass.player_queues.play_media.assert_not_awaited() - - async def test_unknown_player_asks_for_clarification(self) -> None: - """Command mentioning an unknown player returns 200 without playing.""" - mass = _make_mass([MockPlayer(player_id="p1", name="Спальня")]) - handler = self._make_handler(mass) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "включи Metallica на Кухне"}, - } - resp = await handler._handle_webhook(_build_request(body)) - assert resp.status == 200 - mass.player_queues.play_media.assert_not_awaited() - - async def test_no_results_says_not_found(self) -> None: - """No search results returns 200 without playing.""" - mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) - handler = self._make_handler(mass) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "включи nonexistent на кухне"}, - } - resp = await handler._handle_webhook(_build_request(body)) - assert resp.status == 200 - mass.player_queues.play_media.assert_not_awaited() - - async def test_full_happy_path_starts_play_media(self) -> None: - """Resolved track triggers play_media on the correct player.""" - track = MagicMock(uri="library://track/123", spec_set=["uri"]) - mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) - handler = self._make_handler(mass) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "включи Metallica на кухне"}, - } - resp = await handler._handle_webhook(_build_request(body)) - assert resp.status == 200 - # Allow the fire-and-forget task to run. - await asyncio.sleep(0) - mass.player_queues.play_media.assert_awaited_once() - call_kwargs = mass.player_queues.play_media.call_args.kwargs - assert call_kwargs["queue_id"] == "p1" - assert call_kwargs["media"] is track - - -# --------------------------------------------------------------------------- -# Yandex state envelope (P0.1) + tts split (P0.2) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -class TestStatePersistence: - """Tests that the handler reads/writes Yandex state envelope correctly.""" - - def _make_handler(self, mass: MagicMock) -> DialogsWebhookHandler: - return DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - - async def test_resolved_player_persisted_in_session_and_application_state(self) -> None: - """Successful play writes last_player_id to session_state and application_state.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) - handler = self._make_handler(mass) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "включи Metallica на кухне"}, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - body_out = _response_body(resp) - assert body_out["session_state"]["last_player_id"] == "p1" - assert body_out["application_state"]["last_player_id"] == "p1" - # No user identity in the request → no user_state_update. - assert "user_state_update" not in body_out - - async def test_user_state_written_when_user_id_present(self) -> None: - """When session.user.user_id is set, response merges preferred_player_id.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) - handler = self._make_handler(mass) - body = { - "session": { - "skill_id": "skill-uuid-1", - "session_id": "s1", - "new": False, - "user": {"user_id": "yandex-user-1"}, - }, - "request": {"command": "включи Metallica на кухне"}, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - body_out = _response_body(resp) - assert body_out["user_state_update"] == {"preferred_player_id": "p1"} - - async def test_default_player_priority_session_over_application(self) -> None: - """When command has no player hint, session.last_player_id wins over application's.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")], - search_track=track, - ) - handler = self._make_handler(mass) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "включи Beatles"}, - "state": { - "session": {"last_player_id": "p1"}, - "application": {"last_player_id": "p2"}, - }, - } - await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" - - async def test_default_player_falls_through_to_application(self) -> None: - """No session.last_player_id — application_state wins.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")], - search_track=track, - ) - handler = self._make_handler(mass) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "включи Beatles"}, - "state": {"application": {"last_player_id": "p2"}}, - } - await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" - - async def test_default_player_falls_through_to_user(self) -> None: - """Both session and application empty — user.preferred_player_id wins.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")], - search_track=track, - ) - handler = self._make_handler(mass) - body = { - "session": { - "skill_id": "skill-uuid-1", - "session_id": "s1", - "new": False, - "user": {"user_id": "yandex-user-1"}, - }, - "request": {"command": "включи Beatles"}, - "state": {"user": {"preferred_player_id": "p2"}}, - } - await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" - - async def test_user_id_echo_falls_back_to_nested(self) -> None: - """When root session.user_id is missing, echo the nested session.user.user_id.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) - handler = self._make_handler(mass) - body = { - "session": { - "skill_id": "skill-uuid-1", - "session_id": "s1", - "new": False, - # No root "user_id"; only the nested one. - "user": {"user_id": "yandex-user-42"}, - }, - "request": {"command": "включи Metallica на кухне"}, - } - resp = await handler._handle_webhook(_build_request(body)) - body_out = _response_body(resp) - assert body_out["session"]["user_id"] == "yandex-user-42" - - async def test_session_state_preserved_on_player_not_found(self) -> None: - """Even on error, existing session_state is echoed back so other keys aren't lost.""" - mass = _make_mass([MockPlayer(player_id="p1", name="Спальня")]) - handler = self._make_handler(mass) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "включи Metallica на Кухне"}, - "state": {"session": {"foo": "bar"}}, - } - resp = await handler._handle_webhook(_build_request(body)) - body_out = _response_body(resp) - assert body_out["session_state"] == {"foo": "bar"} - - -class TestTtsHelper: - """Tests for _tts_for stress-mark substitution.""" - - def test_known_word_gets_stress_mark(self) -> None: - """A known word from the dict has `+` injected before the stressed vowel.""" - assert _tts_for("Включаю Metallica") == "Включ+аю Metallica" - - def test_unknown_word_passes_through(self) -> None: - """A word not in the dict is unchanged.""" - assert _tts_for("Привет мир") == "Привет мир" - - def test_empty_input(self) -> None: - """Empty input is returned as-is.""" - assert _tts_for("") == "" - - def test_capitalisation_preserved(self) -> None: - """Original capitalisation of the first letter is preserved.""" - # All-lowercase original. - assert _tts_for("включаю джаз") == "включ+аю джаз" - # Capitalised original. - assert _tts_for("Включаю джаз") == "Включ+аю джаз" - - -@pytest.mark.asyncio -class TestTtsResponseField: - """Test that the handler emits separate text + tts in the response envelope.""" - - async def test_response_tts_differs_from_text_when_known_word_used(self) -> None: - """Happy path response has different `tts` from `text` when stress-mark fires.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "включи Metallica на кухне"}, - } - resp = await handler._handle_webhook(_build_request(body)) - body_out = _response_body(resp) - text = body_out["response"]["text"] - tts = body_out["response"]["tts"] - assert text != tts - assert "включ+аю" in tts.lower() - - -# --------------------------------------------------------------------------- -# Control commands integration (P0.6) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -class TestControlCommandsIntegration: - """Integration tests for the control branch in _handle_webhook.""" - - def _setup_mass_with_control_methods(self, players: list[MockPlayer]) -> MagicMock: - mass = _make_mass(players) - mass.player_queues.pause = AsyncMock() - mass.player_queues.resume = AsyncMock() - mass.player_queues.stop = AsyncMock() - mass.player_queues.next = AsyncMock() - mass.player_queues.previous = AsyncMock() - mass.player_queues.set_shuffle = AsyncMock() - mass.player_queues.set_repeat = MagicMock() # NB: sync - mass.player_queues.skip = AsyncMock() - mass.player_queues.seek = AsyncMock() - mass.player_queues.transfer_queue = AsyncMock() - mass.player_queues.get = MagicMock(return_value=None) - mass.players.cmd_volume_up = AsyncMock() - mass.players.cmd_volume_down = AsyncMock() - mass.players.cmd_volume_set = AsyncMock() - mass.players.cmd_volume_mute = AsyncMock() - return mass - - async def test_pause_command_calls_player_queues_pause(self) -> None: - """'пауза на кухне' → mass.player_queues.pause(p1) and confirms in response.""" - mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "пауза на кухне"}, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert resp.status == 200 - mass.player_queues.pause.assert_awaited_once_with("p1") - body_out = _response_body(resp) - assert body_out["response"]["text"] == "Пауза." - # State persisted as in play branch. - assert body_out["session_state"]["last_player_id"] == "p1" - assert body_out["application_state"]["last_player_id"] == "p1" - # play_media should NOT be called for control commands. - mass.player_queues.play_media.assert_not_awaited() - - async def test_volume_set_command(self) -> None: - """'громкость 50 на кухне' → cmd_volume_set(p1, 50).""" - mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "громкость 50 на кухне"}, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert resp.status == 200 - mass.players.cmd_volume_set.assert_awaited_once_with("p1", 50) - - async def test_control_uses_default_player_from_state(self) -> None: - """A control phrase without explicit hint uses state.session.last_player_id.""" - mass = self._setup_mass_with_control_methods( - [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")] - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "пауза"}, - "state": {"session": {"last_player_id": "p2"}}, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert resp.status == 200 - mass.player_queues.pause.assert_awaited_once_with("p2") - - async def test_control_unknown_player_asks_for_clarification(self) -> None: - """Control command with an unknown player hint returns a clarification.""" - mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Спальня")]) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "пауза на гостиной"}, - } - resp = await handler._handle_webhook(_build_request(body)) - assert resp.status == 200 - mass.player_queues.pause.assert_not_awaited() - body_out = _response_body(resp) - assert "Не нашёл колонку «гостиной»" in body_out["response"]["text"] - - async def test_forget_player_clears_state_tiers(self) -> None: - """'забудь колонку' clears last_player_id from session/application/cache. - - After the user picks a player via disambiguation, every later play - command without an explicit hint plays on it (by design — sticky - default for ergonomics). Saying 'забудь колонку' resets that so - the next ambiguous command asks again. - """ - mass = self._setup_mass_with_control_methods( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ] - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - # Pre-seed cache with a stale default-player. - handler._state_cache["user:u1"] = ( - {"last_player_id": "p1"}, - time.monotonic(), - ) - body = { - "session": { - "skill_id": "skill-uuid-1", - "session_id": "s1", - "new": False, - "user": {"user_id": "u1"}, - }, - "request": {"command": "забудь колонку"}, - "state": { - "session": {"last_player_id": "p1"}, - "application": {"last_player_id": "p1"}, - }, - } - resp = await handler._handle_webhook(_build_request(body)) - body_out = _response_body(resp) - assert "Хорошо" in body_out["response"]["text"] - # last_player_id removed from session and application state. - assert "last_player_id" not in body_out["session_state"] - assert "last_player_id" not in body_out["application_state"] - # user_state_update sets preferred_player_id to None (Yandex - # protocol: None = delete the key from merged user state). - assert body_out["user_state_update"] == {"preferred_player_id": None} - # Cache rewritten with no last_player_id. - cached = handler._cache_get({"user": {"user_id": "u1"}}) - assert "last_player_id" not in cached - - async def test_list_players_returns_player_names(self) -> None: - """'сколько колонок видишь' → response with the count and names of exposed players.""" - mass = self._setup_mass_with_control_methods( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - MockPlayer(player_id="p3", name="Гостиная"), - ] - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "сколько колонок видишь"}, - } - resp = await handler._handle_webhook(_build_request(body)) - assert resp.status == 200 - body_out = _response_body(resp) - text = body_out["response"]["text"] - assert "Вижу 3 колонки" in text - assert "Кухня" in text - assert "Спальня" in text - assert "Гостиная" in text - # Informational query — keep the mic open for follow-ups. - assert body_out["response"]["end_session"] is False - # No playback or control was dispatched. - mass.player_queues.pause.assert_not_awaited() - mass.player_queues.play_media.assert_not_awaited() - - async def test_list_players_skips_unavailable(self) -> None: - """Only available + enabled + non-synced players are counted.""" - mass = self._setup_mass_with_control_methods( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Disabled", enabled=False), - MockPlayer(player_id="p3", name="Unavailable", available=False), - MockPlayer(player_id="p4", name="Synced", synced_to="leader"), - ] - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "какие колонки"}, - } - resp = await handler._handle_webhook(_build_request(body)) - body_out = _response_body(resp) - text = body_out["response"]["text"] - assert "Вижу одну колонку: Кухня" in text - assert "Disabled" not in text - assert "Unavailable" not in text - assert "Synced" not in text - - async def test_control_no_hint_no_default_asks_for_player(self) -> None: - """Control with no hint + no default + multi-player → ask for the player. - - Previously responded with the misleading "Не нашёл колонку «(не указано)»"; - now the message tells the user to specify the player. - """ - mass = self._setup_mass_with_control_methods( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ] - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "пауза"}, - } - resp = await handler._handle_webhook(_build_request(body)) - assert resp.status == 200 - mass.player_queues.pause.assert_not_awaited() - body_out = _response_body(resp) - text = body_out["response"]["text"] - assert "(не указано)" not in text - assert "на какой колонке" in text.lower() - - # ------------------------------------------------------------------- - # v1.9.0 — six new commands - # ------------------------------------------------------------------- - - async def test_now_playing_returns_track(self) -> None: - """'что играет на кухне' → reads queue.current_item.name.""" - mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) - # Mock a queue with a current item. - queue = MagicMock() - queue.current_item = MagicMock(name="The Beatles - Let It Be") - queue.current_item.name = "The Beatles - Let It Be" - mass.player_queues.get = MagicMock(return_value=queue) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "что играет на кухне"}, - } - resp = await handler._handle_webhook(_build_request(body)) - body_out = _response_body(resp) - assert "The Beatles - Let It Be" in body_out["response"]["text"] - # No MA mutation. - mass.player_queues.pause.assert_not_awaited() - mass.player_queues.play_media.assert_not_awaited() - - async def test_now_playing_idle_queue(self) -> None: - """'что играет' on an idle queue → 'ничего не играет'.""" - mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) - queue = MagicMock() - queue.current_item = None - mass.player_queues.get = MagicMock(return_value=queue) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "что играет на кухне"}, - } - resp = await handler._handle_webhook(_build_request(body)) - body_out = _response_body(resp) - assert "ничего не играет" in body_out["response"]["text"] - - async def test_shuffle_on(self) -> None: - """'перемешай на кухне' → set_shuffle(p1, True).""" - mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "перемешай на кухне"}, - } - await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - mass.player_queues.set_shuffle.assert_awaited_once_with("p1", shuffle_enabled=True) - - async def test_shuffle_off(self) -> None: - """'выключи перемешивание на кухне' → set_shuffle(p1, False).""" - mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "выключи перемешивание на кухне"}, - } - await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - mass.player_queues.set_shuffle.assert_awaited_once_with("p1", shuffle_enabled=False) - - async def test_repeat_one(self) -> None: - """'повтор песни на кухне' → set_repeat(p1, RepeatMode.ONE) — sync, not awaited.""" - mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "повтор песни на кухне"}, - } - await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - mass.player_queues.set_repeat.assert_called_once_with("p1", RepeatMode.ONE) - - async def test_seek_forward_minute(self) -> None: - """'перемотай вперёд на 1 минуту на кухне' → skip(p1, 60).""" - mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "перемотай вперёд на 1 минуту на кухне"}, - } - await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - mass.player_queues.skip.assert_awaited_once_with("p1", seconds=60) - - async def test_seek_back_seconds(self) -> None: - """'назад на 30 секунд на кухне' → skip(p1, -30).""" - mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "перемотай назад на 30 секунд на кухне"}, - } - await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - mass.player_queues.skip.assert_awaited_once_with("p1", seconds=-30) - - async def test_seek_start(self) -> None: - """'к началу на кухне' → seek(p1, position=0).""" - mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "к началу на кухне"}, - } - await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - mass.player_queues.seek.assert_awaited_once_with("p1", position=0) - - async def test_transfer_to_target(self) -> None: - """'переведи на спальню' with default=p1 → transfer_queue(p1, p2); last_player_id→p2.""" - mass = self._setup_mass_with_control_methods( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ] - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "переведи на спальню"}, - "state": {"session": {"last_player_id": "p1"}}, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - mass.player_queues.transfer_queue.assert_awaited_once_with( - source_queue_id="p1", target_queue_id="p2" - ) - body_out = _response_body(resp) - assert "Спальня" in body_out["response"]["text"] - assert body_out["session_state"]["last_player_id"] == "p2" - assert body_out["application_state"]["last_player_id"] == "p2" - - async def test_transfer_no_default_replies_with_hint(self) -> None: - """Transfer without saved last_player_id replies with 'сначала включи'.""" - mass = self._setup_mass_with_control_methods( - [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")] - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "переведи на спальню"}, - } - resp = await handler._handle_webhook(_build_request(body)) - body_out = _response_body(resp) - assert "Сначала включи" in body_out["response"]["text"] - mass.player_queues.transfer_queue.assert_not_awaited() - - async def test_transfer_to_same_player(self) -> None: - """'переведи на кухню' when default already = кухня → 'уже играет'.""" - mass = self._setup_mass_with_control_methods( - [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")] - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "переведи на кухню"}, - "state": {"session": {"last_player_id": "p1"}}, - } - resp = await handler._handle_webhook(_build_request(body)) - body_out = _response_body(resp) - assert "Уже играет" in body_out["response"]["text"] - mass.player_queues.transfer_queue.assert_not_awaited() - - async def test_add_to_queue_preserved_through_disambiguation(self) -> None: - """Ambiguous "добавь Iron Maiden" → disambiguation → user picks → ADD survives. - - Without this fix, the disambiguation flow rebuilt ParsedCommand - from `pending_command` without `enqueue_option`, so the replay - would hit play_media() without `option` (default REPLACE) - instead of `QueueOption.ADD`. - """ - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня большая"), - MockPlayer(player_id="p2", name="Кухня маленькая"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - # Turn 1: ambiguous "добавь Iron Maiden на кухне" → disambig prompt. - body1 = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "добавь Iron Maiden на кухне"}, - } - resp1 = await handler._handle_webhook(_build_request(body1)) - body_out1 = _response_body(resp1) - # Pending command must carry enqueue_option across the prompt. - assert body_out1["session_state"]["pending_command"]["enqueue_option"] == "add" - mass.player_queues.play_media.assert_not_awaited() - # Turn 2: ordinal "первая" → replay pending → play_media with ADD option. - body2 = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "первая"}, - "state": {"session": body_out1["session_state"]}, - } - await handler._handle_webhook(_build_request(body2)) - await asyncio.sleep(0) - mass.player_queues.play_media.assert_awaited_once() - assert mass.player_queues.play_media.call_args.kwargs["option"] == QueueOption.ADD - - async def test_add_to_queue_uses_queue_option_add(self) -> None: - """'добавь Metallica на кухне' → play_media(option=QueueOption.ADD).""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "добавь Metallica на кухне"}, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - mass.player_queues.play_media.assert_awaited_once() - call_kwargs = mass.player_queues.play_media.call_args.kwargs - assert call_kwargs["queue_id"] == "p1" - assert call_kwargs["option"] == QueueOption.ADD - # radio_mode forced off for add-to-queue. - assert call_kwargs["radio_mode"] is False - body_out = _response_body(resp) - assert "Добавил" in body_out["response"]["text"] - assert "в очередь" in body_out["response"]["text"] - - -# --------------------------------------------------------------------------- -# Disambiguation (P0.3) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -class TestDisambiguation: - """End-to-end tests for the disambiguation prompt + pending-command replay.""" - - async def test_multiple_matches_returns_disambiguation_prompt(self) -> None: - """Two candidates → response carries buttons + pending_command, end_session=False.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня большая"), - MockPlayer(player_id="p2", name="Кухня маленькая"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "включи Metallica на кухне"}, - } - resp = await handler._handle_webhook(_build_request(body)) - assert resp.status == 200 - body_out = _response_body(resp) - assert body_out["response"]["end_session"] is False - assert "buttons" in body_out["response"] - button_titles = {b["title"] for b in body_out["response"]["buttons"]} - assert button_titles == {"Кухня большая", "Кухня маленькая"} - # pending_command is saved with the original play intent + the - # ordered candidate IDs for voice ordinal resolution. - pending = body_out["session_state"]["pending_command"] - assert pending["kind"] == "search" - assert pending["query"] == "metallica" - assert pending["radio_mode"] is True - assert pending["candidate_ids"] == ["p1", "p2"] - # Nothing is played yet. - mass.player_queues.play_media.assert_not_awaited() - - async def test_button_press_resolves_pending(self) -> None: - """ButtonPressed payload.player_id triggers a play of the saved pending_command.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня большая"), - MockPlayer(player_id="p2", name="Кухня маленькая"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": { - "type": "ButtonPressed", - "command": "Кухня большая", - "payload": {"player_id": "p1"}, - }, - "state": { - "session": { - "pending_command": {"kind": "search", "query": "metallica", "radio_mode": True}, - }, - }, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert resp.status == 200 - mass.player_queues.play_media.assert_awaited_once() - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" - # pending_command is cleared from the response state. - body_out = _response_body(resp) - assert "pending_command" not in body_out["session_state"] - assert body_out["session_state"]["last_player_id"] == "p1" - - async def test_slot_elicit_with_hint_persists_player(self) -> None: - """'включи на кухне' (player set, no query) elicits + saves hinted player. - - Previously fell through to "Не нашёл такую музыку: ." — the user - clearly wants something, just didn't name it. Now elicits and - plays the follow-up on the hinted player without re-stating it. - """ - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - # Turn 1: "включи на кухне" — no query, hint=кухне - body1 = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "включи на кухне"}, - } - resp1 = await handler._handle_webhook(_build_request(body1)) - body_out1 = _response_body(resp1) - # Slot-elicit response with hinted player saved. - assert "Что включить" in body_out1["response"]["text"] - assert body_out1["session_state"]["awaiting_query"] is True - assert body_out1["session_state"]["awaiting_player_id"] == "p1" - assert body_out1["application_state"]["awaiting_player_id"] == "p1" - mass.player_queues.play_media.assert_not_awaited() - - # Turn 2: "Metallica" — should play on p1 (the saved hint) - body2 = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "Metallica"}, - "state": { - "session": { - "awaiting_query": True, - "awaiting_player_id": "p1", - }, - }, - } - await handler._handle_webhook(_build_request(body2)) - await asyncio.sleep(0) - mass.player_queues.play_media.assert_awaited_once() - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" - - async def test_slot_elicit_when_query_empty(self) -> None: - """Bare verb (empty query) → 'Что включить?' + awaiting_query=True.""" - mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "включи"}, - } - resp = await handler._handle_webhook(_build_request(body)) - assert resp.status == 200 - body_out = _response_body(resp) - assert body_out["response"]["end_session"] is False - assert "Что включить" in body_out["response"]["text"] - assert body_out["session_state"]["awaiting_query"] is True - # Nothing played. - mass.player_queues.play_media.assert_not_awaited() - - async def test_followup_with_awaiting_query_resolves(self) -> None: - """Next utterance after slot-elicit is treated as the play query.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "Metallica"}, - "state": {"session": {"awaiting_query": True}}, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert resp.status == 200 - mass.player_queues.play_media.assert_awaited_once() - body_out = _response_body(resp) - # awaiting_query is cleared on success. - assert "awaiting_query" not in body_out["session_state"] - - async def test_control_during_awaiting_query_dispatches_control(self) -> None: - """Slot-elicit was active, but the user pivots to a control phrase. - - "Включи." → "Что включить?" (awaiting_query=True). Then the user - says "пауза на кухне" — this must dispatch a control command, not - get prefixed with "включи " and turned into a search query. - """ - mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) - mass.player_queues.pause = AsyncMock() - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "пауза на кухне"}, - "state": {"session": {"awaiting_query": True}}, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert resp.status == 200 - mass.player_queues.pause.assert_awaited_once_with("p1") - # awaiting_query must be cleared on successful control dispatch. - body_out = _response_body(resp) - assert "awaiting_query" not in body_out["session_state"] - # play_media not called — this was a control, not a play. - mass.player_queues.play_media.assert_not_awaited() - - async def test_followup_full_play_command_does_not_double_prefix(self) -> None: - """Follow-up like 'включи Yesterday' is parsed as-is, not double-prefixed.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "включи Yesterday"}, - "state": {"session": {"awaiting_query": True}}, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert resp.status == 200 - mass.player_queues.play_media.assert_awaited_once() - # The search call must use "yesterday" (after parser strips "включи"), - # not "включи yesterday". - search_query = mass.music.search.call_args.kwargs["search_query"] - assert search_query == "yesterday" - - async def test_play_no_hint_no_default_offers_disambiguation(self) -> None: - """Play branch: no hint + no default + 2+ players → disambiguation prompt. - - Without this, the user would see "Не нашёл колонку «(не указано)»". - """ - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "включи Metallica"}, - } - resp = await handler._handle_webhook(_build_request(body)) - body_out = _response_body(resp) - assert body_out["response"]["end_session"] is False - assert "buttons" in body_out["response"] - button_titles = {b["title"] for b in body_out["response"]["buttons"]} - assert button_titles == {"Кухня", "Спальня"} - # pending_command saved with the original play intent + candidate_ids. - # Order is significant — used as the index space for voice ordinal - # resolution ("первая" → candidate_ids[0]). - pending = body_out["session_state"]["pending_command"] - assert pending["kind"] == "search" - assert pending["query"] == "metallica" - assert pending["radio_mode"] is True - assert pending["candidate_ids"] == ["p1", "p2"] - mass.player_queues.play_media.assert_not_awaited() - - async def test_button_payload_validated_against_exposed_set(self) -> None: - """ButtonPressed with a payload targeting a non-exposed player is rejected. - - Defence-in-depth: even though Yandex echoes our own payload back, - we never trust the player_id without re-checking it's currently - exposed/enabled/available. - """ - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": { - "type": "ButtonPressed", - "command": "Гостиная", - "payload": {"player_id": "p99-not-in-set"}, - }, - "state": { - "session": { - "pending_command": {"kind": "search", "query": "metallica", "radio_mode": True}, - }, - }, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - # play_media must NOT be awaited — invalid payload should not play. - mass.player_queues.play_media.assert_not_awaited() - # Status is still 200; the handler falls through, but no playback. - assert resp.status == 200 - - async def test_disambiguation_clears_awaiting_query(self) -> None: - """Slot-elicit → multi-match → disambiguation prompt drops awaiting_query. - - Without this, the next user utterance ("Кухня маленькая") would get - auto-prefixed with "включи " by the awaiting-query branch and miss - the pending-command resolver. - """ - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня большая"), - MockPlayer(player_id="p2", name="Кухня маленькая"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - # Simulate the awaiting_query → ambiguous-resolution turn. - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "Metallica на кухне"}, - "state": {"session": {"awaiting_query": True}}, - } - resp = await handler._handle_webhook(_build_request(body)) - body_out = _response_body(resp) - # Disambiguation prompt is returned (multi-match). - assert body_out["response"]["end_session"] is False - assert "buttons" in body_out["response"] - # And the response carries pending_command but NOT awaiting_query. - assert "pending_command" in body_out["session_state"] - assert "awaiting_query" not in body_out["session_state"] - - async def test_voice_ordinal_resolves_pending(self) -> None: - """User answers disambiguation with 'первая' → first candidate is picked.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "первая"}, - "state": { - "session": { - "pending_command": { - "kind": "search", - "query": "metallica", - "radio_mode": True, - "candidate_ids": ["p1", "p2"], - }, - }, - }, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert resp.status == 200 - mass.player_queues.play_media.assert_awaited_once() - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" - - async def test_voice_ordinal_second_candidate(self) -> None: - """'вторая' picks the second candidate from candidate_ids.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "вторая"}, - "state": { - "session": { - "pending_command": { - "kind": "search", - "query": "metallica", - "radio_mode": True, - "candidate_ids": ["p1", "p2"], - }, - }, - }, - } - await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" - - async def test_ordinal_out_of_range_reasks_does_not_fall_through(self) -> None: - """User says 'третья' when only 2 candidates → re-ask, don't search for 'третья'. - - Without this, the ordinal would be parsed but skip the lookup, - the free-text path would parse the utterance as a search query, - and a default-player resolution might play "третья" on some - random player. - """ - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "третья"}, - "state": { - "session": { - "pending_command": { - "kind": "search", - "query": "metallica", - "radio_mode": True, - "candidate_ids": ["p1", "p2"], - }, - }, - }, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert resp.status == 200 - body_out = _response_body(resp) - # Disambiguation re-asked, not played. - assert body_out["response"]["end_session"] is False - assert "buttons" in body_out["response"] - # pending_command still set (with same candidate set). - assert body_out["session_state"]["pending_command"]["candidate_ids"] == ["p1", "p2"] - mass.player_queues.play_media.assert_not_awaited() - - async def test_ordinal_targets_unexposed_player_reasks(self) -> None: - """User picks a valid ordinal but the indexed player has been removed → re-ask.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - # Only p1 exposed now — p2 is gone since the buttons were sent. - mass = _make_mass( - [MockPlayer(player_id="p1", name="Кухня")], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "вторая"}, - "state": { - "session": { - "pending_command": { - "kind": "search", - "query": "metallica", - "radio_mode": True, - "candidate_ids": ["p1", "p2"], - }, - }, - }, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - body_out = _response_body(resp) - # Re-asked with the remaining exposed candidate (p1). - assert body_out["response"]["end_session"] is False - assert body_out["session_state"]["pending_command"]["candidate_ids"] == ["p1"] - mass.player_queues.play_media.assert_not_awaited() - - async def test_in_process_cache_recovers_when_yandex_drops_state(self) -> None: - """Reproduce the screenless-Station bug from the dev console transcript. - - Yandex doesn't echo `state.session` OR `state.application` back - on the next turn, despite us setting both on the previous - response. The in-process state cache (keyed by user.user_id / - application_id) is the third-tier fallback that recovers. - """ - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Проигрыватель"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - sess_common = { - "skill_id": "skill-uuid-1", - "user": {"user_id": "yandex-user-1"}, - "application": {"application_id": "yandex-app-1"}, - } - # Turn 1: disambig fires + saves cache entry. - await handler._handle_webhook( - _build_request( - { - "session": {**sess_common, "session_id": "s1", "new": False}, - "request": {"command": "включи джаз"}, - } - ) - ) - await asyncio.sleep(0) - cached = handler._cache_get( - { - "user": {"user_id": "yandex-user-1"}, - "application": {"application_id": "yandex-app-1"}, - } - ) - assert cached["pending_command"]["query"] == "джаз" - # Turn 2: NO `state` field in request — mimics dev-console emulator. - await handler._handle_webhook( - _build_request( - { - "session": {**sess_common, "session_id": "s1", "new": False}, - "request": {"command": "кухня"}, - } - ) - ) - await asyncio.sleep(0) - # Played the pending command (джаз) on p1 (Кухня). - mass.player_queues.play_media.assert_awaited_once() - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" - - async def test_in_process_cache_resolves_via_ordinal(self) -> None: - """Same as above, but turn 2 says '2' (ordinal) — also resolves via cache.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Проигрыватель"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - sess_common = { - "skill_id": "skill-uuid-1", - "user": {"user_id": "yandex-user-1"}, - "application": {"application_id": "yandex-app-1"}, - } - await handler._handle_webhook( - _build_request( - { - "session": {**sess_common, "session_id": "s1", "new": False}, - "request": {"command": "включи джаз"}, - } - ) - ) - await asyncio.sleep(0) - await handler._handle_webhook( - _build_request( - { - "session": {**sess_common, "session_id": "s1", "new": False}, - "request": {"command": "2"}, - } - ) - ) - await asyncio.sleep(0) - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" - - async def test_in_process_cache_ttl_expiry(self) -> None: - """Cached state expires after `_STATE_CACHE_TTL_SEC`; later calls don't see it.""" - mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - # Inject an expired entry. - handler._state_cache["user:u1"] = ( - {"pending_command": {"kind": "search", "query": "old"}}, - time.monotonic() - _STATE_CACHE_TTL_SEC - 1, - ) - assert handler._cache_get({"user": {"user_id": "u1"}}) == {} - assert "user:u1" not in handler._state_cache - - async def test_pending_command_falls_back_to_application_state(self) -> None: - """Yandex didn't echo `state.session` but kept `state.application` — still resolves. - - Reproduces the screenless-Station bug where the second turn of - a disambiguation arrives without the `pending_command` we put in - `state.session`. The same record is mirrored in `state.application` - so the handler can recover. - """ - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Проигрыватель"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "Проигрыватель"}, - "state": { - # state.session is empty — Yandex didn't echo it back. - "application": { - "pending_command": { - "kind": "search", - "query": "джаз", - "radio_mode": True, - "candidate_ids": ["p1", "p2"], - }, - }, - }, - } - await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - mass.player_queues.play_media.assert_awaited_once() - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" - - async def test_disambiguation_writes_pending_to_application_state(self) -> None: - """The disambiguation prompt mirrors `pending_command` to application_state. - - Without this, devices that drop `state.session` between turns can - never complete the disambiguation flow. - """ - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Проигрыватель"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "включи джаз"}, - } - resp = await handler._handle_webhook(_build_request(body)) - body_out = _response_body(resp) - # Disambiguation triggered. - assert "buttons" in body_out["response"] - # Pending mirrored in BOTH session_state and application_state. - assert body_out["session_state"]["pending_command"]["candidate_ids"] == ["p1", "p2"] - assert body_out["application_state"]["pending_command"]["candidate_ids"] == ["p1", "p2"] - - async def test_voice_ordinal_with_filler(self) -> None: - """Filler-padded ordinal answers ('выбираю первую', 'хочу вторую') resolve. - - On smart speakers users naturally pad voice replies with filler; - the strict-anchor regex from v1.8.2 missed these. - """ - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "выбираю первую"}, - "state": { - "session": { - "pending_command": { - "kind": "search", - "query": "metallica", - "radio_mode": True, - "candidate_ids": ["p1", "p2"], - }, - }, - }, - } - await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" - - async def test_voice_accusative_adjective(self) -> None: - """Accusative-case answer 'большую' resolves to 'Кухня большая'. - - Caught by the new `ую` suffix in `_INFLECTION_SUFFIXES`. - """ - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня большая"), - MockPlayer(player_id="p2", name="Кухня маленькая"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "большую"}, - "state": { - "session": { - "pending_command": { - "kind": "search", - "query": "metallica", - "radio_mode": True, - "candidate_ids": ["p1", "p2"], - }, - }, - }, - } - await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" - - async def test_voice_accusative_noun(self) -> None: - """Accusative noun 'Кухню' resolves to 'Кухня' via the new `ю` suffix.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "Кухню"}, - "state": { - "session": { - "pending_command": { - "kind": "search", - "query": "metallica", - "radio_mode": True, - "candidate_ids": ["p1", "p2"], - }, - }, - }, - } - await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" - - async def test_voice_ordinal_digit(self) -> None: - """A bare digit ('2') also works as an ordinal.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "2"}, - "state": { - "session": { - "pending_command": { - "kind": "search", - "query": "metallica", - "radio_mode": True, - "candidate_ids": ["p1", "p2"], - }, - }, - }, - } - await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" - - async def test_freetext_narrows_to_candidate_set(self) -> None: - """Free-text answer is matched only against the saved candidate IDs. - - With 3 exposed players (Кухня большая, Кухня маленькая, Гостиная) - and a saved candidate set covering only the two kitchens, saying - 'большая' must pick "Кухня большая" — even though 'большая' - could ambiguously refer to several players in a larger set. - """ - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня большая"), - MockPlayer(player_id="p2", name="Кухня маленькая"), - MockPlayer(player_id="p3", name="Гостиная большая"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "большая"}, - "state": { - "session": { - "pending_command": { - "kind": "search", - "query": "metallica", - "radio_mode": True, - "candidate_ids": ["p1", "p2"], - }, - }, - }, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert resp.status == 200 - # Must pick p1 (Кухня большая, in candidate set) — not p3 - # (also matches "большая" but excluded from candidate_ids). - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" - - async def test_freetext_followup_resolves_pending(self) -> None: - """User says 'на кухне маленькой' after the disambiguation question — plays on p2.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass( - [ - MockPlayer(player_id="p1", name="Кухня большая"), - MockPlayer(player_id="p2", name="Кухня маленькая"), - ], - search_track=track, - ) - handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) - body = { - "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, - "request": {"command": "на кухне маленькой"}, - "state": { - "session": { - "pending_command": {"kind": "search", "query": "metallica", "radio_mode": True}, - }, - }, - } - resp = await handler._handle_webhook(_build_request(body)) - await asyncio.sleep(0) - assert resp.status == 200 - mass.player_queues.play_media.assert_awaited_once() - assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" diff --git a/tests/providers/yandex_smarthome/test_dialogs_control.py b/tests/providers/yandex_smarthome/test_dialogs_control.py deleted file mode 100644 index afe2423559..0000000000 --- a/tests/providers/yandex_smarthome/test_dialogs_control.py +++ /dev/null @@ -1,480 +0,0 @@ -# ruff: noqa: RUF001 -"""Tests for provider/dialogs_control.py — playback control NLU + executor.""" - -from __future__ import annotations - -import logging -from unittest.mock import AsyncMock, MagicMock - -import pytest -from music_assistant_models.enums import RepeatMode - -from music_assistant.providers.yandex_smarthome.dialogs_control import ( - ParsedControl, - _plural_ru, - control_confirmation, - execute_control, - format_list_players, - parse_control, -) - - -class TestParseControl: - """Table-driven tests for parse_control across all action families.""" - - @pytest.mark.parametrize( - ("phrase", "expected_action", "expected_value", "expected_hint"), - [ - # pause - ("пауза", "pause", None, None), - ("на паузу", "pause", None, None), - ("поставь на паузу", "pause", None, None), - ("останови музыку", "pause", None, None), - ("пауза на кухне", "pause", None, "кухне"), - ("поставь на паузу на кухне", "pause", None, "кухне"), - # resume - ("продолжи", "resume", None, None), - ("продолжить", "resume", None, None), - ("включи снова", "resume", None, None), - ("возобнови", "resume", None, None), - # stop - ("стоп", "stop", None, None), - ("останови", "stop", None, None), - ("выключи", "stop", None, None), - ("выключи музыку", "stop", None, None), - ("стоп на спальне", "stop", None, "спальне"), - # next - ("следующая", "next", None, None), - ("следующий трек", "next", None, None), - ("дальше", "next", None, None), - ("переключи", "next", None, None), - # previous - ("предыдущая", "previous", None, None), - ("предыдущий трек", "previous", None, None), - ("назад", "previous", None, None), - ("вернись", "previous", None, None), - # volume relative - ("громче", "volume_up", None, None), - ("сделай громче", "volume_up", None, None), - ("прибавь", "volume_up", None, None), - ("прибавь громкость", "volume_up", None, None), - ("тише", "volume_down", None, None), - ("сделай тише", "volume_down", None, None), - ("убавь", "volume_down", None, None), - ("убавь громкость", "volume_down", None, None), - ("громче на кухне", "volume_up", None, "кухне"), - # volume set - ("громкость 50", "volume_set", 50, None), - ("громкость на 30", "volume_set", 30, None), - ("громкость на 30 процентов", "volume_set", 30, None), - ("сделай громкость 75", "volume_set", 75, None), - ("громкость 50 на кухне", "volume_set", 50, "кухне"), - # volume set clamping - ("громкость 200", "volume_set", 100, None), - # mute / unmute - ("приглуши", "mute", None, None), - ("выключи звук", "mute", None, None), - ("беззвучно", "mute", None, None), - ("включи звук", "unmute", None, None), - ("сделай звук", "unmute", None, None), - # list_players (no player_hint, no value) - ("сколько колонок", "list_players", None, None), - ("сколько колонок ты видишь", "list_players", None, None), - ("сколько колонок ты знаешь", "list_players", None, None), - ("какие колонки", "list_players", None, None), - ("какие колонки видишь", "list_players", None, None), - ("какие колонки ты видишь", "list_players", None, None), - ("какие колонки есть", "list_players", None, None), - ("какие у тебя колонки", "list_players", None, None), - ("перечисли колонки", "list_players", None, None), - ("список колонок", "list_players", None, None), - ("покажи колонки", "list_players", None, None), - ("назови колонки", "list_players", None, None), - # forget_player — clears the saved default-player so the - # next play command without a hint asks again. - ("забудь колонку", "forget_player", None, None), - ("сбрось колонку", "forget_player", None, None), - ("забудь плеер", "forget_player", None, None), - ("забудь выбор", "forget_player", None, None), - ("сбрось выбор", "forget_player", None, None), - ("выбери колонку заново", "forget_player", None, None), - ("поменяй колонку", "forget_player", None, None), - ("сменить колонку", "forget_player", None, None), - # now_playing — info query - ("что играет", "now_playing", None, None), - ("что сейчас играет", "now_playing", None, None), - ("что слушаем", "now_playing", None, None), - ("что мы слушаем", "now_playing", None, None), - ("что за песня", "now_playing", None, None), - ("что за трек", "now_playing", None, None), - ("какой трек", "now_playing", None, None), - ("какой сейчас трек", "now_playing", None, None), - ("что играет на кухне", "now_playing", None, "кухне"), - # shuffle on/off - ("перемешай", "shuffle_on", None, None), - ("включи перемешивание", "shuffle_on", None, None), - ("случайный порядок", "shuffle_on", None, None), - ("в случайном порядке", "shuffle_on", None, None), - ("выключи перемешивание", "shuffle_off", None, None), - ("не перемешивай", "shuffle_off", None, None), - ("по порядку", "shuffle_off", None, None), - ("перемешай на кухне", "shuffle_on", None, "кухне"), - # repeat one/all/off - ("повтор песни", "repeat_one", None, None), - ("повтори песню", "repeat_one", None, None), - ("повтори трек", "repeat_one", None, None), - ("повтор эту", "repeat_one", None, None), - ("повтор всё", "repeat_all", None, None), - ("повтори все", "repeat_all", None, None), - ("повтор очередь", "repeat_all", None, None), - ("повторяй", "repeat_all", None, None), - ("включи повтор", "repeat_all", None, None), - ("выключи повтор", "repeat_off", None, None), - ("не повторяй", "repeat_off", None, None), - # seek_forward (with optional unit) - ("вперёд 30", "seek_forward", 30, None), - ("вперед 30", "seek_forward", 30, None), - ("перемотай вперёд 30", "seek_forward", 30, None), - ("перемотай вперёд на 30", "seek_forward", 30, None), - ("перемотай вперёд на 30 секунд", "seek_forward", 30, None), - ("перемотай вперёд на 1 минуту", "seek_forward", 60, None), - ("перемотай вперёд на 2 минуты", "seek_forward", 120, None), - # seek_back - ("назад 30", "seek_back", 30, None), - ("перемотай назад 30", "seek_back", 30, None), - ("перемотай назад на 1 минуту", "seek_back", 60, None), - ("назад на 5 секунд", "seek_back", 5, None), - # seek_start - ("к началу", "seek_start", None, None), - ("в начало", "seek_start", None, None), - ("перемотай к началу", "seek_start", None, None), - ("начни заново", "seek_start", None, None), - ("начни трек заново", "seek_start", None, None), - # transfer (target captured into player_hint) - ("переведи на спальню", "transfer", None, "спальню"), - ("перенеси на спальню", "transfer", None, "спальню"), - ("продолжи в спальне", "transfer", None, "спальне"), - ("переведи музыку на кухню", "transfer", None, "кухню"), - # alice prefix tolerated - ("Алиса, пауза", "pause", None, None), - ], - ) - def test_parse( - self, - phrase: str, - expected_action: str, - expected_value: int | None, - expected_hint: str | None, - ) -> None: - """Each parametrized phrase maps to the expected ParsedControl.""" - result = parse_control(phrase) - assert result is not None, f"phrase={phrase!r} returned None" - assert result.action == expected_action, f"phrase={phrase!r}" - assert result.value == expected_value, f"phrase={phrase!r}" - assert result.player_hint == expected_hint, f"phrase={phrase!r}" - - @pytest.mark.parametrize( - "phrase", - [ - "", - "включи Metallica", - "включи джаз на кухне", - "включи песню Yesterday", - "включи мою волну", - "что-то непонятное", - "включи альбом Black Album", - ], - ) - def test_play_phrases_return_none(self, phrase: str) -> None: - """Phrases that should fall through to the play parser return None.""" - assert parse_control(phrase) is None - - -class TestPluralRu: - """Tests for the Russian quantitative-form picker.""" - - @pytest.mark.parametrize( - ("n", "expected"), - [ - (1, "колонку"), - (2, "колонки"), - (3, "колонки"), - (4, "колонки"), - (5, "колонок"), - (10, "колонок"), - (11, "колонок"), # 11 is exception — uses 5+ form - (12, "колонок"), - (14, "колонок"), - (21, "колонку"), # 21 → 1-form - (22, "колонки"), - (25, "колонок"), - (101, "колонку"), - (111, "колонок"), - (0, "колонок"), - ], - ) - def test_plural(self, n: int, expected: str) -> None: - """Russian quantitative agreement matches expected form for `n`.""" - assert _plural_ru(n, ("колонку", "колонки", "колонок")) == expected - - -class TestFormatListPlayers: - """Tests for the `list_players` confirmation builder.""" - - def test_zero_players(self) -> None: - """Empty list → 'не вижу'.""" - assert format_list_players([]) == "Не вижу ни одной колонки." - - def test_one_player(self) -> None: - """Single player → singular form.""" - p = MagicMock() - p.name = "Кухня" - p.player_id = "p1" - assert format_list_players([p]) == "Вижу одну колонку: Кухня." - - def test_three_players(self) -> None: - """Three players → 2-4 form ('колонки') with comma-separated names.""" - ps = [] - for name, pid in [("Кухня", "p1"), ("Спальня", "p2"), ("Гостиная", "p3")]: - p = MagicMock() - p.name = name - p.player_id = pid - ps.append(p) - assert format_list_players(ps) == "Вижу 3 колонки: Кухня, Спальня, Гостиная." - - def test_five_players(self) -> None: - """Five players → 5+ form ('колонок').""" - ps = [] - for i in range(5): - p = MagicMock() - p.name = f"Player{i}" - p.player_id = f"p{i}" - ps.append(p) - text = format_list_players(ps) - assert text.startswith("Вижу 5 колонок:") - - -class TestControlConfirmation: - """Tests for the user-facing confirmation strings.""" - - @pytest.mark.parametrize( - ("action", "value", "expected"), - [ - ("pause", None, "Пауза."), - ("resume", None, "Продолжаю."), - ("stop", None, "Остановил."), - ("next", None, "Следующая."), - ("previous", None, "Предыдущая."), - ("volume_up", None, "Громче."), - ("volume_down", None, "Тише."), - ("volume_set", 50, "Громкость 50."), - ("mute", None, "Звук выключен."), - ("unmute", None, "Звук включен."), - ("shuffle_on", None, "Включил перемешивание."), - ("shuffle_off", None, "Выключил перемешивание."), - ("repeat_off", None, "Выключил повтор."), - ("repeat_one", None, "Повтор песни."), - ("repeat_all", None, "Повтор очереди."), - ("seek_forward", 60, "Перемотал на 60 секунд вперёд."), - ("seek_back", 30, "Перемотал на 30 секунд назад."), - ("seek_start", None, "Перемотал к началу."), - ], - ) - def test_confirmation(self, action: str, value: int | None, expected: str) -> None: - """Confirmation text matches the expected per-action template.""" - ctrl = ParsedControl(action=action, value=value) # type: ignore[arg-type] - assert control_confirmation(ctrl) == expected - - -@pytest.mark.asyncio -class TestExecuteControl: - """Tests that execute_control dispatches to the correct MA call.""" - - def _make_mass(self) -> MagicMock: - mass = MagicMock() - mass.player_queues = MagicMock() - mass.player_queues.pause = AsyncMock() - mass.player_queues.resume = AsyncMock() - mass.player_queues.stop = AsyncMock() - mass.player_queues.next = AsyncMock() - mass.player_queues.previous = AsyncMock() - mass.player_queues.set_shuffle = AsyncMock() - mass.player_queues.set_repeat = MagicMock() # NB: sync, not async - mass.player_queues.skip = AsyncMock() - mass.player_queues.seek = AsyncMock() - mass.players = MagicMock() - mass.players.cmd_volume_up = AsyncMock() - mass.players.cmd_volume_down = AsyncMock() - mass.players.cmd_volume_set = AsyncMock() - mass.players.cmd_volume_mute = AsyncMock() - return mass - - def _player(self) -> MagicMock: - player = MagicMock() - player.player_id = "p1" - return player - - async def test_pause_calls_pause(self) -> None: - """action=pause invokes mass.player_queues.pause.""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="pause"), self._player()) - mass.player_queues.pause.assert_awaited_once_with("p1") - - async def test_resume_calls_resume(self) -> None: - """action=resume invokes mass.player_queues.resume.""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="resume"), self._player()) - mass.player_queues.resume.assert_awaited_once_with("p1") - - async def test_stop_calls_stop(self) -> None: - """action=stop invokes mass.player_queues.stop.""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="stop"), self._player()) - mass.player_queues.stop.assert_awaited_once_with("p1") - - async def test_next_calls_next(self) -> None: - """action=next invokes mass.player_queues.next.""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="next"), self._player()) - mass.player_queues.next.assert_awaited_once_with("p1") - - async def test_previous_calls_previous(self) -> None: - """action=previous invokes mass.player_queues.previous.""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="previous"), self._player()) - mass.player_queues.previous.assert_awaited_once_with("p1") - - async def test_volume_up(self) -> None: - """action=volume_up invokes cmd_volume_up.""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="volume_up"), self._player()) - mass.players.cmd_volume_up.assert_awaited_once_with("p1") - - async def test_volume_down(self) -> None: - """action=volume_down invokes cmd_volume_down.""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="volume_down"), self._player()) - mass.players.cmd_volume_down.assert_awaited_once_with("p1") - - async def test_volume_set(self) -> None: - """action=volume_set invokes cmd_volume_set with the requested value.""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="volume_set", value=42), self._player()) - mass.players.cmd_volume_set.assert_awaited_once_with("p1", 42) - - async def test_volume_set_none_falls_back_to_zero(self) -> None: - """volume_set with value=None defaults to 0 (defensive).""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="volume_set", value=None), self._player()) - mass.players.cmd_volume_set.assert_awaited_once_with("p1", 0) - - async def test_mute(self) -> None: - """action=mute invokes cmd_volume_mute(True).""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="mute"), self._player()) - mass.players.cmd_volume_mute.assert_awaited_once_with("p1", True) - - async def test_unmute(self) -> None: - """action=unmute invokes cmd_volume_mute(False).""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="unmute"), self._player()) - mass.players.cmd_volume_mute.assert_awaited_once_with("p1", False) - - async def test_list_players_is_a_safe_noop_with_warning( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """`list_players` reaching execute_control logs a warning and no-ops. - - The handler is supposed to short-circuit `list_players` before - dispatch (it's an informational query), but the typing allows - it as a `ControlAction` so the explicit branch makes a stray - call safe rather than a silent no-op. - """ - mass = self._make_mass() - with caplog.at_level( - logging.WARNING, logger="music_assistant.providers.yandex_smarthome.dialogs_control" - ): - await execute_control(mass, ParsedControl(action="list_players"), self._player()) - # No MA command dispatched. - mass.player_queues.pause.assert_not_awaited() - mass.player_queues.resume.assert_not_awaited() - mass.players.cmd_volume_set.assert_not_awaited() - # Warning emitted. - assert any("list_players" in r.getMessage() for r in caplog.records) - - async def test_shuffle_on(self) -> None: - """action=shuffle_on invokes set_shuffle(True).""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="shuffle_on"), self._player()) - mass.player_queues.set_shuffle.assert_awaited_once_with("p1", shuffle_enabled=True) - - async def test_shuffle_off(self) -> None: - """action=shuffle_off invokes set_shuffle(False).""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="shuffle_off"), self._player()) - mass.player_queues.set_shuffle.assert_awaited_once_with("p1", shuffle_enabled=False) - - async def test_repeat_off(self) -> None: - """action=repeat_off invokes set_repeat(RepeatMode.OFF) — sync, not awaited.""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="repeat_off"), self._player()) - mass.player_queues.set_repeat.assert_called_once_with("p1", RepeatMode.OFF) - - async def test_repeat_one(self) -> None: - """action=repeat_one invokes set_repeat(RepeatMode.ONE).""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="repeat_one"), self._player()) - mass.player_queues.set_repeat.assert_called_once_with("p1", RepeatMode.ONE) - - async def test_repeat_all(self) -> None: - """action=repeat_all invokes set_repeat(RepeatMode.ALL).""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="repeat_all"), self._player()) - mass.player_queues.set_repeat.assert_called_once_with("p1", RepeatMode.ALL) - - async def test_seek_forward(self) -> None: - """action=seek_forward(value=N) invokes skip(qid, +N).""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="seek_forward", value=60), self._player()) - mass.player_queues.skip.assert_awaited_once_with("p1", seconds=60) - - async def test_seek_back_negates_value(self) -> None: - """action=seek_back(value=N) invokes skip(qid, -N) — value is positive at parse time.""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="seek_back", value=30), self._player()) - mass.player_queues.skip.assert_awaited_once_with("p1", seconds=-30) - - async def test_seek_start(self) -> None: - """action=seek_start invokes seek(qid, position=0) — absolute reset.""" - mass = self._make_mass() - await execute_control(mass, ParsedControl(action="seek_start"), self._player()) - mass.player_queues.seek.assert_awaited_once_with("p1", position=0) - - async def test_now_playing_is_safe_noop(self, caplog: pytest.LogCaptureFixture) -> None: - """`now_playing` reaching execute_control logs warning and no-ops (handler dispatches).""" - mass = self._make_mass() - with caplog.at_level( - logging.WARNING, logger="music_assistant.providers.yandex_smarthome.dialogs_control" - ): - await execute_control(mass, ParsedControl(action="now_playing"), self._player()) - mass.player_queues.skip.assert_not_awaited() - assert any("now_playing" in r.getMessage() for r in caplog.records) - - async def test_transfer_is_safe_noop(self, caplog: pytest.LogCaptureFixture) -> None: - """`transfer` reaching execute_control logs warning and no-ops (handler dispatches).""" - mass = self._make_mass() - with caplog.at_level( - logging.WARNING, logger="music_assistant.providers.yandex_smarthome.dialogs_control" - ): - await execute_control( - mass, ParsedControl(action="transfer", player_hint="спальню"), self._player() - ) - mass.player_queues.skip.assert_not_awaited() - assert any("transfer" in r.getMessage() for r in caplog.records) - - async def test_underlying_failure_is_swallowed(self) -> None: - """An exception from the MA call is logged + swallowed (no re-raise).""" - mass = self._make_mass() - mass.player_queues.pause = AsyncMock(side_effect=RuntimeError("boom")) - # Must not raise. - await execute_control(mass, ParsedControl(action="pause"), self._player()) diff --git a/tests/providers/yandex_smarthome/test_dialogs_nlu.py b/tests/providers/yandex_smarthome/test_dialogs_nlu.py deleted file mode 100644 index dfd733ebe5..0000000000 --- a/tests/providers/yandex_smarthome/test_dialogs_nlu.py +++ /dev/null @@ -1,292 +0,0 @@ -# ruff: noqa: RUF001, RUF003 -"""Tests for provider/dialogs_nlu.py — voice command parser + player resolver.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -import pytest - -from music_assistant.providers.yandex_smarthome.dialogs_nlu import ( - ParsedCommand, - parse_command, - resolve_player, - resolve_player_candidates, -) - -# --------------------------------------------------------------------------- -# parse_command — table-driven -# --------------------------------------------------------------------------- - - -class TestParseCommand: - """Tests for parse_command across kinds + player suffix.""" - - @pytest.mark.parametrize( - ("phrase", "expected_kind", "expected_query", "expected_hint", "expected_radio"), - [ - # Bare/default search — radio_mode=True so artists and tracks - # start a radio rather than playing one item and stopping. - ("включи Metallica", "search", "metallica", None, True), - ("включи джаз", "search", "джаз", None, True), - # Track explicit - ("включи песню Yesterday", "track", "yesterday", None, False), - ("включи трек Imagine", "track", "imagine", None, False), - # Album explicit - ("включи альбом Black Album", "album", "black album", None, False), - ("включи пластинку Дыхание", "album", "дыхание", None, False), - # Artist explicit (radio) - ("включи исполнителя Metallica", "artist", "metallica", None, True), - ("включи группу Beatles", "artist", "beatles", None, True), - # Playlist explicit - ("включи плейлист утренний джаз", "playlist", "утренний джаз", None, False), - ("включи подборку рок", "playlist", "рок", None, False), - # My wave - ("включи мою волну", "my_wave", "", None, True), - ("включи свою волну", "my_wave", "", None, True), - # Genre / radio - ("включи жанр джаз", "genre", "джаз", None, True), - ("включи радио рок", "genre", "рок", None, True), - # With player suffix - ("включи Metallica на кухне", "search", "metallica", "кухне", True), - ("включи песню Yesterday на спальне", "track", "yesterday", "спальне", False), - ("включи мою волну на кухне", "my_wave", "", "кухне", True), - ("включи альбом Black Album на колонке", "album", "black album", "колонке", False), - # Punctuation, casing, alice prefix - ("Алиса, включи Metallica.", "search", "metallica", None, True), - ("ВКЛЮЧИ ПЕСНЮ Hey Jude!", "track", "hey jude", None, False), - # Different verbs (incl. infinitives Yandex sometimes returns) - ("поставь Metallica", "search", "metallica", None, True), - ("запусти джаз на кухне", "search", "джаз", "кухне", True), - ("включай Metallica", "search", "metallica", None, True), - ("включайте джаз на кухне", "search", "джаз", "кухне", True), - ("включить Iron Maiden", "search", "iron maiden", None, True), - ("сыграй Metallica на кухне", "search", "metallica", "кухне", True), - ("послушать джаз", "search", "джаз", None, True), - # P0.5 — find/open/show verbs as play synonyms - ("найди Metallica", "search", "metallica", None, True), - ("найти джаз на кухне", "search", "джаз", "кухне", True), - ("открой плейлист утренний джаз", "playlist", "утренний джаз", None, False), - ("покажи альбом Black Album", "album", "black album", None, False), - # Suspicious-split detector — content title starts with "На …" - # so the trailing "на " must NOT be treated as a player hint. - # Without the detector "включи песню На заре" → query="песню", - # hint="заре" — wrong. - ("включи песню На заре", "track", "на заре", None, False), - ("включи альбом На заре", "album", "на заре", None, False), - ("включи плейлист На заре", "playlist", "на заре", None, False), - # Genuine " на " still works after the detector. - ("включи песню Yesterday на кухне", "track", "yesterday", "кухне", False), - ("включи песню", "search", "песню", None, True), # no hint, just a marker - # add-to-queue: "добавь" verb sets enqueue_option="add"; radio_mode forced off. - ("добавь Metallica", "search", "metallica", None, False), - ("добавь песню Yesterday", "track", "yesterday", None, False), - ("добавьте альбом Black Album", "album", "black album", None, False), - ("добавить Iron Maiden на кухне", "search", "iron maiden", "кухне", False), - ], - ) - def test_parse( - self, - phrase: str, - expected_kind: str, - expected_query: str, - expected_hint: str | None, - expected_radio: bool, - ) -> None: - """Each parametrized phrase maps to the expected ParsedCommand fields.""" - result = parse_command(phrase) - assert result.kind == expected_kind, f"phrase={phrase!r}" - assert result.query == expected_query, f"phrase={phrase!r}" - assert result.player_hint == expected_hint, f"phrase={phrase!r}" - assert result.radio_mode == expected_radio, f"phrase={phrase!r}" - - def test_empty(self) -> None: - """Empty input returns a search ParsedCommand with empty query.""" - assert parse_command("") == ParsedCommand(kind="search", query="") - - def test_just_alice(self) -> None: - """Bare 'алиса' without a verb keeps the full word as query.""" - assert parse_command("алиса").query == "алиса" - - def test_enqueue_option_set_for_dobavi(self) -> None: - """'добавь Metallica' → enqueue_option='add' (None for regular 'включи').""" - assert parse_command("добавь Metallica").enqueue_option == "add" - assert parse_command("добавьте альбом Black Album").enqueue_option == "add" - assert parse_command("добавить Iron Maiden").enqueue_option == "add" - # Regular play verbs leave it as None (default REPLACE behaviour). - assert parse_command("включи Metallica").enqueue_option is None - assert parse_command("поставь Metallica").enqueue_option is None - - -# --------------------------------------------------------------------------- -# resolve_player — fixtures + cases -# --------------------------------------------------------------------------- - - -@dataclass -class MockPlayer: - """Minimal player stub for resolver tests.""" - - player_id: str = "p1" - name: str = "Player" - available: bool = True - enabled: bool = True - synced_to: str | None = None - supported_features: set[str] = field(default_factory=set) - - -class MockPlayerController: - """Minimal player controller stub.""" - - def __init__(self, players: list[MockPlayer]) -> None: - """Initialise with a fixed player list.""" - self._players = players - - def all_players(self) -> list[MockPlayer]: - """Return all players.""" - return list(self._players) - - -@dataclass -class MockMass: - """Minimal mass stub for NLU resolver tests.""" - - players: MockPlayerController - - -def _mass(players: list[MockPlayer]) -> MockMass: - return MockMass(players=MockPlayerController(players)) - - -class TestResolvePlayer: - """Tests for resolve_player — fuzzy name matching with Russian inflections.""" - - def test_exact_lowercase_match(self) -> None: - """Exact lowercase hint matches the player with that name.""" - players = [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ] - result = resolve_player(_mass(players), "кухня") # type: ignore[arg-type] - assert result is not None - assert result.player_id == "p1" - - def test_inflected_match(self) -> None: - """Locative-case hint 'кухне' matches the player named 'Кухня'.""" - players = [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ] - result = resolve_player(_mass(players), "кухне") # type: ignore[arg-type] - assert result is not None - assert result.player_id == "p1" - - def test_substring_match(self) -> None: - """Short hint matches a player whose name contains it as substring.""" - players = [ - MockPlayer(player_id="p1", name="Sendspin BT Group"), - MockPlayer(player_id="p2", name="Lenco LS-500"), - ] - result = resolve_player(_mass(players), "lenco") # type: ignore[arg-type] - assert result is not None - assert result.player_id == "p2" - - def test_no_match_returns_none(self) -> None: - """Unrecognised hint returns None.""" - players = [MockPlayer(player_id="p1", name="Кухня")] - assert resolve_player(_mass(players), "гостиная") is None # type: ignore[arg-type] - - def test_skips_disabled(self) -> None: - """Disabled players are excluded from candidates.""" - players = [MockPlayer(player_id="p1", name="Кухня", enabled=False)] - assert resolve_player(_mass(players), "кухня") is None # type: ignore[arg-type] - - def test_skips_unavailable(self) -> None: - """Unavailable players are excluded from candidates.""" - players = [MockPlayer(player_id="p1", name="Кухня", available=False)] - assert resolve_player(_mass(players), "кухня") is None # type: ignore[arg-type] - - def test_skips_synced(self) -> None: - """Players synced to another player are excluded from candidates.""" - players = [MockPlayer(player_id="p1", name="Кухня", synced_to="leader")] - assert resolve_player(_mass(players), "кухня") is None # type: ignore[arg-type] - - def test_default_id_used_when_no_hint(self) -> None: - """No hint falls back to default_id.""" - players = [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ] - result = resolve_player(_mass(players), None, default_id="p2") # type: ignore[arg-type] - assert result is not None - assert result.player_id == "p2" - - def test_single_player_no_hint_picked(self) -> None: - """No hint with a single available player returns that player.""" - players = [MockPlayer(player_id="p1", name="Кухня")] - result = resolve_player(_mass(players), None) # type: ignore[arg-type] - assert result is not None - assert result.player_id == "p1" - - def test_exposed_ids_filter(self) -> None: - """Hint matching a player outside exposed_ids returns None.""" - players = [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ] - result = resolve_player(_mass(players), "кухня", exposed_ids={"p2"}) # type: ignore[arg-type] - assert result is None - - def test_ambiguous_returns_none(self) -> None: - """When the hint matches multiple players in the same tier, resolve_player returns None. - - The caller is expected to use ``resolve_player_candidates`` directly - when it wants to surface the ambiguity (P0.3 disambiguation). - """ - players = [ - MockPlayer(player_id="p1", name="Кухня большая"), - MockPlayer(player_id="p2", name="Кухня маленькая"), - ] - # Both names start with "Кухня" — startswith tier has 2 → ambiguous. - assert resolve_player(_mass(players), "кухня") is None # type: ignore[arg-type] - - -class TestResolvePlayerCandidates: - """Tests for resolve_player_candidates — same matching, surface tier list.""" - - def test_zero_matches(self) -> None: - """No candidate match → empty list.""" - players = [MockPlayer(player_id="p1", name="Кухня")] - assert resolve_player_candidates(_mass(players), "гостиная") == [] # type: ignore[arg-type] - - def test_single_match(self) -> None: - """One unambiguous match → single-element list.""" - players = [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Спальня"), - ] - result = resolve_player_candidates(_mass(players), "кухне") # type: ignore[arg-type] - assert len(result) == 1 - assert result[0].player_id == "p1" - - def test_multiple_matches_returned_in_alphabetical_order(self) -> None: - """When multiple players match the same tier, return all sorted by name.""" - players = [ - MockPlayer(player_id="p1", name="Кухня большая"), - MockPlayer(player_id="p2", name="Кухня маленькая"), - MockPlayer(player_id="p3", name="Спальня"), - ] - result = resolve_player_candidates(_mass(players), "кухня") # type: ignore[arg-type] - assert len(result) == 2 - names = [p.name for p in result] - assert names == sorted(names, key=str.lower) - - def test_exact_tier_wins_over_startswith(self) -> None: - """An exact match excludes startswith candidates from the result.""" - players = [ - MockPlayer(player_id="p1", name="Кухня"), - MockPlayer(player_id="p2", name="Кухня большая"), - ] - result = resolve_player_candidates(_mass(players), "кухня") # type: ignore[arg-type] - # Only the exact match is returned. - assert [p.player_id for p in result] == ["p1"] diff --git a/tests/providers/yandex_smarthome/test_dialogs_player.py b/tests/providers/yandex_smarthome/test_dialogs_player.py deleted file mode 100644 index de2da0ce26..0000000000 --- a/tests/providers/yandex_smarthome/test_dialogs_player.py +++ /dev/null @@ -1,191 +0,0 @@ -# ruff: noqa: RUF003 -"""Tests for provider/dialogs_player.py — content resolver + play wrapper.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from music_assistant.providers.yandex_smarthome.dialogs_nlu import ParsedCommand -from music_assistant.providers.yandex_smarthome.dialogs_player import play_for_alice, resolve_query - -# --------------------------------------------------------------------------- -# resolve_query -# --------------------------------------------------------------------------- - - -@dataclass -class _SearchResults: - artists: list[object] = field(default_factory=list) - albums: list[object] = field(default_factory=list) - tracks: list[object] = field(default_factory=list) - playlists: list[object] = field(default_factory=list) - - -def _make_mass(search_results: _SearchResults | None = None) -> MagicMock: - mass = MagicMock() - mass.music = MagicMock() - mass.music.search = AsyncMock(return_value=search_results or _SearchResults()) - mass.music_providers = [] - mass.providers = [] - mass.player_queues = MagicMock() - mass.player_queues.play_media = AsyncMock() - mass.players = MagicMock() - mass.players.get_player = MagicMock(return_value=None) - mass.players.cmd_power = AsyncMock() - return mass - - -@pytest.mark.asyncio -class TestResolveQuery: - """Tests for resolve_query — content resolver dispatching by ParsedCommand.kind.""" - - async def test_track(self) -> None: - """kind=track returns the first track search result.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass(_SearchResults(tracks=[track])) - result = await resolve_query(mass, ParsedCommand(kind="track", query="yesterday")) - assert result is track - mass.music.search.assert_awaited_once() - - async def test_artist(self) -> None: - """kind=artist returns the first artist search result.""" - artist = MagicMock(uri="library://artist/1", spec_set=["uri"]) - mass = _make_mass(_SearchResults(artists=[artist])) - result = await resolve_query( - mass, ParsedCommand(kind="artist", query="metallica", radio_mode=True) - ) - assert result is artist - - async def test_album(self) -> None: - """kind=album returns the first album search result.""" - album = MagicMock(uri="library://album/1", spec_set=["uri"]) - mass = _make_mass(_SearchResults(albums=[album])) - result = await resolve_query(mass, ParsedCommand(kind="album", query="black album")) - assert result is album - - async def test_playlist(self) -> None: - """kind=playlist returns the first playlist search result.""" - playlist = MagicMock(uri="library://playlist/1", spec_set=["uri"]) - mass = _make_mass(_SearchResults(playlists=[playlist])) - result = await resolve_query(mass, ParsedCommand(kind="playlist", query="rock")) - assert result is playlist - - async def test_search_kind_prefers_artist(self) -> None: - """kind=search prefers artist over playlist/track for unqualified queries. - - Users typically say a band/artist name without a "плейлист" / - "альбом" qualifier ("включи Iron Maiden"). Picking the artist - (with radio_mode=True downstream) matches the intent better than - starting an unrelated playlist that happens to contain the query. - """ - artist = MagicMock(uri="library://artist/1", spec_set=["uri"]) - playlist = MagicMock(uri="library://playlist/1", spec_set=["uri"]) - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - mass = _make_mass(_SearchResults(artists=[artist], playlists=[playlist], tracks=[track])) - result = await resolve_query(mass, ParsedCommand(kind="search", query="iron maiden")) - assert result is artist - - async def test_search_no_results_returns_none(self) -> None: - """Empty search results return None.""" - mass = _make_mass(_SearchResults()) - result = await resolve_query(mass, ParsedCommand(kind="search", query="nope")) - assert result is None - - async def test_my_wave_no_provider_returns_none(self) -> None: - """kind=my_wave without yandex_music provider returns None.""" - mass = _make_mass() - result = await resolve_query(mass, ParsedCommand(kind="my_wave", query="", radio_mode=True)) - assert result is None - - async def test_search_failure_returns_none(self) -> None: - """Search exception is swallowed and returns None.""" - mass = _make_mass() - mass.music.search = AsyncMock(side_effect=RuntimeError("boom")) - result = await resolve_query(mass, ParsedCommand(kind="track", query="x")) - assert result is None - - async def test_cyrillic_query_retries_with_stemmed_form(self) -> None: - """First search empty + Cyrillic query → retry with inflection stripped.""" - track = MagicMock(uri="library://track/1", spec_set=["uri"]) - empty = _SearchResults() - hit = _SearchResults(tracks=[track]) - mass = _make_mass(empty) - # First call returns empty, second returns the track. - mass.music.search = AsyncMock(side_effect=[empty, hit]) - result = await resolve_query(mass, ParsedCommand(kind="track", query="металлику")) - assert result is track - # Two calls were made. - assert mass.music.search.await_count == 2 - # Second call used the stemmed query ("металлик" — last `у` stripped). - second_call_kwargs = mass.music.search.await_args_list[1].kwargs - assert second_call_kwargs["search_query"] == "металлик" - - async def test_ascii_query_does_not_retry(self) -> None: - """ASCII-only query is not retried — stemming has no effect.""" - empty = _SearchResults() - mass = _make_mass(empty) - mass.music.search = AsyncMock(return_value=empty) - result = await resolve_query(mass, ParsedCommand(kind="track", query="metallica")) - assert result is None - assert mass.music.search.await_count == 1 - - async def test_retry_skipped_when_stemmed_equals_original(self) -> None: - """Russian query already in stemmed form (short word) doesn't trigger retry.""" - empty = _SearchResults() - mass = _make_mass(empty) - mass.music.search = AsyncMock(return_value=empty) - # "рок" (3 chars) — too short for the suffix-strip to produce a different stem. - result = await resolve_query(mass, ParsedCommand(kind="search", query="рок")) - assert result is None - assert mass.music.search.await_count == 1 - - -# --------------------------------------------------------------------------- -# play_for_alice -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -class TestPlayForAlice: - """Tests for play_for_alice — power-on + play_media orchestration.""" - - async def test_no_player_object_still_plays(self) -> None: - """When player object is missing, play_media is called without cmd_power.""" - mass = _make_mass() - await play_for_alice(mass, "p1", "library://track/1", radio_mode=False) - mass.players.cmd_power.assert_not_awaited() - mass.player_queues.play_media.assert_awaited_once_with( - queue_id="p1", media="library://track/1", radio_mode=False - ) - - async def test_powers_on_when_off(self) -> None: - """Player with power feature and powered=False gets cmd_power before play.""" - mass = _make_mass() - player = MagicMock() - player.supported_features = {"power"} - player.powered = False - mass.players.get_player = MagicMock(return_value=player) - await play_for_alice(mass, "p1", "library://track/1", radio_mode=False) - mass.players.cmd_power.assert_awaited_once_with("p1", True) - mass.player_queues.play_media.assert_awaited_once() - - async def test_skips_power_when_already_on(self) -> None: - """Player already powered=True does not get cmd_power.""" - mass = _make_mass() - player = MagicMock() - player.supported_features = {"power"} - player.powered = True - mass.players.get_player = MagicMock(return_value=player) - await play_for_alice(mass, "p1", "library://track/1", radio_mode=False) - mass.players.cmd_power.assert_not_awaited() - - async def test_radio_mode_passed_through(self) -> None: - """radio_mode=True is forwarded to play_media.""" - mass = _make_mass() - await play_for_alice(mass, "p1", "library://artist/1", radio_mode=True) - mass.player_queues.play_media.assert_awaited_once_with( - queue_id="p1", media="library://artist/1", radio_mode=True - ) From e37cd368d98dec5ba584b85c5ec81c3f7e838d3a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 May 2026 16:32:03 +0000 Subject: [PATCH 46/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v2.1.0 --- .../providers/yandex_smarthome/__init__.py | 332 +++++++++++++++- .../_smarthome_auto_create.py | 135 +++++++ .../providers/yandex_smarthome/constants.py | 10 + .../yandex_smarthome/ma_authenticator.py | 366 ++++++++++++++++++ .../providers/yandex_smarthome/manifest.json | 2 +- requirements_all.txt | 2 +- .../yandex_smarthome/test_ma_authenticator.py | 109 ++++++ .../test_smarthome_auto_create.py | 149 +++++++ 8 files changed, 1086 insertions(+), 19 deletions(-) create mode 100644 music_assistant/providers/yandex_smarthome/_smarthome_auto_create.py create mode 100644 music_assistant/providers/yandex_smarthome/ma_authenticator.py create mode 100644 tests/providers/yandex_smarthome/test_ma_authenticator.py create mode 100644 tests/providers/yandex_smarthome/test_smarthome_auto_create.py diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index 27b49138d8..7c5ab15831 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -20,6 +20,7 @@ import asyncio import contextlib +import dataclasses import logging import uuid from typing import TYPE_CHECKING, cast @@ -27,12 +28,26 @@ import aiohttp from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption from music_assistant_models.enums import ConfigEntryType, ProviderFeature -from ya_dialogs_api import SecretStr +from ya_dialogs_api import ( + SMART_HOME_CHANNEL, + SecretStr, + SkillCreationArtifacts, + SkillCreationState, + auto_create_skill, + dump_artifacts, + load_artifacts, + load_default_logo_bytes, +) +from ._smarthome_auto_create import derive_smart_home_urls, resolve_base_url from .cloud import get_cloud_otp, register_cloud_instance from .constants import ( + CONF_ACTION_AUTO_CREATE, CONF_ACTION_GET_OTP, CONF_ACTION_REGISTER, + CONF_AUTH_X_TOKEN, + CONF_AUTO_CREATE_ARTIFACTS, + CONF_AUTO_CREATE_SESSION_ID, CONF_CLOUD_CONNECTION_TOKEN, CONF_CLOUD_INSTANCE_ID, CONF_CLOUD_INSTANCE_PASSWORD, @@ -50,6 +65,7 @@ CONNECTION_TYPE_DIRECT, MAX_INPUT_SOURCES, ) +from .ma_authenticator import make_authenticator from .playlists import fetch_playlist_options from .plugin import YandexSmartHomePlugin @@ -142,12 +158,141 @@ def _resolve_external_base_url( return fallback.strip().rstrip("/") +def _resolve_cached_x_token( + mass: MusicAssistant, + instance_id: str | None, + values: dict[str, ConfigValueType], +) -> str: + """Return the cached Yandex Passport x_token, or empty string if absent. + + Like the other secret resolvers, prefers the persisted SECURE_STRING + from saved config since the frontend does not echo secrets back into + ``values`` on re-open. + """ + if instance_id: + prov = mass.get_provider(instance_id) + if prov and prov.config: + saved = prov.config.get_value(CONF_AUTH_X_TOKEN) + if saved: + return str(saved) + return str(values.get(CONF_AUTH_X_TOKEN) or "") + + +async def _run_auto_create_action( + mass: MusicAssistant, + values: dict[str, ConfigValueType], + connection_type: str, + instance_id: str | None, +) -> None: + """Run the smart-home auto-create skill pipeline. + + Never re-raises: failures are persisted in artifacts.last_error so the + UI can render the message on the next form open. + """ + artifacts_raw = values.get(CONF_AUTO_CREATE_ARTIFACTS) + artifacts = load_artifacts(str(artifacts_raw) if artifacts_raw else None) + + # MA's frontend supplies values["session_id"] on every action invocation — + # AuthenticationHelper listens on that exact id to open and later close + # the popup. Generating our own UUID would tie the popup we open via + # auth_helper.send_url(...) to a channel nothing is listening on, leaving + # the user with a popup that doesn't appear or doesn't close. Fail loudly. + session_id_raw = values.get("session_id") + if not session_id_raw or not str(session_id_raw).strip(): + new_artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=( + "Missing session_id from the config-flow frontend. " + "Auto-create needs a session id to open the Device Code " + "popup; the action must be invoked through the MA UI, not " + "programmatically." + ), + ) + values[CONF_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) + _LOGGER.warning("auto-create invoked without frontend session_id") + return + session_id = str(session_id_raw).strip() + values[CONF_AUTO_CREATE_SESSION_ID] = session_id + + base_url = resolve_base_url(mass, str(values.get(CONF_EXTERNAL_BASE_URL) or "") or None) + + try: + urls = derive_smart_home_urls( + connection_type=connection_type, + base_url=base_url, + cloud_instance_id=str(values.get(CONF_CLOUD_INSTANCE_ID, "")), + direct_client_secret=_resolve_direct_client_secret(mass, instance_id, values), + ) + except ValueError as exc: + new_artifacts = dataclasses.replace( + artifacts, state=SkillCreationState.FAILED, last_error=str(exc) + ) + values[CONF_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) + _LOGGER.warning("auto-create precondition failed: %s", exc) + return + + def _cache_x_token(token: str) -> None: + values[CONF_AUTH_X_TOKEN] = token + + async def _persist_artifacts(a: SkillCreationArtifacts) -> None: + values[CONF_AUTO_CREATE_ARTIFACTS] = dump_artifacts(a) + + cached = _resolve_cached_x_token(mass, instance_id, values) or None + authenticator = make_authenticator( + mass=mass, + session_id=session_id, + cached_x_token=cached, + on_token_obtained=_cache_x_token, + ) + + try: + new_artifacts = await auto_create_skill( + authenticator=authenticator, + skill_name=str(values.get(CONF_INSTANCE_NAME) or "Music Assistant"), + artifacts=artifacts, + backend_uri=urls.backend_uri, + oauth_authorize_url=urls.oauth_authorize_url, + oauth_token_url=urls.oauth_token_url, + oauth_client_id=urls.oauth_client_id, + oauth_client_secret=urls.oauth_client_secret, + logo_bytes=load_default_logo_bytes(), + channel=SMART_HOME_CHANNEL, + progress_cb=_persist_artifacts, + ) + except asyncio.CancelledError: + raise + except Exception as exc: # defensive — never crash the config form + # Use type-name + str(exc) instead of repr(exc): repr() of e.g. + # aiohttp.ClientResponseError includes request_info (URL, headers) + # which can leak into the UI-visible artifacts.last_error. The full + # traceback is captured via _LOGGER.exception below. + msg = str(exc).strip() or type(exc).__name__ + # Cap the surfaced message so a runaway exception body can't bloat + # the round-tripped artifacts blob. + if len(msg) > 500: + msg = msg[:497] + "..." + new_artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=f"{type(exc).__name__}: {msg}", + ) + _LOGGER.exception("auto-create hit unexpected error") + + values[CONF_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) + if new_artifacts.state == SkillCreationState.DONE and new_artifacts.skill_id: + # Only set CONF_SKILL_ID on full success so the runtime doesn't + # try to use a half-built skill mid-pipeline. + values[CONF_SKILL_ID] = new_artifacts.skill_id + + async def _handle_config_actions( mass: MusicAssistant, action: str | None, values: dict[str, ConfigValueType], instance_id: str | None, is_cloud_plus: bool, + connection_type: str, ) -> str | None: """Execute config-flow actions; return OTP code if obtained, else None.""" saved_config = None @@ -183,6 +328,9 @@ async def _handle_config_actions( except Exception: _LOGGER.exception("Failed to get OTP code") + if action == CONF_ACTION_AUTO_CREATE: + await _run_auto_create_action(mass, values, connection_type, instance_id) + return otp_code @@ -215,13 +363,20 @@ async def get_config_entries( is_cloud_plus = connection_type == CONNECTION_TYPE_CLOUD_PLUS is_direct = connection_type == CONNECTION_TYPE_DIRECT - otp_code = await _handle_config_actions(mass, action, values, instance_id, is_cloud_plus) + otp_code = await _handle_config_actions( + mass, action, values, instance_id, is_cloud_plus, connection_type + ) is_registered = bool(values.get(CONF_CLOUD_INSTANCE_ID)) and bool( values.get(CONF_CLOUD_CONNECTION_TOKEN) ) label_text = _build_status_label(otp_code, is_cloud_plus, is_registered) + # Load current auto-create artifacts for state-aware button labels. + artifacts_raw = values.get(CONF_AUTO_CREATE_ARTIFACTS) + artifacts_str = str(artifacts_raw) if artifacts_raw else None + artifacts = load_artifacts(artifacts_str) + player_options = await _list_player_options(mass) playlist_options: list[ConfigValueOption] = [] try: @@ -274,14 +429,103 @@ async def get_config_entries( if is_cloud: entries.extend(_cloud_mode_entries(label_text, otp_code, is_registered)) elif is_cloud_plus: - entries.extend(_cloud_plus_mode_entries(label_text, otp_code, is_registered, values)) + entries.extend( + _cloud_plus_mode_entries(label_text, otp_code, is_registered, values, artifacts) + ) elif is_direct: - entries.extend(_direct_mode_entries(mass, instance_id, values)) + entries.extend(_direct_mode_entries(mass, instance_id, values, artifacts)) entries.extend(_common_tail_entries(player_options, playlist_options, values)) return tuple(entries) +def _build_auto_create_status( + artifacts: SkillCreationArtifacts, + skill_id_already_set: bool, +) -> tuple[str, str]: + """Return (status_label, action_button_label) based on artifacts state. + + The action button label flips based on what makes sense to do next: + fresh attempt → 'Create…', resumable failure → 'Retry…', + in-progress → 'Continue…', success → 'Re-create…'. + """ + state = artifacts.state + if state == SkillCreationState.DONE and (artifacts.skill_id or skill_id_already_set): + skill_id = artifacts.skill_id or "" + return ( + f"✅ Smart Home skill registered (skill_id={skill_id}). " + "Click 'Re-create' below to provision a fresh skill in your " + "Yandex account.", + "Re-create skill", + ) + if state == SkillCreationState.FAILED: + err = (artifacts.last_error or "").strip() + if err: + return ( + f"❌ Last attempt failed: {err}\n\n" + "Click 'Retry' to resume from the last completed step.", + "Retry", + ) + return ( + "Click 'Create Smart Home skill' to register a private skill in " + "your Yandex account programmatically (Device Flow OAuth login, " + "then automated skill provisioning).", + "Create Smart Home skill", + ) + if state in ( + SkillCreationState.APP_CREATED, + SkillCreationState.DRAFT_UPDATED, + SkillCreationState.OAUTH_CREATED, + SkillCreationState.OAUTH_ATTACHED, + SkillCreationState.DEPLOY_REQUESTED, + ): + return ( + f"🔄 Pipeline in progress (state: {state.value}). " + "Click 'Continue' to resume from this step.", + "Continue", + ) + # NONE or any other unexpected state: fresh start. + return ( + "Click 'Create Smart Home skill' to register a private skill in " + "your Yandex account programmatically (Device Flow OAuth login, " + "then automated skill provisioning).", + "Create Smart Home skill", + ) + + +def _auto_create_entries( + artifacts: SkillCreationArtifacts, + *, + skill_id_already_set: bool, + depends_on_value: str, +) -> list[ConfigEntry]: + """Auto-create button + state-aware status label, gated by connection_type.""" + status_text, button_label = _build_auto_create_status(artifacts, skill_id_already_set) + return [ + ConfigEntry( + key=f"label_auto_create_status_{depends_on_value}", + type=ConfigEntryType.LABEL, + label=status_text, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=depends_on_value, + ), + ConfigEntry( + key=CONF_ACTION_AUTO_CREATE, + type=ConfigEntryType.ACTION, + label="Auto-create Smart Home skill", + description=( + "Sign in via Yandex Passport (Device Flow) and provision the " + "skill at dialogs.yandex.ru programmatically. The skill_id " + "field below populates on success. After creation you still " + "need to paste the skill OAuth token from the dev console " + "(see https://yandex.ru/dev/dialogs/smart-home/doc/en/concepts/oauth)." + ), + action=CONF_ACTION_AUTO_CREATE, + action_label=button_label, + ), + ] + + def _cloud_mode_entries( label_text: str, otp_code: str | None, is_registered: bool ) -> list[ConfigEntry]: @@ -343,8 +587,10 @@ def _cloud_plus_mode_entries( otp_code: str | None, is_registered: bool, values: dict[str, ConfigValueType], + artifacts: SkillCreationArtifacts, ) -> list[ConfigEntry]: - """Cloud Plus: register + manual skill_id/skill_token from dev console.""" + """Cloud Plus: register + auto-create or manual skill_id/skill_token.""" + skill_id_set = bool(values.get(CONF_SKILL_ID)) return [ ConfigEntry( key="label_status_cp", @@ -359,11 +605,12 @@ def _cloud_plus_mode_entries( label=( "Cloud Plus uses a private skill in your Yandex developer " "account. Steps: 1) Click 'Register with cloud' below to " - "provision a yaha-cloud relay slot. 2) Create a private " - "Smart Home skill at https://dialogs.yandex.ru/developer " - "and paste the skill ID + OAuth token below. 3) Click " - "'Get OTP code' and enter it in the Yandex app to finish " - "linking." + "provision a yaha-cloud relay slot. 2) Click 'Create Smart " + "Home skill' to provision the skill automatically (Device " + "Flow login, then automated skill creation), OR create one " + "manually at https://dialogs.yandex.ru/developer and paste " + "the skill ID + OAuth token below. 3) Click 'Get OTP code' " + "and enter it in the Yandex app to finish linking." ), depends_on=CONF_CONNECTION_TYPE, depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, @@ -388,12 +635,20 @@ def _cloud_plus_mode_entries( action_label="Register with cloud", hidden=is_registered, ), + *_auto_create_entries( + artifacts, + skill_id_already_set=skill_id_set, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + ), ConfigEntry( key=CONF_SKILL_ID, type=ConfigEntryType.STRING, label="Skill ID", description=( - "UUID from the dev console URL (https://dialogs.yandex.ru/developer/skills/)." + "UUID from the dev console URL " + "(https://dialogs.yandex.ru/developer/skills/). " + "Populated automatically after 'Create Smart Home skill' " + "succeeds; set manually if you created the skill yourself." ), required=False, value=cast("str", values.get(CONF_SKILL_ID)) if values else None, @@ -431,8 +686,9 @@ def _direct_mode_entries( mass: MusicAssistant, instance_id: str | None, values: dict[str, ConfigValueType], + artifacts: SkillCreationArtifacts, ) -> list[ConfigEntry]: - """Direct mode: HTTPS callback URL + skill_id/skill_token from dev console.""" + """Direct mode: HTTPS callback URL + auto-create or manual skill_id/skill_token.""" direct_secret = _resolve_direct_client_secret(mass, instance_id, values) if not direct_secret: direct_secret = uuid.uuid4().hex @@ -448,6 +704,7 @@ def _direct_mode_entries( "Assistant Ingress and exposing a public URL globally would break " f"local access. Leave empty to use MA's Base URL ({ma_global_base_url or ''})." ) + skill_id_set = bool(values.get(CONF_SKILL_ID)) return [ ConfigEntry( @@ -456,10 +713,12 @@ def _direct_mode_entries( label=( "Direct mode points Yandex straight at this MA instance. " "Steps: 1) Set the External Base URL below to a public HTTPS " - "URL (Yandex requires HTTPS). 2) Create a Smart Home skill " - "at https://dialogs.yandex.ru/developer with the Backend URL " - "shown after first save. 3) Paste the skill ID + OAuth token " - "from the dev console below." + "URL (Yandex requires HTTPS). 2) Click 'Create Smart Home " + "skill' to provision the skill automatically (Device Flow " + "login, then automated skill creation), OR create one " + "manually at https://dialogs.yandex.ru/developer with the " + "Backend URL shown after first save. 3) Paste the OAuth " + "token from the dev console below." ), depends_on=CONF_CONNECTION_TYPE, depends_on_value=CONNECTION_TYPE_DIRECT, @@ -475,11 +734,20 @@ def _direct_mode_entries( depends_on=CONF_CONNECTION_TYPE, depends_on_value=CONNECTION_TYPE_DIRECT, ), + *_auto_create_entries( + artifacts, + skill_id_already_set=skill_id_set, + depends_on_value=CONNECTION_TYPE_DIRECT, + ), ConfigEntry( key=CONF_SKILL_ID, type=ConfigEntryType.STRING, label="Skill ID", - description="UUID from your skill's dev console URL.", + description=( + "UUID from your skill's dev console URL. Populated " + "automatically after 'Create Smart Home skill' succeeds; " + "set manually if you created the skill yourself." + ), required=False, value=cast("str", values.get(CONF_SKILL_ID)) if values else None, depends_on=CONF_CONNECTION_TYPE, @@ -579,4 +847,34 @@ def _common_tail_entries( required=False, value=(cast("str", values.get(CONF_DIRECT_ACCESS_TOKEN)) if values else None), ), + # Auto-create-skill state — JSON-serialised SkillCreationArtifacts. + # Round-tripped through every config-flow render so the state machine + # survives popup cycles and partial failures. + ConfigEntry( + key=CONF_AUTO_CREATE_ARTIFACTS, + type=ConfigEntryType.STRING, + label="Auto-create artifacts (internal)", + hidden=True, + required=False, + value=(cast("str", values.get(CONF_AUTO_CREATE_ARTIFACTS)) if values else None), + ), + ConfigEntry( + key=CONF_AUTO_CREATE_SESSION_ID, + type=ConfigEntryType.STRING, + label="Auto-create session id (internal)", + hidden=True, + required=False, + value=(cast("str", values.get(CONF_AUTO_CREATE_SESSION_ID)) if values else None), + ), + # Cached Yandex Passport x_token — populated after the first + # successful auto-create Device Flow and reused on subsequent + # auto-create runs to skip the device-code prompt. + ConfigEntry( + key=CONF_AUTH_X_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Yandex Passport x_token (cached)", + hidden=True, + required=False, + value=(cast("str", values.get(CONF_AUTH_X_TOKEN)) if values else None), + ), ] diff --git a/music_assistant/providers/yandex_smarthome/_smarthome_auto_create.py b/music_assistant/providers/yandex_smarthome/_smarthome_auto_create.py new file mode 100644 index 0000000000..e79d928c0d --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/_smarthome_auto_create.py @@ -0,0 +1,135 @@ +"""Smart-home auto-create-skill helpers — URL derivation + preconditions. + +Equivalent of the deleted ``derive_backend_uri`` / ``derive_auth_urls`` / +``derive_client_id`` / ``_resolve_base_url`` / ``check_preconditions`` +that lived inside the old ``provider/auto_skill.py``. Narrower scope: +smart-home only — drops the ``skill_type="dialog"`` branch (that's alice's +concern, lives in trudenboy/ma-provider-yandex-alice). + +These derivations live in the provider, not in the lib, because +``connection_type`` and the cloud-relay/direct-mode protocols are +Music-Assistant-specific concepts. ``ya-dialogs-api`` accepts the +already-computed URLs as plain string parameters. +""" + +from __future__ import annotations + +import contextlib +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from .constants import ( + CLOUD_OAUTH_AUTHORIZE_URL, + CLOUD_OAUTH_TOKEN_URL, + CLOUD_SKILL_CLIENT_ID_TEMPLATE, + CLOUD_SKILL_CLIENT_SECRET, + CLOUD_SKILL_WEBHOOK_TEMPLATE, + CONNECTION_TYPE_CLOUD_PLUS, + CONNECTION_TYPE_DIRECT, + DIRECT_AUTH_BASE_PATH, + DIRECT_BACKEND_URI_PATH, + DIRECT_OAUTH_CLIENT_ID, +) + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +@dataclass(frozen=True, slots=True) +class SmartHomeUrls: + """The five pre-computed values ya_dialogs_api.auto_create_skill needs. + + Pass these straight into ``auto_create_skill`` — they correspond to its + ``backend_uri`` / ``oauth_authorize_url`` / ``oauth_token_url`` / + ``oauth_client_id`` / ``oauth_client_secret`` keyword arguments. + """ + + backend_uri: str + oauth_authorize_url: str + oauth_token_url: str + oauth_client_id: str + oauth_client_secret: str + + +def resolve_base_url(mass: MusicAssistant, override: str | None) -> str: + """Pick override over ``mass.webserver.base_url``; strip trailing slashes. + + ``override`` is the user-set ``CONF_EXTERNAL_BASE_URL``. Lets users keep + MA's global Base URL pointing at the local address (so HA Ingress / local + UI keep working) while exposing a public HTTPS URL only to Yandex via a + reverse proxy. + """ + if override and override.strip(): + return override.strip().rstrip("/") + fallback = "" + with contextlib.suppress(Exception): + fallback = str(mass.webserver.base_url) + return fallback.strip().rstrip("/") + + +def derive_smart_home_urls( + *, + connection_type: str, + base_url: str, + cloud_instance_id: str, + direct_client_secret: str, +) -> SmartHomeUrls: + """Compute the five pre-computed values for ``auto_create_skill``. + + cloud_plus + Backend uses :data:`CLOUD_SKILL_WEBHOOK_TEMPLATE` (the yaha-cloud + relay). OAuth uses yaha-cloud's own authorize/token endpoints. + ``client_id`` is ``yandex_smart_home:{instance_id}``; + ``client_secret`` is the literal ``"secret"`` required by the + relay protocol. + + direct + Backend = ``base_url + DIRECT_BACKEND_URI_PATH``. OAuth uses MA's + own ``/api/yandex_smarthome/auth/{authorize,token}`` endpoints + served by ``provider/direct.py``. ``client_id`` is the social + redirect base (Yandex's existing OAuth client expects this exact + value); ``client_secret`` is the per-install random UUID minted on + first save. + + Raises: + ValueError: ``cloud_plus`` without a registered cloud instance + id; ``direct`` without a client secret or with a non-HTTPS + base URL; any other ``connection_type``. + """ + if connection_type == CONNECTION_TYPE_CLOUD_PLUS: + if not cloud_instance_id: + msg = ( + "Cloud Plus requires a registered yaha-cloud instance first. " + "Use the 'Register with cloud' action." + ) + raise ValueError(msg) + return SmartHomeUrls( + backend_uri=CLOUD_SKILL_WEBHOOK_TEMPLATE, + oauth_authorize_url=CLOUD_OAUTH_AUTHORIZE_URL, + oauth_token_url=CLOUD_OAUTH_TOKEN_URL, + oauth_client_id=CLOUD_SKILL_CLIENT_ID_TEMPLATE.format(instance_id=cloud_instance_id), + oauth_client_secret=CLOUD_SKILL_CLIENT_SECRET, + ) + if connection_type == CONNECTION_TYPE_DIRECT: + if not direct_client_secret: + msg = "Direct mode requires a generated Client Secret" + raise ValueError(msg) + if not base_url.startswith("https://"): + msg = ( + "Direct mode requires MA to be reachable over HTTPS from " + f"the public internet (got base_url={base_url!r}). Yandex " + "rejects skills with non-HTTPS backends." + ) + raise ValueError(msg) + return SmartHomeUrls( + backend_uri=f"{base_url}{DIRECT_BACKEND_URI_PATH}", + oauth_authorize_url=f"{base_url}{DIRECT_AUTH_BASE_PATH}/authorize", + oauth_token_url=f"{base_url}{DIRECT_AUTH_BASE_PATH}/token", + oauth_client_id=DIRECT_OAUTH_CLIENT_ID, + oauth_client_secret=direct_client_secret, + ) + msg = ( + f"auto-create is not supported for connection_type={connection_type!r}; " + "use cloud_plus or direct." + ) + raise ValueError(msg) diff --git a/music_assistant/providers/yandex_smarthome/constants.py b/music_assistant/providers/yandex_smarthome/constants.py index 0b6d0e91f8..4ec1b1f688 100644 --- a/music_assistant/providers/yandex_smarthome/constants.py +++ b/music_assistant/providers/yandex_smarthome/constants.py @@ -20,11 +20,21 @@ CONF_EXPOSED_PLAYERS = "exposed_players" CONF_EXPOSED_PLAYLISTS = "exposed_playlists" +# Auto-create-skill feature state (round-trips through the config form) +CONF_AUTO_CREATE_ARTIFACTS = "auto_create_artifacts" +CONF_AUTO_CREATE_SESSION_ID = "auto_create_session_id" +# Cached Yandex Passport x_token from the first successful Device Flow. +# Reused on subsequent auto-create runs so the user does not have to confirm +# the device code every time. Long-lived (months); automatically refreshed +# on use. Cleared if Yandex returns 401 on refresh. +CONF_AUTH_X_TOKEN = "auth_x_token" + # --------------------------------------------------------------------------- # Config actions # --------------------------------------------------------------------------- CONF_ACTION_REGISTER = "register_cloud" CONF_ACTION_GET_OTP = "get_otp" +CONF_ACTION_AUTO_CREATE = "auto_create_skill" # --------------------------------------------------------------------------- # Connection types diff --git a/music_assistant/providers/yandex_smarthome/ma_authenticator.py b/music_assistant/providers/yandex_smarthome/ma_authenticator.py new file mode 100644 index 0000000000..d78e762285 --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/ma_authenticator.py @@ -0,0 +1,366 @@ +"""Music-Assistant Device-Flow authenticator for ya-dialogs-api. + +Adapter that wraps :class:`ya_passport_auth.PassportClient` Device Flow +behind the :data:`ya_dialogs_api.AuthenticatorCM` Protocol — a no-arg +async-context-manager factory yielding an authorized +``aiohttp.ClientSession``. + +UX: +- Hosts a temporary HTML activation page on ``mass.webserver`` showing the + Device Code prominently (Yandex's ya.ru/device strips query params on + the redirect-to-login, so we cannot pre-fill the code there). +- Opens the page in a popup via ``music_assistant.helpers.auth.AuthenticationHelper``. +- After the user enters the code at ya.ru/device, the page polls a status + endpoint and self-closes on success. + +Cache fast-path: +- If a valid cached ``x_token`` is provided, we skip Device Flow entirely + and call ``refresh_passport_cookies`` directly. On any failure during + refresh we fall back to a fresh Device Flow. + +Body is ported verbatim from the deleted ``provider/auto_skill.py:_default_authenticator``; +the only structural change is the wrapper — async-iterator → ``@asynccontextmanager`` +to match the lib's :data:`AuthenticatorCM` Protocol. + +Pattern originally adapted from ``ma-provider-yandex-station/provider/auth.py``. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Callable + + import aiohttp + from ya_dialogs_api import AuthenticatorCM + + from music_assistant.mass import MusicAssistant + + +_LOGGER = logging.getLogger(__name__) + +# Hard cap on how long we'll wait for the user to enter the code. +DEVICE_FLOW_TIMEOUT_SECONDS = 300.0 + +_DEVICE_CODE_PAGE_PATH = "/yandex_smarthome/device_code" +# Keep the intermediate HTML page alive long enough for the browser to +# observe the done/failed state transition. The page polls every 2s +# (see _build_device_code_page → setTimeout(pollStatus, 2000)), so we +# need at least one full poll interval + RTT margin after flipping the +# server-side state, otherwise the route gets unregistered before the +# page can fetch the final state and self-close. +_POST_AUTH_GRACE_SECONDS = 3 +# Server-suggested interval from Yandex is 5s (RFC 8628) but after the +# user has confirmed the code we want to detect it promptly; 2s is the +# RFC-recommended minimum. If Yandex returns SLOW_DOWN, ya-passport-auth +# bumps the interval automatically. +_DEVICE_FLOW_POLL_INTERVAL = 2.0 +_SAFE_SESSION_ID_RE = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z") + + +def make_authenticator( # noqa: PLR0915 + *, + mass: MusicAssistant, + session_id: str, + timeout: float = DEVICE_FLOW_TIMEOUT_SECONDS, + cached_x_token: str | None = None, + on_token_obtained: Callable[[str], None] | None = None, +) -> AuthenticatorCM: + """Build an :data:`AuthenticatorCM` for ``ya_dialogs_api.auto_create_skill``. + + The returned no-arg callable produces an ``aiohttp.ClientSession`` + context manager. On ``__aenter__`` it either: + + 1. Reuses ``cached_x_token`` (``refresh_passport_cookies`` fast-path), or + 2. Runs the full Device Flow: registers an HTML activation page on + ``mass.webserver``, opens it via ``AuthenticationHelper(mass, session_id)``, + polls Yandex Passport, then refreshes passport cookies. + + After a successful Device Flow, ``on_token_obtained(x_token_str)`` is + called so the caller can persist the new token for the next run. + Callback failures are logged but never break authentication. + + Args: + mass: MusicAssistant runtime — used for ``mass.webserver`` route + registration and ``AuthenticationHelper`` popup management. + session_id: Frontend-supplied session id (matches what ``AuthenticationHelper`` + listens on for popup open/close). Must be safe for URL paths. + timeout: Hard cap on Device Flow polling (seconds). Default 5 min. + cached_x_token: Optional Yandex Passport ``x_token`` from a prior + Device Flow. If still valid, skips Device Flow entirely. + on_token_obtained: Optional callback invoked with the fresh + ``x_token`` (plain ``str``, unwrapped from ``SecretStr``) after + a successful Device Flow. Use to persist into MA config so the + next run can use the cache. + + Raises: + ValueError: ``session_id`` doesn't match the safe character set. + """ + if not _SAFE_SESSION_ID_RE.match(session_id): + msg = "invalid session_id for device authentication" + raise ValueError(msg) + + @asynccontextmanager + async def _cm() -> AsyncIterator[aiohttp.ClientSession]: # noqa: PLR0915 + # Imports kept inline so the module can be imported without MA in test envs. + from aiohttp import web # noqa: PLC0415 + from ya_passport_auth import ClientConfig, PassportClient # noqa: PLC0415 + from ya_passport_auth.config import DEFAULT_ALLOWED_HOSTS # noqa: PLC0415 + from ya_passport_auth.credentials import SecretStr as PpSecretStr # noqa: PLC0415 + + from music_assistant.helpers.auth import AuthenticationHelper # noqa: PLC0415 + + allowed = DEFAULT_ALLOWED_HOSTS | frozenset({"dialogs.yandex.ru"}) + config = ClientConfig(allowed_hosts=allowed) + + async with PassportClient.create(config=config) as client: + # Cache fast-path: try cached x_token first. If the token is + # still valid Yandex returns fresh session cookies and we skip + # Device Flow. + if cached_x_token: + try: + await client.refresh_passport_cookies(PpSecretStr(cached_x_token)) + _LOGGER.info( + "auto-skill: reused cached Yandex Passport x_token (no Device Flow needed)" + ) + yield client._session + return + except asyncio.CancelledError: + raise + except Exception as exc: + _LOGGER.info( + "auto-skill: cached x_token rejected (%s) — " + "falling back to fresh Device Flow", + exc, + ) + + # Device Flow path + device_session = await client.start_device_login() + # Don't log user_code — it's a time-limited credential and writing + # it to shared log backends would leak access. + _LOGGER.info( + "device flow started — verification_url=%s", + device_session.verification_url, + ) + + page_path = f"{_DEVICE_CODE_PAGE_PATH}/{session_id}" + status_path = f"{page_path}/status" + base_url = str(mass.webserver.base_url).rstrip("/") + status_url = f"{base_url}{status_path}" + page_url = f"{base_url}{page_path}" + state = {"value": "pending"} + + page_html = _build_device_code_page( + device_session.user_code, + device_session.verification_url, + status_url, + ) + + async def _serve_page(_request: web.Request) -> web.Response: + return web.Response( + text=page_html, + content_type="text/html", + charset="utf-8", + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + "Expires": "0", + }, + ) + + async def _serve_status(_request: web.Request) -> web.Response: + return web.json_response( + {"state": state["value"]}, + headers={"Cache-Control": "no-store"}, + ) + + mass.webserver.register_dynamic_route(page_path, _serve_page, "GET") + mass.webserver.register_dynamic_route(status_path, _serve_status, "GET") + _LOGGER.warning( + "auto-skill: device-code popup URL %s (path=%s) " + "— if the popup does not open or points at an unreachable " + "address, open the path directly in your browser (the page " + "displays the user_code) or fix Settings → Core → Webserver " + "→ Base URL", + page_url, + page_path, + ) + try: + async with AuthenticationHelper(mass, session_id) as auth_helper: + auth_helper.send_url(page_url) + try: + creds = await client.poll_device_until_confirmed( + device_session, + total_timeout=timeout, + poll_interval=_DEVICE_FLOW_POLL_INTERVAL, + ) + except asyncio.CancelledError: + raise + except Exception: + state["value"] = "failed" + await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) + raise + state["value"] = "done" + await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) + finally: + mass.webserver.unregister_dynamic_route(page_path, "GET") + mass.webserver.unregister_dynamic_route(status_path, "GET") + + await client.refresh_passport_cookies(creds.x_token) + + # Persist the new x_token so subsequent auto-create runs can skip + # Device Flow. Best-effort: a callback failure must not break auth. + # Unwrap SecretStr → str so the callback can store it via the MA + # config plumbing (SECURE_STRING serialiser expects a plain str). + if on_token_obtained is not None: + try: + on_token_obtained(creds.x_token.get_secret()) + except Exception: + _LOGGER.exception( + "auto-skill: on_token_obtained callback failed; x_token will not be cached" + ) + + yield client._session + + return _cm + + +def _build_device_code_page(user_code: str, verification_url: str, status_url: str) -> str: + """Render the HTML page shown during Device Flow login. + + Yandex's ya.ru/device page does not pre-fill from query params and + strips them on redirect-to-login, so the only reliable way to show + the code is to host our own page in MA's webserver that displays + the code prominently and opens ya.ru/device in a new tab. + + Pattern copied from ``ma-provider-yandex-station/provider/auth.py``. + """ + import html # noqa: PLC0415 + + safe_code = html.escape(user_code) + safe_url = html.escape(verification_url, quote=True) + safe_status_url = json.dumps(status_url).replace(" + + + + Yandex Smart Home — Device Code + + + + +
+

Authorise Music Assistant for skill creation

+

Open the link below, log in to your Yandex account, and enter this code.

+
{safe_code}
+
+ +
+ Continue to Yandex +
+ + +""" diff --git a/music_assistant/providers/yandex_smarthome/manifest.json b/music_assistant/providers/yandex_smarthome/manifest.json index 7f3ccac72f..11e877788e 100644 --- a/music_assistant/providers/yandex_smarthome/manifest.json +++ b/music_assistant/providers/yandex_smarthome/manifest.json @@ -9,7 +9,7 @@ ], "requirements": [ "ya-passport-auth==1.3.0", - "ya-dialogs-api>=1.0.0" + "ya-dialogs-api==2.0.0" ], "documentation": "https://github.com/trudenboy/ma-provider-yandex-smarthome", "stage": "beta", diff --git a/requirements_all.txt b/requirements_all.txt index baaebc4001..2a9c9161ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -88,7 +88,7 @@ uv>=0.8.0 websocket-client==1.9.0 wiim==0.1.4 xmltodict==1.0.4 -ya-dialogs-api>=1.0.0 +ya-dialogs-api==2.0.0 ya-passport-auth==1.3.0 yandex-music==3.0.0 ytmusicapi==1.11.5 diff --git a/tests/providers/yandex_smarthome/test_ma_authenticator.py b/tests/providers/yandex_smarthome/test_ma_authenticator.py new file mode 100644 index 0000000000..e11f8a359b --- /dev/null +++ b/tests/providers/yandex_smarthome/test_ma_authenticator.py @@ -0,0 +1,109 @@ +"""Tests for provider/ma_authenticator.py. + +Scope is intentionally narrow — the inline-imported PassportClient + +AuthenticationHelper integration is verified end-to-end against a real +Yandex Passport account in V.4 of the rollout plan. Here we cover what +is fast, deterministic, and unit-testable without stubbing those upstream +packages: + +- session_id validation (the only synchronous gate inside ``make_authenticator``) +- HTML activation page rendering (escaping, JS payload safety) +""" + +from __future__ import annotations + +import pytest + +from music_assistant.providers.yandex_smarthome.ma_authenticator import ( + _build_device_code_page, + make_authenticator, +) + + +class TestSessionIdValidation: + """make_authenticator rejects unsafe session ids before doing anything else.""" + + def test_safe_id_accepted(self) -> None: + """Letters, digits, dashes, underscores, len <= 64 are allowed.""" + # Should not raise; we discard the returned factory. + make_authenticator( + mass=None, # type: ignore[arg-type] + session_id="abc-123_DEF", + ) + + def test_max_length_accepted(self) -> None: + """64-character ids are at the inclusive upper bound.""" + make_authenticator(mass=None, session_id="a" * 64) # type: ignore[arg-type] + + @pytest.mark.parametrize( + "bad", + [ + "", # empty + "a" * 65, # too long + "has spaces", + "../etc/passwd", # path traversal + "with/slash", + "with\\backslash", + "with;semicolon", + "", + verification_url="https://ya.ru/device", + status_url="https://ma.example.com/status", + ) + assert "" not in html + assert "<script>" in html + + def test_escapes_quotes_in_verification_url(self) -> None: + """Quote-escaping protects the href attribute.""" + html = _build_device_code_page( + user_code="X", + verification_url='https://ya.ru/device">', + status_url="https://ma.example.com/status", + ) + assert '">' not in html + # Either " or " depending on html.escape config + assert """ in html or """ in html + + def test_status_url_with_closing_script_tag_escaped(self) -> None: + """Defensive escape of sequence inside the JS string literal. + + Without the replace, a malicious status_url containing ```` would + prematurely close the surrounding ``", + ) + assert "" in html diff --git a/tests/providers/yandex_smarthome/test_smarthome_auto_create.py b/tests/providers/yandex_smarthome/test_smarthome_auto_create.py new file mode 100644 index 0000000000..9cb1d85dcd --- /dev/null +++ b/tests/providers/yandex_smarthome/test_smarthome_auto_create.py @@ -0,0 +1,149 @@ +"""Tests for provider/_smarthome_auto_create.py — URL derivations + helpers.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from music_assistant.providers.yandex_smarthome._smarthome_auto_create import ( + SmartHomeUrls, + derive_smart_home_urls, + resolve_base_url, +) +from music_assistant.providers.yandex_smarthome.constants import ( + CONNECTION_TYPE_CLOUD, + CONNECTION_TYPE_CLOUD_PLUS, + CONNECTION_TYPE_DIRECT, +) + + +class TestResolveBaseUrl: + """resolve_base_url picks override over mass.webserver.base_url.""" + + def test_override_wins(self) -> None: + """Explicit override is preferred over MA's global base_url.""" + mass = MagicMock() + mass.webserver.base_url = "https://internal.example.local" + assert resolve_base_url(mass, "https://public.example.com") == "https://public.example.com" + + def test_override_strips_trailing_slash(self) -> None: + """Trailing slash + whitespace are stripped before use.""" + mass = MagicMock() + mass.webserver.base_url = "https://x" + assert resolve_base_url(mass, "https://ma.example.com/ ") == "https://ma.example.com" + + def test_no_override_uses_mass_base_url(self) -> None: + """Empty override falls back to MA's webserver base URL.""" + mass = MagicMock() + mass.webserver.base_url = "https://ma.example.com/" + assert resolve_base_url(mass, None) == "https://ma.example.com" + + def test_empty_override_treated_as_none(self) -> None: + """Whitespace-only override is treated as no override.""" + mass = MagicMock() + mass.webserver.base_url = "https://ma.example.com" + assert resolve_base_url(mass, " ") == "https://ma.example.com" + + +class TestDeriveSmartHomeUrlsCloudPlus: + """cloud_plus mode → all five values come from yaha-cloud constants.""" + + def test_happy_path(self) -> None: + """All five URLs are pre-computed from constants + instance_id.""" + urls = derive_smart_home_urls( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + base_url="https://ma.example.com", # ignored for cloud_plus + cloud_instance_id="inst-abc-123", + direct_client_secret="ignored", + ) + assert isinstance(urls, SmartHomeUrls) + assert urls.backend_uri == "https://yaha-cloud.ru/api/yandex_smart_home" + assert urls.oauth_authorize_url == "https://yaha-cloud.ru/oauth/authorize" + assert urls.oauth_token_url == "https://yaha-cloud.ru/oauth/token" + assert urls.oauth_client_id == "yandex_smart_home:inst-abc-123" + assert urls.oauth_client_secret == "secret" # literal yaha-cloud protocol value + + def test_missing_instance_id_raises(self) -> None: + """cloud_plus without a registered instance is rejected.""" + with pytest.raises(ValueError, match="yaha-cloud instance"): + derive_smart_home_urls( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + base_url="https://x", + cloud_instance_id="", + direct_client_secret="", + ) + + +class TestDeriveSmartHomeUrlsDirect: + """direct mode → URLs computed from base_url + per-install secret.""" + + def test_happy_path(self) -> None: + """All five URLs match the direct-mode endpoint structure.""" + urls = derive_smart_home_urls( + connection_type=CONNECTION_TYPE_DIRECT, + base_url="https://ma.example.com", + cloud_instance_id="ignored", + direct_client_secret="abc-deadbeef", + ) + assert urls.backend_uri == "https://ma.example.com/api/yandex_smarthome" + assert ( + urls.oauth_authorize_url == "https://ma.example.com/api/yandex_smarthome/auth/authorize" + ) + assert urls.oauth_token_url == "https://ma.example.com/api/yandex_smarthome/auth/token" + assert urls.oauth_client_id == "https://social.yandex.net/" + assert urls.oauth_client_secret == "abc-deadbeef" + + def test_missing_secret_raises(self) -> None: + """Direct mode without per-install client secret is rejected.""" + with pytest.raises(ValueError, match="Client Secret"): + derive_smart_home_urls( + connection_type=CONNECTION_TYPE_DIRECT, + base_url="https://x", + cloud_instance_id="", + direct_client_secret="", + ) + + def test_non_https_base_url_raises(self) -> None: + """Yandex rejects non-HTTPS backends; we surface this client-side.""" + with pytest.raises(ValueError, match="HTTPS"): + derive_smart_home_urls( + connection_type=CONNECTION_TYPE_DIRECT, + base_url="http://insecure.example.com", + cloud_instance_id="", + direct_client_secret="abc", + ) + + def test_empty_base_url_raises(self) -> None: + """Empty base_url fails the HTTPS check.""" + with pytest.raises(ValueError, match="HTTPS"): + derive_smart_home_urls( + connection_type=CONNECTION_TYPE_DIRECT, + base_url="", + cloud_instance_id="", + direct_client_secret="abc", + ) + + +class TestDeriveSmartHomeUrlsUnsupported: + """Plain 'cloud' or any other connection_type is rejected.""" + + def test_cloud_rejected(self) -> None: + """Plain 'cloud' (public Yaha Cloud) doesn't go through auto-create.""" + with pytest.raises(ValueError, match="connection_type"): + derive_smart_home_urls( + connection_type=CONNECTION_TYPE_CLOUD, + base_url="https://x", + cloud_instance_id="i", + direct_client_secret="", + ) + + def test_unknown_rejected(self) -> None: + """Unrecognised connection_type produces a descriptive error.""" + with pytest.raises(ValueError, match="connection_type"): + derive_smart_home_urls( + connection_type="bogus", + base_url="https://x", + cloud_instance_id="i", + direct_client_secret="", + ) From 85c66ccc47dbf84fbcf3e0cdcade5f136bfe2fed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 06:32:27 +0000 Subject: [PATCH 47/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v2.1.1 --- .../providers/yandex_smarthome/__init__.py | 78 +++++++++-- .../providers/yandex_smarthome/notifier.py | 123 +++++++++++++----- .../providers/yandex_smarthome/plugin.py | 25 +++- .../yandex_smarthome/test_notifier.py | 69 +++++++++- 4 files changed, 245 insertions(+), 50 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index 7c5ab15831..5141786e3b 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -64,6 +64,7 @@ CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT, MAX_INPUT_SOURCES, + YANDEX_OAUTH_URL, ) from .ma_authenticator import make_authenticator from .playlists import fetch_playlist_options @@ -116,6 +117,27 @@ async def setup( return YandexSmartHomePlugin(mass, manifest, config, SUPPORTED_FEATURES) +def _is_skill_token_set( + mass: MusicAssistant, + instance_id: str | None, + values: dict[str, ConfigValueType], +) -> bool: + """Return True if a non-empty Skill OAuth token is persisted or in-flight. + + The token is a SECURE_STRING — the frontend doesn't echo it back into + ``values`` on re-open. Prefer the persisted provider-config value; fall + back to ``values`` for the very first save round-trip when nothing is + persisted yet. + """ + if instance_id: + prov = mass.get_provider(instance_id) + if prov and prov.config: + saved = prov.config.get_value(CONF_SKILL_TOKEN) + if saved: + return True + return bool(values.get(CONF_SKILL_TOKEN)) + + def _resolve_direct_client_secret( mass: MusicAssistant, instance_id: str | None, @@ -426,14 +448,18 @@ async def get_config_entries( ), ] + skill_token_set = _is_skill_token_set(mass, instance_id, values) + if is_cloud: entries.extend(_cloud_mode_entries(label_text, otp_code, is_registered)) elif is_cloud_plus: entries.extend( - _cloud_plus_mode_entries(label_text, otp_code, is_registered, values, artifacts) + _cloud_plus_mode_entries( + label_text, otp_code, is_registered, values, artifacts, skill_token_set + ) ) elif is_direct: - entries.extend(_direct_mode_entries(mass, instance_id, values, artifacts)) + entries.extend(_direct_mode_entries(mass, instance_id, values, artifacts, skill_token_set)) entries.extend(_common_tail_entries(player_options, playlist_options, values)) return tuple(entries) @@ -442,16 +468,34 @@ async def get_config_entries( def _build_auto_create_status( artifacts: SkillCreationArtifacts, skill_id_already_set: bool, + *, + skill_token_set: bool = True, ) -> tuple[str, str]: """Return (status_label, action_button_label) based on artifacts state. The action button label flips based on what makes sense to do next: fresh attempt → 'Create…', resumable failure → 'Retry…', in-progress → 'Continue…', success → 'Re-create…'. + + ``skill_token_set`` lets the success-state label nudge the user to paste + the OAuth token next — the auto-create pipeline only fills Skill ID; the + token comes from a separate OAuth flow (oauth.yandex.ru/authorize) that + the user does themselves via the help-link icon on the Skill OAuth Token + field. """ state = artifacts.state if state == SkillCreationState.DONE and (artifacts.skill_id or skill_id_already_set): skill_id = artifacts.skill_id or "" + if not skill_token_set: + return ( + f"✅ Smart Home skill registered (skill_id={skill_id}). " + "**Next step:** click the link icon next to *Skill OAuth " + "Token* below to open the Yandex OAuth page, approve access, " + "and paste the access_token value from the resulting URL " + "fragment into the field. State callbacks won't work until " + "this is done.", + "Re-create skill", + ) return ( f"✅ Smart Home skill registered (skill_id={skill_id}). " "Click 'Re-create' below to provision a fresh skill in your " @@ -497,10 +541,13 @@ def _auto_create_entries( artifacts: SkillCreationArtifacts, *, skill_id_already_set: bool, + skill_token_set: bool, depends_on_value: str, ) -> list[ConfigEntry]: """Auto-create button + state-aware status label, gated by connection_type.""" - status_text, button_label = _build_auto_create_status(artifacts, skill_id_already_set) + status_text, button_label = _build_auto_create_status( + artifacts, skill_id_already_set, skill_token_set=skill_token_set + ) return [ ConfigEntry( key=f"label_auto_create_status_{depends_on_value}", @@ -588,6 +635,7 @@ def _cloud_plus_mode_entries( is_registered: bool, values: dict[str, ConfigValueType], artifacts: SkillCreationArtifacts, + skill_token_set: bool, ) -> list[ConfigEntry]: """Cloud Plus: register + auto-create or manual skill_id/skill_token.""" skill_id_set = bool(values.get(CONF_SKILL_ID)) @@ -638,6 +686,7 @@ def _cloud_plus_mode_entries( *_auto_create_entries( artifacts, skill_id_already_set=skill_id_set, + skill_token_set=skill_token_set, depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, ), ConfigEntry( @@ -660,11 +709,14 @@ def _cloud_plus_mode_entries( type=ConfigEntryType.SECURE_STRING, label="Skill OAuth token", description=( - "OAuth token from " - "https://oauth.yandex.ru/authorize?response_type=token" - "&client_id=c473ca268cd749d3a8371351a8f2bcbd. " - "Used to push state callbacks to Yandex." + "Click the link icon next to this field to open the Yandex " + "OAuth page; approve access for 'Yandex.Dialogs', then copy " + "the access_token value from the resulting URL fragment " + "(after #access_token=) and paste it here. The token is " + "used to push state callbacks back to Yandex when MA " + "players change state." ), + help_link=YANDEX_OAUTH_URL, required=False, value=cast("str", values.get(CONF_SKILL_TOKEN)) if values else None, depends_on=CONF_CONNECTION_TYPE, @@ -687,6 +739,7 @@ def _direct_mode_entries( instance_id: str | None, values: dict[str, ConfigValueType], artifacts: SkillCreationArtifacts, + skill_token_set: bool, ) -> list[ConfigEntry]: """Direct mode: HTTPS callback URL + auto-create or manual skill_id/skill_token.""" direct_secret = _resolve_direct_client_secret(mass, instance_id, values) @@ -737,6 +790,7 @@ def _direct_mode_entries( *_auto_create_entries( artifacts, skill_id_already_set=skill_id_set, + skill_token_set=skill_token_set, depends_on_value=CONNECTION_TYPE_DIRECT, ), ConfigEntry( @@ -758,10 +812,14 @@ def _direct_mode_entries( type=ConfigEntryType.SECURE_STRING, label="Skill OAuth token", description=( - "OAuth token from " - "https://oauth.yandex.ru/authorize?response_type=token" - "&client_id=c473ca268cd749d3a8371351a8f2bcbd." + "Click the link icon next to this field to open the Yandex " + "OAuth page; approve access for 'Yandex.Dialogs', then copy " + "the access_token value from the resulting URL fragment " + "(after #access_token=) and paste it here. The token is " + "used to push state callbacks back to Yandex when MA " + "players change state." ), + help_link=YANDEX_OAUTH_URL, required=False, value=cast("str", values.get(CONF_SKILL_TOKEN)) if values else None, depends_on=CONF_CONNECTION_TYPE, diff --git a/music_assistant/providers/yandex_smarthome/notifier.py b/music_assistant/providers/yandex_smarthome/notifier.py index 8a10269e92..24c4881c97 100644 --- a/music_assistant/providers/yandex_smarthome/notifier.py +++ b/music_assistant/providers/yandex_smarthome/notifier.py @@ -37,6 +37,15 @@ _LOGGER = logging.getLogger(__name__) +class _CallbackErrorAlreadyLogged(RuntimeError): + """Sentinel for state-callback errors that have already been logged. + + Raised by ``_send_state_callback`` after dedupe-aware logging so the + outer exception handler can re-queue (via ``_flush_pending``) without + emitting a second log line. + """ + + class StateNotifier: """Watches MA player events and reports state changes to Yandex.""" @@ -67,12 +76,20 @@ def __init__( self._heartbeat_task: asyncio.Task[None] | None = None self._unsub: Callable[[], None] | None = None - # Track UNKNOWN_USER state — Yandex returns this until the user - # has linked the skill in the Yandex Smart Home / Alice mobile - # app via OAuth. We surface the first occurrence as a clear - # WARNING with instructions and then silence further errors at - # debug level so logs don't flood while linking is in progress. - self._unknown_user_warned: bool = False + # Dedupe state-callback errors by fingerprint. Yandex's backend + # returns transient HTTP 5xx for ~1-2 minutes after a freshly + # created skill (CDN warmup), then 400 + UNKNOWN_USER until the + # user links the skill in the mobile app. Without dedupe each + # 1 s flush logs a full traceback — flooding the log. First + # occurrence per fingerprint logs as follows: + # - UNKNOWN_USER, HTTP 5xx → WARNING (expected first-run state, + # no traceback — see _emit_callback_error) + # - transport / unexpected errors → ERROR + traceback (real + # bugs worth diagnostic detail — see outer except) + # Repeats with the same fingerprint drop to DEBUG until a + # different error class arrives or a successful callback resets + # the fingerprint (which then logs an INFO recovery line). + self._last_error_fingerprint: str | None = None async def start(self) -> None: """Subscribe to player events and start background tasks.""" @@ -191,14 +208,32 @@ async def _flush_pending(self) -> None: # State reporting # ----------------------------------------------------------------------- + def _emit_callback_error(self, fingerprint: str, warn_message: str) -> None: + """Log a state-callback error once per fingerprint, then DEBUG. + + Different fingerprint classes (UNKNOWN_USER, HTTP 5xx, transport + failures) each emit a single WARNING the first time they occur, + then drop to DEBUG until a different error class arrives or a + successful callback resets the fingerprint. + """ + if self._last_error_fingerprint == fingerprint: + self._logger.debug("State callback still failing (%s)", fingerprint) + return + self._last_error_fingerprint = fingerprint + self._logger.warning("%s", warn_message) + async def _send_state_callback(self, devices: list[DeviceState]) -> None: """POST state callback to Yandex. - Yandex returns ``UNKNOWN_USER`` (HTTP 400) until the user has - linked the skill in the Yandex Alice / Smart Home app. That is a - normal first-run state, not a code bug — we emit one WARNING with - linking instructions, then quiet down to debug level so logs - don't flood while linking is in progress. + Yandex's callback endpoint can fail three ways: HTTP 5xx while + the skill propagates through their CDN, HTTP 400 + UNKNOWN_USER + until the user links the skill in the mobile app, and + transport-level errors during network issues. All three are + deduped via ``_last_error_fingerprint`` so each class only logs + once per "episode" — UNKNOWN_USER and 5xx at WARNING (expected + first-run states), transport / unexpected errors at ERROR with + traceback (real bugs worth diagnostic detail). Repeats drop to + DEBUG until something changes. """ payload = CallbackRequest( ts=time.time(), @@ -211,41 +246,63 @@ async def _send_state_callback(self, devices: list[DeviceState]) -> None: headers=self._auth_header, ) as resp: if resp.status in (200, 202): - if self._unknown_user_warned: + if self._last_error_fingerprint is not None: self._logger.info( - "State callback succeeded — Yandex now recognizes the user " - "(account linking complete)" + "State callback recovered (was failing with %s)", + self._last_error_fingerprint, ) - self._unknown_user_warned = False + self._last_error_fingerprint = None self._logger.debug("State callback sent: %d device(s)", len(devices)) return body = await resp.text() if resp.status == 400 and "UNKNOWN_USER" in body: - if not self._unknown_user_warned: - self._logger.warning( - "Yandex returned UNKNOWN_USER for state callback — this means " - "the skill has not been linked to a Yandex account yet. " - "Open https://yandex.ru/quasar/iot or the «Дом с Алисой» app, " # noqa: RUF001 - "find the skill in Devices → +, and tap «Связать аккаунт». " - "State callback errors will be suppressed at debug level " - "until linking succeeds." - ) - self._unknown_user_warned = True - else: - self._logger.debug("State callback still UNKNOWN_USER (account not linked)") + self._emit_callback_error( + "unknown_user", + "Yandex returned UNKNOWN_USER for state callback — the skill is " + "not yet linked to a Yandex account. Open " + "https://yandex.ru/quasar/iot or the «Дом с Алисой» app, find " # noqa: RUF001 + "the skill in Devices → +, and tap «Связать аккаунт». Further " + "callback errors will be suppressed at debug level until linking " + "succeeds.", + ) return # silent — not a real error, don't raise + if 500 <= resp.status < 600: + # Transient Yandex backend issue — common for ~1-2 min + # after a freshly created skill while CDN propagates. + # Dedupe the WARNING but still raise so _flush_pending + # re-queues the dirty players for the next attempt. + self._emit_callback_error( + f"http_{resp.status}", + f"State callback failed with HTTP {resp.status} — Yandex backend " + "may be propagating a freshly created skill. Further callback " + "errors will be suppressed at debug level until the next " + f"successful callback. Body: {body[:200]}", + ) + raise _CallbackErrorAlreadyLogged( + f"State callback failed with HTTP {resp.status}" + ) + raise RuntimeError(f"State callback failed with HTTP {resp.status}: {body[:200]}") except asyncio.CancelledError: # Cooperative cancellation must propagate untouched. raise - except Exception: - # Includes RuntimeError above + transport-level errors - # (aiohttp.ClientError, DNS resolution failures, connection - # resets, etc.). Caller (_flush_pending) re-queues the dirty - # players for the next flush. - self._logger.exception("State callback error") + except _CallbackErrorAlreadyLogged: + # Already deduped via _emit_callback_error above — just propagate + # so _flush_pending re-queues without a second log entry. + raise + except Exception as exc: + # Transport-level errors (aiohttp.ClientError, DNS resolution + # failures, connection resets, etc.) plus the catch-all RuntimeError + # for non-5xx HTTP failures. Dedupe by exception class name so a + # repeat of the same transport failure doesn't flood the log. + fingerprint = type(exc).__name__ + if self._last_error_fingerprint == fingerprint: + self._logger.debug("State callback still failing (%s)", fingerprint) + else: + self._last_error_fingerprint = fingerprint + self._logger.exception("State callback error") raise async def _report_all_states(self) -> None: diff --git a/music_assistant/providers/yandex_smarthome/plugin.py b/music_assistant/providers/yandex_smarthome/plugin.py index 4727678ff6..f0c8636fdf 100644 --- a/music_assistant/providers/yandex_smarthome/plugin.py +++ b/music_assistant/providers/yandex_smarthome/plugin.py @@ -241,12 +241,17 @@ def _on_token_created(token: str) -> None: self._direct_handler.register_routes() # State notifier needs skill_id + skill_token to push state callbacks - # to Yandex — these only exist after a successful auto-create. Skip - # silently if missing; this is the normal "first run" state. - if self._skill_id and self._skill_token and self._skill_token.get_secret(): + # to Yandex — these only exist after a successful auto-create AND the + # user pasting the OAuth token. Skip silently if either is missing; + # this is the normal "first run" / "skill created but token not yet + # pasted" state. + has_skill_id = bool(self._skill_id) + skill_token = self._skill_token + has_skill_token = skill_token is not None and bool(skill_token.get_secret()) + if has_skill_id and has_skill_token and skill_token is not None: session = self.mass.http_session callback_url = f"{YANDEX_DIALOGS_CALLBACK_BASE}/{self._skill_id}/callback/state" - auth_header = {"Authorization": f"OAuth {self._skill_token.get_secret()}"} + auth_header = {"Authorization": f"OAuth {skill_token.get_secret()}"} self._state_notifier = StateNotifier( mass=self.mass, @@ -260,10 +265,18 @@ def _on_token_created(token: str) -> None: ) await self._state_notifier.start() else: + missing = [] + if not has_skill_id: + missing.append("Skill ID") + if not has_skill_token: + missing.append("Skill OAuth Token") self.logger.info( "Direct mode: HTTP routes registered, but state notifier is " - "idle (no skill_id/skill_token yet). Run 'Create Smart Home " - "skill' in the plugin settings to complete setup." + "idle (missing: %s). Open the plugin settings: 'Auto-create " + "Smart Home skill' fills the Skill ID for you, then open the " + "OAuth-token URL shown in the form, approve access, and paste " + "the resulting access_token into 'Skill OAuth Token'.", + " + ".join(missing), ) self.logger.info("Direct connection mode started") diff --git a/tests/providers/yandex_smarthome/test_notifier.py b/tests/providers/yandex_smarthome/test_notifier.py index f802035c08..3d3ecf4e1d 100644 --- a/tests/providers/yandex_smarthome/test_notifier.py +++ b/tests/providers/yandex_smarthome/test_notifier.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging from dataclasses import dataclass, field from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -13,7 +14,10 @@ # Use mock enums from conftest from music_assistant_models.enums import EventType, PlaybackState -from music_assistant.providers.yandex_smarthome.notifier import StateNotifier +from music_assistant.providers.yandex_smarthome.notifier import ( + StateNotifier, + _CallbackErrorAlreadyLogged, +) @dataclass @@ -421,6 +425,69 @@ async def test_rejects_http_500(self) -> None: # Player IDs should be re-queued after failure assert "p1" in notifier._dirty_player_ids + @pytest.mark.asyncio + async def test_http_5xx_dedupe_warns_once(self, caplog: pytest.LogCaptureFixture) -> None: + """Repeated HTTP 5xx logs WARNING once, then DEBUG on retries.""" + mock_resp = AsyncMock() + mock_resp.status = 500 + mock_resp.text = AsyncMock(return_value="Internal Server Error") + + session = MagicMock(spec=aiohttp.ClientSession) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=mock_resp) + ctx.__aexit__ = AsyncMock(return_value=False) + session.post.return_value = ctx + + player = MockPlayer(player_id="p1") + mass = _make_mass([player]) + mass.players.get_player = MagicMock(return_value=player) + notifier = _make_notifier(mass=mass, session=session) + + caplog.set_level(logging.DEBUG, logger=notifier._logger.name) + + # First failure → WARNING + with pytest.raises(_CallbackErrorAlreadyLogged): + await notifier._send_state_callback( + [MagicMock()] # devices payload — content irrelevant for this test + ) + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "HTTP 500" in warnings[0].message + + # Second failure with same fingerprint → DEBUG, no new WARNING + caplog.clear() + with pytest.raises(_CallbackErrorAlreadyLogged): + await notifier._send_state_callback([MagicMock()]) + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + debugs = [r for r in caplog.records if r.levelno == logging.DEBUG] + assert warnings == [] + assert any("still failing" in r.message for r in debugs) + + @pytest.mark.asyncio + async def test_recovery_logs_info_and_clears_fingerprint( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """A successful callback after a failure logs INFO and resets state.""" + # Pre-set the fingerprint to simulate prior failure + mass = _make_mass() + session = MagicMock(spec=aiohttp.ClientSession) + mock_resp = AsyncMock() + mock_resp.status = 200 + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=mock_resp) + ctx.__aexit__ = AsyncMock(return_value=False) + session.post.return_value = ctx + + notifier = _make_notifier(mass=mass, session=session) + notifier._last_error_fingerprint = "http_500" + + caplog.set_level(logging.INFO, logger=notifier._logger.name) + await notifier._send_state_callback([MagicMock()]) + + infos = [r for r in caplog.records if r.levelno == logging.INFO] + assert any("recovered" in r.message for r in infos) + assert notifier._last_error_fingerprint is None + @pytest.mark.asyncio async def test_discovery_url_cloud_plus(self) -> None: """Discovery URL should use replace('/state', '/discovery') for Dialogs API.""" From 5937e76d836c87f42f40d4fbe759fdc3341f6ae7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 07:09:41 +0000 Subject: [PATCH 48/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v2.1.2 --- .../providers/yandex_smarthome/__init__.py | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index 5141786e3b..8be86442a7 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -158,28 +158,6 @@ def _resolve_direct_client_secret( return str(values.get(CONF_DIRECT_CLIENT_SECRET) or "") -def _resolve_external_base_url( - mass: MusicAssistant, - values: dict[str, ConfigValueType] | None = None, -) -> str: - """Return the public-facing Base URL to use for Yandex callbacks/webhooks. - - Priority: - 1. ``CONF_EXTERNAL_BASE_URL`` from values (user-set plugin override) - 2. ``mass.webserver.base_url`` (MA's global setting) - Trailing slashes are stripped. - """ - override = "" - if values is not None: - override = str(values.get(CONF_EXTERNAL_BASE_URL) or "").strip() - if override: - return override.rstrip("/") - fallback = "" - with contextlib.suppress(Exception): - fallback = str(mass.webserver.base_url) - return fallback.strip().rstrip("/") - - def _resolve_cached_x_token( mass: MusicAssistant, instance_id: str | None, From 82db110611b27328c3a516095bd700fcf4574e2a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 16:19:55 +0000 Subject: [PATCH 49/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v2.1.3 --- music_assistant/providers/yandex_smarthome/notifier.py | 9 +++++++-- tests/providers/yandex_smarthome/test_notifier.py | 9 +++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/notifier.py b/music_assistant/providers/yandex_smarthome/notifier.py index 24c4881c97..a035d0e40f 100644 --- a/music_assistant/providers/yandex_smarthome/notifier.py +++ b/music_assistant/providers/yandex_smarthome/notifier.py @@ -198,11 +198,16 @@ async def _flush_pending(self) -> None: return try: await self._send_state_callback(devices) + except asyncio.CancelledError: + raise except Exception: - # Re-queue failed player IDs + # Re-queue failed player IDs and reschedule. _send_state_callback + # already deduplicated the log entry (WARNING for known classes, + # ERROR-with-traceback for unexpected) so we swallow the exception + # here to keep MA's task scheduler from re-logging it as + # "Task exception was never retrieved" on every retry. self._dirty_player_ids |= dirty self._schedule_flush() - raise # ----------------------------------------------------------------------- # State reporting diff --git a/tests/providers/yandex_smarthome/test_notifier.py b/tests/providers/yandex_smarthome/test_notifier.py index 3d3ecf4e1d..4c36e7d392 100644 --- a/tests/providers/yandex_smarthome/test_notifier.py +++ b/tests/providers/yandex_smarthome/test_notifier.py @@ -400,7 +400,7 @@ async def test_accepts_http_202(self) -> None: @pytest.mark.asyncio async def test_rejects_http_500(self) -> None: - """Non-success status codes should re-queue dirty IDs and raise.""" + """HTTP 500: re-queue dirty IDs, swallow exception (already logged).""" mock_resp = AsyncMock() mock_resp.status = 500 mock_resp.text = AsyncMock(return_value="Internal Server Error") @@ -418,11 +418,12 @@ async def test_rejects_http_500(self) -> None: notifier._dirty_player_ids.add("p1") - with pytest.raises(RuntimeError, match="State callback failed"): - await notifier._flush_pending() + # No raise — _send_state_callback's _CallbackErrorAlreadyLogged is + # deduped + swallowed here so MA's task scheduler does not re-log + # it as "Task exception was never retrieved" on every retry. + await notifier._flush_pending() session.post.assert_called_once() - # Player IDs should be re-queued after failure assert "p1" in notifier._dirty_player_ids @pytest.mark.asyncio From 319f56d9f5e3f9b7a66e379d3a1b78ffbf5fa9e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 May 2026 06:44:08 +0000 Subject: [PATCH 50/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v2.1.4 --- .../_smarthome_auto_create.py | 7 ++--- .../providers/yandex_smarthome/direct.py | 25 ++++++++------- .../yandex_smarthome/ma_authenticator.py | 31 ++++++++++--------- 3 files changed, 33 insertions(+), 30 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/_smarthome_auto_create.py b/music_assistant/providers/yandex_smarthome/_smarthome_auto_create.py index e79d928c0d..73e2693d99 100644 --- a/music_assistant/providers/yandex_smarthome/_smarthome_auto_create.py +++ b/music_assistant/providers/yandex_smarthome/_smarthome_auto_create.py @@ -91,10 +91,9 @@ def derive_smart_home_urls( value); ``client_secret`` is the per-install random UUID minted on first save. - Raises: - ValueError: ``cloud_plus`` without a registered cloud instance - id; ``direct`` without a client secret or with a non-HTTPS - base URL; any other ``connection_type``. + :raises ValueError: ``cloud_plus`` without a registered cloud + instance id; ``direct`` without a client secret or with a + non-HTTPS base URL; any other ``connection_type``. """ if connection_type == CONNECTION_TYPE_CLOUD_PLUS: if not cloud_instance_id: diff --git a/music_assistant/providers/yandex_smarthome/direct.py b/music_assistant/providers/yandex_smarthome/direct.py index a7cc951695..196b57aa25 100644 --- a/music_assistant/providers/yandex_smarthome/direct.py +++ b/music_assistant/providers/yandex_smarthome/direct.py @@ -101,17 +101,20 @@ def __init__( on_token_created: Callable[[str], None] | None = None, playlist_uris: tuple[str, ...] | list[str] = (), ) -> None: - """Initialize the handler. - - Args: - mass: MusicAssistant instance. - user_id: User identifier for Yandex API responses. - access_token: Current Bearer access token (may be empty on first run). - client_secret: OAuth client secret for account linking validation. - exposed_ids: Set of player IDs to expose, or None for all. - logger: Optional logger instance. - on_token_created: Callback invoked with new access token when generated - via OAuth flow (to persist in config). + """ + Initialize the handler. + + :param mass: MusicAssistant instance. + :param user_id: User identifier for Yandex API responses. + :param access_token: Current Bearer access token (may be empty + on first run). + :param client_secret: OAuth client secret for account linking + validation. + :param exposed_ids: Set of player IDs to expose, or ``None`` for + all. + :param logger: Optional logger instance. + :param on_token_created: Callback invoked with new access token + when generated via OAuth flow (to persist in config). """ self._mass = mass self._user_id = user_id diff --git a/music_assistant/providers/yandex_smarthome/ma_authenticator.py b/music_assistant/providers/yandex_smarthome/ma_authenticator.py index d78e762285..0de22740e7 100644 --- a/music_assistant/providers/yandex_smarthome/ma_authenticator.py +++ b/music_assistant/providers/yandex_smarthome/ma_authenticator.py @@ -86,21 +86,22 @@ def make_authenticator( # noqa: PLR0915 called so the caller can persist the new token for the next run. Callback failures are logged but never break authentication. - Args: - mass: MusicAssistant runtime — used for ``mass.webserver`` route - registration and ``AuthenticationHelper`` popup management. - session_id: Frontend-supplied session id (matches what ``AuthenticationHelper`` - listens on for popup open/close). Must be safe for URL paths. - timeout: Hard cap on Device Flow polling (seconds). Default 5 min. - cached_x_token: Optional Yandex Passport ``x_token`` from a prior - Device Flow. If still valid, skips Device Flow entirely. - on_token_obtained: Optional callback invoked with the fresh - ``x_token`` (plain ``str``, unwrapped from ``SecretStr``) after - a successful Device Flow. Use to persist into MA config so the - next run can use the cache. - - Raises: - ValueError: ``session_id`` doesn't match the safe character set. + :param mass: MusicAssistant runtime — used for ``mass.webserver`` + route registration and ``AuthenticationHelper`` popup + management. + :param session_id: Frontend-supplied session id (matches what + ``AuthenticationHelper`` listens on for popup open/close). + Must be safe for URL paths. + :param timeout: Hard cap on Device Flow polling (seconds). Default + 5 min. + :param cached_x_token: Optional Yandex Passport ``x_token`` from a + prior Device Flow. If still valid, skips Device Flow entirely. + :param on_token_obtained: Optional callback invoked with the fresh + ``x_token`` (plain ``str``, unwrapped from ``SecretStr``) + after a successful Device Flow. Use to persist into MA config + so the next run can use the cache. + :raises ValueError: ``session_id`` doesn't match the safe + character set. """ if not _SAFE_SESSION_ID_RE.match(session_id): msg = "invalid session_id for device authentication" From 81973fc9b2e70af20df1e36fc61646424bd26cc1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 May 2026 08:47:45 +0000 Subject: [PATCH 51/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v2.1.5 --- music_assistant/providers/yandex_smarthome/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/manifest.json b/music_assistant/providers/yandex_smarthome/manifest.json index 11e877788e..1f37c21025 100644 --- a/music_assistant/providers/yandex_smarthome/manifest.json +++ b/music_assistant/providers/yandex_smarthome/manifest.json @@ -9,7 +9,7 @@ ], "requirements": [ "ya-passport-auth==1.3.0", - "ya-dialogs-api==2.0.0" + "ya-dialogs-api==2.1.1" ], "documentation": "https://github.com/trudenboy/ma-provider-yandex-smarthome", "stage": "beta", diff --git a/requirements_all.txt b/requirements_all.txt index 772263685c..bc23560a86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -88,7 +88,7 @@ uv>=0.8.0 websocket-client==1.9.0 wiim==0.1.4 xmltodict==1.0.4 -ya-dialogs-api==2.0.0 +ya-dialogs-api==2.1.1 ya-passport-auth==1.3.0 yandex-music==3.0.0 ytmusicapi==1.11.5 From 36b7cb2421378b63d13a12fabdb48ff39cda6f1d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 May 2026 09:28:02 +0000 Subject: [PATCH 52/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v2.1.6 --- music_assistant/providers/yandex_smarthome/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/manifest.json b/music_assistant/providers/yandex_smarthome/manifest.json index 1f37c21025..148c45cf2a 100644 --- a/music_assistant/providers/yandex_smarthome/manifest.json +++ b/music_assistant/providers/yandex_smarthome/manifest.json @@ -9,7 +9,7 @@ ], "requirements": [ "ya-passport-auth==1.3.0", - "ya-dialogs-api==2.1.1" + "ya-dialogs-api==2.1.2" ], "documentation": "https://github.com/trudenboy/ma-provider-yandex-smarthome", "stage": "beta", diff --git a/requirements_all.txt b/requirements_all.txt index bc23560a86..49008a0b1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -88,7 +88,7 @@ uv>=0.8.0 websocket-client==1.9.0 wiim==0.1.4 xmltodict==1.0.4 -ya-dialogs-api==2.1.1 +ya-dialogs-api==2.1.2 ya-passport-auth==1.3.0 yandex-music==3.0.0 ytmusicapi==1.11.5 From 132da8c00b218c749bba77b971b92232d417370f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 May 2026 15:39:50 +0000 Subject: [PATCH 53/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v2.1.7 --- music_assistant/providers/yandex_smarthome/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/manifest.json b/music_assistant/providers/yandex_smarthome/manifest.json index 148c45cf2a..bfc7530b05 100644 --- a/music_assistant/providers/yandex_smarthome/manifest.json +++ b/music_assistant/providers/yandex_smarthome/manifest.json @@ -9,7 +9,7 @@ ], "requirements": [ "ya-passport-auth==1.3.0", - "ya-dialogs-api==2.1.2" + "ya-dialogs-api==2.2.0" ], "documentation": "https://github.com/trudenboy/ma-provider-yandex-smarthome", "stage": "beta", diff --git a/requirements_all.txt b/requirements_all.txt index 49008a0b1e..bad0b68cfc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -88,7 +88,7 @@ uv>=0.8.0 websocket-client==1.9.0 wiim==0.1.4 xmltodict==1.0.4 -ya-dialogs-api==2.1.2 +ya-dialogs-api==2.2.0 ya-passport-auth==1.3.0 yandex-music==3.0.0 ytmusicapi==1.11.5 From 55a30a4ed74d99f0f72e35a33172629c6e1eb726 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 May 2026 17:51:44 +0000 Subject: [PATCH 54/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v2.1.8 --- music_assistant/providers/yandex_smarthome/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/manifest.json b/music_assistant/providers/yandex_smarthome/manifest.json index bfc7530b05..ff4617f9ff 100644 --- a/music_assistant/providers/yandex_smarthome/manifest.json +++ b/music_assistant/providers/yandex_smarthome/manifest.json @@ -9,7 +9,7 @@ ], "requirements": [ "ya-passport-auth==1.3.0", - "ya-dialogs-api==2.2.0" + "ya-dialogs-api==2.3.0" ], "documentation": "https://github.com/trudenboy/ma-provider-yandex-smarthome", "stage": "beta", diff --git a/requirements_all.txt b/requirements_all.txt index bad0b68cfc..ffb5c8c3fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -88,7 +88,7 @@ uv>=0.8.0 websocket-client==1.9.0 wiim==0.1.4 xmltodict==1.0.4 -ya-dialogs-api==2.2.0 +ya-dialogs-api==2.3.0 ya-passport-auth==1.3.0 yandex-music==3.0.0 ytmusicapi==1.11.5 From 27fab555dbc438968301417ed88d19da40369a45 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 May 2026 19:51:49 +0000 Subject: [PATCH 55/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v2.1.9 --- music_assistant/providers/yandex_smarthome/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/yandex_smarthome/manifest.json b/music_assistant/providers/yandex_smarthome/manifest.json index ff4617f9ff..8d0744824e 100644 --- a/music_assistant/providers/yandex_smarthome/manifest.json +++ b/music_assistant/providers/yandex_smarthome/manifest.json @@ -9,7 +9,7 @@ ], "requirements": [ "ya-passport-auth==1.3.0", - "ya-dialogs-api==2.3.0" + "ya-dialogs-api==2.4.0" ], "documentation": "https://github.com/trudenboy/ma-provider-yandex-smarthome", "stage": "beta", diff --git a/requirements_all.txt b/requirements_all.txt index ffb5c8c3fb..c17b434753 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -88,7 +88,7 @@ uv>=0.8.0 websocket-client==1.9.0 wiim==0.1.4 xmltodict==1.0.4 -ya-dialogs-api==2.3.0 +ya-dialogs-api==2.4.0 ya-passport-auth==1.3.0 yandex-music==3.0.0 ytmusicapi==1.11.5 From ac045b7ce7da768b7b66793f33a9b0f727624ba7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 12 May 2026 08:46:52 +0000 Subject: [PATCH 56/56] feat(yandex_smarthome): sync provider from ma-provider-yandex-smarthome v2.1.10 --- .../providers/yandex_smarthome/test_device.py | 94 +++++++++---------- .../providers/yandex_smarthome/test_direct.py | 18 ++-- .../yandex_smarthome/test_ma_authenticator.py | 6 +- .../yandex_smarthome/test_notifier.py | 20 ++-- 4 files changed, 69 insertions(+), 69 deletions(-) diff --git a/tests/providers/yandex_smarthome/test_device.py b/tests/providers/yandex_smarthome/test_device.py index 62dc342ff6..74f74c5f82 100644 --- a/tests/providers/yandex_smarthome/test_device.py +++ b/tests/providers/yandex_smarthome/test_device.py @@ -122,7 +122,7 @@ class TestGetDeviceDescription: def test_basic_description(self) -> None: """Test basic description without mute support.""" player = MockPlayer() - desc = get_device_description(player) # type: ignore[arg-type] + desc = get_device_description(player) assert desc.id == "test_player_1" assert desc.name == "Living Room Speaker" assert desc.type == YANDEX_DEVICE_TYPE_MEDIA @@ -134,7 +134,7 @@ def test_basic_description(self) -> None: def test_description_with_mute(self) -> None: """Test description includes mute toggle when VOLUME_MUTE feature is set.""" player = MockPlayer(supported_features={"volume_mute"}) - desc = get_device_description(player) # type: ignore[arg-type] + desc = get_device_description(player) assert len(desc.capabilities) == 5 instances = {c.parameters.instance for c in desc.capabilities if c.parameters} assert INSTANCE_MUTE in instances @@ -142,7 +142,7 @@ def test_description_with_mute(self) -> None: def test_description_group_has_mute(self) -> None: """Group players always get mute toggle even without VOLUME_MUTE feature.""" player = MockPlayer(group_members=["child1", "child2"]) - desc = get_device_description(player) # type: ignore[arg-type] + desc = get_device_description(player) assert len(desc.capabilities) == 5 instances = {c.parameters.instance for c in desc.capabilities if c.parameters} assert INSTANCE_MUTE in instances @@ -150,7 +150,7 @@ def test_description_group_has_mute(self) -> None: def test_capability_types(self) -> None: """Test capability types.""" player = MockPlayer() - desc = get_device_description(player) # type: ignore[arg-type] + desc = get_device_description(player) types = [c.type for c in desc.capabilities] assert YandexCapabilityType.ON_OFF in types assert YandexCapabilityType.RANGE in types @@ -159,7 +159,7 @@ def test_capability_types(self) -> None: def test_volume_range_params(self) -> None: """Test volume range params.""" player = MockPlayer() - desc = get_device_description(player) # type: ignore[arg-type] + desc = get_device_description(player) range_cap = next(c for c in desc.capabilities if c.type == YandexCapabilityType.RANGE) assert range_cap.parameters is not None assert range_cap.parameters.instance == "volume" @@ -170,21 +170,21 @@ def test_volume_range_params(self) -> None: def test_device_info_model(self) -> None: """Test device info model.""" player = MockPlayer(device_info=MockDeviceInfo(model="KEF LS50")) - desc = get_device_description(player) # type: ignore[arg-type] + desc = get_device_description(player) assert desc.device_info is not None assert desc.device_info.model == "KEF LS50" def test_device_info_default(self) -> None: """Test device info default.""" player = MockPlayer() - desc = get_device_description(player) # type: ignore[arg-type] + desc = get_device_description(player) assert desc.device_info is not None assert desc.device_info.model == "MA Player" def test_name_normalized(self) -> None: """Test that device name is normalized for Yandex.""" player = MockPlayer(name="KEF-LS50 (Kitchen)") - desc = get_device_description(player) # type: ignore[arg-type] + desc = get_device_description(player) assert desc.name == "KEF LS 50 Kitchen" @@ -240,7 +240,7 @@ class TestGetDeviceState: def test_idle_state(self) -> None: """Test idle state without mute support.""" player = MockPlayer(playback_state=PlaybackState.IDLE, volume_level=30, volume_muted=False) - state = get_device_state(player) # type: ignore[arg-type] + state = get_device_state(player) assert state.id == "test_player_1" by_instance = {c.state.instance: c.state.value for c in state.capabilities} @@ -257,7 +257,7 @@ def test_idle_state_with_mute(self) -> None: volume_muted=False, supported_features={"volume_mute"}, ) - state = get_device_state(player) # type: ignore[arg-type] + state = get_device_state(player) by_instance = {c.state.instance: c.state.value for c in state.capabilities} assert by_instance[INSTANCE_MUTE] is False @@ -265,7 +265,7 @@ def test_idle_state_with_mute(self) -> None: def test_playing_state(self) -> None: """Test playing state.""" player = MockPlayer(playback_state=PlaybackState.PLAYING, volume_level=75) - state = get_device_state(player) # type: ignore[arg-type] + state = get_device_state(player) by_instance = {c.state.instance: c.state.value for c in state.capabilities} assert by_instance[INSTANCE_ON] is True @@ -275,7 +275,7 @@ def test_playing_state(self) -> None: def test_paused_state(self) -> None: """Test paused state.""" player = MockPlayer(playback_state=PlaybackState.PAUSED, volume_level=50) - state = get_device_state(player) # type: ignore[arg-type] + state = get_device_state(player) by_instance = {c.state.instance: c.state.value for c in state.capabilities} assert by_instance[INSTANCE_ON] is True # paused is still "on" @@ -286,7 +286,7 @@ def test_none_volume(self) -> None: player = MockPlayer( volume_level=None, volume_muted=None, supported_features={"volume_mute"} ) - state = get_device_state(player) # type: ignore[arg-type] + state = get_device_state(player) by_instance = {c.state.instance: c.state.value for c in state.capabilities} assert by_instance[INSTANCE_VOLUME] == 0 @@ -302,7 +302,7 @@ def test_group_state_includes_mute(self) -> None: group_volume=75, group_volume_muted=True, ) - state = get_device_state(player) # type: ignore[arg-type] + state = get_device_state(player) by_instance = {c.state.instance: c.state.value for c in state.capabilities} assert by_instance[INSTANCE_VOLUME] == 75 @@ -618,19 +618,19 @@ class TestIsPlayerExposable: def test_normal_player(self) -> None: """Test normal player.""" - assert is_player_exposable(MockPlayer()) is True # type: ignore[arg-type] + assert is_player_exposable(MockPlayer()) is True def test_unavailable(self) -> None: """Test unavailable.""" - assert is_player_exposable(MockPlayer(available=False)) is False # type: ignore[arg-type] + assert is_player_exposable(MockPlayer(available=False)) is False def test_disabled(self) -> None: """Test disabled.""" - assert is_player_exposable(MockPlayer(enabled=False)) is False # type: ignore[arg-type] + assert is_player_exposable(MockPlayer(enabled=False)) is False def test_synced_to_another(self) -> None: """Test synced to another.""" - assert is_player_exposable(MockPlayer(synced_to="other_player")) is False # type: ignore[arg-type] + assert is_player_exposable(MockPlayer(synced_to="other_player")) is False # --------------------------------------------------------------------------- @@ -677,7 +677,7 @@ class TestChannelCapability: def test_channel_in_description(self) -> None: """Channel capability should always be present in device description.""" player = MockPlayer() - desc = get_device_description(player) # type: ignore[arg-type] + desc = get_device_description(player) channel_caps = [ c for c in desc.capabilities @@ -687,15 +687,15 @@ def test_channel_in_description(self) -> None: ] assert len(channel_caps) == 1 cap = channel_caps[0] - assert cap.parameters.random_access is False # type: ignore[union-attr] - assert cap.parameters.range is not None # type: ignore[union-attr] - assert cap.parameters.range.min == 0 # type: ignore[union-attr] - assert cap.parameters.range.max == 999 # type: ignore[union-attr] + assert cap.parameters.random_access is False + assert cap.parameters.range is not None + assert cap.parameters.range.min == 0 + assert cap.parameters.range.max == 999 def test_channel_state_always_zero(self) -> None: """Channel state should always report value 0.""" player = MockPlayer(playback_state=PlaybackState.PLAYING) - state = get_device_state(player) # type: ignore[arg-type] + state = get_device_state(player) channel_states = [c for c in state.capabilities if c.state.instance == INSTANCE_CHANNEL] assert len(channel_states) == 1 assert channel_states[0].state.value == 0 @@ -752,7 +752,7 @@ class TestInputSourceCapability: def test_no_source_list_no_mode_cap(self) -> None: """Player without source_list should not have mode capability.""" player = MockPlayer(source_list=[]) - desc = get_device_description(player) # type: ignore[arg-type] + desc = get_device_description(player) mode_caps = [c for c in desc.capabilities if c.type == YandexCapabilityType.MODE] assert len(mode_caps) == 0 @@ -763,23 +763,23 @@ def test_with_sources_has_mode_cap(self) -> None: MockPlayerSource(id="optical", name="Optical"), ] player = MockPlayer(source_list=sources, supported_features={"select_source"}) - desc = get_device_description(player) # type: ignore[arg-type] + desc = get_device_description(player) mode_caps = [c for c in desc.capabilities if c.type == YandexCapabilityType.MODE] assert len(mode_caps) == 1 cap = mode_caps[0] - assert cap.parameters.instance == INSTANCE_INPUT_SOURCE # type: ignore[union-attr] - assert cap.parameters.modes is not None # type: ignore[union-attr] - assert len(cap.parameters.modes) == 2 # type: ignore[union-attr] - assert cap.parameters.modes[0].value == "one" # type: ignore[union-attr] - assert cap.parameters.modes[1].value == "two" # type: ignore[union-attr] + assert cap.parameters.instance == INSTANCE_INPUT_SOURCE + assert cap.parameters.modes is not None + assert len(cap.parameters.modes) == 2 + assert cap.parameters.modes[0].value == "one" + assert cap.parameters.modes[1].value == "two" def test_max_10_sources(self) -> None: """Only the first 10 sources should be mapped.""" sources = [MockPlayerSource(id=f"s{i}", name=f"Source {i}") for i in range(15)] player = MockPlayer(source_list=sources, supported_features={"select_source"}) - desc = get_device_description(player) # type: ignore[arg-type] + desc = get_device_description(player) mode_caps = [c for c in desc.capabilities if c.type == YandexCapabilityType.MODE] - assert len(mode_caps[0].parameters.modes) == 10 # type: ignore[arg-type,union-attr] + assert len(mode_caps[0].parameters.modes) == 10 def test_state_with_active_source(self) -> None: """State should report current source as mode value.""" @@ -793,7 +793,7 @@ def test_state_with_active_source(self) -> None: playback_state=PlaybackState.PLAYING, supported_features={"select_source"}, ) - state = get_device_state(player) # type: ignore[arg-type] + state = get_device_state(player) mode_states = [c for c in state.capabilities if c.state.instance == INSTANCE_INPUT_SOURCE] assert len(mode_states) == 1 assert mode_states[0].state.value == "two" # index 1 → "two" @@ -804,7 +804,7 @@ def test_state_no_active_source(self) -> None: player = MockPlayer( source_list=sources, active_source=None, supported_features={"select_source"} ) - state = get_device_state(player) # type: ignore[arg-type] + state = get_device_state(player) mode_states = [c for c in state.capabilities if c.state.instance == INSTANCE_INPUT_SOURCE] assert len(mode_states) == 0 @@ -857,10 +857,10 @@ def test_playlists_only_register_mode_cap(self) -> None: """Player with no native sources but configured playlists gets mode cap.""" player = MockPlayer(source_list=[]) playlist_uris = ["library://playlist/1", "library://playlist/2", "library://playlist/3"] - desc = get_device_description(player, playlist_uris=playlist_uris) # type: ignore[arg-type] + desc = get_device_description(player, playlist_uris=playlist_uris) mode_caps = [c for c in desc.capabilities if c.type == YandexCapabilityType.MODE] assert len(mode_caps) == 1 - modes = mode_caps[0].parameters.modes # type: ignore[union-attr] + modes = mode_caps[0].parameters.modes assert modes is not None assert [m.value for m in modes] == ["one", "two", "three"] @@ -869,20 +869,20 @@ def test_native_then_playlists_capped_at_10(self) -> None: sources = [MockPlayerSource(id=f"s{i}", name=f"Source {i}") for i in range(5)] playlist_uris = [f"library://playlist/{i}" for i in range(7)] player = MockPlayer(source_list=sources, supported_features={"select_source"}) - desc = get_device_description(player, playlist_uris=playlist_uris) # type: ignore[arg-type] + desc = get_device_description(player, playlist_uris=playlist_uris) mode_caps = [c for c in desc.capabilities if c.type == YandexCapabilityType.MODE] assert len(mode_caps) == 1 - assert len(mode_caps[0].parameters.modes) == 10 # type: ignore[arg-type,union-attr] + assert len(mode_caps[0].parameters.modes) == 10 def test_native_full_ignores_playlists(self) -> None: """If native sources already fill all 10 slots, playlists are ignored.""" sources = [MockPlayerSource(id=f"s{i}", name=f"Source {i}") for i in range(10)] playlist_uris = ["library://playlist/extra"] player = MockPlayer(source_list=sources, supported_features={"select_source"}) - desc = get_device_description(player, playlist_uris=playlist_uris) # type: ignore[arg-type] + desc = get_device_description(player, playlist_uris=playlist_uris) mode_caps = [c for c in desc.capabilities if c.type == YandexCapabilityType.MODE] assert len(mode_caps) == 1 - assert len(mode_caps[0].parameters.modes) == 10 # type: ignore[arg-type,union-attr] + assert len(mode_caps[0].parameters.modes) == 10 def test_state_with_native_active_in_combined_mode(self) -> None: """Native active source still reports correct state when playlists also configured.""" @@ -896,7 +896,7 @@ def test_state_with_native_active_in_combined_mode(self) -> None: playback_state=PlaybackState.PLAYING, supported_features={"select_source"}, ) - state = get_device_state(player, playlist_uris=["library://playlist/x"]) # type: ignore[arg-type] + state = get_device_state(player, playlist_uris=["library://playlist/x"]) mode_states = [c for c in state.capabilities if c.state.instance == INSTANCE_INPUT_SOURCE] assert len(mode_states) == 1 assert mode_states[0].state.value == "two" @@ -1017,23 +1017,23 @@ class TestPlayerFilter: def test_no_filter_exposes_all(self) -> None: """Without exposed_ids, all valid players are exposed.""" - assert is_player_exposable(MockPlayer()) is True # type: ignore[arg-type] + assert is_player_exposable(MockPlayer()) is True def test_filter_includes_player(self) -> None: """Player in the filter set is exposed.""" - assert is_player_exposable(MockPlayer(player_id="p1"), exposed_ids={"p1", "p2"}) is True # type: ignore[arg-type] + assert is_player_exposable(MockPlayer(player_id="p1"), exposed_ids={"p1", "p2"}) is True def test_filter_excludes_player(self) -> None: """Player not in the filter set is NOT exposed.""" - assert is_player_exposable(MockPlayer(player_id="p3"), exposed_ids={"p1", "p2"}) is False # type: ignore[arg-type] + assert is_player_exposable(MockPlayer(player_id="p3"), exposed_ids={"p1", "p2"}) is False def test_empty_filter_exposes_all(self) -> None: """Empty set filter should expose all players (same as None).""" - assert is_player_exposable(MockPlayer(player_id="p1"), exposed_ids=set()) is True # type: ignore[arg-type] + assert is_player_exposable(MockPlayer(player_id="p1"), exposed_ids=set()) is True def test_filter_still_checks_available(self) -> None: """Even in filter, unavailable players are not exposed.""" assert ( - is_player_exposable(MockPlayer(player_id="p1", available=False), exposed_ids={"p1"}) # type: ignore[arg-type] + is_player_exposable(MockPlayer(player_id="p1", available=False), exposed_ids={"p1"}) is False ) diff --git a/tests/providers/yandex_smarthome/test_direct.py b/tests/providers/yandex_smarthome/test_direct.py index fdc05b592a..8b5a619b71 100644 --- a/tests/providers/yandex_smarthome/test_direct.py +++ b/tests/providers/yandex_smarthome/test_direct.py @@ -117,7 +117,7 @@ async def _post() -> dict[str, str]: def _get_pending_code(h: DirectConnectionHandler) -> str: """Return the first pending authorization code from a handler.""" - return next(iter(h._pending_codes.keys())) + return str(next(iter(h._pending_codes.keys()))) # --------------------------------------------------------------------------- @@ -319,7 +319,7 @@ async def test_devices_success(handler: DirectConnectionHandler) -> None: ): resp = await handler._handle_devices(req) assert resp.status == 200 - body = json.loads(resp.body) # type: ignore[arg-type] + body = json.loads(resp.body) assert body["request_id"] == "req-1" @@ -543,7 +543,7 @@ async def test_token_exchange_valid_code(handler: DirectConnectionHandler) -> No ) resp = await handler._handle_oauth_token(req_token) assert resp.status == 200 - body = json.loads(resp.body) # type: ignore[arg-type] + body = json.loads(resp.body) assert body["access_token"] == "test-token-abc" assert body["token_type"] == "bearer" assert "refresh_token" in body @@ -579,7 +579,7 @@ async def test_token_exchange_generates_new_token( ) resp = await handler_no_token._handle_oauth_token(req_token) assert resp.status == 200 - body = json.loads(resp.body) # type: ignore[arg-type] + body = json.loads(resp.body) assert body["access_token"] assert len(body["access_token"]) == 32 # uuid4().hex assert len(_handler_no_token_tokens) == 1 @@ -601,7 +601,7 @@ async def test_token_exchange_invalid_client_secret(handler: DirectConnectionHan ) resp = await handler._handle_oauth_token(req) assert resp.status == 401 - body = json.loads(resp.body) # type: ignore[arg-type] + body = json.loads(resp.body) assert body["error"] == "invalid_client" @@ -620,7 +620,7 @@ async def test_token_exchange_invalid_client_id(handler: DirectConnectionHandler ) resp = await handler._handle_oauth_token(req) assert resp.status == 401 - body = json.loads(resp.body) # type: ignore[arg-type] + body = json.loads(resp.body) assert body["error"] == "invalid_client" @@ -639,7 +639,7 @@ async def test_token_exchange_invalid_code(handler: DirectConnectionHandler) -> ) resp = await handler._handle_oauth_token(req) assert resp.status == 400 - body = json.loads(resp.body) # type: ignore[arg-type] + body = json.loads(resp.body) assert body["error"] == "invalid_grant" @@ -676,7 +676,7 @@ async def test_refresh_token_valid(handler: DirectConnectionHandler) -> None: ) resp = await handler._handle_oauth_token(req) assert resp.status == 200 - body = json.loads(resp.body) # type: ignore[arg-type] + body = json.loads(resp.body) assert body["access_token"] == "test-token-abc" @@ -711,7 +711,7 @@ async def test_unsupported_grant_type(handler: DirectConnectionHandler) -> None: ) resp = await handler._handle_oauth_token(req) assert resp.status == 400 - body = json.loads(resp.body) # type: ignore[arg-type] + body = json.loads(resp.body) assert body["error"] == "unsupported_grant_type" diff --git a/tests/providers/yandex_smarthome/test_ma_authenticator.py b/tests/providers/yandex_smarthome/test_ma_authenticator.py index e11f8a359b..55a824701e 100644 --- a/tests/providers/yandex_smarthome/test_ma_authenticator.py +++ b/tests/providers/yandex_smarthome/test_ma_authenticator.py @@ -27,13 +27,13 @@ def test_safe_id_accepted(self) -> None: """Letters, digits, dashes, underscores, len <= 64 are allowed.""" # Should not raise; we discard the returned factory. make_authenticator( - mass=None, # type: ignore[arg-type] + mass=None, session_id="abc-123_DEF", ) def test_max_length_accepted(self) -> None: """64-character ids are at the inclusive upper bound.""" - make_authenticator(mass=None, session_id="a" * 64) # type: ignore[arg-type] + make_authenticator(mass=None, session_id="a" * 64) @pytest.mark.parametrize( "bad", @@ -53,7 +53,7 @@ def test_max_length_accepted(self) -> None: def test_unsafe_ids_rejected(self, bad: str) -> None: """Anything with metacharacters or out-of-bounds length is rejected.""" with pytest.raises(ValueError, match="invalid session_id"): - make_authenticator(mass=None, session_id=bad) # type: ignore[arg-type] + make_authenticator(mass=None, session_id=bad) class TestBuildDeviceCodePage: diff --git a/tests/providers/yandex_smarthome/test_notifier.py b/tests/providers/yandex_smarthome/test_notifier.py index 4c36e7d392..ddf8ed31b5 100644 --- a/tests/providers/yandex_smarthome/test_notifier.py +++ b/tests/providers/yandex_smarthome/test_notifier.py @@ -142,7 +142,7 @@ async def test_stop_unsubscribes(self) -> None: unsub = notifier._unsub await notifier.stop() - unsub.assert_called_once() # type: ignore[union-attr] + unsub.assert_called_once() assert notifier._unsub is None assert notifier._heartbeat_task is None @@ -165,7 +165,7 @@ def test_on_player_updated_queues_state(self) -> None: player = MockPlayer(player_id="p1", playback_state=PlaybackState.PLAYING) event = MockEvent(event=EventType.PLAYER_UPDATED, data=player) - notifier._on_player_event(event) # type: ignore[arg-type] + notifier._on_player_event(event) assert "p1" in notifier._dirty_player_ids @@ -177,7 +177,7 @@ def test_on_player_updated_unavailable_ignored(self) -> None: player = MockPlayer(player_id="p1", available=False) event = MockEvent(event=EventType.PLAYER_UPDATED, data=player) - notifier._on_player_event(event) # type: ignore[arg-type] + notifier._on_player_event(event) assert "p1" not in notifier._dirty_player_ids @@ -187,7 +187,7 @@ def test_on_player_added_triggers_discovery(self) -> None: notifier = _make_notifier(mass=mass) event = MockEvent(event=EventType.PLAYER_ADDED, data=MockPlayer()) - notifier._on_player_event(event) # type: ignore[arg-type] + notifier._on_player_event(event) # Discovery triggers create_task mass.create_task.assert_called() @@ -198,7 +198,7 @@ def test_on_player_removed_triggers_discovery(self) -> None: notifier = _make_notifier(mass=mass) event = MockEvent(event=EventType.PLAYER_REMOVED, data="p1") - notifier._on_player_event(event) # type: ignore[arg-type] + notifier._on_player_event(event) mass.create_task.assert_called() @@ -208,7 +208,7 @@ def test_on_none_data_ignored(self) -> None: notifier = _make_notifier(mass=mass) event = MockEvent(event=EventType.PLAYER_UPDATED, data=None) - notifier._on_player_event(event) # type: ignore[arg-type] + notifier._on_player_event(event) assert len(notifier._dirty_player_ids) == 0 @@ -220,7 +220,7 @@ def test_on_player_filtered_by_exposed_ids(self) -> None: player = MockPlayer(player_id="p1", playback_state=PlaybackState.PLAYING) event = MockEvent(event=EventType.PLAYER_UPDATED, data=player) - notifier._on_player_event(event) # type: ignore[arg-type] + notifier._on_player_event(event) assert "p1" not in notifier._dirty_player_ids @@ -232,7 +232,7 @@ def test_on_player_included_by_exposed_ids(self) -> None: player = MockPlayer(player_id="p1", playback_state=PlaybackState.PLAYING) event = MockEvent(event=EventType.PLAYER_UPDATED, data=player) - notifier._on_player_event(event) # type: ignore[arg-type] + notifier._on_player_event(event) assert "p1" in notifier._dirty_player_ids @@ -243,7 +243,7 @@ def test_child_event_propagates_to_group(self) -> None: child = MockPlayer(player_id="child1", synced_to="grp1") event = MockEvent(event=EventType.PLAYER_UPDATED, data=child) - notifier._on_player_event(event) # type: ignore[arg-type] + notifier._on_player_event(event) assert "grp1" in notifier._dirty_player_ids assert "child1" not in notifier._dirty_player_ids @@ -310,7 +310,7 @@ async def test_flush_reads_fresh_volume(self) -> None: # Simulate event with transient volume=0 event = MockEvent(event=EventType.PLAYER_UPDATED, data=player_event) - notifier._on_player_event(event) # type: ignore[arg-type] + notifier._on_player_event(event) # Flush should use live player state (volume=75) await notifier._flush_pending()