music-assistant-server

27.5 KBPY
player.py
27.5 KB644 lines • python
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