From 89f982c8caff05f7242ac96d430b69e53955bce5 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 26 Aug 2024 16:39:40 +0200 Subject: [PATCH] Some more tweaks/fixes to the Sonos S2 provider (#1613) --- .../server/providers/sonos/__init__.py | 149 ++++++++++++++++-- .../server/providers/sonos/manifest.json | 2 +- .../server/providers/sonos_s1/__init__.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 137 insertions(+), 18 deletions(-) diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index 720e7104..9c109a8a 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio import logging import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import shortuuid from aiohttp import web @@ -15,6 +15,7 @@ from aiosonos.api.models import PlayBackState as SonosPlayBackState 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 @@ -26,6 +27,8 @@ from music_assistant.common.models.config_entries import ( create_sample_rates_config_entry, ) from music_assistant.common.models.enums import ( + ConfigEntryType, + EventType, PlayerFeature, PlayerState, PlayerType, @@ -33,6 +36,7 @@ from music_assistant.common.models.enums import ( 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, @@ -71,6 +75,8 @@ SOURCE_SPOTIFY = "spotify" SOURCE_UNKNOWN = "unknown" SOURCE_RADIO = "radio" +CONF_AIRPLAY_MODE = "airplay_mode" + async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig @@ -130,6 +136,22 @@ class SonosPlayer: 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 @@ -175,6 +197,12 @@ class SonosPlayer: 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.""" @@ -217,13 +245,27 @@ class SonosPlayer: 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: @@ -231,13 +273,18 @@ class SonosPlayer: 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 @@ -289,6 +336,15 @@ class SonosPlayer: 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 ( @@ -299,13 +355,17 @@ class SonosPlayer: 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 ( @@ -315,18 +375,25 @@ class SonosPlayer: 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 @@ -377,6 +444,15 @@ class SonosPlayer: 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.""" @@ -386,7 +462,7 @@ class SonosPlayerProvider(PlayerProvider): @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.""" @@ -467,7 +543,7 @@ class SonosPlayerProvider(PlayerProvider): ) -> 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 ( @@ -475,6 +551,25 @@ class SonosPlayerProvider(PlayerProvider): 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: @@ -546,6 +641,25 @@ class SonosPlayerProvider(PlayerProvider): ) 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() @@ -566,11 +680,16 @@ class SonosPlayerProvider(PlayerProvider): 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 @@ -660,7 +779,7 @@ class SonosPlayerProvider(PlayerProvider): 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), ) diff --git a/music_assistant/server/providers/sonos/manifest.json b/music_assistant/server/providers/sonos/manifest.json index d952d53e..29ad3c53 100644 --- a/music_assistant/server/providers/sonos/manifest.json +++ b/music_assistant/server/providers/sonos/manifest.json @@ -4,7 +4,7 @@ "name": "SONOS", "description": "SONOS Player provider for Music Assistant.", "codeowners": ["@music-assistant"], - "requirements": ["aiosonos==0.1.3"], + "requirements": ["aiosonos==0.1.4"], "documentation": "https://music-assistant.io/player-support/sonos/", "multi_instance": false, "builtin": false, diff --git a/music_assistant/server/providers/sonos_s1/__init__.py b/music_assistant/server/providers/sonos_s1/__init__.py index 3f89ebd4..7b4b8e14 100644 --- a/music_assistant/server/providers/sonos_s1/__init__.py +++ b/music_assistant/server/providers/sonos_s1/__init__.py @@ -137,7 +137,7 @@ class SonosPlayerProvider(PlayerProvider): @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.""" diff --git a/requirements_all.txt b/requirements_all.txt index 190356eb..a6378ee3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ aiohttp==3.10.4 aiojellyfin==0.10.1 aiorun==2024.8.1 aioslimproto==3.0.1 -aiosonos==0.1.3 +aiosonos==0.1.4 aiosqlite==0.20.0 async-upnp-client==0.40.0 bidict==0.23.1 -- 2.34.1