import asyncio
import logging
import time
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, cast
import shortuuid
from aiohttp import web
from aiosonos.client import SonosLocalApiClient
from aiosonos.const import EventType as SonosEventType
from aiosonos.const import SonosEvent
+from aiosonos.exceptions import FailedCommand
from aiosonos.utils import get_discovery_info
from zeroconf import IPVersion, ServiceStateChange
create_sample_rates_config_entry,
)
from music_assistant.common.models.enums import (
+ ConfigEntryType,
+ EventType,
PlayerFeature,
PlayerState,
PlayerType,
RepeatMode,
)
from music_assistant.common.models.errors import PlayerCommandFailed
+from music_assistant.common.models.event import MassEvent
from music_assistant.common.models.player import DeviceInfo, Player, PlayerMedia
from music_assistant.constants import (
CONF_CROSSFADE,
SOURCE_UNKNOWN = "unknown"
SOURCE_RADIO = "radio"
+CONF_AIRPLAY_MODE = "airplay_mode"
+
async def setup(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
self.airplay_player_id = f"ap{self.player_id[7:-5].lower()}"
self.queue_version: str = shortuuid.random(8)
+ @property
+ def airplay_mode(self) -> bool:
+ """Return if the player is in airplay mode."""
+ return cast(
+ bool,
+ self.mass.config.get_raw_player_config_value(self.player_id, CONF_AIRPLAY_MODE, False),
+ )
+
+ def get_linked_airplay_player(self, active_only: bool = True) -> Player | None:
+ """Return the linked airplay player if available/enabled."""
+ if active_only and not self.airplay_mode:
+ return None
+ if airplay_player := self.mass.players.get(self.airplay_player_id):
+ return airplay_player
+ return None
+
async def setup(self) -> None:
"""Handle setup of the player."""
# connect the player first so we can fail early
SonosEventType.PLAYER_UPDATED,
),
)
+ # register callback for airplay player state changes
+ self.mass.subscribe(
+ self._on_airplay_player_event,
+ (EventType.PLAYER_UPDATED, EventType.PLAYER_ADDED),
+ self.airplay_player_id,
+ )
async def connect(self) -> None:
"""Connect to the Sonos player."""
if self.client.player.is_passive:
self.logger.debug("Ignore STOP command: Player is synced to another player.")
return
- await self.client.player.group.stop()
+ if airplay := self.get_linked_airplay_player(True):
+ # linked airplay player is active, redirect the command
+ self.logger.debug("Redirecting STOP command to linked airplay player.")
+ await self.mass.players.cmd_stop(airplay.player_id)
+ return
+ try:
+ await self.client.player.group.stop()
+ except FailedCommand as err:
+ if "ERROR_PLAYBACK_NO_CONTENT" not in str(err):
+ raise
async def cmd_play(self) -> None:
"""Send PLAY command to given player."""
if self.client.player.is_passive:
self.logger.debug("Ignore STOP command: Player is synced to another player.")
return
+ if airplay := self.get_linked_airplay_player(True):
+ # linked airplay player is active, redirect the command
+ self.logger.debug("Redirecting PLAY command to linked airplay player.")
+ await self.mass.players.cmd_play(airplay.player_id)
+ return
await self.client.player.group.play()
async def cmd_pause(self) -> None:
if self.client.player.is_passive:
self.logger.debug("Ignore STOP command: Player is synced to another player.")
return
+ if airplay := self.get_linked_airplay_player(True):
+ # linked airplay player is active, redirect the command
+ self.logger.debug("Redirecting PAUSE command to linked airplay player.")
+ await self.mass.players.cmd_pause(airplay.player_id)
+ return
await self.client.player.group.pause()
async def cmd_volume_set(self, volume_level: int) -> None:
"""Send VOLUME_SET command to given player."""
await self.client.player.set_volume(volume_level)
- # sync volume level with airplay player if linked
- if airplay := self.mass.players.get(self.airplay_player_id):
+ # sync volume level with airplay player
+ if airplay := self.get_linked_airplay_player(False):
if airplay.state not in (PlayerState.PLAYING, PlayerState.PAUSED):
airplay.volume_level = volume_level
self.mass_player.active_source = active_group.coordinator_id
self.mass_player.can_sync_with = ()
+ if (airplay := self.get_linked_airplay_player(True)) and airplay.powered:
+ # linked airplay player is active, update media from there
+ self.mass_player.state = airplay.state
+ self.mass_player.powered = airplay.powered
+ self.mass_player.active_source = airplay.active_source
+ self.mass_player.elapsed_time = airplay.elapsed_time
+ self.mass_player.elapsed_time_last_updated = airplay.elapsed_time_last_updated
+ return
+
# map playback state
self.mass_player.state = PLAYBACK_STATE_MAP[active_group.playback_state]
if (
self.mass_player.elapsed_time = active_group.position
+ is_playing = active_group.playback_state in (
+ SonosPlayBackState.PLAYBACK_STATE_PLAYING,
+ SonosPlayBackState.PLAYBACK_STATE_BUFFERING,
+ )
# figure out the active source based on the container
container_type = active_group.container_type
active_service = active_group.active_service
container = active_group.playback_metadata.get("container")
- if container_type == ContainerType.LINEIN:
+ if is_playing and container_type == ContainerType.LINEIN:
self.mass_player.active_source = SOURCE_LINE_IN
- elif container_type == ContainerType.AIRPLAY:
+ elif is_playing and container_type == ContainerType.AIRPLAY:
# check if the MA airplay player is active
airplay_player = self.mass.players.get(self.airplay_player_id)
if airplay_player and airplay_player.state in (
self.mass_player.active_source = airplay_player.active_source
else:
self.mass_player.active_source = SOURCE_AIRPLAY
- elif container_type == ContainerType.STATION:
+ elif is_playing and container_type == ContainerType.STATION:
self.mass_player.active_source = SOURCE_RADIO
- elif active_service == MusicService.SPOTIFY:
+ elif is_playing and active_service == MusicService.SPOTIFY:
self.mass_player.active_source = SOURCE_SPOTIFY
elif active_service == MusicService.MUSIC_ASSISTANT:
- if container and (object_id := container.get("id", {}).get("objectId")):
+ if (
+ active_group.active_session_id
+ and container
+ and (object_id := container.get("id", {}).get("objectId"))
+ ):
self.mass_player.active_source = object_id.split(":")[-1]
else:
- self.mass_player.active_source = self.player_id
+ self.mass_player.active_source = None
+ elif is_playing:
+ # its playing some service we did not yet map
+ self.mass_player.active_source = active_service
else:
# all our (known) options exhausted, fallback to unknown
- self.mass_player.active_source = active_service
+ self.mass_player.active_source = None
if self.mass_player.active_source == self.player_id and active_group.active_session_id:
# active source is the mass queue
self.update_attributes()
self.mass.players.update(self.player_id)
+ def _on_airplay_player_event(self, event: MassEvent) -> None:
+ """Handle incoming event from linked airplay player."""
+ if not self.airplay_mode:
+ return
+ if event.object_id != self.airplay_player_id:
+ return
+ self.update_attributes()
+ self.mass.players.update(self.player_id)
+
class SonosPlayerProvider(PlayerProvider):
"""Sonos Player provider."""
@property
def supported_features(self) -> tuple[ProviderFeature, ...]:
"""Return the features supported by this Provider."""
- return (ProviderFeature.SYNC_PLAYERS, ProviderFeature.PLAYER_GROUP_CREATE)
+ return (ProviderFeature.SYNC_PLAYERS,)
async def handle_async_init(self) -> None:
"""Handle async initialization of the provider."""
) -> tuple[ConfigEntry, ...]:
"""Return Config Entries for the given player."""
base_entries = await super().get_player_config_entries(player_id)
- if not (self.sonos_players.get(player_id)):
+ if not (sonos_player := self.sonos_players.get(player_id)):
# most probably a syncgroup or the player is not yet discovered
return (*base_entries, CONF_ENTRY_CROSSFADE, CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED)
return (
CONF_ENTRY_CROSSFADE,
CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
create_sample_rates_config_entry(48000, 24, 48000, 24, True),
+ ConfigEntry(
+ key=CONF_AIRPLAY_MODE,
+ type=ConfigEntryType.BOOLEAN,
+ label="Enable Airplay mode (experimental)",
+ description="Almost all newer Sonos speakers have Airplay support. "
+ "If you have the Airplay provider enabled in Music Assistant, "
+ "your Sonos speakers will also be detected as Airplay speakers, meaning "
+ "you can group them with other Airplay speakers.\n\n"
+ "By default, Music Assistant uses the Sonos protocol for playback but with this "
+ "feature enabled, it will use the Airplay protocol instead by redirecting "
+ "the playback related commands to the linked Airplay player in Music Assistant, "
+ "allowing you to mix and match Sonos speakers with Airplay speakers. \n\n"
+ "TIP: When this feature is enabled, it make sense to set the underlying airplay "
+ "players to hide in the UI in the player settings to prevent duplicate players.",
+ required=False,
+ default_value=False,
+ hidden=SonosCapability.AIRPLAY
+ not in sonos_player.discovery_info["device"]["capabilities"],
+ ),
)
async def cmd_stop(self, player_id: str) -> None:
)
raise PlayerCommandFailed(msg)
+ if airplay := sonos_player.get_linked_airplay_player(True):
+ # linked airplay player is active, redirect the command
+ self.logger.debug("Redirecting PLAY_MEDIA command to linked airplay player.")
+ mass_player.active_source = airplay.active_source
+ # Sonos has an annoying bug (for years already, and they dont seem to care),
+ # where it looses its sync childs when airplay playback is (re)started.
+ # Try to handle it here with this workaround.
+ group_childs = (
+ sonos_player.client.player.group_members
+ if len(sonos_player.client.player.group_members) > 1
+ else []
+ )
+ if group_childs:
+ await self.cmd_unsync_many(group_childs)
+ await self.mass.players.play_media(airplay.player_id, media)
+ if group_childs:
+ self.mass.call_later(5, self.cmd_sync_many(player_id, group_childs))
+ return
+
if media.queue_id:
# create a sonos cloud queue and load it
await sonos_player.client.player.group.create_playback_session()
async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
"""Handle enqueuing of the next queue item on the player."""
sonos_player = self.sonos_players[player_id]
+ if sonos_player.get_linked_airplay_player(True):
+ # linked airplay player is active, ignore this command
+ return
if session_id := sonos_player.client.player.group.active_session_id:
await sonos_player.client.api.playback_session.refresh_cloud_queue(session_id)
# sync play modes from player queue --> sonos
mass_queue = self.mass.player_queues.get(media.queue_id)
- crossfade = await self.mass.config.get_player_config_value(player_id, CONF_CROSSFADE)
+ crossfade = await self.mass.config.get_player_config_value(
+ mass_queue.queue_id, CONF_CROSSFADE
+ )
repeat_single_enabled = mass_queue.repeat_mode == RepeatMode.ONE
repeat_all_enabled = mass_queue.repeat_mode == RepeatMode.ALL
play_modes = sonos_player.client.player.group.play_modes
return web.Response(status=501)
offset = max(queue_index - previous_window_size, 0)
queue_items = self.mass.player_queues.items(
- sonos_player_id,
+ mass_queue.queue_id,
limit=upcoming_window_size + previous_window_size,
offset=max(queue_index - previous_window_size, 0),
)