Some more tweaks/fixes to the Sonos S2 provider (#1613)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 26 Aug 2024 14:39:40 +0000 (16:39 +0200)
committerGitHub <noreply@github.com>
Mon, 26 Aug 2024 14:39:40 +0000 (16:39 +0200)
music_assistant/server/providers/sonos/__init__.py
music_assistant/server/providers/sonos/manifest.json
music_assistant/server/providers/sonos_s1/__init__.py
requirements_all.txt

index 720e7104d68f6a2113562661731b1b926b17d1ed..9c109a8a70cba9933e8448d2e00a1aef20168497 100644 (file)
@@ -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),
         )
index d952d53e3c264d83d02eb672dce814f1eeb7009c..29ad3c531142c229108c6900b665e381d2bafc5f 100644 (file)
@@ -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,
index 3f89ebd48daa1aeb025b14af7feca7207262107a..7b4b8e1410c2d588f20619b7bab2219fe234f754 100644 (file)
@@ -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."""
index 190356eb94a2ce61560339d10a397f8fa2ce8c32..a6378ee38a84f6b789029906aa67499b2f5320c3 100644 (file)
@@ -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