/
/
1"""Sendspin Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from collections.abc import Callable, Iterable
8from contextlib import suppress
9from io import BytesIO
10from typing import TYPE_CHECKING, ClassVar, cast
11
12from aiosendspin.models import AudioCodec, MediaCommand
13from aiosendspin.models.management import (
14 ManagementSetPairingConfigPayload,
15 SetDynamicPinConfig,
16 SetStaticPinConfig,
17 SetUnpairedAccessConfig,
18)
19from aiosendspin.models.types import PairMethod, PlaybackStateType, PlayerCommand, role_family
20from aiosendspin.models.types import RepeatMode as SendspinRepeatMode
21from aiosendspin.models.visualizer import BeatAvailability, BeatTiming
22from aiosendspin.noise.driver import HandshakeAbortedError
23from aiosendspin.noise.pairing import (
24 SERVER_FIRST_MESSAGE_TIMEOUT_S,
25 SERVER_GESTURE_TIMEOUT_S,
26 PairingError,
27)
28from aiosendspin.noise.trust_store import PskCategory
29from aiosendspin.server import ClientEvent, GroupEvent, SendspinGroup, VolumeChangedEvent
30from aiosendspin.server.audio import AudioFormat as SendspinAudioFormat
31from aiosendspin.server.client import DisconnectBehaviour
32from aiosendspin.server.events import (
33 ClientGroupChangedEvent,
34 GroupDeletedEvent,
35 GroupMemberAddedEvent,
36 GroupMemberRemovedEvent,
37 GroupStateChangedEvent,
38)
39from aiosendspin.server.roles import (
40 ArtworkGroupRole,
41 ColorGroupRole,
42 ControllerEvent,
43 ControllerGroupRole,
44 ControllerNextEvent,
45 ControllerPauseEvent,
46 ControllerPlayEvent,
47 ControllerPreviousEvent,
48 ControllerRepeatEvent,
49 ControllerSeekEvent,
50 ControllerSeekRelativeEvent,
51 ControllerShuffleEvent,
52 ControllerStopEvent,
53 MetadataGroupRole,
54 VisualizerGroupRole,
55)
56from aiosendspin.server.roles.color.state import Color
57from aiosendspin.server.roles.metadata.state import Metadata
58from aiosendspin.server.roles.player.events import StaticDelayChangedEvent
59from aiosendspin.server.roles.player.types import PlayerRoleProtocol
60from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
61from music_assistant_models.constants import PLAYER_CONTROL_NONE
62from music_assistant_models.enums import (
63 ConfigEntryType,
64 IdentifierType,
65 MediaType,
66 PlaybackState,
67 PlayerFeature,
68 PlayerType,
69 ProviderType,
70 RepeatMode,
71)
72from music_assistant_models.errors import MediaNotFoundError, PlayerCommandFailed
73from music_assistant_models.media_items import Album, Artist, is_track
74from music_assistant_models.player import DeviceInfo
75from PIL import Image
76
77from music_assistant.constants import HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES
78from music_assistant.controllers.streams.audio_analysis import SMART_FADES_ANALYSIS_DOMAIN
79from music_assistant.helpers.util import is_valid_mac_address, join_task
80from music_assistant.models.player import Player, PlayerMedia
81from music_assistant.models.setup_flow import FINISH_STEP_SILENT, AbortFlow, StepExpiredError
82
83from .bridge_role import BridgePlayerRole
84from .constants import (
85 BRIDGE_PREFIX,
86 CONF_ACTION_MANAGEMENT_DYNAMIC_PIN_DISABLE,
87 CONF_ACTION_MANAGEMENT_DYNAMIC_PIN_ENABLE,
88 CONF_ACTION_MANAGEMENT_ENTER,
89 CONF_ACTION_MANAGEMENT_EXIT,
90 CONF_ACTION_MANAGEMENT_STATIC_PIN_DISABLE,
91 CONF_ACTION_MANAGEMENT_STATIC_PIN_ENABLE,
92 CONF_ACTION_MANAGEMENT_UNPAIRED_DISABLE,
93 CONF_ACTION_MANAGEMENT_UNPAIRED_ENABLE,
94 CONF_ACTION_REVOKE_UNPAIRED,
95 CONF_ACTION_UNPAIR,
96 CONF_CAST_AUDIO_UNSUPPORTED,
97 CONF_PAIR_DEVICE,
98 CONF_PAIRING_METHOD,
99 CONF_PAIRING_PIN,
100 CONF_PAIRING_TOKEN,
101 CONF_SENDSPIN_STATIC_DELAY,
102 CONF_SOURCE_APPROVAL_DISMISSED,
103 CONF_SOURCE_AUTOSTART_TARGET,
104 CONF_SOURCE_INPUT_ACTION,
105 CONF_SOURCE_INPUT_NOTE,
106 DEFAULT_SENDSPIN_STATIC_DELAY,
107 PAIR_METHOD_DYNAMIC_PIN,
108 PAIR_METHOD_PIN,
109 PAIR_METHOD_STATIC_PIN,
110 PAIR_METHOD_TOKEN,
111 SOURCE_AUTOSTART_OFF,
112 SOURCE_INPUT_DISMISS,
113 SOURCE_INPUT_PAIR,
114)
115from .helpers import (
116 AlertText,
117 SecurityActionError,
118 action_entry,
119 alert_entry,
120 effective_pair_methods,
121 effective_unpaired_access,
122 error_alert,
123 mac_from_bridge_client_id,
124 pair_method_descriptor,
125 pin_code_format,
126)
127from .playback import SendspinPlaybackSession
128
129# Supported group commands for Sendspin players
130SUPPORTED_GROUP_COMMANDS = [
131 MediaCommand.PLAY,
132 MediaCommand.PAUSE,
133 MediaCommand.STOP,
134 MediaCommand.NEXT,
135 MediaCommand.PREVIOUS,
136 MediaCommand.REPEAT_OFF,
137 MediaCommand.REPEAT_ONE,
138 MediaCommand.REPEAT_ALL,
139 MediaCommand.SHUFFLE,
140 MediaCommand.UNSHUFFLE,
141 MediaCommand.SEEK,
142 MediaCommand.SEEK_RELATIVE,
143]
144
145# Config constants for Sendspin audio format
146CONF_PREFERRED_SENDSPIN_FORMAT = "preferred_sendspin_format"
147SENDSPIN_FORMAT_AUTOMATIC = "automatic"
148
149# Player types that can render a line-in, so groups and pairs stay selectable.
150SOURCE_AUTOSTART_TARGET_TYPES = {
151 PlayerType.PLAYER,
152 PlayerType.STEREO_PAIR,
153 PlayerType.GROUP,
154}
155
156
157def format_to_option_value(fmt: SupportedAudioFormat) -> str:
158 """Convert SupportedAudioFormat to "codec:sample_rate:bit_depth:channels"."""
159 return f"{fmt.codec.value}:{fmt.sample_rate}:{fmt.bit_depth}:{fmt.channels}"
160
161
162def option_value_to_format(value: str) -> tuple[AudioCodec, SendspinAudioFormat] | None:
163 """
164 Parse option value back to (AudioCodec, SendspinAudioFormat).
165
166 :param value: Option value in format "codec:sample_rate:bit_depth:channels".
167 :return: Tuple of (AudioCodec, SendspinAudioFormat) or None if parsing fails.
168 """
169 try:
170 codec_str, sample_rate_str, bit_depth_str, channels_str = value.split(":")
171 codec = AudioCodec(codec_str)
172 audio_format = SendspinAudioFormat(
173 sample_rate=int(sample_rate_str),
174 bit_depth=int(bit_depth_str),
175 channels=int(channels_str),
176 )
177 return (codec, audio_format)
178 except ValueError, KeyError:
179 return None
180
181
182def format_to_display_string(fmt: SupportedAudioFormat) -> str:
183 """Convert to display string like "FLAC 48kHz/24bit stereo"."""
184 codec_name = fmt.codec.name
185 sample_rate_khz = fmt.sample_rate / 1000
186 # Format sample rate: show as integer if whole number, otherwise one decimal
187 if sample_rate_khz == int(sample_rate_khz):
188 sample_rate_str = f"{int(sample_rate_khz)}kHz"
189 else:
190 sample_rate_str = f"{sample_rate_khz:.1f}kHz"
191 if fmt.channels == 2:
192 channels_str = "stereo"
193 elif fmt.channels == 1:
194 channels_str = "mono"
195 else:
196 channels_str = f"{fmt.channels}ch"
197 return f"{codec_name} {sample_rate_str}/{fmt.bit_depth}bit {channels_str}"
198
199
200_MANAGEMENT_ACTIONS = {
201 CONF_ACTION_MANAGEMENT_UNPAIRED_ENABLE: ManagementSetPairingConfigPayload(
202 unpaired_access=SetUnpairedAccessConfig(enabled=True)
203 ),
204 CONF_ACTION_MANAGEMENT_UNPAIRED_DISABLE: ManagementSetPairingConfigPayload(
205 unpaired_access=SetUnpairedAccessConfig(enabled=False)
206 ),
207 CONF_ACTION_MANAGEMENT_STATIC_PIN_ENABLE: ManagementSetPairingConfigPayload(
208 static_pin=SetStaticPinConfig(enabled=True)
209 ),
210 CONF_ACTION_MANAGEMENT_STATIC_PIN_DISABLE: ManagementSetPairingConfigPayload(
211 static_pin=SetStaticPinConfig(enabled=False)
212 ),
213 CONF_ACTION_MANAGEMENT_DYNAMIC_PIN_ENABLE: ManagementSetPairingConfigPayload(
214 dynamic_pin=SetDynamicPinConfig(enabled=True)
215 ),
216 CONF_ACTION_MANAGEMENT_DYNAMIC_PIN_DISABLE: ManagementSetPairingConfigPayload(
217 dynamic_pin=SetDynamicPinConfig(enabled=False)
218 ),
219}
220
221# Seconds the setup flow gives the operator to enter the PIN. Advisory: the authoritative end
222# is the device's own attempt timeout (spec-recommended 2 minutes), which aborts the attempt.
223PAIR_PIN_ENTRY_TIMEOUT = 120.0
224# Seconds the setup flow waits for pairing to complete after the PIN is submitted.
225PAIR_CONFIRM_TIMEOUT = 30.0
226# Seconds a Cast-bridged member gets to report its Sendspin app ready.
227CAST_APP_READY_TIMEOUT = 30.0
228
229# Terminal pairing-error slugs that map to a dedicated setup_flow.abort reason;
230# anything else falls back to the generic "pairing_failed" abort.
231_PAIRING_ABORT_REASONS = {
232 "pairing_error_concurrent": "pairing_error_concurrent",
233 "pairing_error_no_pin_method": "no_pair_methods",
234 "pairing_error_not_connected": "pairing_error_not_connected",
235}
236
237# How the operator gets a method's secret, per the device's own descriptor hints: where a
238# configured secret is found, or which channel conveys a per-session PIN. Values outside these
239# maps render nothing.
240_SECRET_HINT_LABELS = {
241 PairMethod.STATIC_PIN: {
242 "device": "static_pin_location_device",
243 "leaflet": "static_pin_location_leaflet",
244 "operator": "static_pin_location_operator",
245 },
246 PairMethod.PAIRING_PSK: {
247 "device": "pairing_psk_location_device",
248 "leaflet": "pairing_psk_location_leaflet",
249 "operator": "pairing_psk_location_operator",
250 },
251 PairMethod.DYNAMIC_PIN: {
252 "display": "dynamic_pin_channel_display",
253 "speaker": "dynamic_pin_channel_speaker",
254 },
255}
256
257
258def _pin_error_slug(error: Exception | None) -> str:
259 """Return the strings.json errors slug for a retryable PIN failure (re-rendered form)."""
260 if error is None:
261 return "pairing_error_generic"
262 return error_alert(error).key
263
264
265def _pairing_abort_reason(error: Exception | None) -> str:
266 """Return the setup_flow.abort reason slug for a terminal pairing failure."""
267 if error is None:
268 return "pairing_failed"
269 key = error.alert_key if isinstance(error, SecurityActionError) else error_alert(error).key
270 return _PAIRING_ABORT_REASONS.get(key, "pairing_failed")
271
272
273if TYPE_CHECKING:
274 from aiosendspin.models.core import ClientHelloPayload
275 from aiosendspin.models.management import ManagementResultData, PairingMethodConfig
276 from aiosendspin.models.player import SupportedAudioFormat
277 from aiosendspin.noise.trust_store import ServerPairingRecord
278 from aiosendspin.server.client import SendspinClient
279 from music_assistant_models.media_items import MediaItemPalette
280 from music_assistant_models.player_queue import PlayerQueue
281 from music_assistant_models.queue_item import QueueItem
282
283 from music_assistant.controllers.player_queues.state import PlayerQueueData
284 from music_assistant.models.setup_flow import SetupSession
285 from music_assistant.providers.chromecast.sendspin_bridge import SendspinBridgeManager
286 from music_assistant.providers.hass import HomeAssistantProvider
287
288 from .provider import PinPairingSession, SendspinProvider
289
290
291class SendspinBasePlayer(Player):
292 """
293 Base class for Sendspin players in Music Assistant.
294
295 Provides shared device-info, group membership, and event handling logic
296 that is common to both audio and non-audio (visualizer) player types.
297 """
298
299 api: SendspinClient
300 unsub_event_cb: Callable[[], None] | None
301 unsub_group_event_cb: Callable[[], None] | None
302 is_web_player: bool = False
303 # transient alert produced by the most recent security action, surfaced on the next
304 # config-entries render (the settings page re-renders after invoking an action)
305 _pending_security_alert: AlertText | None = None
306
307 def __init__(
308 self,
309 provider: SendspinProvider,
310 player_id: str,
311 initial_hello: ClientHelloPayload | None = None,
312 ) -> None:
313 """
314 Initialize the base Sendspin player.
315
316 :param provider: The Sendspin provider instance.
317 :param player_id: The unique player identifier.
318 :param initial_hello: Optional hello payload from the client.
319 """
320 super().__init__(provider, player_id)
321 sendspin_client = provider.server_api.get_client(player_id)
322 assert sendspin_client is not None
323 self.api = sendspin_client
324 self.unsub_event_cb = None
325 self.unsub_group_event_cb = None
326 self.logger = self.provider.logger.getChild(player_id)
327 self._attr_power_control = PLAYER_CONTROL_NONE
328 self._refresh_client_info(sendspin_client, hello_payload=initial_hello)
329 self._subscribe_client_callbacks()
330
331 def event_cb(self, client: SendspinClient, event: ClientEvent) -> None:
332 """Event callback registered to the sendspin client."""
333 match event:
334 case ClientGroupChangedEvent(new_group=new_group):
335 if self.unsub_group_event_cb is not None:
336 self.unsub_group_event_cb()
337 self.unsub_group_event_cb = new_group.add_event_listener(self.group_event_cb)
338 self._on_group_changed(new_group)
339 self.update_state()
340
341 def group_event_cb(self, group: SendspinGroup, event: GroupEvent) -> None:
342 """Event callback registered to the sendspin group this player belongs to."""
343 if self.synced_to is not None:
344 # Only handle group events as the leader, except for:
345 # - GroupMemberRemovedEvent: to handle being removed from a group
346 # - GroupStateChangedEvent: to update playback state when leader stops/disconnects
347 if not isinstance(event, (GroupMemberRemovedEvent, GroupStateChangedEvent)):
348 return
349 match event:
350 case GroupStateChangedEvent(state=state):
351 match state:
352 case PlaybackStateType.PLAYING:
353 self._attr_playback_state = PlaybackState.PLAYING
354 case PlaybackStateType.PAUSED:
355 self._attr_playback_state = PlaybackState.PAUSED
356 case PlaybackStateType.STOPPED:
357 self._attr_playback_state = PlaybackState.IDLE
358 self._attr_elapsed_time = 0
359 self._attr_elapsed_time_last_updated = time.time()
360 self._on_group_stopped()
361 self.update_state()
362 case GroupMemberAddedEvent(client_id=client_id):
363 is_group_leader = (
364 bool(group.clients) and group.clients[0].client_id == self.player_id
365 )
366 if is_group_leader and (
367 not self._attr_group_members or self._attr_group_members[0] != self.player_id
368 ):
369 self._attr_group_members = [self.player_id, *self._attr_group_members]
370 if client_id not in self._attr_group_members:
371 self._attr_group_members.append(client_id)
372 self.update_state()
373 self._schedule_membership_sync(group)
374 case GroupMemberRemovedEvent(client_id=client_id):
375 self.mass.create_task(self._handle_group_member_removed(group, client_id))
376 self._schedule_membership_sync(group)
377 case GroupDeletedEvent():
378 pass
379
380 async def on_unload(self) -> None:
381 """Handle logic when the player is unloaded from the Player controller."""
382 await super().on_unload()
383 self._unsubscribe_client_callbacks()
384
385 @property
386 def needs_setup(self) -> bool:
387 """
388 Whether the device is connected and encrypted but not yet usable for playback.
389
390 An unpaired device that has not been paired or allowed unpaired playback still
391 connects, but the server activates no roles for it. Reporting needs_setup keeps it
392 out of the ready-to-play targets while its settings (and the pairing actions) stay
393 reachable. Legacy unencrypted devices and the built-in web player can play as-is.
394 """
395 if self._is_bridge_or_web_player:
396 return False
397
398 # Deliberately no is_connected gate: between a (re)connect's hello and its first
399 # client/state, security and active roles are already valid while is_connected is not.
400
401 if self.api.connection_security is None:
402 return False
403 # A device with active roles stays usable even while its audio input is
404 # undecided (e.g. granted unpaired access before the input decision
405 # existed): the input can be enabled by pairing from the settings page.
406 return not self.api.active_roles
407
408 @property
409 def setup_reason(self) -> str | None:
410 """Return the reason this device needs setup (pairing), or None when it does not."""
411 return "pairing_required" if self.needs_setup else None
412
413 async def get_config_entries(self) -> list[ConfigEntry]:
414 """Return all (provider/player specific) Config Entries for the player."""
415 entries = await super().get_config_entries()
416 entries.extend(await self._get_security_config_entries())
417 entries.extend(self._get_source_autostart_config_entries())
418 return entries
419
420 async def handle_config_action(self, action: str) -> list[ConfigEntry]:
421 """Run an unpair/management action, then re-render the entries."""
422 if self.api.connection is not None:
423 provider = cast("SendspinProvider", self.provider)
424 alert = await self._handle_security_action(provider, action)
425 if alert is not None:
426 self._pending_security_alert = alert
427 return await self.get_config_entries()
428
429 async def run_setup_flow(self, session: SetupSession) -> None:
430 """
431 Drive approval or pairing for this encrypted Sendspin device.
432
433 An unapproved device shows a one-time consent step that allows it to play,
434 with secure pairing offered as an optional extra; a device whose only pending
435 part is its audio input picks between pairing and declining the input.
436 Re-running the flow on a paired device verifies its presence via a dynamic
437 PIN. Pairing succeeds as a side effect of the provider pairing calls;
438 declining the audio input persists via the player config. Bridge/web players
439 and unencrypted (legacy) connections have nothing to pair.
440
441 :param session: The setup flow session used to interact with the user.
442 """
443 security = self.api.connection_security
444 if self._is_bridge_or_web_player or security is None:
445 raise AbortFlow("nothing_to_configure")
446 provider = cast("SendspinProvider", self.provider)
447 record = await provider.server_api.pairing_store.record_by_client_id(self.player_id)
448 if security.psk_category is PskCategory.LONG_TERM and record is not None:
449 await self._run_verify_presence_flow(session, provider, record)
450 await session.finish({})
451 return
452 options = self._pairing_method_options(provider)
453 wants_pairing = True
454 if not self.api.active_roles and self._offers_unpaired_consent:
455 wants_pairing = await self._run_consent_step(
456 session, provider, offer_pairing=bool(options)
457 )
458 elif self.api.active_roles and self._source_input_pending:
459 # the device already plays, only its audio input is pending
460 if await self._run_source_input_step(session, offer_pair=bool(options)):
461 session.finish_step_id = FINISH_STEP_SILENT
462 await session.finish({})
463 return
464 if not wants_pairing:
465 # a one-click allow needs no success screen, the device just becomes usable
466 session.finish_step_id = FINISH_STEP_SILENT
467 await session.finish({})
468 return
469 if not options:
470 raise AbortFlow("no_pair_methods")
471 if len(options) == 1:
472 method = options[0]
473 else:
474 values = await session.form(
475 [
476 ConfigEntry(
477 key=CONF_PAIRING_METHOD,
478 type=ConfigEntryType.STRING,
479 required=True,
480 options=[ConfigValueOption(value=option) for option in options],
481 expanded_options=True,
482 )
483 ],
484 step_id="select_method",
485 )
486 method = str(values[CONF_PAIRING_METHOD])
487 if method == PAIR_METHOD_TOKEN:
488 await self._run_token_pairing_flow(session, provider)
489 else:
490 await self._run_pin_pairing_flow(
491 session, provider, static=method == PAIR_METHOD_STATIC_PIN
492 )
493 await session.finish({})
494
495 def _get_source_autostart_config_entries(self) -> list[ConfigEntry]:
496 """Return the line-in autostart entries, for source clients that can sense a signal."""
497 if not self._supports_line_sense():
498 return []
499 # A source that is also a player defaults to playing on itself, which for a
500 # protocol player means the visible player it belongs to.
501 own_target = self.protocol_parent_id or self.player_id
502 options = [
503 ConfigValueOption(SOURCE_AUTOSTART_OFF),
504 *(
505 ConfigValueOption(
506 player.player_id,
507 title=None if player.player_id == own_target else player.display_name,
508 translation_key="this_device" if player.player_id == own_target else None,
509 )
510 for player in sorted(
511 self.mass.players.all_players(False, False),
512 # Lead with the device's own player, so the default sits at the top
513 # of the list rather than alphabetically among every other player.
514 key=lambda player: (
515 player.player_id != own_target,
516 player.display_name.lower(),
517 ),
518 )
519 # Only players that render audio, so lights, displays and other
520 # capture-only clients are not offered as somewhere to play a line-in.
521 if player.type in SOURCE_AUTOSTART_TARGET_TYPES
522 ),
523 ]
524 valid = {option.value for option in options}
525 default = (
526 own_target if "player" in self._negotiated_families() and own_target in valid else None
527 )
528 return [
529 ConfigEntry(
530 key=CONF_SOURCE_AUTOSTART_TARGET,
531 type=ConfigEntryType.STRING,
532 options=options,
533 default_value=default or SOURCE_AUTOSTART_OFF,
534 ),
535 ]
536
537 def _negotiated_families(self) -> set[str]:
538 """Role families the client negotiated, which survive an inactive/unpaired state."""
539 return {role_family(role_id) for role_id in self.api.negotiated_role_ids}
540
541 def _supports_line_sense(self) -> bool:
542 """Whether this client has a source role that reports line-in signal presence."""
543 if "source" not in self._negotiated_families():
544 return False
545 info = self.api.info_or_none
546 support = info.source_support if info else None
547 features = support.features if support else None
548 return bool(features and features.line_sense)
549
550 @property
551 def _is_bridge_or_web_player(self) -> bool:
552 """Whether this is a protocol bridge or built-in web/app player (skips pairing setup)."""
553 return self.player_id.startswith(BRIDGE_PREFIX) or self.is_web_player
554
555 @property
556 def _offers_unpaired_consent(self) -> bool:
557 """Whether a one-click consent (instead of pairing) can make this device usable."""
558 provider = cast("SendspinProvider", self.provider)
559 return effective_unpaired_access(
560 self.api.info_or_none, provider.pairing_config_snapshot(self.player_id)
561 )
562
563 @property
564 def _source_input_pending(self) -> bool:
565 """Whether the device's audio input awaits pairing or an explicit decline."""
566 if "source" not in self._negotiated_families():
567 return False
568 if self.api.roles_by_family("source"):
569 return False
570 return not self.mass.config.get_raw_player_config_value(
571 self.player_id, CONF_SOURCE_APPROVAL_DISMISSED, False
572 )
573
574 async def _run_consent_step(
575 self, session: SetupSession, provider: SendspinProvider, *, offer_pairing: bool
576 ) -> bool:
577 """Show the one-time consent step; return True when the user opted into pairing."""
578 entries = []
579 if offer_pairing:
580 entries.append(
581 ConfigEntry(
582 key=CONF_PAIR_DEVICE,
583 type=ConfigEntryType.BOOLEAN,
584 default_value=False,
585 )
586 )
587 if self._source_input_pending:
588 # pairing is what enables the audio input, so the consent page carries the
589 # note and a plain allow declines the input (revisable by pairing later)
590 entries.append(ConfigEntry(key=CONF_SOURCE_INPUT_NOTE, type=ConfigEntryType.ALERT))
591 values = await session.form(entries, step_id="approve_device")
592 if offer_pairing and bool(values.get(CONF_PAIR_DEVICE)):
593 return True
594 # record the input decline only once the grant actually took effect
595 declines_input = self._source_input_pending
596 await provider.set_trusted_unpaired(self.player_id, enabled=True)
597 if declines_input:
598 self.mass.config.set_raw_player_config_value(
599 self.player_id, CONF_SOURCE_APPROVAL_DISMISSED, True
600 )
601 return False
602
603 async def _run_source_input_step(self, session: SetupSession, *, offer_pair: bool) -> bool:
604 """Ask what to do with the pending audio input; return True when it was declined."""
605 options = [ConfigValueOption(value=SOURCE_INPUT_PAIR)] if offer_pair else []
606 options.append(ConfigValueOption(value=SOURCE_INPUT_DISMISS))
607 values = await session.form(
608 [
609 ConfigEntry(
610 key=CONF_SOURCE_INPUT_ACTION,
611 type=ConfigEntryType.STRING,
612 required=True,
613 options=options,
614 expanded_options=True,
615 )
616 ],
617 step_id="source_input",
618 )
619 if str(values[CONF_SOURCE_INPUT_ACTION]) != SOURCE_INPUT_DISMISS:
620 return False
621 self.mass.config.set_raw_player_config_value(
622 self.player_id, CONF_SOURCE_APPROVAL_DISMISSED, True
623 )
624 self.update_state()
625 return True
626
627 def _subscribe_client_callbacks(self) -> None:
628 """Subscribe to client and group events for the currently bound client."""
629 self.api.disconnect_behaviour = DisconnectBehaviour.UNGROUP
630 self.unsub_event_cb = self.api.add_event_listener(self.event_cb)
631 self.unsub_group_event_cb = self.api.group.add_event_listener(self.group_event_cb)
632
633 def _unsubscribe_client_callbacks(self) -> None:
634 """Unsubscribe any active client and group listeners."""
635 if self.unsub_event_cb is not None:
636 with suppress(Exception):
637 self.unsub_event_cb()
638 self.unsub_event_cb = None
639 if self.unsub_group_event_cb is not None:
640 with suppress(Exception):
641 self.unsub_group_event_cb()
642 self.unsub_group_event_cb = None
643
644 def _refresh_client_info(
645 self,
646 sendspin_client: SendspinClient,
647 hello_payload: ClientHelloPayload | None = None,
648 ) -> None:
649 """
650 Refresh shared player attributes from a Sendspin client hello/info payload.
651
652 :param sendspin_client: The Sendspin client instance.
653 :param hello_payload: Optional hello payload to use instead of client info.
654 """
655 client_info = hello_payload or sendspin_client.info
656 preserved_identifiers = dict(self._attr_device_info.identifiers)
657 self._attr_name = client_info.name
658 if device_info := client_info.device_info:
659 self._attr_device_info = DeviceInfo(
660 model=device_info.product_name or "Unknown model",
661 manufacturer=device_info.manufacturer or "Unknown Manufacturer",
662 software_version=device_info.software_version,
663 )
664 else:
665 self._attr_device_info = DeviceInfo()
666 for id_type, id_value in preserved_identifiers.items():
667 self._attr_device_info.add_identifier(id_type, id_value)
668 # Add player_id as MAC identifier for protocol linking (if it's a valid MAC)
669 # This enables linking with bridged players (e.g., AirPlay via Sendspin bridge)
670 if IdentifierType.MAC_ADDRESS not in self._attr_device_info.identifiers:
671 if _mac := mac_from_bridge_client_id(self.player_id):
672 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, _mac)
673 elif is_valid_mac_address(self.player_id):
674 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, self.player_id)
675 self._attr_available = True
676
677 @property
678 def _artwork_role(self) -> ArtworkGroupRole | None:
679 """Get the ArtworkGroupRole for this player's group."""
680 role = self.api.group.group_role("artwork")
681 if isinstance(role, ArtworkGroupRole):
682 return role
683 return None
684
685 @property
686 def _metadata_role(self) -> MetadataGroupRole | None:
687 """Get the MetadataGroupRole for this player's group."""
688 role = self.api.group.group_role("metadata")
689 if isinstance(role, MetadataGroupRole):
690 return role
691 return None
692
693 @property
694 def _color_role(self) -> ColorGroupRole | None:
695 """Get the ColorGroupRole for this player's group."""
696 role = self.api.group.group_role("color")
697 if isinstance(role, ColorGroupRole):
698 return role
699 return None
700
701 @property
702 def _visualizer_role(self) -> VisualizerGroupRole | None:
703 """Get the VisualizerGroupRole for this player's group."""
704 role = self.api.group.group_role("visualizer")
705 if isinstance(role, VisualizerGroupRole):
706 return role
707 return None
708
709 @property
710 def _controller_role(self) -> ControllerGroupRole | None:
711 """Get the ControllerGroupRole for this player's group."""
712 role = self.api.group.group_role("controller")
713 if isinstance(role, ControllerGroupRole):
714 return role
715 return None
716
717 @property
718 def _player_role(self) -> PlayerRoleProtocol | None:
719 """Get the player role for this client (not group role)."""
720 for role in self.api.roles_by_family("player"):
721 if isinstance(role, PlayerRoleProtocol):
722 return role
723 return None
724
725 def _on_group_changed(self, new_group: SendspinGroup) -> None:
726 """
727 Handle group change logic.
728
729 Syncs playback state from the new group and schedules membership sync.
730 Override in subclasses for additional behaviour.
731
732 :param new_group: The new group this player has been assigned to.
733 """
734 # Sync playback state from the new group
735 match new_group.state:
736 case PlaybackStateType.PLAYING:
737 self._attr_playback_state = PlaybackState.PLAYING
738 case PlaybackStateType.PAUSED:
739 self._attr_playback_state = PlaybackState.PAUSED
740 case PlaybackStateType.STOPPED:
741 self._attr_playback_state = PlaybackState.IDLE
742 self._attr_elapsed_time = 0
743 self._attr_elapsed_time_last_updated = time.time()
744 # Update in case this is a newly created group
745 # GroupMemberAddedEvent or GroupMemberRemovedEvent will be fired before this
746 # so group members are already up to date at this point
747 self._schedule_membership_sync(new_group)
748
749 def _on_group_stopped(self) -> None:
750 """Handle the group transitioning to STOPPED state."""
751
752 async def _sync_membership_from_group(self, group: SendspinGroup) -> None:
753 """
754 Sync MA player group membership from authoritative group state.
755
756 :param group: The Sendspin group to sync from.
757 """
758 # Ignore stale events from a group we no longer belong to.
759 if group is not self.api.group:
760 return
761 group_client_ids = [client.client_id for client in group.clients]
762 is_leader = bool(group_client_ids) and group_client_ids[0] == self.player_id
763 desired_group_members = group_client_ids if is_leader else []
764 if self._attr_group_members != desired_group_members:
765 self._attr_group_members = desired_group_members
766 self.update_state()
767
768 def _schedule_membership_sync(self, group: SendspinGroup) -> None:
769 """Schedule a coalesced membership reconciliation task for this player."""
770 self.mass.create_task(
771 self._sync_membership_from_group(group),
772 task_id=f"sendspin_membership_sync_{self.player_id}",
773 abort_existing=True,
774 )
775
776 async def _handle_group_member_removed(self, group: SendspinGroup, client_id: str) -> None:
777 """Handle a group member being removed asynchronously."""
778 if client_id == self.player_id:
779 was_leader = (
780 bool(self._attr_group_members) and self._attr_group_members[0] == self.player_id
781 )
782 if was_leader and len(group.clients) > 0:
783 # We were removed as the group leader but other clients remain.
784 # Don't stop the group -- the PushStream keeps running for
785 # remaining members (playback session was transferred in set_members).
786 self.logger.debug(
787 "Player %s removed as group leader; group continues for remaining members",
788 self.player_id,
789 )
790 elif not was_leader:
791 self.logger.debug(
792 "Player %s removed from group as non-leader; keeping old group playing",
793 self.player_id,
794 )
795 # Clear members for our detached/solo state.
796 self._attr_group_members = []
797 self.update_state()
798 elif client_id in self._attr_group_members:
799 # Someone else left our group
800 self._attr_group_members.remove(client_id)
801 self.update_state()
802
803 async def _get_security_config_entries(self) -> list[ConfigEntry]:
804 """Build the pairing/security section entries."""
805 if self._is_bridge_or_web_player:
806 return []
807 provider = cast("SendspinProvider", self.provider)
808 # surface (and clear) any alert produced by the most recent action
809 alert: AlertText | None = self._pending_security_alert
810 self._pending_security_alert = None
811 status, actions = await self._security_state_entries(provider)
812 entries = [status] if status is not None else []
813 if alert is not None:
814 entries.append(alert_entry(alert))
815 return entries + actions
816
817 async def _security_state_entries(
818 self, provider: SendspinProvider
819 ) -> tuple[ConfigEntry | None, list[ConfigEntry]]:
820 """Return the current security-status entry and the available action entries."""
821 if not self.api.is_connected:
822 # The action dropped the connection (e.g. unpair); the client will reconnect
823 return ConfigEntry(key="security_status_disconnected", type=ConfigEntryType.LABEL), []
824
825 security = self.api.connection_security
826 if security is None:
827 return ConfigEntry(key="security_status_unencrypted", type=ConfigEntryType.ALERT), []
828
829 record = await provider.server_api.pairing_store.record_by_client_id(self.player_id)
830
831 # psk_category is fixed at handshake time: right after an unpair the live connection
832 # still reports LONG_TERM while the record is already gone. The settings view refetches
833 # in exactly that window and will not observe the later reconnect until reopened, so
834 # require the record too or else render the unpaired end state immediately.
835 if security.psk_category is PskCategory.LONG_TERM and record is not None:
836 return (
837 ConfigEntry(key="security_status_paired", type=ConfigEntryType.LABEL),
838 await self._paired_entries(
839 provider, provider.pairing_config_snapshot(self.player_id)
840 ),
841 )
842
843 trusted_unpaired = (
844 await provider.server_api.pairing_store.trusted_unpaired(self.player_id) is not None
845 )
846 if not trusted_unpaired:
847 return None, []
848 return (
849 ConfigEntry(key="security_status_unpaired", type=ConfigEntryType.ALERT),
850 [action_entry(CONF_ACTION_REVOKE_UNPAIRED)],
851 )
852
853 async def _paired_entries(
854 self,
855 provider: SendspinProvider,
856 pairing_config: ManagementResultData | None,
857 ) -> list[ConfigEntry]:
858 """Return the action entries for the paired view (management, unpair)."""
859 entries: list[ConfigEntry] = []
860 if provider.get_management_session(self.player_id) is not None:
861 # Render from the snapshot the enter/patch actions keep fresh; only fetch if it is
862 # unexpectedly empty (e.g. a session opened without a fetch).
863 management_config = pairing_config
864 if management_config is None:
865 try:
866 management_config = await provider.management_get_pairing_config(self.player_id)
867 except SecurityActionError as err:
868 provider.exit_management(self.player_id)
869 entries.append(alert_entry(error_alert(err)))
870 if management_config is not None:
871 entries.extend(self._management_section_entries(management_config))
872 return entries
873 entries.append(action_entry(CONF_ACTION_MANAGEMENT_ENTER))
874 entries.append(action_entry(CONF_ACTION_UNPAIR, advanced=True))
875 return entries
876
877 @staticmethod
878 def _management_section_entries(config: ManagementResultData) -> list[ConfigEntry]:
879 """Return the entries for the open device-management section."""
880 entries: list[ConfigEntry] = [
881 ConfigEntry(key="management_status", type=ConfigEntryType.LABEL)
882 ]
883 if config.unpaired_access is not None:
884 action = (
885 CONF_ACTION_MANAGEMENT_UNPAIRED_DISABLE
886 if config.unpaired_access.enabled
887 else CONF_ACTION_MANAGEMENT_UNPAIRED_ENABLE
888 )
889 entries.append(action_entry(action))
890 entries.extend(
891 SendspinBasePlayer._management_pin_method_entries(
892 config.static_pin,
893 CONF_ACTION_MANAGEMENT_STATIC_PIN_ENABLE,
894 CONF_ACTION_MANAGEMENT_STATIC_PIN_DISABLE,
895 )
896 )
897 entries.extend(
898 SendspinBasePlayer._management_pin_method_entries(
899 config.dynamic_pin,
900 CONF_ACTION_MANAGEMENT_DYNAMIC_PIN_ENABLE,
901 CONF_ACTION_MANAGEMENT_DYNAMIC_PIN_DISABLE,
902 )
903 )
904 entries.append(action_entry(CONF_ACTION_MANAGEMENT_EXIT))
905 return entries
906
907 @staticmethod
908 def _management_pin_method_entries(
909 method: PairingMethodConfig | None,
910 enable_action: str,
911 disable_action: str,
912 ) -> list[ConfigEntry]:
913 """Return the toggle for one PIN pairing method, empty if the device lacks it."""
914 if method is None:
915 return []
916 action = disable_action if method.enabled else enable_action
917 return [action_entry(action, advanced=True)]
918
919 async def _handle_security_action(
920 self, provider: SendspinProvider, action: str
921 ) -> AlertText | None:
922 """Execute an unpair/management action, returning a localized alert on failure."""
923 try:
924 if action == CONF_ACTION_UNPAIR:
925 await provider.unpair_client(self.player_id)
926 elif action == CONF_ACTION_REVOKE_UNPAIRED:
927 await provider.set_trusted_unpaired(self.player_id, enabled=False)
928 elif action == CONF_ACTION_MANAGEMENT_ENTER:
929 provider.enter_management(self.player_id)
930 try:
931 await provider.management_get_pairing_config(self.player_id)
932 except SecurityActionError:
933 provider.exit_management(self.player_id)
934 raise
935 elif action == CONF_ACTION_MANAGEMENT_EXIT:
936 provider.exit_management(self.player_id)
937 elif action in _MANAGEMENT_ACTIONS:
938 await provider.management_set_pairing_config(
939 self.player_id, _MANAGEMENT_ACTIONS[action]
940 )
941 except (
942 HandshakeAbortedError,
943 PairingError,
944 TimeoutError,
945 OSError,
946 SecurityActionError,
947 ValueError,
948 ) as err:
949 return error_alert(err)
950 return None
951
952 def _pairing_method_options(self, provider: SendspinProvider) -> list[str]:
953 """Return the pairing-method option values the device currently offers for setup."""
954 info = self.api.info_or_none
955 pairing_config = provider.pairing_config_snapshot(self.player_id)
956 pair_methods = effective_pair_methods(info, pairing_config)
957 usable_pin_methods = {
958 descriptor.method
959 for descriptor in pair_methods
960 if descriptor.method in (PairMethod.DYNAMIC_PIN, PairMethod.STATIC_PIN)
961 }
962 options: list[str] = []
963 if usable_pin_methods:
964 # Static PIN is only a distinct, meaningful choice when both PIN methods are usable;
965 # opposite it the other option names the dynamic PIN rather than PINs in general.
966 both_pin_methods = usable_pin_methods >= {PairMethod.DYNAMIC_PIN, PairMethod.STATIC_PIN}
967 options.append(PAIR_METHOD_DYNAMIC_PIN if both_pin_methods else PAIR_METHOD_PIN)
968 if both_pin_methods:
969 options.append(PAIR_METHOD_STATIC_PIN)
970 elif any(descriptor.method is PairMethod.PAIRING_PSK for descriptor in pair_methods):
971 # Only show the token pairing method in case the client doesn't implement any other one.
972 # token pairing should only be used as a last resort due to the worse UX.
973 options.append(PAIR_METHOD_TOKEN)
974 return options
975
976 async def _pairing_succeeded(
977 self, provider: SendspinProvider, pin_session: PinPairingSession
978 ) -> bool:
979 """Whether the attempt finished cleanly (or a long-term pairing record now exists)."""
980 if pin_session.finished and pin_session.error is None:
981 return True
982 if pin_session.verify:
983 return False
984 # a confirm wait that outlived its deadline: the record is the proof of success
985 return (
986 await provider.server_api.pairing_store.record_by_client_id(self.player_id) is not None
987 )
988
989 async def _run_verify_presence_flow(
990 self, session: SetupSession, provider: SendspinProvider, record: ServerPairingRecord
991 ) -> None:
992 """Confirm a paired device's physical presence via its dynamic PIN."""
993 info = self.api.info_or_none
994 pairing_config = provider.pairing_config_snapshot(self.player_id)
995 offers_dynamic_pin = any(
996 descriptor.method is PairMethod.DYNAMIC_PIN
997 for descriptor in effective_pair_methods(info, pairing_config)
998 )
999 # presence proven by a dynamic-PIN pairing itself needs no re-verification
1000 if not offers_dynamic_pin or PairMethod.DYNAMIC_PIN in record.pair_methods:
1001 raise AbortFlow("already_paired")
1002 await self._run_pin_pairing_flow(session, provider, static=False, verify=True)
1003
1004 async def _run_pin_pairing_flow(
1005 self,
1006 session: SetupSession,
1007 provider: SendspinProvider,
1008 *,
1009 static: bool,
1010 verify: bool = False,
1011 ) -> None:
1012 """
1013 Pair via PIN: the device wait, PIN entry and the retry-in-place loop.
1014
1015 A retryable failure re-renders the PIN form (start_pin_pairing resumes the session in
1016 place); a terminal failure aborts the flow and an expired device wait propagates as a
1017 timed_out abort. On any non-success exit the finally tears down a device-side session
1018 still in flight - including when the flow is cancelled.
1019
1020 :param static: Pair with the device's static PIN instead of a dynamic one.
1021 :param verify: Verify an already-paired device's presence (dynamic PIN only).
1022 """
1023 succeeded = False
1024 errors: dict[str, str] | None = None
1025 try:
1026 while True:
1027 try:
1028 pin_session = await provider.start_pin_pairing(
1029 self.player_id, static=static, verify=verify
1030 )
1031 except SecurityActionError as err:
1032 raise AbortFlow(_pairing_abort_reason(err)) from err
1033 await self._await_pin_request(session, pin_session)
1034 if not pin_session.awaiting_pin:
1035 # The attempt ended before a PIN could be entered.
1036 if await self._pairing_succeeded(provider, pin_session):
1037 succeeded = True
1038 return
1039 if pin_session.can_retry:
1040 errors = {"base": _pin_error_slug(pin_session.error)}
1041 continue
1042 raise AbortFlow(_pairing_abort_reason(pin_session.error))
1043 try:
1044 pin_values = await session.form(
1045 self._pin_form_entries(provider, pin_session),
1046 step_id="verify_pin" if verify else "enter_pin",
1047 errors=errors,
1048 expires_in=PAIR_PIN_ENTRY_TIMEOUT,
1049 )
1050 except StepExpiredError:
1051 # The countdown mirrors the device's attempt timeout, which aborts the
1052 # attempt around now; retry in place rather than dropping the flow.
1053 errors = {"base": "pairing_error_timeout"}
1054 continue
1055 errors = None
1056 try:
1057 provider.submit_pin(self.player_id, str(pin_values[CONF_PAIRING_PIN]).strip())
1058 except SecurityActionError as err:
1059 # the session ended underneath us (cancelled/timed out); start afresh
1060 errors = {"base": err.alert_key}
1061 continue
1062 task = pin_session.task
1063 if task is not None and not task.done():
1064 # Join the pairing task: the step deadline must not cancel it.
1065 with suppress(StepExpiredError):
1066 await session.progress_until(
1067 join_task(task),
1068 step_id="confirming",
1069 text="confirming",
1070 expires_in=PAIR_CONFIRM_TIMEOUT,
1071 )
1072 if await self._pairing_succeeded(provider, pin_session):
1073 succeeded = True
1074 return
1075 if pin_session.can_retry:
1076 errors = {"base": _pin_error_slug(pin_session.error)}
1077 continue
1078 raise AbortFlow(_pairing_abort_reason(pin_session.error))
1079 finally:
1080 if succeeded:
1081 provider.clear_pin_session(self.player_id)
1082 elif provider.get_pin_session(self.player_id) is not None:
1083 await provider.cancel_pin_pairing(self.player_id)
1084
1085 async def _await_pin_request(
1086 self,
1087 session: SetupSession,
1088 pin_session: PinPairingSession,
1089 ) -> None:
1090 """Wait until the device asks for its PIN, showing what it is waiting on."""
1091 if pin_session.awaiting_first_message:
1092 await session.progress_until(
1093 pin_session.wait_first_message(),
1094 step_id="awaiting_device",
1095 text="awaiting_device",
1096 expires_in=SERVER_FIRST_MESSAGE_TIMEOUT_S,
1097 )
1098 if pin_session.awaiting_gesture:
1099 await session.progress_until(
1100 pin_session.wait_pin_request(),
1101 step_id="awaiting_gesture",
1102 text="awaiting_gesture",
1103 expires_in=SERVER_GESTURE_TIMEOUT_S,
1104 )
1105
1106 def _pin_form_entries(
1107 self, provider: SendspinProvider, pin_session: PinPairingSession
1108 ) -> list[ConfigEntry]:
1109 """Return the PIN form fields, hinting how the operator gets the PIN."""
1110 entries = self._secret_hint_entries(provider, pin_session.method)
1111 # only a dynamic PIN carries a negotiated length; a static PIN is always
1112 # exactly 8 digits (enforced by aiosendspin)
1113 pin_length = pin_session.pin_length if pin_session.pin_length is not None else 8
1114 entries.append(
1115 ConfigEntry(
1116 key=CONF_PAIRING_PIN,
1117 type=ConfigEntryType.PAIRING_CODE,
1118 required=True,
1119 format=pin_code_format(pin_length),
1120 )
1121 )
1122 return entries
1123
1124 def _secret_hint_entries(
1125 self, provider: SendspinProvider, method: PairMethod
1126 ) -> list[ConfigEntry]:
1127 """Return LABEL entries for the device's hints on obtaining a method's pairing secret."""
1128 descriptor = pair_method_descriptor(
1129 effective_pair_methods(
1130 self.api.info_or_none, provider.pairing_config_snapshot(self.player_id)
1131 ),
1132 method,
1133 )
1134 if descriptor is None:
1135 return []
1136 hints = (
1137 descriptor.out_channels if method is PairMethod.DYNAMIC_PIN else descriptor.locations
1138 )
1139 labels = _SECRET_HINT_LABELS[method]
1140 return [
1141 ConfigEntry(key=key, type=ConfigEntryType.LABEL)
1142 for hint in hints or []
1143 if (key := labels.get(hint)) is not None
1144 ]
1145
1146 async def _run_token_pairing_flow(
1147 self, session: SetupSession, provider: SendspinProvider
1148 ) -> None:
1149 """Pair via a pasted pairing token, re-rendering the form on a recoverable failure."""
1150 errors: dict[str, str] | None = None
1151 while True:
1152 token_values = await session.form(
1153 [
1154 *self._secret_hint_entries(provider, PairMethod.PAIRING_PSK),
1155 ConfigEntry(key=CONF_PAIRING_TOKEN, type=ConfigEntryType.STRING, required=True),
1156 ],
1157 step_id="enter_token",
1158 errors=errors,
1159 )
1160 try:
1161 await provider.pair_with_token(
1162 self.player_id, str(token_values[CONF_PAIRING_TOKEN]).strip()
1163 )
1164 except (
1165 SecurityActionError,
1166 PairingError,
1167 HandshakeAbortedError,
1168 TimeoutError,
1169 OSError,
1170 ) as err:
1171 errors = {"base": error_alert(err).key}
1172 continue
1173 return
1174
1175
1176class SendspinPlayer(SendspinBasePlayer):
1177 """A sendspin audio player in Music Assistant."""
1178
1179 _attr_type = PlayerType.PROTOCOL
1180
1181 last_sent_artwork_url: str | None = None
1182 last_sent_artist_artwork_url: str | None = None
1183 _last_beat_queue_item_id: str | None = None
1184 _last_beat_anchor_us: int | None = None
1185 # Background poller that retries _send_beat_schedule when analysis is
1186 # not yet available. Cancelled on track change / stop / successful push.
1187 _beat_retry_task: asyncio.Task[None] | None = None
1188 # Queue item the current poller is targeting (so a track switch cancels it).
1189 _beat_retry_queue_item_id: str | None = None
1190 playback_session: SendspinPlaybackSession
1191 static_delay_default_ms: int = DEFAULT_SENDSPIN_STATIC_DELAY
1192 # HA media_player entity announcements are relayed to (ESPHome-backed devices)
1193 _hass_announce_entity_id: str | None = None
1194
1195 @property
1196 def requires_flow_mode(self) -> bool:
1197 """Return if the player requires flow mode."""
1198 return True
1199
1200 def __init__(
1201 self,
1202 provider: SendspinProvider,
1203 player_id: str,
1204 initial_hello: ClientHelloPayload | None = None,
1205 ) -> None:
1206 """Initialize the Player."""
1207 super().__init__(provider, player_id, initial_hello)
1208 self._attr_can_group_with = {provider.instance_id}
1209 hello_payload = initial_hello or self.api.info
1210 self.playback_session = SendspinPlaybackSession(self)
1211 self._attr_supported_features = {
1212 PlayerFeature.PLAY_MEDIA,
1213 PlayerFeature.SET_MEMBERS,
1214 PlayerFeature.MULTI_DEVICE_DSP,
1215 }
1216 # Keep volume/mute features of the first registration as a workaround for Cast.
1217 if hello_payload.player_support:
1218 _supported_commands = hello_payload.player_support.supported_commands
1219 if PlayerCommand.VOLUME in _supported_commands:
1220 self._attr_supported_features.add(PlayerFeature.VOLUME_SET)
1221 if PlayerCommand.MUTE in _supported_commands:
1222 self._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
1223
1224 @property
1225 def supported_sample_rates(self) -> list[tuple[int, int]] | None:
1226 """Return supported (sample_rate, bit_depth) tuples derived from the player role."""
1227 # not cached: the player role / reported formats can change after the
1228 # client (re)registers, so we always re-resolve from the live role state
1229 if (player_role := self._player_role) is not None:
1230 formats = player_role.get_supported_formats() or []
1231 rates = sorted({(fmt.sample_rate, fmt.bit_depth) for fmt in formats})
1232 if rates:
1233 return rates
1234 return [(44100, 16)]
1235
1236 def preserve_control_features_from(self, other: SendspinPlayer) -> None:
1237 """Keep the first registration's volume/mute features as a workaround for Cast."""
1238 for feature in (PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE):
1239 if feature in other.supported_features:
1240 self._attr_supported_features.add(feature)
1241 else:
1242 self._attr_supported_features.discard(feature)
1243
1244 def set_hass_announce_entity(self, entity_id: str | None) -> None:
1245 """
1246 Set or clear the Home Assistant entity used to relay announcements.
1247
1248 ESPHome devices support announcements natively (ducking any running
1249 playback), but that capability is only reachable through their Home
1250 Assistant media_player entity; the PLAY_ANNOUNCEMENT feature follows it.
1251
1252 :param entity_id: The HA media_player entity id, or None to clear.
1253 """
1254 self._hass_announce_entity_id = entity_id
1255 if entity_id is not None:
1256 self._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1257 else:
1258 self._attr_supported_features.discard(PlayerFeature.PLAY_ANNOUNCEMENT)
1259
1260 async def play_announcement(
1261 self, announcement: PlayerMedia, volume_level: int | None = None
1262 ) -> None:
1263 """Handle (provider native) playback of an announcement on given player."""
1264 entity_id = self._hass_announce_entity_id
1265 hass = cast("HomeAssistantProvider | None", self.mass.get_provider("hass"))
1266 if entity_id is None or hass is None or not hass.available:
1267 raise PlayerCommandFailed(
1268 f"Announcement relay via Home Assistant is not available for {self.display_name}"
1269 )
1270 self.logger.info(
1271 "Playing announcement %s on %s (via Home Assistant)",
1272 announcement.uri,
1273 self.display_name,
1274 )
1275 if volume_level is not None:
1276 # the device's announcement pipeline plays at its own volume;
1277 # the announce volume config entries are hidden for this player
1278 self.logger.debug("Ignoring announcement volume level for player %s", self.display_name)
1279 await hass.play_announcement_on_entity(entity_id, announcement)
1280 self.logger.debug("Playing announcement on %s completed", self.display_name)
1281
1282 def restore_bridge_identity(
1283 self, previous_device_info: DeviceInfo, previous_type: PlayerType
1284 ) -> None:
1285 """Keep bridge players exposed as protocol bridges after client attach updates."""
1286 if previous_type != PlayerType.PROTOCOL:
1287 return
1288 if not (
1289 IdentifierType.CAST_UUID in previous_device_info.identifiers
1290 or IdentifierType.AIRPLAY_ID in previous_device_info.identifiers
1291 ):
1292 return
1293 refreshed_identifiers = dict(self._attr_device_info.identifiers)
1294 self._attr_device_info = DeviceInfo(
1295 model=previous_device_info.model,
1296 manufacturer=previous_device_info.manufacturer,
1297 software_version=self._attr_device_info.software_version,
1298 )
1299 for id_type, id_value in refreshed_identifiers.items():
1300 self._attr_device_info.add_identifier(id_type, id_value)
1301 self.is_web_player = False
1302 self._attr_hidden_by_default = False
1303 self._attr_private = False
1304 self._attr_expose_to_ha_by_default = True
1305 self._attr_type = PlayerType.PROTOCOL
1306
1307 def event_cb(self, client: SendspinClient, event: ClientEvent) -> None:
1308 """Event callback registered to the sendspin client."""
1309 match event:
1310 case VolumeChangedEvent(volume=volume, muted=muted):
1311 self._attr_volume_level = volume
1312 self._attr_volume_muted = muted
1313 self.update_state()
1314 case StaticDelayChangedEvent(static_delay_ms=delay_ms):
1315 self.logger.debug("Static delay changed to %d ms", delay_ms)
1316 current = self.config.get_value(
1317 CONF_SENDSPIN_STATIC_DELAY, self.static_delay_default_ms
1318 )
1319 if current != delay_ms:
1320 self.mass.config.set_raw_player_config_value(
1321 self.player_id, CONF_SENDSPIN_STATIC_DELAY, delay_ms
1322 )
1323 case _:
1324 super().event_cb(client, event)
1325
1326 def group_event_cb(self, group: SendspinGroup, event: GroupEvent) -> None:
1327 """Event callback registered to the sendspin group this player belongs to."""
1328 # Leader only: a synced follower's self.state.current_media is a reference to
1329 # the leader's PlayerMedia object, so the refresh below would mutate the leader's
1330 # anchor from every follower. The metadata push (also leader-only) is what these
1331 # refreshes exist to serve, so followers have nothing to do here.
1332 is_resume = (
1333 isinstance(event, GroupStateChangedEvent)
1334 and event.state == PlaybackStateType.PLAYING
1335 and self._attr_playback_state == PlaybackState.PAUSED
1336 and self.synced_to is None
1337 )
1338 if is_resume:
1339 # _attr_elapsed_time_last_updated is only advanced by playback.py's commit
1340 # loop, which stops while paused - so it's still anchored to the moment
1341 # playback paused. Fast-forward it now, before update_state() below flips
1342 # playback_state to PLAYING, so corrected_elapsed_time doesn't extrapolate
1343 # across the paused span.
1344 self._attr_elapsed_time_last_updated = time.time()
1345 super().group_event_cb(group, event)
1346 if is_resume and self.state.current_media is not None:
1347 # send_current_media_metadata() (scheduled below) reads self.state.current_media,
1348 # which the queue controller rebuilds from its own cached elapsed-time anchor -
1349 # only refreshed via a 500ms-debounced callback, so it's still stale here even
1350 # after the fix above. Patch this update's snapshot directly so the imminent
1351 # metadata push doesn't race ahead of that debounce with a stale value.
1352 self.state.current_media.elapsed_time_last_updated = time.time()
1353 match event:
1354 case GroupStateChangedEvent(state=state) if self.synced_to is None and state in (
1355 PlaybackStateType.PLAYING,
1356 PlaybackStateType.PAUSED,
1357 ):
1358 # Push progress explicitly: current_media's identity is unchanged across
1359 # pause/resume so update_state() above won't debounce a metadata push
1360 # through the normal media-changed callback.
1361 self.mass.create_task(
1362 self.send_current_media_metadata(),
1363 task_id=f"sendspin_metadata_{self.player_id}",
1364 abort_existing=True,
1365 )
1366 case ControllerEvent() as controller_event:
1367 if self.synced_to is None:
1368 self.mass.create_task(self._handle_controller_event(controller_event))
1369
1370 async def volume_set(self, volume_level: int) -> None:
1371 """Handle VOLUME_SET command on the player."""
1372 roles = self.api.roles_by_family("player")
1373 for role in roles:
1374 role.set_player_volume(volume_level)
1375
1376 async def volume_mute(self, muted: bool) -> None:
1377 """Handle VOLUME MUTE command on the player."""
1378 roles = self.api.roles_by_family("player")
1379 for role in roles:
1380 role.set_player_mute(muted)
1381 # Native clients don't always emit a VolumeChangedEvent in response to a
1382 # mute command, so update our state directly to keep MA in sync.
1383 self._attr_volume_muted = muted
1384 self.update_state()
1385
1386 async def stop(self) -> None:
1387 """Stop command."""
1388 self.logger.debug("Received STOP command on player %s", self.display_name)
1389 self.mark_stop_called()
1390 self._attr_current_media = None
1391 self._attr_playback_state = PlaybackState.IDLE
1392 self._attr_elapsed_time = 0
1393 self._attr_elapsed_time_last_updated = time.time()
1394 self.update_state()
1395 # group.stop() snapshots the live position, which it can only do while the push
1396 # stream is up - cancelling first leaves it re-emitting a stale anchor. Teardown
1397 # goes in finally so a failing group stop can't strand the session, and nothing
1398 # may await between the two: the STOPPED event cancels this session inline, and a
1399 # suspension in between would let that cancel cut pipeline teardown short.
1400 try:
1401 await self.api.group.stop()
1402 finally:
1403 # Bridge members buffer seconds of audio on their downstream protocol
1404 # and keep that transport warm across stream ends; a user stop must
1405 # reach them so the device is silenced now instead of playing out its
1406 # buffer. Synchronous, so nothing suspends before the cancel below.
1407 self._notify_bridges_explicit_stop(self.api.group.clients)
1408 await self.playback_session.cancel("stop command")
1409 # A group stop that raised before ending the stream left the notify
1410 # above without effect (the bridges still saw themselves streaming).
1411 # The cancel is what ends the stream on that path, so deliver the
1412 # notify again; bridges already torn down ignore it.
1413 self._notify_bridges_explicit_stop(self.api.group.clients)
1414
1415 async def play_media(self, media: PlayerMedia) -> None:
1416 """Play media command."""
1417 self.logger.debug(
1418 "Received PLAY_MEDIA command on player %s with uri %s", self.display_name, media.uri
1419 )
1420
1421 self._attr_current_media = media
1422 self._attr_elapsed_time = None
1423 self._attr_elapsed_time_last_updated = None
1424
1425 # The spec reserves stream/end for queue-empty, not track changes.
1426 await self.playback_session.cancel("new media requested", keep_stream=True)
1427
1428 # Cast-only: reset future before start() to avoid racing _on_stream_start
1429 # and to cancel any stale pending future from a previous timed-out attempt.
1430 cast_app_ready: asyncio.Future[None] | None = None
1431 if (mgr := self._get_cast_bridge_manager()) and (
1432 bridge := mgr.get_bridge_by_client_id(self.player_id)
1433 ):
1434 cast_app_ready = bridge.reset_cast_app_ready()
1435
1436 await self.playback_session.start(media)
1437 self.update_state()
1438
1439 if cast_app_ready is None:
1440 return
1441 try:
1442 await asyncio.wait_for(asyncio.shield(cast_app_ready), timeout=CAST_APP_READY_TIMEOUT)
1443 except BaseException as exc:
1444 if not cast_app_ready.done():
1445 cast_app_ready.cancel()
1446 # Cancel the playback session so we don't keep streaming to a
1447 # device that never reported ready.
1448 with suppress(Exception):
1449 await self.playback_session.cancel("cast app readiness failed")
1450 if isinstance(exc, TimeoutError):
1451 raise PlayerCommandFailed(
1452 f"Cast app on {self.display_name} did not report ready within 30s",
1453 translation_key="cast_app_not_ready",
1454 translation_owner=self.translation_owner,
1455 translation_args=[self.display_name],
1456 ) from None
1457 raise
1458
1459 async def on_config_updated(self) -> None:
1460 """Handle logic when the PlayerConfig is first loaded or updated."""
1461 await self._apply_preferred_format()
1462 await self._apply_static_delay()
1463
1464 async def set_members(
1465 self,
1466 player_ids_to_add: list[str] | None = None,
1467 player_ids_to_remove: list[str] | None = None,
1468 ) -> None:
1469 """Handle SET_MEMBERS command on the player."""
1470 for player_id in player_ids_to_remove or []:
1471 member_player = self.mass.players.get_player(player_id, True)
1472 member_player = cast("SendspinPlayer", member_player)
1473
1474 # Dynamic leader switch: transfer the active playback session to the
1475 # next remaining group member before removing ourselves from the group.
1476 # This keeps the PushStream alive for the remaining members.
1477 if (
1478 player_id == self.player_id
1479 and self.playback_session.playback_task is not None
1480 and not self.playback_session.playback_task.done()
1481 ):
1482 remaining = [c for c in self.api.group.clients if c.client_id != self.player_id]
1483 if remaining:
1484 new_owner_id = remaining[0].client_id
1485 new_owner = self.mass.players.get_player(new_owner_id)
1486 if isinstance(new_owner, SendspinPlayer):
1487 self.logger.info(
1488 "Transferring playback session to %s for dynamic leader switch",
1489 new_owner.display_name,
1490 )
1491 await self.playback_session.transfer_to(new_owner)
1492 new_owner.playback_session = self.playback_session
1493 self.playback_session = SendspinPlaybackSession(self)
1494
1495 await self.api.group.remove_client(member_player.api)
1496 # An explicit removal ends playback for that member; a bridge among
1497 # its roles must silence its device now rather than play out the
1498 # audio it still holds buffered. A move to another group passes
1499 # here too (the controller ungroups before it adds) and trades its
1500 # warm transport handover for that immediate silence.
1501 self._notify_bridges_explicit_stop([member_player.api])
1502 # Cast-only: reset futures before add so a fatal error on a Cast-bridged
1503 # member (e.g. AudioContext unsupported) raises PlayerCommandFailed.
1504 # Only track readiness while streaming, only then add_client launches the app.
1505 bridge_manager = self._get_cast_bridge_manager()
1506 pending_cast: list[tuple[SendspinPlayer, asyncio.Future[None]]] = []
1507 try:
1508 for player_id in player_ids_to_add or []:
1509 member_player = cast(
1510 "SendspinPlayer", self.mass.players.get_player(player_id, True)
1511 )
1512 if (
1513 self.api.group.has_active_stream
1514 and bridge_manager
1515 and (bridge := bridge_manager.get_bridge_by_client_id(player_id))
1516 ):
1517 pending_cast.append((member_player, bridge.reset_cast_app_ready()))
1518 await self.api.group.add_client(member_player.api)
1519
1520 if pending_cast:
1521 # asyncio.wait leaves the futures untouched: the stuck list below needs
1522 # them intact, and a waiter that adopts them makes asyncio report a member
1523 # failing afterwards to the loop exception handler as well
1524 await asyncio.wait(
1525 [f for _, f in pending_cast],
1526 timeout=CAST_APP_READY_TIMEOUT,
1527 return_when=asyncio.FIRST_EXCEPTION,
1528 )
1529 for _, ready in pending_cast:
1530 if ready.done():
1531 # a member's own failure outranks the readiness timeout
1532 ready.result()
1533 if stuck := [m.display_name for m, f in pending_cast if not f.done()]:
1534 raise PlayerCommandFailed(
1535 f"Cast app on {', '.join(stuck)} did not report ready within 30s",
1536 translation_key="cast_app_members_not_ready",
1537 translation_owner=self.translation_owner,
1538 translation_args=[", ".join(stuck)],
1539 )
1540 except BaseException:
1541 # Roll back Cast members we just added so a failed group operation
1542 # doesn't leave dead members in the Sendspin group.
1543 for member, _ in pending_cast:
1544 with suppress(Exception):
1545 await self.api.group.remove_client(member.api)
1546 raise
1547 finally:
1548 for _, f in pending_cast:
1549 if not f.done():
1550 f.cancel()
1551 # self.group_members will be updated by the group event callback
1552
1553 def on_player_media_updated(self) -> None:
1554 """Handle callback when the current media of the player is updated."""
1555 if self.synced_to is not None:
1556 # Only leader sends metadata
1557 return
1558 self.mass.create_task(
1559 self.send_current_media_metadata(),
1560 task_id=f"sendspin_metadata_{self.player_id}",
1561 abort_existing=True,
1562 )
1563
1564 async def send_current_media_metadata(self) -> None:
1565 """Send the current media metadata to the sendspin group."""
1566 if not self.available:
1567 return
1568 current_media = self.state.current_media
1569 if current_media is None:
1570 await self._clear_current_media_metadata()
1571 return
1572 # check if we are playing a MA queue item
1573 queue_item: QueueItem | None = None
1574 queue: PlayerQueue | None = None
1575 if current_media.source_id and current_media.queue_item_id:
1576 queue = self.mass.player_queues.get(current_media.source_id)
1577 queue_item = self.mass.player_queues.get_item(
1578 current_media.source_id, current_media.queue_item_id
1579 )
1580
1581 # Runs even without a queue item so radio / Spotify Connect streams still get art.
1582 await self._send_album_artwork(current_media)
1583 if queue_item:
1584 await self._send_artist_artwork(queue_item)
1585
1586 track_number: int | None = None
1587 year: int | None = None
1588 album_artist: str | None = None
1589 if queue_item and queue_item.media_item and is_track(queue_item.media_item):
1590 track = queue_item.media_item
1591 track_number = track.track_number or None
1592 album_mapping = track.album
1593 if album_mapping is not None:
1594 year = album_mapping.year
1595 if not isinstance(album_mapping, Album):
1596 # Cheap DB-only lookup, no external API call; None if not in library
1597 result = await self.mass.music.get_library_item_by_prov_id(
1598 MediaType.ALBUM, album_mapping.item_id, album_mapping.provider
1599 )
1600 full_album: Album | None = result if isinstance(result, Album) else None
1601 else:
1602 full_album = album_mapping
1603 if full_album and full_album.artists:
1604 album_artist = full_album.artist_str
1605
1606 track_duration = current_media.duration or 0
1607 if controller_role := self._controller_role:
1608 controller_role.set_seek_max_ms(int(track_duration * 1000) if track_duration else None)
1609 repeat = SendspinRepeatMode.OFF
1610 if queue and queue.repeat_mode == RepeatMode.ALL:
1611 repeat = SendspinRepeatMode.ALL
1612 elif queue and queue.repeat_mode == RepeatMode.ONE:
1613 repeat = SendspinRepeatMode.ONE
1614
1615 shuffle = queue.shuffle_enabled if queue else False
1616 is_playing = self.state.playback_state == PlaybackState.PLAYING
1617 track_progress = self._compute_track_progress_ms(current_media, is_playing=is_playing)
1618
1619 metadata = Metadata(
1620 title=current_media.title,
1621 artist=current_media.artist,
1622 album_artist=album_artist,
1623 album=current_media.album,
1624 artwork_url=current_media.image_url,
1625 year=year,
1626 track=track_number,
1627 track_duration=track_duration * 1000 if track_duration is not None else None,
1628 track_progress=track_progress,
1629 playback_speed=1000 if is_playing else 0,
1630 repeat=repeat,
1631 shuffle=shuffle,
1632 )
1633
1634 # Send metadata to the group
1635 if (metadata_role := self._metadata_role) is not None:
1636 metadata_role.set_metadata(metadata)
1637
1638 self._publish_repeat_shuffle(repeat, shuffle=shuffle)
1639
1640 # Send color palette derived from the cover art (already computed by
1641 # the players controller with the Sendspin defined minimum contrast values).
1642 if (color_role := self._color_role) is not None:
1643 self._send_color_palette(color_role, current_media.palette)
1644
1645 await self._send_beat_schedule(queue, queue_item, track_progress, is_playing)
1646
1647 async def get_config_entries(self) -> list[ConfigEntry]:
1648 """Return all (provider/player specific) Config Entries for the player."""
1649 entries: list[ConfigEntry] = []
1650 # Show alert if this Cast device is known to lack AudioContext support
1651 if self.mass.config.get_raw_player_config_value(
1652 self.player_id, CONF_CAST_AUDIO_UNSUPPORTED
1653 ):
1654 entries.append(
1655 ConfigEntry(
1656 key="cast_audio_unsupported",
1657 type=ConfigEntryType.ALERT,
1658 required=False,
1659 )
1660 )
1661 entries.extend(await super().get_config_entries())
1662 # Build dynamic format options from player's supported formats
1663 player_role = self._player_role
1664 if player_role is not None:
1665 supported_formats = player_role.get_supported_formats()
1666 if supported_formats:
1667 format_options = [
1668 ConfigValueOption(SENDSPIN_FORMAT_AUTOMATIC),
1669 ]
1670 for fmt in supported_formats:
1671 format_options.append(
1672 ConfigValueOption(
1673 format_to_option_value(fmt), title=format_to_display_string(fmt)
1674 )
1675 )
1676 entries.append(
1677 ConfigEntry(
1678 key=CONF_PREFERRED_SENDSPIN_FORMAT,
1679 type=ConfigEntryType.STRING,
1680 category="protocol_generic",
1681 default_value=SENDSPIN_FORMAT_AUTOMATIC,
1682 options=format_options,
1683 advanced=True,
1684 )
1685 )
1686
1687 if (
1688 player_role is not None
1689 and PlayerCommand.SET_STATIC_DELAY in player_role.state_supported_commands
1690 ):
1691 entries.append(
1692 ConfigEntry(
1693 key=CONF_SENDSPIN_STATIC_DELAY,
1694 type=ConfigEntryType.INTEGER,
1695 required=False,
1696 default_value=self.static_delay_default_ms,
1697 range=(0, 5000),
1698 immediate_apply=True,
1699 # Not a advanced option since this will only show up for players where it is likely
1700 # necessary to adjust the delay.
1701 advanced=False,
1702 )
1703 )
1704
1705 if self._hass_announce_entity_id is not None:
1706 # announcements are relayed to the device via Home Assistant,
1707 # which has no volume control for announcements
1708 entries.extend(HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES)
1709
1710 return entries
1711
1712 async def on_unload(self) -> None:
1713 """Handle logic when the player is unloaded from the Player controller."""
1714 await self.playback_session.close()
1715 await super().on_unload()
1716
1717 def _subscribe_client_callbacks(self) -> None:
1718 """Subscribe to client and group events for the currently bound client."""
1719 super()._subscribe_client_callbacks()
1720 self.api.disconnect_behaviour = DisconnectBehaviour.STOP
1721 if controller_role := self._controller_role:
1722 controller_role.set_supported_commands(SUPPORTED_GROUP_COMMANDS)
1723
1724 def _refresh_client_info(
1725 self,
1726 sendspin_client: SendspinClient,
1727 hello_payload: ClientHelloPayload | None = None,
1728 ) -> None:
1729 """Refresh player attributes from a Sendspin client hello/info payload."""
1730 super()._refresh_client_info(sendspin_client, hello_payload=hello_payload)
1731 client_info = hello_payload or sendspin_client.info
1732 if device_info := client_info.device_info:
1733 # determine if this is a web/app player based on product name or manufacturer
1734 # TODO: make this part of the spec and let clients explicitly report if they
1735 # are a web/app player instead of relying on heuristics
1736 self.is_web_player = (
1737 device_info.product_name
1738 in (
1739 "Web Browser",
1740 "Web Player",
1741 "Mobile Application",
1742 "PWA",
1743 )
1744 or device_info.manufacturer == "Music Assistant"
1745 )
1746 else:
1747 self.is_web_player = False
1748 if client_info.player_support:
1749 for role in sendspin_client.roles_by_family("player"):
1750 volume = role.get_player_volume()
1751 muted = role.get_player_muted()
1752 if volume is not None:
1753 self._attr_volume_level = volume
1754 if muted is not None:
1755 self._attr_volume_muted = muted
1756 if volume is not None or muted is not None:
1757 break
1758 # virtual players are server-owned anchors that follow the same
1759 # hidden/standalone semantics as web players, without relying on the
1760 # device-info heuristics above
1761 is_standalone = self.is_web_player or cast(
1762 "SendspinProvider", self.provider
1763 ).is_virtual_player(self.player_id)
1764 self._attr_expose_to_ha_by_default = not is_standalone
1765 self._attr_hidden_by_default = is_standalone
1766 self._attr_private = is_standalone
1767 # register web/app player as native player type because it doesn't need to be linked
1768 # every web/app player is just a standalone player.
1769 self._attr_type = PlayerType.PLAYER if is_standalone else PlayerType.PROTOCOL
1770
1771 def _on_group_changed(self, new_group: SendspinGroup) -> None:
1772 """Handle group change with controller commands and playback session cancellation."""
1773 if controller_role := self._controller_role:
1774 controller_role.set_supported_commands(SUPPORTED_GROUP_COMMANDS)
1775 # Cancel active playback - push stream belongs to the old group
1776 self.mass.create_task(self.playback_session.cancel("group changed"))
1777 super()._on_group_changed(new_group)
1778
1779 def _on_group_stopped(self) -> None:
1780 """Cancel playback session when group stops and we are the leader."""
1781 if self.synced_to is not None:
1782 return
1783 # Bind the cancel to the session task that is live right now: by the time
1784 # the deferred task below runs, play_media may already have started a fresh
1785 # session, which must not be torn down by this stale group-stopped event.
1786 stale_task = self.playback_session.playback_task
1787 if stale_task is None or stale_task.done():
1788 return
1789 self.mass.create_task(self._cancel_stale_playback_session(stale_task))
1790
1791 async def _cancel_stale_playback_session(self, task: asyncio.Task[None]) -> None:
1792 """Cancel the playback session only if the given task is still the active one."""
1793 if self.playback_session.playback_task is not task:
1794 return
1795 await self.playback_session.cancel("group stopped")
1796
1797 async def _handle_controller_event(self, event: ControllerEvent) -> None:
1798 """Handle a controller event from the ControllerGroupRole."""
1799 queue = self.mass.player_queues.get_active_queue(self.player_id)
1800 match event:
1801 case ControllerPlayEvent():
1802 await self.mass.players.cmd_play(self.player_id)
1803 case ControllerPauseEvent():
1804 await self.mass.players.cmd_pause(self.player_id)
1805 case ControllerStopEvent():
1806 await self.mass.players.cmd_stop(self.player_id)
1807 case ControllerNextEvent():
1808 await self.mass.players.cmd_next_track(self.player_id)
1809 case ControllerPreviousEvent():
1810 await self.mass.players.cmd_previous_track(self.player_id)
1811 case ControllerRepeatEvent(mode=mode) if queue:
1812 match mode:
1813 case SendspinRepeatMode.OFF:
1814 await self.mass.player_queues.set_repeat(queue.queue_id, RepeatMode.OFF)
1815 case SendspinRepeatMode.ONE:
1816 await self.mass.player_queues.set_repeat(queue.queue_id, RepeatMode.ONE)
1817 case SendspinRepeatMode.ALL:
1818 await self.mass.player_queues.set_repeat(queue.queue_id, RepeatMode.ALL)
1819 case ControllerShuffleEvent(shuffle=shuffle) if queue:
1820 await self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=shuffle)
1821 case ControllerSeekEvent(position_ms=position_ms) if (
1822 queue and queue.current_item and queue.current_item.duration
1823 ):
1824 # Clamp in case track duration changed after we advertised the seek range.
1825 duration_ms = int(queue.current_item.duration * 1000)
1826 await self.mass.player_queues.seek(
1827 queue.queue_id, max(0, min(position_ms, duration_ms)) // 1000
1828 )
1829 case ControllerSeekRelativeEvent(offset_ms=offset_ms) if (
1830 queue and queue.current_item and queue.current_item.duration
1831 ):
1832 # Clamp current position + offset to the 0..duration range.
1833 target_ms = int(queue.corrected_elapsed_time * 1000) + offset_ms
1834 duration_ms = int(queue.current_item.duration * 1000)
1835 await self.mass.player_queues.seek(
1836 queue.queue_id, max(0, min(target_ms, duration_ms)) // 1000
1837 )
1838
1839 async def _sync_membership_from_group(self, group: SendspinGroup) -> None:
1840 """Sync MA/player + playback session membership from authoritative group state."""
1841 # Ignore stale events from a group we no longer belong to.
1842 if group is not self.api.group:
1843 return
1844 group_client_ids = [client.client_id for client in group.clients]
1845 is_leader = bool(group_client_ids) and group_client_ids[0] == self.player_id
1846 desired_group_members = group_client_ids if is_leader else []
1847 desired_session_members = group_client_ids[1:] if is_leader else []
1848 if self._attr_group_members != desired_group_members:
1849 self._attr_group_members = desired_group_members
1850 self.update_state()
1851 # Only use STOP when we actually lead other members.
1852 self.api.disconnect_behaviour = (
1853 DisconnectBehaviour.STOP
1854 if is_leader and len(desired_session_members) > 0
1855 else DisconnectBehaviour.UNGROUP
1856 )
1857 await self.playback_session.sync_members(set(desired_session_members))
1858
1859 def _notify_bridges_explicit_stop(self, clients: Iterable[SendspinClient]) -> None:
1860 """
1861 Tell bridge roles among the given clients that playback was explicitly stopped.
1862
1863 :param clients: The Sendspin clients whose bridge roles to notify.
1864 """
1865 for client in list(clients):
1866 for role in client.roles_by_family("player"):
1867 if not isinstance(role, BridgePlayerRole):
1868 continue
1869 try:
1870 role.notify_explicit_stop()
1871 except Exception:
1872 # Best effort: one bridge failing must not keep the stop
1873 # from reaching the other members or the session teardown.
1874 self.logger.exception(
1875 "Error notifying bridge %s of an explicit stop", client.client_id
1876 )
1877
1878 def _get_cast_bridge_manager(self) -> SendspinBridgeManager | None:
1879 """Return the Chromecast provider's Sendspin bridge manager, if loaded."""
1880 chromecast_provider = self.mass.get_provider("chromecast")
1881 if chromecast_provider is None:
1882 return None
1883 manager = getattr(chromecast_provider, "bridge_manager", None)
1884 if manager is None:
1885 return None
1886 return cast("SendspinBridgeManager", manager)
1887
1888 async def _apply_preferred_format(self) -> None:
1889 """Read config and set/clear the players preferred format."""
1890 player_role = self._player_role
1891 if player_role is None:
1892 return
1893
1894 config_value = cast(
1895 "str",
1896 self.config.get_value(CONF_PREFERRED_SENDSPIN_FORMAT, SENDSPIN_FORMAT_AUTOMATIC),
1897 )
1898 if config_value == SENDSPIN_FORMAT_AUTOMATIC:
1899 # Automatic mode: clear override and let client decide.
1900 player_role.set_preferred_format(None, None)
1901 return
1902
1903 parsed = option_value_to_format(config_value)
1904 if parsed is None:
1905 self.logger.warning(
1906 "Invalid audio format config value '%s' for player %s",
1907 config_value,
1908 self.display_name,
1909 )
1910 return
1911
1912 codec, audio_format = parsed
1913 if not player_role.set_preferred_format(audio_format, codec):
1914 self.logger.warning(
1915 "Failed to set preferred audio format %s %s for player %s",
1916 codec.name,
1917 audio_format,
1918 self.display_name,
1919 )
1920
1921 async def _apply_static_delay(self) -> None:
1922 """Read config and send set_static_delay command if supported."""
1923 player_role = self._player_role
1924 if player_role is None:
1925 return
1926
1927 config_value = cast(
1928 "int",
1929 self.config.get_value(CONF_SENDSPIN_STATIC_DELAY, self.static_delay_default_ms),
1930 )
1931 player_role.set_static_delay(config_value)
1932
1933 async def _send_album_artwork(self, current_media: PlayerMedia) -> str | None:
1934 """
1935 Send album artwork to the sendspin group.
1936
1937 Args:
1938 current_media: The current player media.
1939 """
1940 # image_url is resolved per-source upstream (radio / Spotify Connect / queue items).
1941 artwork_url = current_media.image_url
1942 if artwork_url != self.last_sent_artwork_url:
1943 self.last_sent_artwork_url = artwork_url
1944 if artwork_url is not None:
1945 # Fetch from the resolved URL so the bytes match artwork_url, even when
1946 # radio now-playing art differs from the queue item's own image.
1947 try:
1948 image_data = await self.mass.metadata.get_thumbnail(
1949 artwork_url, provider="builtin"
1950 )
1951 except MediaNotFoundError:
1952 # artwork file was removed from disk; skip rather than crash the send
1953 image_data = None
1954 if isinstance(image_data, bytes):
1955 # decode through the guard so undecodable art (e.g. SVG) is skipped, not crashed
1956 image = await self._decode_artwork(image_data)
1957 if image is not None and (artwork_role := self._artwork_role) is not None:
1958 await artwork_role.set_album_artwork(image)
1959 elif (artwork_role := self._artwork_role) is not None:
1960 await artwork_role.set_album_artwork(None)
1961
1962 return artwork_url
1963
1964 async def _send_artist_artwork(self, current_item: QueueItem) -> None:
1965 """Send artist artwork to the sendspin group."""
1966 artist_artwork_url: str | None = None
1967
1968 if current_item.media_item is not None and is_track(current_item.media_item):
1969 artists = current_item.media_item.artists
1970 if artists:
1971 primary_artist = artists[0]
1972 # Prefer a full library artist (has reliable up-to-date artwork) over
1973 # the ItemMapping in the queue item, which often has image=None.
1974 result = await self.mass.music.get_library_item_by_prov_id(
1975 MediaType.ARTIST, primary_artist.item_id, primary_artist.provider
1976 )
1977 artist_item = result if isinstance(result, Artist) else None
1978 image = artist_item.image if artist_item is not None else primary_artist.image
1979 if image is not None:
1980 artist_artwork_url = self.mass.metadata.get_image_url(image)
1981
1982 if artist_artwork_url != self.last_sent_artist_artwork_url:
1983 self.last_sent_artist_artwork_url = artist_artwork_url
1984 if artist_artwork_url is not None:
1985 # Fetch bytes from the already-resolved URL to avoid the secondary
1986 # provider lookup that get_image_data_for_item triggers for ItemMappings.
1987 try:
1988 artist_image_data = await self.mass.metadata.get_thumbnail(
1989 artist_artwork_url, provider="builtin"
1990 )
1991 except MediaNotFoundError:
1992 # artwork file was removed from disk; skip rather than crash the send
1993 artist_image_data = None
1994 if isinstance(artist_image_data, bytes):
1995 artist_image = await self._decode_artwork(artist_image_data)
1996 if (
1997 artist_image is not None
1998 and (artwork_role := self._artwork_role) is not None
1999 ):
2000 await artwork_role.set_artist_artwork(artist_image)
2001 elif (artwork_role := self._artwork_role) is not None:
2002 await artwork_role.set_artist_artwork(None)
2003
2004 async def _decode_artwork(self, image_data: bytes) -> Image.Image | None:
2005 """
2006 Decode artwork bytes into a Pillow image, returning None if undecodable.
2007
2008 :param image_data: Raw image bytes to decode.
2009 """
2010
2011 def _open() -> Image.Image:
2012 img = Image.open(BytesIO(image_data))
2013 img.load()
2014 return img
2015
2016 try:
2017 return await asyncio.to_thread(_open)
2018 except OSError as err:
2019 self.logger.debug("Skipping undecodable artwork: %s", err)
2020 return None
2021
2022 async def _clear_current_media_metadata(self) -> None:
2023 """Clear all metadata and artwork from the sendspin group."""
2024 # Stop any in-flight beat-analysis polling task
2025 self._cancel_beat_retry()
2026 if (metadata_role := self._metadata_role) is not None:
2027 metadata_role.set_metadata(Metadata())
2028 if (visualizer_role := self._visualizer_role) is not None:
2029 visualizer_role.clear_beat_schedule()
2030 # Reset to PENDING so beats are re-deferred until the next track's analysis lands.
2031 visualizer_role.set_beat_availability(BeatAvailability.PENDING)
2032 if (artwork_role := self._artwork_role) is not None:
2033 await artwork_role.set_album_artwork(None)
2034 await artwork_role.set_artist_artwork(None)
2035 if (color_role := self._color_role) is not None:
2036 color_role.clear()
2037 if (controller_role := self._controller_role) is not None:
2038 controller_role.set_seek_max_ms(None)
2039 self.last_sent_artwork_url = None
2040 self.last_sent_artist_artwork_url = None
2041 self._last_beat_queue_item_id = None
2042 self._last_beat_anchor_us = None
2043
2044 def _publish_repeat_shuffle(self, repeat: SendspinRepeatMode, *, shuffle: bool) -> None:
2045 """
2046 Push repeat/shuffle to controller state for current-spec clients.
2047
2048 Clients implementing the older spec version still read the copy mirrored
2049 onto metadata state, for now.
2050 """
2051 if (controller_role := self._controller_role) is not None:
2052 controller_role.set_repeat(repeat)
2053 controller_role.set_shuffle(shuffle=shuffle)
2054
2055 def _send_color_palette(
2056 self, color_role: ColorGroupRole, palette: MediaItemPalette | None
2057 ) -> None:
2058 """Push the palette already attached to current_media to the sendspin group."""
2059 if palette is None:
2060 color_role.clear()
2061 return
2062 color_role.set_color(
2063 Color(
2064 background_dark=palette.background_dark,
2065 background_light=palette.background_light,
2066 primary=palette.primary,
2067 accent=palette.accent,
2068 on_dark=palette.on_dark,
2069 on_light=palette.on_light,
2070 )
2071 )
2072
2073 def _compute_track_progress_ms(self, current_media: PlayerMedia, *, is_playing: bool) -> int:
2074 """
2075 Resolve current track position in ms from queue/media elapsed time.
2076
2077 Prefer queue/media elapsed as source of truth. Only interpolate while
2078 actively playing; for paused/idle states keep the last fixed position.
2079 """
2080 elapsed_time: float | None = (
2081 float(current_media.elapsed_time) if current_media.elapsed_time is not None else None
2082 )
2083 if is_playing and current_media.corrected_elapsed_time is not None:
2084 elapsed_time = current_media.corrected_elapsed_time
2085 if elapsed_time is None:
2086 elapsed_time = self.corrected_elapsed_time if is_playing else self.elapsed_time
2087 return max(0, int(elapsed_time * 1000)) if elapsed_time is not None else 0
2088
2089 async def _refresh_beat_schedule(self) -> None:
2090 """
2091 Re-attempt beat hydration only, without re-pushing metadata/artwork.
2092
2093 Lighter than `send_current_media_metadata`: the retry poller uses this so
2094 late-arriving analysis lands without re-running the full pipeline.
2095 """
2096 current_media = self.state.current_media
2097 if current_media is None:
2098 return
2099 queue_item: QueueItem | None = None
2100 queue: PlayerQueue | None = None
2101 if current_media.source_id and current_media.queue_item_id:
2102 queue = self.mass.player_queues.get(current_media.source_id)
2103 queue_item = self.mass.player_queues.get_item(
2104 current_media.source_id, current_media.queue_item_id
2105 )
2106 is_playing = self.state.playback_state == PlaybackState.PLAYING
2107 track_progress = self._compute_track_progress_ms(current_media, is_playing=is_playing)
2108 await self._send_beat_schedule(queue, queue_item, track_progress, is_playing)
2109
2110 @staticmethod
2111 def _flow_track_offset_us(pq_data: PlayerQueueData | None, queue_item: QueueItem) -> int | None:
2112 """
2113 Return the current track's flow-stream start offset (minus file seek), in µs.
2114
2115 Sums the streamed duration of every track committed before the current
2116 one in the queue-flow stream, so beats anchor to this track's start
2117 rather than the first track's. Returns None when the flow log hasn't
2118 recorded this track yet, so the caller falls back to the progress anchor.
2119
2120 A track whose intro was crossfade-trimmed loses its raw seek position
2121 (only the elapsed-inflated value survives), so its anchor can be off by
2122 up to the crossfade duration. Exact handling needs a raw-seek field on
2123 the flow log entry.
2124 """
2125 if pq_data is None or queue_item.streamdetails is None:
2126 return None
2127 log = pq_data.flow_mode_stream_log
2128 if not log or log[-1].queue_item_id != queue_item.queue_item_id:
2129 return None
2130 track_flow_start_s = sum(entry.seconds_streamed or 0.0 for entry in log[:-1])
2131 file_seek_s = float(queue_item.streamdetails.seek_position or 0)
2132 return int((track_flow_start_s - file_seek_s) * 1_000_000)
2133
2134 async def _send_beat_schedule(
2135 self,
2136 queue: PlayerQueue | None,
2137 queue_item: QueueItem | None,
2138 track_progress_ms: int,
2139 is_playing: bool,
2140 ) -> None:
2141 """Hydrate per-track beat timings from audio analysis and push to visualizer."""
2142 visualizer_role = self._visualizer_role
2143 if visualizer_role is None:
2144 return
2145 if not is_playing or queue_item is None or queue_item.streamdetails is None:
2146 visualizer_role.clear_beat_schedule()
2147 self._last_beat_queue_item_id = None
2148 self._last_beat_anchor_us = None
2149 self._cancel_beat_retry()
2150 return
2151 # smart_fades is the only AA provider that emits beats. Without it, no
2152 # beats will ever arrive for this source.
2153 if not any(
2154 p.available and p.domain == "smart_fades"
2155 for p in self.mass.get_providers(ProviderType.AUDIO_ANALYSIS)
2156 ):
2157 visualizer_role.clear_beat_schedule()
2158 visualizer_role.set_beat_availability(BeatAvailability.UNAVAILABLE)
2159 self._last_beat_queue_item_id = None
2160 self._last_beat_anchor_us = None
2161 self._cancel_beat_retry()
2162 return
2163 provider = cast("SendspinProvider", self.provider)
2164 now_us = provider.server_api.clock.now_us()
2165 # Anchor beats to the current track's spot in the flow stream's audio
2166 # timeline. Falls back to the progress-derived anchor until the first
2167 # chunk commits or while the flow log hasn't recorded this track yet.
2168 anchor_us: int | None = None
2169 pq_data = self.mass.player_queues.queue_data_or_none(queue.queue_id) if queue else None
2170 offset_us = self._flow_track_offset_us(pq_data, queue_item)
2171 if offset_us is not None:
2172 anchor_us = self.playback_session.flow_track_anchor_us(offset_us)
2173 if anchor_us is None:
2174 anchor_us = now_us - track_progress_ms * 1000
2175 # Re-push only on track change or seek (anchor jumps beyond natural drift).
2176 if (
2177 queue_item.queue_item_id == self._last_beat_queue_item_id
2178 and self._last_beat_anchor_us is not None
2179 and abs(anchor_us - self._last_beat_anchor_us) < 500_000
2180 ):
2181 return
2182 sd = queue_item.streamdetails
2183 analysis = await self.mass.streams.audio_analysis.get_audio_analysis(
2184 sd.item_id,
2185 sd.provider,
2186 media_type=sd.media_type,
2187 priority=(SMART_FADES_ANALYSIS_DOMAIN,),
2188 )
2189 if analysis is None or analysis.beats is None or len(analysis.beats) == 0:
2190 visualizer_role.clear_beat_schedule()
2191 # Analysis may still be running (offline NN takes ~5-10 s). Kick a
2192 # poller so beats land once available, without waiting for the
2193 # next player media update.
2194 # This could be solved more elegantly with an event instead of this
2195 # poller, but that means changes outside the sendspin provider, which
2196 # is riskier.
2197 self._schedule_beat_retry(queue_item.queue_item_id)
2198 return # don't poison the cache; retry asynchronously
2199 # Analysis is in â no more retries needed.
2200 self._cancel_beat_retry()
2201 downbeats = (
2202 {float(d) for d in analysis.downbeats} if analysis.downbeats is not None else set()
2203 )
2204 beats: list[BeatTiming] = []
2205 for b in analysis.beats:
2206 beat_us = int(anchor_us + float(b) * 1_000_000)
2207 if beat_us < now_us:
2208 continue
2209 beats.append(BeatTiming(timestamp_us=beat_us, is_downbeat=float(b) in downbeats))
2210 visualizer_role.clear_beat_schedule()
2211 if beats:
2212 visualizer_role.append_beat_schedule(beats)
2213 self._last_beat_queue_item_id = queue_item.queue_item_id
2214 self._last_beat_anchor_us = anchor_us
2215
2216 # Initial backoff for the beat-analysis poller. The neural beat tracker
2217 # in smart_fades takes ~5-10 s; retry every 3 s until it lands. Capped so
2218 # tracks that won't ever have analysis don't poll forever.
2219 _BEAT_RETRY_INTERVAL_S: ClassVar[float] = 3.0
2220 _BEAT_RETRY_MAX_ATTEMPTS: ClassVar[int] = 30 # ~90 s of wait
2221
2222 def _schedule_beat_retry(self, queue_item_id: str) -> None:
2223 """
2224 Start the background poller for late-arriving beat analysis.
2225
2226 If a poller is already running for this queue item, no-op.
2227 """
2228 if (
2229 self._beat_retry_task is not None
2230 and not self._beat_retry_task.done()
2231 and self._beat_retry_queue_item_id == queue_item_id
2232 ):
2233 return
2234 self._cancel_beat_retry()
2235 self._beat_retry_queue_item_id = queue_item_id
2236 self._beat_retry_task = asyncio.create_task(self._beat_retry_loop(queue_item_id))
2237
2238 def _cancel_beat_retry(self) -> None:
2239 """Cancel the beat-analysis poller if running."""
2240 if self._beat_retry_task is not None and not self._beat_retry_task.done():
2241 self._beat_retry_task.cancel()
2242 self._beat_retry_task = None
2243 self._beat_retry_queue_item_id = None
2244
2245 async def _beat_retry_loop(self, queue_item_id: str) -> None:
2246 """Retry beat-schedule hydration until beats land or the track changes."""
2247 try:
2248 for _ in range(self._BEAT_RETRY_MAX_ATTEMPTS):
2249 await asyncio.sleep(self._BEAT_RETRY_INTERVAL_S)
2250 if self._beat_retry_queue_item_id != queue_item_id:
2251 return # superseded by another poller
2252 current = self.current_media
2253 if current is None or current.queue_item_id != queue_item_id:
2254 return # track changed
2255 if self._last_beat_queue_item_id == queue_item_id:
2256 return # beats were pushed via some other path
2257 # Re-attempt beat hydration only; _send_beat_schedule will push
2258 # beats now or schedule another retry.
2259 try:
2260 await self._refresh_beat_schedule()
2261 except Exception:
2262 self.logger.exception("Beat-schedule retry failed for %s", queue_item_id)
2263 continue
2264 if self._last_beat_queue_item_id == queue_item_id:
2265 return
2266 # Cap exhausted without beats landing. Flip the visualizer to
2267 # UNAVAILABLE so clients (Hue, web) stop waiting and the peak
2268 # walker / static palette path takes over for this track.
2269 if (
2270 self._beat_retry_queue_item_id == queue_item_id
2271 and (current := self.current_media) is not None
2272 and current.queue_item_id == queue_item_id
2273 and (visualizer_role := self._visualizer_role) is not None
2274 ):
2275 visualizer_role.set_beat_availability(BeatAvailability.UNAVAILABLE)
2276 except asyncio.CancelledError:
2277 return
2278 finally:
2279 if self._beat_retry_queue_item_id == queue_item_id:
2280 self._beat_retry_queue_item_id = None
2281
2282
2283class SendspinVisualizerPlayer(SendspinBasePlayer):
2284 """A non-audio Sendspin player for visualizer/lighting devices."""
2285
2286 _attr_type = PlayerType.VISUALIZER
2287 _attr_hidden_by_default = True
2288 _attr_expose_to_ha_by_default = False
2289
2290 def __init__(
2291 self,
2292 provider: SendspinProvider,
2293 player_id: str,
2294 initial_hello: ClientHelloPayload | None = None,
2295 ) -> None:
2296 """
2297 Initialize the visualizer player.
2298
2299 :param provider: The Sendspin provider instance.
2300 :param player_id: The unique player identifier.
2301 :param initial_hello: Optional hello payload from the client.
2302 """
2303 super().__init__(provider, player_id, initial_hello)
2304 self._attr_can_group_with = {provider.instance_id}
2305 self._attr_supported_features = {PlayerFeature.SET_MEMBERS}
2306
2307 async def set_members(
2308 self,
2309 player_ids_to_add: list[str] | None = None,
2310 player_ids_to_remove: list[str] | None = None,
2311 ) -> None:
2312 """Handle SET_MEMBERS command for the visualizer player."""
2313 for player_id in player_ids_to_remove or []:
2314 member = self.mass.players.get_player(player_id, True)
2315 if isinstance(member, SendspinBasePlayer):
2316 await self.api.group.remove_client(member.api)
2317 for player_id in player_ids_to_add or []:
2318 member = self.mass.players.get_player(player_id, True)
2319 if isinstance(member, SendspinBasePlayer):
2320 await self.api.group.add_client(member.api)
2321
2322
2323class SendspinSourcePlayer(SendspinBasePlayer):
2324 """
2325 A capture-only Sendspin player for clients that just feed audio in.
2326
2327 Renders nothing and is never a playback or grouping target. It is listed as an
2328 audio input so the device stays discoverable and its pairing, enabling and
2329 line-in autostart settings are easy to reach. The sendspin_source plugin
2330 exposes the audio itself.
2331 """
2332
2333 _attr_type = PlayerType.SOURCE
2334 _attr_expose_to_ha_by_default = False
2335
2336 @property
2337 def _offers_unpaired_consent(self) -> bool:
2338 """A capture-only device gains nothing from unpaired access, so it pairs instead."""
2339 return False
2340