/
/
/
1"""Sendspin Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from collections.abc import Callable
8from io import BytesIO
9from typing import TYPE_CHECKING, cast
10
11from aiosendspin.models import AudioCodec, MediaCommand
12from aiosendspin.models.types import PlaybackStateType
13from aiosendspin.models.types import RepeatMode as SendspinRepeatMode
14from aiosendspin.server import ClientEvent, GroupEvent, SendspinGroup, VolumeChangedEvent
15from aiosendspin.server.audio import AudioFormat as SendspinAudioFormat
16from aiosendspin.server.client import DisconnectBehaviour
17from aiosendspin.server.events import (
18 ClientGroupChangedEvent,
19 GroupDeletedEvent,
20 GroupMemberAddedEvent,
21 GroupMemberRemovedEvent,
22 GroupStateChangedEvent,
23)
24from aiosendspin.server.roles import (
25 ArtworkGroupRole,
26 ControllerEvent,
27 ControllerGroupRole,
28 ControllerNextEvent,
29 ControllerPauseEvent,
30 ControllerPlayEvent,
31 ControllerPreviousEvent,
32 ControllerRepeatEvent,
33 ControllerShuffleEvent,
34 ControllerStopEvent,
35 MetadataGroupRole,
36)
37from aiosendspin.server.roles.metadata.state import Metadata
38from aiosendspin.server.roles.player.types import PlayerRoleProtocol
39from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
40from music_assistant_models.constants import PLAYER_CONTROL_NONE
41from music_assistant_models.enums import (
42 ConfigEntryType,
43 ImageType,
44 PlaybackState,
45 PlayerFeature,
46 PlayerType,
47 RepeatMode,
48)
49from music_assistant_models.player import DeviceInfo
50from PIL import Image
51
52from music_assistant.constants import (
53 CONF_ENTRY_HTTP_PROFILE_HIDDEN,
54 CONF_ENTRY_OUTPUT_CODEC_HIDDEN,
55 CONF_ENTRY_SAMPLE_RATES,
56)
57from music_assistant.models.player import Player, PlayerMedia
58from music_assistant.providers.sendspin.playback import SendspinPlaybackSession
59
60# Supported group commands for Sendspin players
61SUPPORTED_GROUP_COMMANDS = [
62 MediaCommand.PLAY,
63 MediaCommand.PAUSE,
64 MediaCommand.STOP,
65 MediaCommand.NEXT,
66 MediaCommand.PREVIOUS,
67 MediaCommand.REPEAT_OFF,
68 MediaCommand.REPEAT_ONE,
69 MediaCommand.REPEAT_ALL,
70 MediaCommand.SHUFFLE,
71 MediaCommand.UNSHUFFLE,
72]
73
74# Config constants for Sendspin audio format
75CONF_PREFERRED_SENDSPIN_FORMAT = "preferred_sendspin_format"
76SENDSPIN_FORMAT_AUTOMATIC = "automatic"
77
78
79def format_to_option_value(fmt: SupportedAudioFormat) -> str:
80 """Convert SupportedAudioFormat to "codec:sample_rate:bit_depth:channels"."""
81 return f"{fmt.codec.value}:{fmt.sample_rate}:{fmt.bit_depth}:{fmt.channels}"
82
83
84def option_value_to_format(value: str) -> tuple[AudioCodec, SendspinAudioFormat] | None:
85 """Parse option value back to (AudioCodec, SendspinAudioFormat).
86
87 :param value: Option value in format "codec:sample_rate:bit_depth:channels".
88 :return: Tuple of (AudioCodec, SendspinAudioFormat) or None if parsing fails.
89 """
90 try:
91 codec_str, sample_rate_str, bit_depth_str, channels_str = value.split(":")
92 codec = AudioCodec(codec_str)
93 audio_format = SendspinAudioFormat(
94 sample_rate=int(sample_rate_str),
95 bit_depth=int(bit_depth_str),
96 channels=int(channels_str),
97 )
98 return (codec, audio_format)
99 except (ValueError, KeyError):
100 return None
101
102
103def format_to_display_string(fmt: SupportedAudioFormat) -> str:
104 """Convert to display string like "FLAC 48kHz/24bit stereo"."""
105 codec_name = fmt.codec.name
106 sample_rate_khz = fmt.sample_rate / 1000
107 # Format sample rate: show as integer if whole number, otherwise one decimal
108 if sample_rate_khz == int(sample_rate_khz):
109 sample_rate_str = f"{int(sample_rate_khz)}kHz"
110 else:
111 sample_rate_str = f"{sample_rate_khz:.1f}kHz"
112 if fmt.channels == 2:
113 channels_str = "stereo"
114 elif fmt.channels == 1:
115 channels_str = "mono"
116 else:
117 channels_str = f"{fmt.channels}ch"
118 return f"{codec_name} {sample_rate_str}/{fmt.bit_depth}bit {channels_str}"
119
120
121if TYPE_CHECKING:
122 from aiosendspin.models.player import SupportedAudioFormat
123 from aiosendspin.server.client import SendspinClient
124 from music_assistant_models.config_entries import ConfigValueType
125 from music_assistant_models.player_queue import PlayerQueue
126 from music_assistant_models.queue_item import QueueItem
127
128 from .provider import SendspinProvider
129
130
131class SendspinPlayer(Player):
132 """A sendspin audio player in Music Assistant."""
133
134 _attr_type = PlayerType.PROTOCOL
135
136 api: SendspinClient
137 unsub_event_cb: Callable[[], None]
138 unsub_group_event_cb: Callable[[], None]
139 last_sent_artwork_url: str | None = None
140 last_sent_artist_artwork_url: str | None = None
141 playback_session: SendspinPlaybackSession
142 is_web_player: bool = False
143
144 @property
145 def requires_flow_mode(self) -> bool:
146 """Return if the player requires flow mode."""
147 return True
148
149 def __init__(self, provider: SendspinProvider, player_id: str) -> None:
150 """Initialize the Player."""
151 super().__init__(provider, player_id)
152 sendspin_client = provider.server_api.get_client(player_id)
153 assert sendspin_client is not None
154 self.api = sendspin_client
155 self.api.disconnect_behaviour = DisconnectBehaviour.STOP
156 self.unsub_event_cb = sendspin_client.add_event_listener(self.event_cb)
157 self.unsub_group_event_cb = sendspin_client.group.add_event_listener(self.group_event_cb)
158 if controller_role := self._controller_role:
159 controller_role.set_supported_commands(SUPPORTED_GROUP_COMMANDS)
160
161 self.playback_session = SendspinPlaybackSession(self)
162
163 self.logger = self.provider.logger.getChild(player_id)
164 # init some static variables
165 self._attr_name = sendspin_client.name
166 self._attr_supported_features = {
167 PlayerFeature.PLAY_MEDIA,
168 PlayerFeature.SET_MEMBERS,
169 PlayerFeature.VOLUME_SET,
170 PlayerFeature.VOLUME_MUTE,
171 PlayerFeature.MULTI_DEVICE_DSP,
172 }
173 self._attr_can_group_with = {provider.instance_id}
174 self._attr_power_control = PLAYER_CONTROL_NONE
175 if device_info := sendspin_client.info.device_info:
176 self._attr_device_info = DeviceInfo(
177 model=device_info.product_name or "Unknown model",
178 manufacturer=device_info.manufacturer or "Unknown Manufacturer",
179 software_version=device_info.software_version,
180 )
181 else:
182 self._attr_device_info = DeviceInfo()
183 if sendspin_client.info.player_support:
184 for role in sendspin_client.roles_by_family("player"):
185 volume = role.get_player_volume()
186 muted = role.get_player_muted()
187 if volume is not None:
188 self._attr_volume_level = volume
189 if muted is not None:
190 self._attr_volume_muted = muted
191 if volume is not None or muted is not None:
192 break
193 self._attr_available = True
194 self.is_web_player = sendspin_client.name.startswith(
195 "Web (" # The regular Web Interface
196 ) or sendspin_client.name.startswith(
197 "PWA (" # The PWA App
198 )
199 self._attr_expose_to_ha_by_default = not self.is_web_player
200 self._attr_hidden_by_default = self.is_web_player
201 # register web/app player as native player type because it doesn't need to be linked
202 # every web/app player is just a standalone player.
203 self._attr_type = PlayerType.PLAYER if self.is_web_player else PlayerType.PROTOCOL
204
205 @property
206 def _artwork_role(self) -> ArtworkGroupRole | None:
207 """Get the ArtworkGroupRole for this player's group."""
208 role = self.api.group.group_role("artwork")
209 if isinstance(role, ArtworkGroupRole):
210 return role
211 return None
212
213 @property
214 def _metadata_role(self) -> MetadataGroupRole | None:
215 """Get the MetadataGroupRole for this player's group."""
216 role = self.api.group.group_role("metadata")
217 if isinstance(role, MetadataGroupRole):
218 return role
219 return None
220
221 @property
222 def _controller_role(self) -> ControllerGroupRole | None:
223 """Get the ControllerGroupRole for this player's group."""
224 role = self.api.group.group_role("controller")
225 if isinstance(role, ControllerGroupRole):
226 return role
227 return None
228
229 @property
230 def _player_role(self) -> PlayerRoleProtocol | None:
231 """Get the player role for this client (not group role)."""
232 for role in self.api.roles_by_family("player"):
233 if isinstance(role, PlayerRoleProtocol):
234 return role
235 return None
236
237 async def _handle_controller_event(self, event: ControllerEvent) -> None:
238 """Handle a controller event from the ControllerGroupRole."""
239 queue = self.mass.player_queues.get_active_queue(self.player_id)
240 match event:
241 case ControllerPlayEvent():
242 await self.mass.players.cmd_play(self.player_id)
243 case ControllerPauseEvent():
244 await self.mass.players.cmd_pause(self.player_id)
245 case ControllerStopEvent():
246 await self.mass.players.cmd_stop(self.player_id)
247 case ControllerNextEvent():
248 await self.mass.players.cmd_next_track(self.player_id)
249 case ControllerPreviousEvent():
250 await self.mass.players.cmd_previous_track(self.player_id)
251 case ControllerRepeatEvent(mode=mode) if queue:
252 match mode:
253 case SendspinRepeatMode.OFF:
254 self.mass.player_queues.set_repeat(queue.queue_id, RepeatMode.OFF)
255 case SendspinRepeatMode.ONE:
256 self.mass.player_queues.set_repeat(queue.queue_id, RepeatMode.ONE)
257 case SendspinRepeatMode.ALL:
258 self.mass.player_queues.set_repeat(queue.queue_id, RepeatMode.ALL)
259 case ControllerShuffleEvent(shuffle=shuffle) if queue:
260 await self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=shuffle)
261
262 async def _sync_membership_from_group(self, group: SendspinGroup) -> None:
263 """Sync MA/player + playback session membership from authoritative group state."""
264 # Ignore stale events from a group we no longer belong to.
265 if group is not self.api.group:
266 return
267 group_client_ids = [client.client_id for client in group.clients]
268 is_leader = bool(group_client_ids) and group_client_ids[0] == self.player_id
269 desired_group_members = group_client_ids if is_leader else []
270 desired_session_members = group_client_ids[1:] if is_leader else []
271 if self._attr_group_members != desired_group_members:
272 self._attr_group_members = desired_group_members
273 self.update_state()
274 # Only use STOP when we actually lead other members.
275 self.api.disconnect_behaviour = (
276 DisconnectBehaviour.STOP
277 if is_leader and len(desired_session_members) > 0
278 else DisconnectBehaviour.UNGROUP
279 )
280 await self.playback_session.sync_members(set(desired_session_members))
281
282 def event_cb(self, client: SendspinClient, event: ClientEvent) -> None:
283 """Event callback registered to the sendspin client."""
284 match event:
285 case VolumeChangedEvent(volume=volume, muted=muted):
286 self._attr_volume_level = volume
287 self._attr_volume_muted = muted
288 self.update_state()
289 case ClientGroupChangedEvent(new_group=new_group):
290 self.unsub_group_event_cb()
291 self.unsub_group_event_cb = new_group.add_event_listener(self.group_event_cb)
292 if controller_role := self._controller_role:
293 controller_role.set_supported_commands(SUPPORTED_GROUP_COMMANDS)
294 # Cancel active playback - push stream belongs to the old group
295 self.mass.create_task(self.playback_session.cancel("group changed"))
296 # Sync playback state from the new group
297 match new_group.state:
298 case PlaybackStateType.PLAYING:
299 self._attr_playback_state = PlaybackState.PLAYING
300 case PlaybackStateType.PAUSED:
301 self._attr_playback_state = PlaybackState.PAUSED
302 case PlaybackStateType.STOPPED:
303 self._attr_playback_state = PlaybackState.IDLE
304 self._attr_elapsed_time = 0
305 self._attr_elapsed_time_last_updated = time.time()
306 # Update in case this is a newly created group
307 # GroupMemberAddedEvent or GroupMemberRemovedEvent will be fired before this
308 # so group members are already up to date at this point
309 self.mass.create_task(self._sync_membership_from_group(new_group))
310 self.update_state()
311
312 def group_event_cb(self, group: SendspinGroup, event: GroupEvent) -> None:
313 """Event callback registered to the sendspin group this player belongs to."""
314 if self.synced_to is not None:
315 # Only handle group events as the leader, except for:
316 # - GroupMemberRemovedEvent: to handle being removed from a group
317 # - GroupStateChangedEvent: to update playback state when leader stops/disconnects
318 if not isinstance(event, (GroupMemberRemovedEvent, GroupStateChangedEvent)):
319 return
320 match event:
321 case GroupStateChangedEvent(state=state):
322 match state:
323 case PlaybackStateType.PLAYING:
324 self._attr_playback_state = PlaybackState.PLAYING
325 case PlaybackStateType.PAUSED:
326 self._attr_playback_state = PlaybackState.PAUSED
327 case PlaybackStateType.STOPPED:
328 self._attr_playback_state = PlaybackState.IDLE
329 self._attr_elapsed_time = 0
330 self._attr_elapsed_time_last_updated = time.time()
331 if self.synced_to is None:
332 self.mass.create_task(self.playback_session.cancel("group stopped"))
333 self.update_state()
334 case GroupMemberAddedEvent(client_id=client_id):
335 is_group_leader = (
336 bool(group.clients) and group.clients[0].client_id == self.player_id
337 )
338 if is_group_leader and (
339 not self._attr_group_members or self._attr_group_members[0] != self.player_id
340 ):
341 self._attr_group_members = [self.player_id, *self._attr_group_members]
342 if client_id not in self._attr_group_members:
343 self._attr_group_members.append(client_id)
344 self.update_state()
345 self.mass.create_task(self.playback_session.add_member(client_id))
346 self.mass.create_task(self._sync_membership_from_group(group))
347 case GroupMemberRemovedEvent(client_id=client_id):
348 self.mass.create_task(self.playback_session.remove_member(client_id))
349 self.mass.create_task(self._handle_group_member_removed(group, client_id))
350 self.mass.create_task(self._sync_membership_from_group(group))
351 case GroupDeletedEvent():
352 pass
353 case ControllerEvent() as controller_event:
354 if self.synced_to is None:
355 self.mass.create_task(self._handle_controller_event(controller_event))
356
357 async def _handle_group_member_removed(self, group: SendspinGroup, client_id: str) -> None:
358 """Handle a group member being removed asynchronously."""
359 if client_id == self.player_id:
360 if len(group.clients) > 0:
361 # We were just removed as a leader:
362 # 1. stop playback on the old group
363 await group.stop()
364 # 2. clear our members (since we are now alone in a new group)
365 self._attr_group_members = []
366 self.update_state()
367 elif client_id in self._attr_group_members:
368 # Someone else left our group
369 self._attr_group_members.remove(client_id)
370 self.update_state()
371
372 async def volume_set(self, volume_level: int) -> None:
373 """Handle VOLUME_SET command on the player."""
374 roles = self.api.roles_by_family("player")
375 for role in roles:
376 role.set_player_volume(volume_level)
377
378 async def volume_mute(self, muted: bool) -> None:
379 """Handle VOLUME MUTE command on the player."""
380 roles = self.api.roles_by_family("player")
381 for role in roles:
382 role.set_player_mute(muted)
383
384 async def stop(self) -> None:
385 """Stop command."""
386 self.logger.debug("Received STOP command on player %s", self.display_name)
387 self.mark_stop_called()
388 self._attr_current_media = None
389 self._attr_playback_state = PlaybackState.IDLE
390 self._attr_elapsed_time = 0
391 self._attr_elapsed_time_last_updated = time.time()
392 self.update_state()
393 await self.playback_session.cancel("stop command")
394 await self.api.group.stop()
395
396 async def play_media(self, media: PlayerMedia) -> None:
397 """Play media command."""
398 self.logger.debug(
399 "Received PLAY_MEDIA command on player %s with uri %s", self.display_name, media.uri
400 )
401
402 # Update player state optimistically
403 self._attr_current_media = media
404 self._attr_elapsed_time = 0
405 self._attr_elapsed_time_last_updated = time.time()
406 # playback_state will be set by the group state change event
407
408 # Stop previous stream in case we were already playing something
409 await self.playback_session.cancel("new media requested")
410 await self.api.group.stop()
411 await self.playback_session.start(media)
412 self.update_state()
413
414 async def on_config_updated(self) -> None:
415 """Handle logic when the PlayerConfig is first loaded or updated."""
416 await self._apply_preferred_format()
417
418 async def _apply_preferred_format(self) -> None:
419 """Read config and call set_preferred_format() if not automatic."""
420 player_role = self._player_role
421 if player_role is None:
422 return
423
424 config_value = cast(
425 "str",
426 self.config.get_value(CONF_PREFERRED_SENDSPIN_FORMAT, SENDSPIN_FORMAT_AUTOMATIC),
427 )
428 if config_value == SENDSPIN_FORMAT_AUTOMATIC:
429 # Automatic mode: don't set a preferred format, let client decide.
430 return
431
432 parsed = option_value_to_format(config_value)
433 if parsed is None:
434 self.logger.warning(
435 "Invalid audio format config value '%s' for player %s",
436 config_value,
437 self.display_name,
438 )
439 return
440
441 codec, audio_format = parsed
442 if not player_role.set_preferred_format(audio_format, codec):
443 self.logger.warning(
444 "Failed to set preferred audio format %s %s for player %s",
445 codec.name,
446 audio_format,
447 self.display_name,
448 )
449
450 async def set_members(
451 self,
452 player_ids_to_add: list[str] | None = None,
453 player_ids_to_remove: list[str] | None = None,
454 ) -> None:
455 """Handle SET_MEMBERS command on the player."""
456 for player_id in player_ids_to_remove or []:
457 player = self.mass.players.get_player(player_id, True)
458 player = cast("SendspinPlayer", player) # For type checking
459 await self.api.group.remove_client(player.api)
460 for player_id in player_ids_to_add or []:
461 player = self.mass.players.get_player(player_id, True)
462 player = cast("SendspinPlayer", player) # For type checking
463 await self.api.group.add_client(player.api)
464 # self.group_members will be updated by the group event callback
465
466 async def _send_album_artwork(self, current_item: QueueItem) -> str | None:
467 """
468 Send album artwork to the sendspin group.
469
470 Args:
471 current_item: The current queue item.
472 """
473 artwork_url = None
474 if current_item.image is not None:
475 artwork_url = self.mass.metadata.get_image_url(current_item.image)
476
477 if artwork_url != self.last_sent_artwork_url:
478 # Image changed, resend the artwork
479 self.last_sent_artwork_url = artwork_url
480 if artwork_url is not None and current_item.media_item is not None:
481 image_data = await self.mass.metadata.get_image_data_for_item(
482 current_item.media_item
483 )
484 if image_data is not None:
485 image = await asyncio.to_thread(Image.open, BytesIO(image_data))
486 if (artwork_role := self._artwork_role) is not None:
487 await artwork_role.set_album_artwork(image)
488 # Clear artwork if none available
489 elif (artwork_role := self._artwork_role) is not None:
490 await artwork_role.set_album_artwork(None)
491
492 return artwork_url
493
494 async def _send_artist_artwork(self, current_item: QueueItem) -> None:
495 """
496 Send artist artwork to the sendspin group.
497
498 Args:
499 current_item: The current queue item.
500 """
501 # Extract primary artist if available
502 artist_artwork_url = None
503 if current_item.media_item is not None and hasattr(current_item.media_item, "artists"):
504 artists = getattr(current_item.media_item, "artists", None)
505 if artists and len(artists) > 0:
506 primary_artist = artists[0]
507 if hasattr(primary_artist, "image"):
508 artist_image = getattr(primary_artist, "image", None)
509 if artist_image is not None:
510 artist_artwork_url = self.mass.metadata.get_image_url(artist_image)
511
512 if artist_artwork_url != self.last_sent_artist_artwork_url:
513 # Artist image changed, resend the artwork
514 self.last_sent_artist_artwork_url = artist_artwork_url
515 if artist_artwork_url is not None:
516 artist_image_data = await self.mass.metadata.get_image_data_for_item(
517 primary_artist, img_type=ImageType.THUMB
518 )
519 if artist_image_data is not None:
520 artist_image = await asyncio.to_thread(Image.open, BytesIO(artist_image_data))
521 if (artwork_role := self._artwork_role) is not None:
522 await artwork_role.set_artist_artwork(artist_image)
523 # Clear artist artwork if none available
524 elif (artwork_role := self._artwork_role) is not None:
525 await artwork_role.set_artist_artwork(None)
526
527 def _on_player_media_updated(self) -> None:
528 """Handle callback when the current media of the player is updated."""
529 if self.synced_to is not None:
530 # Only leader sends metadata
531 return
532
533 if self.state.current_media is None:
534 # Clear metadata when no media loaded
535 if (metadata_role := self._metadata_role) is not None:
536 metadata_role.set_metadata(Metadata())
537 return
538 self.mass.create_task(self.send_current_media_metadata())
539
540 async def send_current_media_metadata(self) -> None:
541 """Send the current media metadata to the sendspin group."""
542 if not self.available:
543 return
544 current_media = self.state.current_media
545 if current_media is None:
546 return
547 # check if we are playing a MA queue item
548 queue_item: QueueItem | None = None
549 queue: PlayerQueue | None = None
550 if current_media.source_id and current_media.queue_item_id:
551 queue = self.mass.player_queues.get(current_media.source_id)
552 queue_item = self.mass.player_queues.get_item(
553 current_media.source_id, current_media.queue_item_id
554 )
555
556 # Send album and artist artwork
557 if queue_item:
558 await self._send_album_artwork(queue_item)
559 await self._send_artist_artwork(queue_item)
560
561 track_duration = current_media.duration or 0
562 repeat = SendspinRepeatMode.OFF
563 if queue and queue.repeat_mode == RepeatMode.ALL:
564 repeat = SendspinRepeatMode.ALL
565 elif queue and queue.repeat_mode == RepeatMode.ONE:
566 repeat = SendspinRepeatMode.ONE
567
568 shuffle = queue.shuffle_enabled if queue else False
569
570 metadata = Metadata(
571 title=current_media.title,
572 artist=current_media.artist,
573 album_artist=None,
574 album=current_media.album,
575 artwork_url=current_media.image_url,
576 year=None,
577 track=None,
578 track_duration=track_duration * 1000 if track_duration is not None else None,
579 track_progress=int(current_media.corrected_elapsed_time * 1000)
580 if current_media.corrected_elapsed_time
581 else 0,
582 playback_speed=1000,
583 repeat=repeat,
584 shuffle=shuffle,
585 )
586
587 # Send metadata to the group
588 if (metadata_role := self._metadata_role) is not None:
589 metadata_role.set_metadata(metadata)
590
591 async def get_config_entries(
592 self,
593 action: str | None = None,
594 values: dict[str, ConfigValueType] | None = None,
595 ) -> list[ConfigEntry]:
596 """Return all (provider/player specific) Config Entries for the player."""
597 default_entries = await super().get_config_entries(action=action, values=values)
598 entries = [
599 *default_entries,
600 CONF_ENTRY_OUTPUT_CODEC_HIDDEN,
601 CONF_ENTRY_HTTP_PROFILE_HIDDEN,
602 ConfigEntry.from_dict({**CONF_ENTRY_SAMPLE_RATES.to_dict(), "hidden": True}),
603 ]
604
605 # Build dynamic format options from player's supported formats
606 player_role = self._player_role
607 if player_role is not None:
608 supported_formats = player_role.get_supported_formats()
609 if supported_formats:
610 format_options = [
611 ConfigValueOption(
612 title="Automatic (let client decide)",
613 value=SENDSPIN_FORMAT_AUTOMATIC,
614 ),
615 ]
616 for fmt in supported_formats:
617 format_options.append(
618 ConfigValueOption(
619 title=format_to_display_string(fmt),
620 value=format_to_option_value(fmt),
621 )
622 )
623 entries.append(
624 ConfigEntry(
625 key=CONF_PREFERRED_SENDSPIN_FORMAT,
626 type=ConfigEntryType.STRING,
627 label="Preferred audio format",
628 description="Select the audio format to use for playback on this player.",
629 category="protocol_generic",
630 default_value=SENDSPIN_FORMAT_AUTOMATIC,
631 options=format_options,
632 advanced=True,
633 )
634 )
635
636 return entries
637
638 async def on_unload(self) -> None:
639 """Handle logic when the player is unloaded from the Player controller."""
640 await self.playback_session.close()
641 await super().on_unload()
642 self.unsub_event_cb()
643 self.unsub_group_event_cb()
644