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