/
/
1"""Player Provider for Sendspin."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7import re
8from collections.abc import Callable
9from contextlib import suppress
10from copy import deepcopy
11from dataclasses import dataclass, field
12from ipaddress import ip_address
13from pathlib import Path
14from typing import TYPE_CHECKING, Any, cast
15from urllib.parse import urlsplit
16from uuid import uuid4
17
18from aiosendspin.models.core import ClientHelloPayload
19from aiosendspin.models.core import DeviceInfo as SendspinDeviceInfo
20from aiosendspin.models.player import ClientHelloPlayerSupport, SupportedAudioFormat
21from aiosendspin.models.types import (
22 AudioCodec,
23 ManagementResult,
24 PairAbortReason,
25 PairMethod,
26 PlayerCommand,
27 role_family,
28)
29from aiosendspin.noise.driver import HandshakeAbortedError
30from aiosendspin.noise.pairing import (
31 LocalPairingAbortError,
32 PairingAbortError,
33 PairingAttempt,
34 PairingError,
35 PairingTimeoutError,
36)
37from aiosendspin.noise.pairing_token import decode_token
38from aiosendspin.noise.trust_store import FileServerPairingStore, PskCategory
39from aiosendspin.server import (
40 ClientAddedEvent,
41 ClientConnectedEvent,
42 ClientDisconnectedEvent,
43 ClientRemovedEvent,
44 ClientUpdatedEvent,
45 SendspinEvent,
46 SendspinServer,
47)
48from music_assistant_models.auth import Scope
49from music_assistant_models.config_entries import ConfigEntry
50from music_assistant_models.enums import (
51 ConfigEntryType,
52 EventType,
53 IdentifierType,
54 PlayerFeature,
55 PlayerType,
56 ProviderFeature,
57)
58from music_assistant_models.errors import (
59 AlreadyRegisteredError,
60 InvalidCommand,
61 SetupFailedError,
62)
63
64from music_assistant.constants import (
65 CONF_ENABLED,
66 CONF_ENTRY_MANUAL_DISCOVERY_IPS,
67 CONF_LOG_LEVEL,
68 CONF_PLAYERS,
69 CONF_PROVIDERS,
70 SENDSPIN_SERVER_PORT,
71 VERBOSE_LOG_LEVEL,
72)
73from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
74from music_assistant.helpers.guest_access import (
75 credential_owner,
76 credential_owner_user_id,
77 credential_owners_for_user_id,
78 is_session_scoped_owner,
79)
80from music_assistant.helpers.util import format_ip_for_url
81from music_assistant.mass import MusicAssistant
82from music_assistant.models.player import Player
83from music_assistant.models.player_provider import PlayerProvider
84from music_assistant.providers.sendspin.bridge_role import (
85 BRIDGE_BIT_DEPTH,
86 BRIDGE_CHANNELS,
87 BRIDGE_ROLE_ID,
88 BRIDGE_SAMPLE_RATE,
89 BridgePlayerRole,
90)
91from music_assistant.providers.sendspin.constants import (
92 CONF_ALLOW_LEGACY_CLIENTS,
93 CONF_MIN_PIN_LENGTH,
94 CONF_SENDSPIN_STATIC_DELAY,
95 CONF_VIRTUAL_PLAYER_OWNER,
96 DEFAULT_MIN_PIN_LENGTH,
97 VIRTUAL_PLAYER_ID_PREFIX,
98)
99from music_assistant.providers.sendspin.helpers import (
100 SecurityActionError,
101 effective_pair_methods,
102 error_alert,
103 negotiated_pin_length,
104 pair_method_descriptor,
105)
106from music_assistant.providers.sendspin.player import (
107 SendspinBasePlayer,
108 SendspinPlayer,
109 SendspinSourcePlayer,
110 SendspinVisualizerPlayer,
111)
112from music_assistant.providers.sendspin.security import (
113 IDENTITY_FILENAME,
114 get_or_create_server_identity,
115)
116
117if TYPE_CHECKING:
118 from collections.abc import Awaitable, Sequence
119
120 from aiosendspin.models.core import PairMethodDescriptor
121 from aiosendspin.models.management import (
122 ManagementResultData,
123 ManagementSetPairingConfigPayload,
124 )
125 from aiosendspin.noise.trust_store import ServerPairingStore
126 from aiosendspin.server.client import SendspinClient
127 from aiosendspin.server.connection import SendspinConnection
128 from aiosendspin.server.server import ExternalStreamStartRequest
129 from music_assistant_models.auth import User
130 from music_assistant_models.config_entries import ProviderConfig
131 from music_assistant_models.event import MassEvent
132 from music_assistant_models.provider import ProviderManifest
133
134 from music_assistant.controllers.webserver.auth import AuthenticationManager
135 from music_assistant.providers.hass import HomeAssistantProvider
136
137
138DEFAULT_SENDSPIN_CLIENT_PORT = 8928
139DEFAULT_SENDSPIN_CLIENT_PATH = "/sendspin"
140VIRTUAL_PLAYER_REGISTER_TIMEOUT = 10.0
141VIRTUAL_PLAYER_CLEANUP_DELAYS = (0.0, 0.5, 2.0)
142VIRTUAL_PLAYER_CLEANUP_TIMEOUT = 2.0
143WEB_PLAYER_CONNECT_TIMEOUT = 10.0
144# Grace period so a network blip keeps the pairing record.
145SESSION_PAIRING_EVICTION_GRACE = 120.0
146
147PIN_REQUEST_FEEDBACK_TIMEOUT = 2
148PIN_RETRY_IDLE_TIMEOUT = 300
149MANAGEMENT_REQUEST_TIMEOUT = 10
150MANAGEMENT_IDLE_TIMEOUT = 300
151
152
153@dataclass
154class PinPairingSession:
155 """State of an operator PIN pairing session for one client, across retry-in-place attempts."""
156
157 client_id: str
158 method: PairMethod
159 pin_future: asyncio.Future[str]
160 verify: bool = False
161 static: bool = False
162 pin_length: int | None = None
163 task: asyncio.Task[None] | None = None
164 pin_request_event: asyncio.Event = field(default_factory=asyncio.Event)
165 gesture_event: asyncio.Event = field(default_factory=asyncio.Event)
166 error: Exception | None = None
167 retryable: bool = False
168 opened_management: bool = False
169
170 @property
171 def attempt_running(self) -> bool:
172 """Whether an attempt is currently in flight."""
173 return self.task is not None and not self.task.done()
174
175 @property
176 def awaiting_first_message(self) -> bool:
177 """Whether the attempt is still waiting for the client's first pairing message."""
178 return (
179 self.attempt_running
180 and not self.gesture_event.is_set()
181 and not self.pin_request_event.is_set()
182 )
183
184 @property
185 def awaiting_gesture(self) -> bool:
186 """Whether the client reported the attempt gesture-gated and still awaits a window."""
187 return (
188 self.attempt_running
189 and self.gesture_event.is_set()
190 and not self.pin_request_event.is_set()
191 )
192
193 @property
194 def awaiting_pin(self) -> bool:
195 """Whether the attempt is waiting for the operator to submit a PIN."""
196 return self.attempt_running and not self.pin_future.done()
197
198 async def wait_first_message(self) -> None:
199 """Resolve once the client asks for a gesture or the PIN, or the attempt ends."""
200 await self._wait_events(self.gesture_event, self.pin_request_event)
201
202 async def wait_pin_request(self) -> None:
203 """Resolve once the client asks for the PIN, or the attempt ends."""
204 await self._wait_events(self.pin_request_event)
205
206 @property
207 def can_retry(self) -> bool:
208 """Whether a failed attempt can be retried in place."""
209 return self.task is not None and self.task.done() and self.retryable
210
211 @property
212 def finished(self) -> bool:
213 """Whether the session reached a terminal outcome (no retry possible)."""
214 return self.task is not None and self.task.done() and not self.retryable
215
216 async def _wait_events(self, *events: asyncio.Event) -> None:
217 """Resolve on the first of ``events`` or on the attempt ending, whichever comes first."""
218 waiters: list[asyncio.Future[Any]] = [
219 asyncio.ensure_future(event.wait()) for event in events
220 ]
221 if self.task is not None:
222 # Shielded: dropping this wait must never cancel the pairing attempt.
223 waiters.append(asyncio.shield(self.task))
224 try:
225 await asyncio.wait(waiters, return_when=asyncio.FIRST_COMPLETED)
226 finally:
227 for waiter in waiters:
228 waiter.cancel()
229
230
231@dataclass
232class ManagementSession:
233 """State of an operator device-management session for one client."""
234
235 client_id: str
236 connection: SendspinConnection
237 lock: asyncio.Lock = field(default_factory=asyncio.Lock)
238
239
240async def _poll_until[T](check: Callable[[], T | None], timeout: float) -> T | None:
241 """Poll ``check`` every 0.1s until it returns a value, or ``None`` once ``timeout`` lapses."""
242 try:
243 async with asyncio.timeout(timeout):
244 while True:
245 if (result := check()) is not None:
246 return result
247 await asyncio.sleep(0.1)
248 except TimeoutError:
249 return None
250
251
252def _evict_session_pairing_task_id(client_id: str) -> str:
253 """Task id for a client's delayed session-scoped pairing eviction."""
254 return f"sendspin_evict_session_pairing_{client_id}"
255
256
257def _pin_idle_task_id(client_id: str) -> str:
258 """Timer/task id for a client's pairing-retry idle timeout."""
259 return f"sendspin_pin_idle_{client_id}"
260
261
262def _management_idle_task_id(client_id: str) -> str:
263 """Timer/task id for a client's management-session idle timeout."""
264 return f"sendspin_management_idle_{client_id}"
265
266
267_MANAGEMENT_RESULT_ALERTS = {
268 ManagementResult.PERMISSION_DENIED: "management_error_permission_denied",
269 ManagementResult.ALREADY_EXISTS: "management_error_already_exists",
270 ManagementResult.INVALID: "management_error_invalid",
271 ManagementResult.NOT_FOUND: "management_error_not_found",
272 ManagementResult.STORAGE_EXHAUSTED: "management_error_storage_exhausted",
273}
274
275
276def _check_management_result(result: ManagementResult) -> None:
277 """Raise a structured error for a non-ok management result."""
278 if result is ManagementResult.OK:
279 return
280 alert_key = _MANAGEMENT_RESULT_ALERTS.get(result)
281 if alert_key is None:
282 raise SecurityActionError("management_error_generic", detail=result.value)
283 raise SecurityActionError(alert_key)
284
285
286async def _evict_stale_pairings(
287 pairing_store: ServerPairingStore, auth: AuthenticationManager
288) -> tuple[int, int]:
289 """
290 Remove the pairing records whose owning authorization is gone.
291
292 Session-scoped pairings live only as long as their client's connection, and no
293 connection survives a restart. Account-bound ones do survive a restart, but not an
294 account that was deleted or disabled while this provider was not there to hear it.
295
296 :return: How many session-scoped and how many account-bound records were removed.
297 """
298 session_scoped = 0
299 orphaned = 0
300 for record in await pairing_store.list_records():
301 if record.owner is None:
302 continue
303 if is_session_scoped_owner(record.owner):
304 session_scoped += 1
305 else:
306 if await _owner_has_access(record.owner, auth):
307 continue
308 orphaned += 1
309 await pairing_store.remove_record(record.client_id)
310 return session_scoped, orphaned
311
312
313async def _owner_has_access(owner: str, auth: AuthenticationManager) -> bool:
314 """Return whether the account an owner id is bound to still has access."""
315 user_id = credential_owner_user_id(owner)
316 if user_id is None:
317 # another kind of owner, whose lifetime is not ours to judge
318 return True
319 # get_user answers None for a deleted as well as a disabled account
320 return await auth.get_user(user_id) is not None
321
322
323def _manual_client_url(address: str) -> str:
324 """Convert a manually configured Sendspin host/IP to a client WebSocket URL."""
325 stripped_address = address.strip()
326 if not stripped_address:
327 raise ValueError("Address is empty")
328
329 if "://" in stripped_address:
330 return stripped_address
331
332 try:
333 parsed_ip = ip_address(stripped_address)
334 except ValueError:
335 pass
336 else:
337 return (
338 f"ws://{format_ip_for_url(str(parsed_ip))}:"
339 f"{DEFAULT_SENDSPIN_CLIENT_PORT}{DEFAULT_SENDSPIN_CLIENT_PATH}"
340 )
341
342 parsed_address = urlsplit(f"//{stripped_address}")
343 if parsed_address.hostname is None:
344 raise ValueError("Address does not contain a host")
345
346 return (
347 f"ws://{format_ip_for_url(parsed_address.hostname)}:"
348 f"{parsed_address.port or DEFAULT_SENDSPIN_CLIENT_PORT}"
349 f"{parsed_address.path or DEFAULT_SENDSPIN_CLIENT_PATH}"
350 )
351
352
353class SendspinProvider(PlayerProvider):
354 """Player Provider for Sendspin."""
355
356 reload_on_streams_network_change = True
357 server_api: SendspinServer
358 unregister_cbs: list[Callable[[], None]]
359 _pending_unregisters: dict[str, asyncio.Event]
360 _bridge_identifiers: dict[str, dict[IdentifierType, str]]
361 _bridge_underlying_players: dict[str, str]
362 _bridge_static_delay_defaults: dict[str, int]
363 _client_event_versions: dict[str, int]
364 _client_event_task_counts: dict[str, int]
365 _manual_ip_config: tuple[str, ...]
366 _virtual_players: dict[str, str]
367 _unloading: bool
368 _hass_available: bool
369
370 def __init__(
371 self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
372 ) -> None:
373 """Initialize a new Sendspin player provider."""
374 super().__init__(mass, manifest, config)
375 # Handle config option for manual IP's. Read a default here: at construction the
376 # config only carries the server defaults + stored raw values (the provider's typed
377 # option entries are resolved and applied by the config controller right after this).
378 manual_ip_config = cast(
379 "list[str]", config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key) or []
380 )
381 self._manual_ip_config = tuple(address for address in manual_ip_config if address.strip())
382 self._pending_unregisters = {}
383 self._bridge_identifiers = {}
384 self._headless_client_ids: set[str] = set()
385 self._bridge_underlying_players = {}
386 self._bridge_static_delay_defaults = {}
387 self._bridge_player_types: dict[str, PlayerType] = {}
388 self._client_event_versions = {}
389 self._client_event_task_counts = {}
390 self._virtual_players = {}
391 self._pin_sessions: dict[str, PinPairingSession] = {}
392 self._pending_pairing_evictions: set[str] = set()
393 self._running_pairing_evictions: set[asyncio.Task[None]] = set()
394 self._management_sessions: dict[str, ManagementSession] = {}
395 self._pairing_config_snapshots: dict[
396 str, tuple[SendspinConnection, ManagementResultData]
397 ] = {}
398 self._unloading = False
399 self._hass_available = False
400 self.unregister_cbs = []
401
402 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
403 """Return Config entries to configure this provider."""
404 return (
405 CONF_ENTRY_MANUAL_DISCOVERY_IPS,
406 ConfigEntry(
407 key=CONF_ALLOW_LEGACY_CLIENTS,
408 type=ConfigEntryType.BOOLEAN,
409 default_value=True,
410 ),
411 ConfigEntry(
412 key=CONF_MIN_PIN_LENGTH,
413 type=ConfigEntryType.INTEGER,
414 range=(4, 12),
415 default_value=DEFAULT_MIN_PIN_LENGTH,
416 ),
417 )
418
419 async def handle_async_init(self) -> None:
420 """Load the persistent server identity and pairing store, then create the server."""
421 self._set_aiosendspin_log_level()
422 storage_dir = Path(self.mass.storage_path) / "sendspin"
423 identity_path = storage_dir / IDENTITY_FILENAME
424 try:
425 identity = await asyncio.to_thread(get_or_create_server_identity, storage_dir)
426 except ValueError as err:
427 raise SetupFailedError(
428 f"The Sendspin server identity at {identity_path} is corrupt: {err}. Restore it "
429 "from a backup, or remove the file to start fresh - every paired device will "
430 "then need to be re-paired."
431 ) from err
432 except OSError as err:
433 raise SetupFailedError(
434 f"Could not read the Sendspin server identity at {identity_path}: {err}. Fix the "
435 "file-access problem and reload; do not delete the file or every paired device "
436 "will need to be re-paired."
437 ) from err
438 pairing_store_path = storage_dir / "pairing_store.json"
439 try:
440 pairing_store = await FileServerPairingStore.open(pairing_store_path)
441 except (ValueError, TypeError, KeyError) as err:
442 raise SetupFailedError(
443 f"The Sendspin pairing store at {pairing_store_path} is corrupt: {err}. Restore it "
444 "from a backup, or remove the file to start fresh - this discards all pairings and "
445 "unpaired-access approvals."
446 ) from err
447 except OSError as err:
448 raise SetupFailedError(
449 f"Could not read the Sendspin pairing store at {pairing_store_path}: {err}. Fix the "
450 "file-access problem and reload; do not delete the file or all pairings will be "
451 "lost."
452 ) from err
453 session_scoped, orphaned = await _evict_stale_pairings(
454 pairing_store, self.mass.webserver.auth
455 )
456 if session_scoped:
457 self.logger.info(
458 "Removed %d session-scoped pairing(s) from a previous run", session_scoped
459 )
460 if orphaned:
461 self.logger.info("Removed %d pairing(s) of a deleted or disabled account", orphaned)
462 allow_legacy_clients = cast("bool", self.config.get_value(CONF_ALLOW_LEGACY_CLIENTS, True))
463 self.server_api = SendspinServer(
464 self.mass.loop,
465 identity,
466 "Music Assistant",
467 self.mass.http_session,
468 pairing_store=pairing_store,
469 allow_unencrypted=allow_legacy_clients,
470 allow_noncompliant_clients=allow_legacy_clients,
471 min_pin_length=cast(
472 "int", self.config.get_value(CONF_MIN_PIN_LENGTH, DEFAULT_MIN_PIN_LENGTH)
473 ),
474 )
475 # Pitch (YINFFT) is the heaviest visualizer DSP and result quality is
476 # still very mixed, needs more testing. Disable it globally for now to
477 # spare low-power hosts.
478 self.server_api.set_visualizer_pitch_enabled(enabled=False)
479 self.unregister_cbs = [
480 self.server_api.add_event_listener(self.event_cb),
481 self.mass.subscribe(self._on_providers_updated, EventType.PROVIDERS_UPDATED),
482 ]
483 # seed the hass availability snapshot so the first (un)load is seen as a change
484 hass = self.mass.get_provider("hass")
485 self._hass_available = hass is not None and hass.available
486
487 async def update_config(self, config: ProviderConfig, changed_keys: set[str]) -> None:
488 """Handle logic when the config is updated."""
489 await super().update_config(config, changed_keys)
490 # a log level(-only) change does not reload the provider,
491 # so realign aiosendspin's logger here
492 if f"values/{CONF_LOG_LEVEL}" in changed_keys:
493 self._set_aiosendspin_log_level()
494
495 def event_cb(self, server: SendspinServer, event: SendspinEvent) -> None:
496 """Event callback registered to the sendspin server."""
497 match event:
498 case ClientAddedEvent(client_id):
499 event_version = self._begin_client_event(client_id)
500 self.mass.create_task(self._handle_client_added(client_id, event_version))
501 case ClientRemovedEvent(client_id):
502 event_version = self._begin_client_event(client_id)
503 self.mass.create_task(self._handle_client_removed(client_id, event_version))
504 case ClientUpdatedEvent(client_id):
505 event_version = self._begin_client_event(client_id)
506 self.mass.create_task(self._handle_client_updated(client_id, event_version))
507 # Transport lifecycle events, implemented in another PR.
508 case ClientConnectedEvent():
509 pass
510 case ClientDisconnectedEvent(client_id):
511 self._pending_pairing_evictions.add(client_id)
512 self.mass.call_later(
513 SESSION_PAIRING_EVICTION_GRACE,
514 self._evict_session_pairing,
515 client_id,
516 task_id=_evict_session_pairing_task_id(client_id),
517 )
518 case _:
519 self.logger.error("Unknown sendspin event: %s", event)
520
521 def on_player_enabled(self, player_id: str) -> None:
522 """Call (by config manager) when a player gets enabled."""
523 # A client that connected while disabled has no player object;
524 # replay the add event so re-enabling takes effect immediately.
525 if (
526 self.server_api.get_client(player_id) is not None
527 and self.mass.players.get_player(player_id) is None
528 ):
529 event_version = self._begin_client_event(player_id)
530 self.mass.create_task(self._handle_client_added(player_id, event_version))
531 return
532 super().on_player_enabled(player_id)
533
534 def register_bridge_identifiers(
535 self, client_id: str, identifiers: dict[IdentifierType, str]
536 ) -> None:
537 """
538 Pre-register extra identifiers for a bridge client.
539
540 Called by bridge managers (Chromecast, AirPlay) before registering an
541 external player, so that the resulting SendspinPlayer carries the parent
542 player's protocol-specific identifiers for cross-protocol matching.
543
544 :param client_id: The bridge client_id that will be used for registration.
545 :param identifiers: Extra identifiers to attach to the SendspinPlayer.
546 """
547 self._bridge_identifiers[client_id] = identifiers
548
549 def register_bridge_underlying_player(self, client_id: str, underlying_player_id: str) -> None:
550 """
551 Pre-register the underlying player a bridge client runs on top of.
552
553 Called by bridge managers before registering an external player, so that
554 the resulting SendspinPlayer carries the derived-transport edge and the
555 protocol linking layer can resolve its parent deterministically.
556
557 :param client_id: The bridge client_id that will be used for registration.
558 :param underlying_player_id: The player_id of the player the bridge rides on.
559 """
560 self._bridge_underlying_players[client_id] = underlying_player_id
561
562 def register_bridge_static_delay_default(self, client_id: str, default_ms: int) -> None:
563 """
564 Register a protocol-specific default static delay for a bridge client.
565
566 If the SendspinPlayer already exists, the default is applied immediately;
567 otherwise it is stashed and picked up when the player is created.
568
569 :param client_id: The bridge client_id for which the default applies.
570 :param default_ms: Model-specific default static delay in milliseconds.
571 """
572 existing = self.mass.players.get_player(client_id)
573 if isinstance(existing, SendspinPlayer):
574 existing.static_delay_default_ms = default_ms
575 # If no user-set value exists, push the new default to the device now
576 # so already-connected clients pick it up without a config edit.
577 if (
578 self.mass.config.get_raw_player_config_value(client_id, CONF_SENDSPIN_STATIC_DELAY)
579 is None
580 ):
581 self.mass.create_task(existing._apply_static_delay())
582 return
583 self._bridge_static_delay_defaults[client_id] = default_ms
584
585 def register_headless_client(self, client_id: str) -> None:
586 """
587 Mark a client id as headless: no MA player is registered for it.
588
589 Called by in-process consumers (e.g. the MilkDrop visualizer tap) whose
590 Sendspin clients exist purely to receive group audio and must never
591 surface as players anywhere in the UI or API.
592 """
593 self._headless_client_ids.add(client_id)
594
595 def register_bridge_player_type(self, client_id: str, player_type: PlayerType) -> None:
596 """
597 Pre-register a PlayerType override for a bridge client.
598
599 Called by bridge managers to set the player type for the resulting
600 player (e.g. PlayerType.LIGHT for Hue Entertainment bridges).
601 """
602 self._bridge_player_types[client_id] = player_type
603
604 async def apply_bridge_claim(
605 self,
606 client_id: str,
607 identifiers: dict[IdentifierType, str],
608 bridge_hello: ClientHelloPayload,
609 underlying_player_id: str | None = None,
610 ) -> bool:
611 """
612 Post-claim an already-registered SendspinPlayer as a bridge client.
613
614 Used when a bridge manager reaches setup_bridge AFTER the external client
615 has already connected on its own (e.g. a JS Cast receiver reconnecting to
616 the server before the Chromecast bridge could register). Attaches the
617 bridge's protocol-specific identifiers so cross-protocol matching can
618 link the SendspinPlayer to its native peer, and replays the bridge
619 hello's supported_commands restriction on the player features.
620
621 :param client_id: The Sendspin client_id whose player should be claimed.
622 :param identifiers: Protocol-specific identifiers (e.g. CAST_UUID) to
623 attach to the player for cross-protocol matching.
624 :param bridge_hello: The bridge's intended ClientHelloPayload. Its
625 player_support.supported_commands gates which volume/mute features
626 the player is allowed to expose.
627 :param underlying_player_id: The player_id of the player the bridge rides
628 on, establishing the derived-transport edge for protocol linking.
629 :return: True if a matching SendspinPlayer was found and updated.
630 """
631 player = self.mass.players.get_player(client_id)
632 if not isinstance(player, SendspinPlayer):
633 return False
634 for id_type, id_value in identifiers.items():
635 player.device_info.add_identifier(id_type, id_value)
636 if underlying_player_id is not None:
637 player._attr_underlying_player_id = underlying_player_id
638 bridge_supported_commands: list[PlayerCommand] = []
639 if bridge_hello.player_support:
640 bridge_supported_commands = list(bridge_hello.player_support.supported_commands)
641 if PlayerCommand.VOLUME in bridge_supported_commands:
642 player._attr_supported_features.add(PlayerFeature.VOLUME_SET)
643 else:
644 player._attr_supported_features.discard(PlayerFeature.VOLUME_SET)
645 if PlayerCommand.MUTE in bridge_supported_commands:
646 player._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
647 else:
648 player._attr_supported_features.discard(PlayerFeature.VOLUME_MUTE)
649 # Expose the claimed player as a protocol bridge, not a standalone web
650 # player. A JS Cast receiver advertises product_name="Web Browser" and
651 # would otherwise be classified as is_web_player → PlayerType.PLAYER
652 # (hidden). Restore protocol semantics so UI links it under its native peer.
653 player.is_web_player = False
654 player._attr_hidden_by_default = False
655 player._attr_expose_to_ha_by_default = True
656 player._attr_type = PlayerType.PROTOCOL
657 self.logger.info(
658 "Bridge claim applied to existing SendspinPlayer %s (client_id=%s)",
659 player.display_name,
660 client_id,
661 )
662 await self.mass.players.register_or_update(player)
663 return True
664
665 async def create_virtual_player(
666 self,
667 owner_instance_id: str,
668 display_name: str,
669 player_id: str | None = None,
670 ) -> str:
671 """
672 Create a hidden, server-side virtual Sendspin player.
673
674 A virtual player owns its own PlayerQueue and leads a native Sendspin
675 group, but never renders audio itself: the audio stream is delivered to
676 the guest players that are attached to it through standard grouping.
677 It is hidden from the UI and not exposed to Home Assistant by default,
678 and is automatically removed when the owning provider unloads.
679
680 :param owner_instance_id: Instance id of the (loaded) provider that owns
681 the virtual player and controls its lifecycle.
682 :param display_name: Human readable name for the virtual player.
683 :param player_id: Optional stable id for the virtual player; a random id
684 is generated when omitted. The id is always prefixed with
685 ``VIRTUAL_PLAYER_ID_PREFIX``.
686 :return: The player_id of the registered virtual player.
687 :raises SetupFailedError: If the virtual player can not be created.
688 """
689 if (owner := self.mass.get_provider(owner_instance_id)) is None:
690 raise SetupFailedError(f"Owner provider {owner_instance_id} is not loaded")
691 if owner.instance_id != owner_instance_id and (
692 len(self.mass.get_provider_instances(owner.domain)) > 1
693 ):
694 raise SetupFailedError(
695 f"Multiple instances exist for {owner_instance_id}: pass an exact instance id"
696 )
697 # normalize a provider domain to the actual instance id
698 owner_instance_id = owner.instance_id
699 if player_id is None:
700 player_id = uuid4().hex
701 elif not re.fullmatch(r"[a-zA-Z0-9_-]+", player_id):
702 raise SetupFailedError(
703 f"Invalid player_id {player_id}: only alphanumerics, '_' and '-' are allowed"
704 )
705 if not player_id.startswith(VIRTUAL_PLAYER_ID_PREFIX):
706 player_id = f"{VIRTUAL_PLAYER_ID_PREFIX}{player_id}"
707 if player_id in self._virtual_players or self.server_api.get_client(player_id) is not None:
708 raise SetupFailedError(f"Virtual player {player_id} already exists")
709 # a persisted config may only be reclaimed by the same owner
710 stored_owner = self._get_virtual_player_config_owner(player_id)
711 if stored_owner is not None and stored_owner != owner_instance_id:
712 raise SetupFailedError(f"Virtual player {player_id} is owned by {stored_owner}")
713 self._virtual_players[player_id] = owner_instance_id
714 try:
715 self._register_virtual_player_client(player_id, display_name)
716 await self._wait_for_virtual_player(player_id)
717 except asyncio.CancelledError:
718 await self._cleanup_failed_virtual_player_creation(player_id)
719 raise
720 except Exception as err:
721 await self._cleanup_failed_virtual_player_creation(player_id)
722 if isinstance(err, SetupFailedError):
723 raise
724 raise SetupFailedError(f"Failed to create virtual player {player_id}") from err
725 # persist the owner so orphaned configs can be swept after a restart
726 self.mass.config.set_raw_player_config_value(
727 player_id, CONF_VIRTUAL_PLAYER_OWNER, owner_instance_id
728 )
729 self.logger.info("Virtual player %s created for %s", player_id, owner_instance_id)
730 return player_id
731
732 async def remove_virtual_player(self, player_id: str) -> None:
733 """
734 Remove a virtual Sendspin player and permanently delete its configuration.
735
736 :param player_id: The player_id returned by create_virtual_player.
737 :raises ValueError: If the given player_id is not a (known) virtual player.
738 """
739 if not player_id.startswith(VIRTUAL_PLAYER_ID_PREFIX) or (
740 player_id not in self._virtual_players
741 and self._get_virtual_player_config_owner(player_id) is None
742 ):
743 raise ValueError(f"{player_id} is not a virtual player")
744 # unregister the player first so the client removed event handler
745 # can not race us with a non-permanent unregister
746 await self.mass.players.unregister(player_id, permanent=True)
747 if self.server_api.get_client(player_id) is not None:
748 await self.server_api.remove_client(player_id)
749 # the config may linger when the player was never registered
750 self.mass.players.delete_player_config(player_id)
751 self._virtual_players.pop(player_id, None)
752 self.logger.info("Virtual player %s removed", player_id)
753
754 def is_virtual_player(self, player_id: str) -> bool:
755 """Return whether the given player_id belongs to a registered virtual player."""
756 return player_id in self._virtual_players
757
758 def get_pin_session(self, client_id: str) -> PinPairingSession | None:
759 """Return the in-flight or just-finished PIN pairing session for a client."""
760 return self._pin_sessions.get(client_id)
761
762 def clear_pin_session(self, client_id: str) -> None:
763 """Drop a finished PIN pairing session (after its outcome has been shown)."""
764 session = self._pin_sessions.get(client_id)
765 if session is not None and session.finished:
766 self._cancel_pin_idle_timeout(client_id)
767 self._pin_sessions.pop(client_id, None)
768 if session.opened_management:
769 self.exit_management(client_id)
770
771 async def start_pin_pairing(
772 self, client_id: str, *, verify: bool = False, static: bool = False
773 ) -> PinPairingSession:
774 """
775 Begin (or retry in place) an operator PIN pairing attempt with a connected client.
776
777 Returns once the client has asked for the PIN, the attempt has failed, or a short
778 feedback window has elapsed, so the caller's first render reflects whether the
779 device-side pairing gesture is still pending. The attempt keeps running until the PIN
780 is supplied via submit_pin (or it times out / is cancelled). A session left retryable
781 by a failed attempt is resumed in place, preserving the chosen method and verify mode.
782
783 :param verify: Re-verify an already-paired device's presence (dynamic PIN only).
784 :param static: Pair with the static PIN even when a dynamic PIN is offered.
785 """
786 session = self._pin_sessions.get(client_id)
787 if session is not None and (session.verify != verify or session.static != static):
788 # a stale session from an earlier run never resumes; the caller's
789 # static/verify choice must win
790 if session.attempt_running:
791 raise SecurityActionError("pairing_error_concurrent")
792 await self.cancel_pin_pairing(client_id)
793 session = None
794 if session is not None and session.can_retry:
795 self._begin_pin_attempt(session)
796 await self._pin_request_feedback(session)
797 return session
798 if session is not None and session.attempt_running:
799 return session
800 client = self.server_api.get_client(client_id)
801 # A disconnected client keeps its last hello, so info alone does not prove it is connected.
802 info = client.info_or_none if client is not None and client.is_connected else None
803 if info is None:
804 raise SecurityActionError("pairing_error_not_connected")
805 offered = effective_pair_methods(info, self.pairing_config_snapshot(client_id))
806 method = self._pick_pin_method(offered, verify=verify, static=static)
807 pin_length = (
808 # From the hello advertisement, not the live config: that is what the server's own
809 # negotiation reads, so the predicted length matches the PIN the device derives.
810 negotiated_pin_length(
811 pair_method_descriptor(info.supported_pair_methods or (), PairMethod.DYNAMIC_PIN),
812 self.server_api.min_pin_length,
813 )
814 if method is PairMethod.DYNAMIC_PIN
815 else None
816 )
817 session = PinPairingSession(
818 client_id=client_id,
819 method=method,
820 pin_future=self.mass.loop.create_future(),
821 verify=verify,
822 static=static,
823 pin_length=pin_length,
824 opened_management=await self._open_pairing_window(client_id),
825 )
826 self._pin_sessions[client_id] = session
827 self._begin_pin_attempt(session)
828 await self._pin_request_feedback(session)
829 return session
830
831 def submit_pin(self, client_id: str, pin: str) -> None:
832 """Deliver the operator-entered PIN to the in-flight pairing attempt."""
833 session = self._pin_sessions.get(client_id)
834 if session is None or session.task is None:
835 raise SecurityActionError("pairing_error_no_pin_session")
836 if not session.pin_future.done():
837 session.pin_future.set_result(pin.strip())
838
839 async def cancel_pin_pairing(self, client_id: str) -> None:
840 """Abort an in-flight or parked PIN pairing session, restoring normal service."""
841 session = self._pin_sessions.pop(client_id, None)
842 if session is None:
843 return
844 self._cancel_pin_idle_timeout(client_id)
845 await self._end_pairing_quietly(client_id)
846 if session.task is not None:
847 with suppress(Exception):
848 await session.task
849 if session.opened_management:
850 self.exit_management(client_id)
851 await self._refresh_player(client_id)
852
853 async def pair_with_token(
854 self, client_id: str, token_value: str, owner: str | None = None
855 ) -> None:
856 """
857 Pair a connected client using its pasted pairing token.
858
859 :param client_id: The connected client to pair.
860 :param token_value: The client's pairing token.
861 :param owner: Application-defined authorization id to bind the pairing to;
862 ``None`` is a standalone pairing.
863 """
864 session = self._pin_sessions.get(client_id)
865 if session is not None and session.attempt_running:
866 raise SecurityActionError("pairing_error_concurrent")
867 try:
868 token = decode_token(token_value)
869 except ValueError as err:
870 raise SecurityActionError("pairing_error_token_invalid") from err
871 if token.client_id != client_id:
872 raise SecurityActionError("pairing_error_token_mismatch")
873 try:
874 await self.server_api.initiate_pairing(
875 client_id,
876 PairingAttempt(PairMethod.PAIRING_PSK, pairing_psk=token.pairing_psk, owner=owner),
877 )
878 except PairingAbortError:
879 # Token pairing is single-shot; unpark the connection before surfacing the failure.
880 await self._end_pairing_quietly(client_id)
881 raise
882 except HandshakeAbortedError as err:
883 # A client that does not recognize the token's PSK closes the connection
884 # without an application-level error (spec); the server has disconnected it.
885 raise PairingError(
886 "the token was rejected by the device; make sure it is correct"
887 ) from err
888 await self._refresh_player(client_id)
889
890 async def unpair_client(self, client_id: str) -> None:
891 """Drop the pairing with a connected client (both sides forget the credential)."""
892 await self.server_api.unpair(client_id)
893 await self._refresh_player(client_id)
894
895 async def set_trusted_unpaired(self, client_id: str, enabled: bool) -> None:
896 """Approve or revoke unpaired (unauthenticated) playback for a client."""
897 if enabled:
898 await self.server_api.trust_unpaired(client_id)
899 else:
900 await self.server_api.untrust_unpaired(client_id)
901 await self._refresh_player(client_id)
902
903 async def pair_web_player(self, pairing_token: str) -> None:
904 """
905 Pair the built-in web player that minted the given pairing token.
906
907 :param pairing_token: The calling web player's version 0 pairing token.
908 """
909 # The token names the client it belongs to, so this works on every transport,
910 # including Ingress where the session carries no client id at all.
911 try:
912 client_id = decode_token(pairing_token).client_id
913 except ValueError as err:
914 raise InvalidCommand(
915 "The pairing token is not valid",
916 translation_key="pairing_error_token_invalid",
917 translation_owner=self.translation_owner,
918 ) from err
919 player = await self._await_connected_client(client_id)
920 if not player.is_web_player:
921 raise InvalidCommand(f"Client {client_id} is not a built-in web player")
922 security = player.api.connection_security
923 # An unencrypted client cannot hold a pairing, same as the setup flow refuses it
924 if security is None:
925 return
926 record = await self.server_api.pairing_store.record_by_client_id(client_id)
927 # The pairing is bound to the caller's account: a guest's ends with their
928 # session or access, a full user's with their account. Only pairings made
929 # through the settings/setup flow are standalone.
930 user = get_current_user()
931 owner = credential_owner(user) if user is not None else None
932 # We already paired this web player so no action needed. A record on its own is not
933 # enough: the client can have lost its half, leaving a record it cannot authenticate.
934 # A record bound to another account is re-paired instead (a browser keeps its
935 # identity across logins), so the lifetime always follows the current caller.
936 if (
937 security.psk_category is PskCategory.LONG_TERM
938 and record is not None
939 and (record.owner is None or record.owner == owner)
940 ):
941 return
942 try:
943 await self.pair_with_token(client_id, pairing_token, owner=owner)
944 except (
945 SecurityActionError,
946 PairingError,
947 HandshakeAbortedError,
948 TimeoutError,
949 OSError,
950 ) as err:
951 # Report the reason without the request, which carries the pairing token.
952 alert = error_alert(err)
953 raise InvalidCommand(
954 f"Cannot pair web player {client_id}",
955 translation_key=alert.key,
956 translation_args=alert.params,
957 translation_owner=self.translation_owner,
958 ) from err
959 # The handshake takes a moment, in which an eviction can have missed this record.
960 if owner is not None and not await _owner_has_access(owner, self.mass.webserver.auth):
961 await self._evict_pairings_for_owner(owner)
962
963 def get_management_session(self, client_id: str) -> ManagementSession | None:
964 """Return the client's management session, dropping one whose connection is gone."""
965 session = self._management_sessions.get(client_id)
966 if session is None:
967 return None
968 client = self.server_api.get_client(client_id)
969 if client is None or client.connection is not session.connection:
970 self._drop_management_session(session)
971 return None
972 return session
973
974 def enter_management(self, client_id: str) -> ManagementSession:
975 """Open (or refresh) the operator management session for a paired connected client."""
976 if (session := self.get_management_session(client_id)) is not None:
977 self._arm_management_idle_timeout(session)
978 return session
979 try:
980 connection = self.server_api.enable_management(client_id)
981 except RuntimeError as err:
982 raise SecurityActionError("management_error_generic", detail=str(err)) from err
983 session = ManagementSession(client_id=client_id, connection=connection)
984 self._management_sessions[client_id] = session
985 self._arm_management_idle_timeout(session)
986 return session
987
988 def exit_management(self, client_id: str) -> None:
989 """Close the client's management session, restoring normal server admission."""
990 if (session := self._management_sessions.get(client_id)) is not None:
991 self._drop_management_session(session)
992
993 async def management_get_pairing_config(self, client_id: str) -> ManagementResultData:
994 """Fetch the device's pairing configuration over its management session."""
995 session = self._management_session_or_raise(client_id)
996 async with session.lock:
997 self._arm_management_idle_timeout(session)
998 result, data, _ = await self._management_call(
999 session.connection, session.connection.get_pairing_config()
1000 )
1001 _check_management_result(result)
1002 self._pairing_config_snapshots[client_id] = (session.connection, data)
1003 return data
1004
1005 async def management_open_pairing_window(self, client_id: str) -> None:
1006 """Open a pairing window on the device over its management session, sparing the gesture."""
1007 session = self._management_session_or_raise(client_id)
1008 async with session.lock:
1009 self._arm_management_idle_timeout(session)
1010 result = await self._management_call(
1011 session.connection, session.connection.open_pairing_window()
1012 )
1013 _check_management_result(result)
1014
1015 def pairing_config_snapshot(self, client_id: str) -> ManagementResultData | None:
1016 """
1017 Return the last management-fetched pairing config for the client's current connection.
1018
1019 While the connection it was fetched on is still the active one, the snapshot is
1020 fresher than the hello advertisement (which cannot change until reconnect); after a
1021 reconnect the new hello is authoritative and the snapshot is dropped.
1022 """
1023 snapshot = self._pairing_config_snapshots.get(client_id)
1024 if snapshot is None:
1025 return None
1026 connection, data = snapshot
1027 client = self.server_api.get_client(client_id)
1028 if client is None or client.connection is not connection:
1029 self._pairing_config_snapshots.pop(client_id, None)
1030 return None
1031 return data
1032
1033 async def management_set_pairing_config(
1034 self, client_id: str, patch: ManagementSetPairingConfigPayload
1035 ) -> None:
1036 """Apply a pairing-config patch on the device and refresh the cached snapshot."""
1037 session = self._management_session_or_raise(client_id)
1038 async with session.lock:
1039 self._arm_management_idle_timeout(session)
1040 result = await self._management_call(
1041 session.connection, session.connection.set_pairing_config(patch)
1042 )
1043 _check_management_result(result)
1044 await self.management_get_pairing_config(client_id)
1045
1046 @property
1047 def supported_features(self) -> set[ProviderFeature]:
1048 """Return the features supported by this Provider."""
1049 return {
1050 ProviderFeature.SYNC_PLAYERS,
1051 }
1052
1053 async def loaded_in_mass(self) -> None:
1054 """Call after the provider has been loaded."""
1055 await super().loaded_in_mass()
1056 self.unregister_cbs.append(
1057 self.mass.register_api_command(
1058 "sendspin/pair_web_player",
1059 self.pair_web_player,
1060 # Guests pair their own web player too, since party mode plays through Sendspin.
1061 required_scope=Scope.PLAYERS_CONTROL,
1062 )
1063 )
1064 # Pairings bound to a user's access must not outlive it (guest access switched
1065 # off, account deleted, all sessions revoked).
1066 self.unregister_cbs.append(
1067 self.mass.webserver.auth.subscribe_user_access_revoked(self._on_user_access_revoked)
1068 )
1069 self._remove_orphan_virtual_player_configs()
1070 # Start server for handling incoming Sendspin connections from clients
1071 # and mDNS discovery of new clients
1072 await self.server_api.start_server(
1073 port=SENDSPIN_SERVER_PORT,
1074 host=self.mass.streams.bind_ip,
1075 advertise_addresses=[self.mass.streams.publish_ip],
1076 )
1077 for address in self._manual_ip_config:
1078 try:
1079 url = _manual_client_url(address)
1080 except ValueError as err:
1081 self.logger.warning(
1082 "Ignoring invalid manual Sendspin client address %s: %s", address, err
1083 )
1084 continue
1085 self.logger.debug("Connecting to manually configured Sendspin client at %s", url)
1086 self.server_api.connect_to_client(
1087 url,
1088 retry_initial_connection=True,
1089 retry_indefinitely=True,
1090 )
1091
1092 async def unload(self, is_removed: bool = False) -> None:
1093 """
1094 Handle unload/close of the provider.
1095
1096 Called when provider is deregistered (e.g. MA exiting or config reloading).
1097
1098 :param is_removed: True when the provider is removed from the configuration.
1099 """
1100 self._unloading = True
1101 # call_later timers are not swept by mass.stop(), so cancel them explicitly here.
1102 for session in self._pin_sessions.values():
1103 if session.task is not None:
1104 session.task.cancel()
1105 self._cancel_pin_idle_timeout(session.client_id)
1106 self._pin_sessions.clear()
1107 for client_id in self._pending_pairing_evictions:
1108 self.mass.cancel_timer(_evict_session_pairing_task_id(client_id))
1109 self._pending_pairing_evictions.clear()
1110 for management_session in self._management_sessions.values():
1111 self.mass.cancel_timer(_management_idle_task_id(management_session.client_id))
1112 self._management_sessions.clear()
1113 self._pairing_config_snapshots.clear()
1114 if self._running_pairing_evictions:
1115 await asyncio.gather(*self._running_pairing_evictions, return_exceptions=True)
1116 player_ids = [player.player_id for player in self.players]
1117 # Stop the Sendspin server
1118 await self.server_api.close()
1119
1120 for cb in self.unregister_cbs:
1121 cb()
1122 self.unregister_cbs = []
1123 self._client_event_task_counts.clear()
1124 self._client_event_versions.clear()
1125 self._virtual_players.clear()
1126 await asyncio.gather(
1127 *(
1128 self.mass.players.unregister(player_id, permanent=is_removed)
1129 for player_id in player_ids
1130 ),
1131 return_exceptions=True,
1132 )
1133
1134 def _set_aiosendspin_log_level(self) -> None:
1135 """Keep aiosendspin's (very chatty) logging quiet unless verbose logging is enabled."""
1136 # aiosendspin logs every protocol message of every client session at debug
1137 # level, so only pass that through when verbose logging is enabled
1138 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
1139 logging.getLogger("aiosendspin").setLevel(logging.DEBUG)
1140 else:
1141 logging.getLogger("aiosendspin").setLevel(self.logger.level + 10)
1142
1143 def _begin_client_event(self, client_id: str) -> int:
1144 """Increment version and in-flight task count for a client event."""
1145 version = self._client_event_versions.get(client_id, 0) + 1
1146 self._client_event_versions[client_id] = version
1147 self._client_event_task_counts[client_id] = (
1148 self._client_event_task_counts.get(client_id, 0) + 1
1149 )
1150 return version
1151
1152 def _finish_client_event(self, client_id: str) -> None:
1153 """Drop in-flight bookkeeping and prune version state when idle."""
1154 task_count = self._client_event_task_counts.get(client_id, 0)
1155 if task_count <= 1:
1156 self._client_event_task_counts.pop(client_id, None)
1157 self._client_event_versions.pop(client_id, None)
1158 return
1159 self._client_event_task_counts[client_id] = task_count - 1
1160
1161 def _is_current_client_event(self, client_id: str, event_version: int) -> bool:
1162 """Return True if the event version is still the latest for the client."""
1163 return self._client_event_versions.get(client_id) == event_version
1164
1165 async def _apply_hass_esphome_enrichment(self, players: Sequence[SendspinBasePlayer]) -> None:
1166 """
1167 Apply Home Assistant-sourced enrichment to ESPHome-backed Sendspin players.
1168
1169 Applies the HA display name and resolves the HA media_player entity that
1170 announcements are relayed to: ESPHome devices support announcements
1171 natively, but that capability is only reachable through the HA API.
1172 Players are correlated by MAC address (the Sendspin client id).
1173 """
1174 esphome_players = [
1175 player for player in players if player.device_info.manufacturer == "ESPHome"
1176 ]
1177 if not esphome_players:
1178 return
1179 hass = cast("HomeAssistantProvider | None", self.mass.get_provider("hass"))
1180 if hass is None or not hass.available:
1181 for player in esphome_players:
1182 if isinstance(player, SendspinPlayer):
1183 player.set_hass_announce_entity(None)
1184 return
1185 try:
1186 device_infos = await hass.get_media_player_device_infos(
1187 [player.player_id for player in esphome_players], platform="esphome"
1188 )
1189 except Exception as err:
1190 self.logger.warning("Failed to apply Home Assistant enrichment: %s", err)
1191 return
1192 for player in esphome_players:
1193 device_info = device_infos.get(player.player_id.lower())
1194 if device_info is not None and device_info["name"]:
1195 player._attr_name = device_info["name"]
1196 if isinstance(player, SendspinPlayer):
1197 player.set_hass_announce_entity(
1198 device_info["announce_entity_id"] if device_info is not None else None
1199 )
1200
1201 async def _refresh_hass_esphome_enrichment(self) -> None:
1202 """Re-apply the HA enrichment to all registered ESPHome players (in place)."""
1203 players = [
1204 player
1205 for player in self.players
1206 if isinstance(player, SendspinBasePlayer)
1207 and player.device_info.manufacturer == "ESPHome"
1208 ]
1209 if not players:
1210 return
1211 await self._apply_hass_esphome_enrichment(players)
1212 for player in players:
1213 if player.initialized.is_set():
1214 player.update_state()
1215
1216 def _create_player(
1217 self,
1218 client_id: str,
1219 sendspin_client: SendspinClient,
1220 existing_player: Player | None,
1221 initial_hello: ClientHelloPayload | None = None,
1222 ) -> SendspinBasePlayer:
1223 """
1224 Create the appropriate player class based on client roles.
1225
1226 Priority: player role -> SendspinPlayer, metadata role -> DISPLAY,
1227 visualizer role -> VISUALIZER, source role -> SendspinSourcePlayer.
1228 Bridge-registered type overrides the default.
1229 """
1230 extra_ids = self._bridge_identifiers.pop(client_id, None)
1231 bridge_player_type = self._bridge_player_types.pop(client_id, None)
1232 underlying_player_id = self._bridge_underlying_players.pop(client_id, None)
1233 if underlying_player_id is None and existing_player is not None:
1234 underlying_player_id = existing_player.underlying_player_id
1235 static_delay_default_ms = self._bridge_static_delay_defaults.pop(client_id, None)
1236 if static_delay_default_ms is None and isinstance(existing_player, SendspinPlayer):
1237 static_delay_default_ms = existing_player.static_delay_default_ms
1238
1239 # Select on negotiated (not active) roles: activation follows pairing/trust state,
1240 # which must not change the player class.
1241 negotiated_families = {
1242 role_family(role_id) for role_id in sendspin_client.negotiated_role_ids
1243 }
1244 has_player_role = "player" in negotiated_families
1245 has_metadata_role = "metadata" in negotiated_families
1246 has_visualizer_role = "visualizer" in negotiated_families
1247
1248 if has_player_role:
1249 audio_player = SendspinPlayer(self, client_id, initial_hello=initial_hello)
1250 if isinstance(existing_player, SendspinPlayer):
1251 audio_player.preserve_control_features_from(existing_player)
1252 player: SendspinBasePlayer = audio_player
1253 elif has_metadata_role or has_visualizer_role:
1254 default_type = PlayerType.DISPLAY if has_metadata_role else PlayerType.VISUALIZER
1255 viz_player = SendspinVisualizerPlayer(self, client_id, initial_hello=initial_hello)
1256 viz_player._attr_type = bridge_player_type or default_type
1257 player = viz_player
1258 elif "source" in negotiated_families:
1259 # Capture-only device: a SendspinPlayer here would advertise playback it
1260 # cannot do. It only needs a settings page.
1261 player = SendspinSourcePlayer(self, client_id, initial_hello=initial_hello)
1262 else:
1263 audio_player = SendspinPlayer(self, client_id, initial_hello=initial_hello)
1264 if isinstance(existing_player, SendspinPlayer):
1265 audio_player.preserve_control_features_from(existing_player)
1266 player = audio_player
1267
1268 if extra_ids:
1269 for id_type, id_value in extra_ids.items():
1270 player.device_info.add_identifier(id_type, id_value)
1271 if underlying_player_id is not None:
1272 player._attr_underlying_player_id = underlying_player_id
1273 if static_delay_default_ms is not None and isinstance(player, SendspinPlayer):
1274 player.static_delay_default_ms = static_delay_default_ms
1275 return player
1276
1277 @staticmethod
1278 def _pick_pin_method(
1279 offered: list[PairMethodDescriptor], *, verify: bool = False, static: bool = False
1280 ) -> PairMethod:
1281 """
1282 Select the preferred usable PIN method from the client's offer.
1283
1284 :param verify: Restrict to dynamic PIN, the only method that proves device presence.
1285 :param static: Restrict to static PIN, overriding the dynamic-first default.
1286 """
1287 wanted: tuple[PairMethod, ...]
1288 if verify:
1289 wanted = (PairMethod.DYNAMIC_PIN,)
1290 elif static:
1291 wanted = (PairMethod.STATIC_PIN,)
1292 else:
1293 wanted = (PairMethod.DYNAMIC_PIN, PairMethod.STATIC_PIN)
1294 offered_methods = {descriptor.method for descriptor in offered}
1295 for method in wanted:
1296 if method in offered_methods:
1297 return method
1298 raise SecurityActionError("pairing_error_no_pin_method")
1299
1300 async def _open_pairing_window(self, client_id: str) -> bool:
1301 """
1302 Open a pairing window over management, sparing the operator the device-side gesture.
1303
1304 Only works before the attempt starts: the pairing activate takes management off the
1305 connection's activities. Returns whether a management session was opened here,
1306 for the caller to close once the pairing session ends.
1307 """
1308 opened = self.get_management_session(client_id) is None
1309 keep = False
1310 try:
1311 self.enter_management(client_id)
1312 await self.management_open_pairing_window(client_id)
1313 keep = opened
1314 except SecurityActionError as err:
1315 self.logger.debug("No pairing window opened on %s: %s", client_id, err)
1316 finally:
1317 # Hand back a session opened here unless the caller inherits it, cancellation included.
1318 if opened and not keep:
1319 self.exit_management(client_id)
1320 return keep
1321
1322 def _begin_pin_attempt(self, session: PinPairingSession) -> None:
1323 """Start or restart a pairing attempt for the session, resetting per-attempt state."""
1324 self._cancel_pin_idle_timeout(session.client_id)
1325 session.error = None
1326 session.retryable = False
1327 session.pin_request_event.clear()
1328 session.gesture_event.clear()
1329 if session.pin_future.done():
1330 session.pin_future = self.mass.loop.create_future()
1331 session.task = self.mass.create_task(self._run_pin_pairing(session))
1332
1333 async def _pin_request_feedback(self, session: PinPairingSession) -> None:
1334 """Wait briefly for the attempt to reach the PIN wait (or end), for an accurate render."""
1335 if session.task is None:
1336 return
1337 waiter = self.mass.create_task(session.wait_first_message())
1338 try:
1339 await asyncio.wait((waiter,), timeout=PIN_REQUEST_FEEDBACK_TIMEOUT)
1340 finally:
1341 waiter.cancel()
1342
1343 async def _run_pin_pairing(self, session: PinPairingSession) -> None:
1344 """Run one PIN pairing attempt, classifying the outcome for the UI."""
1345
1346 def pin_provider() -> asyncio.Future[str]:
1347 # Invoked only once the client's pair-init has arrived (post-gesture).
1348 session.pin_request_event.set()
1349 return session.pin_future
1350
1351 def on_pair_pending() -> None:
1352 session.gesture_event.set()
1353
1354 try:
1355 await self.server_api.initiate_pairing(
1356 session.client_id,
1357 PairingAttempt(
1358 session.method,
1359 pin_provider=pin_provider,
1360 verify=session.verify,
1361 on_pair_pending=on_pair_pending,
1362 languages=self._spoken_pin_languages()
1363 if session.method is PairMethod.DYNAMIC_PIN
1364 else (),
1365 ),
1366 )
1367 except PairingTimeoutError as err:
1368 # The device never answered; aiosendspin cancelled the attempt in band and left
1369 # pairing, so the connection is still usable and a retry can start afresh.
1370 session.error = err
1371 self.logger.debug("PIN pairing with %s timed out: %s", session.client_id, err)
1372 session.retryable = True
1373 self._arm_pin_idle_timeout(session)
1374 except PairingAbortError as err:
1375 if (
1376 isinstance(err, LocalPairingAbortError)
1377 and err.reason is PairAbortReason.USER_CANCELLED
1378 ):
1379 # Our own end_pairing cancelled this attempt; the connection is already restored.
1380 return
1381 session.error = err
1382 self.logger.debug("PIN pairing with %s aborted: %s", session.client_id, err)
1383 session.retryable = True
1384 self._arm_pin_idle_timeout(session)
1385 except Exception as err:
1386 # A non-abort failure: the server has already disconnected the client.
1387 session.error = err
1388 self.logger.debug("PIN pairing with %s failed: %s", session.client_id, err)
1389 else:
1390 await self._refresh_player(session.client_id)
1391 finally:
1392 if not session.pin_future.done():
1393 session.pin_future.cancel()
1394
1395 def _spoken_pin_languages(self) -> tuple[str, ...]:
1396 """
1397 Return the language preference for a spoken dynamic PIN, most preferred first.
1398
1399 The metadata locale is the only server-wide language setting, so it stands in for the
1400 operator's own preference.
1401 """
1402 locale = self.mass.metadata.locale.replace("_", "-")
1403 language = locale.split("-")[0]
1404 return (locale, language) if language != locale else (locale,)
1405
1406 def _arm_pin_idle_timeout(self, session: PinPairingSession) -> None:
1407 """Schedule restoration of the connection if a failed attempt is left unretried."""
1408 self.mass.call_later(
1409 PIN_RETRY_IDLE_TIMEOUT,
1410 self._pin_idle_timeout,
1411 session,
1412 task_id=_pin_idle_task_id(session.client_id),
1413 )
1414
1415 def _cancel_pin_idle_timeout(self, client_id: str) -> None:
1416 """Cancel a pairing idle timeout, whether still pending or already firing."""
1417 task_id = _pin_idle_task_id(client_id)
1418 self.mass.cancel_timer(task_id)
1419 self.mass.cancel_task(task_id)
1420
1421 async def _pin_idle_timeout(self, session: PinPairingSession) -> None:
1422 """Terminate an abandoned retryable session, restoring the connection to service."""
1423 session.retryable = False
1424 session.error = TimeoutError("timed out waiting for a pairing retry")
1425 await self._end_pairing_quietly(session.client_id)
1426 await self._refresh_player(session.client_id)
1427
1428 async def _end_pairing_quietly(self, client_id: str) -> None:
1429 """End pairing on a client, tolerating an already-gone connection."""
1430 try:
1431 await self.server_api.end_pairing(client_id)
1432 except Exception as err:
1433 self.logger.debug("Ending pairing for %s failed: %s", client_id, err)
1434
1435 def _management_session_or_raise(self, client_id: str) -> ManagementSession:
1436 """Return the client's management session or raise if none is open."""
1437 session = self.get_management_session(client_id)
1438 if session is None:
1439 raise SecurityActionError("management_error_no_session")
1440 return session
1441
1442 async def _management_call[T](self, connection: SendspinConnection, request: Awaitable[T]) -> T:
1443 """Run a management request with a timeout, mapping transport failures to SecurityActionError."""
1444 try:
1445 async with asyncio.timeout(MANAGEMENT_REQUEST_TIMEOUT):
1446 return await request
1447 except TimeoutError as err:
1448 # Replies match requests by order with no id, so a timed-out request left in
1449 # flight would desync the next one; drop the connection to reset the channel.
1450 await connection.disconnect()
1451 raise SecurityActionError("management_error_timeout") from err
1452 except RuntimeError as err:
1453 raise SecurityActionError("management_error_generic", detail=str(err)) from err
1454
1455 def _arm_management_idle_timeout(self, session: ManagementSession) -> None:
1456 """(Re)start the idle countdown that closes an abandoned management session."""
1457 self.mass.call_later(
1458 MANAGEMENT_IDLE_TIMEOUT,
1459 self._drop_management_session,
1460 session,
1461 task_id=_management_idle_task_id(session.client_id),
1462 )
1463
1464 def _drop_management_session(self, session: ManagementSession) -> None:
1465 """Remove a management session, releasing its hold on the connection."""
1466 self.mass.cancel_timer(_management_idle_task_id(session.client_id))
1467 self._management_sessions.pop(session.client_id, None)
1468 with suppress(Exception):
1469 session.connection.disable_management()
1470
1471 async def _refresh_player(self, client_id: str) -> None:
1472 """Re-evaluate a registered player after a pairing/trust change (in place)."""
1473 player = self.mass.players.get_player(client_id)
1474 if not isinstance(player, SendspinBasePlayer) or not player.initialized.is_set():
1475 return
1476 # A trust change (de)activates roles; role instances are recreated on activation,
1477 # so pushed config (preferred format, static delay) must be re-applied.
1478 await player.on_config_updated()
1479 player.update_state()
1480
1481 def _on_user_access_revoked(self, user: User) -> None:
1482 """Handle a user's access being withdrawn (tokens revoked or account deleted)."""
1483 # Both owner forms, so the match cannot depend on the user's role at mint time.
1484 for owner in credential_owners_for_user_id(user.user_id):
1485 task = self.mass.create_task(self._evict_pairings_for_owner(owner))
1486 self._running_pairing_evictions.add(task)
1487 task.add_done_callback(self._running_pairing_evictions.discard)
1488
1489 async def _evict_pairings_for_owner(self, owner: str) -> None:
1490 """Drop every pairing bound to ``owner``, unpairing connected clients in-band."""
1491 if self._unloading:
1492 return
1493 pairing_store = self.server_api.pairing_store
1494 for record in await pairing_store.records_by_owner(owner):
1495 # records_by_owner was read once: only withdraw what this owner still holds.
1496 current = await pairing_store.record_by_client_id(record.client_id)
1497 if current is None or current.owner != owner:
1498 continue
1499 try:
1500 await self.server_api.unpair(record.client_id)
1501 except ValueError:
1502 # Not connected: there is no client half to notify, drop only our record.
1503 await pairing_store.remove_record(record.client_id)
1504 self.logger.info(
1505 "Removed the pairing of client %s: its owner's access was revoked",
1506 record.client_id,
1507 )
1508 await self._refresh_player(record.client_id)
1509
1510 async def _evict_session_pairing(self, client_id: str) -> None:
1511 """Drop a disconnected client's session-scoped pairing (a no-op for durable ones)."""
1512 self._pending_pairing_evictions.discard(client_id)
1513 if self._unloading:
1514 return
1515 client = self.server_api.get_client(client_id)
1516 if client is not None and client.is_connected:
1517 # Already reconnected: the pairing lives until the connection truly ends.
1518 return
1519 record = await self.server_api.pairing_store.record_by_client_id(client_id)
1520 if record is None or record.owner is None or not is_session_scoped_owner(record.owner):
1521 return
1522 await self.server_api.pairing_store.remove_record(client_id)
1523 self.logger.info("Removed the session-scoped pairing of client %s on disconnect", client_id)
1524 await self._refresh_player(client_id)
1525
1526 async def _await_connected_client(self, client_id: str) -> SendspinBasePlayer:
1527 """Return a client's fully registered player, waiting for its connection to land first."""
1528
1529 # A web player asks to be paired a beat before its Sendspin handshake lands, and
1530 # the pairing refresh needs a fully registered player to re-apply config onto.
1531 def _registered_player() -> SendspinBasePlayer | None:
1532 client = self.server_api.get_client(client_id)
1533 if client is None or not client.is_connected:
1534 return None
1535 player = self.mass.players.get_player(client_id)
1536 if isinstance(player, SendspinBasePlayer) and player.initialized.is_set():
1537 return player
1538 return None
1539
1540 player = await _poll_until(_registered_player, WEB_PLAYER_CONNECT_TIMEOUT)
1541 if player is None:
1542 raise InvalidCommand(f"Client {client_id} did not register")
1543 return player
1544
1545 async def _handle_client_added(self, client_id: str, event_version: int) -> None:
1546 """Handle a new client connection asynchronously."""
1547 try:
1548 if self._unloading or client_id in self._headless_client_ids:
1549 return
1550 sendspin_client = self.server_api.get_client(client_id)
1551 if sendspin_client is None:
1552 self.logger.debug("Client %s disconnected before add handling started", client_id)
1553 return
1554 bridge_hello_snapshot = None
1555 if (
1556 client_id in self._bridge_identifiers
1557 and (bridge_hello := sendspin_client.info_or_none) is not None
1558 ):
1559 # Snapshot the bridges hello before a reconnect can overwrite it
1560 bridge_hello_snapshot = deepcopy(bridge_hello)
1561 if pending_event := self._pending_unregisters.get(client_id):
1562 self.logger.debug(
1563 "Waiting for pending unregister of %s before registering", client_id
1564 )
1565 await pending_event.wait()
1566 if not self._is_current_client_event(client_id, event_version):
1567 self.logger.debug("Skipping stale add event for %s after waiting", client_id)
1568 return
1569 # Check if client still exists (may have disconnected while waiting)
1570 sendspin_client = self.server_api.get_client(client_id)
1571 if sendspin_client is None:
1572 self.logger.debug("Client %s disconnected before hello completed", client_id)
1573 return
1574 # Wait for client hello to be processed (info becomes available)
1575 # ClientAddedEvent fires before the hello handshake completes
1576 for _ in range(50): # Wait up to 5 seconds
1577 if sendspin_client.info_or_none is not None:
1578 break
1579 await asyncio.sleep(0.1)
1580 else:
1581 self.logger.warning("Client %s hello not received within timeout", client_id)
1582 return
1583 if not self._is_current_client_event(client_id, event_version):
1584 self.logger.debug("Skipping stale add event for %s", client_id)
1585 return
1586 if not self.mass.config.get_raw_player_config_value(client_id, CONF_ENABLED, True):
1587 self.logger.debug("Ignoring disabled sendspin client: %s", client_id)
1588 return
1589 existing_player = self.mass.players.get_player(client_id)
1590 preserved_identifiers = (
1591 dict(existing_player.device_info.identifiers) if existing_player is not None else {}
1592 )
1593 if existing_player is not None:
1594 self.logger.debug("Refreshing existing player object for %s", client_id)
1595 await self.mass.players.unregister(client_id)
1596 if not self._is_current_client_event(client_id, event_version):
1597 self.logger.debug("Skipping stale add event for %s after unregister", client_id)
1598 return
1599 sendspin_client = self.server_api.get_client(client_id)
1600 if sendspin_client is None:
1601 self.logger.debug("Client %s disconnected after unregister", client_id)
1602 return
1603
1604 player = self._create_player(
1605 client_id, sendspin_client, existing_player, bridge_hello_snapshot
1606 )
1607 for id_type, id_value in preserved_identifiers.items():
1608 player.device_info.add_identifier(id_type, id_value)
1609 self.logger.debug("Client %s connected", client_id)
1610 await self._apply_hass_esphome_enrichment([player])
1611 if not self._is_current_client_event(client_id, event_version):
1612 self.logger.debug("Skipping stale add event for %s after HA enrichment", client_id)
1613 player._unsubscribe_client_callbacks()
1614 return
1615 try:
1616 await self.mass.players.register(player)
1617 except AlreadyRegisteredError:
1618 self.logger.debug(
1619 "Client %s already registered while handling add event", client_id
1620 )
1621 player._unsubscribe_client_callbacks()
1622 finally:
1623 self._finish_client_event(client_id)
1624
1625 async def _handle_client_removed(self, client_id: str, event_version: int) -> None:
1626 """Handle a client disconnection asynchronously."""
1627 try:
1628 if self._unloading:
1629 return
1630 if client_id in self._headless_client_ids:
1631 self._headless_client_ids.discard(client_id)
1632 return
1633 self.logger.debug("Client %s disconnected", client_id)
1634 if not self._is_current_client_event(client_id, event_version):
1635 self.logger.debug("Skipping stale remove event for %s", client_id)
1636 return
1637 unregister_event = asyncio.Event()
1638 self._pending_unregisters[client_id] = unregister_event
1639 try:
1640 await self.mass.players.unregister(client_id)
1641 finally:
1642 self._pending_unregisters.pop(client_id, None)
1643 unregister_event.set()
1644 finally:
1645 self._finish_client_event(client_id)
1646
1647 async def _handle_client_updated(self, client_id: str, event_version: int) -> None:
1648 """Handle a client whose hello payload changed on reconnect."""
1649 try:
1650 if self._unloading:
1651 return
1652 if pending_event := self._pending_unregisters.get(client_id):
1653 self.logger.debug("Waiting for pending unregister of %s before updating", client_id)
1654 await pending_event.wait()
1655 if not self._is_current_client_event(client_id, event_version):
1656 self.logger.debug("Skipping stale update event for %s after waiting", client_id)
1657 return
1658 sendspin_client = self.server_api.get_client(client_id)
1659 if sendspin_client is None:
1660 return
1661 if not self._is_current_client_event(client_id, event_version):
1662 self.logger.debug("Skipping stale update event for %s", client_id)
1663 return
1664 existing_player = self.mass.players.get_player(client_id)
1665 if not isinstance(existing_player, SendspinBasePlayer):
1666 return
1667 previous_device_info = existing_player.device_info
1668 previous_type = existing_player.type
1669 existing_player._refresh_client_info(sendspin_client)
1670 if isinstance(existing_player, SendspinPlayer):
1671 existing_player.restore_bridge_identity(previous_device_info, previous_type)
1672 await self._apply_hass_esphome_enrichment([existing_player])
1673 if not self._is_current_client_event(client_id, event_version):
1674 self.logger.debug("Skipping stale update event for %s after refresh", client_id)
1675 return
1676 if previous_type == PlayerType.PROTOCOL and existing_player.type != PlayerType.PROTOCOL:
1677 existing_player.set_protocol_parent_id(None)
1678 existing_player._attr_underlying_player_id = None
1679 await self.mass.players.register_or_update(existing_player)
1680 finally:
1681 self._finish_client_event(client_id)
1682
1683 def _get_virtual_player_config_owner(self, player_id: str) -> str | None:
1684 """Return the owner instance id from a stored virtual player config, if any."""
1685 raw_conf = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}")
1686 if not isinstance(raw_conf, dict) or raw_conf.get("provider") != self.instance_id:
1687 return None
1688 values = raw_conf.get("values")
1689 if not isinstance(values, dict):
1690 return None
1691 return cast("str | None", values.get(CONF_VIRTUAL_PLAYER_OWNER))
1692
1693 def _register_virtual_player_client(self, player_id: str, display_name: str) -> None:
1694 """Register the silent external Sendspin client backing a virtual player."""
1695 hello = ClientHelloPayload(
1696 client_id=player_id,
1697 name=display_name,
1698 version=1,
1699 supported_roles=[BRIDGE_ROLE_ID],
1700 device_info=SendspinDeviceInfo(
1701 product_name="Virtual Player",
1702 manufacturer="Music Assistant",
1703 ),
1704 player_support=ClientHelloPlayerSupport(
1705 supported_formats=[
1706 SupportedAudioFormat(
1707 codec=AudioCodec.PCM,
1708 channels=BRIDGE_CHANNELS,
1709 sample_rate=BRIDGE_SAMPLE_RATE,
1710 bit_depth=BRIDGE_BIT_DEPTH,
1711 )
1712 ],
1713 buffer_capacity=1_000,
1714 supported_commands=[],
1715 ),
1716 )
1717 sendspin_client = self.server_api.register_external_player(
1718 hello, on_stream_start=self._on_virtual_player_stream_start
1719 )
1720 for role in sendspin_client.roles_by_family("player"):
1721 if not isinstance(role, BridgePlayerRole):
1722 continue
1723 # audio delivered to the role is simply discarded: the virtual player
1724 # only anchors the group, the members receive the actual stream
1725 role.set_callbacks(
1726 on_audio_chunk=_virtual_player_noop,
1727 on_volume_change=_virtual_player_noop,
1728 on_mute_change=_virtual_player_noop,
1729 on_stream_start=_virtual_player_noop,
1730 on_stream_end=_virtual_player_noop,
1731 )
1732 role.setup_audio_requirements()
1733 role.set_timing(required_lead_time_ms=0, min_buffer_ms=0)
1734 break
1735
1736 async def _wait_for_virtual_player(self, player_id: str) -> None:
1737 """Wait until the virtual player is registered in MA with its queue."""
1738
1739 def _registered() -> bool | None:
1740 if (
1741 self.mass.players.get_player(player_id) is not None
1742 and self.mass.player_queues.get(player_id) is not None
1743 ):
1744 return True
1745 return None
1746
1747 if await _poll_until(_registered, VIRTUAL_PLAYER_REGISTER_TIMEOUT) is None:
1748 raise SetupFailedError(f"Virtual player {player_id} was not registered in time")
1749
1750 async def _cleanup_failed_virtual_player_creation(self, player_id: str) -> None:
1751 """
1752 Remove a virtual player after its creation does not complete.
1753
1754 :param player_id: Virtual player to remove.
1755 """
1756 last_error: Exception | None = None
1757 for delay in VIRTUAL_PLAYER_CLEANUP_DELAYS:
1758 if delay:
1759 await asyncio.sleep(delay)
1760 try:
1761 async with asyncio.timeout(VIRTUAL_PLAYER_CLEANUP_TIMEOUT):
1762 await self.remove_virtual_player(player_id)
1763 return
1764 except Exception as err:
1765 last_error = err
1766 self.logger.warning(
1767 "Could not clean up failed virtual player creation %s: %s",
1768 player_id,
1769 last_error,
1770 )
1771
1772 def _on_virtual_player_stream_start(self, _request: ExternalStreamStartRequest) -> None:
1773 """Accept stream start requests for virtual players (nothing to connect)."""
1774
1775 async def _on_providers_updated(self, event: MassEvent) -> None:
1776 """Handle a change in the loaded providers."""
1777 # during (server) shutdown all providers unload; the startup sweep
1778 # takes care of configs whose owner is really gone
1779 if self._unloading or self.mass.closing:
1780 return
1781 # remove virtual players whose owning provider is no longer loaded
1782 for player_id, owner_instance_id in list(self._virtual_players.items()):
1783 if self.mass.get_provider(owner_instance_id) is not None:
1784 continue
1785 self.logger.debug(
1786 "Removing virtual player %s: owner %s unloaded", player_id, owner_instance_id
1787 )
1788 await self.remove_virtual_player(player_id)
1789 # (re)apply the HA-backed enrichment when the hass plugin (un)loads
1790 hass = self.mass.get_provider("hass")
1791 hass_available = hass is not None and hass.available
1792 if hass_available != self._hass_available:
1793 self._hass_available = hass_available
1794 await self._refresh_hass_esphome_enrichment()
1795
1796 def _remove_orphan_virtual_player_configs(self) -> None:
1797 """Delete stored configs of virtual players whose owner provider is gone."""
1798 all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
1799 for player_id, raw_conf in list(all_player_configs.items()):
1800 if not isinstance(raw_conf, dict) or raw_conf.get("provider") != self.instance_id:
1801 continue
1802 values = raw_conf.get("values")
1803 if not isinstance(values, dict):
1804 values = {}
1805 owner_instance_id = values.get(CONF_VIRTUAL_PLAYER_OWNER)
1806 if owner_instance_id is None:
1807 continue
1808 if self.mass.config.get(f"{CONF_PROVIDERS}/{owner_instance_id}") is not None:
1809 # owner still configured; it will recreate its virtual players
1810 continue
1811 self.logger.debug("Removing orphan virtual player config %s", player_id)
1812 self.mass.players.delete_player_config(player_id)
1813
1814
1815def _virtual_player_noop(*_args: object) -> None:
1816 """No-op callback for virtual players, which never render audio locally."""
1817