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